最近帮朋友排查了一个"网站上动画很卡"的问题,不查不知道,一查吓一跳。那个页面本身没什么复杂逻辑,就是全屏背景飘着几百个粒子、几个渐隐渐显的弹层、一个无限旋转的加载转圈,结果在 Chrome 里帧率掉到 20 帧左右,鼠标滚动都跟着一卡一卡的。当时第一反应是"是不是 JS 里有死循环",看了半天代码也没找到明显问题,直到打开 Performance 面板录了一段,才发现瓶颈根本不在于 JS 逻辑,而是一堆看似无害的 CSS 动画属性,把浏览器的渲染管线硬生生拖垮了。
这事让我觉得挺值得单独写一篇的。CSS 动画性能优化,很多人以为"加一行 transition 就完事了",实际上想让动画如丝般顺滑,需要搞清楚浏览器每一帧是怎么工作的、哪些属性动起来便宜、哪些属性动起来要命,以及真遇到卡顿时应该用什么方法一层层把瓶颈揪出来。这篇文章会从我自己的排查经历出发,把 CSS 动画的性能原理、实操技巧、移动端注意点和排查工具完整说一遍,适合正在做活动页、数据大屏、H5 动画、Loading 动效,还有被"Chrome 里动画很卡"折磨过的人参考。
1. 卡顿不是玄学:先看懂浏览器每一帧的渲染路径
不夸张地说,90% 的 CSS 动画卡顿问题,根源都是开发者完全不知道浏览器在动画过程中做了什么。你改一个width,浏览器可不只是"把宽度变一下"那么轻松,它背后触发了一整套流程。理解了这套流程,后面所有优化手段都不用死记硬背,因为你能自己推理出"这个属性能不能碰"。
1.1 渲染管线的四个阶段
当一帧画面被绘制出来时,浏览器大致会走下面这几步:
- Style(样式计算):把 CSS 规则和元素已有的样式做匹配,算出每个元素最终生效的样式值。比如你改了
background-color,这一步就要重新算一遍。 - Layout(布局/重排):根据样式计算每个元素的位置和尺寸。只要影响到几何信息(宽高、位置、字体大小等),这一步就逃不掉。页面越复杂,这一步越贵。
- Paint(绘制/重绘):把文字、颜色、阴影、边框这些像素画出来。比起 Layout,这步稍轻一些,但依然是在 CPU 上逐像素操作。
- Composite(合成):把已经画好的图层组合成最终画面,交给 GPU 显示。这一步最轻,因为 CPU 不怎么参与,GPU 自己就能搞定。
用个生活化的类比吧:画一幅多人合影的油画,Style 阶段是确定"谁站在哪、穿什么颜色的衣服",Layout 阶段是铺画布、打格子、把每个人的位置量出来,Paint 阶段是用画笔一点点把每个像素涂上去,Composite 阶段是给这幅画覆一层膜,然后整体端起来展示。如果只是把整幅画往左挪一点,你根本不需要重新打格子、重新涂颜料,直接把整块画布端过去就行了——对应到浏览器里,这就是合成阶段专门干的事。但如果你要求画里一个人的"手臂长度"变长,那就得重新打格子、重新画那个区域,代价完全不同。
1.2 每一帧的预算只有 16.7ms
现在的显示器大多是 60Hz 刷新率,也就是每秒显示 60 帧,留给每一帧的时间大约是 16.7ms。这 16.7ms 不是只给 CSS 动画用的,它还包括浏览器自身的开销、JS 执行、样式计算、网络回调等等。也就是说,如果动画本身占据了超过 10ms 的渲染时间,留给其他工作的余量就很少了;一旦整帧超过 16.7ms,显示器就会丢帧,表现就是肉眼可见的卡顿。
高刷屏(120Hz)就更紧张,一帧预算只有约 8.3ms。这也是为什么同一套动画在普通屏上感觉还行,换到高刷屏反而暴露问题——不是高刷屏变差了,而是它每帧要求你干得更快,你的动画在 60Hz 下看似刚好够用,到了 120Hz 下就超预算了。
1.3 优化优先级:从"少干活"到"换条路走"
理解了渲染管线之后,性能优化的思路就很清晰了。每次写动画前你都可以问自己三个问题:
- 这个动画能不能不触发 Layout?(能不重排就不重排)
- 如果必须触发 Layout,能不能缩小影响范围?(避免整个页面重排)
- 能不能只走 Composite?(只做合成,性能最好)
最优路径永远是"只动合成阶段、不动 Paint 和 Layout"。而 CSS 里能满足这个条件的属性非常少,下一节详细说。
2. 为什么只有 transform 和 opacity 能"免费"动
很多人可能听过"动画只用 transform 和 opacity"这个说法,但没人跟你说透为什么。这里值得花一整节讲明白,因为你一旦知道了底层原因,就再也不会写出left: 0 → left: 500px这种动画了。
2.1 从 left/top 改为 transform:一次立竿见影的替换
先看一组最常见的反面教材:
/* 不推荐:每一帧都要触发布局 */ .box { position: absolute; left: 0; top: 0; transition: left 0.3s ease, top 0.3s ease; } .box.move { left: 300px; top: 200px; }left和top属于几何属性,它们一变,浏览器就得重新做 Layout,算出所有可能受影响的元素位置,然后还要 Paint、Composite。就算只是一个元素在动,浏览器也没法只重算这一个元素,它必须检查整个页面的布局是否有连锁变化。
换成transform就完全不一样了:
/* 推荐:只走合成阶段 */ .box { transform: translate(0, 0); transition: transform 0.3s ease; } .box.move { transform: translate(300px, 200px); }transform触发的是合成器合成,浏览器会把这个元素作为一个独立图层,在 GPU 上直接做位移,不碰 Layout、不碰 Paint。实际体感就是:同样的位移效果,后者帧率稳如老狗,前者在复杂页面里很容易掉帧。
不只是位移。想让元素变大缩小,优先用scale而不是width/height;想让元素旋转,就用rotate,不要靠改变坐标来"硬凑"。记住一个口诀:凡是能通过transform完成的视觉变化,就不要去改布局属性。
2.2 opacity 动画为什么也便宜
opacity是另一个适合做动画的属性。原因在于它只影响透明度的合成,不需要重新布局,也没有复杂的重绘。你把元素的 opacity 从 1 渐变到 0,浏览器只需在合成阶段调整这个图层的透明度参数即可,成本极低。
这直接影响了动画设计思路。比如一个常见的"弹层渐隐渐现"效果:
/* 低成本写法 */ .modal { opacity: 1; transform: scale(1); transition: opacity 0.25s ease, transform 0.25s ease; } .modal.hide { opacity: 0; transform: scale(0.96); }对比很多新手容易写的display: none → block配合height过渡——既难看又昂贵。用opacity + transform组合,几乎覆盖了所有"淡入淡出 + 轻微缩放"的 UI 需求。
顺便说一句,如果你想让元素隐藏时完全不响应点击,可以在过渡结束后设置visibility: hidden,它不会触发 Layout/Paint,只是让元素不可见、不接收事件,和display: none在性能上完全不是一个量级。
2.3 will-change 的正确用法,以及我怎么被反噬的
will-change这个属性是个典型的"双刃剑"。它告诉浏览器"某个元素将来会变化",让浏览器提前针对这个元素创建合成层,避免动画开始的那一刻才临时提升图层。理论上很好,但滥用的人特别多,最常见的错误写法是:
/* 反面教材:一上来就给所有元素加 */ * { will-change: transform; }我的建议是:在核心动画元素上,按需使用will-change。比如你知道某个拖拽卡片会被频繁拖来拖去,可以给它加will-change: transform;一个几千个元素的列表,你给每个子项都加上,就是把 GPU 显存当玩具,移动端直接闪退都不奇怪。
我也不建议为了"求稳"把translateZ(0)这种 hack 到处加。这招在七八年前用来骗浏览器开启 GPU 加速,但在现代浏览器里,GPU 加速已经不是"开了就一定好"的事了。图层越多,合成耗时越长,显存占用越大。盲目加图层,最后会变成"动画是流畅了,页面一打开就白屏"。
2.4 图层爆炸:别让每个元素都变成 GPU 的负担
一个容易踩的隐藏坑是"图层爆炸"(Layer Explosion)。当一个页面里有几十上百个元素都因为will-change、translateZ(0)或position: fixed等原因被提升为独立合成层时,GPU 的内存压力会指数级上涨。尤其在大屏可视化、粒子背景这类场景,几百个粒子如果每个都独立成层,低端手机的浏览器几乎当场死亡。
我自己处理过一个案例:一个粒子背景 Canvas 动画不卡,但粒子是用 DOM 元素做的,400 个div每个都加了will-change: transform,结果在普通安卓机上帧率只有 8 帧。把will-change去掉之后反而好了很多,因为浏览器不再为每个粒子维护独立图层,它把同一个合成层里的元素一次性画完再整体变换,开销反而更小。
这里要提醒的是:"减少图层"和"增加图层"不是非黑即白。理想状态是让需要频繁独立变化的元素单独成层,让静态元素待在同一层里。用 Chrome DevTools 的 Layers 面板可以直观看到当前页面生成了哪些图层,逐个检查有没有莫名其妙被提升的。
3. 实测排查:一个在 Chrome 里很卡的全屏 Loading 动画
理论讲完了,来个真实场景吧。这也是我开头提到的那个"Chrome 网页动画展示的时候很卡"的实际排查过程。当时那个全屏 Loading 动画的结构大概是这样的:一个全屏遮罩、中间一个旋转的圆环、圆环周围一圈扩散涟漪、底部一行文字"加载中...",粒子在后台飘。
3.1 用 Performance 面板定位瓶颈的完整步骤
我先在 Chrome 里打开 DevTools,切到 Performance 面板,点击录制按钮,然后在页面上让 Loading 动画播放几秒,停止录制。录完会得到一条瀑布图,具体怎么读这张图,我这几年总结出的经验是:
- 看 FPS 曲线:Performance 上方会显示帧率,如果长时间红条、绿条稀疏,说明掉帧严重。
- 看 Main Thread(主线程):这段瀑布图里有很多色块,黄色是 JS 执行,紫色是样式计算/布局,绿色是绘制。如果紫色和绿色格外宽,问题多半出在渲染阶段。
- 看颜色块里的具体任务:点开最宽的那个色块,DevTools 会告诉你具体是哪个函数、哪个样式属性耗时最多。有一次它直接告诉我"Recalculate Style 耗时 42ms",这方向立马就明确了。
那次录制的结果非常直观:主线程上紫色区块(Layout)占了一大片,刷新 animate 的时候每分钟都在重复大量布局计算。单独看某个动画帧,从样式计算到绘制花了 30ms 以上,远超 16.7ms 的预算。
3.2 定位到"Layout"后,我做了什么
既然问题集中在 Layout,接下来就是找出"哪些属性在触发布局"。我在 Performance 里点开每一项 Layout 任务,看它涉及的 DOM 节点是什么。结果很快锁定在"涟漪扩散"效果上。
原代码大概长这样:
/* 有问题的涟漪扩散写法 */ .ripple { position: absolute; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.6); animation: ripple 1.5s ease-out infinite; } @keyframes ripple { from { width: 20px; height: 20px; opacity: 1; } to { width: 240px; height: 240px; opacity: 0; } }每帧都在改width和height,意味着每一帧都要重新计算布局。一个圆环还好,可这个 Loading 上有 6 个涟漪圆环依次扩散,再加上粒子飘动本身也有布局计算,主线程直接被拖垮。
改法特别简单:用transform: scale()替代width/height。把圆环固定为 240×240 的盒子,初始时transform: scale(0.1),动画结束时transform: scale(1)。这样每一帧只走合成,不碰布局。配合opacity渐变,视觉上跟原来一模一样,但性能天差地别。
/* 推荐写法 */ .ripple { position: absolute; width: 240px; height: 240px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.6); transform: scale(0.1); opacity: 1; animation: ripple 1.5s ease-out infinite; } @keyframes ripple { from { transform: scale(0.1); opacity: 1; } to { transform: scale(1); opacity: 0; } }3.3 优化后的数值变化
改完以后我又录了一次 Performance,主线程上的紫色 Layout 区块几乎消失,只剩少量样式计算,动画帧耗时从 30ms 降到了 8ms 左右,FPS 基本稳定在 60。同样的代码放到低端安卓机上,虽然帧率有波动,但起码不卡到影响交互了。
那次排查给我留下了一个非常深的印象:很多"性能问题"根本不是硬件问题,而是你在让浏览器做它最不擅长的事。把长跑改成短跑,把每个动画属性放到它该待的渲染阶段,效果立竿见影。
4. 动画编排里的隐性开销:delay、fill-mode 与 JS 的交接
性能不能只看单个动画,动画与动画之间的编排、属性设置、和 JS 的配合,也会影响到整体流畅度。热搜词里有一组特别符合这个主题:"css3动画延迟和完成后状态的保持"和"第3关:css3动画执行次数和逆向播放"。这两个看着像是基础关卡,实际踩坑的人多得很。
4.1 animation-fill-mode 忘了写,动画"显示不全"的真相
我经常收到"我做的动画为什么最后回弹到初始状态/直接消失"这类问题,最后十有八九是animation-fill-mode没设置。fill-mode控制动画开始前和结束后,元素是否保持关键帧里的状态。
/* 元素会在动画结束后回到初始状态,弹层永远"显示不全" */ .mask { animation: fadeIn 0.3s ease; } /* 解决方法:让动画结束状态保持住 */ .mask { animation: fadeIn 0.3s ease forwards; }forwards表示动画结束后保持to关键帧的状态;backwards表示动画开始前,先应用from关键帧状态(配合delay使用时很有用);both就是两个都管。
这里有一个性能相关的点:当大量元素使用animation-fill-mode: forwards时,浏览器需要额外维护"动画结束后的最终样式状态",这会让样式计算阶段的花费小幅上升。所以我的建议是:非必要不滥用forwards。如果动画只需要做一次而且不影响后续交互,让它自然结束也没问题;但如果弹层动画结束后马上要显示文案,那还是老老实实加forwards,别为了省这点开销导致视觉 bug,得不偿失。
4.2 执行次数、逆向播放的真实消耗
animation-iteration-count和animation-direction也是很多人随手写、但从不思考性能影响的属性。比如一个无限循环的旋转加载动画,标准写法是:
.spinner { animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }这个写法性价比极高,因为涉及transform: rotate,同样只走合成。但如果你用animation-direction: alternate做"来回摆动"效果,要意识到:每一次往反方向运行,浏览器都要重新计算开始状态。虽然对于单个元素不算大开销,但如果一个页面有几十个元素同时做交替动画,Main Thread 的样式计算压力会翻倍。遇到这种情况,可以考虑拆成两个独立动画,或者用animation-delay错开启动时间,避免所有元素在同一帧进入状态切换。
另一个细节是:animation的简写顺序有隐藏的坑。比如animation: 1s ease spin这种写法,可能因为animation-name没写而根本不执行。我习惯写成animation: spin 1s ease infinite,名字放最前,不容易忘。
4.3 用 requestAnimationFrame 配合 CSS 动画的正确姿势
有些动画光靠 CSS 很难做,比如需要跟随鼠标位置移动的元素、需要实时计算距离后调整位移的场景。这时候很多人直接用 JS 改样式,每帧都写el.style.left = ...,性能直接爆炸。
我的做法是尽量把"需要 JS 计算的数值"转换为给 CSS 用的变量,然后让 CSS 动画去消费它:
// 每一帧只更新 CSS 变量,不直接操作布局属性 function onPointerMove(e) { const x = e.clientX - rect.left; const y = e.clientY - rect.top; el.style.setProperty('--dx', x + 'px'); el.style.setProperty('--dy', y + 'px'); }配合 CSS 里:
.follower { transform: translate(var(--dx), var(--dy)); }这里的关键是:JS 只负责算数值,CSS 负责把数值映射到合成器友好的transform上。这样即使 JS 每帧都在运行,它触发的也只是样式更新和合成,不会触发 Layout 重排。如果你需要在动画过程中读取元素的几何属性(比如offsetTop),尽量在动画开始前批量读好,避免"读属性→改样式→读属性→改样式"这种强制同步布局的操作,那是最容易引发 Layout thrashing 的写法。
5. 移动端是另一套玩法:内存、像素密度与动画规模
如果你做的只是桌面端页面,前面那些优化其实已经够用。但移动端才是 CSS 动画性能最残酷的试金石。同样一份代码,桌面 Chrome 跑得飞起,安卓微信内置浏览器一打开就卡成幻灯片,这是很常见的情况。
5.1 为什么移动端同一个动画更卡
移动端和桌面端相比有几个天生劣势:
- GPU 性能差异巨大:中低端手机 GPU 的显存带宽和处理能力跟桌面独立显卡没法比,合成层太多、图层太大都会直接压垮它。
- 内存更紧张:每个合成层都要占显存,页面动辄一两百个层,移动端浏览器容易触发内存警告。
- 设备像素比(DPR)高:现在手机普遍是 2x 甚至 3x 屏幕,意味着同样的 CSS 像素,GPU 实际要处理的物理像素是 4 倍甚至 9 倍。一张 1920×1080 的图,在 3x 屏上可能相当于要处理 5760×3240 的像素量。
- CPU 频率易降频:长时间高负载渲染会让手机发烫、降频,帧率进一步崩溃。
所以移动端的性能优化,很多时候不是"让动画更快",而是"让动画更省"。省 GPU 显存、省内存带宽、省 CPU 时间。
5.2 涟漪光圈扩散的正确实现方式
热搜词里有个"css涟漪光圈扩散",刚好用来做移动端优化案例。实现涟漪效果最直观的思路是两个圆叠加,从中心向外扩散并淡出。我见过不少实现是用border-radius: 50%加修改width/height,这在桌面端偶尔还能撑住,但在移动端低端机上是灾难。
正确的移动端做法是:
.ripple { position: absolute; width: 200px; height: 200px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.7); opacity: 0; transform: scale(0); animation: ripple 1.8s ease-out infinite; } @keyframes ripple { 0% { opacity: 0.7; transform: scale(0); } 100% { opacity: 0; transform: scale(1); } }所有属性都只涉及opacity和transform。用多个元素设置不同的animation-delay,就能制造连续波纹的效果。这么一改,6 个圆环同时扩散,在千元安卓机上也能保持流畅。移动端优化并不需要什么黑科技,"把属性选对"本身就是最大的优化。
5.3 大规模动画时的降级策略
如果你的页面必须在移动端跑一个包含大量动画元素的效果,比如满屏飘落的花瓣、密集的星星闪烁,我建议提前设计降级策略,而不是等用户机器卡了再临时想办法。
我的降级策略一般按级别来:
- 基础层:只保留核心动效,去掉非关键动画元素,比如花瓣从 50 片降到 10 片。
- 过渡层:如果设备内存较低(可以根据
navigator.deviceMemory粗略判断),关闭所有大范围位移类动画,只保留透明度变化。 - 无障碍层:适配
prefers-reduced-motion: reduce,当系统开启"减少动态效果"时,直接用过渡代替平滑动画,或者干脆禁用动画。这不仅是性能策略,也是体验策略。
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } }这块代码我基本都会放到项目的全局样式里,算是一个保险丝。
6. 排查工具与可直接抄的性能清单
介绍完原理和案例,最后分享一些我日常排查"Chrome 网页动画展示很卡"这类问题时常用的工具,以及一份我自己照着做过的优化清单。这部分对实战最有帮助。
6.1 DevTools 里我常用的四个面板
要说排查 CSS 动画卡顿,我日常使用最多的不是插件,而是 Chrome DevTools 自带的几个面板:
Performance(性能记录):录一段交互或动画,看 Main Thread 上每个任务的耗时分布。这是定位"到底是 JS 慢、还是样式计算慢、还是绘制慢"最直接的手段。注意录制时尽量让动画持续 3 秒以上,样本越多越容易看出问题。
Rendering(渲染标签):在 DevTools 的更多工具里可以打开。我比较常用的是 FPS meter(实时帧率)、Paint flashing(绘制闪烁,黄色高亮区域就是正在进行绘制的地方,如果整个屏幕都在闪,说明每帧都在大面积重绘)、Layer borders(显示合成层边界,可以看到页面到底生成了多少层)。
Layers(图层面板):逐层查看页面的合成层。如果发现某个静态大块区域被独立成层了,十有八九是加过will-change或旧式translateZ(0)hack。这个面板在排查"为什么页面很占显存"时特别好用。
Coverage(覆盖率):这个严格来说跟动画无关,但我排查性能问题时也经常看——因为 CSS 文件里冗余样式太多,会影响样式计算阶段的时间。覆盖率面板可以看到哪些 CSS 规则根本没被用到,删掉冗余样式后,动画整体流畅度也会有微妙提升。
表格总结一下:
| 面板 | 主要作用 | 常用场景 |
|---|---|---|
| Performance | 定位卡顿发生在哪个渲染阶段 | 动画持续掉帧、主线程任务过重 |
| Rendering | 实时看帧率、绘制区域、图层边界 | 快速确认是否在逐帧大面积重绘 |
| Layers | 查看元素合成层及数量 | 排查图层爆炸、显存占用过高 |
| Coverage | 检查未使用的 CSS 规则 | 动画前的首屏性能清理 |
6.2 要不要用动画库:原生 CSS、GSAP、anime.js 的取舍
很多人在"要不要引入动画库"这件事上纠结。我的看法是:能用原生 CSS 实现的动画,尽量用原生 CSS;动画逻辑复杂到 CSS 写不动,再考虑库。
原生 CSS 动画的优势是零依赖、性能好、和渲染引擎配合最紧密。适合做 hover 过渡、进入离场、循环 Loading、简单关键帧动画。缺点是没有时间轴上"编排多个元素精确接力"的能力,写复杂积木动画容易乱。
GSAP 是专业动画库,它最大的价值不是"让每个动画更快",而是提供了精细的时间线控制、缓动函数、ScrollTrigger 联动滚动等能力。它内部已经对 transform 做了很好的封装,用gsap.to(el, { x: 300, y: 200 })底层也是走合成器属性,性能并不比原生 CSS 差。
anime.js 和 Motion One 也各有特点,前者 API 小、适合小项目,后者基于 Web Animations API、体积也小。如果你只是需要一个 Loading 动画或者一个点击反馈,完全没必要引库;但如果你的业务里有一整套交互动效系统,自己手搓时间线又容易出 bug,那选一个成熟的库是更省心的选择。
6.3 一份"挂墙式"自查清单
这部分是给喜欢照做的读者准备的。这段时间我会把下面这份清单贴在项目文件里,每次做动效前逐条过一遍:
- [ ] 动画属性是否只用了
transform和opacity? - [ ] 有没有在动画中用
width/height/margin/padding/left/top/font-size这类布局属性?如果有,换成scale或translate能不能达到同样效果? - [ ]
will-change是否只在关键动画元素上使用?有没有给大量子元素统一加? - [ ] 有没有给不必要的地方加
translateZ(0)或transform: translate3d(0,0,0)这类 hack? - [ ] 动画元素数量在低端机上能不能撑住?宁可减少元素个数,也不要让每个元素都吃独立图层。
- [ ] 循环动画有没有用
animation-delay错开启动时间,避免所有元素同一帧开始计算? - [ ] JS 里有没有在动画过程中反复读取
offsetTop/offsetWidth等几何属性?如果有,改用缓存值或ResizeObserver。 - [ ] 有没有写
@media (prefers-reduced-motion: reduce)降级方案? - [ ] 有没有放过
will-change: opacity在动画结束之后清理掉? - [ ] 最后在 Performance 面板录一段,主线程的 Layout/Paint 开销是否接近 0,是否还有无意义的样式计算?
我自己在实际项目中就是拿这份清单一条条检查的,它对"优化完不知道有没有效果"的焦虑特别有效。
如果想再进阶一点,可以试试给动画元素加contain: layout paint,这能告诉浏览器"这个元素内部布局变化不要影响外部",适合弹层内部有大量子元素动画的场景。不过这个属性对兼容性有一定要求,生产环境使用前建议先查一下支持情况。
CSS 动画性能优化这条路说穿了就一句话:别让浏览器干多余的活。你尊重渲染管线,画面就丝滑给你看;你无视它,它就用掉帧和卡顿向你抗议。只要把 transform、opacity 这两个好朋友用熟,学会用 DevTools 快速定位瓶颈,再记住移动端要克制、要有降级,大部分动画性能问题都能解决。