Skip to content

JavaScript 事件循环:宏任务、微任务与渲染时机 ​

事件循环是前端面试的常客,但很多人只记住了"微任务先于宏任务",却解释不了这些问题:

  • 为什么 await 之后的代码总比 setTimeout 先执行?
  • 为什么一段密集的微任务能让页面完全卡死?
  • 为什么 Node.js 里 setTimeout(fn, 0) 和 setImmediate 的顺序不固定?

要回答这些问题,需要把事件循环放回浏览器的渲染帧和 Node.js 的阶段模型中理解。

浏览器的任务模型 ​

浏览器主线程同一时间只执行一个任务。任务分为两类:

  • 宏任务(Task):脚本执行、setTimeout、setInterval、用户交互事件、网络回调、MessageChannel 消息。
  • 微任务(Microtask):Promise 回调、queueMicrotask、MutationObserver 回调。

每个宏任务执行完后,浏览器会清空微任务队列,然后才可能进入渲染阶段:

text
执行一个宏任务
  → 清空微任务队列(包括微任务中新产生的微任务)
  → 判断是否需要渲染
      → requestAnimationFrame 回调
      → 样式计算 / 布局 / 绘制 / 合成
  → 进入下一个宏任务

关键点是:微任务队列必须清空。如果微任务不断产生新的微任务,渲染永远没有机会执行。

js
function starve() {
  queueMicrotask(starve)
}
starve()
// 页面会卡死,因为微任务队列永远清不空

一段代码的执行顺序 ​

js
console.log('sync 1')

setTimeout(() => console.log('timeout'), 0)

queueMicrotask(() => console.log('microtask 1'))

Promise.resolve().then(() => console.log('promise'))

requestAnimationFrame(() => console.log('raf'))

console.log('sync 2')

输出顺序是:

text
sync 1
sync 2
microtask 1
promise
raf
timeout

解释:

  1. 同步代码先执行完。
  2. 当前宏任务的微任务按入队顺序执行。
  3. 微任务清空后,浏览器进入渲染阶段,requestAnimationFrame 在绘制前执行。
  4. setTimeout 的回调进入下一个宏任务,通常在渲染之后执行。

setTimeout(fn, 0) 并不是立即执行,而是把回调放入宏任务队列,最快也要等当前任务、微任务和一次渲染机会结束。浏览器还会对嵌套的定时器做最小延迟限制。

async/await 的本质 ​

async 函数的同步部分立即执行,await 之后的部分被包装成微任务:

js
async function run() {
  console.log('async start')
  await null
  console.log('after await')
}

console.log('script start')
run()
console.log('script end')

输出是 script start、async start、script end、after await。await 并没有阻塞线程,只是把后续代码推迟到微任务。

这也意味着,在 await 之后拿到的状态可能已经变化。涉及取消、竞态的场景,仍然需要显式处理:

js
let requestId = 0

async function search(keyword) {
  const current = ++requestId
  const result = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
  if (current !== requestId) return
  render(await result.json())
}

长任务与让出主线程 ​

浏览器把超过 50ms 的任务视为长任务。长任务会延迟输入响应、阻塞渲染,是 Interaction to Next Paint(INP)变差的主要原因。

把大任务切片的常见方式:

js
function yieldToMain() {
  return new Promise((resolve) => {
    setTimeout(resolve, 0)
  })
}

async function process(items) {
  for (const [index, item] of items.entries()) {
    handle(item)
    if (index % 50 === 0) {
      await yieldToMain()
    }
  }
}

setTimeout 的缺点是受最小延迟影响,切片过细反而降低吞吐。MessageChannel 可以让出主线程但不经过定时器队列:

js
function yieldWithMessageChannel() {
  return new Promise((resolve) => {
    const channel = new MessageChannel()
    channel.port1.onmessage = () => {
      channel.port1.close()
      resolve()
    }
    channel.port2.postMessage(null)
  })
}

更新的浏览器提供了 scheduler.yield() 和 scheduler.postTask(),可以更好地表达任务优先级:

js
async function processWithScheduler(items) {
  for (const item of items) {
    handle(item)
    if (window.scheduler?.yield) {
      await window.scheduler.yield()
    }
  }
}

使用前做好特性检测,旧浏览器回退到 setTimeout 或 MessageChannel。

requestAnimationFrame 与 requestIdleCallback ​

  • requestAnimationFrame:在下一帧绘制前执行,适合更新动画状态。回调执行频率通常与屏幕刷新率一致。
  • requestIdleCallback:在浏览器空闲时执行,适合非关键的分析、预取、日志上报。它不保证一定执行,也不能用于动画。
js
requestIdleCallback(
  (deadline) => {
    while (deadline.timeRemaining() > 4 && tasks.length > 0) {
      tasks.shift()()
    }
  },
  { timeout: 2000 }
)

timeout 保证即使一直不空闲,任务也会在两秒内被调度,避免被无限推迟。

Node.js 的事件循环 ​

Node.js 的事件循环分为多个阶段,常见的有:

text
timers          → setTimeout / setInterval
pending callbacks
idle / prepare
poll            → I/O 回调
check           → setImmediate
close callbacks → socket.close 等

每个阶段之间会清空微任务队列,另外还有独立的 process.nextTick 队列,优先级高于 Promise 微任务。

js
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
process.nextTick(() => console.log('nextTick'))
Promise.resolve().then(() => console.log('promise'))

在顶层代码中,nextTick 和 promise 一定先输出,而 timeout 与 immediate 的顺序取决于事件循环启动时定时器是否已经到期,因此不保证固定。

在 I/O 回调中,顺序则是确定的:

js
const fs = require('node:fs')

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0)
  setImmediate(() => console.log('immediate'))
})

此时当前阶段是 poll,下一次循环会先进入 check 阶段执行 setImmediate,所以 immediate 先于 timeout。

常见坑 ​

  1. 用微任务做批量调度。微任务不会让出渲染,大量使用会饿死渲染帧。
  2. 认为 setTimeout(fn, 0) 是立即执行。它只是尽快进入宏任务队列,实际延迟受当前任务和浏览器限制影响。
  3. 在动画中使用 setInterval。定时器与屏幕刷新不同步,容易丢帧或抖动,应使用 requestAnimationFrame。
  4. 在 await 后假设组件仍挂载。异步返回时组件可能已卸载,需要取消或检查状态。
  5. 忽略 Node.js 的 nextTick 递归。和浏览器微任务一样,递归 nextTick 会阻止事件循环继续。

小结 ​

事件循环的本质是调度优先级:同步代码优先,微任务在任务结束后立即清空,渲染发生在微任务之后,定时器和 I/O 回调进入后续宏任务。理解这个顺序,才能写出既不阻塞渲染、又能正确处理异步竞态的前端代码。

基于 VitePress 构建 · 持续更新中