文章中英模式
布鲁斯前端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