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(渲染前)、瀏覽器渲染幀
使用者互動 clickkeydown 事件回呼
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:

  1. 取一個巨集任務,執行
  2. 執行過程中產生的微任務全部在當前 tick 內執行完
  3. 微任務中產生的微任務也會在同一個 tick 執行完(遞迴清空)
  4. 渲染(如果需要)
  5. 取下一個巨集任務

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); };

相關工具

本站提供瀏覽器本地工具,免註冊即可試用 →

#JavaScript#事件循环#异步编程#Promise#教程