外观
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解释:
- 同步代码先执行完。
- 当前宏任务的微任务按入队顺序执行。
- 微任务清空后,浏览器进入渲染阶段,
requestAnimationFrame在绘制前执行。 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。
常见坑
- 用微任务做批量调度。微任务不会让出渲染,大量使用会饿死渲染帧。
- 认为
setTimeout(fn, 0)是立即执行。它只是尽快进入宏任务队列,实际延迟受当前任务和浏览器限制影响。 - 在动画中使用
setInterval。定时器与屏幕刷新不同步,容易丢帧或抖动,应使用requestAnimationFrame。 - 在
await后假设组件仍挂载。异步返回时组件可能已卸载,需要取消或检查状态。 - 忽略 Node.js 的
nextTick递归。和浏览器微任务一样,递归nextTick会阻止事件循环继续。
小结
事件循环的本质是调度优先级:同步代码优先,微任务在任务结束后立即清空,渲染发生在微任务之后,定时器和 I/O 回调进入后续宏任务。理解这个顺序,才能写出既不阻塞渲染、又能正确处理异步竞态的前端代码。