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

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

前端开发 2026-06-07 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.thensetTimeout 先执行、为什么 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!性能杀手!

⚠️ 警告:在同一个宏任务中交替读写布局属性(如 offsetWidthgetBoundingClientRect())会触发强制同步布局(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))会导致页面完全冻结,因为微任务队列永远不会清空,浏览器永远等不到渲染时机。如果需要循环执行异步操作,使用 requestAnimationFramescheduler.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 最小延迟限制

相关工具推荐:

📚 相关文章