JavaScript 深度
Event Loop

Event Loop 完整圖解
JS 如何做到「非同步」

為什麼 setTimeout 0ms 不是真的立刻執行?
Promise 和 setTimeout 誰先誰後?用互動圖解一次說清楚

J

Joseph Chen

2026·JavaScript 深度系列·5 min read·1.2k views
Event Loop
非同步
Microtask

一、JS 是單執行緒,卻能「同時」做很多事

JavaScript 是單執行緒(Single-threaded)語言,也就是說, 它一次只能做一件事。但我們天天寫 setTimeoutfetchaddEventListener——這些明明看起來是「同時在跑」的,這是怎麼做到的?

答案是:JS 本身確實只有一條執行緒,但它所在的宿主環境(瀏覽器或 Node.js) 提供了額外的能力——Web APIs。這些 API 可以在背景執行耗時操作, 完成後把結果透過 Event Loop 通知 JS。

JS Runtime 架構示意

JS Engine(V8)

Call Stack
Memory Heap

Web APIs

setTimeout / setInterval
fetch / XMLHttpRequest
DOM Events
requestAnimationFrame

Event Loop

Microtask Queue
Macrotask Queue
Event Loop

Web APIs 完成後把 callback 放進 Queue,Event Loop 看到 Call Stack 空了就把 Queue 裡的任務搬進來執行

二、Call Stack:JS 的執行順序管理員

Call Stack(呼叫堆疊)是 JS 追蹤「現在執行到哪個函式」的資料結構, 遵循後進先出(LIFO)原則——最後呼叫的函式最先結束。

call-stack.js — 執行順序追蹤
function c() { console.log('c'); }
function b() { c(); console.log('b'); }
function a() { b(); console.log('a'); }

a();
// Call Stack 的變化:
// → a() 進 Stack
//   → b() 進 Stack
//     → c() 進 Stack → 印出 'c' → c() 出 Stack
//   → 印出 'b' → b() 出 Stack
// → 印出 'a' → a() 出 Stack

// 輸出順序:c → b → a
⚠️
Stack Overflow:如果函式無限遞迴,Call Stack 會不斷堆疊直到超出上限, 瀏覽器拋出 Maximum call stack size exceeded。 這就是 Stack Overflow 的由來(也是那個著名 QA 網站的名稱來源)。

三、Macrotask vs Microtask:兩種任務佇列

非同步任務完成後,callback 會被放進「佇列(Queue)」等待執行。 但佇列不只一個——JS 有兩種優先級不同的佇列:

優先度:高

Microtask Queue

微任務佇列

每次 Call Stack 清空後,必須把 Microtask Queue 全部清空,才能執行下一個 Macrotask。

屬於 Microtask 的 API

Promise.then / .catch / .finally
queueMicrotask()
MutationObserver
優先度:低

Macrotask Queue

宏任務佇列(Task Queue)

每次只取出一個 Macrotask 執行,執行完後再去清空 Microtask Queue,如此循環。

屬於 Macrotask 的 API

setTimeout / setInterval
setImmediate(Node.js)
DOM 事件 callback
I/O callback

Event Loop 的完整循環邏輯(虛擬碼)

event-loop-pseudocode.js
while (true) {
  // 1. 執行 Call Stack 裡的同步程式碼
  executeSyncCode();

  // 2. 清空所有 Microtask(可能產生新的 Microtask,繼續清空)
  while (microtaskQueue.length > 0) {
    microtaskQueue.shift()();
  }

  // 3. 取出 Macrotask Queue 的第一個任務執行
  if (macrotaskQueue.length > 0) {
    macrotaskQueue.shift()();
  }

  // 4. 回到步驟 2(清空可能新增的 Microtask)
  // → 如此無限循環
}

四、互動圖解:這段程式碼的執行順序

execution-order.js — 猜猜輸出順序?
console.log('start');

setTimeout(() => {
  console.log('timeout');
}, 0);

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

console.log('end');

// 答案:start → end → promise → timeout

很多人以為 setTimeout 0 等於「立刻執行」, 但實際上 promise 永遠比 timeout 先印出來。 下面的互動圖解一步步說明原因:

步驟 1 / 6

開始執行

同步程式碼進 Call Stack 執行

Call Stack

LIFO
console.log("start")
全域執行環境

Microtask Queue

優先

(空)

Macrotask Queue

延後

(空)

目前輸出

'start'

五、Event Loop 常見陷阱

陷阱 #1

setTimeout 0ms 不是「立刻執行」

setTimeout(fn, 0) 只是說「把 fn 放進 Macrotask Queue, 盡快執行」。但它必須等到 Call Stack 清空、Microtask Queue 也清空之後才會執行。0ms 只是最短等待時間,不是實際執行時間。

settimeout-0.js
// setTimeout 0 的實際含義
setTimeout(() => console.log('A'), 0);
Promise.resolve().then(() => console.log('B'));
console.log('C');

// 輸出:C → B → A
// C:同步,立刻執行
// B:Microtask,Call Stack 清空後立刻執行
// A:Macrotask,Microtask 清空後才執行
陷阱 #2

無限 Microtask 會阻塞頁面渲染

瀏覽器的畫面重繪(re-render)是在 Macrotask 之間執行的。 如果 Microtask Queue 一直有任務(例如 then 裡面不斷 resolve 新 Promise), 畫面就永遠不會更新——頁面「卡死」。

infinite-microtask.js
// ❌ 危險:無限遞迴 Microtask,頁面卡死
function loop() {
  Promise.resolve().then(loop); // 不斷往 Microtask Queue 塞
}
loop();

// ✅ 如果需要「盡快但允許渲染」的定時任務
// 改用 setTimeout 讓瀏覽器有機會重繪
function loopSafe() {
  // do something
  setTimeout(loopSafe, 0); // Macrotask,中間讓瀏覽器喘口氣
}
陷阱 #3

長時間同步運算阻塞 UI

JS 是單執行緒,同步程式碼執行期間,Event Loop 完全停擺——使用者的點擊、滾動、動畫全部凍結。

blocking-sync.js
// ❌ 問題:同步迴圈阻塞 3 秒,UI 凍結
function heavyWork() {
  const start = Date.now();
  while (Date.now() - start < 3000) {
    // 空轉 3 秒,這段期間頁面完全無回應
  }
}
heavyWork();

// ✅ 解法 1:分批處理,讓 Event Loop 有空隙
async function heavyWorkChunked(items) {
  for (let i = 0; i < items.length; i++) {
    process(items[i]);
    if (i % 100 === 0) {
      await new Promise(r => setTimeout(r, 0)); // 讓出控制權
    }
  }
}

// ✅ 解法 2:Web Worker(真正的多執行緒)
// 把耗時計算移到 Worker,不阻塞主執行緒

六、async/await 底層就是 Promise + Microtask

async/await 是 Promise 的語法糖。await 的本質是:暫停函式執行,把後面的程式碼包進 .then(), 放進 Microtask Queue 等待。

async-await-event-loop.js
async function asyncFunc() {
  console.log('A');          // ① 同步執行
  await Promise.resolve();   // ② 暫停,把後面的程式碼放進 Microtask Queue
  console.log('B');          // ③ Microtask:等 await 解決後執行
}

console.log('start');
asyncFunc();
console.log('end');

// 輸出:start → A → end → B
// 因為 await 之後的 console.log('B')
// 等同於 Promise.resolve().then(() => console.log('B'))
// 是個 Microtask,等同步碼(end)執行完後才跑

核心重點整理

Call Stack

JS 執行同步程式碼的地方,LIFO,一次只跑一個函式

Web APIs

瀏覽器提供,在背景執行 setTimeout、fetch 等,完成後把 callback 放進 Queue

Microtask Queue

Promise.then 的 callback,Call Stack 清空後立刻全部清空(優先於 Macrotask)

Macrotask Queue

setTimeout / 事件 callback,每次只取一個執行,執行完先清空 Microtask

Event Loop

不停地問:Call Stack 空了嗎?空了就先清 Microtask,再取一個 Macrotask

setTimeout 0

不是立刻執行,是「Call Stack 和 Microtask 都清空後」才執行

async/await

await 暫停函式,把之後的程式碼丟進 Microtask Queue,本質是 Promise.then