关于 60fps-animation
60fps-animation 是面向 Web 开发者的动画性能指南,介绍如何减少布局和绘制开销,并用 transform、opacity 和 FLIP 实现动画。还涵盖自动高度动画、布局读写批处理及性能验证。
主要功能
- 01优先使用 transform 和 opacity
- 02用 FLIP 动画化布局变化
- 03自动高度过渡方案
- 04批处理布局读取与写入
- 05动画冻结、截图和性能验证
- 06谨慎使用 will-change
适用场景
- 01排查动画卡顿和掉帧
- 02平滑实现元素移动、重排或缩放
- 03制作可展开面板和阴影悬停效果
- 04检查动画是否触发布局或绘制
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ iart-ai/ web-animation-skills --skill 60fps-animation安装后在 Claude Code 中输入/60fps-animation确认已加载。
附带文件README.mdreferences/patterns-and-profiling.md
详细介绍
高性能 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 调整位置和尺寸
/* BAD: animates layout every frame */
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
.box:hover { left: 200px; width: 200px; }
/* GOOD: compositor-only */
.box {
transition: transform 300ms ease;
transform: translateX(0) scaleX(1);
transform-origin: left center;
}
.box:hover { transform: translateX(200px) scaleX(2); }
scaleX 会使内部内容变形(拉伸文本)。若要真实调整尺寸且不产生变形,请使用 FLIP。
通过伪元素 opacity 实现低成本 box-shadow
对 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; }
模糊阴影只会栅格化一次;悬停时只会改变合成器上的 opacity——无论帧率如何,都能保持流畅。
FLIP:以低成本为布局变化添加动画
FLIP(First、Last、Invert、Play)使用纯 transform 为布局变化(重新排序、调整尺寸、在容器之间移动)添加动画。先测量元素原先的位置(First)和目标位置(Last),再应用反转变换,使元素看起来没有移动,然后将变换动画恢复为初始状态(Play)。DOM 最终会处于真实的目标布局;整个移动过程只由合成器处理。
function flip(el, mutate) {
const first = el.getBoundingClientRect(); // First
mutate(); // change the DOM/layout
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)' }
);
}
// usage: animate an element moving to a new grid position
flip(card, () => targetContainer.appendChild(card));
Web Animations API 的 animate() 会在合成器上执行变换。对于大量元素,请在进行任何更改之前批量读取所有 getBoundingClientRect()(参见下文的布局抖动)。
为 height: auto 添加动画
过去 height: auto 无法插值。现代方案和回退方案如下:
/* Modern (Chrome 129+/supporting browsers): opt size keywords into animation */
.panel {
interpolate-size: allow-keywords; /* enables animating to/from auto */
height: 0;
overflow: clip;
transition: height 300ms ease;
}
.panel.open { height: auto; }
/* calc-size() also works: height: calc-size(auto, size); */
/* Robust fallback today: CSS grid 1fr -> 0fr */
.wrapper {
display: grid;
grid-template-rows: 0fr; /* collapsed */
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)会强制触发同步回流。在循环中交替读取和写入(“布局抖动”)可能导致每帧触发数十次强制回流。
// BAD: read, write, read, write... forces reflow each iteration
items.forEach((el) => {
const w = el.offsetWidth; // read (forces layout)
el.style.width = w * 1.5 + 'px'; // write (invalidates layout)
});
// GOOD: batch all reads, then all writes
const widths = items.map((el) => el.offsetWidth); // all reads
items.forEach((el, i) => { // all writes
el.style.width = widths[i] * 1.5 + 'px';
});
对于与帧同步的操作,请在 requestAnimationFrame 回调中读取并应用写入;fastdom 等库可将这种读写调度规范化。
will-change:谨慎使用
will-change: transform 会提前将元素提升到自己的合成器图层,从而避免动画开始时出现卡顿。但每个提升的图层都会占用 GPU 内存,过度使用会降低性能。
.menu { will-change: transform; } /* only on elements about to animate */
规则:在动画开始前应用(例如悬停或父级状态变化时),空闲后移除(设为 will-change: auto),绝不要对大量元素一概应用,也不要让它永久作用于大型或数量众多的节点。单独使用 transform: translateZ(0) 也能实现相同的图层提升效果,但更难撤销,因此优先使用 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 transitions、@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与合成器相关,但仍会消耗大量资源;应通过 opacity 交叉淡化来显示或隐藏它们,而非对模糊半径添加动画。- grid
1fr→0fr中的内容必须设置overflow: hidden和min-height: 0,否则不会收起。 - 务必通过
@media (prefers-reduced-motion: reduce)禁用非必要动画。
参考文件
references/patterns-and-profiling.md— 完整的可运行示例(手风琴、重新排序列表、视差)、确认仅由合成器处理帧的 DevTools 性能分析指南、减少动态效果的模式,以及属性开销速查表。