约 8 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
动画卡顿几乎从来不是「浏览器性能不够」,而是动错了属性。浏览器渲染一帧要走 Style、Layout、Paint、Composite 四个阶段,改 width 会从 Layout 重跑整条链路,改 transform 则直接跳到 Composite。把动效限制在 transform 和 opacity 上,等于把每帧的工作量从「重排整棵子树」降到「合成层内做一次位移」。60fps 的预算是每帧 16.7ms,一次强制同步布局就能吃掉一大半,剩下的时间还要留给脚本和绘制。所以优化顺序永远是:先换属性,再谈 will-change,最后才考虑 requestAnimationFrame 手动编排。
| CSS 属性 | 触发的流水线阶段 | 每帧成本 |
|---|---|---|
| transform / opacity | Composite | 合成层内位移或透明度变化,交由 GPU 处理,通常 1ms 以内 |
| background-color / box-shadow / border-radius | Paint → Composite | 重绘面积越大越贵,大面积模糊阴影可达数毫秒 |
| width / height / top / left / margin / font-size | Layout → Paint → Composite | 整棵子树重新布局,DOM 节点越多越慢,最容易连锁掉帧 |
| filter: blur() / backdrop-filter | Paint → Composite | 逐像素运算,移动端是最容易翻车的一类 |
| color / visibility(离散变化) | Paint → Composite | 无过渡,不产生中间帧,成本取决于重绘区域 |
一帧的时间片是共享的:JavaScript 执行、样式计算、布局、绘制、合成全部挤在这 16.7ms 里,浏览器还要留出余量给输入事件和垃圾回收。这意味着只要脚本占掉 10ms,动画就已经没有足够时间跑满帧率。更隐蔽的是强制同步布局——在同一个任务里先读 offsetHeight 或 getBoundingClientRect,再修改样式,浏览器为了返回正确的值必须立刻重排一次,读写成对出现就会反复触发。常见于在循环里交替读写 DOM 的「布局抖动」。用 requestAnimationFrame 把所有读操作集中到帧首、写操作集中到帧尾,能一次性消掉大部分抖动。样式层面还可以用 contain: layout 或 content-visibility 把重排范围限制在子树内。还有一类成本常被忽略:合成层不是免费的。图层数量过多时,合成阶段本身会变慢,移动端 GPU 的显存和带宽都有限,一个全屏的半透明模糊层就能把整机拖到 30fps。所以「提升为图层」应当看成一次资源分配,而不是无条件的优化手段。
/* left 与 width 都触发 Layout,元素一多就掉帧 */
.card {
position: relative;
left: 0;
width: 280px;
transition: left 0.3s ease, width 0.3s ease;
}
.card:hover {
left: 24px;
width: 320px;
}/* transform 与 opacity 不进入 Layout / Paint */
.card {
transform: translateX(0) scale(1);
transition: transform 0.3s ease, opacity 0.3s ease;
}
.card:hover {
transform: translateX(24px) scale(1.14);
opacity: 0.92;
}
/* 只在交互前一刻提示浏览器准备图层,结束后移除 */
.card:hover {
will-change: transform;
}transition 描述两个状态之间的过渡:必须有起点和终点,由属性值变化触发,不能循环,也不方便描述多段。animation 用 @keyframes 定义任意段数的时间线,可以循环、可以延迟、可以反向播放、可以设置填充模式。经验规则很直接:hover、展开收起、主题切换这类「从 A 到 B」用 transition;入场、加载指示、循环呼吸这类「一段编排」用 animation。用 transition 硬凑多段动画,最后一定得到一串嵌套的 setTimeout 和互相覆盖的状态类,别人接手时只能靠猜。动手之前先问一句:这个动效有没有明确的起点和终点?有就是 transition 的场景,没有就该用 animation。另一个常被忽略的点是 animation 的合成顺序:多个 animation 同时作用于同一属性时,后声明的覆盖先声明的。另一个判断标准是可控性。需要中途暂停、反向播放、按进度回调的场景,用 Web Animations API 更直接:element.animate 返回的 Animation 对象有 pause、reverse、currentTime 和 playState,比在 CSS 类名之间来回切换干净得多。
/* 尊重系统「减少动态效果」,前庭功能障碍用户会因大幅位移不适 */
@media (prefers-reduced-motion: reduce) {
.toast,
.card {
animation: none;
transition: none;
}
html {
scroll-behavior: auto;
}
}
/* forwards 保留最后一帧,否则动画结束元素弹回原位 */
.toast {
animation: slide-in 0.24s ease-out forwards;
}
@keyframes slide-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}按这个顺序过一遍,多数卡顿当场就能定位。第一步在 DevTools 的 Performance 面板录一段动画,看有没有紫色 Layout 条混在绿色 Paint 里;第二步把动画属性统一收敛到 transform 和 opacity,位移改用 translate3d 或 translate,缩放改用 scale;第三步删掉样式表里所有非交互态的 will-change,改成按需添加;第四步加上 prefers-reduced-motion 分支,这一条不是可选项;第五步回到真机,尤其是中低端安卓,用 4 倍 CPU 降速模拟,帧率曲线会诚实得多。做完这五步还剩卡顿,才需要怀疑是合成层爆炸或滤镜滥用,而不是属性选择。最后是两个测量与手感上的提醒。开着 Performance 面板录制时,采样本身有开销,帧率看起来会比真实情况更差,判断达标要看长时间的平均帧时间,而不是单次录制的峰值。另外,把界面过渡的 duration 控制在 200 到 300 毫秒之间,超过 400ms 的操作反馈会让界面显得迟钝,这和帧率是两件不同的事。
transform 只改合成层的变换矩阵,不触发 Layout 和 Paint;left 会改变布局,每帧都要重排。
不建议。它会让元素常驻独立合成层并占用显存,几十个元素就可能吃掉几十 MB。动画结束应改回 auto,或只在交互前动态添加。
让元素在动画结束后保持最后一帧的样式。默认值 none 会让元素立刻回到动画前的状态,看起来像「弹回原位」。
过渡被中断、属性值没实际变化、或元素在过渡中被 display: none 移除时都不会触发。被打断的情况要监听 transitioncancel。