先问大家一个问题:你有没有遇到过这样的场景——页面上有一段动画,明明只是改了transform和opacity,却还是卡成了幻灯片。或者更常见的是,setTimeout(() => doSomething(), 0)明明写的是0毫秒延迟,页面却明显顿了一下。如果你去查Performance面板,大概率会看到一整条被塞满的主线程任务,中间夹着大段的紫色、黄色块。这个现象的根源,就是标题里这两个词:事件循环与浏览器渲染机制。
我见过太多人把这两块内容当成两条独立的线去学:先学事件循环,记住宏任务微任务的执行顺序;再学渲染机制,背下Style、Layout、Paint、Composite这几个阶段。但实际开发中它们根本不是两条线,而是同一条主线程管道上交替运行的两套逻辑。不懂它们的协作关系,你写的代码就永远是在盲猜。这篇博文不打算做那种条目式的知识盘点,我想从真实项目中遇到的卡顿、延迟和诡异执行顺序出发,把这套机制彻底掰开揉碎,讲清楚背后"为什么",顺带给出可以直接抄的排查和优化方案。
1. 先纠正一个普遍误区:事件循环和渲染不是"先后关系",而是"协作关系"
很多人对事件循环的理解是这样一张图:调用栈执行完脚本 → 从宏任务队列取出第一个任务执行 → 再清空微任务队列 → 然后浏览器渲染 → 回到宏任务队列取下一个。这个图表面上没错,但它暗示了一种错误直觉——好像每一次事件循环都会触发一次渲染,而且渲染总是在微任务之后、下一个宏任务之前。
真实情况远没有那么"规律"。
1.1 页面不是"每循环一次就刷一帧"的电视
浏览器渲染的触发时机,取决于它自己内部的渲染时机判定逻辑(rendering opportunity)。这个逻辑会综合考虑屏幕刷新率、当前页面是否可见、电池状态、页面能耗预算等因素。一个60Hz刷新率的显示器,理想情况下每16.6ms有一次渲染机会,但如果页面在后台标签页,浏览器为了省电可能把渲染频次降到0——也就是干脆不渲染。这就是为什么后台页面的requestAnimationFrame回调会直接停摆,而setTimeout依然在跑。
基于这个前提,我们再回头看那三个关键机制在事件循环里的真实位置:
- 宏任务队列:JS引擎从宏任务队列取出一个任务执行,执行过程中产生的所有新宏任务(比如嵌套的
setTimeout)不会插入当前队列头部,而是排到队尾。 - 微任务队列:当前宏任务执行完毕、调用栈清空后,JS引擎会一口气把所有微任务清空。清空过程中新建的微任务也会在本轮内执行完。所以微任务队列有"饿死主线程"的能力。
- 渲染机会:清空微任务之后,浏览器会问自己"现在需要渲染吗?"。如果不需要(比如刚渲染过、或页面不可见),就跳过渲染阶段,直接进入下一次宏任务循环。
我在项目里就踩过这个坑:当时为了让表格渲染完再滚动到指定行,用了一个while循环在微任务里批量处理DOM节点,结果整个页面卡死。原因就是微任务队列被无限期的插入新任务,渲染阶段根本轮不到。记住一句话:微任务里不要做"永远追加微任务"的递归操作,它会卡死整个页面。
1.2 渲染阶段在事件循环里的"插队位"
当一个渲染时机到来时,浏览器会执行一段"渲染帧"流程,这段流程包含以下步骤:
- 执行所有
requestAnimationFrame回调 - 重算样式(Style / Recalc Style)
- 布局(Layout)
- 绘制(Paint)
- 合成(Composite)
注意,requestAnimationFrame的回调是挂在这个渲染阶段的钩子上,不是挂在宏任务或微任务队列里。所以很多人问"requestAnimationFrame属于宏任务还是微任务",答案是:它既不属于宏任务也不属于微任务,它是渲染帧的回调队列。
这意味着什么?意味着如果你在requestAnimationFrame回调里做了重活,比如遍历一个10万项的数组,或者递归修改DOM触发强制同步布局,你就是在压缩渲染阶段的预算。等这个回调执行完,浏览器只剩非常短的时间去做Style和Paint,甚至做不完就丢给下一帧,掉帧就是这么来的。
实用口诀:
setTimeout/setInterval/ I/O事件回调:宏任务,适合不着急、可以等下一回合的普通业务逻辑。Promise.then/MutationObserver/queueMicrotask:微任务,适合必须在当前同步代码结束后立刻执行的逻辑,但切忌无限追加。requestAnimationFrame:渲染帧任务,适合所有要和屏幕同步的操作。- `requestIdleCallbac
