文章中英模式
布魯斯前端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.執行同步程式碼(Call Stack)
- 2.清空所有 Microtask queue
- 3.執行一個 Macrotask
- 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.timers:處理
setTimeout
/setInterval
- 2.pending callbacks:某些異步操作的回呼
- 3.idle, prepare:內部使用
- 4.poll:等待新的 I/O 事件
- 5.check:執行
setImmediate
- 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)?
解答:
- 微任務與宏任務:
JavaScript 是單線程語言,一次只能做一件事。為了處理非同步,JavaScript 將任務分為兩類:
- Microtask(微任務): 比如
Promise.then
、queueMicrotask
、MutationObserver
- Macrotask(宏任務): 比如
setTimeout
、setInterval
、MessageChannel
、Node.js 中的setImmediate
執行順序是「同步程式碼 → 所有 microtask → 一個 macrotask → 重複」
- Microtask(微任務): 比如
- 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,優先於 macrotasksetImmediate
:只存在於 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
解析:
- 同步程式碼先執行:
start
、async1 start
、end
- 接著進入 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');
輸出順序解析:
- 先執行同步任務:
script start
→script end
- 接著執行當前幀的所有微任務:
microtask 1
→microtask 2
- 然後執行 requestAnimationFrame 回調:
rAF callback
(在下一次重繪前) - 瀏覽器進行渲染
- 最後執行宏任務:
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