JavaScript Event Loop (事件循環)

理解 Event Loop 是掌握 JavaScript 異步程式設計的關鍵。Event Loop 是讓 JavaScript 這個單執行緒語言能夠處理異步操作的核心機制。

JavaScript 是單執行緒

JavaScript 是單執行緒 (single-threaded) 的語言,意思是一次只能執行一件事。那為什麼我們可以同時處理多個異步操作(如 AJAX 請求、計時器)呢?答案就是 Event Loop。

執行環境的組成

要理解 Event Loop,需要先了解 JavaScript 執行環境的幾個重要組成:

Call Stack(呼叫堆疊)

Call Stack 是一個資料結構,用來追蹤程式目前執行到哪裡。當你呼叫一個函式,它會被推入 (push) 堆疊;當函式執行完畢,它會被彈出 (pop) 堆疊。

function multiply(a, b) {
    return a * b;
}

function square(n) {
    return multiply(n, n);
}

function printSquare(n) {
    var result = square(n);
    console.log(result);
}

printSquare(4);

執行過程中 Call Stack 的變化:

1. [printSquare]
2. [printSquare, square]
3. [printSquare, square, multiply]
4. [printSquare, square]  // multiply 回傳後彈出
5. [printSquare]          // square 回傳後彈出
6. [printSquare, console.log]
7. [printSquare]          // console.log 執行完彈出
8. []                     // printSquare 執行完彈出

Web APIs

瀏覽器提供的 API,如 setTimeoutfetch、DOM 事件等。這些 API 不是 JavaScript 引擎本身的一部分,而是由瀏覽器(或 Node.js)提供。

Callback Queue(回呼佇列)

也稱為 Task Queue 或 Message Queue。當異步操作完成時,它的回呼函式會被放入這個佇列等待執行。

Event Loop

Event Loop 的工作就是不斷檢查 Call Stack 是否為空,如果是空的,就從 Callback Queue 取出一個任務放入 Call Stack 執行。

Event Loop 運作流程

console.log('1');

setTimeout(function() {
    console.log('2');
}, 0);

console.log('3');

執行結果是 1, 3, 2,而不是 1, 2, 3。讓我們一步步看發生了什麼:

  1. console.log('1') 進入 Call Stack,執行後輸出 1,彈出
  2. setTimeout 進入 Call Stack,瀏覽器設定計時器,setTimeout 彈出
  3. console.log('3') 進入 Call Stack,執行後輸出 3,彈出
  4. 計時器到期(即使是 0ms),callback 被放入 Callback Queue
  5. Event Loop 檢查 Call Stack 為空,將 callback 移入 Call Stack
  6. 執行 callback,輸出 2

重點:即使 setTimeout 設定 0 毫秒,callback 也不會立即執行,因為它必須等 Call Stack 清空後,才會被 Event Loop 放入執行。

實際範例

範例一:長時間運算阻塞

console.log('開始');

// 模擬長時間運算
var end = Date.now() + 3000;
while (Date.now() < end) {
    // 阻塞 3 秒
}

console.log('結束');

在這 3 秒期間,整個頁面會凍結,無法回應任何使用者互動。這是因為 Call Stack 一直被佔用,Event Loop 無法處理其他任務。

範例二:非阻塞做法

console.log('開始');

setTimeout(function() {
    console.log('3 秒後');
}, 3000);

console.log('結束');  // 這行會立即執行

使用 setTimeout,主執行緒不會被阻塞,使用者仍可與頁面互動。

Microtask 與 Macrotask

Callback Queue 其實分為兩種:

  • Macrotask Queue(宏任務):setTimeout、setInterval、I/O、UI 渲染
  • Microtask Queue(微任務):Promise.then、MutationObserver

執行優先順序:

  1. 執行 Call Stack 中的程式碼
  2. Call Stack 清空後,先執行所有 Microtask
  3. Microtask 清空後,執行一個 Macrotask
  4. 重複步驟 2-3
console.log('1');

setTimeout(function() {
    console.log('2');
}, 0);

Promise.resolve().then(function() {
    console.log('3');
});

console.log('4');

// 輸出順序:1, 4, 3, 2

解釋:

  • console.log('1') 同步執行
  • setTimeout callback 進入 Macrotask Queue
  • Promise.then callback 進入 Microtask Queue
  • console.log('4') 同步執行
  • Call Stack 清空,執行 Microtask,輸出 3
  • 執行 Macrotask,輸出 2

更複雜的範例

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

Promise.resolve()
    .then(function() {
        console.log('promise1');
    })
    .then(function() {
        console.log('promise2');
    });

console.log('script end');

// 輸出順序:
// script start
// script end
// promise1
// promise2
// setTimeout

實際應用

避免阻塞 UI

如果需要處理大量資料,可以分批處理:

function processLargeArray(array) {
    var index = 0;
    var chunkSize = 100;
    
    function processChunk() {
        var end = Math.min(index + chunkSize, array.length);
        
        for (var i = index; i < end; i++) {
            // 處理 array[i]
        }
        
        index = end;
        
        if (index < array.length) {
            // 讓出執行權,讓 UI 有機會更新
            setTimeout(processChunk, 0);
        }
    }
    
    processChunk();
}

確保 DOM 更新後執行

element.style.display = 'block';

// 使用 setTimeout 確保在 DOM 更新後執行
setTimeout(function() {
    element.classList.add('animate');
}, 0);

總結

  • JavaScript 是單執行緒,靠 Event Loop 處理異步操作
  • Call Stack 執行同步程式碼
  • 異步操作完成後,callback 會進入 Queue 等待
  • Event Loop 在 Call Stack 清空時,將 Queue 中的任務放入執行
  • Microtask(Promise)優先於 Macrotask(setTimeout)執行

理解 Event Loop 對於寫出高效能、不阻塞的 JavaScript 程式碼非常重要。