本文 AI 產出,尚未審核

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。
  • 包含的任務setTimeoutsetIntervalsetImmediate(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');

執行順序

  1. script start
  2. script end
  3. setTimeout 的回呼(雖然延遲為 0,但必須等到下一輪事件循環)

3. Microtask Queue(微任務佇列)

  • 別名:Micro‑task queue、Job queue(在 ECMAScript 規範中)。
  • 包含的任務Promise.then / catch / finallyqueueMicrotaskprocess.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');

執行順序

  1. script start
  2. script end
  3. microtask: Promise.then(先於 setTimeout)
  4. macro task: setTimeout

4. 事件循環的完整流程

  1. 執行 Call Stack 中的同步程式碼。
  2. 若 Call Stack 為空,檢查 Microtask Queue
    • 只要有微任務,就一直執行,直到佇列清空。
  3. 清空微任務後,若有 Task Queue 中的宏任務,取出最早的那一個,放入 Call Stack 執行。
  4. 渲染階段(瀏覽器)或 I/O 回呼(Node)會在此時觸發。
  5. 重複以上步驟,形成 永續迴圈

程式碼範例

以下示範 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.nextTicksetImmediate

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 「搶先」 使用 queueMicrotaskPromise.resolve().then 明確 表示需要微任務;若真的想延遲到下一輪,使用 setTimeout
await 產生意外的微任務 await 會把後續程式碼放入微任務佇列,可能導致變數狀態在預期之外改變 在需要同步執行的情況下,避免在同一函式內混用 await 與同步程式碼;或使用 立即執行函式 包裝邏輯
微任務洪水 大量微任務會阻塞渲染或 I/O,造成卡頓 若需要大量迭代,分批 把工作放入宏任務(如 setTimeout)或使用 Web Workers
Node.js 的 process.nextTicksetImmediate 混用 process.nextTick 會永遠在同一輪事件循環的最前端執行,可能導致無限迴圈 僅在需要確保「在任何 I/O 之前」執行的情況下使用 process.nextTick;一般情況使用 setImmediate
錯誤處理不當 Promise 錯誤若未 .catch,會變成未捕獲的異常,且不會阻止後續微任務執行 為每個 Promise 加上 .catch,或使用 async/await 搭配 try/catch,確保錯誤被妥善處理

實際應用場景

  1. 防抖(Debounce)與節流(Throttle)

    • 使用 setTimeout(宏任務)延遲執行,搭配 Promise.resolve().then 立即更新 UI,確保使用者操作感受流暢。
  2. UI 更新與資料同步

    • 在大量資料變更後,先把變更放入微任務(queueMicrotask),等所有微任務完成後,再觸發一次 重繪(宏任務),可減少不必要的渲染次數。
  3. 伺服器端日誌緩衝

    • 在 Node.js 中,使用 process.nextTick 立即寫入緩衝區,確保錯誤訊息不會因為異步 I/O 耽誤;而真正的磁碟寫入則交給 setImmediatefs.writeFile(宏任務)。
  4. 測試框架的非同步斷言

    • Jest、Mocha 等測試框架會把斷言結果放入微任務,以保證測試結束前所有非同步驗證都已完成。
  5. 動畫與渲染

    • requestAnimationFrame 本質上是宏任務,但瀏覽器會在每次渲染前先清空微任務,讓你可以在 Promise.then 中安全地修改 DOM,確保變更在下一幀顯示。

總結

  • Call Stack 負責同步執行,始終只能同時跑一件事。
  • Task Queue(宏任務) 包含 setTimeout、I/O 回呼等,會在微任務全部完成後才被取出執行。
  • Microtask Queue(微任務) 包含 Promise.thenqueueMicrotaskprocess.nextTick,其優先級高於宏任務,會在每一次事件循環的「結束前」全部清空。
  • 了解三者的執行順序,能幫助你 預測程式碼行為、避免常見的非同步陷阱,並在實務上寫出 更可預測、效能更佳 的應用。

掌握了這套機制,無論是前端的 UI 互動,還是 Node.js 後端的高併發服務,都能更從容地設計非同步流程,讓 JavaScript 的單執行緒特性不再是限制,而是 強而有力的工具。祝你寫程式順利,玩轉事件循環!