动画为什么会卡:transform/opacity 与 reflow、will-change 的正确用法

卡顿通常不是性能不够,而是动错了属性。改 width 会让浏览器从布局阶段重跑,改 transform 只走合成。60fps 下每帧预算只有 16.7ms。

约 8 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

先给结论

动画卡顿几乎从来不是「浏览器性能不够」,而是动错了属性。浏览器渲染一帧要走 Style、Layout、Paint、Composite 四个阶段,改 width 会从 Layout 重跑整条链路,改 transform 则直接跳到 Composite。把动效限制在 transform 和 opacity 上,等于把每帧的工作量从「重排整棵子树」降到「合成层内做一次位移」。60fps 的预算是每帧 16.7ms,一次强制同步布局就能吃掉一大半,剩下的时间还要留给脚本和绘制。所以优化顺序永远是:先换属性,再谈 will-change,最后才考虑 requestAnimationFrame 手动编排。

哪些属性贵,贵在哪

CSS 属性触发的流水线阶段每帧成本
transform / opacityComposite合成层内位移或透明度变化,交由 GPU 处理,通常 1ms 以内
background-color / box-shadow / border-radiusPaint → Composite重绘面积越大越贵,大面积模糊阴影可达数毫秒
width / height / top / left / margin / font-sizeLayout → Paint → Composite整棵子树重新布局,DOM 节点越多越慢,最容易连锁掉帧
filter: blur() / backdrop-filterPaint → Composite逐像素运算,移动端是最容易翻车的一类
color / visibility(离散变化)Paint → Composite无过渡,不产生中间帧,成本取决于重绘区域

16.7ms 里到底发生了什么

一帧的时间片是共享的:JavaScript 执行、样式计算、布局、绘制、合成全部挤在这 16.7ms 里,浏览器还要留出余量给输入事件和垃圾回收。这意味着只要脚本占掉 10ms,动画就已经没有足够时间跑满帧率。更隐蔽的是强制同步布局——在同一个任务里先读 offsetHeight 或 getBoundingClientRect,再修改样式,浏览器为了返回正确的值必须立刻重排一次,读写成对出现就会反复触发。常见于在循环里交替读写 DOM 的「布局抖动」。用 requestAnimationFrame 把所有读操作集中到帧首、写操作集中到帧尾,能一次性消掉大部分抖动。样式层面还可以用 contain: layout 或 content-visibility 把重排范围限制在子树内。还有一类成本常被忽略:合成层不是免费的。图层数量过多时,合成阶段本身会变慢,移动端 GPU 的显存和带宽都有限,一个全屏的半透明模糊层就能把整机拖到 30fps。所以「提升为图层」应当看成一次资源分配,而不是无条件的优化手段。

把 left/width 换成 transform

改错属性:每帧重排
/* 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;
}

will-change 的四条纪律

  • 它会为元素单独提升一个合成层,图层本身占显存。几十个元素常驻,移动端就能吃掉几十 MB,低端机反而更容易崩。
  • 不要写在通配符或列表项上。只加在真正即将发生动画的那几个元素,数量控制在个位数。
  • 用完要撤。要么在 animationend / transitionend 回调里把 will-change 置回 auto,要么只在 hover、focus、pointerdown 这类交互前动态添加。
  • 它不能掩盖布局抖动。如果动画仍在改 left 或 top,加多少 will-change 都没用,因为布局阶段照样要跑。
  • 不要为了「开启硬件加速」无脑写 translateZ(0)。这个技巧在十年前的浏览器上有意义,如今 transform 和 opacity 已经自动走合成,多余的 3D 变换只会平白多出一个图层,还可能让文字渲染变模糊。

transition 还是 animation

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 类名之间来回切换干净得多。

无障碍与填充模式

prefers-reduced-motion + forwards
/* 尊重系统「减少动态效果」,前庭功能障碍用户会因大幅位移不适 */
@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); }
}

四个静默失效的细节

  • transition 从 auto 到具体值不生效。浏览器无法对 auto 插值,height: auto 到 0 不会有过渡,需要改用 grid-template-rows、max-height,或测量后的像素值。
  • display: none 的元素上动画不会启动。先切 display,在下一帧再加动画类;或者用 @starting-style(Chrome 117 起支持)声明入场起始值。
  • transitionend 在过渡被打断时不触发。要同时监听 transitioncancel,多个属性同时过渡时事件会触发多次,记得按 event.propertyName 过滤。
  • animation-fill-mode 默认是 none。动画结束元素立即回到初始状态,这不是 bug;要停在终态写 forwards,要两端都保留写 both。

上线前的检查顺序

按这个顺序过一遍,多数卡顿当场就能定位。第一步在 DevTools 的 Performance 面板录一段动画,看有没有紫色 Layout 条混在绿色 Paint 里;第二步把动画属性统一收敛到 transform 和 opacity,位移改用 translate3d 或 translate,缩放改用 scale;第三步删掉样式表里所有非交互态的 will-change,改成按需添加;第四步加上 prefers-reduced-motion 分支,这一条不是可选项;第五步回到真机,尤其是中低端安卓,用 4 倍 CPU 降速模拟,帧率曲线会诚实得多。做完这五步还剩卡顿,才需要怀疑是合成层爆炸或滤镜滥用,而不是属性选择。最后是两个测量与手感上的提醒。开着 Performance 面板录制时,采样本身有开销,帧率看起来会比真实情况更差,判断达标要看长时间的平均帧时间,而不是单次录制的峰值。另外,把界面过渡的 duration 控制在 200 到 300 毫秒之间,超过 400ms 的操作反馈会让界面显得迟钝,这和帧率是两件不同的事。

常见问题

为什么 transform 动画比 left 动画流畅?

transform 只改合成层的变换矩阵,不触发 Layout 和 Paint;left 会改变布局,每帧都要重排。

will-change 可以一直写在样式里吗?

不建议。它会让元素常驻独立合成层并占用显存,几十个元素就可能吃掉几十 MB。动画结束应改回 auto,或只在交互前动态添加。

animation-fill-mode: forwards 有什么用?

让元素在动画结束后保持最后一帧的样式。默认值 none 会让元素立刻回到动画前的状态,看起来像「弹回原位」。

transitionend 为什么有时候不触发?

过渡被中断、属性值没实际变化、或元素在过渡中被 display: none 移除时都不会触发。被打断的情况要监听 transitioncancel。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件