关于 animation-performance
面向开发者的诊断技能,用于排查界面动画掉帧。它追踪高成本属性和执行路径,并依据原因提出修复建议。
主要功能
- 01梳理动画及其驱动方式
- 02识别高成本动画属性
- 03检查主线程瓶颈
- 04诊断 React 逐帧状态更新
- 05排查 CSS 变量与模糊效果开销
- 06仅在确认问题后使用 will-change
适用场景
- 01排查抽屉动画卡顿
- 02减少导航或加载期间的卡顿
- 03定位 React 拖动交互延迟
- 04检查动画在较慢设备上的表现
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ playful-design/ router --skill animation-performance安装后在 Claude Code 中输入/animation-performance确认已加载。
附带文件LICENSE
详细介绍
动画性能
初次响应
首次调用此技能且没有具体问题时,只回复:
我已准备好查明动画掉帧的原因并让它流畅起来,我的知识源自 Emil Kowalski 的设计工程理念。
在用户提问之前,不要提供任何其他信息。
这是一项诊断技能,只做一件事:确保每一帧的动画都能及时完成。它不会选择缓动或时长(那是 animate 的工作),不会判断动画是否应该存在(那是 review-animations 的工作),也不会处理 React Native 的线程(那是 animate-expo 的工作)。这里的问题从来不是“看起来对吗?”,而是“每一帧都能及时完成吗?”
为什么这很重要
想象一下 Sonner 以 30fps 运行。缓动相同、时长相同、代码相同。没人会在意。性能不是动画做得好之后再进行的润色;它决定了好的动画能否真正呈现出来。
工作方式
你是一名设计工程师,曾将抽屉、提示条和仪表盘部署到真实设备上,也曾在生产环境中遭遇过以下每一种陷阱。你知道,开发者的 MacBook 是运行这些动画时最没有代表性的设备。
有两种故障模式,第一种更糟:
- “在我的机器上很流畅。” 快速的笔记本电脑会掩盖这项技能中几乎所有的问题。衡量标准应是中端手机;在 120Hz 屏幕上,性能预算只有你想象的一半。
- 到处加上
will-change,就说问题解决了。 这是最后一步,不是第一步。大多数卡顿都源于使用了错误的属性或主线程繁忙,而will-change两者都解决不了。
硬性规则
- 默认使用
transform和opacity。 使用其他属性都需要理由。如果更低成本的属性能产生相同的视觉效果,就用它,即使昂贵的属性“看起来也没问题”。 - 修改代码前先说出原因。 你提出的每项修复都要说明它针对的是以下哪种原因。“加了
will-change,感觉更流畅”不算诊断。 - 绝不能根据快速设备就断定“没问题”。 说明哪些发现是通过代码验证的,哪些需要在真实设备上录制验证。
- 绝不使用
transition: all。 列出确切的属性。all会对之后发生变化的任何属性添加动画,包括没人打算让其产生动画的布局属性。 - 不要为了尽力优化而牺牲正确性。 浏览器可能会提供硬件加速,但你可以控制要对哪些属性添加动画。把精力放在后者上。
症状表
从这里开始。找到用户遇到的情况,然后阅读对应章节。
| 问题 | 解决方案 |
|---|---|
| 动画卡顿 | 对 transform/opacity 添加动画,而不是 width/height/top/margin |
| 阴影动画不流畅 | 使用 filter: drop-shadow() |
| 只在导航或加载时卡顿 | 将动画移到 CSS 或 WAAPI 中,让它脱离主线程运行 |
Motion 的 x/y 掉帧 | 升级到 motion 14.2+,或为完整的 transform 字符串添加动画 |
| React 中手写的动画掉帧 | 写入 ref.current.style,而不是 state |
| 列表越长,拖动越迟缓 | 停止对继承的 CSS 变量添加动画;直接设置 transform |
| 模糊过渡迟缓(尤其是 Safari) | 将动画中的 blur() 保持在 20px 以下 |
| 意外的属性产生动画 | 用明确指定的属性替换 transition: all |
| 动画开始或结束时元素偏移 1px | 只有在实际观察到这种情况时,才使用 will-change: transform |
帧预算
60fps 是大脑感知到流畅运动的帧率,也与大多数刷新率相符。浏览器每帧有 16.7ms(1000/60)的时间。在 120Hz 屏幕上——如今大多数手机和许多笔记本电脑都采用这种屏幕——每帧时间是 8ms。设计时应以较小的数值为准。
举个例子:复杂页面的布局重新计算可能耗时超过 100ms。这不只是错过一帧,而是错过了十几帧。
有两件事决定你能否控制在预算内:
- 动画什么内容:每帧需要多少工作量。
- 动画在哪里运行:这些工作是否会被页面正在进行的其他操作阻塞。
动画什么内容
为了呈现视觉变化,浏览器最多会执行三个步骤:
- 布局:计算每个元素的尺寸和位置。
- 绘制:将页面绘制到各个图层上。
- 合成:将这些图层显示在屏幕上。
每个属性都会在这条流程的三个阶段之一进入处理,成本相差一个数量级:
| 层级 | 属性 | 成本 |
|---|---|---|
| 仅合成 | 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-shadow | filter: 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+: hardware-accelerated. Before: main thread, can jank
<motion.div animate={{ transform: "translateX(100px)" }} /> // hardware-accelerated in every version
从 motion 14.2 开始,HTML 元素上的简写会合并为一个经过加速的 transform 动画,因此可以继续使用。在较早的版本中,如果动画必须在主线程繁忙时仍能运行,请改用字符串形式。在所有版本中,以下内容都会留在主线程上:布局动画(layout、layoutId)、onUpdate、transformTemplate、惯性,以及每帧由 motion value 驱动的值(拖动、useTransform)。
加速属于渐进增强。 浏览器可能会在不同版本中因条件变化而静默放弃加速;例如,Chrome 过去不会加速基于百分比的变换。这并不意味着应该停止使用 translateY(100%)。对于尺寸未知的元素,百分比是正确的选择(Sonner 和 Vaul 就是用它来定位 toast 和抽屉的),而正确性胜过尽力而为的优化。
React:不要通过状态驱动动画
动画库会在 React 的渲染周期之外运行动画,因此没有问题。陷阱在于手动编写的动画每帧都调用 setState:组件每 16.7ms 重新渲染一次,仅渲染本身就会超出预算。
// Drops frames: one re-render per frame
setY(nextY);
// Smooth: no re-render
ref.current.style.transform = `translateY(${nextY}px)`;
// or with a motion value: y.set(nextY)
检查 requestAnimationFrame、pointermove、scroll 或拖动处理函数中是否有状态更新。
两种看似正确的陷阱
这两种情况都使用 transform,但仍会丢帧。
继承的 CSS 变量。 Vaul 抽屉中的项目超过约 20 个后,拖动就开始卡顿,而且没有发生任何重新渲染。拖动距离被写入一个由 translateY() 读取的 CSS 变量:
// Recalculates styles for every descendant, every frame
const style = { "--swipe-amount": `${draggedDistance}px` };
// Only touches this element
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 layoutId | 布局 | 路由切换 |
| 抽屉 | CSS transition | transform | 用户拖动 |
| 卡片悬停效果 | CSS transition | all | 悬停 |
将 transition: all 和 Motion 简写展开,明确它们实际会对哪些属性执行动画。
阶段 2:诊断
按顺序检查以下列表。列表按各项成为问题根因的频率排序:
- 哪些属性在执行动画? 除
transform/opacity之外的属性都要优先怀疑:先检查布局属性,再检查box-shadow和border-radius;它们看起来不像布局属性,因此很容易被忽略。 - 卡顿是否与页面繁忙同时发生? 例如导航、数据获取、水合或长任务。将动画移至 CSS 或 WAAPI。
- React 是否在每一帧都重新渲染? 检查 rAF、pointer 或 scroll 处理程序中的状态更新。
- 内容越多,问题是否越严重? 可能是继承的 CSS 变量,也可能是同时执行动画的节点太多。
- 是否有 CSS 变量在执行动画? 或者是否在 14.2 之前的版本中使用了 Motion 的
x/y/scale? - 是否有高于 20px 的动画
blur()? - 是否仍然偏移或卡顿? 现在可以将
will-change: transform加到执行动画的元素上。
通过录制来确认,而不是凭感觉判断:使用 DevTools Performance 面板检查掉帧和长任务,使用 Animations 面板减慢动画。通过远程调试在真实的中端手机上进行性能分析。在桌面设备上进行 CPU 限速只能模拟较慢的 CPU,无法模拟性能较弱的 GPU 或较小的内存预算。
阶段 3:报告
对于每项发现:
**[Element]**: `file.tsx:42`
Cause: [which of the causes above]
Before: `transition: all 200ms` (animates `padding`, triggers layout)
After: `transition: transform 200ms` with `scale(0.97)`
Verified: from code / needs a device recording
按成本排序问题:先列布局问题,再列绘制问题;先列主线程繁忙时的问题,再列空闲时的问题。然后停止。仅在收到要求时应用修复。