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 图,把五个阶段和它们之间的依赖关系画清楚了。你可以保存下来,做动画优化时对照着看。

浏览器渲染流水线五大阶段 浏览器渲染流水线:五大阶段 JavaScript Style Layout Paint Composite 执行脚本 选择器匹配 几何计算 像素绘制 图层合成 成本:中 成本:中 成本:高 成本:高 成本:低 触发路径: 修改 transform/opacity → 仅触发 Composite(绿色路径) 修改 color/background → 触发 Paint + Composite(黄色路径) 修改 width/height → 触发 Layout + Paint + Composite(红色路径) 优化建议: 1. 动画优先使用 transform 和 opacity,避免触发 Layout 2. 使用 will-change 提前告知浏览器创建独立图层 3. 避免在动画循环中读取 offsetTop 等强制 Layout 的属性

2.8 实战中怎么用这套理论

知道了五个阶段,你遇到掉帧问题时,思路就清晰了:

  1. 先定位卡在哪个阶段。打开 Chrome DevTools → Performance 面板,录制一段动画,看每个阶段的耗时。如果 Layout 时间超过 10ms,说明布局是瓶颈。
  2. 再看触发了哪些属性。在 Performance 面板的 Summary 里,能看到具体是哪些 CSS 属性变化导致了 Layout 或 Paint。
  3. 然后替换属性。比如用 transform: translateX() 代替 left,用 opacity 代替 visibility 动画。

我记得有一次帮一个团队优化轮播图。他们用 left 做位移,每帧触发 Layout,移动端掉帧严重。改成 transform: translateX() 后,帧率从 20fps 直接升到 60fps。改动就一行代码,效果天差地别。

记住这个原则:能用 Composite 解决的,绝不动 Paint;能用 Paint 解决的,绝不动 Layout。这是动画优化的黄金法则。