JavaScript 事件循環:Call Stack、Task Queue 與 Microtask Queue
簡介
在 JavaScript 中,非同步 是日常開發不可或缺的概念。無論是瀏覽器的 UI 互動、網路請求,或是 Node.js 的檔案 I/O,都仰賴 事件循環(Event Loop) 來協調同步與非同步程式碼的執行順序。
如果不了解 Call Stack、Task Queue、Microtask Queue 之間的關係,常會遇到「為什麼 Promise 先於 setTimeout 執行?」或「程式碼執行順序與預期不符」的困惑。掌握這些基礎,才能寫出 可預測、效能佳 的非同步程式。
本篇文章將以 淺顯易懂 的方式說明三大核心結構,並透過實用範例展示它們在真實專案中的應用與常見陷阱。
核心概念
1. Call Stack(執行堆疊)
- 定義:一個 LIFO(後進先出)的資料結構,用來追蹤目前正在執行的函式。
- 運作:每當 JavaScript 執行一個函式(同步或非同步的 callback)時,會把它 推入 (push) 堆疊;函式執行完畢後,再 彈出 (pop)。
- 特性:同時只能執行 一個 任務,這也是 JavaScript 為何是單執行緒的根本原因。
function a() {
console.log('a start');
b(); // 呼叫 b,b 會被推入堆疊
console.log('a end');
}
function b() {
console.log('b');
}
a(); // 輸出順序:a start → b → a end
重點:只要 Call Stack 不為空,事件循環不會去檢查 Task Queue 或 Microtask Queue。
2. Task Queue(宏任務佇列)
- 別名:Macro‑task queue、Job queue。
- 包含的任務:
setTimeout、setInterval、setImmediate(Node)、I/O 事件、UI 重新渲染等。 - 排程規則:當 Call Stack 清空後,事件循環會從 Task Queue 取出 最早 加入的任務,放入 Call Stack 執行。
console.log('script start');
setTimeout(() => {
console.log('macro task: setTimeout');
}, 0);
console.log('script end');
執行順序:
script startscript endsetTimeout的回呼(雖然延遲為 0,但必須等到下一輪事件循環)
3. Microtask Queue(微任務佇列)
- 別名:Micro‑task queue、Job queue(在 ECMAScript 規範中)。
- 包含的任務:
Promise.then / catch / finally、queueMicrotask、process.nextTick(Node)等。 - 排程規則:在每一次宏任務執行完畢、且重新渲染之前,事件循環會先清空 所有 微任務。這意味著微任務的優先級高於宏任務。
console.log('script start');
Promise.resolve().then(() => {
console.log('microtask: Promise.then');
});
setTimeout(() => {
console.log('macro task: setTimeout');
}, 0);
console.log('script end');
執行順序:
script startscript endmicrotask: Promise.then(先於 setTimeout)macro task: setTimeout
4. 事件循環的完整流程
- 執行 Call Stack 中的同步程式碼。
- 若 Call Stack 為空,檢查 Microtask Queue:
- 只要有微任務,就一直執行,直到佇列清空。
- 清空微任務後,若有 Task Queue 中的宏任務,取出最早的那一個,放入 Call Stack 執行。
- 渲染階段(瀏覽器)或 I/O 回呼(Node)會在此時觸發。
- 重複以上步驟,形成 永續迴圈。
程式碼範例
以下示範 5 個常見情境,說明 Call Stack、Task Queue、Microtask Queue 的交錯執行順序。
範例 1:同步、微任務、宏任務的混合
console.log('1️⃣ 同步 start');
setTimeout(() => console.log('2️⃣ 宏任務:setTimeout'), 0);
Promise.resolve()
.then(() => console.log('3️⃣ 微任務:Promise.then'))
.then(() => console.log('4️⃣ 微任務:第二個 then'));
console.log('5️⃣ 同步 end');
執行順序:
1️⃣ → 5️⃣ → 3️⃣ → 4️⃣ → 2️⃣
說明:微任務會在同一輪事件循環的「宏任務」之前全部執行完畢。
範例 2:async / await 與微任務
async function foo() {
console.log('A. async function start');
await Promise.resolve(); // 產生微任務
console.log('B. after await');
}
console.log('X. before foo');
foo();
console.log('Y. after foo');
執行順序:
X → A → Y → B
重點:
await會把後面的程式碼放入 微任務佇列,因此在foo()呼叫後,Y先被印出。
範例 3:Node.js 中的 process.nextTick 與 setImmediate
console.log('① start');
process.nextTick(() => console.log('② nextTick (microtask)'));
setImmediate(() => console.log('③ setImmediate (macro)'));
console.log('④ end');
執行順序(Node):
① → ④ → ② → ③
process.nextTick被視為微任務,優先於setImmediate(宏任務)執行。
範例 4:queueMicrotask 手動加入微任務
console.log('>> script start');
queueMicrotask(() => console.log('>> 微任務:queueMicrotask'));
setTimeout(() => console.log('>> 宏任務:setTimeout'), 0);
console.log('>> script end');
執行順序:
script start → >> script end → >> 微任務:queueMicrotask → >> 宏任務:setTimeout
queueMicrotask是 ECMAScript 官方提供的加入微任務的 API,行為與Promise.then完全相同。
範例 5:大量微任務可能導致「阻塞」
function floodMicrotasks(count) {
for (let i = 0; i < count; i++) {
Promise.resolve().then(() => console.log(`微任務 #${i}`));
}
}
console.log('開始');
setTimeout(() => console.log('宏任務:setTimeout'), 0);
floodMicrotasks(5);
console.log('結束');
執行順序:
開始 → 結束 → 微任務 #0 … #4 → 宏任務:setTimeout
若
count非常大(例如 10,000),會在同一輪事件循環內耗盡大量時間,導致 UI 失去回應。這就是 微任務洪水 的風險。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的最佳實踐 |
|---|---|---|
| 微任務優先於宏任務 | 誤以為 setTimeout(..., 0) 會立刻執行,結果被 Promise.then 「搶先」 |
使用 queueMicrotask 或 Promise.resolve().then 明確 表示需要微任務;若真的想延遲到下一輪,使用 setTimeout |
await 產生意外的微任務 |
await 會把後續程式碼放入微任務佇列,可能導致變數狀態在預期之外改變 |
在需要同步執行的情況下,避免在同一函式內混用 await 與同步程式碼;或使用 立即執行函式 包裝邏輯 |
| 微任務洪水 | 大量微任務會阻塞渲染或 I/O,造成卡頓 | 若需要大量迭代,分批 把工作放入宏任務(如 setTimeout)或使用 Web Workers |
Node.js 的 process.nextTick 與 setImmediate 混用 |
process.nextTick 會永遠在同一輪事件循環的最前端執行,可能導致無限迴圈 |
僅在需要確保「在任何 I/O 之前」執行的情況下使用 process.nextTick;一般情況使用 setImmediate |
| 錯誤處理不當 | Promise 錯誤若未 .catch,會變成未捕獲的異常,且不會阻止後續微任務執行 |
為每個 Promise 加上 .catch,或使用 async/await 搭配 try/catch,確保錯誤被妥善處理 |
實際應用場景
防抖(Debounce)與節流(Throttle)
- 使用
setTimeout(宏任務)延遲執行,搭配Promise.resolve().then立即更新 UI,確保使用者操作感受流暢。
- 使用
UI 更新與資料同步
- 在大量資料變更後,先把變更放入微任務(
queueMicrotask),等所有微任務完成後,再觸發一次 重繪(宏任務),可減少不必要的渲染次數。
- 在大量資料變更後,先把變更放入微任務(
伺服器端日誌緩衝
- 在 Node.js 中,使用
process.nextTick立即寫入緩衝區,確保錯誤訊息不會因為異步 I/O 耽誤;而真正的磁碟寫入則交給setImmediate或fs.writeFile(宏任務)。
- 在 Node.js 中,使用
測試框架的非同步斷言
- Jest、Mocha 等測試框架會把斷言結果放入微任務,以保證測試結束前所有非同步驗證都已完成。
動畫與渲染
requestAnimationFrame本質上是宏任務,但瀏覽器會在每次渲染前先清空微任務,讓你可以在Promise.then中安全地修改 DOM,確保變更在下一幀顯示。
總結
- Call Stack 負責同步執行,始終只能同時跑一件事。
- Task Queue(宏任務) 包含
setTimeout、I/O 回呼等,會在微任務全部完成後才被取出執行。 - Microtask Queue(微任務) 包含
Promise.then、queueMicrotask、process.nextTick,其優先級高於宏任務,會在每一次事件循環的「結束前」全部清空。 - 了解三者的執行順序,能幫助你 預測程式碼行為、避免常見的非同步陷阱,並在實務上寫出 更可預測、效能更佳 的應用。
掌握了這套機制,無論是前端的 UI 互動,還是 Node.js 後端的高併發服務,都能更從容地設計非同步流程,讓 JavaScript 的單執行緒特性不再是限制,而是 強而有力的工具。祝你寫程式順利,玩轉事件循環!