先看一段几乎所有面试都会出现的代码:
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.then、queueMicrotask、MutationObserver 的回调,放进的是另一个队列:微任务队列。
微任务的优先级比宏任务高,这是理解输出顺序的关键。
事件循环的完整流程
把上面三者连起来,一轮事件循环大致是这样:
- 从调用栈执行同步代码,直到栈清空
- 检查微任务队列,把里面的微任务全部执行完(执行过程中新产生的微任务也要一起清空)
- 取一个宏任务执行
- 再次清空微任务队列
- 浏览器视情况进行一次渲染
- 回到第 3 步,取下一个宏任务
注意第 2 步和第 4 步:每执行完一个宏任务,都会把微任务队列清空一次。这就是"微任务插队"的本质。
回到开头那段代码
现在再看开头的例子:
console.log('1') // 同步
setTimeout(() => console.log('2'), 0) // 宏任务
Promise.resolve().then(() => console.log('3')) // 微任务
console.log('4') // 同步执行过程:
- 同步执行
console.log('1')→ 输出1 setTimeout把回调放进宏任务队列Promise.then把回调放进微任务队列- 同步执行
console.log('4')→ 输出4 - 同步代码跑完,调用栈清空。清空微任务队列,执行
console.log('3')→ 输出3 - 取一个宏任务执行,
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分析:
start、end是同步,最先输出- 同步结束后清空微任务,输出
promise - 接着进入渲染阶段前的
requestAnimationFrame,输出raf - 最后才轮到宏任务
timeout
注意:
raf和timeout的相对顺序在不同浏览器、不同帧时机下可能有差异,但微任务一定排在它们前面。
Node.js 的事件循环不太一样
浏览器和 Node.js 都有事件循环,但 Node 的实现基于 libuv,把宏任务分成了多个阶段(timers、poll、check 等),还多了 process.nextTick 和 setImmediate。
简单记两点区别:
- Node 里
process.nextTick的优先级比 Promise 微任务还高 - Node 的宏任务分阶段,
setTimeout和setImmediate的先后在不同场景下不固定
本文聚焦浏览器,Node 的细节可以单独展开,但"微任务优先于宏任务"这条核心规则在两边是一致的。
常见坑
第一,以为 setTimeout(fn, 0) 会立刻执行。它只是尽快进入宏任务队列,前面还排着所有同步代码和微任务。
第二,用 setTimeout 做动画。帧率不稳、容易丢帧,动画应该用 requestAnimationFrame。
第三,在微任务里无限产生微任务,导致页面卡死、点击无响应。微任务会被一次性清空,别把它当成"喘息机会"。
第四,把大量计算塞进一个宏任务里。即使是异步回调,只要单次执行时间太长,照样会卡住主线程。耗时计算应该考虑切片(分多个宏任务)或交给 Web Worker。
总结
事件循环的核心可以浓缩成几句话:
- JS 单线程,靠事件循环调度异步回调
- 同步代码在调用栈里跑完
- 每个宏任务执行后,微任务队列会被清空
- 微任务(Promise.then)优先于宏任务(setTimeout)
- 渲染大致发生在一轮宏任务和微任务之后,动画用
requestAnimationFrame
记住"同步 → 微任务清空 → 一个宏任务 → 微任务清空 → 渲染"这条循环链路,几乎所有异步执行顺序的问题都能推导出来。