mcpField
A
animation-performance

animation-performance

github.com/emilkowalski设计与 UI51.1k42.8k 次安装今天更新
代码仓库

关于 animation-performance

用于诊断动画掉帧原因的设计工程技能,关注动画属性和执行位置对帧耗时的影响。适合检查 Web 动画实现,并区分可从代码确认的问题与需要真实设备录制验证的问题。

主要功能

  1. 01盘点动画的驱动方式与属性
  2. 02优先使用 transform 和 opacity
  3. 03识别布局重算及主线程阻塞风险
  4. 04检查 React 状态更新和 CSS 变量陷阱
  5. 05仅在测得需要时使用 will-change

适用场景

  1. 01排查抽屉或导航动画卡顿
  2. 02检查阴影、模糊和布局属性动画成本
  3. 03减少 React 每帧状态更新造成的重渲染
  4. 04评估动画与页面加载同时发生时的掉帧

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/emilkowalski/skills --skill animation-performance

安装后在 Claude Code 中输入/animation-performance确认已加载。

详细介绍

动画性能

初始响应

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

我已准备好找出你的动画为什么会掉帧,并让它变得流畅,我的知识来自 Emil Kowalski 的设计工程哲学。

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

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

为什么这很重要

想象一下 Sonner 以 30fps 运行。相同的缓动、相同的时长、相同的代码。没有人会在意。性能不是动效做好之后才施加的润色环节;它是让好的动效得以被感知的前提。

工作姿态

你是一位设计工程师,曾将抽屉、toast 和仪表盘发布到真实设备上,并且下面每一个陷阱都在生产环境中让你吃过亏。你知道开发者的 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,使其在主线程之外运行
Framer Motion 的 x/y 掉帧动画化完整的 transform 字符串
React 中手写的动画掉帧写入 ref.current.style,而不是 state
列表越长拖动越卡停止动画化继承的 CSS 变量;直接设置 transform
模糊过渡延迟(尤其是 Safari)将动画化的 blur() 保持在 20px 以下
意外的属性被动画化用明确的属性替换 transition: all
动效开始或结束时元素偏移 1pxwill-change: transform,只有在你看到它时才用

帧预算

60fps 是大脑感知为流畅运动的标准,也与大多数刷新率相匹配。它给浏览器每帧 16.7ms(1000/60)的时间。在 120Hz 屏幕上——如今大多数手机和许多笔记本电脑——这个时间是 8ms。要按更小的数字来设计。

作为参照:在复杂页面上重新计算布局可能需要超过 100ms。那不是错过一帧,而是十几帧。

有两件事决定你能否保持在预算之内:

  1. 你动画化什么:每一帧的开销有多大。
  2. 它在哪里运行:这些工作是否会被页面正在做的其他所有事情阻塞。

你动画的是什么

为了展现视觉变化,浏览器最多会执行三个步骤:

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

每个属性都会在这三个环节之一进入这条流水线,而代价相差一个数量级:

层级属性代价
仅合成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/react 组件)始终在主线程
Web Animations API(element.animate()、Motion 的 animate())可硬件加速
CSS 过渡和关键帧可硬件加速

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

可稳定加速的属性:transform 和 opacity。正在获得支持的属性:filter、clip-path、background-color。

需要留意的模式是在页面执行繁重工作时仍在运行的动画:页面过渡、导航过程中的标签指示器,以及任何与数据获取或 hydration 同时触发的东西。Vercel 的仪表盘曾用 Framer Motion 的共享布局动画为其活动标签指示器添加动画。当时浏览器恰好在加载下一个页面,因此丢帧了。把它改为 CSS 动画后问题就解决了。

Framer Motion 可以被加速,但仅当 transform 是字符串形式时:

<motion.div animate={{ x: 100 }} />                          // main thread, can jank
<motion.div animate={{ transform: "translateX(100px)" }} />  // hardware-accelerated

x、y、scale、rotate 这些简写是用 CSS 变量实现的,而带 CSS 变量的动画不会被加速。简写可读性更好,所以默认保留它们,当动画必须在主线程繁忙时仍保持流畅,再切换到字符串形式。

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

React:不要通过 state 来做动画

动画库在 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 变量。 当抽屉中放置超过约 20 个项目后,Vaul 的拖拽变得卡顿,且没有涉及任何重新渲染。拖拽距离被写入一个由 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 动画随着内容增长而变差时,就要怀疑这一点。这与 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:找出哪些内容在播放动画

列出范围内每个动画及其驱动方式和属性:

元素驱动方式属性播放时机
Tab 指示器motion/react layoutIdlayout路由变化
抽屉CSS transitiontransform用户拖拽
卡片悬停CSS transitionall悬停

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

阶段 2:诊断

按顺序排查以下列表。列表按每项成为原因的频率排序:

  1. 哪些属性在播放动画? 任何不是 transform/opacity 的属性都是首要嫌疑:首先是布局属性,然后是 box-shadow 和 border-radius,它们看起来不像布局属性,很容易被忽略。
  2. 卡顿是否与页面繁忙同时发生? 导航、请求数据、水合、长任务。将动画移到 CSS 或 WAAPI。
  3. React 是否每帧都在重新渲染? rAF、pointer 或 scroll 处理函数中的状态更新。
  4. 内容增长时是否变得更糟? 继承的 CSS 变量,或同时播放动画的节点太多。
  5. 是否有 CSS 变量在播放动画? 直接动画,或通过 Motion 的 x/y/scale。
  6. 是否有 20px 以上的动画 blur()?
  7. 仍然在偏移或卡顿? 现在给正在播放动画的元素添加 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

按成本排列发现:布局先于绘制,主线程繁忙先于空闲。然后停下。在被要求时再应用修复。

评论

支持 Markdown