Menu

Callback trong JavaScript: hàm làm đối số và callback hell

Cách callback hoạt động trong JavaScript: truyền hàm làm đối số, khuôn mẫu error-first, và vì sao callback lồng nhau đã đẩy cộng đồng sang promise.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Callback là hàm bạn trao cho một hàm khác

Hàm trong JavaScript là giá trị. Bạn có thể lưu chúng vào biến, đặt chúng vào mảng, và quan trọng nhất ở đây là truyền chúng làm đối số. Khi bạn truyền một hàm cho một hàm khác để hàm kia gọi nó sau, hàm bạn truyền vào chính là một callback.

greet không biết và cũng không quan tâm formatter làm gì. Nó chỉ gọi formatter với một cái tên rồi dùng kết quả. Bạn quyết định cách xử lý bằng cách truyền các callback khác nhau. Sự linh hoạt đó là toàn bộ lý do callback tồn tại.

Callback đồng bộ chạy ngay lập tức

Không phải callback nào cũng bất đồng bộ. Nhiều phương thức mảng bạn đang dùng hoạt động dựa trên callback, và chúng chạy callback một cách đồng bộ, trước khi lời gọi bên ngoài trả về:

map, filter và reduce đều nhận một callback và gọi nó một lần cho mỗi phần tử, ngay lúc đó. Khi map trả về, mọi lời gọi tới callback đã xảy ra xong. Không có gì được xếp hàng chờ để chạy sau.

Đây là khuôn mẫu hàm bậc cao (higher-order function) đơn thuần: "đây là việc cần làm, đây là cách làm, trả kết quả cho tôi". Không dính dáng gì đến event loop.

Callback bất đồng bộ chạy sau

Khi người ta nói "callback", họ thường muốn nói đến loại bất đồng bộ. Bạn trao một hàm cho một API cần thời gian, như bộ hẹn giờ, request mạng hay đọc tệp, và API đó gọi lại hàm của bạn khi công việc xong.

Thứ tự đầu ra: before, after, rồi một giây sau là timer fired. setTimeout không tạm dừng chương trình. Nó trao callback cho môi trường chạy, trả về ngay lập tức, và phần còn lại của script tiếp tục chạy. Một giây sau, event loop nhặt callback lên và chạy nó.

Hình dạng "trả về ngay, gọi lại sau" đó là mô hình tư duy cho mọi API callback bất đồng bộ trong JavaScript, từ addEventListener đến các API tệp cũ của Node.js.

Quy ước error-first (Node.js)

Trước khi có promise, Node.js chuẩn hóa một hình dạng callback cụ thể: đối số đầu tiên là lỗi (hoặc null), các đối số còn lại là kết quả thật. Bạn vẫn sẽ gặp nó trong code cũ và một số thư viện.

Nơi gọi kiểm tra err trước và thoát sớm nếu nó truthy. Chỉ khi đó mới tin vào kết quả. Đây là một quy ước, ngôn ngữ không bắt buộc, nhưng một khi đã thấy chữ ký (err, result) => ... thì bạn sẽ nhận ra nó ở khắp nơi.

Callback hell

Rắc rối bắt đầu khi một bước bất đồng bộ phụ thuộc vào kết quả của bước khác. Mỗi callback phải lồng bên trong callback trước đó, và code của bạn cứ thế thụt dần sang phải như bậc thang:

Đây là "kim tự tháp diệt vong" (pyramid of doom) nổi tiếng, hay còn gọi là callback hell. Vài điều khiến nó khổ sở:

  • Luồng điều khiển chạy zíc zắc thay vì đọc từ trên xuống dưới.
  • Mỗi cấp lặp lại cùng một đoạn if (err) return ... rập khuôn.
  • Một callback ném ngoại lệ không lan ra các callback bên ngoài: bạn phải xử lý lỗi ở từng tầng.
  • Tái cấu trúc đồng nghĩa với việc thụt lề lại cả khối code.

Bạn có thể làm phẳng phần nào bằng cách tách ra các hàm có tên, nhưng vấn đề cốt lõi, kết hợp các bước bất đồng bộ bằng callback thuần rất vụng về, vẫn không biến mất. Đó chính là vấn đề mà promise được thiết kế để giải quyết.

Hai cái bẫy đáng biết

Đừng vô tình gọi callback. Khi truyền callback, bạn truyền chính hàm đó, không phải kết quả của việc gọi nó.

Coi chừng this. Nếu callback là một hàm thường có dùng this, giá trị của this phụ thuộc vào cách callback được gọi, không phải nơi nó được định nghĩa. Arrow function né được vấn đề này bằng cách kế thừa this từ phạm vi bao quanh:

Arrow function là lựa chọn mặc định cho callback viết tại chỗ chính vì lý do này.

Callback và promise

Callback vẫn xuất hiện trong các API đồng bộ (map, forEach, sort), event listener (element.addEventListener("click", ...)) và các hook cấp thấp của môi trường chạy. Với công việc bất đồng bộ cho ra một kết quả duy nhất, hệ sinh thái gần như đã chuyển hẳn sang promise.

So sánh nhanh:

  • Callback: trực tiếp, tối giản, nhưng kết hợp kém. Xử lý lỗi thủ công ở từng bước.
  • Promise: một giá trị đại diện cho kết quả trong tương lai. Nối chúng bằng .then(), xử lý lỗi một lần bằng .catch(), và kim tự tháp được làm phẳng.

Bạn vẫn cần hiểu callback: promise được xây trên nền callback, và callback có mặt khắp nơi trong code hướng sự kiện. Nhưng ngày nay bạn hiếm khi viết API bất đồng bộ mới bằng callback thuần.

Tiếp theo: promise

Promise lấy ý tưởng "làm việc này khi việc kia xong" và gói nó vào một đối tượng mà bạn có thể truyền đi, nối chuỗi và kết hợp. Đó là nội dung của trang tiếp theo, và cũng là cầu nối tới async/await, cách mà phần lớn JavaScript hiện đại xử lý công việc bất đồng bộ.

Câu hỏi thường gặp

Hàm callback trong JavaScript là gì?

Callback là một hàm bạn truyền làm đối số cho một hàm khác, để hàm đó có thể gọi nó sau. setTimeout(() => console.log('hi'), 1000) truyền một arrow function làm callback: setTimeout giữ nó lại và gọi nó khi bộ hẹn giờ kích hoạt. Callback là cách đầu tiên JavaScript xử lý chuyện 'làm việc này khi việc kia xong'.

Callback đồng bộ và callback bất đồng bộ khác nhau thế nào?

Callback đồng bộ chạy ngay lập tức, trong chính lời gọi nhận nó: [1, 2, 3].map(x => x * 2) gọi callback ba lần trước khi map trả về. Callback bất đồng bộ được giữ lại và gọi sau, khi một sự kiện nào đó xảy ra: setTimeout, fs.readFile và các event listener của DOM đều hoạt động theo cách này. Callback bất đồng bộ không chặn phần còn lại của code.

Callback hell là gì và làm sao để tránh?

Callback hell là hình kim tự tháp xuất hiện khi các callback bất đồng bộ phụ thuộc vào nhau và bị lồng sâu nhiều cấp. Nó khiến luồng điều khiển và việc xử lý lỗi khó theo dõi. Cách khắc phục là dùng promise với chuỗi .then(), hoặc tốt hơn là async/await: cả hai đều làm phẳng kim tự tháp thành thứ dễ đọc trở lại.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu