一个常见的卡顿场景
首屏轮播图是电商和内容站最常见的组件之一。它需要每隔几秒切换一张图片,同时支持用户手动滑动。很多实现会直接修改元素的 left 或 width 来实现位移和缩放:
.slide {
position: relative;
left: 0;
transition: left 0.3s;
}
.slide.active {
left: -100%;
}
在低端设备上,这种写法很容易出现明显的掉帧:滑动时图片一顿一顿,甚至整个页面滚动都变卡。问题不在于 JavaScript 执行,而在于修改 left 触发了完整的渲染流水线——布局、绘制、合成,每一步都可能成为瓶颈。
如果把位移改成 transform: translateX(-100%),同样的动画会流畅得多。为什么属性不同,性能差异会这么大?这需要从浏览器渲染管线的分层机制说起。
渲染管线的四个阶段
浏览器把一帧画面从样式计算到屏幕像素,大致经过四个阶段:
- 样式计算(Style):根据 CSS 规则计算每个元素的最终样式。
- 布局(Layout):根据样式计算元素的几何信息(位置、尺寸),生成布局树。
- 绘制(Paint):把每个元素的视觉内容(颜色、背景、边框、文本等)绘制成位图或绘制指令。
- 合成(Composite):把各个图层按正确的顺序和变换组合成最终画面。
不是所有属性变化都会走完这四个阶段。修改 color 只会触发样式计算和绘制,不会影响布局;修改 width 则可能影响兄弟元素的位置,触发整个子树的布局。
关键区别在于:布局和绘制通常在主线程执行,而合成可以在独立的合成器线程完成。主线程一旦被长时间占用,动画就会卡顿;合成器线程则能保持动画的流畅性。
为什么修改 left 会触发重排
left 属于几何属性,它的变化会改变元素在文档流中的位置。布局阶段需要重新计算该元素及其后代、甚至兄弟元素的几何信息。如果轮播图容器内有大量图片和文本,一次布局就可能耗费几毫秒。
更糟的是,布局之后通常需要重新绘制。绘制阶段要把元素的内容(图片、文字、背景)重新栅格化为像素,这是一个 CPU 密集(或 GPU 密集)的过程。对于全屏轮播图,绘制整个滑动区域可能占据一帧预算的很大一部分。
修改 left 的动画路径是:样式计算 → 布局 → 绘制 → 合成。每一步都在主线程完成,主线程既要处理用户输入、JavaScript,又要执行这些渲染工作,很容易超过 16.6 毫秒的帧预算,导致掉帧。
transform 和 opacity 的特殊地位
transform 和 opacity 是少数几个可以绕过布局和绘制的属性。它们的变化不会改变元素在文档流中的几何位置,也不会改变元素的内容,只影响元素在合成阶段的视觉呈现。
transform可以平移、缩放、旋转元素,但元素的布局位置和尺寸保持不变,因此无需重新布局。opacity只改变元素的透明度,元素的内容已经绘制完成,合成时直接调整 alpha 值即可。
当浏览器检测到动画只涉及这两个属性时,可以将元素提升到一个独立的合成层,动画的每一帧只需要在合成器线程更新该层的变换矩阵或透明度,然后与其它层合成。主线程完全不需要参与逐帧更新,除非动画的起始或结束需要重新计算样式。
合成层与层树
浏览器会把页面内容划分为多个图层,每个图层独立绘制,最终在合成阶段合并。图层可以看作是一个“离屏画布”,它有自己的位图内容。
当元素被提升为合成层时,它的内容会被单独栅格化并缓存。之后如果只改变该层的 transform 或 opacity,合成器只需要移动或调整这个层,无需重新绘制内容。
哪些情况会创建合成层?常见触发条件包括:
- 应用了 3D 变换(如
translateZ(0)) - 应用了
will-change: transform或will-change: opacity - 使用
<video>、<canvas>、<iframe>等元素 - 元素有
position: fixed或z-index较高(不同浏览器实现有差异)
合成层的好处是隔离渲染,但每个层都会占用额外的内存(位图存储)。如果页面上的合成层数量过多,内存占用会急剧上升,甚至导致 GPU 内存不足,反而拖慢性能。
轮播图的合成路径
回到开头的轮播图。假设我们使用 transform: translateX() 来实现滑动:
.slide {
will-change: transform;
transition: transform 0.3s;
}
.slide.active {
transform: translateX(-100%);
}
浏览器会在动画开始前(因为 will-change 或动画本身)将 .slide 提升为合成层。动画每一帧的流程如下:
- 主线程只更新
transform的样式值,不触发布局和绘制。 - 合成器线程读取新的变换矩阵,直接移动对应的图层。
- 合成器将移动后的图层与其它层(背景、其它轮播项)合成,输出到屏幕。
整个过程中,主线程只做了样式计算,布局和绘制完全跳过。即使主线程正在执行耗时的 JavaScript 任务,合成器线程的动画也能继续运行,不会中断。
下面的流程图展示了两种属性修改的渲染路径差异:
flowchart TD
A[修改 left] --> B[样式计算]
B --> C[布局]
C --> D[绘制]
D --> E[合成]
E --> F[屏幕]
G[修改 transform] --> H[样式计算]
H --> I[合成器线程更新层变换]
I --> J[合成]
J --> F
对比表格:left 与 transform 的渲染路径
| 属性 | 触发阶段 | 主线程参与 | 合成器参与 | 性能风险 |
|---|---|---|---|---|
left | 样式计算、布局、绘制、合成 | 是(布局和绘制) | 否 | 高:布局和绘制可能耗时,主线程繁忙时动画卡顿 |
transform | 样式计算、合成 | 仅样式计算 | 是(逐帧更新层变换) | 低:主线程负担小,合成器独立运行 |
合成层的创建与层爆炸风险
will-change 属性可以提前告知浏览器某个属性即将变化,让浏览器提前创建合成层。但过度使用会带来严重的副作用。
W3C 规范明确指出,对大量元素使用 will-change 会消耗大量资源,甚至导致页面变慢或崩溃。例如:
* {
will-change: transform, opacity;
}
这种做法会让每个元素都成为合成层,内存占用飙升,GPU 处理能力被耗尽,反而得不偿失。
规范建议:will-change 应该在动画开始前通过脚本添加,动画结束后移除,而不是在样式表中长期保留。
// 在动画开始前添加
slide.style.willChange = 'transform';
// 动画结束后移除
slide.addEventListener('transitionend', () => {
slide.style.willChange = 'auto';
});
此外,合成层本身也有创建成本。规范提到,将元素提升到新图层是一个相对昂贵的操作,可能会延迟动画的开始。因此,will-change 的价值在于提前做好优化,避免动画启动时的延迟。
何时使用 will-change:权衡与边界
Google 的开发者文档建议:如果动画可能在接下来的 200 毫秒内触发,那么对目标元素启用 will-change 是合理的。对于轮播图,用户可能随时滑动,因此对轮播项启用 will-change: transform 是合适的。
但要注意,will-change 不应滥用。以下场景需要谨慎:
- 元素数量多:如果页面上有成百上千个元素都启用了
will-change,合成层数量会爆炸,内存和 GPU 负担剧增。 - 动画结束后不清理:如果动画完成后仍保留
will-change,元素会一直占用合成层资源,浪费内存。 - 非必要属性:
will-change只对合成器能优化的属性(如transform、opacity)有意义,对width、left等属性无效,因为浏览器无法将这些属性的动画提升到合成器。
替代方案与适用边界
除了 transform 和 opacity,还有一些技术可以提升动画性能:
- 使用
requestAnimationFrame手动控制:可以精确控制动画帧,但无法避免布局和绘制,除非只修改合成器属性。 - 使用 Web Animations API:原生支持合成器线程,与 CSS 动画类似,但提供了更灵活的 JavaScript 控制。
- 使用 FLIP 技术:先记录元素的首尾位置,然后使用
transform反向补偿,避免布局动画。
这些方案各有适用场景。例如,如果必须动画 width(如手风琴展开),可以考虑 FLIP 技巧:先测量目标尺寸,再通过 transform: scaleX() 模拟宽度变化,最后在动画结束后恢复实际宽度。
但需要注意,transform 动画并非万能。如果动画过程中需要改变元素的内容(如文本变化、图片加载),合成层无法避免重绘。此外,transform 动画在低端设备上也可能因为 GPU 性能不足而卡顿,但通常仍优于布局动画。
可观测信号与诊断
要判断动画是否被提升到合成器,可以在浏览器开发者工具中检查。Chrome DevTools 的 Performance 面板可以录制动画过程,查看是否有布局和绘制事件。如果动画帧中只有合成(Composite)而没有布局和绘制,说明优化生效。
另一个信号是内存占用。如果页面合成层过多,Memory 面板会显示 GPU 内存增长。可以通过 chrome://tracing 或 Performance Monitor 观察。
常见失败模式包括:
- 强制同步布局:在动画帧中读取
offsetWidth等布局属性,会强制浏览器立即执行布局,抵消合成器优化。 - 动画属性触发重排:误用
width、top等属性,导致动画无法提升。 - 合成层滥用:过度使用
will-change导致内存爆炸,反而降低性能。
结论
transform 和 opacity 动画之所以不触发重排,是因为浏览器可以将它们提升到合成器线程,跳过布局和绘制阶段。这是现代浏览器渲染引擎的关键优化之一。
在实际开发中,应优先使用这两个属性,并合理使用 will-change 提示浏览器提前优化。同时要警惕合成层爆炸的风险,避免过度优化。理解渲染管线的分层机制,才能做出正确的性能决策。