【每日一面】CSS 动画性能
动画卡不卡,看改动落在渲染流水线的哪一段:收敛到 transform/opacity、跑在合成器线程、压住重绘范围,才是治本而非加个 translateZ(0)。
“首页轮播图滚动时掉帧,你从哪几个方向排查?”这道题看着是 CSS 题,实际考的是对渲染流水线的理解:一帧是怎么被浏览器做出来的,动画改动落在流水线的哪一段,这决定了它要花多少成本。这个答案就藏在 JS → 样式 → 布局 → 绘制 → 合成这条链上。
基础问答
问:为什么用 transform 做动画比改 width、left 更流畅?
答:渲染流水线分 JS、样式计算、布局、绘制、合成五段,改动的属性所处位置,决定了要重跑多少内容。width / height 改的是几何尺寸,触发从布局开始的全部流程;而 left / top 改的是位置,同样走布局。transform 和 opacity 不参与布局与绘制,若元素在独立合成层上,动画可以只走合成阶段,甚至交给合成器线程执行,这样一来,主线程再忙也不会掉帧。
问:will-change 是干什么的?该怎么用?
答:它是给浏览器提前预告元素将要变化的属性,让提升合成层、预栅格化在动画开始前完成,避免首帧卡顿。
这个属性的用法是,只给真正要动的属性、只在动画临近时加、动画结束撤掉。will-change: transform 会把元素提升为独立合成层并常驻内存;对列表里几百个元素无脑加这个配置,会导致内存与合成开销增大,反而拖垮帧率。
will-change 不决定动画是否流畅,只影响动画开始得是否平滑。
问:CSS animation 和 transition 有什么区别?
答:transition 是状态驱动的补间(在已知起止状态之间自动生成中间状态),需要属性值发生变化(:hover、类切换)后才执行,有明确的起止值,本身不能自动播放;animation 是关键帧驱动的,用 @keyframes 定义任意中间状态,可循环、可延迟、可反向、可自动播放,不依赖值变化。
性能上两者没有本质差异:只要动的是 transform / opacity 且元素有独立合成层,都能跑在合成器线程。
问:requestAnimationFrame 做动画,比 setInterval 好在哪?
答:
-
时机上,rAF 回调在浏览器每次重绘前执行,天然对齐屏幕刷新率,setInterval 固定间隔,与绘制节奏不同步,会出现同一帧改多次或整帧被错过。
-
节能方面,页面不可见时(切标签、最小化)rAF 自动暂停,setInterval 在后台会节流(这玩意儿有时候会在你轮询的时候坑你一把),但是还会运行。
-
时间驱动,而不是帧驱动,rAF 回调收到的是高精度时间戳,可按实际经过时间计算位移(通过时间间隔可以计算相对某个时间点的位置),如果用固定增量方式,如每帧 +5px,在 120Hz 屏上会变成两倍速。所以我们做动画循环时首选 rAF。
问:什么是合成层?哪些操作会创建它?
答:合成层是浏览器为元素单独分出的渲染层,拥有独立位图纹理,合成器可以对纹理做位移、缩放、透明度变化,不重跑布局与绘制。常见创建条件:3D 变换(translateZ(0)、translate3d)、
will-change: transform / opacity、opacity 小于 1 的动画、filter、position: fixed、video / canvas、iframe。关键在于:合成层不是免费的,每层都要占显存、栅格化、上传纹理。问:怎么排查 CSS 动画卡顿?
答:
先定位,Performance 面板录制动画过程,看掉帧发生在主线程还是合成器线程,如果主线程有长任务说明是 JS / 样式 / 布局阻塞,合成器在空转说明动画没跑在合成层上。
再看层,Layers 面板检查元素是否被提升、层的数量是否失控(层爆炸同样会反向拖慢)。
最后改属性,把动画属性收敛到 transform / opacity,避免动画期间读取 offsetTop / offsetWidth 触发强制同步布局,必要时用 contain 缩小重绘范围。
扩展延伸
渲染流水线
浏览器把一帧拆成六个阶段,每一阶段的产物是下一阶段的输入:
flowchart LR
A["JS<br/>触发属性变更"] --> B["样式计算<br/>Recalc Style"]
B --> C["布局<br/>Layout · 回流"]
C --> D["绘制<br/>Paint · 重绘<br/>生成绘制指令"]
D --> R["光栅化<br/>Raster<br/>绘制指令 → 像素/纹理"]
R --> E["合成<br/>Composite"]
B -.->|"transform / opacity<br/>有独立合成层且层内容不变时<br/>跳过 Layout / Paint / Raster"| E
style C fill:#fdecea,stroke:#c0392b
style D fill:#fff4e5,stroke:#e67e22
style R fill:#eef2ff,stroke:#4f46e5
style E fill:#e8f5e9,stroke:#1a7a3a,stroke-width:2px
改动属性和它触发的阶段大致对应如下:
| 改动属性 | 触发的阶段 | 相对成本 |
|---|---|---|
transform / opacity | 合成(元素有独立合成层且层内容不变时,跳过 Layout、Paint、Raster) | 最低,可交合成器线程 |
color / box-shadow | Paint → Raster → Composite | 中,重绘 + 光栅化,不重排 |
width / height / padding | Layout → Paint → Raster → Composite | 高,几何重排连锁到子树 |
left / top / margin | Layout → Paint → Raster → Composite | 高,位置重排 |
font-size / 内容撑高 | Layout → Paint → Raster → Composite | 最高,可能引发多次回流 |
注意几个要点:
-
布局是成本大头。一次回流会连锁影响祖先与后代,改动几何属性的元素越多,这一段的耗时越长。
-
transform / opacity 是唯一能跳过前两段主要工作的属性,但前提是元素有独立合成层;没有独立层时,transform 变化仍会触发重绘。
-
大幅缩放会带来额外成本,层内容按初始尺寸栅格化,放大后纹理被拉伸模糊,浏览器需按新尺寸重新光栅化,这一瞬就是动画里的“模糊/闪烁”。
浏览器渲染一帧是有时间预算的。60Hz 屏幕每帧间隔约 16.7ms,但这 16.7ms 不是全留给动画,而是要在主线程上分给JS 执行、事件处理、样式计算、布局、绘制、部分光栅化和合成提交这些任务的,真正留给主线程动画代码的余量,比想象中少。而 120Hz 屏幕就更严格了,每帧只有约 8.3ms。当某一帧总耗时超出这个时间,这一帧就“丢掉了”,在用户角度上感知就是掉帧、卡顿。所以动画优化的目标不是单纯更快,而是,把每帧耗时减少,并且让波动尽量小。 偶发的长任务通常比整体略慢更致命,因为它会造成明显的顿挫感。
这里要结合前面的渲染流水线理解:
-
如果动画走主线程:
JS → Style → Layout → Paint → Raster → Composite,每一段都在抢这 16.7ms / 8.3ms。 -
如果动画是
transform/opacity,且元素在独立合成层、层内容不变,它可以跳过 Layout、Paint、Raster,直接由合成器线程处理。它不占主线程帧预算,但也不是完全免费的,仍会占合成器和 GPU 的时间。强制同步布局,也叫布局抖动。典型触发方式是在动画循环里“读—写—读—写”交替:
javascript// 不好:读 offsetWidth 和写 style.width 交替 for (const el of items) { el.style.width = el.offsetWidth + 10 + 'px'; }offsetWidth是布局属性。浏览器本来可以先把一批样式变更攒起来,等合适时机统一布局。但你在写样式之后又立刻读布局属性,浏览器为了拿到准确值,只能马上回流。这样操作的结果就是本该批处理的样式变更,被拆成多次布局。我们在实践中应该先批量读,再批量写。
合成层
元素被提升为合成层后,内容栅格化成位图纹理;在动画期间对 transform/opacity 的处理只是对纹理做几何变换或混合,既不重跑布局绘制,又能由合成器线程独立完成。这是主线程被长任务卡住,动画却依然流畅的原因。
但同样也是有代价的:
-
内存:每层一张纹理,尺寸随元素大小与 devicePixelRatio 增长。一个全屏层在 2x 屏上就是数 MB。
-
栅格化与上传:新层首次要栅格化并上传 GPU,层越多,动画启动越慢。
-
层爆炸:父元素被提升后,满足条件的子元素可能各自成层,数量失控后显存与合成开销反而拖垮帧率。
既然存在代价,也就意味着不能随意的将元素提升到合成层,
will-change作为最常用的提升手段之一,使用是就需要格外克制,它要加在即将发生动画的少量元素上、在动画前很短的窗口内加、在结束后立即撤掉。
动画方案选择
在动画方案的选择上,我这里有三条技术路线,他们的核心区别是执行线程与控制力:
-
CSS transition / animation:声明式、代码最少,transform / opacity 动画可跑合成器线程,但缺点是控制力弱,不好暂停、中间态也不好取到。
-
Web Animations API(WAAPI):
element.animate()返回 Animation 对象,可 play / pause / playbackRate / finished,与 CSS 动画底层同一套引擎,合成器线程执行时同样不受主线程阻塞影响。 -
rAF + 逐帧 JS:最灵活(物理模拟、手势联动),但跑在主线程,主线程一忙就掉帧,且每帧写样式必须避免强制同步布局。
能用声明式表达就用 CSS / WAAPI,让动画跑在合成器线程;只有需要运行时动态计算、与手势强耦合时才用 rAF。
我们落地时,可以参考这两个准则。
其一,用类切换而非逐帧写内联样式,因为每帧手工改
style会逐帧触发样式重算,而一次性切换 class 只做一次样式计算,这样就可以把变化交给 CSS 引擎处理。其二,尊重用户动效偏好,这既是无障碍的要求,也为我们顺带的节省了性能。
css/* 当用户在系统设置里开启减少动态效果时生效 */ @media (prefers-reduced-motion: reduce) { /* 选中所有元素以及它们的伪元素 */ *, *::before, *::after { /* 把动画时长压到接近 0,相当于关掉动画 */ animation-duration: .01ms !important; /* 把过渡时长也压到接近 0 */ transition-duration: .01ms !important; } }前庭功能敏感的用户会因大幅位移、视差与缩放动画感到眩晕,对这部分用户关掉动画,既照顾人,也省算力。
另外还有特殊场景特别值得我们关注,一个是 FLIP,另一个是滚动。
FLIP:专治布局驱动的动画。
像列表重排、展开收起这类动画天生要改几何,这个开销是很贵的。FLIP(First–Last–Invert–Play)的做法是,先记录元素首态与末态的布局(First / Last),用
transform把末态反转回视觉上的首态(Invert),再把它transform回 0 播放(Play)。全程只需要改一下transform,就把昂贵的几何动画转成廉价的合成动画。滚动联动动画:让滚动离开主线程。
滚动事件默认在主线程,监听器里做重活会阻塞滚动。用
{ passive: true }声明不调用preventDefault,浏览器便可跳过等待监听器直接滚动。但更彻底的是滚动驱动动画(animation-timeline: scroll()),把动画时间轴交给合成器,这样滚动与动画都可以不经过主线程。
代码示例
示例 1:left 动画 vs transform 动画。
/* bad:left 每帧触发布局 */
.box { position: absolute; left: 0; transition: left .3s; }
.box:hover { left: 200px; }
/* good:transform 不触发布局,有合成层时只走合成 */
.box { transition: transform .3s; }
.box:hover { transform: translateX(200px); }示例 2:rAF 动画循环,用时间戳消除累积误差。
let start = null;
const el = document.querySelector('.ball');
function step(ts) {
if (start === null) start = ts;
const p = Math.min((ts - start) / 1000, 1); // 按真实经过时间计算,跨刷新率一致
el.style.transform = `translateX(${p * 300}px)`;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);示例 3:批量读写,避免强制同步布局。
// 反例:读 offsetHeight 与写 style 交替,每次读都触发回流
els.forEach(el => { el.style.height = el.offsetHeight + 20 + 'px'; });
// 正例:先批量读,再批量写
const heights = els.map(el => el.offsetHeight);
els.forEach((el, i) => { el.style.height = heights[i] + 20 + 'px'; });示例 4:will-change 的加与撤。
// 动画前加、结束后撤,避免合成层长期占用显存
panel.classList.add('animating'); // .animating { will-change: transform }
panel.addEventListener('transitionend', (e) => {
if (e.propertyName === 'transform') {
panel.classList.remove('animating');
}
}, { once: true });示例 5:动画面向合成层的属性清单。
/* 推荐:可由合成器处理,不触发布局 */
.badge-enter { opacity: 0; transform: translateY(8px); }
/* 避免:触发布局或昂贵重绘 */
.panel-enter { height: 0; margin-top: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.2); }对比
按照这个系列的惯例,这里给一个对比表格
| 对比维度 | CSS transition / animation | Web Animations API | rAF + 逐帧 JS |
|---|---|---|---|
| 声明方式 | CSS 声明 | JS 命令式 | 手写帧循环 |
| 执行线程 | 合成器线程(transform/opacity 且有独立层时) | 同左 | 主线程 |
| 主线程阻塞时 | 合成动画不受影响 | 合成动画不受影响 | 掉帧 |
| 精细控制 | 弱(难暂停、难取中间态) | 强(暂停 / 调速 / seek) | 最强 |
| 代码量 | 最少 | 中 | 最多 |
| 适用场景 | 简单状态切换 | 需动态控制的交互动画 | 物理模拟、手势联动 |
| 主要坑 | 难中断 | 部分高级特性兼容性有差异 | 强制同步布局、累积误 |
我们在实现的时候,要优先让动画跑在合成器线程,主线程只留给必须实时计算的逻辑。 可以按照下面的流程来判定:
flowchart TD
A["要做一个动画"] --> B{"能用声明式表达?<br/>状态切换 / 固定关键帧"}
B -->|"是"| C["CSS transition / animation<br/>限 transform · opacity"]
B -->|"否"| D{"需要运行时暂停 / 调速 / 取中间态?"}
D -->|"是"| E["Web Animations API"]
D -->|"否:物理模拟 / 手势联动"| F["rAF + 逐帧 JS<br/>避免强制同步布局"]
C --> G["跑在合成器线程<br/>主线程阻塞不影响"]
E --> G
F --> H["跑在主线程<br/>需控制每帧耗时"]
style G fill:#e8f5e9,stroke:#1a7a3a,stroke-width:2px
style H fill:#fdecea,stroke:#c0392b
面试追问
追问1:动画 width / height 一定会卡吗?有没有优化办法?
答:width / height 触发布局,但并不是必然卡顿,这取决于动画面积、元素数量与设备性能。
优化路径:
-
能用
transform替代的位移、缩放,一律替代; -
必须改几何的(展开收起)用 FLIP 转成
transform动画; -
实在只能改布局的(文字自适应撑高),缩小作用范围(
contain: layout、隔离容器),把样式写入合并成一次,并避免动画期间读取布局属性。追问2:列表里 100 个元素同时做淡入,怎么保证不掉帧?
答:
-
先减数量,只对进入视口的元素做动画(通过IntersectionObserver 触发),离屏元素不参与,这是最有效的一步。
-
再挑属性,用 opacity(合成)而非 visibility / display(重绘或布局)。
-
再看层,不要给 100 个元素各自都加上 will-change / translateZ(0),这样会制造出 100 个合成层,导致显存与栅格化开销爆炸,可改为提升共同父层或分批启动。
-
最后错峰启动,在同一帧启动上百个动画会让首帧变成一次长任务,我们可以将动画分散到不同的时间点开始启动,就和在单位或学校吃饭一样,你错开5分钟就不会等很久。
追问3:will-change 到底该加在哪、什么时候撤?加多了会怎样?
答:加在即将发生 transform / opacity 动画的元素上,动画前很短窗口内加、结束后立即移除(transitionend / animationend)。加多了的直接后果是层爆炸:每层独立纹理,显存上涨、栅格化与合成开销上升,可能比不加还卡,而且浏览器对层数量与内存有隐式上限,超限时提升行为会被忽略,动画首帧反而掉帧。
追问4:动画结束后元素出现一瞬模糊 / 闪烁,为什么?怎么解?
答:常见于元素被提升为合成层后进行缩放动画,层内容按初始尺寸栅格化成纹理,放大时纹理被拉伸而模糊,浏览器需按新尺寸重新栅格化,这一瞬即“模糊 / 闪烁”。这也有对应的解法,就是让栅格化尺寸提前就位,在动画开始前挂上 will-change 或预处理 scale,使浏览器按目标尺寸栅格化,或者避免大幅缩放,只做位移与透明度,如果必须缩放,那就需要控制倍率并接受一次性重栅格化的成本。
追问5:轮播图滚动卡顿,给一套系统性排查路径。
答:
第一步量化,Performance 录制,确认是否有超单帧预算的帧、长任务落在主线程还是合成器。
第二步定位属性,确认动画改的是不是 transform / opacity,若是 left / top / width 立即替换。
第三步查层,Layers 面板看轮播项是否在合成层、层数是否异常,并排查动画循环里是否读 offsetWidth 造成强制同步布局。
第四步收敛范围,用 contain / content-visibility 限制重绘区域,离屏项暂停动画,必要时改用 WAAPI 或降低动画复杂度。
写在最后
动画性能的本质,是让每一帧只花在必要的阶段上:把改动收敛到合成阶段(transform / opacity)、把执行搬到合成器线程(CSS 动画 / WAAPI)、把范围压到最小(contain、离屏暂停、层数可控)。面试时说到这三点,动画卡顿这道面试题算是答到了本质,而不是停在加个 translateZ(0) 这种问题解决上,会给面试官耳目一新的感觉。