JavaScript 事件循环深度解析:宏任务、微任务与浏览器渲染的完整调度机制

深入剖析 JavaScript 事件循环的工作原理,涵盖宏任务与微任务的执行顺序、浏览器渲染时机、Node.js 事件循环差异,附完整代码示例与面试高频题解析,彻底搞懂异步调度的核心机制。

前端开发 2026-06-08 15 分钟

一道面试题难倒了 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 最小延迟限制

相关工具推荐:

📚 相关文章