2. 浏览器渲染流水线:JavaScript、Style、Layout、Paint、Composite 五大阶段详解
做动画掉帧修复,你得先搞清楚浏览器到底是怎么把一帧画面画出来的。
很多人一上来就查「为什么卡」,然后瞎改 CSS 属性,碰运气。我不建议这么干。你得先理解渲染流水线——说白了,就是浏览器从 JS 代码到你眼睛看到画面的那条路。
这条路分五个阶段:JavaScript → Style → Layout → Paint → Composite。任何一个阶段卡住,帧率就崩了。
核心认知:不是所有 CSS 属性变化都会触发全部五个阶段。有些只触发 Composite,成本极低;有些会触发 Layout + Paint,成本极高。选对属性,是掉帧修复的第一步。
2.1 五大阶段,逐个拆解
我习惯把这五个阶段想象成一条流水线。每个阶段都有明确的输入和输出。咱们从上游往下游走。
| 阶段 | 英文名 | 核心工作 | 触发条件举例 |
|---|---|---|---|
| JavaScript | JavaScript | 执行脚本、DOM 操作、请求动画帧 | 修改元素样式、增删 DOM、事件回调 |
| 样式计算 | Style | 匹配 CSS 选择器,计算最终样式 | 修改 class、内联样式、CSS 变量 |
| 布局 | Layout | 计算元素几何尺寸和位置 | 修改宽高、边距、定位、字体 |
| 绘制 | Paint | 绘制像素,生成位图层 | 修改颜色、背景、阴影、边框 |
| 合成 | Composite | 合并图层,交给 GPU 显示 | 修改 transform、opacity |
嗯,这里要注意:每个阶段都依赖前一阶段的结果。比如 Layout 必须等 Style 算完才能跑。所以一旦触发 Layout,前面 Style 也得重算,后面 Paint 和 Composite 也跑不掉。
2.2 JavaScript 阶段:动画的起点
动画通常从 JavaScript 开始。你用 requestAnimationFrame 或者直接改 element.style,都属于这个阶段。
我曾经在项目里遇到一个坑:用 setInterval 做动画,每 16ms 改一次 left 值。结果帧率忽高忽低,掉帧严重。后来发现 setInterval 不跟浏览器渲染同步,它只管定时执行,不管渲染队列排到哪了。
我的建议:动画相关的 JS 操作,永远用 requestAnimationFrame。它会在浏览器准备渲染下一帧时调用你的回调,天然对齐 60fps 节奏。
// ❌ 不推荐
setInterval(() => {
box.style.left = box.offsetLeft + 1 + 'px';
}, 16);
// ✅ 推荐
function animate() {
box.style.left = box.offsetLeft + 1 + 'px';
requestAnimationFrame(animate);
}
animate();
2.3 Style 阶段:选择器匹配与样式计算
JS 改了样式之后,浏览器要重新计算每个元素最终长什么样。这个过程叫 Style 计算。
说白了,就是把所有 CSS 规则(内联、class、id、伪类、继承)合并成一个最终样式对象。你想想看,一个页面几千个元素,每个元素可能匹配几十条规则,计算量不小。
我记得有一次优化一个后台表格,滚动时卡得不行。排查发现,每个单元格都用了一个很深的 CSS 选择器:.table > .row > .cell > .content > span。浏览器每次 Style 计算都要从右往左匹配,层级越深越慢。
避坑指南:我曾经见过有人用 * { box-sizing: border-box; } 这种通配符选择器,配合大量元素修改,每次 Style 计算都遍历所有元素。能避免就避免。尽量用 class 选择器,少用后代选择器。
2.4 Layout 阶段:最贵的阶段之一
Style 算完了,浏览器知道每个元素长什么样了。但还不知道它们该放在哪、占多大地方。Layout 就是干这个的。
Layout 也叫 Reflow。它计算元素的宽、高、x、y 坐标。一旦触发 Layout,浏览器要重新计算整个文档流——至少是受影响的那部分子树。
哪些操作会触发 Layout?我列几个常见的:
- 修改
width、height、padding、margin、border - 修改
display、position、float、top、left - 修改
font-size、line-height - 读取
offsetWidth、offsetHeight、getBoundingClientRect()
嗯,最后一条很多人不知道。读取这些属性时,浏览器为了给你准确的值,会强制触发一次 Layout。这叫「强制同步布局」。我见过最离谱的代码:在循环里反复读 offsetTop 再改 top,每次循环都触发 Layout,帧率直接掉到个位数。
核心原则:批量写、批量读。把读操作集中在一起,写操作集中在一起,避免读写交替触发强制 Layout。
2.5 Paint 阶段:画像素
Layout 算完了位置,浏览器知道每个元素该画在哪了。接下来 Paint 阶段负责把元素画成像素。
Paint 也叫 Rasterization。它把每个元素的可视部分(背景、文字、边框、阴影、圆角)画到一张或多张位图上。这个过程在 CPU 上完成,然后传给 GPU。
Paint 的成本取决于绘制区域的大小和复杂度。比如一个全屏渐变背景,绘制面积大,成本就高。一个 50x50 的纯色按钮,成本就低。
我遇到过一个问题:一个页面里用了大量 box-shadow,每个阴影都要单独绘制,Paint 时间飙到 30ms 以上。后来用伪元素模拟阴影,把绘制量降下来了。
小技巧:Chrome DevTools 的 Rendering 面板里,打开「Paint flashing」,页面中闪烁绿色的区域就是正在重绘的区域。闪烁越多,Paint 越重。
2.6 Composite 阶段:最后的拼图
Paint 画完的位图,还不是最终画面。浏览器要把这些位图「合成」到一起,交给 GPU 显示。这就是 Composite 阶段。
Composite 在 GPU 上完成,速度极快。它只做一件事:把各个图层按照正确的顺序、位置、透明度叠在一起。
为什么 Composite 快?因为它不涉及像素级别的计算,只做图层的平移、缩放、旋转、透明度混合。这些操作 GPU 硬件加速,几乎不占主线程时间。
所以,只触发 Composite 的属性是动画优化的首选。比如:
transform: translate()、scale()、rotate()opacity
这些属性变化时,浏览器不会重新 Layout,也不会重新 Paint。它直接把现有图层交给 GPU 做合成。成本极低。
注意:不是所有元素默认都有独立图层。浏览器会根据一些条件自动提升为图层,比如 will-change: transform、transform: translateZ(0)、video、canvas 等。但图层太多也会消耗 GPU 内存,别滥用。
2.7 一张图看懂渲染流水线
下面这张 SVG 图,把五个阶段和它们之间的依赖关系画清楚了。你可以保存下来,做动画优化时对照着看。
2.8 实战中怎么用这套理论
知道了五个阶段,你遇到掉帧问题时,思路就清晰了:
- 先定位卡在哪个阶段。打开 Chrome DevTools → Performance 面板,录制一段动画,看每个阶段的耗时。如果 Layout 时间超过 10ms,说明布局是瓶颈。
- 再看触发了哪些属性。在 Performance 面板的 Summary 里,能看到具体是哪些 CSS 属性变化导致了 Layout 或 Paint。
- 然后替换属性。比如用
transform: translateX()代替left,用opacity代替visibility动画。
我记得有一次帮一个团队优化轮播图。他们用 left 做位移,每帧触发 Layout,移动端掉帧严重。改成 transform: translateX() 后,帧率从 20fps 直接升到 60fps。改动就一行代码,效果天差地别。
记住这个原则:能用 Composite 解决的,绝不动 Paint;能用 Paint 解决的,绝不动 Layout。这是动画优化的黄金法则。