Học bằng cách đọc theo thứ tự

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 mapfilter, 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 được thay mới sau mỗi vòng
Vòng 1:total là 0Trả về0 + 3200Vòng 2:total là 3200Trả về3200 + 2480Vòng 3:total là 5680Trả về5680 + 1180totalPricelà 6860Lưu nguyên giá trịtrả về cuối cùng
Giá trị 3200 trả về ở vòng 1 được truyền vào làm 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.

Từ danh sách đặt mua văn phòng phẩm của công ty, hãy tính tổng số lượng và tổng tiền của cả đơn. orderLines đã được khai báo sẵn, mỗi dòng là một mặt hàng.

① Hãy hiển thị tổng số lượng.

② Hãy hiển thị tổng tiền, với tiền của mỗi dòng là đơn giá × số lượng.

③ Hãy hiển thị số tiền trung bình cho mỗi mặt hàng, tức tổng ở ② chia cho số mặt hàng.

(Nếu chạy đúng, phần giải thích sẽ xuất hiện.)

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

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
Bắt đầu bằng gì thì mang theo thứ đó đến cuối
Giá trị khởi tạo 0(là số)So sánh maxvới video.viewsTrả vềsố lớn hơnmaxViews là15400Giá trị khởi tạovideos[0]So sánh top.viewsvới video.viewsTrả vềphần tử lớn hơnĐọc đượctopVideo.title
Hàng trên chỉ mang theo một số, còn hàng dưới mang theo nguyên phần tử. Khi cần biết cả tiêu đề, hãy dùng phần tử làm giá trị khởi tạo.

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.viewsundefined, nên phép so sánh lần nào cũng là false0 đượ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.

Từ doanh thu và chi phí tháng này của từng cửa hàng, hãy tìm cửa hàng có lợi nhuận cao nhất và thấp nhất. stores đã được khai báo sẵn, đơn vị là 10.000 yên.

① Hãy hiển thị tên cửa hàng có lợi nhuận (doanh thu − chi phí) cao nhất.

② Hãy hiển thị tên cửa hàng có lợi nhuận thấp nhất.

③ Hãy hiển thị lợi nhuận của cửa hàng ở ②.

④ Hãy hiển thị chênh lệch lợi nhuận giữa cửa hàng ở ① và cửa hàng ở ②.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Đế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
Cả 3 vòng đều cập nhật key trên cùng một counts
Vòng 1:counts là {}Gán Đổi trả là 1rồi trả vềVòng 2:Đổi trả là 1Gán Giao hàng là 1rồi trả vềVòng 3: Đổi trả 1và Giao hàng 1Gán Giao hàng là 2rồi trả về
Vòng nào cũng tăng một key trên 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ànhGiá trị khởi tạoCallback trả về
Tổng0Tổng của vòng trước cộng thêm giá trị
Phần tử lớn nhấtPhần tử đầu tiên của mảngPhần tử lớn hơn sau khi so sánh
Số lượng theo danh mụcMột object rỗngObject đã cập nhật key

Từ danh sách đơn hàng của một shop online, hãy tính doanh thu theo từng phương thức thanh toán. orders đã được khai báo sẵn, amount là số tiền của một đơn.

① Hãy tạo object salesByMethod với key là phương thức thanh toán và value là tổng số tiền.

② Hãy chuyển salesByMethod thành chuỗi JSON và hiển thị.

③ Hãy hiển thị tỷ lệ của thẻ tín dụng trong tổng doanh thu, dưới dạng số phần trăm.

④ Hãy hiển thị có bao nhiêu phương thức thanh toán khác nhau.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

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
Mảng rỗng khi có và không có giá trị khởi tạo
Mảng rỗng,khởi tạo là 0Không có phần tửnào để truyềnCallbackkhông được gọiTrả về nguyêngiá trị 0Mảng rỗng,không khởi tạoKhông có phần tửđầu tiên để lấyKhông cótotal ban đầuDừng vớiTypeError
Cùng là mảng rỗng, nhưng kết quả khác nhau tùy bạn có viết giá trị khởi tạo hay không. Chỉ khi không có giá trị khởi tạo mới cần đến phần tử đầu tiên.

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.

Từ các khoản hoàn tiền tháng này, hãy tính tổng các khoản hoàn tiền lớn. refunds đã được khai báo sẵn, đơn vị là yên.

① Hãy hiển thị tổng tất cả các khoản hoàn tiền, theo cách viết bỏ giá trị khởi tạo.

② Hãy tạo mảng chỉ gồm các khoản hoàn tiền từ 5000 trở lên và hiển thị số phần tử.

③ Hãy hiển thị tổng của mảng ở ②, theo cách viết có truyền giá trị khởi tạo.

④ Hãy hiển thị tổng của mảng ở ②, theo cách viết bỏ giá trị khởi tạo.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra
QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

Câu 1Tham số thứ nhất của callback truyền vào reduce nhận giá trị gì?

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?