cover

結論先講

猜猜這段的輸出順序:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

答案:1, 4, 3, 2

為什麼?因為 JavaScript 是單執行緒,非同步靠 Event Loop 調度。不同的 async 機制優先級不同:

同步 code > microtask (Promise) > macrotask (setTimeout)

搞懂 Event Loop 你會:

  • 不再被 async code 的執行順序嚇到
  • 知道為什麼長任務會卡 UI
  • 知道 requestAnimationFrame 什麼時候跑
  • 寫出效能更好的 React 元件

基本模型:四個角色

┌───────────────────────────┐
│     Call Stack            │  ← 目前在跑什麼
│  ┌─────────────────────┐  │
│  │ doSomething()        │  │
│  │ main()               │  │
│  └─────────────────────┘  │
└───────────────────────────┘
           ↑ pop / push
┌───────────────────────────┐
│  Heap(記憶體)            │
└───────────────────────────┘

外部(瀏覽器 / Node.js):
┌─────────────┐  ┌──────────────────┐
│  Web APIs   │  │  Task Queues     │
│ setTimeout  │  │ ┌──────────────┐ │
│ fetch       │  │ │ Microtask    │ │ ← Promise.then
│ DOM events  │  │ └──────────────┘ │
│ ...         │  │ ┌──────────────┐ │
│             │  │ │ Macrotask    │ │ ← setTimeout, I/O
│             │  │ └──────────────┘ │
└─────────────┘  └──────────────────┘
           ↓ 結果進 queue    ↑ Event Loop 挑 task 推進 stack
  1. Call Stack:目前在執行的函式堆疊
  2. Web APIs:瀏覽器提供的非同步 API(setTimeout、fetch、DOM 事件)
  3. Task Queues:等待被執行的任務(分 microtask 跟 macrotask)
  4. Event Loop:不斷檢查「stack 空了嗎?空了就從 queue 拉 task 來跑」

執行規則:關鍵三條

規則 1:Stack 必須先清空

JS 單執行緒,一次只能跑一個函式。Stack 沒清空前,queue 的 task 都不會被挑

規則 2:Stack 清空後,先清空 Microtask Queue

Microtask queue 的所有任務跑完才會看 macrotask。

規則 3:Macrotask 一次只跑一個

每跑完一個 macrotask,重新回 step 1(看 stack、再清 microtask)。


回到開頭的例子

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

執行步驟

1. console.log('1')          → 印 1(同步)
2. setTimeout(..., 0)         → 交給 Web API,0ms 後丟到 macrotask queue
3. Promise.resolve().then()   → microtask queue 加一個 task
4. console.log('4')           → 印 4(同步)
—— 同步 code 跑完,stack 清空 ——
5. 檢查 microtask queue:有 console.log('3') → 印 3
—— microtask 清空 ——
6. 檢查 macrotask queue:有 console.log('2') → 印 2

輸出:1, 4, 3, 2

關鍵洞察

setTimeout(fn, 0) 不是立即執行,它至少要等到:

  • 目前同步 code 跑完
  • 所有 microtask 跑完

所以 setTimeout(fn, 0) 實際延遲通常 4ms 以上(瀏覽器最小值)。


Microtask vs Macrotask 分類

Microtask

  • Promise.then / catch / finally
  • queueMicrotask()
  • MutationObserver
  • async/await(底層就是 Promise)

Macrotask

  • setTimeout / setInterval
  • DOM 事件(click、scroll)
  • setImmediate(Node.js)
  • requestAnimationFrame(特殊 — 看下面)
  • I/O(fetch 的 response、file read)

關鍵差別

一次 Event Loop tick 會跑完所有 microtask,但 macrotask 只跑一個

意思是:microtask 可以「插隊」到下一個 macrotask 前面。

setTimeout(() => console.log('macro 1'), 0);
setTimeout(() => console.log('macro 2'), 0);
 
Promise.resolve().then(() => {
  console.log('micro 1');
  Promise.resolve().then(() => console.log('micro 2'));  // 仍在這輪
});

輸出:micro 1, micro 2, macro 1, macro 2

即使 micro 2 是 micro 1 跑到一半才加的,它還是在 macro 1 之前跑。


為什麼長任務會卡 UI

UI 更新(render、paint)也在 Event Loop 裡跑。一個流程:

同步 code
↓
清空 microtask
↓
(瀏覽器可選)render / paint
↓
跑一個 macrotask
↓
回 step 1

如果某個同步任務跑太久(例如大量迴圈),整個流程卡住,UI 也更新不了

function blockingLoop() {
  const start = Date.now();
  while (Date.now() - start < 3000) {
    // 忙轉 3 秒
  }
}
 
button.addEventListener('click', () => {
  blockingLoop();  // UI 凍結 3 秒
});

解法:拆開跑

async function nonBlocking() {
  for (let i = 0; i < 1000000; i++) {
    if (i % 10000 === 0) {
      await new Promise(r => setTimeout(r, 0));  // 讓 Event Loop 喘息
    }
    // ... do work
  }
}

或用 Web Worker 搬到另一個執行緒。


requestAnimationFrame:特殊的 macrotask

rAF 的 callback 在下次 repaint 之前跑,跟 macrotask 類似但不在一般 queue。

requestAnimationFrame(() => {
  // 瀏覽器要畫面更新前執行
});

用途:動畫。瀏覽器能保證 60fps(每 16.7ms 一幀)時 rAF 每幀跑一次。

不要用 setTimeout 做動畫:setTimeout 跟 paint 沒同步,會掉幀。用 rAF。


async/await 的 Event Loop 行為

async/await 表面看起來同步,底層就是 Promise(microtask)。

console.log('A');
 
async function foo() {
  console.log('B');
  await Promise.resolve();
  console.log('C');  // microtask
}
 
foo();
console.log('D');

輸出:A, B, D, C

await 把後面的程式變成 microtask,所以 C 晚於 D


React 的 Event Loop 運用

React 18 的 automatic batching:

function handleClick() {
  setState1(1);
  setState2(2);
  setState3(3);
}

React 把這三個 setState 合併成一次 render(microtask),而不是 3 次。這讓效能提升。

自動 batching 的前提是 React 能控制這個 task boundary。await 之後就跳出 batching:

async function handleClick() {
  setState1(1);
  await fetch('/api');
  setState2(2);  // 另一個 render(不同 microtask)
}

Debug 工具:DevTools Performance 面板

Chrome DevTools Performance tab 可以錄一段時間看:

  • 每個 task 多長
  • Long Task(> 50ms 警告)
  • Microtask、Animation Frame、Paint 的時序
  • 什麼時候卡住了

效能優化必備工具。


實戰 Checklist

  • 知道 microtask 比 macrotask 優先
  • setTimeout(fn, 0) 不是立即執行
  • 長任務拆成小段避免卡 UI(用 await setTimeout)
  • 動畫用 requestAnimationFrame,不用 setTimeout
  • React state update 之後用 await 會跳出 batching
  • Long Task > 50ms 要警覺
  • Debug 用 Performance 面板看 task 時序

相關文章