流畅动画的差别不在用了多少特效,而在每一帧浏览器被迫做多少工作。理解渲染管线,就能预判一个动画是 60fps 还是掉帧。
浏览器渲染四步
JS → Style(计算样式)→ Layout(几何位置)→ Paint(填色)→ Composite(合成上屏)
每帧重跑代价递减:Layout 比 Paint 贵,Paint 比 Composite 贵。理想动画只走最后一步——Composite,因为合成由 GPU 处理,主线程几乎不参与。
只有 transform 和 opacity 能“只合成”
改动 transform(translate/scale/rotate)和 opacity 时,元素已被提升为独立合成层,浏览器只需重新合成,跳过 Layout 与 Paint:
.card-enter {
transform: translateY(0.75rem);
opacity: 0;
}
.card-enter-active {
transform: translateY(0);
opacity: 1;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 220ms;
}
而下面这些属性每变一次都会触发昂贵的重排或重绘:
| 动画属性 | 触发的最贵阶段 |
|---|---|
top/left/right/bottom、width/height、margin/padding |
Layout |
box-shadow、filter: blur()、background |
Paint |
font-size、line-height |
Layout |
同样“上浮淡入”,用 top 位移是每帧 Layout + 父级重排,用 transform 是纯合成——这就是为什么动效规范里写“位移一律 translate,不用 top”。
会触发 Layout 的“隐形雷区”:JS 读几何
即使动画只用 transform,如果在 scroll/resize 回调里同步读取几何属性再立刻写入,会触发“强制同步布局”(layout thrash):
// 反面:每个元素读一次 offsetTop 写一次 style,读被写强制刷新布局 → N 次重排
items.forEach((el) => {
el.style.transform = `translateY(${el.offsetTop}px)`;
});
读写分离:先批量读,再批量写:
const tops = items.map((el) => el.offsetTop); // 只读一次布局
requestAnimationFrame(() => {
items.forEach((el, i) => {
el.style.transform = `translateY(${tops[i]}px)`;
}); // 集中写
});
高频事件(scroll)里还应加节流——用 requestAnimationFrame 把多次事件合并到一帧更新一次,而不是每个事件都读写。
will-change:用对是加速,用满是负债
will-change: transform 提前把元素提成合成层,动画开始前给一帧提示有益。但它有内存代价(每层独立纹理),滥用会让 GPU 内存吃紧、移动端反而更卡。原则:
- 只在即将动画的元素上加,动画结束后移除;
- 不要给大量列表项同时常驻
will-change; - 揭示完成后释放:
el.dataset.revealed && el.style.willChange = 'auto'。
box-shadow / filter 的光晕怎么办
光晕动效常需 box-shadow 过渡,而它是 Paint 级。折中:
- 静态光晕用
box-shadow(不变就不重绘); - 需要“发光呼吸”时用叠加一个
opacity动画的伪元素层,把 Paint 转成合成——::after画好阴影,只动它的opacity。
用 reduced-motion 兜底性能与体验
无论动画多便宜,都要尊重 prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
这既是无障碍要求(前庭功能敏感用户),也是低端设备的事实降载。
小结
动效性能的功课就三条:只动 transform/opacity、读写分离不制造强制同步布局、will-change 点到为止。把这三条内化成肌肉记忆,比事后拿性能面板逐帧救火省力得多。