Communitygithub.com

yokyznt/60fps-动画

通过将动画限制在 `transform` 和 `opacity` 属性上,消除由布局重排或重绘导致的卡顿 CSS 动画。

60fps-动画 是什么?

该技能专注于解决 Web 动画卡顿的根本原因:动画化那些会强制浏览器在每个帧上重新计算布局(重排)或重绘的属性。浏览器渲染阶段为 **布局 → 绘制 → 合成**。动画化 `width`、`height`、`top`、`left`、`margin`、`padding`、`box-shadow` 或 `filter` 等属性会触发布局和/或绘制,阻塞主线程,导致帧率下降。该技能的核心理念是**仅动画化 `transform` 和 `opacity`**,因为这两个属性的动画完全在合成器(通常是 GPU)上执行,跳过了布局和绘制阶段,从而轻松实现 60/120fps 的流畅动画。它提供了将昂贵动画映射到廉价替代方案的清晰表格,涵盖位置、大小、阴影和高度的动画化,并详细阐述了 FLIP(First, Last, Invert, Play)技术,这是一种通过 `transform` 廉价动画化布局变化的强大方法。此外,该技能还包含了避免布局抖动、谨慎使用 `will-change` 的实用技巧,以及一个可交付的独立 HTML 文件格式和验证方法,确保动画既视觉正确又性能高效。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/yokyznt/claude-skills-web-definitiva/tree/HEAD/skills/60fps-animation

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

高性能 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。

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

  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 与合成器相关但仍然昂贵;通过不透明度交叉淡入淡出来动画化它们的存在,而不是动画化模糊半径。
  • 网格 1fr→0fr 内容必须具有 overflow: hidden 和 min-height: 0,否则不会折叠。
  • 始终将非必要的运动置于 @media (prefers-reduced-motion: reduce) 之后。

参考文件

  • references/patterns-and-profiling.md — 完整的可运行示例(手风琴、排序列表、视差),DevTools 性能分析演练以确认仅合成器帧,减少运动模式,以及属性成本速查表。

相关技能