Câu 1Tham số thứ nhất của callback truyền vào reduce nhận giá trị gì?
reduce — gộp cả mảng thành một giá trị
Tìm hiểu reduce gộp mảng thành một giá trị: accumulator, giá trị khởi tạo, tổng, max, gom nhóm và TypeError với mảng rỗng.
Muốn tính tổng tiền từ các dòng của một đơn hàng thì sao? map giữ nguyên số phần tử còn filter chỉ bỏ bớt phần tử — cả hai đều trả về mảng. Còn việc gộp cả mảng thành một giá trị, như tính tổng hay tìm giá trị lớn nhất, thì cả hai đều không làm được.
Bài này nói về reduce, phương thức gộp một mảng thành một giá trị, cùng với giá trị khởi tạo — điểm xuất phát của phép gộp.
Gộp mảng thành một giá trị — reduce và giá trị khởi tạo
Giả sử bạn muốn hiển thị tổng tiền của giỏ hàng. Nếu viết bằng forEach, biến chứa tổng sẽ nằm bên ngoài vòng lặp, và bất kỳ dòng nào phía sau cũng có thể ghi đè lên nó. Vì giá trị thay đổi sau mỗi lần cộng, bạn còn phải khai báo biến đó bằng let thay vì const.
Phương thức mảng reduce (phương thức lần lượt đưa từng phần tử cho callback và chỉ trả về kết quả của lần gọi callback cuối cùng) nhận một callback. Khác với map và filter, tham số thứ nhất của callback này là accumulator (tham số chứa giá trị trả về của vòng trước), còn phần tử hiện tại là tham số thứ hai. Tham số thứ hai của bản thân reduce là giá trị khởi tạo (giá trị mà accumulator nhận ở vòng đầu tiên).
const cartItems = [
{ name: "Giá đỡ laptop", price: 3200 },
{ name: "Hub USB", price: 2480 },
{ name: "Cáp HDMI", price: 1180 },
];
// Tham số thứ nhất là giá trị trả về của vòng trước, tham số thứ hai là phần tử hiện tại
const addPrice = (total, item) => total + item.price;
// Tham số thứ hai, 0, trở thành total ở vòng đầu tiên
const totalPrice = cartItems.reduce(addPrice, 0);
console.log(totalPrice); // 6860
// Kết quả trả về là một giá trị duy nhất, không phải mảng
console.log(typeof totalPrice); // number
total ở vòng 2. 6860 do vòng cuối trả về chính là kết quả của reduce.Bạn không cần tự tạo biến lưu kết quả tạm, vì reduce đảm nhận việc chuyển giá trị từ vòng này sang vòng sau. Thứ được chuyển sang vòng sau không nhất thiết phải là số — nó cũng có thể là một phần tử hay một object.
Giữ lại giá trị lớn hơn — chuyển tiếp số hay phần tử
Giả sử bạn muốn biết video nào trong số các video đã đăng có nhiều lượt xem nhất. Khác với tính tổng, ở đây không cộng dồn mà so sánh rồi chỉ mang giá trị lớn hơn sang vòng sau. Điều bạn cần quyết định là accumulator ở vòng đầu tiên sẽ bắt đầu bằng gì.
Accumulator có thể chứa một số hoặc chính phần tử. Nếu tham số thứ hai là 0, bạn chuyển một số sang vòng sau; nếu là videos[0], bạn chuyển chính phần tử đó. Hãy chọn giá trị khởi tạo cùng loại với giá trị mà callback trả về.
const videos = [
{ title: "SQL cơ bản #1", views: 8200 },
{ title: "Linux cơ bản #3", views: 15400 },
{ title: "Git căn bản", views: 9700 },
];
// Giá trị khởi tạo là số, nên so sánh và trả về đều là số
const maxViews = videos.reduce((max, video) => Math.max(max, video.views), 0);
console.log(maxViews); // 15400
// Giá trị khởi tạo là phần tử, nên so sánh và trả về đều là phần tử
const topVideo = videos.reduce(
(top, video) => (video.views > top.views ? video : top),
videos[0],
);
console.log(topVideo.title); // Linux cơ bản #3
Cả hai dòng đều so sánh giá trị views; điểm khác duy nhất là thứ được chuyển sang vòng sau. Nếu đặt giá trị khởi tạo của dòng dưới là 0, thì ở vòng đầu top.views là undefined, nên phép so sánh lần nào cũng là false và 0 được trả về cho đến cuối.
Giá trị khởi tạo 0 cho kết quả sai khi có số âm
Với mảng có lẫn giá trị âm, như dữ liệu nhiệt độ, nếu bắt đầu từ 0 thì dù giá trị lớn nhất thực tế là -3, 0 vẫn lớn hơn và được giữ lại, nên kết quả là một giá trị không hề có trong mảng. Nếu so sánh các phần tử với nhau và dùng phần tử đầu tiên của mảng làm giá trị khởi tạo, thì dù giá trị trong mảng âm hay dương, kết quả vẫn đúng.
Đếm theo từng loại vào một object — gom nhóm
Giả sử bạn muốn biết mỗi danh mục có bao nhiêu yêu cầu hỗ trợ. Khác với tính tổng, kết quả không phải một con số duy nhất — bạn cần một giá trị cho mỗi danh mục. Bạn có thể đặt một object rỗng ở bên ngoài rồi cộng dồn bằng forEach, nhưng như vậy object chứa kết quả thống kê lại nằm bên ngoài phần xử lý.
Giá trị khởi tạo cũng có thể là {}. Khi đó accumulator là object đang được thống kê, và callback cập nhật một key rồi trả về chính object đó. Đọc một key chưa có sẽ nhận về undefined, nên hãy chèn ?? 0 để bắt đầu đếm từ 0.
const tickets = [
{ subject: "Tôi muốn trả lại hàng", category: "Đổi trả" },
{ subject: "Chưa nhận được hàng", category: "Giao hàng" },
{ subject: "Nhờ in lại phiếu giao hàng", category: "Giao hàng" },
];
// Đếm lần lượt từng yêu cầu theo danh mục
const countByCategory = tickets.reduce((counts, ticket) => {
counts[ticket.category] = (counts[ticket.category] ?? 0) + 1;
return counts; // Chuyển counts đã cập nhật sang vòng sau
}, {}); // {} này trở thành counts ở vòng đầu tiên
console.log(JSON.stringify(countByCategory)); // {"Đổi trả":1,"Giao hàng":2}
console.log(countByCategory["Giao hàng"]); // 2
counts nhận được thêm 1 rồi trả về nguyên object đó. Nơi duy nhất được ghi vào là object truyền vào làm giá trị khởi tạo.{} dùng làm giá trị khởi tạo là một object mới chỉ để chứa kết quả thống kê, và không có gì được ghi vào các phần tử của tickets. Bảng dưới đây tóm tắt cặp giá trị khởi tạo và giá trị callback trả về trong 3 cách dùng từ đầu bài đến giờ.
| Muốn gộp thành | Giá trị khởi tạo | Callback trả về |
|---|---|---|
| Tổng | 0 | Tổng của vòng trước cộng thêm giá trị |
| Phần tử lớn nhất | Phần tử đầu tiên của mảng | Phần tử lớn hơn sau khi so sánh |
| Số lượng theo danh mục | Một object rỗng | Object đã cập nhật key |
Khi không có phần tử nào — reduce không có giá trị khởi tạo
Kết quả sau khi lọc có thể không còn phần tử nào. Chẳng hạn khi tính tổng các đơn giao trong ngày vào một ngày không có đơn nào, tùy cách viết mà reduce sẽ trả về 0 hoặc dừng lại với lỗi.
Bạn có thể bỏ tham số thứ hai, tức giá trị khởi tạo. Khi đó phần tử đầu tiên của mảng trở thành accumulator ban đầu, và callback được gọi lần đầu với phần tử ở index 1. Mảng rỗng không có phần tử đầu tiên để lấy ra, nên một TypeError sẽ được ném ra.
const yesterdayAmounts = [3200, 2480, 1180];
const todayAmounts = [];
// Không có giá trị khởi tạo thì phần tử đầu tiên, 3200, trở thành total ban đầu
console.log(yesterdayAmounts.reduce((total, amount) => total + amount)); // 6860
// Dù mảng rỗng, nếu có giá trị khởi tạo thì giá trị đó được trả về nguyên vẹn
console.log(todayAmounts.reduce((total, amount) => total + amount, 0)); // 0
// Không có giá trị khởi tạo thì không xác định được total ban đầu
// todayAmounts.reduce((total, amount) => total + amount);
// TypeError: Reduce of empty array with no initial value
Lỗi này khó nhận ra lúc viết code: với dữ liệu đang có thì mọi thứ vẫn chạy, và chỉ đến ngày đầu tiên không có phần tử nào thỏa điều kiện thì nó mới dừng. Khi truyền kết quả của filter vào reduce, hãy truyền giá trị khởi tạo để phòng trường hợp mảng rỗng.
Bỏ {} thì phần tử đầu tiên bị ghi đè
Nếu bỏ {} ở cuối ví dụ countByCategory, chính object yêu cầu hỗ trợ tickets[0] sẽ trở thành counts ban đầu. Không có lỗi nào: key Giao hàng được thêm vào object đó, còn 1 yêu cầu Đổi trả thì không bao giờ được đếm. Kể cả khi mảng có phần tử, hãy truyền giá trị khởi tạo khi gộp vào object.
Kiểm tra kiến thức
Hãy trả lời từng câu hỏi một.
Câu 2Nếu truyền giá trị khởi tạo 0 cho một reduce so sánh views của các phần tử rồi trả về phần tử lớn hơn, kết quả trả về là gì?
Câu 3Chuyện gì xảy ra khi gọi reduce không có giá trị khởi tạo trên một mảng rỗng?