React 动画实现与性能优化
React 动画实现与性能优化
动画是提升用户体验的重要手段,精心设计的动画可以引导用户注意力、提供视觉反馈、增强应用的交互感。研究表明,恰当的动效能让用户感知到的加载时间下降约 20%,让操作的"确定感"显著提升。但反过来,一个卡顿掉帧的动画比没有动画更糟糕——它会直接暴露应用的性能短板,让用户产生"卡"的负面印象。React 提供了多种动画实现方式,从简单的 CSS 过渡到复杂的物理动画,开发者需要在"表现力"和"性能"之间做出权衡,根据需求选择合适的方案。
本文将系统梳理 React 动画的完整技术栈:CSS Transition/Animation、状态驱动动画、Framer Motion、React Spring、Lottie,以及贯穿始终的性能优化主线——60fps 的本质、浏览器渲染管线、合成层与 GPU 加速、FLIP 技术、prefers-reduced-motion 等。每个主题都遵循"概念 → 原理 → 代码 → 案例 → 数据 → 坑 → 最佳实践"的结构展开。
先理解目标:60fps 与渲染管线
什么是 60fps:
主流显示器刷新率为 60Hz,即每秒刷新 60 次。这意味着浏览器必须在 1000ms / 60 ≈ 16.67ms 内完成一帧的全部工作(JS 计算 + 样式计算 + 布局 + 绘制 + 合成)。实际上考虑到浏览器自身的开销,留给开发者的预算大约只有 10ms。一旦某一帧超时,就会发生"掉帧"(jank),用户就能感知到卡顿。在 120Hz 的高刷屏(如 iPhone ProMotion、许多安卓旗舰)上,预算进一步压缩到 8.33ms。
为什么重要:
60fps 是流畅动画的分水岭。低于 60fps 时人眼能明显感知到不连贯;低于 30fps 时体验会急剧劣化。理解每帧只有约 16ms 预算,是所有动画性能优化的出发点——我们所有的技巧(用 transform 而非 top、避免强制同步布局、利用合成层)本质上都是为了不让任何一帧超时。
浏览器渲染管线(Pixel Pipeline)原理:
浏览器把一帧的渲染分成五个阶段,每个阶段都可能成为瓶颈:
| 阶段 | 说明 | 触发成本 |
| --- | --- | --- |
| JavaScript | 执行脚本、计算样式变化、触发动画 | 取决于代码 |
| Style(样式计算) | 计算每个元素最终应用哪些 CSS 规则 | 中 |
| Layout(布局/重排) | 计算元素的几何位置和尺寸 | 高,会影响后代 |
| Paint(绘制/重绘) | 把元素绘制成像素位图(分图层) | 中到高 |
| Composite(合成) | 把各图层按正确顺序合成到屏幕 | 低,可交给 GPU |
关键结论:改动不同的 CSS 属性会进入管线的不同阶段。改 width/height/top/left 会触发从 Layout 开始的完整管线;改 color/background 会触发从 Paint 开始;而改 transform/opacity 只需要 Composite 阶段,成本最低,且能交给 GPU 处理。这就是"优先用 transform 和 opacity 做动画"这条黄金法则的底层原因。
动画实现方式详解
CSS 动画 - 高性能基础方案:
CSS 动画是实现动画效果的基础方案,包括 CSS Transitions 和 CSS Animations 两种方式。CSS Transitions 适合简单的状态变化动画,如悬停效果、展开收起等,通过 transition 属性定义过渡效果。CSS Animations 适合复杂的动画序列,通过 @keyframes 定义关键帧动画。CSS 动画的性能优势在于可以利用 GPU 加速,特别是 transform 和 opacity 属性的变化只触发合成层,不会导致重排和重绘。CSS 动画由浏览器的合成线程直接驱动,即使主线程(JS)被阻塞,动画依然能流畅运行,这是它相较 JS 动画的最大优势。
React 状态驱动动画:
使用 useState 和 useEffect 控制动画状态,可以实现复杂的动画逻辑,与业务逻辑紧密结合。通过状态变化触发 CSS 类名切换,实现声明式的动画控制。这种方式适合需要根据数据状态动态变化的动画,如列表项的添加删除、模态框的显示隐藏等。需要注意的是,React 中"卸载即消失",元素一旦从虚拟 DOM 移除就无法播放退场动画,这也是我们需要 AnimatePresence 这类工具的原因。
第三方动画库:
Framer Motion 是 React 生态中最流行的动画库,提供了声明式的动画 API,支持布局动画、手势动画、页面过渡等高级功能。React Spring 是基于物理的动画库,使用真实的物理弹簧模拟,动画效果更自然流畅。Lottie 可以将 Adobe After Effects 导出的 JSON 动画在 Web 端渲染,适合复杂的矢量动画。选择哪个库,取决于你的动画复杂度、包体积预算和团队熟悉程度。
一、CSS Transition 与 Animation
概念定义:
Transition(过渡)描述的是"从状态 A 变到状态 B 时的中间过程",它需要一个触发条件(如 :hover、类名切换)。Animation(动画)通过 @keyframes 定义一段独立的、可以自动播放和循环的关键帧序列,不依赖状态切换即可运行。
原理:
Transition 只有起点和终点两个状态,浏览器自动插值中间帧;Animation 可以定义任意多个关键帧(0% 到 100% 之间),控制更精细,还支持 animation-iteration-count(循环次数)、animation-direction(方向)、animation-fill-mode(填充模式)等。
代码示例:
// CSS 过渡动画
import './styles.css';
function FadeInComponent({ isVisible }) {
return (
<div className={`fade-container ${isVisible ? 'visible' : ''}`}>
<p>Content fades in and out</p>
</div>
);
}
// styles.css
// .fade-container {
// opacity: 0;
// transform: translateY(10px);
// transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
// }
// .fade-container.visible {
// opacity: 1;
// transform: translateY(0);
// }
// CSS 关键帧动画
function Spinner() {
return <div className="spinner" />;
}
// styles.css
// @keyframes spin {
// from { transform: rotate(0deg); }
// to { transform: rotate(360deg); }
// }
// .spinner {
// width: 40px;
// height: 40px;
// border: 4px solid #f3f3f3;
// border-top: 4px solid #3498db;
// border-radius: 50%;
// animation: spin 1s linear infinite;
// }
// 多关键帧的脉冲/心跳动画
function PulseButton({ children }) {
return <button className="pulse-btn">{children}</button>;
}
// styles.css
// @keyframes pulse {
// 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(52,152,219,0.6); }
// 70% { transform: scale(1.05); box-shadow: 0 0 0 12px rgba(52,152,219,0); }
// 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(52,152,219,0); }
// }
// .pulse-btn {
// animation: pulse 1.8s ease-out infinite;
// }
// 骨架屏加载动画(shimmer)
function SkeletonLine() {
return <div className="skeleton" />;
}
// styles.css
// @keyframes shimmer {
// 0% { background-position: -200% 0; }
// 100% { background-position: 200% 0; }
// }
// .skeleton {
// height: 16px;
// border-radius: 4px;
// background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
// background-size: 200% 100%;
// animation: shimmer 1.5s ease-in-out infinite;
// }真实案例:
GitHub 的"Star 按钮"点击后的星标飞入、Twitter/X 的点赞爱心迸发、绝大多数网站的骨架屏(Skeleton Screen)加载态,都是纯 CSS 动画实现的。它们的共同点是:动画简单、需要极致性能、且不希望占用主线程。骨架屏是 CSS 动画的经典应用——LinkedIn 在 2015 年首创后,Facebook、YouTube、Medium 纷纷跟进,因为它比转圈 loading 更能降低用户的焦虑感。
常见坑:
最佳实践:
二、React 状态驱动动画
概念定义:
通过 React 的状态(useState)控制 DOM 的类名或内联样式,从而声明式地触发 CSS 动画。这是把"动画状态"纳入 React 数据流管理的做法。
为什么重要:
很多动画本质上是数据驱动的:列表项增删、Tab 切换、手风琴展开、表单校验抖动。把这些动画交给状态管理,能让 UI 始终是状态的纯函数映射,符合 React 的心智模型。
代码示例:
import { useState, useEffect, useRef } from 'react';
// React 状态驱动动画:列表项进场
function AnimatedList({ items }) {
const [displayItems, setDisplayItems] = useState(items);
const addItem = (item) => {
setDisplayItems(prev => [...prev, { ...item, isNew: true }]);
setTimeout(() => {
setDisplayItems(prev =>
prev.map(i => i.id === item.id ? { ...i, isNew: false } : i)
);
}, 300);
};
return (
<ul className="animated-list">
{displayItems.map(item => (
<li
key={item.id}
className={`list-item ${item.isNew ? 'item-enter' : ''}`}
>
{item.name}
</li>
))}
</ul>
);
}
// 手写退场动画:卸载前先播放动画
function FadeUnmount({ show, children }) {
const [shouldRender, setShouldRender] = useState(show);
useEffect(() => {
if (show) setShouldRender(true);
}, [show]);
const onAnimationEnd = () => {
if (!show) setShouldRender(false);
};
if (!shouldRender) return null;
return (
<div
className={`fade ${show ? 'fade-in' : 'fade-out'}`}
onAnimationEnd={onAnimationEnd}
>
{children}
</div>
);
}
// styles.css
// @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
// @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
// .fade-in { animation: fadeIn 0.25s forwards; }
// .fade-out { animation: fadeOut 0.25s forwards; }
// 表单校验错误抖动
function ShakeInput({ error }) {
const [shake, setShake] = useState(false);
useEffect(() => {
if (error) {
setShake(true);
const t = setTimeout(() => setShake(false), 400);
return () => clearTimeout(t);
}
}, [error]);
return (
<input className={`input ${shake ? 'shake' : ''}`} />
);
}
// styles.css
// @keyframes shake {
// 0%, 100% { transform: translateX(0); }
// 20%, 60% { transform: translateX(-8px); }
// 40%, 80% { transform: translateX(8px); }
// }
// .shake { animation: shake 0.4s; }真实案例:
Ant Design、Material UI 等组件库的 Modal、Drawer、Message 组件,底层都用了这种"延迟卸载 + 类名切换"的模式来实现进场/退场动画。React 官方也曾维护 react-transition-group 库来标准化这个流程。
常见坑:
最佳实践:
用 onAnimationEnd/onTransitionEnd 事件替代 setTimeout 硬编码时长,更可靠;对于列表增删动画,优先考虑成熟库(Framer Motion 的 AnimatePresence)而非手写。
三、Framer Motion(现 Motion)
概念定义:
Framer Motion 是目前 React 生态最流行的声明式动画库(现已更名为 Motion)。核心是 motion 组件——把任意 HTML 标签升级为可动画元素,通过 initial/animate/exit 三个 props 描述动画的起点、目标和退场状态。
为什么重要:
它用最少的代码解决了 React 动画最棘手的几个问题:退场动画(AnimatePresence)、布局变化动画(layout prop,底层是 FLIP)、手势交互(drag/whileHover/whileTap)。声明式 API 极大降低了心智负担。
原理:
Framer Motion 内部维护一套独立于 React 渲染的动画引擎,通过 requestAnimationFrame 驱动,直接操作 DOM 的 style,绕开 React 的重渲染,因此性能很好。variants 机制允许父组件统一编排子组件的动画(stagger 交错)。
代码示例:
import { useState } from 'react';
import { motion, AnimatePresence, useAnimate, useScroll, useTransform } from 'framer-motion';
// Framer Motion 基础用法 + AnimatePresence 退场
function MotionComponent() {
const [isVisible, setIsVisible] = useState(true);
return (
<div>
<button onClick={() => setIsVisible(!isVisible)}>Toggle</button>
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
>
<p>Animated content</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// variants 编排 + stagger 交错动画
const containerVariants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.1 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 }
};
function StaggeredList({ items }) {
return (
<motion.ul variants={containerVariants} initial="hidden" animate="show">
{items.map(item => (
<motion.li key={item.id} variants={itemVariants}>
{item.name}
</motion.li>
))}
</motion.ul>
);
}
// Framer Motion 列表动画 + layout 动画(FLIP)
function MotionList({ items }) {
return (
<ul>
<AnimatePresence>
{items.map((item, index) => (
<motion.li
key={item.id}
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
transition={{ delay: index * 0.1 }}
layout
>
{item.name}
</motion.li>
))}
</AnimatePresence>
</ul>
);
}
// Framer Motion 手势动画:拖拽 + 悬停 + 点击
function DraggableCard() {
return (
<motion.div
drag
dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }}
dragElastic={0.2}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="card"
>
Drag me!
</motion.div>
);
}
// useAnimate:命令式动画序列
function SequenceButton() {
const [scope, animate] = useAnimate();
const runSequence = async () => {
await animate(scope.current, { scale: 1.2 }, { duration: 0.2 });
await animate(scope.current, { rotate: 360 }, { duration: 0.4 });
await animate(scope.current, { scale: 1, rotate: 0 }, { duration: 0.2 });
};
return <button ref={scope} onClick={runSequence}>Click me</button>;
}
// 滚动驱动动画:视差效果
function ParallaxHeader() {
const { scrollY } = useScroll();
const y = useTransform(scrollY, [0, 500], [0, 150]);
const opacity = useTransform(scrollY, [0, 300], [1, 0]);
return (
<motion.h1 style={{ y, opacity }}>
Scroll to see parallax
</motion.h1>
);
}页面过渡与共享布局:
// 页面过渡动画
import { motion, AnimatePresence } from 'framer-motion';
const pageVariants = {
initial: { opacity: 0, x: '-100vw' },
in: { opacity: 1, x: 0 },
out: { opacity: 0, x: '100vw' }
};
const pageTransition = {
type: 'tween',
ease: 'anticipate',
duration: 0.5
};
function PageTransition({ children }) {
return (
<motion.div
initial="initial"
animate="in"
exit="out"
variants={pageVariants}
transition={pageTransition}
>
{children}
</motion.div>
);
}
// 共享布局动画:layoutId 实现元素在两个位置间"飞行"
function SharedLayoutTabs({ tabs, active, onSelect }) {
return (
<div className="tabs">
{tabs.map(tab => (
<button key={tab.id} onClick={() => onSelect(tab.id)} className="tab">
{tab.label}
{active === tab.id && (
<motion.div className="underline" layoutId="tab-underline" />
)}
</button>
))}
</div>
);
}真实案例:
Framer 官网、Linear、Vercel Dashboard 大量使用 Framer Motion。共享布局动画(layoutId)是很多"卡片点击后平滑放大成详情页"效果的实现方式,典型如 Apple Music/Spotify 网页版的专辑卡片展开。
常见坑:
最佳实践:
四、React Spring
概念定义:
React Spring 是基于物理弹簧模型的动画库。它不用"时长 + 缓动曲线"来描述动画,而是用物理参数——质量(mass)、张力(tension)、摩擦力(friction)——来模拟真实世界的运动,因此动画天然带有惯性和回弹,更自然。
为什么重要:
基于时间的缓动动画在被打断时会显得生硬(比如动画进行到一半用户又触发了反向操作)。而弹簧动画是基于当前速度和位置连续计算的,任何时刻被打断都能平滑地转向新目标,交互手感更好。iOS 的绝大多数动画都是弹簧驱动的。
代码示例:
import { useState } from 'react';
import { useSpring, useTransition, useTrail, animated } from '@react-spring/web';
// React Spring 物理动画:翻转卡片
function SpringComponent() {
const [flip, setFlip] = useState(false);
const { transform, opacity } = useSpring({
opacity: flip ? 1 : 0,
transform: `perspective(600px) rotateX(${flip ? 180 : 0}deg)`,
config: { mass: 5, tension: 500, friction: 80 }
});
return (
<animated.div
onClick={() => setFlip(!flip)}
style={{ opacity: opacity.to(o => 1 - o), transform }}
>
Click to flip
</animated.div>
);
}
// React Spring 数字滚动动画
function AnimatedNumber({ value }) {
const { number } = useSpring({
from: { number: 0 },
number: value,
delay: 200,
config: { mass: 1, tension: 20, friction: 10 }
});
return <animated.span>{number.to(n => n.toFixed(2))}</animated.span>;
}
// useTransition:列表进出场动画
function TransitionList({ items }) {
const transitions = useTransition(items, {
keys: item => item.id,
from: { opacity: 0, transform: 'translate3d(0,-40px,0)' },
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
leave: { opacity: 0, transform: 'translate3d(0,40px,0)' },
config: { tension: 220, friction: 24 }
});
return transitions((style, item) => (
<animated.div style={style}>{item.name}</animated.div>
));
}
// useTrail:多个元素跟随式的"拖尾"动画
function TrailText({ words }) {
const trail = useTrail(words.length, {
from: { opacity: 0, x: 20 },
to: { opacity: 1, x: 0 },
config: { mass: 1, tension: 280, friction: 30 }
});
return (
<div style={{ display: 'flex', gap: 8 }}>
{trail.map((style, i) => (
<animated.span key={i} style={style}>{words[i]}</animated.span>
))}
</div>
);
}真实案例:
React Spring 被 Notion、Coinbase 等产品用于关键交互动画。数字滚动(AnimatedNumber)常见于数据看板、金额展示、点赞计数等场景。
常见坑:
最佳实践:
用 config.default 预设(如 config.gentle、config.wobbly、config.stiff)快速起步;被频繁打断的交互(拖拽、跟手)优先选 React Spring 而非基于时间的动画。
五、Lottie 矢量动画
概念定义:
Lottie 是 Airbnb 开源的方案,能把设计师用 After Effects 制作的复杂动画通过 Bodymovin 插件导出为 JSON,然后在 Web、iOS、Android 上原生渲染。
为什么重要:
对于品牌插画、引导页动画、复杂的加载动画(如支付成功的对勾动画),用代码手写成本极高。Lottie 让设计师直接产出可交付的动画资产,且矢量渲染在任意分辨率都清晰、体积远小于 GIF/视频。
代码示例:
import { useState, useRef } from 'react';
import Lottie from 'lottie-react';
import animationData from './animation.json';
// 基础 Lottie 动画
function LottieAnimation() {
const [isPlaying, setIsPlaying] = useState(true);
return (
<div>
<Lottie
animationData={animationData}
loop={true}
autoplay={isPlaying}
style={{ width: 200, height: 200 }}
/>
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
);
}
// 通过 ref 命令式控制播放段落(如按钮的 hover/click 分段动画)
function InteractiveLottie() {
const lottieRef = useRef(null);
return (
<div
onMouseEnter={() => lottieRef.current?.playSegments([0, 30], true)}
onMouseLeave={() => lottieRef.current?.playSegments([30, 60], true)}
>
<Lottie
lottieRef={lottieRef}
animationData={animationData}
autoplay={false}
loop={false}
style={{ width: 120, height: 120 }}
/>
</div>
);
}真实案例:
Airbnb 的加载动画、微信读书的书籍翻页、各类 App 的空状态插画、支付宝的"支付成功"对勾动画,很多都是 Lottie 实现的。
常见坑:
最佳实践:
用 dynamic import 懒加载动画 JSON;对纯装饰性动画包裹在 prefers-reduced-motion 判断里;复杂动画选 Canvas 渲染器。
性能优化策略(核心)
1. 使用 GPU 加速属性:
transform 和 opacity 是仅有的只触发合成阶段的 CSS 属性,不会导致重排和重绘。优先使用 translate、scale、rotate 代替 top、left、width、height 等属性。下面这组对比是性能优化的核心:
| 需求 | 差(触发重排/重绘) | 好(仅合成,GPU 加速) |
| --- | --- | --- |
| 移动元素 | left / top / margin | transform: translate() |
| 缩放元素 | width / height | transform: scale() |
| 旋转元素 | 无对应低效写法 | transform: rotate() |
| 隐藏/淡出 | visibility / display | opacity |
| 展开收起 | height: auto | transform: scaleY() 或 max-height |
2. 合成层与 will-change:
当一个元素被提升为独立的合成层(compositing layer)后,它的 transform/opacity 动画完全由 GPU 处理,不占用主线程。触发独立合成层的方式包括:3D transform(translate3d、translateZ(0))、will-change: transform、video/canvas 元素等。will-change 属性提前告知浏览器元素将要变化,让浏览器提前创建合成层并优化,但不要过度使用——每个合成层都占用 GPU 内存,滥用 will-change 反而会因为内存暴涨拖慢整体性能。正确做法是在动画开始前动态加上 will-change,动画结束后移除。
3. 避免强制同步布局(Layout Thrashing):
避免在动画中"读一次写一次"地交替读取和修改布局属性。读取 offsetWidth、offsetTop、getBoundingClientRect、scrollHeight 等属性会强制浏览器立即执行一次布局计算(因为它必须返回最新值)。如果在循环里读写交替,会触发几十上百次同步布局,帧率暴跌。正确做法是批量读、再批量写。
// 坏:读写交替,强制同步布局(layout thrashing)
function badResize(elements) {
elements.forEach(el => {
const width = el.offsetWidth; // 读,强制布局
el.style.width = width * 2 + 'px'; // 写,使布局失效
// 下一次循环再读,又强制布局……
});
}
// 好:先批量读,再批量写
function goodResize(elements) {
const widths = elements.map(el => el.offsetWidth); // 批量读
elements.forEach((el, i) => {
el.style.width = widths[i] * 2 + 'px'; // 批量写
});
}4. requestAnimationFrame 驱动 JS 动画:
如果必须用 JS 计算动画(如 Canvas、跟手交互),务必用 requestAnimationFrame 而不是 setInterval/setTimeout。rAF 会与屏幕刷新率同步,在每帧绘制前回调一次,避免丢帧和过度渲染,页面切到后台时还会自动暂停节省电量。
import { useEffect, useRef } from 'react';
// 用 requestAnimationFrame 实现平滑滚动到顶部
function useScrollToTop() {
const rafId = useRef(null);
const scrollToTop = () => {
const start = window.scrollY;
const startTime = performance.now();
const duration = 400;
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
const step = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
window.scrollTo(0, start * (1 - easeOutCubic(progress)));
if (progress < 1) {
rafId.current = requestAnimationFrame(step);
}
};
rafId.current = requestAnimationFrame(step);
};
useEffect(() => () => {
if (rafId.current) cancelAnimationFrame(rafId.current);
}, []);
return scrollToTop;
}5. FLIP 技术:
FLIP 是 First-Last-Invert-Play 的缩写,是实现高性能布局动画的经典技术,也是 Framer Motion layout 动画的底层原理。它的思路是:不去直接动画代价高昂的布局属性,而是先记录元素动画前(First)和动画后(Last)的位置,计算出差值(Invert,用 transform 把元素"假装"放回原位),然后用 GPU 加速的 transform 动画到最终位置(Play)。这样,一个涉及重排的布局变化被转换成了纯 transform 动画。
import { useLayoutEffect, useRef } from 'react';
// 手写 FLIP:让元素在位置变化时平滑过渡
function useFlip(dep) {
const ref = useRef(null);
const prevRect = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const last = el.getBoundingClientRect(); // Last
if (prevRect.current) {
const first = prevRect.current;
const dx = first.left - last.left; // Invert
const dy = first.top - last.top;
if (dx || dy) {
// 先瞬间放回原位(无过渡)
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.style.transition = 'none';
// 下一帧再动画到最终位置(Play)
requestAnimationFrame(() => {
el.style.transform = '';
el.style.transition = 'transform 0.3s ease';
});
}
}
prevRect.current = last;
}, [dep]);
return ref;
}6. React 层面的优化:
使用 React.memo 避免动画组件的不必要重新渲染。将动画状态与业务状态分离,避免动画每一帧都触发业务逻辑更新(这是 React 动画卡顿最常见的原因——把动画进度存进 state 导致每帧重渲染整棵树)。跟手/连续动画应绕过 React 直接操作 DOM 或用库的命令式 API。使用 CSS 变量动态控制动画参数,减少 JavaScript 计算。对于长列表动画,使用虚拟列表(react-window、react-virtuoso)减少 DOM 节点数量。
// 坏:把动画进度存进 state,每帧重渲染整个组件
function BadFollowMouse() {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const onMove = e => setPos({ x: e.clientX, y: e.clientY }); // 每次移动都 setState
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return <div style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }} />;
}
// 好:用 ref 直接操作 DOM,绕开 React 重渲染
function GoodFollowMouse() {
const ref = useRef(null);
useEffect(() => {
const onMove = e => {
if (ref.current) {
ref.current.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
}
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return <div ref={ref} />;
}
// 展开收起动画:优先 transform 而非 top/left
function OptimizedAnimation() {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div
className="optimized-container"
style={{
transform: isExpanded ? 'scale(1.1)' : 'scale(1)',
opacity: isExpanded ? 0.8 : 1,
transition: 'transform 0.3s ease, opacity 0.3s ease',
willChange: 'transform, opacity'
}}
onClick={() => setIsExpanded(!isExpanded)}
>
Optimized Animation
</div>
);
}性能基准对比
下表是各方案在典型场景下的量化对比(数值为经验估算,用于横向参考,实际因设备和动画复杂度而异):
| 方案 | 是否占用主线程 | 典型帧率 | 包体积(gzip) | 适用场景 |
| --- | --- | --- | --- | --- |
| CSS Transition | 否(合成线程驱动) | 稳定 60fps | 0 | 简单状态过渡、悬停 |
| CSS @keyframes | 否 | 稳定 60fps | 0 | loading、骨架屏、循环动画 |
| JS + setInterval | 是 | 易掉帧 30-50fps | 0 | 不推荐 |
| JS + requestAnimationFrame | 是 | 接近 60fps | 0 | Canvas、跟手交互 |
| Framer Motion | 部分(引擎绕开 React) | 稳定 60fps | 约 30-50KB | 复杂交互、退场、布局动画 |
| React Spring | 部分 | 稳定 60fps | 约 20KB | 物理弹簧、可打断交互 |
| Lottie | 是(SVG/Canvas 渲染) | 视复杂度 40-60fps | 约 60KB + JSON | 品牌插画、复杂矢量动画 |
再看具体属性的渲染成本对比(触发的管线阶段):
| CSS 属性 | 触发阶段 | 每帧相对成本 |
| --- | --- | --- |
| transform | 仅 Composite | 最低(可 GPU) |
| opacity | 仅 Composite | 最低(可 GPU) |
| color / background-color | Paint + Composite | 中 |
| box-shadow | Paint + Composite | 中高(绘制开销大) |
| width / height / top / left | Layout + Paint + Composite | 高(波及后代) |
可访问性考虑
动画设计需要考虑可访问性,尊重用户的 prefers-reduced-motion 设置。部分用户(前庭功能障碍、眩晕敏感人群)会在系统里开启"减少动态效果",我们必须尊重这个偏好。使用 CSS 媒体查询 @media (prefers-reduced-motion: reduce) 禁用或简化动画。提供动画开关选项,让用户可以自主选择。确保动画不会导致内容闪烁或引起光敏性癫痫——WCAG 规定内容闪烁频率不得超过每秒 3 次。
// CSS 层面:全局尊重 reduced-motion
// @media (prefers-reduced-motion: reduce) {
// *, *::before, *::after {
// animation-duration: 0.01ms !important;
// animation-iteration-count: 1 !important;
// transition-duration: 0.01ms !important;
// scroll-behavior: auto !important;
// }
// }
// JS 层面:用 Hook 读取用户偏好,动态决定是否播放动画
import { useState, useEffect } from 'react';
function usePrefersReducedMotion() {
const query = '(prefers-reduced-motion: reduce)';
const [reduced, setReduced] = useState(
() => typeof window !== 'undefined' && window.matchMedia(query).matches
);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = () => setReduced(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return reduced;
}
// 在 Framer Motion 中应用:偏好减少动态时禁用动画
function AccessibleMotion({ children }) {
const reduced = usePrefersReducedMotion();
return (
<motion.div
initial={reduced ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.3 }}
>
{children}
</motion.div>
);
}如何选型:决策指南
| 你的场景 | 推荐方案 | 理由 |
| --- | --- | --- |
| 悬停、简单过渡、loading | 纯 CSS | 零依赖、性能最佳 |
| Modal/Drawer 进出场 | Framer Motion (AnimatePresence) | 退场动画开箱即用 |
| 列表重排、卡片展开成详情 | Framer Motion (layout / layoutId) | 内置 FLIP |
| 拖拽、跟手、可被打断的交互 | React Spring | 弹簧物理连续可打断 |
| 品牌插画、复杂矢量动画 | Lottie | 设计师直接交付资产 |
| Canvas、粒子、自定义渲染 | requestAnimationFrame | 精细控制每一帧 |
总结
React 动画的核心不是"会用某个库",而是理解浏览器渲染管线并始终围绕 60fps 预算做取舍。记住三条主线:优先用 transform/opacity 让动画走 GPU 合成、把动画状态与业务状态解耦避免无谓重渲染、尊重用户的 prefers-reduced-motion 偏好。库只是工具——简单场景 CSS 就够,复杂交互再引入 Framer Motion 或 React Spring,品牌动画交给 Lottie。
| 维度 | 关键要点 |
| --- | --- |
| 性能黄金法则 | 只动画 transform 和 opacity,避开 Layout/Paint |
| 帧预算 | 60Hz 约 16.7ms/帧,120Hz 约 8.3ms/帧 |
| 合成层 | 用 will-change/translateZ 提升,但按需、用完即撤 |
| 避免陷阱 | 不读写交替触发强制同步布局;不把动画进度存进 state |
| JS 动画 | 一律用 requestAnimationFrame,配 cancelAnimationFrame 清理 |
| 布局动画 | FLIP 技术把重排转成 transform 动画 |
| 退场动画 | React 需延迟卸载或用 AnimatePresence |
| 库选型 | CSS < Framer Motion < React Spring < Lottie 逐级加重 |
| 可访问性 | 必须响应 prefers-reduced-motion,闪烁不超每秒 3 次 |
六、Framer Motion 进阶:MotionValue 与响应式动画
概念定义:
前面用的 initial/animate 是"声明式状态动画",而 Framer Motion 还有一套更底层的响应式原语——MotionValue。MotionValue 是一个游离于 React 渲染之外的可订阅数值容器,它的更新不会触发组件重渲染,而是直接写入 DOM 的 style。这正是 Framer Motion 高性能的秘密:一个跟手拖拽的元素,即使每秒更新 120 次坐标,React 组件本身一次都不会重渲染。
原理:
`useMotionValue(0)` 创建一个初始值为 0 的 MotionValue;`useTransform(mv, inputRange, outputRange)` 从一个 MotionValue 派生出另一个(做映射插值);`useSpring(mv, config)` 给一个 MotionValue 套上弹簧物理,让它对目标值的追随带惯性。它们组合起来可以搭出复杂的响应式动画管线,全程零重渲染。
代码示例:
import { motion, useMotionValue, useTransform, useSpring, useMotionValueEvent } from 'framer-motion';
// 卡片 3D 倾斜:鼠标位置驱动 rotateX / rotateY,全程不重渲染
function TiltCard() {
const x = useMotionValue(0);
const y = useMotionValue(0);
// 把 -0.5~0.5 的归一化坐标映射为 -15deg~15deg 的旋转
const rotateX = useTransform(y, [-0.5, 0.5], [15, -15]);
const rotateY = useTransform(x, [-0.5, 0.5], [-15, 15]);
// 套一层弹簧,让回中更自然(stiffness 越大越硬,damping 越大越不回弹)
const springX = useSpring(rotateX, { stiffness: 300, damping: 30 });
const springY = useSpring(rotateY, { stiffness: 300, damping: 30 });
const handleMove = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
x.set((e.clientX - rect.left) / rect.width - 0.5);
y.set((e.clientY - rect.top) / rect.height - 0.5);
};
const handleLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
onMouseMove={handleMove}
onMouseLeave={handleLeave}
style={{
rotateX: springX,
rotateY: springY,
transformPerspective: 800,
width: 240,
height: 320,
}}
className="tilt-card"
/>
);
}
// useMotionValueEvent:订阅数值变化做副作用(如埋点、节流上报),不触发重渲染
function ProgressWatcher() {
const { scrollYProgress } = useScroll();
useMotionValueEvent(scrollYProgress, 'change', (latest) => {
if (latest > 0.9) console.log('用户已滚到底部 90%');
});
return null;
}滚动进场:useInView 与 scroll offset:
Framer Motion 的 `useInView` 基于 IntersectionObserver 封装,用来实现"滚动到视口内才播放进场动画"。而 `useScroll({ target, offset })` 可以拿到某个元素相对视口的滚动进度(0~1),做局部滚动驱动。
import { useRef } from 'react';
import { motion, useInView, useScroll, useTransform } from 'framer-motion';
// 滚动到视口才淡入上移;once: true 表示只播一次
function RevealOnScroll({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: '0px 0px -100px 0px' });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 40 }}
animate={inView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, ease: 'easeOut' }}
>
{children}
</motion.div>
);
}
// 局部滚动进度驱动图片缩放(scroll-linked,随手指/滚轮实时联动)
function ScrollZoomImage({ src }: { src: string }) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'], // 元素顶部进入视口底部 到 元素底部离开视口顶部
});
const scale = useTransform(scrollYProgress, [0, 1], [1.2, 1]);
return (
<div ref={ref} style={{ overflow: 'hidden' }}>
<motion.img src={src} style={{ scale }} />
</div>
);
}按需加载减小体积:LazyMotion
完整引入 `motion` 组件会带来约 30-50KB(gzip)的体积。若只用基础动画,可用 `LazyMotion` + `domAnimation` 特性包把核心体积压到约 4.6KB,把 `motion.div` 换成 `m.div`:
import { LazyMotion, domAnimation, m } from 'framer-motion';
// 只加载基础动画特性(约 4.6KB),拖拽/布局动画用 domMax(约 15KB)
function App() {
return (
<LazyMotion features={domAnimation} strict>
<m.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
</LazyMotion>
);
}常见坑:
最佳实践:
七、Web Animations API(WAAPI):浏览器原生动画
概念定义:
Web Animations API 是浏览器原生提供的命令式动画接口,核心是 `element.animate(keyframes, options)`。它相当于"用 JS 写 @keyframes",但返回一个可控的 Animation 对象,能 pause/play/reverse/finish、监听 finished(Promise)、动态改 playbackRate。它由浏览器合成线程驱动,性能与 CSS 动画同级,却拥有 JS 的可编程性,且零依赖、零 bundle 成本。
原理:
WAAPI 与 CSS 动画共享同一套底层实现,因此同样能把 transform/opacity 动画放到合成层由 GPU 处理,不占用主线程。相比手写 requestAnimationFrame(每帧在主线程算插值),WAAPI 把插值交给浏览器引擎,主线程更空闲。现代浏览器(Chrome/Firefox/Safari 全支持)覆盖率已超过 95%。
代码示例:
import { useRef, useEffect } from 'react';
// 用 WAAPI 封装一个可复用的进场 Hook,零依赖
function useEnterAnimation<T extends HTMLElement>() {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const anim = el.animate(
[
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{
duration: 400,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)', // easeOutQuint,快进慢停
fill: 'both',
}
);
return () => anim.cancel();
}, []);
return ref;
}
// 可控播放:hover 加速、离开反向,用 finished Promise 串联序列
function ControllableBox() {
const ref = useRef<HTMLDivElement>(null);
const animRef = useRef<Animation | null>(null);
const start = async () => {
const el = ref.current;
if (!el) return;
animRef.current = el.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }],
{ duration: 300, fill: 'forwards' }
);
await animRef.current.finished; // 等动画完成再执行下一步
console.log('放大完成');
};
const reverse = () => animRef.current?.reverse();
return <div ref={ref} onMouseEnter={start} onMouseLeave={reverse} />;
}WAAPI vs CSS vs 库 对比:
| 维度 | CSS Animation | WAAPI | Framer Motion |
| --- | --- | --- | --- |
| bundle 成本 | 0 | 0 | 约 30-50KB |
| 是否占主线程 | 否 | 否 | 部分 |
| 可编程控制 | 弱(需切类名) | 强(play/pause/reverse) | 强 |
| 动态关键帧 | 不便 | 便捷(JS 对象) | 便捷 |
| 学习成本 | 低 | 中 | 中 |
常见坑:
最佳实践:
需要"CSS 的性能 + JS 的可控性"且不想加依赖时,WAAPI 是最优解;配合 finished Promise 能优雅地串联多段动画。
八、缓动函数(Easing)与手写 rAF 动画引擎
概念定义:
缓动函数决定动画进度随时间的变化曲线——线性(linear)显得机械,而 ease-in(慢进快出)、ease-out(快进慢停)、ease-in-out 更符合物理直觉。本质上它是一个把时间进度 t(0~1)映射为动画进度(0~1)的纯函数。
常用缓动函数实现:
// 一组常用缓动函数(t 均为 0~1)
const Easing = {
linear: (t: number) => t,
easeInQuad: (t: number) => t * t,
easeOutQuad: (t: number) => t * (2 - t),
easeInOutCubic: (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
easeOutQuint: (t: number) => 1 - Math.pow(1 - t, 5),
// 回弹(overshoot):先冲过头再拉回,弹性手感
easeOutBack: (t: number) => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
// 弹跳
easeOutBounce: (t: number) => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) return n1 * t * t;
if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75;
if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375;
return n1 * (t -= 2.625 / d1) * t + 0.984375;
},
};
// 通用 rAF 补间动画引擎:从 from 到 to,带缓动与完成回调
function animateValue(
from: number,
to: number,
duration: number,
easing: (t: number) => number,
onUpdate: (v: number) => void,
onComplete?: () => void
) {
const startTime = performance.now();
let rafId = 0;
const tick = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = easing(progress);
onUpdate(from + (to - from) * eased);
if (progress < 1) {
rafId = requestAnimationFrame(tick);
} else {
onComplete?.();
}
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId); // 返回取消函数
}帧率无关(delta time)的连续动画:
对于持续运行的动画(如无限滚动、粒子),不能假设每帧固定 16.7ms,否则在 120Hz 屏上速度会翻倍、在掉帧时会顿挫。正确做法是用两帧之间的时间差(delta)来推进:
import { useEffect, useRef } from 'react';
// 帧率无关的匀速旋转:无论 60Hz 还是 120Hz,视觉速度一致
function useSpin(speedDegPerSec = 90) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let angle = 0;
let last = performance.now();
let rafId = 0;
const loop = (now: number) => {
const delta = (now - last) / 1000; // 转成秒
last = now;
angle = (angle + speedDegPerSec * delta) % 360;
if (ref.current) ref.current.style.transform = `rotate(${angle}deg)`;
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafId);
}, [speedDegPerSec]);
return ref;
}常见坑:
最佳实践:
把补间引擎抽成可复用工具,通过传入不同 easing 复用;连续动画一律 delta time 驱动;每个 rAF 循环都要在卸载时 cancelAnimationFrame 清理。
九、滚动驱动动画:IntersectionObserver 与 CSS 原生方案
概念定义:
滚动驱动动画分两类:一是"滚动触发"(scroll-triggered,元素进入视口后播一次进场),二是"滚动联动"(scroll-linked,动画进度实时绑定滚动位置,如视差、进度条)。前者首选 IntersectionObserver(避免监听 scroll 事件带来的高频回调),后者可用 CSS 原生的 scroll-timeline / view-timeline。
IntersectionObserver 实现滚动进场:
import { useEffect, useRef, useState } from 'react';
// 通用"进入视口才显示"Hook,性能远优于监听 scroll 事件
function useInViewport<T extends HTMLElement>(options?: IntersectionObserverInit) {
const ref = useRef<T>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.unobserve(entry.target); // 只触发一次,触发后停止观察省开销
}
}, { threshold: 0.15, ...options });
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, visible };
}
function FadeInSection({ children }: { children: React.ReactNode }) {
const { ref, visible } = useInViewport<HTMLDivElement>();
return (
<div
ref={ref}
style={{
opacity: visible ? 1 : 0,
transform: visible ? 'translateY(0)' : 'translateY(30px)',
transition: 'opacity 0.6s ease, transform 0.6s ease',
}}
>
{children}
</div>
);
}CSS 原生滚动驱动动画(scroll-timeline / view-timeline):
Chrome 115+ 起支持纯 CSS 的滚动驱动动画,完全跑在合成线程,连 rAF 都不用写。`animation-timeline: scroll()` 把动画进度绑定到滚动容器,`view()` 绑定到元素在视口中的可见进度。
/* 顶部阅读进度条:随页面滚动从 0 拉伸到 100%,纯 CSS,零 JS */
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
width: 100%;
transform-origin: left;
background: #3498db;
animation: grow-progress linear;
animation-timeline: scroll(root block); /* 绑定根滚动容器的垂直滚动 */
}
/* 图片进入视口时淡入上移:view() 绑定元素自身可见进度 */
@keyframes reveal {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal-on-scroll {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%; /* 从刚进入视口到进入 60% 之间完成 */
}@property 让渐变/自定义属性可动画:
默认自定义属性(CSS 变量)不可插值动画,用 `@property` 声明类型后即可平滑过渡,常用于渐变角度、颜色滚动等:
@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
@keyframes rotate-gradient {
to { --gradient-angle: 360deg; }
}
.conic-border {
background: conic-gradient(from var(--gradient-angle), #3498db, #9b59b6, #3498db);
animation: rotate-gradient 3s linear infinite;
}常见坑:
最佳实践:
滚动触发一次性进场用 IntersectionObserver 并在触发后 unobserve;滚动联动优先尝试 CSS 原生方案,不支持再降级到 Framer Motion 的 useScroll。
十、SVG 路径动画与线条绘制
概念定义:
SVG 动画能做出用 DOM/CSS 难以实现的效果,最经典的是"线条自我绘制"(如签名、对勾、图标描边逐渐画出)。核心技巧是利用 `stroke-dasharray`(虚线间隔)和 `stroke-dashoffset`(虚线偏移):把整条路径设成一段等于路径总长的虚线,再从"完全偏移出去"动画到"偏移为 0",视觉上就是线条被逐渐画出来。
代码示例:
import { useRef, useEffect } from 'react';
// SVG 对勾"绘制"动画:常用于"提交成功"反馈
function AnimatedCheck() {
const pathRef = useRef<SVGPathElement>(null);
useEffect(() => {
const path = pathRef.current;
if (!path) return;
const length = path.getTotalLength(); // 拿到路径总长度
path.style.strokeDasharray = String(length);
path.style.strokeDashoffset = String(length); // 先"藏起来"
// 强制重排后再触发过渡,确保动画生效
path.getBoundingClientRect();
path.style.transition = 'stroke-dashoffset 0.6s ease-out';
path.style.strokeDashoffset = '0'; // 画出来
}, []);
return (
<svg width="80" height="80" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="36" fill="none" stroke="#2ecc71" strokeWidth="4" />
<path
ref={pathRef}
d="M24 42 L36 54 L58 28"
fill="none"
stroke="#2ecc71"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}也可用纯 CSS 配合 @keyframes 实现同样效果(性能更好,走合成线程):
.draw-path {
stroke-dasharray: 200;
stroke-dashoffset: 200;
animation: draw 0.8s ease-out forwards;
}
@keyframes draw {
to { stroke-dashoffset: 0; }
}常见坑:
最佳实践:
简单描边动画能用纯 CSS 就别用 JS;对勾/加载/图标微动效用 SVG + dashoffset 体积极小(几乎为 0)且清晰无损。
十一、动画性能剖析:用 DevTools 找出掉帧根因
概念定义:
优化的前提是测量。Chrome DevTools 提供了几套定位动画性能问题的利器:Performance 面板(火焰图 + 帧率轨道)、Rendering 面板的 FPS meter / Paint flashing / Layer borders,以及 Layers 面板。
排查流程(原理 + 操作):
| 工具 | 位置 | 看什么 | 定位的问题 |
| --- | --- | --- | --- |
| FPS meter | Rendering 面板 | 实时帧率曲线 | 是否掉帧、掉到多少 |
| Paint flashing | Rendering 面板 | 重绘区域高亮闪绿 | 有没有本不该重绘的区域在重绘 |
| Layer borders | Rendering 面板 | 合成层橙色边框 | 元素是否被提升为合成层 |
| Performance 火焰图 | Performance 面板 | Main 轨道长任务、紫色 Layout 块 | 强制同步布局、长任务阻塞 |
| Frames 轨道 | Performance 面板 | 每帧耗时、红色掉帧标记 | 哪一帧超了 16.7ms 预算 |
火焰图读法:
录制一段动画后,看 Main 线程轨道。紫色代表 Layout/重排,绿色代表 Paint,若在动画期间反复出现密集紫色块,说明触发了强制同步布局(Layout Thrashing)——右上角还会有红色警告三角 "Forced reflow"。理想的 transform/opacity 动画在录制里 Main 线程应该几乎是空的,工作都在 Compositor 和 GPU 线程。
// 用 PerformanceObserver 在生产环境采集长任务(>50ms)做监控上报
import { useEffect } from 'react';
function useLongTaskMonitor() {
useEffect(() => {
if (!('PerformanceObserver' in window)) return;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 超过 50ms 的任务会阻塞渲染,动画期间尤其致命
if (entry.duration > 50) {
console.warn(`长任务 ${entry.duration.toFixed(1)}ms,可能导致掉帧`);
}
}
});
observer.observe({ entryTypes: ['longtask'] });
return () => observer.disconnect();
}, []);
}
// 手动测量一段动画的实际帧率
function measureFps(durationMs = 1000): Promise<number> {
return new Promise((resolve) => {
let frames = 0;
const start = performance.now();
const tick = () => {
frames++;
if (performance.now() - start < durationMs) {
requestAnimationFrame(tick);
} else {
resolve(Math.round((frames * 1000) / (performance.now() - start)));
}
};
requestAnimationFrame(tick);
});
}性能预算参考:
| 指标 | 目标值 | 说明 |
| --- | --- | --- |
| 单帧总耗时 | < 16.7ms(60Hz) | 超出即掉帧 |
| 单帧 JS 执行 | < 10ms | 留出样式/布局/绘制时间 |
| 高刷屏单帧 | < 8.3ms(120Hz) | ProMotion / 安卓旗舰 |
| 长任务阈值 | 50ms | 超过即判定为 long task |
| 交互响应(INP) | < 200ms | Core Web Vitals 良好线 |
| 合成层数量 | 适度(几十个内) | 过多耗 GPU 内存反成负担 |
常见坑:
最佳实践:
先测量再优化,用 Performance 火焰图定位是 Layout 还是 Paint 瓶颈;用 CPU throttling 复现低端机表现;生产环境用 PerformanceObserver 采集 longtask 和 INP 做长期监控。
十二、动画库全景选型对比
综合体积、能力、心智模型给出一张更完整的选型速查表(体积为 gzip 后经验值,随版本浮动):
| 库 / 方案 | bundle(gzip) | 动画模型 | 强项 | 短板 | 何时选它 |
| --- | --- | --- | --- | --- | --- |
| 纯 CSS / WAAPI | 0 | 时间 + 缓动 | 零成本、合成线程、最稳 | 复杂编排繁琐 | 简单过渡、loading、进度条 |
| Framer Motion | 约 30-50KB(可 LazyMotion 降到 4.6KB) | 声明式 + 弹簧 + MotionValue | 退场、布局 FLIP、手势、生态 | 全量体积偏大 | 交互丰富的产品级应用 |
| React Spring | 约 20KB | 纯物理弹簧 | 可打断、跟手手感 | API 偏底层、上手略陡 | 拖拽、跟手、连续可打断交互 |
| GSAP | 约 45KB | 时间线(timeline) | 复杂时间线编排、跨框架、兼容性强 | 商用部分插件收费、非 React 原生 | 复杂剧情动画、营销页 |
| React Transition Group | 约 5KB | 生命周期钩子 | 轻量、只管进出场 | 需自己写 CSS、能力有限 | 只需要进出场且想极致轻量 |
| Lottie | 约 60KB + JSON | AE 导出回放 | 设计师直接交付复杂矢量 | JSON 大、低端机可能掉帧 | 品牌插画、复杂矢量动画 |
| Auto-Animate | 约 3KB | 自动 FLIP | 一行代码给列表加动画 | 只能做增删移位 | 快速给列表/表单加过渡 |
选型决策口诀:
能 CSS 就 CSS;要退场/布局/手势上 Framer Motion;跟手可打断选 React Spring;复杂时间线找 GSAP;设计师产出的复杂矢量交给 Lottie;只想给列表一行代码加动画用 Auto-Animate。永远从"最轻的够用方案"起步,别一上来就引入重型库。
结语:动画工程师的心智模型
React 动画的进阶,本质是从"会调库的 API"升级到"理解每一帧发生了什么"。四条贯穿全文的主线值得反复咀嚼:第一,所有动画最终都要落到浏览器渲染管线,能只碰 transform/opacity 就不碰 Layout/Paint;第二,把动画数值从 React 渲染循环里剥离出去(MotionValue、ref 直接写 DOM、WAAPI、CSS),是高性能的共同秘诀;第三,测量先于优化,DevTools 火焰图和帧率轨道是判断的唯一依据,而非直觉;第四,动画是为人服务的,尊重 prefers-reduced-motion、控制闪烁频率、给出关闭开关,才是完整的工程实践。
| 进阶维度 | 一句话要点 |
| --- | --- |
| MotionValue | 数值游离于渲染之外,跟手动画零重渲染 |
| WAAPI | 原生、零 bundle、有 CSS 的性能与 JS 的可控 |
| 缓动与 rAF | 连续动画用 delta time,帧率无关才不漂移 |
| 滚动驱动 | 触发用 IntersectionObserver,联动优先 CSS scroll-timeline |
| SVG 描边 | stroke-dasharray + dashoffset,体积几乎为零 |
| 性能剖析 | 先测量:FPS meter、Paint flashing、火焰图找紫色 Layout |
| 库选型 | 从最轻够用起步,CSS → Framer/Spring → GSAP/Lottie |
| 帧预算 | 60Hz 约 16.7ms,120Hz 约 8.3ms,JS 部分留在 10ms 内 |