Bí Mật Asynchronous Trong JavaScript: Khám Phá Micro Task Và Macro Task

Trong quá trình phát triển web, rất nhiều tác vụ cần được xử lý song song hoặc tách riêng để tránh “đóng băng” giao diện, ví dụ như gọi API, thao tác với cơ sở dữ liệu, hay chờ người dùng tương tác. JavaScript lại chỉ chạy trên một luồng (single-thread), nên cơ chế bất đồng bộ (asynchronous) trở thành chìa khóa giúp chúng ta xử lý tác vụ hiệu quả.

Dù vậy, nhiều người mới học dễ bị “lạc lối” khi gặp khái niệm micro task queuemacro task queue, hoặc bối rối vì sao setTimeout(callback, 0) lại không thực sự chạy ngay lập tức. Trong bài này, chúng ta sẽ cùng tìm hiểu chi tiết về các hàng đợi nhiệm vụ trong JavaScript (trên trình duyệt), qua đó biết cách tối ưu code và tránh những lỗi thường gặp.

#1. Asynchronous và vòng lặp sự kiện (Event Loop)

Trong môi trường trình duyệt (Browser), JavaScript có một cơ chế gọi là Event Loop (vòng lặp sự kiện) để quản lý việc thực thi các tác vụ. Khi code được thực thi, JavaScript sẽ đẩy các tác vụ bất đồng bộ vào một trong hai loại “hàng đợi”:

  1. Macro Task Queue (hay còn gọi là Task Queue)
  2. Micro Task Queue

#1.1. Macro Task Queue là gì?

  • Macro Task (thường gọi tắt là Task) là những tác vụ lớn, ví dụ như:

    • setTimeout
    • setInterval
    • Lắng nghe sự kiện DOM (onclick, addEventListener, …)
    • AJAX/XHR callback
    • Một số thao tác khác của trình duyệt…
  • Mỗi khi Event Loop kết thúc một vòng xử lý, nó sẽ kiểm tra Micro Task Queue trước. Nếu hàng đợi micro còn nhiệm vụ nào, nó sẽ được xử lý ngay lập tức. Sau khi xong micro, lúc đó JavaScript mới quay lại xử lý Macro Task Queue.

#1.2. Micro Task Queue là gì?

  • Micro Task là những tác vụ nhỏ, thường được ưu tiên xử lý ngay sau khi JavaScript hoàn thành xong đoạn code chính hiện tại.
  • Ví dụ:
    • Promise.resolve() hoặc promise.then(...)
    • Các callback trong MutationObserver
    • Một số cơ chế khác được xếp vào micro tasks…

Điều quan trọng cần nhớ: Micro tasks luôn được xử lý trước macro tasks, nếu cả hai cùng xuất hiện trong thời điểm gần nhau.

#2. Thứ tự ưu tiên: Promise callback vs setTimeout(0)

Khi chúng ta gọi:

1
2
3
4
5
6
7
setTimeout(() => {
console.log('setTimeout 0ms');
}, 0);

Promise.resolve().then(() => {
console.log('Promise resolved');
});

Kết quả in ra sẽ là:

1
2
Promise resolved
setTimeout 0ms

#2.1. Vì sao Promise.resolve() chạy trước setTimeout(0ms)?

  • Lý do: Callback của Promise.resolve() được xếp vào Micro Task Queue, vốn được ưu tiên xử lý trước khi JavaScript lấy nhiệm vụ tiếp theo từ Macro Task Queue (chính là setTimeout).

  • setTimeout(() => {}, 0) nằm trong Macro Task Queue, nên phải đợi đến vòng lặp sự kiện tiếp theo mới được xử lý.

Đây là nguồn gốc của nhiều hiểu lầm, bởi nhiều người cho rằng setTimeout(..., 0) sẽ chạy ngay tức thì. Thực tế, promise callback vẫn chạy sớm hơn.

#3. Ví dụ về Promise, axios, fetch

#3.1. Promise đơn giản

1
2
3
4
5
6
7
8
9
10
11
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Dữ liệu đã được tải');
}, 1000);
});
}

fetchData().then((data) => {
console.log(data);
});

Flow thực thi:

  1. Gọi fetchData() để tạo Promise mới:

    • Callback trong new Promise(...) chạy NGAY LẬP TỨC.
    • setTimeout(...) được đăng ký với timer 1000ms.
  2. Call stack trống, event loop tiếp tục vòng lặp đợi các task.

  3. Sau khi 1000ms trôi qua:

    • Callback () => { resolve('Dữ liệu đã được tải'); } được đưa vào Macro Task Queue.
    • Event loop kiểm tra thấy call stack trống thì sẽ đẩy callback này vào call stack và thực thi nó.
  4. Thực thi hàm resolve(...):

    • Callback (data) => { console.log(data); } được đưa vào Micro task Queue.
  5. Event loop xử lý Microtask Queue:

    • Event loop kiểm tra call stack, nếu call stack trống, callback trên được đẩy vào call stack.
    • Thực thi: console.log('Dữ liệu đã được tải').

#3.2. fetch

1
2
3
4
5
fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(response => response.json())
.then(json => {
console.log(json);
});
  • fetchWeb API. Lời hứa (promise) của nó cũng sẽ được đưa vào Micro Task Queue khi có kết quả.

  • Điều này tương tự với axios:

#3.3. axios

1
2
3
4
axios.get('https://jsonplaceholder.typicode.com/posts/1')
.then(response => {
console.log(response.data);
});
  • axios bên trong cũng sử dụng XHR hoặc fetch (tùy cấu hình). Kết quả trả về cuối cùng cũng được xử lý qua callback then(...) trong micro task queue.

#4. XHR callback không nằm trong micro task queue

Khi bạn dùng XHR (XMLHttpRequest):

1
2
3
4
5
6
7
8
9
10
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/posts/1');

xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log('XHR response:', xhr.responseText);
}
};

xhr.send();

Callback onreadystatechange được xử lý qua Macro Task Queue, không phải Micro Task. Đây là điểm dễ gây nhầm lẫn:

#4.1. Tại sao lại là macro task?

  • Bởi XHR callback được đưa vào vòng lặp sự kiện giống như các sự kiện DOM. Khi trình duyệt nhận được dữ liệu, nó sẽ “thông báo” và đợi Event Loop chuyển sang vòng lặp tiếp theo để gọi callback. Vì thế, callback XHR thuộc nhóm macro tasks.

#5. Bảng tổng hợp: API nào thuộc Macro Task hay Micro Task

Dưới đây là bảng phân loại một số API phổ biến trong trình duyệt:

API/CallbackThuộc Macro Task (Task)Thuộc Micro Task
setTimeout, setIntervalX
requestAnimationFrameX (trình duyệt kiểm soát riêng)
XHR/AJAX (onreadystatechange)X
DOM Events (click, keyup, …)X
Promise (.then, .catch, .finally)X
fetch (sau khi nhận response)X
MutationObserverX
  • requestAnimationFrame cũng được xem như một loại macro task đặc thù, do trình duyệt tối ưu cho việc vẽ và cập nhật giao diện.

#6. Các lỗi thường gặp và cách tối ưu

#6.1. Quên thứ tự thực thi giữa Promise và setTimeout

Nhiều người tin rằng setTimeout(callback, 0) sẽ chạy trước Promise. Tuy nhiên, như đã nói, Promise nằm trong micro tasks và được ưu tiên cao hơn. Điều này có thể gây nhầm lẫn khi ta mong chờ kết quả hiển thị sớm hơn.

#Cách khắc phục:

  • Hãy nhớ quy tắc: Micro tasks (Promise) xử lý trước Macro tasks (setTimeout).
  • Tận dụng điều này để sắp xếp code theo thứ tự mong muốn.

#6.2. Các vấn đề phạm vi biến trong Execution Context

Khi viết code bất đồng bộ, ta dễ quên phạm vi biến. Ví dụ:

1
2
3
4
5
6
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log('Index:', i);
}, 10);
}
// Kết quả: 3, 3, 3

Sử dụng var khiến i không bị “giữ” giá trị tại thời điểm tạo hàm. Lúc setTimeout chạy, i đã thành 3.

#Cách khắc phục:

  • Dùng let thay cho var để tránh hiện tượng “rò rỉ” biến. Hoặc đóng gói i trong một hàm mới:
1
2
3
4
5
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log('Index:', i);
}, 10);
}

Kết quả lúc này: 0, 1, 2.

#7. Bài tập và câu hỏi ôn luyện

  1. Giải thích vì sao Promise.resolve() luôn chạy callback trước setTimeout(…, 0)?
  2. Kết quả bên dưới sẽ được in ra như thế nào? Tại sao?
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    console.log('1. Start');

    Promise.resolve()
    .then(() => {
    console.log('2. Promise callback');
    });

    setTimeout(() => {
    console.log('3. setTimeout 0ms callback');
    }, 0);

    console.log('4. End');
  3. Khi nào bạn nên sử dụng setInterval và khi nào nên sử dụng requestAnimationFrame?

#8. Kết luận

Hiểu rõ asynchronous, micro task queuemacro task queue trong JavaScript (đặc biệt trong môi trường trình duyệt) sẽ giúp bạn tránh được nhiều lỗi “đau đầu” trong các dự án thực tế. Nắm rõ cơ chế này còn giúp bạn tối ưu hóa mã, viết code gọn gàng và dễ bảo trì hơn.

Về lâu dài, hiểu cách hoạt động của Execution ContextCall Stack cũng như cách Event Loop xử lý các hàng đợi sẽ giảm thiểu lỗi trong các dự án lớn. Bạn sẽ tự tin hơn khi xử lý các tác vụ phức tạp, đồng thời dễ dàng “debug” những vấn đề bất ngờ liên quan đến thứ tự thực thi.