JavaScript – 事件循環(Event Loop & 非同步)
主題:Event Loop 概念
簡介
在 JavaScript 中,非同步 是日常開發不可或缺的特性。無論是處理使用者輸入、發送 AJAX 請求、或是讀寫檔案,都必須依賴事件循環(Event Loop)來協調執行順序。
如果不了解 Event Loop 的運作機制,程式很容易出現「先執行完」或「執行順序錯亂」的問題,進而導致錯誤、效能下降,甚至是資源泄漏。
本篇文章將從 概念層面、實作細節、常見陷阱 以及 實務應用 四個面向,完整說明 JavaScript 的事件循環,讓讀者能在開發過程中正確使用非同步 API,寫出更可靠且易維護的程式碼。
核心概念
1. 單執行緒與任務隊列
JavaScript 本身是一個 單執行緒(single‑thread)的語言,所有程式碼都在同一條執行路徑上跑。為了不讓 I/O(例如網路、檔案)阻塞主執行緒,瀏覽器或 Node.js 會把這些耗時操作交給 底層的執行環境(Web API、Thread Pool),等操作完成後再把結果放入 任務隊列(Task Queue)。
任務隊列 其實是一個 FIFO(先進先出)的結構,裡面的每一項稱為 任務(task)。
2. 事件循環(Event Loop)
事件循環 是一個不斷重複的迴圈,負責在 呼叫堆疊(Call Stack) 為空時,從任務隊列中取出第一個任務,推入呼叫堆疊執行。流程大致如下:
- 執行全域程式碼 → 產生同步函式呼叫,推入呼叫堆疊。
- 同步程式結束,呼叫堆疊清空。
- 事件循環檢查任務隊列,若有待執行的任務,將其移至呼叫堆疊。
- 執行任務(可能是
setTimeout、Promise.then、或 UI 事件等)。 - 重複 2~4,直到程式結束或沒有待處理的任務。
關鍵概念:只有當呼叫堆疊為空,事件循環才會把任務取出來執行。這也是為什麼「同步程式」會阻塞非同步回呼的原因。
3. 任務類型:宏任務 vs 微任務
在 ECMAScript 規範中,任務被分為兩大類:
| 類別 | 代表 API | 執行時機 |
|---|---|---|
| 宏任務(macrotask) | setTimeout、setInterval、setImmediate(Node)requestAnimationFrame、I/O 回呼 |
每一次事件迴圈的 結束,會先執行所有微任務,然後才執行下一個宏任務 |
| 微任務(microtask) | Promise.then、Promise.catch、queueMicrotask、MutationObserver |
在同一個事件迴圈內,所有宏任務執行前,會先把微任務全部執行完畢 |
因此,微任務的優先級高於宏任務,這是導致 Promise 看起來「先於 setTimeout」執行的根本原因。
程式碼範例
下面提供 5 個常見且實用的範例,說明 Event Loop 在不同情境下的行為。每段程式碼都加入詳細註解,方便初學者理解。
範例 1:同步 vs 非同步的執行順序
console.log('A'); // 同步執行,立即印出
setTimeout(() => {
console.log('B'); // 宏任務,放入 task queue
}, 0);
Promise.resolve().then(() => {
console.log('C'); // 微任務,放入 microtask queue
});
console.log('D'); // 同步執行,立即印出
執行結果:A D C B
說明:先執行同步程式 (A、D) → 呼叫堆疊清空 → 先處理微任務 (C) → 最後才輪到宏任務 (B)。
範例 2:async/await 與微任務
async function foo() {
console.log('E'); // 同步
await Promise.resolve(); // 產生微任務
console.log('F'); // 會在微任務之後執行
}
foo();
console.log('G');
執行結果:E G F
說明:await 之後的程式碼被包裝成微任務,等到當前同步程式結束、微任務隊列被清空後才執行。
範例 3:多層微任務的執行順序
Promise.resolve()
.then(() => {
console.log('H'); // 第一次微任務
return Promise.resolve();
})
.then(() => {
console.log('I'); // 第二次微任務(在第一次微任務結束後才加入)
});
console.log('J');
執行結果:J H I
說明:每一次 then 都會產生一個新的微任務,且 新增的微任務會在本輪微任務執行完畢後才加入。
範例 4:setTimeout 與 setImmediate(Node.js)
此範例僅在 Node.js 環境下可執行,展示宏任務的優先順序差異。
setTimeout(() => {
console.log('K'); // 宏任務 (timers)
}, 0);
setImmediate(() => {
console.log('L'); // 宏任務 (check)
});
console.log('M');
執行結果(大多數情況)M K L 或 M L K(取決於執行環境的實作)。
說明:setTimeout 與 setImmediate 都屬於宏任務,但在 Node.js 中,setImmediate 會在 I/O 事件之後、setTimeout 之前執行。
範例 5:事件循環與 UI 重新渲染(瀏覽器)
function heavyComputation() {
// 模擬長時間運算(阻塞 UI)
const start = Date.now();
while (Date.now() - start < 100); // 100ms 同步阻塞
console.log('N');
}
// 1. 先排入一個微任務
Promise.resolve().then(() => console.log('O'));
// 2. 排入宏任務
setTimeout(() => console.log('P'), 0);
// 3. 執行阻塞任務
heavyComputation();
// 4. 瀏覽器會在這一輪宏任務結束後,才進行畫面重繪
執行結果:O N P
說明:即使 setTimeout 設為 0,瀏覽器仍會在同步阻塞任務完成後才觸發下一輪事件迴圈,畫面重繪也會延後。這提醒我們 盡量避免長時間同步運算,以免卡住 UI。
常見陷阱與最佳實踐
| 陷阱 | 為何會發生 | 解法 / 最佳實踐 |
|---|---|---|
同步迴圈阻塞(如 while(true){}) |
會佔滿呼叫堆疊,導致微任務、宏任務無法被執行 | 使用 setTimeout(...,0) 或 requestIdleCallback 把大工作切割成小塊,讓事件循環有機會跑 |
setTimeout(..., 0) 仍有延遲 |
0 並不代表「立即」執行,而是「最早在下一個宏任務」 | 若需要更快的微任務,改用 Promise.resolve().then |
await 與 try/catch 混用不當 |
await 失敗會拋出錯誤,若未捕獲會變成未處理的 rejection,影響後續微任務 |
使用 try { await … } catch (e) { … },或在最外層加 process.on('unhandledRejection') 監聽 |
Promise 內部忘記回傳 |
then 內部若沒有 return,下一個 then 會收到 undefined,導致邏輯錯位 |
永遠回傳(return)需要傳遞的值或另一個 Promise |
過度使用 setInterval |
若前一次任務尚未完成,就已觸發下一次,導致 重疊執行 | 改用 setTimeout 形成「自我調度」的迴圈,或在回呼內先清除再重新設定 |
小技巧
- 微任務排隊順序:
queueMicrotask(() => {...})可以在不建立Promise的情況下,直接將函式加入微任務隊列。 - 避免「Callback Hell」:使用
async/await或Promise.all來平行化多個非同步操作,提升可讀性。 - 監控事件循環:在 Chrome DevTools → Performance → 「Event Loop」視圖,可視覺化宏任務與微任務的分布,協助找出卡頓點。
實際應用場景
| 場景 | 為何需要掌握 Event Loop | 常用技巧 |
|---|---|---|
| 前端表單驗證 | 需要先同步檢查欄位,再非同步向伺服器驗證唯一性 | 使用 debounce(setTimeout)配合 Promise,避免過度請求 |
| 即時聊天 | 收到訊息後立即更新 UI,同時保持長連線(WebSocket) | WebSocket 事件屬於宏任務,UI 更新可放在 requestAnimationFrame 中 |
| 資料批次處理(Node.js) | 大量檔案 I/O 需要分批執行,防止事件循環阻塞 | 使用 stream + await,或 setImmediate 讓每批次結束後釋放堆疊 |
| 動畫與渲染 | 需要在每一幀前完成計算,否則會掉幀 | 把計算放在 requestAnimationFrame,確保在瀏覽器的渲染階段之前完成 |
| 錯誤收集與上報 | 異步錯誤(Promise rejection)若不捕獲會導致未處理的 rejection | 在程式入口加入 process.on('unhandledRejection')(Node)或 window.addEventListener('unhandledrejection')(瀏覽器) |
總結
- JavaScript 為單執行緒,透過 事件循環 把同步與非同步工作協調在同一條執行路徑上。
- 宏任務 與 微任務 的分層機制決定了程式碼的實際執行順序,了解這點能避免「先執行完」的尷尬。
- 常見的陷阱多半來自 同步阻塞、不當的
setTimeout使用、或 未捕獲的 Promise 錯誤,只要遵守 「把長時間工作切成小塊」、「使用微任務排程」 以及 「適時捕獲錯誤」 的原則,就能寫出穩定且效能友善的程式。 - 在實務開發中,無論是前端 UI 更新、後端 I/O、或是即時通訊,都離不開對 Event Loop 的正確理解與運用。
掌握了事件循環的概念,你就能更自信地使用 async/await、Promise、以及各種瀏覽器/Node.js 的非同步 API,寫出 可讀、可維護、且效能佳 的 JavaScript 程式碼。祝你開發順利!