高性能 Web 动画
消除卡顿 Web 动画的头号原因:动画化那些会强制浏览器在每个帧上重新计算布局(重排)或重绘的属性。浏览器渲染分阶段进行——布局 → 绘制 → 合成。动画化 width、height、top、left、margin、padding、box-shadow 或 filter 会每帧重新运行布局和/或绘制,阻塞主线程。动画化 transform 和 opacity 则完全在合成器(通常是 GPU)上运行,跳过了布局和绘制,这正是动画在 60/120fps 下流畅的原因。
何时使用
当动画出现卡顿或掉帧时,当悬停/滚动效果感觉沉重时,当动画化大小/位置/阴影时,当布局变化需要平滑动画时(卡片重新排序、元素在容器间移动),当动画化 height: auto 时,或审查动画代码的性能时使用。
核心规则:只使用 transform 和 opacity
将每个“昂贵”的动画映射到对应的廉价替代方案。
| 动画化(昂贵) | 触发 | 替换为(廉价) |
|---|---|---|
width / height | 布局 + 绘制 | transform: scaleX()/scaleY() (+ FLIP 用于真实大小) |
top / left / margin | 布局 + 绘制 | transform: translate() |
box-shadow | 绘制 | 通过伪元素动画化 opacity 来持有阴影 |
filter: blur() | 绘制(沉重) | 通过 opacity 交叉淡入淡出两个图层,或谨慎使用 |
background-position | 绘制 | 在子图层上使用 transform: translate() |
color / background | 绘制 | 通常可接受;或交叉淡入淡出图层 |
通过 transform 实现位置和大小
/* 不好的做法:每帧都动画化布局 */
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
.box:hover { left: 200px; width: 200px; }
/* 好的做法:仅合成器 */
.box {
transition: transform 300ms ease;
transform: translateX(0) scaleX(1);
transform-origin: left center;
}
.box:hover { transform: translateX(200px) scaleX(2); }
scaleX 会扭曲内部内容(文本拉伸)。如需无损调整大小,请使用 FLIP。
通过伪元素透明度实现轻量阴影
动画化 box-shadow 每帧都会重绘一个大的模糊区域。相反,将阴影一次性绘制在 ::after 上,然后仅动画化其 opacity。
.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 28px rgba(0,0,0,0.35);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }
模糊阴影光栅化一次;悬停仅改变合成器的不透明度——在任何帧率下都流畅。
FLIP:轻量动画化布局变化
FLIP(First, Last, Invert, Play)仅使用 transform 来动画化布局变化(重新排序、调整大小、在容器间移动)。测量元素的位置(First)和将会到达的位置(Last),应用一个反向变换使其在视觉上看起来未移动,然后将变换动画化回恒等变换(Play)。DOM 最终处于其真实的最终布局;运动是纯合成器工作。
function flip(el, mutate) {
const first = el.getBoundingClientRect(); // First
mutate(); // 改变 DOM/布局
const last = el.getBoundingClientRect(); // Last
const dx = first.left - last.left;
const dy = first.top - last.top;
const sx = first.width / last.width;
const sy = first.height / last.height;
el.animate(
[
{ transformOrigin: 'top left',
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, // Invert
{ transformOrigin: 'top left', transform: 'none' }, // Play
],
{ duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
);
}
// 用法:动画化一个元素移动到一个新的网格位置
flip(card, () => targetContainer.appendChild(card));
Web Animations API animate() 在合成器上运行变换。对于多个元素,在所有*突变之前批量处理所有 getBoundingClientRect() 读取(参见下面的布局抖动)。
动画化 height: auto
height: auto 历来不可插值。现代和回退方法:
/* 现代方法(Chrome 129+/支持的浏览器):将尺寸关键字纳入动画 */
.panel {
interpolate-size: allow-keywords; /* 启用动画化到/从 auto */
height: 0;
overflow: clip;
transition: height 300ms ease;
}
.panel.open { height: auto; }
/* calc-size() 同样有效:height: calc-size(auto, size); */
/* 当前可靠的回退方法:CSS grid 1fr -> 0fr */
.wrapper {
display: grid;
grid-template-rows: 0fr; /* 折叠 */
transition: grid-template-rows 300ms ease;
}
.wrapper.open { grid-template-rows: 1fr; }
.wrapper > .content { overflow: hidden; min-height: 0; }
网格技巧动画化了轨道大小(对合成器友好且广泛支持),并且不需要 JS 测量高度。在目标浏览器支持的情况下使用 interpolate-size: allow-keywords / calc-size(auto, size);将网格技术作为跨浏览器默认方案保留。
避免布局抖动(批量读取,然后写入)
在写入之后读取布局属性(offsetWidth,getBoundingClientRect,scrollTop,getComputedStyle)会强制进行同步重排。在循环中交错读取和写入(“布局抖动”)可能导致每帧执行数十次强制重排。
// 不好的做法:读,写,读,写... 每次迭代都强制重排
items.forEach((el) => {
const w = el.offsetWidth; // 读取(强制布局)
el.style.width = w * 1.5 + 'px'; // 写入(使布局失效)
});
// 好的做法:先批量读取所有,再批量写入所有
const widths = items.map((el) => el.offsetWidth); // 所有读取
items.forEach((el, i) => { // 所有写入
el.style.width = widths[i] * 1.5 + 'px';
});
对于帧同步工作,在 requestAnimationFrame 回调中读取并应用写入;像 fastdom 这样的库将这种读/写调度形式化。
will-change:谨慎使用
will-change: transform 会提前将元素提升到其自己的合成器图层,避免动画开始时的卡顿。但每个提升的图层都会消耗 GPU 内存,过度使用会降低性能。
.menu { will-change: transform; } /* 仅应用于即将动画化的元素 */
规则:仅在动画之前应用(例如悬停/父状态),闲置时移除它(will-change: auto),切勿对许多元素一概应用,并且切勿在大型/众多节点上永久保留它。单个 transform: translateZ(0) hack 执行相同的提升,但更难撤销——优先使用 will-change。
交付和验证(独立 HTML)
打包助手(
scripts/):scripts/seek-shot.sh anim.html 0 1.5 3冻结?t=N测试框架并在每个时刻截图;scripts/contact-sheet.sh sheet.png frame-*.png将它们平铺以便一目了然地审查。参见scripts/README.md。
可交付成果是一个自包含的 .html,可以直接在浏览器中打开——标记、CSS/JS 动画以及冻结框架都在一个文件中。对于此技能,验证是双方面的:帧必须看起来正确而且生成成本低(仅合成器)。
输出约定:
- 一个
.html,依赖项通过 CDN(如果有),动画由 CSS 过渡/@keyframes或 Web Animations API 驱动。 - 一个冻结机制,使截图落在确定性帧上:
- CSS
@keyframes→?t=N设置el.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused'。 - WAAPI / JS → 保持动画对象并执行
anim.pause(); anim.currentTime = N*1000。
- CSS
验证循环——冻结 → 截图,然后分析卡顿:
- 在开始/中间/结尾(
?t=0,中间,结尾)以无头模式打开,分别截图;确认运动在视觉上是正确的(没有因scaleX导致的裁剪/拉伸文本,FLIP 落在真实布局上,阴影/高度过渡看起来正常)。 - 确认它仅限合成器——这是本技能的全部意义。要么:
- DevTools → Performance:记录动画,检查每帧没有紫色“Layout”或绿色“Paint”(只有“Composite Layers”)。
- 或者无头跟踪:使用
npx playwright screenshot进行视觉检查,加上 CDP/tracing捕获,并断言在动画窗口期间没有Layout/Paint事件触发。
- 观察 FPS/“Frame Rendering Stats”覆盖层保持在 60——掉帧意味着某个昂贵属性悄悄回来了。
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png
在完成之前:
- 独立打开——无控制台错误。
- 每帧仅
transform/opacity动画化;DevTools 显示无每帧 Layout/Paint。 - 在开始/中间/结尾截图——正确,无
scaleX文本失真,FLIP 落点真实。 - 保持 60fps;
will-change即时应用并在闲置时移除(无残留图层)。 - 遵守
prefers-reduced-motion。
快速参考
| 目标 | 执行此操作 |
|---|---|
| 移动元素 | transform: translate() |
| 无损调整大小 | 使用 transform 的 FLIP |
| 悬停时的阴影 | 动画化阴影伪元素的 opacity |
| 展开到内容高度 | grid 0fr→1fr,或 interpolate-size: allow-keywords |
| 多个元素移动 | 批量 getBoundingClientRect 读取,然后写入 |
| 平滑动画开始 | 即时使用 will-change,闲置时移除 |
| 验证仅合成器 | DevTools Performance:每帧无紫色“Layout”/绿色“Paint” |
注意事项
scaleX/scaleY会拉伸文本和子元素;当内容需要保持清晰时使用 FLIP。transform百分比是相对于元素自身的盒子,而不是父元素——与left: %不同。- 过度使用
will-change或translateZ(0)会创建太多图层并损害性能;仅提升将要动画化的元素。 filter和backdrop-filter与合成器相关但仍然昂贵;通过不透明度交叉淡入淡出来动画化它们的存在,而不是动画化模糊半径。- 网格
1fr→0fr内容必须具有overflow: hidden和min-height: 0,否则不会折叠。 - 始终将非必要的运动置于
@media (prefers-reduced-motion: reduce)之后。
参考文件
references/patterns-and-profiling.md— 完整的可运行示例(手风琴、排序列表、视差),DevTools 性能分析演练以确认仅合成器帧,减少运动模式,以及属性成本速查表。