Làm việc với mảng trong JavaScript giờ đây hiếm khi đòi hỏi bạn phải viết vòng lặp for nữa. Ngôn ngữ này hiện cung cấp cho bạn các công cụ diễn đạt mạnh mẽ, giúp biến đổi các dữ liệu lồng nhau, rắc rối thành các phép biến đổi sạch sẽ và dễ đọc. Hướng dẫn này sẽ tiếp nối từ những kiến thức cơ bản—tập trung vào việc làm phẳng (flattening), thu gọn (reducing), truy vấn (querying) và các mẫu bất biến (immutable patterns) mới hơn giúp giữ nguyên dữ liệu gốc của bạn.
Làm phẳng các cấu trúc lồng nhau với flat() và flatMap()
Dữ liệu trong thực tế thường có cấu trúc lồng nhau. Khi lấy dữ liệu từ một API, phân tích một tệp CSV hoặc đọc một tệp cấu hình, bạn sẽ thường xuyên gặp phải tình trạng mảng nằm trong mảng. flat() tồn tại để thu gọn các lớp đó thành một danh sách tuyến tính duy nhất.
Gọi flat() trên một mảng lồng nhau và mặc định nó sẽ làm phẳng một cấp:
const nested = [[1, 2], [3, 4]];
const flat = nested.flat();
// [1, 2, 3, 4]
Mảng nested ban đầu vẫn giữ nguyên như cũ. Nếu cấu trúc lồng nhau của bạn sâu hơn, hãy truyền vào đối số độ sâu như flat(2) hoặc thậm chí là flat(Infinity) khi bạn không biết có bao nhiêu lớp tồn tại. Điều này tốt hơn nhiều so với việc phải tự viết các hàm hỗ trợ đệ quy bằng tay.
Khi bạn cần vừa biến đổi các phần tử vừa làm phẳng kết quả, flatMap() sẽ thực hiện cả hai trong một lần duyệt duy nhất. Nó chạy một hàm ánh xạ (mapping function) qua từng phần tử, sau đó làm phẳng các mảng được trả về xuống một cấp. Việc thực hiện điều này bằng các lời gọi .map().flat() riêng biệt vẫn hoạt động, nhưng flatMap() sẽ bỏ qua mảng trung gian, giúp tiết kiệm bộ nhớ và tránh được lần lặp thứ hai. Một trường hợp sử dụng điển hình là chia một mảng các câu thành các từ riêng lẻ:
const lines = ['hello world', 'foo bar'];
const words = lines.flatMap(line => line.split(' '));
// ['hello', 'world', 'foo', 'bar']
Lọc và Ánh xạ: Hiểu rõ sự khác biệt
filter() tạo ra một mảng mới chỉ chứa các mục vượt qua bài kiểm tra của bạn. Hàm callback nên trả về một giá trị truthy để giữ phần tử đó, hoặc một giá trị falsy để loại bỏ nó. Nó không bao giờ làm thay đổi (mutate) mảng nguồn.
const users = [
{ name: 'Ajay', active: true },
{ name: 'Bina', active: false }
];
const activeUsers = users.filter(u => u.active);
Rất dễ nhầm lẫn filter() với map() vì cả hai đều trả về mảng mới và chấp nhận callback. Sự khác biệt nằm ở "hợp đồng" của chúng. map() trả về một mảng mới có cùng độ dài. Khi biến đổi mọi phần tử và chạy một kiểm tra điều kiện, bạn sẽ thực sự thấy true hoặc false cho mọi vị trí, bởi vì map() không bao giờ loại bỏ các mục—nó chỉ thay đổi chúng. Ngược lại, filter() trả về một mảng có thể ngắn hơn, chỉ chứa duy nhất các phần tử vượt qua bài kiểm tra của bạn. Nếu bạn muốn loại bỏ dữ liệu, hãy dùng filter(). Nếu bạn muốn biến đổi mọi phần tử dữ liệu trong khi vẫn giữ nguyên số lượng, hãy dùng map().
Thu gọn mảng với reduce()
Trong khi map() và filter() trả về mảng, reduce() cô đọng toàn bộ danh sách thành một giá trị duy nhất. Nó duyệt từ trái sang phải, mang theo một biến tích lũy (accumulator) qua từng bước.
const totals = [10, 20, 30];
const sum = totals.reduce((acc, current) => acc + current, 0);
// 60
Đối số thứ hai—0 trong ví dụ trên—là giá trị tích lũy ban đầu. Nếu bỏ qua nó, JavaScript sẽ sử dụng phần tử đầu tiên làm điểm bắt đầu và bắt đầu lặp từ chỉ số (index) 1. Điều này thường khiến mọi người mắc lỗi khi mảng trống, vì gọi reduce() mà không có giá trị ban đầu trên một mảng trống sẽ ném ra lỗi TypeError.
reduce() không chỉ dành cho các phép toán số học. Bạn có thể sử dụng nó để xây dựng bảng tần suất, hợp nhất các đối tượng, tìm giá trị lớn nhất hoặc truyền dữ liệu qua nhiều bước biến đổi. Vì nó rất linh hoạt, tốt nhất là bạn nên giữ cho hàm callback nhỏ gọn và đặt tên biến rõ ràng. Một khối reduce() quá dày đặc sẽ nhanh chóng trở nên khó đọc hơn cả vòng lặp mà nó thay thế.
Truy vấn Boolean: every() và some()
Đôi khi bạn không cần một mảng mới; bạn cần một câu trả lời "có" hoặc "không" về tập hợp đó. every() chỉ trả về true nếu tất cả các phần tử đều thỏa mãn điều kiện của bạn. Nếu ngay cả phần tử đầu tiên không thỏa mãn, nó sẽ dừng lại ngay lập tức và trả về false.
const scores = [85, 92, 78];
const allPassed = scores.every(s => s >= 50);
some() là hình ảnh phản chiếu của nó. Nó trả về true nếu có ít nhất một phần tử vượt qua bài kiểm tra. Nếu tìm thấy một kết quả khớp, nó sẽ ngắt (short-circuit) ngay lập tức.
const hasPerfectScore = scores.some(s => s === 100);
Các phương thức này thay thế các vòng lặp thủ công để kiểm tra tính hợp lệ. Cần xác nhận mọi trường trong biểu mẫu đều đã được điền? Hãy dùng every(). Cần biết liệu có bất kỳ người dùng nào có quyền admin không? Hãy dùng some().
Kiểm tra và Cập nhật mà không làm thay đổi dữ liệu gốc
JavaScript hiện đại hướng mạnh mẽ vào tính bất biến (immutability). Các phương thức này giúp bạn kiểm tra hoặc cập nhật mảng mà không chạm vào mảng gốc.
keys() trả về một đối tượng iterator chứa các chỉ số của mảng. entries() trả về một iterator của các cặp [index, value]. Chúng phát huy tác dụng tối đa trong các vòng lặp for...of khi bạn muốn thực hiện destructuring một cách sạch sẽ:
const fruits = ['apple', 'banana'];
for (const [index, fruit] of fruits.entries()) {
console.log(index, fruit);
}
with() là một bổ sung mới hơn cho ngôn ngữ, giúp thay thế một phần tử tại một chỉ số cụ thể và trả về một mảng hoàn toàn mới. Nó giải quyết cùng một nhu cầu như phép gán bằng ngoặc vuông—arr[index] = value—ngoại trừ việc nó giữ nguyên mảng nguồn. Trong các framework như React, nơi mà việc thay đổi trực tiếp (mutation) làm hỏng cơ chế phát hiện thay đổi (change detection), with() là một lựa chọn an toàn hơn:
const original = [10, 20, 30];
const updated = original.with(1, 99);
// original: [10, 20, 30]
// updated: [10, 99, 30]
Spread và Rest: Hai hướng của ...
Cú pháp spread và tham số rest dùng chung ba dấu chấm, nhưng chúng di chuyển dữ liệu theo các hướng ngược nhau.
Sử dụng toán tử spread để trải rộng một mảng thành các phần tử riêng lẻ. Đây là cách sạch sẽ nhất để hợp nhất các mảng hoặc sao chép chúng:
const front = [1, 2];
const back = [3, 4];
const merged = [...front, ...back];
const copy = [...merged];
Bạn cũng có thể dễ dàng thêm vào các mục bổ sung: [0, ...front, 99, ...back].
Tham số rest gom nhiều phần tử vào một mảng duy nhất. Bên trong chữ ký hàm (function signature), nó thu thập bất kỳ đối số nào còn lại:
function logFirst(first, ...rest) {
console.log('First:', first);
console.log('Rest:', rest);
}
logFirst('a', 'b', 'c');
// First: a
// Rest: ['b', 'c']
Cùng với nhau, chúng xử lý tác vụ phổ biến là chấp nhận đầu vào biến đổi (variadic input), xử lý nó như một mảng, và sau đó trải nó ra lại vào các lời gọi hàm hoặc các cấu trúc dữ liệu mới.
Bài học thực sự rút ra
Mục tiêu của các phương thức này không phải là để gây ấn tượng với đồng nghiệp bằng những dòng code viết gọn trên một dòng. Mục tiêu là để làm cho ý định của bạn trở nên rõ ràng. Khi một lập trình viên khác thấy filter(), họ biết bạn đang loại bỏ các mục. Khi họ thấy reduce(), họ biết bạn đang thu gọn dữ liệu. Khi họ thấy with(), họ biết mảng ban đầu vẫn còn nguyên vẹn ở đâu đó. Làm chủ được các công cụ này có nghĩa là viết ra những đoạn mã có khả năng tự giải thích, và điều đó tiết kiệm thời gian hơn nhiều so với bất kỳ vòng lặp thông minh nào.
