我不知道的浏览器(03)— 事件循环:浏览器如何协调 JS 与渲染
很多人以为 setTimeout(fn, 0) 就是"立即执行",但实际上——它排在所有当前微任务之后,排在浏览器渲染之后,有时候甚至要等几毫秒。
很多人以为 setTimeout(fn, 0) 就是”立即执行”,但实际上——它排在所有当前微任务之后,排在浏览器渲染之后,有时候甚至要等几毫秒。
这背后是事件循环的调度规则。弄清楚这个规则,才能真正解释为什么同样是”异步”,Promise 和 setTimeout 的执行时机会有差别,以及 JS 执行和页面渲染之间的时序关系。
一、为什么需要事件循环
上一篇说过,渲染主线程是单线程的。但单线程有一个问题:如果遇到需要等待的操作——网络请求、定时器——线程就得在那里干等着,期间什么都做不了。
事件循环的作用是让单线程”不阻塞等待”。 对于耗时或需要等待的操作,JS 不会同步等待结果,而是注册一个回调,继续执行后续代码。等待完成后,回调被放入任务队列,等主线程空闲时取出来执行。
这样主线程始终在”做有用的工作”,而不是”等待”。
二、宏任务与微任务:两个优先级不同的队列
事件循环管理着两类任务队列,它们的执行时机不同:
宏任务(Macrotask)是来自外部调度的任务,每次事件循环取一个宏任务执行。常见来源:
setTimeout/setInterval的回调- I/O 事件回调(网络响应、文件读取)
- 用户输入事件(
click、keydown) <script>标签中的整体代码(初始执行)
微任务(Microtask)是当前任务产生的”紧急后续”,每次宏任务执行完毕后,会清空所有微任务,然后才进入下一个宏任务。常见来源:
Promise.then / .catch / .finally的回调queueMicrotask()MutationObserver的回调
下面这段代码,能说出它的输出顺序吗?
console.log('1: 同步代码');
setTimeout(() => {
console.log('4: 宏任务 setTimeout');
}, 0);
Promise.resolve()
.then(() => console.log('3: 微任务 1'))
.then(() => console.log('3b: 微任务 2(由微任务 1 产生)'));
console.log('2: 同步代码');
输出顺序:1 → 2 → 3 → 3b → 4
说白了,微任务的优先级高于宏任务——宏任务执行完毕后必须先把所有微任务处理完,才能执行下一个宏任务。微任务里产生的新微任务,也会在同一轮清空,不会推迟到下一轮。
三、渲染在事件循环中的位置
这是很多人不清楚的部分:浏览器渲染发生在宏任务与宏任务之间,但不是每次间隔都渲染。
事件循环的完整节拍大致是:
执行一个宏任务
↓
清空所有微任务(包括微任务里新产生的微任务)
↓
[可选] 执行 requestAnimationFrame 回调(如果下一帧到了)
↓
[可选] 浏览器渲染(Layout → Paint → Composite)
↓
取下一个宏任务
↓
……循环
“可选”意味着浏览器渲染不是每次宏任务后都触发,而是由屏幕刷新率决定(通常 60Hz = 每 16.7ms 一次)。
这个顺序有两个重要推论:
推论一:微任务里的 DOM 操作,在渲染前已经全部完成。 Promise.then 里修改了 DOM,浏览器会在下一次渲染时统一绘制,不会”每次 Promise 就重绘一次”。
推论二:requestAnimationFrame 的回调在渲染前执行。 这是它适合做动画的原因——每一帧渲染前都会执行,和屏幕刷新率精确同步;而 setTimeout 做动画会有累积误差。
四、setTimeout 为什么不精确
setTimeout(fn, 0) 的 0 不是”立刻”,而是”至少等 0ms 后放入宏任务队列”。实际执行时间取决于:
当前宏任务是否执行完:如果当前宏任务还在运行(比如一段耗时的同步循环),定时器回调只能等。
微任务队列是否清空:宏任务结束后,必须先处理完所有微任务,才轮到 setTimeout 的回调。
浏览器的最小间隔限制:W3C 规范规定,嵌套超过 5 层的 setTimeout,最小延迟强制变为 4ms。浏览器后台标签页通常还会有额外的节流(最小 1000ms)。
// 这不是"立即执行",而是"等当前调用栈和所有微任务处理完后,尽快执行"
setTimeout(() => console.log('我是宏任务'), 0);
Promise.resolve().then(() => console.log('我是微任务'));
// 输出顺序:微任务 → 宏任务
所以 setTimeout(fn, 0) 的正确理解是:“延迟到当前任务链(含微任务)全部完成后执行”,而不是”立即执行”。
五、requestAnimationFrame 与 requestIdleCallback 的定位
在动画和性能优化场景下,有两个 API 更精准地对应事件循环的特定时间点:
requestAnimationFrame(fn) 的回调在每帧渲染前执行,天然与屏幕刷新率同步。适合所有需要和视觉帧对齐的操作——CSS 动画之外的 JS 动画、需要在绘制前读取布局信息等。
requestIdleCallback(fn) 的回调在主线程空闲时执行——即当前帧还有剩余时间,或者浏览器检测到用户没有交互。适合低优先级的后台任务(数据上报、预渲染非关键内容)。注意它不保证执行时机,延迟不可控。
问题的关键在于——选错工具会导致动画掉帧。用 setTimeout 做 60fps 动画,因为计时不精确,帧时间会有波动,肉眼可见抖动;换成 requestAnimationFrame,浏览器保证在每次屏幕刷新前调用,完全消除时间误差。
六、总结
事件循环是浏览器协调单线程上多种任务的调度机制:宏任务是主轴,微任务是每个宏任务后的”立即清算”,渲染是可选的帧间插入。
如果你只记住一句话:宏任务执行完 → 清空所有微任务 → (可能)渲染 → 下一个宏任务。 这个顺序解释了 Promise 比 setTimeout 更快、DOM 操作不会立刻触发重绘、requestAnimationFrame 为什么适合动画。
本系列其他文章:
- 上一篇:渲染线程为什么是单线程的
- 下一篇:渲染流水线:从 HTML 到屏幕的八个步骤
相关主题:
- 从 V8 引擎实现视角深入宏任务调度:宏任务的调度逻辑:事件循环的主干
- 微任务的底层实现与 MicrotaskQueue:微任务:执行时机、优先级与底层实现
- async/await 与事件循环的关系:async/await:状态机、伪协程与微任务调度