先给结论:CSS 动画卡顿的根因,九成是动错了属性。只有 transform 和 opacity 能跳过布局与绘制,直接在合成线程上完成;动 width、top、margin 会触发整棵子树的重新布局,动 box-shadow、filter 会触发重绘,页面元素一多就掉帧。

这个结论听起来像玄学口诀,背后其实是一条很具体的渲染管线:浏览器每一帧都要走样式计算、布局、绘制、合成四步。你改的属性决定了要从哪一步开始重做。从合成开始最便宜,从布局开始最贵。
本文用一个列表 hover 的场景做对照:同样是一个"上浮"效果,改 top 和改 transform 的代码几乎一样,但每帧的开销差了一个数量级。文中会给出改造前后的完整代码,以及如何用开发者工具自己验证而不是靠感觉判断。
先看结论
- 位移、缩放、旋转:一律用 transform,不要用 top / left / width。
- 显隐、淡入淡出:用 opacity,不要用 visibility 配合 display 切换。
- 阴影、模糊、渐变这类"看起来只是装饰"的属性动画代价很高,能换成伪元素的 opacity 就换。
属性归类拿不准时,翻 CSS3 教程 里动画与变换两个章节最快。
一、最小场景与预期行为
任务定死:一个 20 项的列表,鼠标移入时当前项上移 8 像素并出现阴影,移出时还原,整个过渡 200 毫秒。
输入是一段 HTML 加 CSS。预期行为是:60 帧下每帧耗时低于 16.7 毫秒,快速划过多项时不出现掉帧,笔记本风扇不狂转。
<ul class="list">
<li class="item">列表项一</li>
<li class="item">列表项二</li>
</ul>
.item {
position: relative;
top: 0;
transition: top .2s, box-shadow .2s;
}
.item:hover {
top: -8px;
box-shadow: 0 8px 20px rgba(0, 0, 0, .18);
}
这段做的是用 top 做位移动画、用 box-shadow 做阴影过渡。写法没错,效果也能出来,问题是它每一帧都要重新布局,阴影每一帧都要重新绘制。
预期结果(未在本机执行):在 20 项的列表里快速划过,性能面板里会看到 Layout 与 Paint 两条记录反复出现;改成合成层属性后这两条会消失,只剩 Composite。这也是判断动画卡顿到底出在哪一步的基准。
二、渲染管线:属性决定代价

浏览器渲染一帧的四个阶段依次是:
- 样式计算:算出每个元素最终用哪条声明。
- 布局:算出每个元素的几何位置与尺寸。
- 绘制:把元素画成位图,包括阴影、模糊、渐变。
- 合成:把已经画好的各层按变换矩阵叠起来。
关键区别在这里:transform 和 opacity 的改变只影响第 4 步。元素已经被画成一张位图了,位移只是换个矩阵、透明度只是改个混合系数,都不需要重新计算布局,也不需要重新画。
而 top、left、width、height、margin 的改变会影响第 2 步。布局是有传染性的——一个元素的位置变了,它后面的兄弟节点、它的父容器尺寸都可能跟着变,浏览器必须把受影响的整片区域重新算一遍。
box-shadow、filter、border-radius 这类属性不影响布局,但要重新绘制。模糊半径越大的阴影,重绘的像素越多,代价也就越高。
三、把动画改到合成层上
改造思路:位移交给 transform,阴影换成一个预先画好的伪元素,用 opacity 控制它的显隐。
.item {
position: relative;
transform: translateY(0);
transition: transform .2s;
}
.item::after {
content: "";
position: absolute;
inset: 0;
box-shadow: 0 8px 20px rgba(0, 0, 0, .18);
opacity: 0;
transition: opacity .2s;
pointer-events: none;
}
.item:hover {
transform: translateY(-8px);
}
.item:hover::after {
opacity: 1;
}
这段做的是:阴影本身不再参与过渡,它被一次性画进伪元素里,动画只改这个伪元素的 opacity。位移则从 top 换成 translateY。两条过渡都落在合成阶段。
两个容易踩的坑:一是 transform 会创建新的包含块,内部用 position: fixed 定位的子元素会以这个元素为参照,行为会变;二是阴影伪元素要加 pointer-events: none,否则它会挡住鼠标事件。
关于 will-change:它的作用是提前提示浏览器把元素提升为独立的层。只在动画即将开始的元素上用,用完移除。给几十个元素常驻 will-change: transform 会让显存占用飙升,反而更容易卡,这属于典型的"优化变劣化"。
四、常见失败分支与验证清单
4.1 改了 transform 还是掉帧
动画卡顿并没有消失,多半是同层里还有别的属性在动,比如同时改了 background-color 或 border。把过渡属性写全、逐个排除,确认过渡列表里只剩 transform 与 opacity。
4.2 动画元素被父层裁切后合成失效
父容器有 overflow: hidden 时,某些浏览器无法把子元素提升到独立层,会退化回重绘。修复办法是把裁切范围放宽,或者接受重绘的代价。
4.3 用 JavaScript 逐帧改样式
在 requestAnimationFrame 里读 offsetTop 再改 style,会造成强制同步布局,比 CSS 过渡慢得多,也是很多动画卡顿的真正来源。修复办法是把动画交给 CSS,JS 只负责加一个 class。
4.4 验证清单
- 检查:开发者工具 Performance 面板录制一段动画,看有没有 Layout 记录
- 运行:把列表项数量从 20 改到 200,重复快速划过
- 预期:帧率保持稳定,Main 线程上只有 Composite 相关条目
- 失败:出现紫色 Layout 块 → 仍有属性触发重排
- 修复:逐个移除过渡属性定位到触发者,换成等价的合成层写法
- 验证:在 Layers 面板确认动画元素被提升为独立层,且层数没有失控
上面这些步骤做的是定位重排来源、确认层提升、排除强制同步布局。预期结果(未在本机执行)是改造后 Main 线程几乎空闲;若层数超过几十个,说明 will-change 被滥用。

总结
遇到动画卡顿不要先想着加硬件加速,先问一句"我动的是哪个属性"。位移用 transform、显隐用 opacity,把动画留在合成阶段,是代价最低也最稳的一条路。will-change 只在动画临开始前短暂使用,长期常驻会付出显存代价。
三条要点带走:
- 改布局属性的动画会传染,改绘制属性的动画会重画,只有合成属性最便宜;
- box-shadow 的过渡换成"预画阴影 + opacity 过渡",能省掉每帧重绘;
- 判断靠工具不靠感觉,Performance 面板里出现 Layout 就是没改干净。
下一步建议把项目里所有写了 top、left、width 的 transition 过一遍,逐个换成等价的 transform 写法。渲染细节记不清时,查 CSS 参考手册 里属性是否触发重排的说明。
常见问题
Q:为什么改 transform 比改 top 快?
A:动画卡顿通常就卡在这一步。transform 只影响合成阶段,元素已经被画成位图,换个变换矩阵就行;top 会改变布局,浏览器要重新计算元素及其影响范围内所有节点的位置。
Q:will-change 写了就能加速吗?
A:不能滥用。它会让浏览器提前把元素提升为独立层,层数多了显存占用上升,反而更容易卡。只在动画即将开始的元素上临时加,结束就移除。
Q:opacity 动画会不会触发重绘?
A:不会。元素在自己的层里已经画好了,改 opacity 只是调整合成时的混合系数。前提是它确实被提升到了独立层,用 Layers 面板可以确认。
Q:box-shadow 一定要换成伪元素吗?
A:看数量和模糊半径。单项的浅阴影直接过渡问题不大;列表页里几十项同时做阴影过渡,重绘面积大,换成伪元素加 opacity 更稳。
Q:为什么加了 transform 之后 fixed 定位的子元素跑位了?
A:transform 会创建新的包含块,内部 fixed 元素会以它为参照而不是视口。修复办法是把这个子元素移出动画元素,或者改用它自己的 transform 定位。
延伸学习
想把动画性能这块练熟,可以按这个顺序来:
- 读一篇相关笔记 CSS 动画,把过渡与关键帧的写法看清;
- 想系统补一遍基础,HTML5+CSS3 可以跟一遍;
- 想快速验证属性值,用 CSS代码生成器 生成后对照改。

TRAE-AI编程



