返回首页

浏览器事件循环:宏任务、微任务与渲染时机一次讲清

为什么 setTimeout 总是慢半拍?Promise.then 凭什么插队?这篇从单线程模型出发,把调用栈、任务队列、宏任务与微任务、以及渲染时机串成一条完整的执行链路。

22 约 5 分钟 · 4248 字 前端

先看一段几乎所有面试都会出现的代码:

console.log('1')

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

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

console.log('4')

输出顺序是 1 4 3 2,而不是按书写顺序的 1 2 3 4

setTimeout 写了 0 毫秒却最后才执行,Promise.then 后写却比 setTimeout 先跑。要解释清楚这件事,就绕不开浏览器的事件循环(Event Loop)。

为什么需要事件循环

JavaScript 在浏览器里是单线程的:同一时刻只有一段代码在执行。

单线程的好处是简单,不用考虑多线程那些锁和竞态。但它带来一个问题:如果遇到一个耗时操作(网络请求、定时器、读文件),线程就被卡住了,页面会直接卡死。

浏览器的解法是:把耗时的事情交给浏览器自己的其他线程去做(网络线程、定时器线程等),JS 主线程不等待,继续往下跑。等那些事情有结果了,再把对应的回调"排队"丢回主线程执行。

事件循环就是这个"排队 + 取出执行"机制的总调度器。

三个关键角色

要理解事件循环,先认识三个角色。

1. 调用栈(Call Stack)

调用栈记录当前正在执行的函数。函数被调用就压栈,执行完就出栈。

function a() {
  b()
}
function b() {
  console.log('hi')
}
a()

执行 a() 时,栈里是 a;a 里调用 b,栈变成 a -> b;b 执行完出栈,a 也执行完出栈,栈空。

主线程同步代码,就是在调用栈里一行行跑完的。

2. 任务队列(Task Queue)

setTimeout、点击事件、网络响应这些异步操作有结果时,它们的回调不会立刻执行,而是被放进任务队列里排队。

这类回调被称为宏任务(macrotask)

3. 微任务队列(Microtask Queue)

Promise.thenqueueMicrotaskMutationObserver 的回调,放进的是另一个队列:微任务队列。

微任务的优先级比宏任务高,这是理解输出顺序的关键。

事件循环的完整流程

把上面三者连起来,一轮事件循环大致是这样:

  1. 从调用栈执行同步代码,直到栈清空
  2. 检查微任务队列,把里面的微任务全部执行完(执行过程中新产生的微任务也要一起清空)
  3. 一个宏任务执行
  4. 再次清空微任务队列
  5. 浏览器视情况进行一次渲染
  6. 回到第 3 步,取下一个宏任务

注意第 2 步和第 4 步:每执行完一个宏任务,都会把微任务队列清空一次。这就是"微任务插队"的本质。

回到开头那段代码

现在再看开头的例子:

console.log('1')                                   // 同步
setTimeout(() => console.log('2'), 0)              // 宏任务
Promise.resolve().then(() => console.log('3'))     // 微任务
console.log('4')                                   // 同步

执行过程:

  1. 同步执行 console.log('1') → 输出 1
  2. setTimeout 把回调放进宏任务队列
  3. Promise.then 把回调放进微任务队列
  4. 同步执行 console.log('4') → 输出 4
  5. 同步代码跑完,调用栈清空。清空微任务队列,执行 console.log('3') → 输出 3
  6. 取一个宏任务执行,console.log('2') → 输出 2

所以顺序是 1 4 3 2

setTimeout(fn, 0)0 只代表"至少 0 毫秒后可以进队列",并不代表立刻执行。它始终是宏任务,要等当前同步代码和所有微任务都跑完才轮得到。

微任务会"一直清空",哪怕一直在产生

微任务队列有个容易踩的细节:它不是"取一个执行一个",而是一次性清空到空为止。如果一个微任务里又产生了新的微任务,新的也要在本轮一起执行完。

Promise.resolve().then(() => {
  console.log('a')
  Promise.resolve().then(() => console.log('b'))
})

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

输出是 a b c:

  • 第一个微任务输出 a,并产生了新的微任务
  • 新微任务输出 b(仍在本轮微任务清空阶段)
  • 微任务队列空了,才轮到宏任务 c

这也意味着:如果在微任务里写了死循环式的"自我递归产生微任务",会一直卡住,宏任务和渲染永远轮不上。这是一个真实存在的卡死陷阱。

渲染发生在什么时候

很多人忽略了渲染也参与事件循环。

浏览器大致会在一轮宏任务执行完、微任务清空之后,判断是否需要重新渲染(通常对齐屏幕刷新率,约每 16.7ms 一次)。

这带来两个实用结论。

第一,在同一个宏任务里多次修改 DOM,浏览器通常只渲染一次,而不是每改一次画一次。

第二,如果想在"下一帧渲染前"做动画相关的计算,应该用 requestAnimationFrame,而不是 setTimeout:

function animate() {
  // 在每一帧渲染前更新位置
  box.style.transform = `translateX(${x}px)`
  x += 2
  requestAnimationFrame(animate)
}
requestAnimationFrame(animate)

requestAnimationFrame 的回调会在下一次渲染前执行,天然和刷新率对齐,做动画比 setTimeout 更稳。

一个综合例子

把同步、微任务、宏任务、rAF 放一起:

console.log('start')

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

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

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

console.log('end')

常见输出顺序:

start
end
promise
raf
timeout

分析:

  • startend 是同步,最先输出
  • 同步结束后清空微任务,输出 promise
  • 接着进入渲染阶段前的 requestAnimationFrame,输出 raf
  • 最后才轮到宏任务 timeout

注意:raftimeout 的相对顺序在不同浏览器、不同帧时机下可能有差异,但微任务一定排在它们前面。

Node.js 的事件循环不太一样

浏览器和 Node.js 都有事件循环,但 Node 的实现基于 libuv,把宏任务分成了多个阶段(timers、poll、check 等),还多了 process.nextTicksetImmediate

简单记两点区别:

  1. Node 里 process.nextTick 的优先级比 Promise 微任务还高
  2. Node 的宏任务分阶段,setTimeoutsetImmediate 的先后在不同场景下不固定

本文聚焦浏览器,Node 的细节可以单独展开,但"微任务优先于宏任务"这条核心规则在两边是一致的。

常见坑

第一,以为 setTimeout(fn, 0) 会立刻执行。它只是尽快进入宏任务队列,前面还排着所有同步代码和微任务。

第二,用 setTimeout 做动画。帧率不稳、容易丢帧,动画应该用 requestAnimationFrame

第三,在微任务里无限产生微任务,导致页面卡死、点击无响应。微任务会被一次性清空,别把它当成"喘息机会"。

第四,把大量计算塞进一个宏任务里。即使是异步回调,只要单次执行时间太长,照样会卡住主线程。耗时计算应该考虑切片(分多个宏任务)或交给 Web Worker。

总结

事件循环的核心可以浓缩成几句话:

  1. JS 单线程,靠事件循环调度异步回调
  2. 同步代码在调用栈里跑完
  3. 每个宏任务执行后,微任务队列会被清空
  4. 微任务(Promise.then)优先于宏任务(setTimeout)
  5. 渲染大致发生在一轮宏任务和微任务之后,动画用 requestAnimationFrame

记住"同步 → 微任务清空 → 一个宏任务 → 微任务清空 → 渲染"这条循环链路,几乎所有异步执行顺序的问题都能推导出来。