Xử lý bất đồng bộ (asynchronous) là một trong những đặc trưng quan trọng nhất của JavaScript, vì ngôn ngữ này vốn là single-threaded (chỉ có một luồng thực thi) nhưng vẫn cần xử lý những tác vụ tốn thời gian như gọi API, đọc file, hay đợi người dùng thao tác mà không làm "đứng hình" toàn bộ trang web. Qua nhiều năm, JavaScript đã phát triển ba cách tiếp cận: Callback, Promise, và async/await. Bài viết này sẽ đi qua từng giai đoạn để bạn hiểu vì sao chúng ra đời và cách dùng của từng cái.
1. Vấn đề cốt lõi: Bất đồng bộ là gì?
Hãy tưởng tượng bạn gọi API để lấy dữ liệu người dùng - việc này không xảy ra tức thì, mà cần thời gian để server phản hồi. Nếu JavaScript "đứng đợi" (blocking) trong lúc chờ, toàn bộ trang web sẽ bị đơ. Vì vậy, JavaScript xử lý các tác vụ này theo cách bất đồng bộ: giao việc đó cho trình duyệt (hoặc Node.js) xử lý ngầm, còn luồng chính vẫn tiếp tục chạy code khác, và khi nào có kết quả thì mới quay lại xử lý tiếp.
2. Callback - Cách tiếp cận đầu tiên
Callback đơn giản là một hàm được truyền vào một hàm khác, để gọi lại (call back) khi tác vụ bất đồng bộ hoàn tất.
function getUser(userId, callback) {
setTimeout(() => {
const user = { id: userId, name: "Nguyen" };
callback(user);
}, 1000);
}
getUser(1, (user) => {
console.log("Lấy được user:", user.name);
});Nhìn qua thì khá đơn giản, nhưng vấn đề xuất hiện khi bạn cần thực hiện nhiều tác vụ bất đồng bộ nối tiếp nhau - ví dụ: lấy user, sau đó lấy danh sách bài viết của user đó, sau đó lấy bình luận của bài viết đầu tiên:
getUser(1, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
console.log(comments);
// Càng lồng càng sâu, càng khó đọc, khó bảo trì...
});
});
});
Đây chính là hiện tượng nổi tiếng gọi là "Callback Hell" (hay "Pyramid of Doom") - code bị lồng nhau quá nhiều tầng, khó đọc, khó xử lý lỗi, và khó bảo trì.
3. Promise - Giải pháp cho Callback Hell
Promise (ra mắt trong ES6) là một object đại diện cho kết quả trong tương lai của một tác vụ bất đồng bộ. Một Promise luôn ở một trong ba trạng thái:
- Pending: đang chờ xử lý.
- Fulfilled: hoàn thành thành công.
- Rejected: thất bại.
Tạo và sử dụng Promise
function getUser(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId) {
resolve({ id: userId, name: "Nguyen" });
} else {
reject(new Error("Không tìm thấy userId"));
}
}, 1000);
});
}
getUser(1)
.then((user) => console.log("User:", user.name))
.catch((error) => console.error("Lỗi:", error.message));
Giải quyết Callback Hell bằng chuỗi .then()
Nhờ Promise, các tác vụ nối tiếp nhau có thể viết theo dạng chuỗi phẳng thay vì lồng nhau:
getUser(1)
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error("Có lỗi xảy ra:", error.message));
Chuỗi .then() giúp code dễ đọc hơn nhiều so với callback lồng nhau, và đặc biệt là chỉ cần một .catch() duy nhất để bắt lỗi cho toàn bộ chuỗi, thay vì phải xử lý lỗi ở từng tầng callback.
Promise.all, Promise.race - xử lý nhiều Promise song song
// Chạy song song, đợi tất cả hoàn thành
Promise.all([getUser(1), getUser(2), getUser(3)])
.then((users) => console.log("Tất cả users:", users))
.catch((error) => console.error(error));
// Chỉ lấy kết quả của Promise nào hoàn thành trước nhất
Promise.race([getUser(1), getUser(2)])
.then((firstUser) => console.log("User đầu tiên:", firstUser));
4. async/await - Cú pháp giúp code bất đồng bộ trông như đồng bộ
Dù Promise đã cải thiện rất nhiều so với callback, chuỗi .then() dài vẫn có thể khó đọc trong các trường hợp phức tạp (đặc biệt khi cần rẽ nhánh điều kiện). ES2017 giới thiệu async/await - cú pháp đường (syntactic sugar) được xây dựng trên nền Promise, giúp code bất đồng bộ được viết và đọc giống hệt code đồng bộ thông thường.
async function fetchUserData() {
try {
const user = await getUser(1);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
console.log(comments);
} catch (error) {
console.error("Có lỗi xảy ra:", error.message);
}
}
fetchUserData();
So với chuỗi .then(), đoạn code trên đọc gần như tuyến tính, dễ hiểu như đang thao tác đồng bộ, trong khi vẫn giữ nguyên bản chất bất đồng bộ và không chặn luồng chính.
Những điều cần lưu ý với async/await
Từ khóa async đặt trước một hàm sẽ khiến hàm đó luôn trả về một Promise, dù bên trong bạn return giá trị gì:
async function double(x) {
return x * 2;
}
double(5).then((result) => console.log(result)); // 10
Từ khóa await chỉ có thể dùng bên trong hàm async, và nó sẽ "tạm dừng" việc thực thi hàm đó cho đến khi Promise được resolve/reject - nhưng không hề chặn (block) toàn bộ luồng chính của chương trình.
Xử lý lỗi dùng try...catch truyền thống thay vì .catch():
async function safeFetch() {
try {
const data = await getUser(1);
return data;
} catch (error) {
console.error("Lỗi khi lấy dữ liệu:", error.message);
return null;
}
}
Chạy song song với async/await bằng Promise.all
Một lỗi phổ biến là dùng await tuần tự cho những tác vụ độc lập không cần chờ nhau, khiến tổng thời gian chạy bị cộng dồn không cần thiết:
// Cách chậm: chạy tuần tự, tổng thời gian = 1s + 1s + 1s = 3s
const user1 = await getUser(1);
const user2 = await getUser(2);
const user3 = await getUser(3);
// Cách nhanh: chạy song song, tổng thời gian ≈ 1s
const [user1, user2, user3] = await Promise.all([
getUser(1),
getUser(2),
getUser(3)
]);
Nếu các tác vụ không phụ thuộc lẫn nhau, hãy dùng Promise.all để chạy song song thay vì await từng cái một cách tuần tự.
5. So sánh tổng quan
| Tiêu chí | Callback | Promise | async/await |
|---|---|---|---|
| Độ dễ đọc | Thấp (dễ lồng sâu) | Trung bình - Cao | Cao nhất |
| Xử lý lỗi | Phải tự xử lý ở từng tầng | .catch() tập trung |
try...catch quen thuộc |
| Chạy song song | Phức tạp, khó kiểm soát | Promise.all |
Promise.all + await |
| Bản chất | Nguyên thủy nhất | Object chuẩn hóa | Cú pháp bọc trên Promise |
| Độ phổ biến hiện nay | Ít dùng (trừ số API cũ) | Vẫn dùng nhiều, đặc biệt kết hợp với async/await | Phổ biến nhất trong code hiện đại |
6. Kết luận
Ba cách tiếp cận này không hoàn toàn thay thế nhau mà thể hiện quá trình tiến hóa của JavaScript trong việc giải quyết cùng một vấn đề: làm sao viết code bất đồng bộ dễ đọc, dễ bảo trì và dễ xử lý lỗi hơn. Callback là nền tảng đầu tiên nhưng dễ dẫn đến "callback hell". Promise chuẩn hóa việc xử lý bất đồng bộ bằng chuỗi .then()/.catch(). Và async/await, được xây trên nền Promise, mang lại cú pháp gần gũi nhất với tư duy lập trình đồng bộ thông thường. Trong code hiện đại, async/await thường là lựa chọn ưu tiên, nhưng hiểu rõ cả Callback lẫn Promise vẫn rất cần thiết - vì bản chất async/await chính là Promise được "khoác áo" cú pháp dễ đọc hơn mà thôi.







