Bí Mật Asynchronous Trong JavaScript: Khám Phá Micro Task Và Macro Task
- 1. Asynchronous và vòng lặp sự kiện (Event Loop)
- 2. Thứ tự ưu tiên: Promise callback vs setTimeout(0)
- 3. Ví dụ về Promise, axios, fetch
- 4. XHR callback không nằm trong micro task queue
- 5. Bảng tổng hợp: API nào thuộc Macro Task hay Micro Task
- 6. Các lỗi thường gặp và cách tối ưu
- 7. Bài tập và câu hỏi ôn luyện
- 8. Kết luận
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 queue và macro 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”:
- Macro Task Queue (hay còn gọi là Task Queue)
- 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ặcpromise.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 | setTimeout(() => { |
Kết quả in ra sẽ là:
1 | Promise resolved |
#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 | function fetchData() { |
Flow thực thi:
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.
- Callback trong
Call stack trống, event loop tiếp tục vòng lặp đợi các task.
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ó.
- Callback
Thực thi hàm
resolve(...)
:- Callback
(data) => { console.log(data); }
được đưa vào Micro task Queue.
- Callback
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 | fetch('https://jsonplaceholder.typicode.com/posts/1') |
fetch
là Web 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 | axios.get('https://jsonplaceholder.typicode.com/posts/1') |
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 callbackthen(...)
trong micro task queue.
#4. XHR callback không nằm trong micro task queue
Khi bạn dùng XHR (XMLHttpRequest):
1 | const xhr = new XMLHttpRequest(); |
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/Callback | Thuộc Macro Task (Task) | Thuộc Micro Task |
---|---|---|
setTimeout , setInterval | X | |
requestAnimationFrame | X (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 | |
MutationObserver | X |
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 | for (var i = 0; i < 3; i++) { |
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 chovar
để tránh hiện tượng “rò rỉ” biến. Hoặc đóng góii
trong một hàm mới:
1 | for (let i = 0; i < 3; i++) { |
Kết quả lúc này: 0, 1, 2
.
#7. Bài tập và câu hỏi ôn luyện
- Giải thích vì sao Promise.resolve() luôn chạy callback trước setTimeout(…, 0)?
- 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
12console.log('1. Start');
Promise.resolve()
.then(() => {
console.log('2. Promise callback');
});
setTimeout(() => {
console.log('3. setTimeout 0ms callback');
}, 0);
console.log('4. End'); - Khi nào bạn nên sử dụng
setInterval
và khi nào nên sử dụngrequestAnimationFrame
?
#8. Kết luận
Hiểu rõ asynchronous, micro task queue và macro 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 Context và Call 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.