本文 AI 產出,尚未審核

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) 為空時,從任務隊列中取出第一個任務,推入呼叫堆疊執行。流程大致如下:

  1. 執行全域程式碼 → 產生同步函式呼叫,推入呼叫堆疊。
  2. 同步程式結束,呼叫堆疊清空。
  3. 事件循環檢查任務隊列,若有待執行的任務,將其移至呼叫堆疊。
  4. 執行任務(可能是 setTimeoutPromise.then、或 UI 事件等)。
  5. 重複 2~4,直到程式結束或沒有待處理的任務。

關鍵概念只有當呼叫堆疊為空,事件循環才會把任務取出來執行。這也是為什麼「同步程式」會阻塞非同步回呼的原因。

3. 任務類型:宏任務 vs 微任務

在 ECMAScript 規範中,任務被分為兩大類:

類別 代表 API 執行時機
宏任務(macrotask) setTimeoutsetIntervalsetImmediate(Node)
requestAnimationFrameI/O 回呼
每一次事件迴圈的 結束,會先執行所有微任務,然後才執行下一個宏任務
微任務(microtask) Promise.thenPromise.catchqueueMicrotaskMutationObserver 在同一個事件迴圈內,所有宏任務執行前,會先把微任務全部執行完畢

因此,微任務的優先級高於宏任務,這是導致 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
說明:先執行同步程式 (AD) → 呼叫堆疊清空 → 先處理微任務 (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:setTimeoutsetImmediate(Node.js)

此範例僅在 Node.js 環境下可執行,展示宏任務的優先順序差異。

setTimeout(() => {
  console.log('K');   // 宏任務 (timers)
}, 0);

setImmediate(() => {
  console.log('L');   // 宏任務 (check)
});

console.log('M');

執行結果(大多數情況)M K LM L K(取決於執行環境的實作)。
說明setTimeoutsetImmediate 都屬於宏任務,但在 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
awaittry/catch 混用不當 await 失敗會拋出錯誤,若未捕獲會變成未處理的 rejection,影響後續微任務 使用 try { await … } catch (e) { … },或在最外層加 process.on('unhandledRejection') 監聽
Promise 內部忘記回傳 then 內部若沒有 return,下一個 then 會收到 undefined,導致邏輯錯位 永遠回傳return)需要傳遞的值或另一個 Promise
過度使用 setInterval 若前一次任務尚未完成,就已觸發下一次,導致 重疊執行 改用 setTimeout 形成「自我調度」的迴圈,或在回呼內先清除再重新設定

小技巧

  1. 微任務排隊順序queueMicrotask(() => {...}) 可以在不建立 Promise 的情況下,直接將函式加入微任務隊列。
  2. 避免「Callback Hell」:使用 async/awaitPromise.all 來平行化多個非同步操作,提升可讀性。
  3. 監控事件循環:在 Chrome DevTools → Performance → 「Event Loop」視圖,可視覺化宏任務與微任務的分布,協助找出卡頓點。

實際應用場景

場景 為何需要掌握 Event Loop 常用技巧
前端表單驗證 需要先同步檢查欄位,再非同步向伺服器驗證唯一性 使用 debouncesetTimeout)配合 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/awaitPromise、以及各種瀏覽器/Node.js 的非同步 API,寫出 可讀、可維護、且效能佳 的 JavaScript 程式碼。祝你開發順利!