魯斯前端布魯斯前端

文章中英模式

布魯斯前端JS面試題目 - 事件循環與非同步操作

完整解析 JavaScript 與 Node.js 的事件循環,包含 microtask/macrotask 差異、瀏覽器與 Node.js 執行順序、nextTick 與 Promise 優先權、常見面試陷阱與解法。

影片縮圖

懶得看文章?那就來看影片吧

什麼是非同步操作?

JavaScript 是單執行緒語言,無法同時處理多個任務。為了避免遇到等待操作(如 API 請求、計時器)時卡住整個程式,JavaScript 引入了非同步機制。這些非同步任務會先交由瀏覽器或 Node.js 的底層處理,完成後再透過事件循環排隊回來執行。

  • setTimeout:macrotask,延遲後執行
  • Promise.then:microtask,同步後立即執行
  • async/await:Promise 語法糖
  • queueMicrotask:手動加入 microtask queue

瀏覽器的事件循環

在瀏覽器中,事件循環每一輪的執行順序為:

  1. 1.
    執行同步程式碼(Call Stack)
  2. 2.
    清空所有 Microtask queue
  3. 3.
    執行一個 Macrotask
  4. 4.
    重複循環

Microtask 會在每輪事件循環結束前全部執行完畢,優先於任何 macrotask。

console.log('1');

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

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

console.log('4');

輸出順序:1 → 4 → 3 → 2

requestAnimationFrame 在事件循環中的位置

requestAnimationFrame (rAF) 是瀏覽器提供的用於優化動畫效能的 API,它在事件循環中有特殊的位置:

瀏覽器每一幀的處理順序:
1. 執行同步程式碼
2. 執行所有 microtasks (Promise.then, queueMicrotask 等)
3. 執行 requestAnimationFrame 回調
4. 瀏覽器渲染 (layout, paint, composite)
5. 執行一個 macrotask (setTimeout, setInterval 等)
6. 返回步驟 1 開始下一輪循環

Node.js 的事件循環

Node.js 的事件循環是基於 libuv,總共有六個階段,每個階段都有自己對應的任務。每一階段之間會先執行所有 microtask(含 process.nextTick 和 Promise)。

  1. 1.
    timers:處理 setTimeout / setInterval
  2. 2.
    pending callbacks:某些異步操作的回呼
  3. 3.
    idle, prepare:內部使用
  4. 4.
    poll:等待新的 I/O 事件
  5. 5.
    check:執行 setImmediate
  6. 6.
    close callbacks:像是 socket.on('close')
process.nextTick(() => console.log('tick'));
Promise.resolve().then(() => console.log('promise'));
setTimeout(() => console.log('timeout'), 0);
console.log('sync');

輸出順序:sync → tick → promise → timeout

Microtask vs Macrotask 整理表

  • 🟢
    Microtask(同步後立即執行):Promise.then, queueMicrotask, MutationObserver, async/await
  • 🟡
    Macrotask(下一輪事件循環):setTimeout, setInterval, setImmediate
  • 🔴
    特別:Node.js 中 process.nextTick 優先於所有 microtask

setTimeout vs setImmediate

在 Node.js 中,如果 setTimeout setImmediate 同時出現在 I/O callback 中, setImmediate 會優先執行。

但如果直接在主程式中執行,順序會不穩定:
// 在 I/O callback 中,順序固定
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'));
  setImmediate(() => console.log('immediate'));
});
// 輸出:immediate → timeout

// 直接執行,順序不固定
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 輸出:順序不固定!
原因:主程式中的 setTimeout(fn, 0) 實際上會被設為 1ms,而事件循環啟動時機的微小差異會影響執行順序。

🔥 常見面試題目

(一)什麼是事件循環(Event Loop)?

解答:

  1. 微任務與宏任務:

    JavaScript 是單線程語言,一次只能做一件事。為了處理非同步,JavaScript 將任務分為兩類:

    • Microtask(微任務): 比如 Promise.thenqueueMicrotaskMutationObserver
    • Macrotask(宏任務): 比如 setTimeoutsetIntervalMessageChannel、Node.js 中的 setImmediate

    執行順序是「同步程式碼 → 所有 microtask → 一個 macrotask → 重複」

  2. Node.js 與瀏覽器的差異:

    在瀏覽器中,每輪事件循環會先執行所有同步程式碼與 microtask,然後再從 macrotask queue 中取出一個執行。

    在 Node.js 中,事件循環分為六大階段:timers pending callbacks poll check close callbacks,每個階段結束都會先跑 process.nextTick Promise 的 microtask。

    • process.nextTick:Node.js 專有,優先順序最高
    • Promise.then:屬於 microtask,優先於 macrotask
    • setImmediate:只存在於 Node.js,在 check 階段執行

(二)事件循環順序

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');

解答:輸出A → D → C → B

  • 1. 先執行同步程式碼,印出 A 和 D
  • 2. setTimeout 是 macrotask,會被放到下一輪事件循環
  • 3. Promise.then 是 microtask,會在同步程式碼後立即執行,印出 C
  • 4. 最後才執行 setTimeout 的 callback,印出 B

(三)setTimeout vs setImmediate 的非同步行為

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

解答:輸出順序不固定(可能是 timeout → immediate,也可能是 immediate → timeout)

  • 解釋:這段程式碼沒有包在 I/O callback 裡,所以執行的先後順序無法保證,取決於執行環境與事件循環排程時機。
  • setTimeout 在 timers 階段
  • setImmediate 在 check 階段
  • 因為這段程式碼沒有包在 I/O callback 裡,所以執行的先後順序無法保證,取決於執行環境與事件循環排程時機。

(四)nextTick vs Promise

process.nextTick(() => console.log('tick'));
Promise.resolve().then(() => console.log('promise'));
console.log('sync');

解答:輸出sync → tick → promise

  • 1. 同步程式碼先執行,印出 sync
  • 2. process.nextTick 優先於所有 microtask,印出 tick
  • 3. Promise.then 是 microtask,最後執行並印出 promise

(五)Promise in setTimeout

setTimeout(() => {
  console.log('1');
  Promise.resolve().then(() => console.log('2'));
}, 0);

Promise.resolve().then(() => console.log('3'));
console.log('4');

解答:輸出4 → 3 → 1 → 2

  • 1. 先執行同步程式碼,印出 4
  • 2. Promise.then 是 microtask,立即執行並印出 3
  • 3. setTimeout 是 macrotask,在下一輪事件循環執行,印出 1
  • 4. setTimeout 內的 Promise.then 會在 setTimeout 執行完後立即執行,印出 2

(六)async/await 與事件循環

async function foo() {
  console.log('A');
  await Promise.resolve();
  console.log('B');
}

console.log('C');
foo();
console.log('D');

解答:輸出C → A → D → B

解析:async/await 會在 await 處中斷,將後續邏輯作為 microtask 進入 queue。

(七)setTimeout + Promise + async/await 混合執行順序

console.log('start');

setTimeout(() => {
  console.log('timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('promise');
});

(async () => {
  console.log('async1 start');
  await Promise.resolve();
  console.log('async1 end');
})();

console.log('end');

解答:輸出start → async1 start → end → promise → async1 end → timeout

解析:

  • 同步程式碼先執行:startasync1 startend
  • 接著進入 microtask queue,先執行 promise,再執行 async1 end
  • 最後才是 macrotask 的 setTimeout

(八) requestAnimationFrame 與 Event Loop 的關係是什麼?

A: requestAnimationFrame 在事件循環中有特殊的執行時機。它會在每一幀的微任務(microtasks)執行完畢後、渲染前被調用,具體順序為:

事件循環中的一幀執行順序:
1. 執行同步任務
2. 執行微任務隊列(Promise.then, queueMicrotask等)
3. 執行 requestAnimationFrame 回調 ← 特殊時機點
4. 瀏覽器渲染(layout, paint, composite)
5. 執行宏任務(setTimeout, setInterval等)
6. 進入下一幀循環

這使得 rAF 能在渲染前的最後時刻更新畫面,
確保動畫計算結果能立即被渲染,不會延遲到下一幀。
console.log('script start');

setTimeout(() => {
  console.log('setTimeout 1');

  Promise.resolve().then(() => {
    console.log('microtask inside setTimeout 1');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('microtask 1');
});

Promise.resolve().then(() => {
  console.log('microtask 2');
});

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

requestAnimationFrame(() => {
  console.log('rAF callback');
});

console.log('script end');

輸出順序解析:

  1. 先執行同步任務:script start script end
  2. 接著執行當前幀的所有微任務:microtask 1 microtask 2
  3. 然後執行 requestAnimationFrame 回調:rAF callback(在下一次重繪前)
  4. 瀏覽器進行渲染
  5. 最後執行宏任務:setTimeout 1 microtask inside setTimeout 1(注意這個微任務在 setTimeout 回調內,所以會立即執行)→ setTimeout 2

完整輸出順序:script start script end microtask 1 microtask 2 rAF callback setTimeout 1 microtask inside setTimeout 1 setTimeout 2