【每日一面】CSS 动画性能

2026-10-24 24 min 8374 字 -- 次阅读
摘要

动画卡不卡,看改动落在渲染流水线的哪一段:收敛到 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 好在哪?

答:

  1. 时机上,rAF 回调在浏览器每次重绘前执行,天然对齐屏幕刷新率,setInterval 固定间隔,与绘制节奏不同步,会出现同一帧改多次或整帧被错过。

  2. 节能方面,页面不可见时(切标签、最小化)rAF 自动暂停,setInterval 在后台会节流(这玩意儿有时候会在你轮询的时候坑你一把),但是还会运行。

  3. 时间驱动,而不是帧驱动,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-shadowPaint → Raster → Composite中,重绘 + 光栅化,不重排
width / height / paddingLayout → Paint → Raster → Composite高,几何重排连锁到子树
left / top / marginLayout → 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 动画。

css
/* 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 动画循环,用时间戳消除累积误差。

javascript
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:批量读写,避免强制同步布局。

javascript
// 反例:读 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 的加与撤。

javascript
// 动画前加、结束后撤,避免合成层长期占用显存
panel.classList.add('animating');           // .animating { will-change: transform }
panel.addEventListener('transitionend', (e) => {
  if (e.propertyName === 'transform') {
    panel.classList.remove('animating');
  }
}, { once: true });

示例 5:动画面向合成层的属性清单。

css
/* 推荐:可由合成器处理,不触发布局 */
.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 / animationWeb Animations APIrAF + 逐帧 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 个元素同时做淡入,怎么保证不掉帧?

    答:

  1. 先减数量,只对进入视口的元素做动画(通过IntersectionObserver 触发),离屏元素不参与,这是最有效的一步。

  2. 再挑属性,用 opacity(合成)而非 visibility / display(重绘或布局)。

  3. 再看层,不要给 100 个元素各自都加上 will-change / translateZ(0),这样会制造出 100 个合成层,导致显存与栅格化开销爆炸,可改为提升共同父层或分批启动。

  4. 最后错峰启动,在同一帧启动上百个动画会让首帧变成一次长任务,我们可以将动画分散到不同的时间点开始启动,就和在单位或学校吃饭一样,你错开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) 这种问题解决上,会给面试官耳目一新的感觉。

评论