JavaScript 事件迴圈與非同步程式設計深度解析:從巨集任務到微任務的完整鏈路
前端工程
先從一個 bug 說起
去年同事寫了一個輪詢函式,頁面跑了 30 秒後瀏覽器直接卡死:
function pollServer() {
while (true) {
const response = fetch('/api/status'); // 本意是每秒查一次
// ...
}
}
他一眼沒看出問題——fetch 是非同步的,while(true) 怎麼會阻塞?這就是經典的「把非同步當多執行緒」誤區。while(true) 在主執行緒上死迴圈,事件迴圈根本沒有機會去執行 fetch 的回呼(callback)。理解事件迴圈,就是理解 JavaScript 的單執行緒並發模型。
呼叫堆疊(Call Stack)
JavaScript 引擎只有一個呼叫堆疊。函式呼叫往裡壓,返回往外彈:
function multiply(a, b) {
return a * b;
}
function square(n) {
const result = multiply(n, n);
return result;
}
function main() {
const value = square(5);
console.log(value);
}
main();
呼叫堆疊爆了就是 RangeError: Maximum call stack size exceeded——通常是無限遞迴。
任務佇列(Task Queue)
呼叫堆疊空了,事件迴圈從任務佇列裡取一個任務執行。任務佇列裡的每一項叫巨集任務(MacroTask)。
哪些操作會產生巨集任務?
| 來源 | 範例 |
|---|---|
setTimeout / setInterval |
setTimeout(() => {}, 0) |
| I/O 操作 | 檔案讀寫、網路請求回呼 |
| UI 渲染 | requestAnimationFrame(渲染前)、瀏覽器渲染幀 |
| 使用者互動 | click、keydown 事件回呼 |
setImmediate |
Node.js 專用 |
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
// 輸出:1 → 3 → 2
即使用 setTimeout(fn, 0),fn 也要等目前所有同步程式碼執行完。
微任務(MicroTask)
微任務是 ECMAScript 規範中的概念——每個巨集任務執行完畢後,事件迴圈會清空整個微任務佇列,再取下一個巨集任務。
產生微任務的操作:
| 來源 | 範例 |
|---|---|
Promise.then/catch/finally |
Promise.resolve().then(fn) |
async/await(await 之後的部分) |
await something |
queueMicrotask |
queueMicrotask(fn) |
MutationObserver |
DOM 變化回呼 |
這是面試題的核心考點:微任務在巨集任務之前執行。
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => console.log('5'), 0);
});
console.log('6');
// 輸出:1 → 6 → 4 → 2 → 3 → 5
完整的事件迴圈模型
┌──────────────────────────────────────┐
│ 呼叫堆疊(同步) │
│ ↓ 呼叫堆疊空了 │
│ 微任務佇列(清空整個佇列) │
│ ↓ 微任務空了 │
│ 巨集任務佇列(取一個執行) │
│ ↓ 回到呼叫堆疊 │
│ 循環往復... │
└──────────────────────────────────────┘
一個完整的 tick:
- 取一個巨集任務,執行
- 執行過程中產生的微任務全部在當前 tick 內執行完
- 微任務中產生的微任務也會在同一個 tick 執行完(遞迴清空)
- 渲染(如果需要)
- 取下一個巨集任務
async/await 的底層轉換
async/await 本質上是 Generator + Promise 的語法糖。
// 我們寫的:
async function fetchUser() {
const response = await fetch('/api/user');
const user = await response.json();
return user;
}
// 等價於:
function fetchUser() {
return Promise.resolve().then(() => {
return fetch('/api/user');
}).then((response) => {
return response.json();
});
}
這裡有一個關鍵細節:await 後面的表達式會用 Promise.resolve() 包裝。如果 await 後面不是一個 Promise,會被自動轉為已解決的 Promise。所以 await 42 等價於 await Promise.resolve(42)。
Node.js 中的事件迴圈
Node.js 的事件迴圈比瀏覽器複雜——它分了 6 個階段,每個階段有自己的 FIFO 佇列:
┌───────────────────────┐
┌─>│ timers │ setTimeout、setInterval 回呼
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ pending callbacks │ 延遲到下一輪的 I/O 回呼
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │ 內部使用
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ poll │ 獲取新的 I/O 事件(核心階段)
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ check │ setImmediate 回呼
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ close callbacks │ socket.on('close', ...)
│ └──────────┬────────────┘
└──────────────┘
每個階段結束後 → 清空微任務佇列
process.nextTick 的陷阱
console.log('1');
process.nextTick(() => {
console.log('2');
process.nextTick(() => console.log('3'));
});
Promise.resolve().then(() => console.log('4'));
console.log('5');
// 輸出:1 → 5 → 2 → 3 → 4
// nextTick 優先於 Promise 微任務!
實戰調校:避免長任務阻塞
問題:一次渲染 10000 筆資料
// ❌ 主執行緒卡死數秒
function renderAll(items) {
items.forEach(item => {
const div = document.createElement('div');
div.textContent = item.name;
container.appendChild(div);
});
}
方案一:時間切片(Time Slicing)
function renderChunked(items, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
const div = document.createElement('div');
div.textContent = items[index].name;
container.appendChild(div);
}
if (index < items.length) {
setTimeout(processChunk, 0); // 交出控制權
}
}
processChunk();
}
方案二:Web Worker(真正並行)
// main.js
const worker = new Worker('/workers/data-processor.js');
worker.postMessage({ data: rawData, action: 'transform' });
worker.onmessage = (event) => { renderData(event.data); };
相關工具
- JSON 格式化 — 除錯 API 返回的非同步資料
- Base64 編碼解碼 — 處理非同步檔案讀取與編碼轉換
本站提供瀏覽器本地工具,免註冊即可試用 →
#JavaScript#事件循环#异步编程#Promise#教程