Community编程与开发github.com

Bront888/动画性能

将网页动画控制在帧预算内——仅动画合成器属性,当页面繁忙时保持动画在主线程外运行,并找出动画掉帧的原因。涵盖布局/绘制/合成流程、更经济的属性替换、CSS vs WAAPI vs requestAnimationFrame、Framer Motion的硬件加速路径、每帧的React重渲染、继承CSS变量的重新计算、模糊的成本,以及`will-change`何时真正有效。当动画出现卡顿、跳动、断断续续或掉帧时使用;当选择要动画的属性时使用;当在CSS和JS动画之间做性能决策时使用;或者当考虑使用`will-change`或GPU层时使用。如需选择缓动、持续时间或是否动画,请使用`animate`;如需全面审查运动,请使用`review-animations`。

动画性能 是什么?

该技能旨在诊断并修复网页动画中的性能问题,确保每一帧都能按时完成。它专注于分析动画为何掉帧或卡顿,而不是判断运动本身是否美观(后者由其他技能处理)。技能基于设计工程哲学,强调性能是让良好运动得以被感知的基础,而非后期抛光步骤。它涵盖的核心内容包括:帧预算(60fps为16.7ms,120Hz屏幕为8ms)、属性选择(优先使用`transform`和`opacity`这类仅触发合成的属性)、执行线程(CSS和WAAPI可硬件加速,脱离主线程运行)、React中通过状态更新的陷阱、继承CSS变量的开销、`filter: blur()`的成本以及`will-change`的正确使用时机。 该技能提供了一套系统的工作流程:首先列出所有动画及其驱动器和属性,然后按优先级顺序进行诊断(属性选择、主线程负载、React重渲染、内容增长影响等),最后形成结构化的报告。技能还给出了一张症状表,帮助用户快速匹配问题与解决方案,例如动画卡顿对应应使用`transform`/`opacity`,拖拽随列表增长而变卡对应停止动画化继承的CSS变量等。它适用于各类开发环境,包括标准Web应用、使用Framer Motion或Motion的React项目等。

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

Installed? Explore more 编程与开发 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

在你喜欢的 AI 中提问

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

文档

动画性能 是做什么的?

初始响应

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

我已准备好找出你的动画掉帧的原因并使其流畅,我的知识来源于 Emil Kowalski 的设计工程哲学。

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

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

为什么这很重要

想象一下以 30fps 运行的 Sonner。同样的缓动、同样的持续时间、同样的代码。没有人会在意。性能不是运动设计好之后才进行的润色处理;它是让好的运动能够被感知到的基础。

工作姿态

你是一名设计工程师,在真实设备上发布过抽屉、提示框和仪表盘,并且下面列出的每一个陷阱都在生产环境中咬过你。你知道开发者的 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
运动开始或结束时元素偏移 1px仅在看到时添加 will-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。

要注意的模式是,当页面正在执行繁重工作时运行的运动:页面过渡、导航期间的标签指示器、任何与数据获取或水合一起触发的东西。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 layoutIdlayout路由变化
抽屉CSS transitiontransform用户拖拽
卡片悬停CSS transitionall悬停

将 transition: all 和 Motion 的速记属性展开为它们实际动画化的内容。

阶段 2:诊断

按此列表排查。顺序按每种原因出现的频率排列:

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

通过录屏确认,而不是靠感觉:使用 DevTools 的性能面板查看掉帧和长任务,使用动画面板减慢运动速度。通过远程调试在真实的中端手机上进行分析。在桌面上使用 CPU 节流模拟的是慢速 CPU,而不是弱 GPU 或小的内存预算。

阶段 3:报告

对于每个发现:

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

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

相关技能