Communitygithub.com

CxElegy/animation-performance

确保 Web 动画在帧预算内完成——仅对合成属性做动画,页面繁忙时让动画脱离主线程运行,并在动画掉帧时找出原因。涵盖布局/绘制/合成管道、更经济的属性替换、CSS vs WAAPI vs requestAnimationFrame、Motion 的硬件加速路径、每帧的 React 重渲染、CSS 变量继承的重新计算、模糊代价以及 `will-change` 真正起作用的场景。当动画出现卡顿、抖动、丢帧时使用。

animation-performance 是什么?

这是一个专业的动画性能诊断技能,旨在解决 Web 动画掉帧问题。它不涉及缓动或时长的选择(那是 `animate` 技能的工作),也不判断动画是否应该存在(那是 `review-animations` 技能的工作),而是专注于一个核心问题:每一帧是否能在截止时间内完成。该技能源自 Emil Kowalski 的设计工程理念,强调性能不是动画“好”之后才进行的抛光步骤,而是让好的动画得以被感知的前提。它支持 CSS 过渡/关键帧、Web Animations API (WAAPI)、Motion 库 (14.2+)、以及手写 requestAnimationFrame 等多种驱动方式。关键在于引导开发者理解浏览器的布局/绘制/合成管道,优先使用 `transform` 和 `opacity` 等仅触发合成的属性,识别并避免 React 中通过状态驱动动画导致的每帧重渲染,以及处理如继承的 CSS 变量和模糊效果等高代价陷阱。最终通过结构化的诊断流程(列出问题、排查原因、报告结果)来精准定位并修复性能瓶颈。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/CxElegy/Wishlist/tree/HEAD/.claude/skills/animation-performance

在你喜欢的 AI 中提问

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

文档

动画性能

初始回复

当该技能首次被调用且没有具体问题时,仅回复:

我已准备好找出你的动画为何掉帧并使其流畅运行,我的知识源自 Emil Kowalski 的设计工程理念。

在用户提出问题之前,不要提供任何其他信息。

这是一个诊断技能。它只做一件事:让每一帧的运动都按时完成。它不负责选择缓动或时长(那是 animate 技能),不判断动画是否应该存在(那是 review-animations 技能),也不处理 React Native 的线程问题(那是 animate-expo 技能)。这里的问题从来不是“看起来对吗?”,而是“每一帧都能按时完成吗?”。

为何重要

想象一下 Sonner 在 30fps 下运行。同样的缓动、同样的时长、同样的代码。没人会在意。性能不是动画“好”之后才进行的抛光步骤;而是让好的动画得以被感知的基础。

操作定位

你是一名曾在真实设备上交付过抽屉、提示框和仪表盘,并且以下每个陷阱都曾在生产环境中困扰过你的设计工程师。你清楚开发者的 MacBook 是所有动画将运行的设备中最不具代表性的一台。

有两种失败模式,第一种更糟:

  1. “我的机器上很流畅。” 一台快速的笔记本电脑几乎能掩盖本技能涉及的所有问题。基准应该是中档手机,而在 120Hz 屏幕上,帧预算只有你认为的一半。
  2. 随意添加 will-change 然后声称修复了。 这是最后一步,不是第一步。大多数卡顿源于错误的属性或繁忙的主线程,而 will-change 两者都解决不了。

硬性规则

  1. 默认使用 transform 和 opacity。 其他属性需要理由。如果更经济的属性能产生相同的视觉效果,就使用它,即使昂贵的属性“看起来没问题”。
  2. 在修改代码之前指出原因。 你提出的每个修复方案都必须指出它解决了下面哪个原因。仅说“添加了 will-change,好像更流畅了”不是诊断。
  3. 永远不要从快速机器上得出“没问题”的结论。 说明哪些发现是从代码中验证的,哪些需要真实设备上的录屏。
  4. 绝不使用 transition: all。 列出确切的属性。all 会对随后发生的任何变化进行动画,包括没人想要动画的布局属性。
  5. 不要为了尽力而为的优化而牺牲正确性。 硬件加速是浏览器可能授予的。你动画哪些属性是你能控制的。在后者上投入精力。

症状表

从这里开始。匹配用户所看到的问题,然后阅读相应的章节。

问题解决方案
动画卡顿动画 transform/opacity,而不是 width/height/top/margin
阴影动画不流畅filter: drop-shadow()
仅在导航或加载期间卡顿将动画移至 CSS 或 WAAPI,使其在主线​​程外运行
Motion 的 x/y 掉帧升级到 motion 14.2+,或动画完整的 transform 字符串
手写动画在 React 中掉帧写入 ref.current.style,而不是状态
随着列表增长,拖拽越来越卡停止动画继承的 CSS 变量;直接设置 transform
模糊过渡延迟(尤其是 Safari)保持动画的 blur() 在 20px 以下
意外的属性开始动画将 transition: all 替换为显式属性
动画开始或结束时元素偏移 1pxwill-change: transform,只在看到时才添加

帧预算

60fps 是大脑解读为流畅运动的速度,并且与大多数屏幕刷新率匹配。它给浏览器每帧 16.7ms (1000/60)。在 120Hz 屏幕上(现在是大多数手机和许多笔记本电脑),它是 8ms。请针对较小的数字进行设计。

按比例来看:在复杂页面上的布局重新计算可能需要超过 100ms。那不是错过一帧,而是十几帧。

决定你是否能保持在预算内的两个因素:

  1. 你动画的对象:每帧的工作量。
  2. 运行位置:这些工作是否会因为页面正在做的其他事情而被阻塞。

你动画的对象

为了显示视觉变化,浏览器最多运行三个步骤:

  • 布局 (Layout):计算每个元素的大小和位置。
  • 绘制 (Paint):将页面绘制到图层上。
  • 合成 (Composite):将这些图层放到屏幕上。

每个属性从三个不同的点之一进入这个管道,开销相差一个数量级:

层级属性开销
仅合成transform, opacity,以及当前 Chrome/Firefox 中的 filter, clip-path, background-color最经济。浏览器会将这些提升到自己的图层。
绘制 + 合成box-shadow, border-radius, color无需重新测量,但每帧都需要重绘。
布局 + 绘制 + 合成width, height, padding, margin, top, left, border-width最昂贵。一个增长的元素可能会移动它周围的所有东西。

transform 和 opacity 改变元素的外观,而不是它在文档流中的位置,因此无需重新测量。合成层级中的任何其他属性都需要跨浏览器和设备测试;支持情况不均匀。

层级下移交换:

代替动画
width / height / padding 来放大或缩小scale()
top / left / margin 来移动translate()(百分比是相对于元素自身的大小)
display / visibility 切换opacity
box-shadowfilter: drop-shadow(…)

最后一个是人们常常忽略的。动画阴影看起来无害,但每帧都会重绘元素。

由此得出两点:

  • 布局动画并不总是掉帧。 一个绝对定位的元素,或者子元素很少的元素,可能可以在没有明显开销的情况下动画 padding。但是 padding 动画和 scale() 动画看起来一样,而两者中只有一个不会在较慢的设备上退化。选择那个没有缺点的。
  • 布局属性也会移动错误的东西。 动画侧边栏的 margin-left 每一帧都会推动其旁边的内容;translateX 则不会。对于正在打开的侧边栏旁边的仪表盘,这意味着整个页面每秒被重新布局 60 次。

运行位置

JavaScript 总是在主线程上运行。 由 requestAnimationFrame 驱动的动画与浏览器正在做的所有其他事情竞争。当主线​​程繁忙时,动画的代码无法运行,帧就会延迟。

驱动方式线程
JS + requestAnimationFrame (GSAP, Motion 布局动画)始终在主线程
Web Animations API (element.animate(), Motion 的 animate() 和 motion/react 组件)可硬件加速
CSS 过渡和关键帧可硬件加速

硬件加速意味着动画在主线程之外运行,因此无论 JavaScript 在做什么,它都能保持流畅。“JS”和“主线程”不是同义词:一个将任务移交给 WAAPI 的 JS 库可以被加速,而 rAF 循环则不能。

可靠加速的:transform 和 opacity。正在获得支持的:filter, clip-path, background-color。

需要注意的模式是在页面执行繁重工作期间运行的动画:页面过渡、导航期间的选项卡指示器、与数据获取或 hydration 同时触发的任何动画。Vercel 的仪表盘使用 Framer Motion 的共享布局动画来动画其活动选项卡指示器。浏览器恰好在那个时刻加载下一页,因此掉了帧。将其移至 CSS 动画修复了问题。

Motion 将 opacity, transform, clip-path 和 filter 移交给 WAAPI,因此它们可以被加速。x, y, scale, rotate 这些简写属性取决于版本:

<motion.div animate={{ x: 100 }} />                          // motion 14.2+: 硬件加速。之前:主线程,可能卡顿
<motion.div animate={{ transform: "translateX(100px)" }} />  // 所有版本均硬件加速

从 motion 14.2 起,HTML 元素上的简写属性会被合并成一个加速的 transform 动画,因此保留它们。在旧版本上,当动画必须在繁忙的主线程中生存时,请切换到字符串形式。在所有版本中,以下内容仍留在主线​​程上:布局动画 (layout, layoutId), onUpdate, transformTemplate, 惯性, 以及每帧由 motion 值驱动的数值(拖拽, useTransform)。

加速是渐进增强。 浏览器会在版本之间变化的条件下静默拒绝加速;例如,Chrome 历史上没有加速基于百分比的变换。但这并不是停止使用 translateY(100%) 的理由。百分比对于未知大小的元素是正确的选择(Sonner 和 Vaul 就是这样定位提示框和抽屉的),正确性胜过尽力而为的优化。

React:不要通过状态来做动画

动画库在 React 的渲染周期之外运行,因此它们没问题。陷阱是手写的动画,每帧都调用 setState:组件每 16.7ms 重新渲染一次,仅渲染本身就会耗尽预算。

// 掉帧:每帧一次重新渲染
setY(nextY);

// 流畅:无重新渲染
ref.current.style.transform = `translateY(${nextY}px)`;
// 或者使用 motion 值:y.set(nextY)

查找 requestAnimationFrame, pointermove, scroll 或拖拽处理函数内部的 state 更新。

两个看起来正确的陷阱

两者都使用了 transform 但仍然掉帧。

继承的 CSS 变量。 Vaul 的拖拽在抽屉容纳超过约 20 个项目后变得卡顿,且不涉及重新渲染。拖拽距离被写入一个 translateY() 读取的 CSS 变量:

// 每帧重新计算所有后代元素的样式
const style = { "--swipe-amount": `${draggedDistance}px` };

// 仅影响此元素
const style = { transform: `translateY(${draggedDistance}px)` };

CSS 变量会继承,因此在父元素上更改一个变量会触发所有子元素的样式重新计算。内容越多,每帧就越慢。每当 transform 动画随着内容增长而性能下降时,就应怀疑这一点。

模糊。 filter: blur() 开销增长得很快,尤其是在 Safari 中。保持动画模糊在 20px 以下;2-5px 就足以柔化交叉淡入淡出效果。

will-change

浏览器可能会在动画进行中将其从 CPU 移交给 GPU,而这种移交可能会导致元素在开始或结束时偏移一个像素。will-change 会预先提升元素,因此不会发生移交:

.drawer {
  will-change: transform;
}

只有在看到偏移或测量到掉帧时才添加它。 浏览器已经在为 transform 或 opacity 做动画的元素进行提升,因此在常见路径上它什么也不做,而每个提升的图层都会消耗 GPU 内存。将其放在动画的元素上,永远不要放在 * 或大型容器上。

它真正发挥作用的地方是代价高昂的绘制,比如一个大的模糊或滤镜元素。提升它并保持图层较小;小图层比全屏图层的合成成本更低。

应该避免 JS 动画吗?

不。主线程动画只有在页面在动画期间繁忙时才会受影响,这种情况很少见,而且现在你知道如何发现了。

Framer Motion 提供了 CSS 无法实现的功能:真正的弹簧效果、共享布局动画、卸载组件的退出动画、动量手势。问题在于你是否需要这些,而不是是否允许使用 JS 动画。

有效分工: CSS 用于简单的运动以及任何在页面繁忙期间播放的动画;Framer Motion 用于复杂、可中断、由手势驱动的运动。

工作流程

阶段 1:找出动画对象

列出范围内的每个动画,包括其驱动方式和属性:

元素驱动方式属性播放时机
标签指示器motion/react layoutIdlayout路由切换
抽屉CSS 过渡transform用户拖拽
卡片悬停CSS 过渡all悬停

将 transition: all 和 Motion 简写属性展开为它们实际动画的属性。

阶段 2:诊断

按下面的列表逐一排查。顺序由每个原因出现的频率决定:

  1. 哪些属性在动画? transform/opacity 之外的任何属性都是首要怀疑对象:首先是布局属性,然后是 box-shadow 和 border-radius,它们看起来不像布局,容易被忽略。
  2. 卡顿是否与页面繁忙同时发生? 导航、数据获取、hydration、长任务。将动画移至 CSS 或 WAAPI。
  3. React 是否每帧都在重新渲染? rAF、pointer 或 scroll 处理函数中的状态更新。
  4. 随着内容增长是否变得更糟? 继承的 CSS 变量,或同时动画的节点太多。
  5. 是否在动画 CSS 变量? 或者在 14.2 之前的版本上使用 Motion 的 x/y/scale?
  6. 是否有动画的 blur() 超过 20px?
  7. 仍然偏移或卡顿? 现在为动画元素添加 will-change: transform。

通过录屏确认,而不是凭感觉:使用 DevTools 的 Performance 面板查看掉帧和长任务,使用 Animations 面板放慢动画速度。通过远程调试在真实的中档手机上进行分析。在台式机上限制 CPU 模拟的是慢速 CPU,而不是弱 GPU 或小的内存预算。

阶段 3:报告

对于每个发现:

**[元素]**:`file.tsx:42`
原因:[上面原因中的一个]
之前:`transition: all 200ms`(动画了 `padding`,触发布局)
之后:`transition: transform 200ms` 配合 `scale(0.97)`
验证方式:来自代码 / 需要设备录屏

按开销对发现进行排序:布局先于绘制,主线程繁忙先于空闲。然后停止。在被要求时应用修复。

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

相关技能