做前端动画时最常见的一条建议是「只动 transform 和 opacity」。这条规则我照着用了很久,但一开始并不清楚背后的原因,直到踩过几次掉帧才真正理解。这篇把浏览器渲染管线的逻辑理一遍。
浏览器画一帧要做的事
从 DOM 到屏幕像素,浏览器大致要走这几步:
- Style:计算每个元素最终生效的 CSS 属性值。
- Layout(回流):算出每个元素的几何信息——宽高、位置。
- Paint(重绘):把元素画成一系列绘制指令,填充颜色、边框、阴影、文字。
- Composite(合成):把若干图层按顺序叠起来,交给 GPU 输出到屏幕。
关键在于:这四步是有依赖顺序的。改动触发的阶段越靠前,后面的阶段就得跟着重做一遍。
不同属性触发的阶段不同
| 改动的属性 | 触发 | 代价 |
|---|---|---|
width height top left margin padding | Layout → Paint → Composite | 最高 |
background-color box-shadow border-radius color | Paint → Composite | 中 |
transform opacity | 只有 Composite | 最低 |
动画意味着每秒要重复这个过程 60 次,每帧的预算只有约 16.7ms。如果每帧都要重新 layout 整棵子树,很容易就超预算,表现出来就是卡顿掉帧。
而 transform 和 opacity 之所以便宜,是因为它们可以完全由合成器(compositor)线程处理:元素已经被光栅化成一张位图,动画只是在改这张位图的变换矩阵和透明度。这个过程甚至不需要主线程参与——也就是说,即使主线程正在跑一段耗时的 JS,这类动画依然能保持流畅。
常见的改写
大部分「看起来必须改布局」的动画,都能翻译成 transform:
/* ❌ 每帧触发 layout */
.box { transition: left 300ms; }
.box.move { left: 200px; }
/* ✅ 只走合成 */
.box { transition: transform 300ms; }
.box.move { transform: translateX(200px); }
缩放同理,用 scale() 代替改 width/height:
/* ❌ */
.card:hover { width: 320px; height: 220px; }
/* ✅ */
.card { transition: transform 250ms; }
.card:hover { transform: scale(1.04); }
注意 scale 会把内部的文字和圆角一起缩放,如果不希望文字变形,做法是给子元素套一个反向的 scale,或者干脆只对不含文字的装饰层做缩放。
阴影怎么办
box-shadow 动画属于 paint 阶段,模糊半径越大越贵。想要「hover 时阴影加深」的效果又不掉帧,常见做法是准备两层阴影,用 opacity 交叉淡入:
.card { position: relative; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.card::after {
content: ""; position: absolute; inset: 0; border-radius: inherit;
box-shadow: 0 18px 40px -18px rgba(0,0,0,.25);
opacity: 0; transition: opacity 250ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }
实际项目里如果卡片数量不多、阴影模糊半径也不夸张,直接 transition box-shadow 通常也能接受。这个技巧留给列表里有几十上百个卡片的场景。
高度展开动画
折叠面板是个老问题,因为 height: auto 无法参与过渡。现在有几个选择:
grid-template-rows: 0fr → 1fr,配合子元素overflow: hidden,纯 CSS 且支持自动高度;- 用 JS 量出真实高度再设成具体像素值;
- 如果只是要个揭示效果,用
clip-path或transform: scaleY()绕过高度。
这三种都不完美,grid 方案目前是可读性最好的。
will-change 不是万能药
will-change: transform 会提前把元素提升为独立的合成层,避免动画开始那一帧的抖动。但它有成本——每个合成层都要占显存,滥用会让内存暴涨,反而更卡。
我的用法是:只在确实观察到动画起始有 1px 抖动时才加,而且尽量通过 CSS 加在真正会动的那个元素上,不要写成 will-change: all。如果是用 JS 控制的一次性动画,动画结束后应该把它移除。
filter: blur() 的取舍
模糊是 GPU 上开销较大的操作,半径越大越贵,在 Safari 上尤其明显。做背景光晕这类效果时,我会控制在 20px 以内,或者用一张预先模糊好的渐变图代替实时滤镜。动画中同时改变 blur 半径基本要避免——那等于每帧重新做一次模糊。
怎么验证
光靠肉眼判断不可靠,Chrome DevTools 里有两个直接的入口:
- Rendering 面板 → 勾选 Paint flashing,动画时如果大面积绿色闪烁,说明在重绘;勾选 Layer borders 能看到哪些元素被提升成了合成层。
- Performance 面板 → 录一段动画,看主线程时间轴。理想情况下动画期间主线程几乎是空的,紫色的 Layout 和绿色的 Paint 条不应该周期性出现。
最后一条
性能之外还有一条底线:任何动画都要给 prefers-reduced-motion 留退路。前庭功能障碍的用户会因为大幅位移和视差产生眩晕,这跟掉帧不同,是实实在在的不适。
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none !important;
transition: none !important;
}
}
一行媒体查询的事,没有理由不写。