mcpField
6
60fps-animation

60fps-animation

github.com/iart-ai设计与 UI632.7k 次安装18 周前更新
代码仓库

关于 60fps-animation

60fps-animation 是面向 Web 开发者的动画性能指南,介绍如何减少布局和绘制开销,并用 transform、opacity 和 FLIP 实现动画。还涵盖自动高度动画、布局读写批处理及性能验证。

主要功能

  1. 01优先使用 transform 和 opacity
  2. 02用 FLIP 动画化布局变化
  3. 03自动高度过渡方案
  4. 04批处理布局读取与写入
  5. 05动画冻结、截图和性能验证
  6. 06谨慎使用 will-change

适用场景

  1. 01排查动画卡顿和掉帧
  2. 02平滑实现元素移动、重排或缩放
  3. 03制作可展开面板和阴影悬停效果
  4. 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。

验证流程——冻结 → 截图,然后分析卡顿:

  1. 在开始 / 中间 / 结束时以无头模式打开(?t=0、中间时刻、结束时刻),分别截图;确认动画视觉效果正确(没有被 scaleX 裁切或拉伸的文字、FLIP 最终落在实际布局位置、阴影 / 高度过渡效果正确)。
  2. 确认动画仅使用合成器——这是此技能的核心。任选一种方法:
    • DevTools → Performance:录制动画,检查每帧都**没有紫色的 “Layout” 或绿色的 “Paint”**色带(只有 “Composite Layers”)。
    • 或使用无头跟踪:通过 npx playwright screenshot 检查画面,并使用 CDP/tracing 捕获跟踪记录,确认动画时间段内没有触发 Layout/Paint 事件。
  3. 观察 FPS /“Frame Rendering Stats”叠加层是否保持在 60;掉帧意味着又引入了耗费资源的属性。
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png

完成前:

  1. 可独立打开——控制台没有错误。
  2. 每帧只对 transform / opacity 进行动画处理;DevTools 显示没有逐帧 Layout/Paint。
  3. 已截取开始 / 中间 / 结束时的画面——效果正确,没有被 scaleX 拉伸的文字,FLIP 最终落在实际位置。
  4. 保持 60fps;will-change 在需要时及时应用,空闲后移除(不遗留图层)。
  5. 遵循 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 性能分析指南、减少动态效果的模式,以及属性开销速查表。

评论

支持 Markdown