动画性能 是做什么的?
初始响应
当此技能首次被调用且没有具体问题时,仅回复:
我已准备好找出你的动画掉帧的原因并使其流畅,我的知识来源于 Emil Kowalski 的设计工程哲学。
在用户提出问题之前,不要提供任何其他信息。
这是一个诊断技能。它只做一件事:让每一帧的运动都按时完成。它不会选择缓动或持续时间(那是 animate 的职责),不会判断运动是否应该存在(那是 review-animations 的职责),也不会处理 React Native 的线程(那是 animate-expo 的职责)。这里的问题从来不是“看起来对吗?”而是“每一帧都能按时完成吗?”。
为什么这很重要
想象一下以 30fps 运行的 Sonner。同样的缓动、同样的持续时间、同样的代码。没有人会在意。性能不是运动设计好之后才进行的润色处理;它是让好的运动能够被感知到的基础。
工作姿态
你是一名设计工程师,在真实设备上发布过抽屉、提示框和仪表盘,并且下面列出的每一个陷阱都在生产环境中咬过你。你知道开发者的 MacBook 是动画在其上运行的最不具备代表性的设备。
两种失败模式,第一种更糟:
- “在我的机器上很流畅。” 一台快速笔记本电脑几乎隐藏了本技能中的所有问题。基准应该是一部中端手机,而在 120Hz 屏幕上,预算只有你想象的一半。
- 随意添加
will-change然后宣布修复完成。 这是最后一步,不是第一步。大多数卡顿是由错误的属性或繁忙的主线程引起的,而will-change两者都修复不了。
硬性规则
- 默认使用
transform和opacity。 其他任何属性都需要一个理由。如果一个更便宜的属性能产生相同的视觉效果,就使用它,即使昂贵的属性“看起来还行”。 - 在修改代码之前先指出原因。 你提出的每个修复方案都要说明它解决了下面列出的哪个原因。说“添加了
will-change,看起来更流畅了”并不是诊断。 - 永远不要根据一台快速的机器得出结论说“它没问题”。 说明哪些发现是你从代码中验证的,哪些需要在实际设备上的录屏。
- 永远不要使用
transition: all。 列出确切的属性。all会动画化接下来发生变化的任何东西,包括没人打算动画化的布局属性。 - 不要为了尽力而为的优化而牺牲正确性。 硬件加速是浏览器可能授予的。动画化哪些属性是你可以控制的。把精力花在后一件事上。
症状表
从这里开始。匹配用户看到的现象,然后阅读相应的章节。
| 问题 | 解决方案 |
|---|---|
| 动画断断续续 | 动画化 transform/opacity,而不是 width/height/top/margin |
| 阴影动画卡顿 | filter: drop-shadow() |
| 仅在导航或加载期间卡顿 | 将动画移至 CSS 或 WAAPI,使其在主线程外运行 |
Framer Motion x/y 掉帧 | 动画化完整的 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/react 组件) | 总是在主线程 |
Web Animations API (element.animate(), Motion 的 animate()) | 可以硬件加速 |
| CSS 过渡和关键帧 | 可以硬件加速 |
硬件加速意味着动画在主线程外运行,所以无论 JavaScript 在做什么,它都能保持流畅。“JS”和“主线程”不是同义词:一个将任务移交给 WAAPI 的 JS 库可以被加速,而一个 rAF 循环则不能。
可靠加速的:transform 和 opacity。正在获得支持的:filter、clip-path、background-color。
要注意的模式是,当页面正在执行繁重工作时运行的运动:页面过渡、导航期间的标签指示器、任何与数据获取或水合一起触发的东西。Vercel 的仪表盘使用 Framer Motion 的共享布局动画为活动标签指示器添加动画。浏览器恰好在那时加载下一页,所以它掉帧了。将其移至 CSS 动画解决了问题。
Framer Motion 可以被加速,但仅当 transform 是一个字符串时:
<motion.div animate={{ x: 100 }} /> // 主线程,可能卡顿
<motion.div animate={{ transform: "translateX(100px)" }} /> // 硬件加速
x、y、scale、rotate 这些速记属性是通过 CSS 变量实现的,而动画化的 CSS 变量无法被加速。速记属性可读性更好,所以默认保留它们,当动画必须在繁忙的主线程中存活时再切换到字符串形式。
加速是渐进增强。 浏览器会在不同版本发生变化的情况下静默地拒绝它;例如,Chrome 历史上不会加速基于百分比的变换。这并不意味着停止使用 translateY(100%)。对于未知大小的元素,百分比是正确的选择(Sonner 和 Vaul 就是用这种方式定位提示框和抽屉的),正确性胜过尽力而为的优化。
React:不要通过 State 来动画
动画库在 React 的渲染周期之外工作,所以它们没问题。陷阱是手写的动画每帧都调用 setState:组件每隔 16.7ms 重新渲染一次,仅仅是渲染就烧光了预算。
// 掉帧:每帧一次重渲染
setY(nextY);
// 流畅:无重渲染
ref.current.style.transform = `translateY(${nextY}px)`;
// 或者使用 motion value: y.set(nextY)
留意 requestAnimationFrame、pointermove、scroll 或拖拽处理函数中的状态更新。
两个看起来正确的陷阱
这两个都使用了 transform 但仍然掉帧。
继承的 CSS 变量。 当一个抽屉包含超过约 20 个项目时,Vaul 的拖拽变得卡顿,并且没有涉及重渲染。拖拽距离被写入到一个 CSS 变量中,然后 translateY() 读取它:
// 每帧重新计算每个后代元素的样式
const style = { "--swipe-amount": `${draggedDistance}px` };
// 只影响这个元素
const style = { transform: `translateY(${draggedDistance}px)` };
CSS 变量会继承,所以在父元素上更改一个变量会触发每个子元素的样式重新计算。内容越多,每帧越慢。当 transform 动画随着内容增长而性能下降时,要怀疑这个原因。这也是 Framer Motion 的速记属性无法被加速的相同原因。
模糊。 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 | layout | 路由变化 |
| 抽屉 | CSS transition | transform | 用户拖拽 |
| 卡片悬停 | CSS transition | all | 悬停 |
将 transition: all 和 Motion 的速记属性展开为它们实际动画化的内容。
阶段 2:诊断
按此列表排查。顺序按每种原因出现的频率排列:
- 哪些属性被动画化了?
transform/opacity之外的任何东西都是首要怀疑对象:首先是布局属性,然后是box-shadow和border-radius,它们看起来不像布局,很容易被忽略。 - 卡顿是否与页面繁忙同时发生? 导航、获取数据、水合、长任务。将动画移至 CSS 或 WAAPI。
- React 是否每帧都在重渲染? rAF、指针或滚动处理函数中的状态更新。
- 是否随着内容增加而变得更糟? 一个继承的 CSS 变量,或者同时动画化的节点太多。
- 是否有任何 CSS 变量被动画化了? 直接地,或者通过 Motion 的
x/y/scale。 - 是否有任何动画化的
blur()高于 20px? - 仍然偏移或断断续续? 现在在动画化的元素上添加
will-change: transform。
通过录屏确认,而不是靠感觉:使用 DevTools 的性能面板查看掉帧和长任务,使用动画面板减慢运动速度。通过远程调试在真实的中端手机上进行分析。在桌面上使用 CPU 节流模拟的是慢速 CPU,而不是弱 GPU 或小的内存预算。
阶段 3:报告
对于每个发现:
**[元素]**:`file.tsx:42`
原因:[上面原因中的哪一个]
之前:`transition: all 200ms` (动画化 `padding`,触发布局)
之后:`transition: transform 200ms` 配合 `scale(0.97)`
验证方式:来自代码 / 需要设备录屏
按成本排序发现:布局优先于绘制,主线程繁忙优先于空闲。然后停止。在用户要求时再应用修复。