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

Higher-order function và callback — Truyền hàm và trả về hàm

Tìm hiểu higher-order function nhận hàm làm đối số và callback. Kèm forEach, tự viết filter và hàm trả về hàm.

Đôi khi bạn muốn làm những việc hơi khác nhau với cùng một danh sách: chỉ hiển thị từng phần tử, lọc theo điều kiện, hoặc cộng dồn số tiền. Điểm chung của chúng là khung vòng lặp; khác biệt duy nhất là cách xử lý từng phần tử.

Bài này nói về higher-order function — hàm nhận hàm khác làm đối số — và callback — hàm được truyền vào và được gọi.

Truyền phần xử lý làm đối số — Callback và forEach

Giả sử bạn muốn hiển thị lần lượt từng đơn hàng trong danh sách. Dùng for...of thì làm được, nhưng mỗi lần hiển thị một danh sách bạn lại phải viết cùng một vòng lặp, trong khi thứ duy nhất thay đổi là cách hiển thị từng phần tử.

Higher-order function (hàm nhận một hàm làm đối số, hoặc trả về một hàm) giúp bạn tách khung vòng lặp khỏi phần xử lý từng phần tử. Còn hàm được truyền vào làm đối số để hàm nhận nó gọi tới được gọi là callback. Phương thức forEach của mảng gọi callback bạn truyền vào một lần cho mỗi phần tử.

const orders = [
  { id: "A-1101", item: "Vở", amount: 480 },
  { id: "A-1102", item: "Bút bi", amount: 150 },
  { id: "A-1103", item: "Giấy note", amount: 320 },
];

// Hàm chỉ lo hiển thị một phần tử
const printOrder = (order) => {
  console.log(`${order.id} ${order.item} ${order.amount} yên`);
};

// Chỉ truyền tên hàm. Việc gọi hàm do forEach đảm nhận
orders.forEach(printOrder);
// A-1101 Vở 480 yên
// A-1102 Bút bi 150 yên
// A-1103 Giấy note 320 yên
Cùng một printOrder được gọi ba lần
Phần tử 1:A-1101order chứaA-1101Hiển thị A-1101Vở 480 yênPhần tử 2:A-1102order chứaA-1102Hiển thị A-1102Bút bi 150 yênPhần tử 3:A-1103order chứaA-1103Hiển thị A-1103Giấy note 320 yên
Bạn chỉ truyền một hàm là printOrder, và forEach gọi nó. Mỗi lần gọi, chỉ có phần tử nằm trong order là thay đổi.

Dòng orders.forEach(printOrder) không có điều kiện lặp, cũng không có index. Muốn đổi cách hiển thị, bạn chỉ cần sửa printOrder, và có thể truyền nguyên hàm đó cho các danh sách khác.

forEach không dừng giữa chừng được

Bạn không thể viết break bên trong forEach, còn return chỉ kết thúc callback của phần tử đó — các phần tử còn lại vẫn tiếp tục được xử lý. Với vòng lặp cần dừng ngay khi tìm thấy phần tử mình cần, hãy dùng for...of cùng break.

Hiển thị lần lượt từng yêu cầu hỗ trợ trong danh sách. tickets đã được khai báo sẵn.

① Viết hàm printTicket hiển thị một yêu cầu theo dạng "[T-01] Đơn hàng của tôi đang ở đâu? (đã xử lý)". Nếu chưa xử lý thì hiển thị (chưa xử lý).

② Truyền printTicket cho forEach để hiển thị toàn bộ yêu cầu.

③ Đếm số yêu cầu đã xử lý và hiển thị theo dạng "Đã xử lý: N".

(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

Nhận phép kiểm tra dưới dạng hàm — Tự viết filter và boolean

Giả sử bạn muốn lọc danh sách theo những điều kiện khác nhau: lúc thì chỉ sản phẩm còn hàng, lúc thì chỉ sản phẩm từ 3000 yên trở lên. Nếu viết riêng một vòng lặp cho mỗi điều kiện, bạn sẽ có những đoạn code gần như y hệt nhau, giống nhau đến cả mảng chứa kết quả lẫn dòng push.

Thứ duy nhất thay đổi là dòng điều kiện, nên hãy nhận điều kiện dưới dạng một hàm kiểm tra và chỉ gom những phần tử mà hàm đó trả về true. Truyền gì cho callback, và theo thứ tự nào, là do hàm nhận và gọi callback quyết định. filterItems dưới đây chỉ truyền một phần tử cho hàm kiểm tra.

const products = [
  { name: "Tấm lót bàn", stock: 4 },
  { name: "Kệ màn hình", stock: 0 },
  { name: "Hộp đựng dây cáp", stock: 9 },
];

// Nhận một hàm kiểm tra và chỉ gom những phần tử mà nó trả về true
function filterItems(items, judge) {
  const kept = [];
  items.forEach((item) => {
    if (judge(item)) {
      kept.push(item);
    }
  });
  return kept;
}

const inStock = filterItems(products, (product) => product.stock > 0);
console.log(inStock.length);          // 2
console.log(inStock[0].name);         // Tấm lót bàn
Giá trị trả về quyết định nơi mỗi phần tử đi tới
Tấm lót bàncòn 4 trong khoKiểm trastock > 04 > 0 làtrueĐược đưavào keptKệ màn hìnhcòn 0 trong khoKiểm trastock > 00 > 0 làfalseKhông đượcđưa vào kept
Sản phẩm còn 4 trong kho nhận true, sản phẩm còn 0 nhận false, và chỉ sản phẩm đầu được đưa vào kept. Phần tử có được giữ lại hay không phụ thuộc vào giá trị trả về của hàm bạn truyền vào.

Bên trong filterItems không hề nhắc tới tồn kho. Nếu sau này bạn chỉ muốn lấy sản phẩm còn nhiều hàng, chỉ cần đổi phép kiểm tra ở dòng gọi thành product.stock >= 5, hoàn toàn không phải động vào filterItems.

Đếm số chuyến hàng thỏa điều kiện trong lịch giao hàng. shipments đã được khai báo sẵn.

① Viết countIf, hàm nhận một hàm kiểm tra và trả về số phần tử thỏa điều kiện.

② Hiển thị số chuyến giao trong ngày.

③ Hiển thị số chuyến có phí từ 4000 yên trở lên.

④ Đưa hàm kiểm tra các chuyến không giao trong ngày vào biến isNextDay, rồi truyền nó cho countIf và hiển thị số lượng.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Truyền nguyên phép kiểm tra đã tạo — Higher-order function trả về hàm

Có lúc bạn phải truyền cho filterItems nhiều phép kiểm tra chỉ khác nhau ở danh mục đem so sánh: "chỉ văn phòng phẩm", "chỉ chén bát", v.v. Nếu lần gọi nào cũng viết lại biểu thức kiểm tra, thì khi cần đổi tên property đem so sánh, bạn phải sửa tất cả những lời gọi đó.

Dạng còn lại của higher-order function là hàm trả về hàm. Nó có cùng hình dạng với function factory ở bài trước: bạn tạo byCategory, hàm nhận một danh mục và trả về một hàm kiểm tra. Hàm kiểm tra được trả về ghi nhớ category dưới dạng closure, nên bạn có thể truyền thẳng nó cho filterItems.

const items = [
  { name: "Vở", category: "Văn phòng phẩm" },
  { name: "Cốc", category: "Chén bát" },
  { name: "Bút bi", category: "Văn phòng phẩm" },
];

// Nhận một danh mục và trả về hàm kiểm tra xem phần tử có thuộc danh mục đó không
function byCategory(category) {
  return (item) => item.category === category;
}

// Lúc này chưa so sánh gì cả; thứ được trả về là hàm kiểm tra
console.log(typeof byCategory("Văn phòng phẩm"));   // function

// Truyền thẳng hàm kiểm tra được trả về cho filterItems ở phần trước
console.log(filterItems(items, byCategory("Văn phòng phẩm")).length);   // 2
console.log(filterItems(items, byCategory("Chén bát")).length);         // 1
Giá trị trong phép kiểm tra đến từ đâu
Tầng ngoài cùng (không nằm trong ngoặc nhọn nào)
  • filterItems(items, byCategory("Văn phòng phẩm")) — tạo phép kiểm tra và truyền nó làm đối số thứ 2
Bên trong byCategory
  • category — nhận "Văn phòng phẩm" ở dòng gọi nó
  • return (item) => ... — trả về hàm kiểm tra mà chưa so sánh gì
Bên trong hàm kiểm tra được trả về
  • item — nhận một phần tử do filterItems truyền vào
  • item.category === category — so với category đã ghi nhớ
Bên trong filterItems
  • judge — chứa hàm kiểm tra do byCategory trả về
  • judge(item) — truyền lần lượt từng phần tử và gọi hàm kiểm tra
category được xác định ở dòng gọi byCategory, còn item được xác định bên trong filterItems. Nơi tạo ra phép kiểm tra và nơi gọi nó là hai chỗ khác nhau.

Dòng chạy byCategory("Văn phòng phẩm") chưa so sánh gì cả; phép so sánh chỉ diễn ra khi judge(item) được gọi bên trong filterItems. Với 3 sản phẩm, cùng một hàm kiểm tra được gọi 3 lần, category vẫn giữ nguyên là "Văn phòng phẩm" và chỉ có item thay đổi.

Thiếu cặp ngoặc thì mọi phần tử đều được giữ lại

Nếu bạn viết filterItems(items, byCategory), từng sản phẩm sẽ được truyền cho byCategory làm category, và thứ được trả về làm kết quả kiểm tra là một hàm. Hàm là giá trị truthy (bất kỳ giá trị nào không phải falsy), nên sản phẩm nào cũng được giữ lại. Thứ bạn cần truyền là hàm kiểm tra do byCategory("Văn phòng phẩm") trả về.

Lọc ra những đơn hàng được miễn phí vận chuyển dành riêng cho thành viên. ordersfilterItems đã được khai báo sẵn.

① Viết both, hàm nhận hai hàm kiểm tra và trả về một hàm kiểm tra chỉ trả về true khi cả hai đều thỏa.

② Lưu hai hàm kiểm tra vào biến: một cho đơn hàng từ 5000 yên trở lên, một cho thành viên.

③ Kết hợp hai hàm kiểm tra ở ② bằng both, truyền kết quả cho filterItems, và hiển thị mã của từng đơn hàng còn lại.

④ Dùng hàm kiểm tra ở ③ cho đơn hàng thứ hai và hiển thị kết quả.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Một khung cho mọi trường hợp — Hàm dùng chung với phần xử lý thay được

Giả sử bạn đã viết hai hàm tính tổng phí vận chuyển, một cho giao thường và một cho giao nhanh. Vòng lặp và biến chứa tổng đều giống nhau; khác biệt duy nhất là biểu thức tính phí cho từng đơn. Viết như vậy thì rất dễ sửa bên này mà quên sửa bên kia.

Hãy biến phần khác nhau thành một tham số kiểu hàm và chỉ giữ một bản của khung. sumShipping nhận mảng đơn hàng và một hàm tính phí, truyền lần lượt từng đơn cho hàm đó và cộng dồn các giá trị trả về. Bên gọi chỉ viết biểu thức tính phí, còn bên nhận chỉ lo vòng lặp và phép cộng tổng.

const shippingOrders = [{ id: "A-1101", total: 6200 }, { id: "A-1102", total: 3400 }];

// Chỉ một khung. Riêng cách tính phí được nhận dưới dạng hàm
function sumShipping(orders, calcFee) {
  let total = 0;
  for (const order of orders) {
    total = total + calcFee(order);
  }
  return total;
}

const normalFee = (order) => (order.total >= 5000 ? 0 : 500);
const expressFee = () => 800;   // Không dùng order nên có thể bỏ tham số
const flatFee = () => 300;

console.log(sumShipping(shippingOrders, normalFee));    // 500
console.log(sumShipping(shippingOrders, expressFee));   // 1600
console.log(sumShipping(shippingOrders, flatFee));      // 600
Ba hàm tính phí, một khung duy nhất
Chỉ một hàmsumShippingcalcFee nhậnnormalFeecalcFee nhậnexpressFeecalcFee nhậnflatFeeTừ 5000 yên: 0,dưới mức đó: 500800 vớimọi đơn hàng300 vớimọi đơn hàngTổng là 500Tổng là 1600Tổng là 600
Tùy hàm được truyền làm calcFee, tổng của cùng hai đơn hàng sẽ là 500, 1600 hoặc 600. Code bên trong sumShipping giống hệt nhau ở cả ba lần.

Ngay cả một thay đổi như đặt mức trần cho tổng cũng chỉ cần sửa một chỗ trong sumShipping, và nó áp dụng cho cả ba kiểu tính phí. Bảng dưới đây cho biết ba hàm nhận callback trong bài này gọi callback ra sao và dùng giá trị trả về thế nào.

Hàm nhận callbackCách gọi callbackCallback trả về
forEachTruyền lần lượt từng phần tửKhông dùng
filterItems tự viếtTruyền từng phần tử qua judge(item)Chỉ gom phần tử trả về true
sumShipping tự viếtGọi calcFee(order) với từng đơnCộng làm phí của đơn đó

Từ bảng chấm công của nhân viên bán thời gian, hiển thị tiền lương của từng ca và tính tổng. shifts đã được khai báo sẵn.

① Viết payReport, hàm nhận bảng chấm công và một hàm tính lương, hiển thị từng ca theo dạng "N giờ: N yên", rồi trả về tổng.

② Truyền phép tính với mức lương 1100 yên mỗi giờ, và hiển thị tổng được trả về theo dạng "Tổng cộng N yên".

③ Truyền phép tính trong đó chỉ ca đêm được trả gấp 1.25 lần, và hiển thị tổng theo cùng dạng.

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 1Khi bạn viết orders.forEach(printOrder), ai là bên gọi printOrder?

Câu 2Trong filterItems tự viết, điều gì xảy ra với một phần tử khi hàm kiểm tra bạn truyền vào trả về false?

Câu 3Nếu truyền byCategory mà không có cặp ngoặc, như filterItems(items, byCategory), mảng được trả về chứa gì?