一道面试题难倒了 80% 的候选人:
// 面试题:请写出以下代码的输出顺序
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
Promise.resolve().then(() => {
console.log('4')
setTimeout(() => console.log('5'), 0)
})
console.log('6')
答案是 1 6 3 4 2 5,而不是很多人以为的 1 6 2 3 4 5。事件循环(Event Loop) 是 JavaScript 异步编程的底层调度机制——你写的每一行 async/await、每一个 setTimeout、每一次 DOM 更新,都由它控制执行顺序。不理解事件循环,就无法解释为什么 Promise.then 比 setTimeout 先执行、为什么 requestAnimationFrame 的回调时机和你想的不一样、为什么 await 后面的代码「暂停」了却没有阻塞其他请求。
📌 **记住:**事件循环不是 JavaScript 语言规范的一部分——它由宿主环境(浏览器或 Node.js)实现。这意味着浏览器和 Node.js 的事件循环行为存在微妙但重要的差异。
🔬 一、事件循环的核心模型:调用栈 + 任务队列
1.1 三个核心概念
理解事件循环,只需要搞清楚三个东西:
- 调用栈(Call Stack):同步代码的执行场所,后进先出(LIFO)
- 任务队列(Task Queue):宏任务(Macrotask)的等待队列,先进先出(FIFO)
- 微任务队列(Microtask Queue):微任务(Microtask)的等待队列,先进先出(FIFO)
事件循环的运转逻辑可以用一句话概括:从任务队列取一个宏任务执行,执行完毕后清空所有微任务,然后可能触发渲染,再取下一个宏任务。
// 事件循环的伪代码表示
while (true) {
// 1. 从宏任务队列取一个任务执行
const macrotask = macrotaskQueue.dequeue()
if (macrotask) execute(macrotask)
// 2. 清空所有微任务(包括执行微任务过程中新产生的微任务)
while (microtaskQueue.length > 0) {
const microtask = microtaskQueue.dequeue()
execute(microtask)
}
// 3. 如果需要渲染,执行渲染步骤(浏览器环境)
if (shouldRender()) {
runRenderingSteps() // style → layout → paint
}
// 4. 回到第 1 步
}
💡 **提示:**第 2 步的「清空所有微任务」是关键——微任务队列会被完全清空,包括在执行微任务过程中新产生的微任务。这意味着微任务可以递归产生新的微任务,导致微任务队列永远不为空,从而阻塞宏任务和渲染。
1.2 宏任务 vs 微任务:谁先进队列,谁先执行
这是最容易混淆的地方。以下代码展示了宏任务和微任务的执行优先级:
// 宏任务 vs 微任务的执行顺序演示
console.log('【同步】script start')
// 宏任务:setTimeout 的回调进入宏任务队列
setTimeout(() => {
console.log('【宏任务】setTimeout 1')
// 在宏任务内部创建的微任务,会在当前宏任务结束后立即执行
Promise.resolve().then(() => {
console.log('【微任务】promise inside setTimeout')
})
}, 0)
// 微任务:Promise.then 的回调进入微任务队列
Promise.resolve().then(() => {
console.log('【微任务】promise 1')
// 微任务内部创建的新微任务——也会在本轮清空
Promise.resolve().then(() => {
console.log('【微任务】promise 2 (嵌套)')
})
}).then(() => {
// .then 链式调用也是微任务
console.log('【微任务】promise 1 then chain')
})
console.log('【同步】script end')
// 输出顺序:
// 【同步】script start
// 【同步】script end
// 【微任务】promise 1
// 【微任务】promise 1 then chain
// 【微任务】promise 2 (嵌套)
// 【宏任务】setTimeout 1
// 【微任务】promise inside setTimeout
⚡ **关键结论:**同步代码 > 微任务 > 宏任务。每执行完一个宏任务,必须清空所有微任务(包括新产生的),然后才能执行下一个宏任务。
1.3 常见 API 的任务类型分类
| API | 任务类型 | 说明 |
|---|---|---|
script 标签整体代码 |
宏任务 | 整个 <script> 是第一个宏任务 |
setTimeout / setInterval |
宏任务 | 回调进入宏任务队列 |
setImmediate(Node.js) |
宏任务 | Node.js 独有 |
I/O(文件、网络) |
宏任务 | 回调在 I/O 完成后入队 |
UI rendering |
宏任务 | 浏览器在微任务清空后、下一个宏任务前执行 |
Promise.then / catch / finally |
微任务 | 在当前宏任务结束后立即执行 |
async/await(await 之后的代码) |
微任务 | await 等价于 .then() 包装 |
queueMicrotask() |
微任务 | 显式创建微任务 |
MutationObserver |
微任务 | DOM 变化回调作为微任务 |
process.nextTick(Node.js) |
微任务(优先级最高) | 在所有其他微任务之前执行 |
requestAnimationFrame |
特殊 | 不是宏任务也不是微任务(见下文) |
⚠️ 警告:
requestAnimationFrame的回调既不是宏任务也不是微任务。它在浏览器渲染之前的「动画帧回调」阶段执行,时机在微任务清空之后、实际渲染之前。不要把它和setTimeout混为一谈。
🖥️ 二、浏览器渲染时机:rAF、微任务与 Layout 的三角关系
2.1 一帧之内发生了什么
浏览器的一帧(约 16.6ms,对应 60fps)包含以下阶段:
/*
浏览器一帧的完整流程:
┌─────────────────────────────────────────────────────┐
│ 1. 执行宏任务(从宏任务队列取一个) │
│ 2. 执行所有微任务(清空微任务队列) │
│ 3. requestAnimationFrame 回调 │
│ 4. 浏览器渲染(Style → Layout → Paint → Composite) │
│ 5. requestIdleCallback 回调(如果有空闲时间) │
│ 6. 回到步骤 1,取下一个宏任务 │
└─────────────────────────────────────────────────────┘
*/
这意味着你修改 DOM 后不会立即看到视觉变化——浏览器会等当前宏任务和所有微任务执行完毕后,才统一计算样式和绘制。
// 演示:DOM 修改的批处理行为
const box = document.getElementById('box')
// 同步代码中的多次 DOM 修改——浏览器只渲染最终结果
box.style.width = '100px' // 不会触发 layout
box.style.width = '200px' // 不会触发 layout
box.style.width = '300px' // 不会触发 layout
// 只有当同步代码全部执行完、进入渲染阶段时,浏览器才会用 300px 做一次 layout
// 但如果你强制读取布局属性,会触发「强制同步布局」(Forced Synchronous Layout)
console.log(box.offsetWidth) // ⚠️ 强制浏览器立即计算 layout,返回基于 300px 的值
box.style.width = '400px' // 再次修改
console.log(box.offsetWidth) // ⚠️ 再次强制 layout!性能杀手!
⚠️ 警告:在同一个宏任务中交替读写布局属性(如
offsetWidth、getBoundingClientRect())会触发强制同步布局(Forced Synchronous Layout),浏览器被迫在两次写操作之间插入一次布局计算。Chrome DevTools 的 Performance 面板中,黄色的「Recalculate Style」和紫色的「Layout」块就是这个问题的标志。
2.2 requestAnimationFrame 的真实执行时机
很多文章说 rAF 在「每帧开始时执行」——这是不准确的。rAF 回调在微任务清空之后、渲染之前执行:
// rAF 的真实执行顺序验证
document.getElementById('btn').addEventListener('click', () => {
// 1. 事件处理器本身是宏任务
console.log('1. click handler (宏任务)')
// 2. 微任务——在 click handler 结束后立即执行
Promise.resolve().then(() => {
console.log('2. Promise.then (微任务)')
})
// 3. rAF——在微任务清空后、渲染前执行
requestAnimationFrame(() => {
console.log('3. rAF callback (渲染前)')
// rAF 内部创建的微任务——会在 rAF 回调结束后立即执行,
// 且在本次渲染之前
Promise.resolve().then(() => {
console.log('4. microtask inside rAF (渲染前)')
})
})
console.log('5. click handler end (宏任务)')
})
// 输出顺序:
// 1. click handler (宏任务)
// 5. click handler end (宏任务)
// 2. Promise.then (微任务)
// 3. rAF callback (渲染前)
// 4. microtask inside rAF (渲染前)
// [浏览器渲染发生在这里]
2.3 await 的隐藏微任务
async/await 是 Promise 的语法糖,但很多开发者不知道 await 后面的代码实际上是微任务:
// await 的执行顺序真相
async function foo() {
console.log('1. before await')
await Promise.resolve()
// 以下代码等价于 Promise.resolve().then(() => { ... })
// 它会作为微任务在下一轮执行
console.log('2. after await (微任务)')
}
async function bar() {
console.log('3. before bar await')
await null // await null 也产生微任务!
console.log('4. after bar await (微任务)')
}
console.log('5. script start')
foo()
bar()
console.log('6. script end')
// 输出顺序:
// 5. script start
// 1. before await
// 3. before bar await
// 6. script end
// 2. after await (微任务)
// 4. after bar await (微任务)
💡 提示:
await Promise.resolve()和await null的行为完全相同——await会将后面的表达式包装为Promise.resolve(expr),然后将后续代码注册为微任务。即使是await 42也会产生一个微任务。
🔄 三、Node.js 事件循环:六阶段模型
3.1 Node.js 的事件循环与浏览器完全不同
Node.js 使用 libuv 库实现事件循环,它的事件循环分为 六个阶段,每个阶段有自己的任务队列:
/*
Node.js 事件循环的六个阶段:
┌───────────────────────────┐
┌─>│ timers │ ← setTimeout / setInterval
│ └──────────┬────────────────┘
│ ┌──────────┴────────────────┐
│ │ pending callbacks │ ← 系统级回调(TCP 错误等)
│ └──────────┬────────────────┘
│ ┌──────────┴────────────────┐
│ │ idle, prepare │ ← 内部使用
│ └──────────┬────────────────┘
│ ┌──────────┴────────────────┐
│ │ poll │ ← I/O 回调(文件读写、网络)
│ └──────────┬────────────────┘
│ ┌──────────┴────────────────┐
│ │ check │ ← setImmediate
│ └──────────┬────────────────┘
│ ┌──────────┴────────────────┐
│ │ close callbacks │ ← close 事件回调
│ └──────────┬────────────────┘
│ │
└─────────────┘
*/
3.2 setTimeout vs setImmediate:谁先执行?
这是 Node.js 面试的经典问题。答案取决于代码执行的上下文:
// 场景 1:在 I/O 回调中,setImmediate 总是先执行
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'), 0)
setImmediate(() => console.log('setImmediate'))
})
// 输出(稳定):
// setImmediate
// setTimeout
// 原因:I/O 回调在 poll 阶段执行,poll 之后是 check 阶段(setImmediate),
// 再之后才是 timers 阶段(setTimeout)
// 场景 2:在主模块中,顺序不确定
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'), 0)
// 输出(随机):可能是 timeout → immediate,也可能是 immediate → timeout
// 原因:setTimeout(fn, 0) 实际上被 Node.js 内部设为 1ms,
// 如果进入事件循环的耗时 < 1ms,timer 还没到期,setImmediate 先执行
// 如果进入事件循环的耗时 >= 1ms,timer 已到期,setTimeout 先执行
3.3 process.nextTick:微任务中的「超级 VIP」
process.nextTick 的优先级高于所有其他微任务,包括 Promise.then:
// process.nextTick vs Promise.then 的优先级
Promise.resolve().then(() => console.log('promise 1'))
process.nextTick(() => console.log('nextTick 1'))
Promise.resolve().then(() => console.log('promise 2'))
process.nextTick(() => console.log('nextTick 2'))
// 输出顺序(Node.js):
// nextTick 1
// nextTick 2
// promise 1
// promise 2
// process.nextTick 队列在微任务队列之前被清空
⚠️ **警告:**滥用
process.nextTick可能导致 I/O 饥饿——因为nextTick回调会在事件循环的每个阶段之间执行,如果递归调用nextTick,poll 阶段的 I/O 回调永远得不到执行。Node.js 官方建议使用setImmediate替代,除非你有明确的理由需要更高的优先级。
3.4 浏览器 vs Node.js 事件循环差异总结
| 特性 | 浏览器 | Node.js |
|---|---|---|
| 事件循环实现 | HTML 规范定义 | libuv 库实现 |
| 宏任务队列 | 单一队列 | 六个阶段,每阶段独立队列 |
| 微任务执行时机 | 每个宏任务后、渲染前 | 每个阶段切换时 |
process.nextTick |
❌ 不支持 | ✅ 优先级最高 |
setImmediate |
❌ 不支持 | ✅ check 阶段 |
| 渲染步骤 | ✅ 有(style → layout → paint) | ❌ 无(无 DOM) |
requestAnimationFrame |
✅ 渲染前执行 | ❌ 不支持 |
queueMicrotask |
✅ 标准微任务 | ✅ 等价于 Promise.then |
🧪 四、高频面试题实战解析
4.1 经典综合题
// 综合题:请写出以下代码的输出顺序(Node.js 环境)
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end') // 微任务
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout') // 宏任务
}, 0)
async1()
new Promise((resolve) => {
console.log('promise1')
resolve()
}).then(() => {
console.log('promise2') // 微任务
})
console.log('script end')
/*
执行过程分析:
1. 同步阶段(第一个宏任务):
- console.log('script start') → 输出: script start
- setTimeout 回调入宏任务队列
- 调用 async1():
- console.log('async1 start') → 输出: async1 start
- await async2() → 调用 async2()
- console.log('async2') → 输出: async2
- await 后面的代码入微任务队列
- new Promise 的 executor 立即执行:
- console.log('promise1') → 输出: promise1
- resolve() → .then 回调入微任务队列
- console.log('script end') → 输出: script end
2. 清空微任务队列:
- console.log('async1 end') → 输出: async1 end
- console.log('promise2') → 输出: promise2
3. 执行下一个宏任务(setTimeout 回调):
- console.log('setTimeout') → 输出: setTimeout
最终输出:
script start → async1 start → async2 → promise1 → script end
→ async1 end → promise2 → setTimeout
*/
4.2 微任务递归导致渲染卡死
// ❌ 危险:递归微任务阻塞渲染
function dangerousMicrotaskLoop() {
// 这段代码会让页面完全卡死!
// 因为微任务队列永远不为空,浏览器永远等不到渲染时机
Promise.resolve().then(() => dangerousMicrotaskLoop())
}
// ✅ 正确:使用 requestAnimationFrame 保证渲染
function safeAnimationLoop(callback) {
function loop(time) {
callback(time)
requestAnimationFrame(loop) // 在渲染之后安排下一帧
}
requestAnimationFrame(loop)
}
// ✅ 正确:使用 scheduler.yield() 让出主线程
async function cooperativeScheduling(tasks) {
for (const task of tasks) {
task()
// 让出控制权,允许浏览器处理渲染和用户输入
if ('scheduler' in globalThis && scheduler.yield) {
await scheduler.yield()
} else {
// 降级方案:使用 MessageChannel 模拟让出
await new Promise(resolve => {
const channel = new MessageChannel()
channel.port1.onmessage = resolve
channel.port2.postMessage(null)
})
}
}
}
⚠️ **警告:**递归微任务(如
Promise.resolve().then(recurse))会导致页面完全冻结,因为微任务队列永远不会清空,浏览器永远等不到渲染时机。如果需要循环执行异步操作,使用requestAnimationFrame或scheduler.yield()来定期让出主线程。
4.3 setTimeout 的最小延迟陷阱
// setTimeout(fn, 0) 并不是立即执行
const start = performance.now()
setTimeout(() => {
const delay = performance.now() - start
console.log(`实际延迟: ${delay.toFixed(2)}ms`)
// 浏览器中通常输出 1-5ms,Node.js 中通常 1ms
// 在嵌套调用中,延迟会递增:
// 第 5 层嵌套后最小延迟为 4ms(浏览器规范限制)
}, 0)
// 嵌套 setTimeout 的延迟递增问题
let depth = 0
function nestedTimeout() {
const t0 = performance.now()
setTimeout(() => {
const actual = performance.now() - t0
console.log(`嵌套深度 ${++depth}: 实际延迟 ${actual.toFixed(2)}ms`)
if (depth < 10) nestedTimeout()
}, 0)
}
nestedTimeout()
// 嵌套深度 1-4: ~1ms
// 嵌套深度 5+: 跳到 ~4ms(浏览器的最小延迟钳制)
💡 **提示:**HTML 规范规定,当
setTimeout嵌套深度 ≥ 5 时,最小延迟被强制设为 4ms。这是为了防止恶意脚本通过无限嵌套setTimeout(fn, 0)耗尽 CPU。如果你需要精确的高频调度,使用requestAnimationFrame(60fps)或MessageChannel(无最小延迟限制)。
💡 五、最佳实践与性能建议
5.1 何时使用哪种异步方案
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 立即执行但不阻塞当前同步代码 | queueMicrotask() |
微任务,同步代码结束后立即执行 |
| 延迟执行(最低优先级) | setTimeout(fn, 0) |
宏任务,等待所有微任务和渲染 |
| 动画和视觉更新 | requestAnimationFrame |
渲染前执行,保证 60fps |
| 长任务分片(不让页面卡顿) | scheduler.yield() |
让出主线程,允许渲染 |
| 空闲时执行低优先级任务 | requestIdleCallback |
只在浏览器空闲时执行 |
| Node.js 中替代 setTimeout(0) | setImmediate |
无最小延迟限制,语义更清晰 |
5.2 避免事件循环相关的常见 Bug
// ❌ Bug 1:在微任务中无限循环
async function processLargeArray(items) {
for (const item of items) {
// 如果 items 有 100 万个,这个循环产生的微任务会让页面卡死
await processItem(item)
}
}
// ✅ 修复:每处理 N 个项目就让出主线程
async function processLargeArrayFixed(items, batchSize = 1000) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize)
for (const item of batch) {
await processItem(item)
}
// 每批次结束后让出主线程,允许渲染和用户交互
await new Promise(resolve => setTimeout(resolve, 0))
}
}
// ❌ Bug 2:期望 setTimeout 精确计时
let count = 0
function inaccurateTimer() {
count++
console.log(`第 ${count} 次,期望 100ms 间隔`)
setTimeout(inaccurateTimer, 100) // 累积误差会越来越大
}
// ✅ 修复:使用 performance.now() 修正漂移
function accurateTimer(callback, interval) {
let expected = performance.now() + interval
function step() {
const drift = performance.now() - expected
callback()
expected += interval
// 用实际时间差修正下一次的等待时间,消除累积误差
setTimeout(step, Math.max(0, interval - drift))
}
setTimeout(step, interval)
}
📋 总结
事件循环是 JavaScript 异步编程的底层引擎,理解它才能真正掌控代码的执行顺序和性能表现。
核心要点回顾:
- ✅ 执行优先级:同步代码 > 微任务(process.nextTick > Promise.then)> 宏任务 > 渲染
- ✅ 微任务清空规则:每个宏任务后必须清空所有微任务,包括新产生的微任务
- ✅ 渲染时机:在微任务清空之后、下一个宏任务之前,浏览器可能执行渲染
- ✅ rAF 不是宏任务也不是微任务:它在渲染前的专门阶段执行
- ✅ await 是微任务语法糖:
await expr等价于Promise.resolve(expr).then(...) - ✅ Node.js 六阶段模型:timers → pending → idle → poll → check → close
- ❌ 不要递归微任务:会导致渲染永远无法执行,页面冻结
- ❌ 不要依赖 setTimeout 精确计时:嵌套调用有 4ms 最小延迟限制
相关工具推荐:
- 🔧 jsjson.com JSON 格式化工具 — 处理异步返回的 JSON 数据
- 🔧 jsjson.com 时间戳转换工具 — 调试事件循环中的时间计算
- 📖 MDN Event Loop 文档 — 权威参考
- 📖 Node.js Event Loop 文档 — Node.js 官方详解