3.9 动画 本节摘要:React 里的动画有一条从轻到重的路径:CSS 过渡处理简单状态变化、CSS 动画做复杂序列、react-transition-group 管理组件进出场、react-spring 与 framer-motion 提供物理动画与手势支持。本节按这条路径逐个上手,并给出「按效果复杂度选方案」的决策标准。 本节导航 阅读完本节,你应当能够: 用 CSS 过渡为状态变化添加平滑效果 用 @keyframes 定义复杂的 CSS 动画序列 用 react-transition-group 管理组件的进入退出过渡 理解 react-spring 的物理动画模型 根据效果复杂度选择合适的动画方案 一、问题与直觉:动画的「甜点区」在哪 动画不是越多越好。
本节摘要:React 里的动画有一条从轻到重的路径:CSS 过渡处理简单状态变化、CSS 动画做复杂序列、react-transition-group 管理组件进出场、react-spring 与 framer-motion 提供物理动画与手势支持。本节按这条路径逐个上手,并给出「按效果复杂度选方案」的决策标准。
阅读完本节,你应当能够:
动画不是越多越好。恰到好处的动画让界面有质感——弹窗出现时的淡入、列表项删除时的收拢、按钮按压时的反馈。而滥用的动画让应用显得浮夸、拖沓。
React 里做动画有两条路线:CSS 动画(浏览器原生,性能好)和 JS 动画库(灵活,可做复杂交互)。选择的核心标准是「效果复杂度」:简单的过渡用 CSS,复杂的序列和手势用库。
SOURCE 原文按这个思路组织:CSS 过渡 → CSS 动画 → react-transition-group → react-spring → framer-motion,从最轻到最重。本节沿这条路径走,最后给选型表。
CSS 过渡处理「属性从一个值平滑变到另一个值」。React 里的做法是:切换 className,CSS 负责过渡。
import { useState } from 'react'; function TransitionExample() { const [isVisible, setIsVisible] = useState(false); return ( <div> <button onClick={() => setIsVisible(!isVisible)}> 切换显示 </button> <div className={`box ${isVisible ? 'visible' : ''}`}> 带过渡的盒子 </div> </div> ); }
配套 CSS:
.box { opacity: 0; transition: opacity 0.5s ease-in-out; } .box.visible { opacity: 1; }
点按钮 → isVisible 变化 → className 切换 → opacity 从 0 平滑到 1。.box.visible 不是从 0 直接跳到 1,而是按 transition 规则在 0.5 秒内渐变。transition 是「两个状态之间的渐变」,它本身不产生新状态,只是让状态切换的过程更平滑——这个「过程」正是动画的本质。
| 优点 | 代价 |
|---|---|
| 浏览器原生,性能好 | 只能过渡简单属性 |
| 代码量小 | 无法做复杂序列 |
| 无需引库 | 与 JS 交互有限 |
CSS 过渡在 React 里的一个关键点是「初始态与目标态的分离」。过渡发生的条件是两个状态都存在且有差异——首次渲染时如果没有「初始态」,就不会有过渡。常见做法是给元素一个默认 class 作为初始态,条件变化时切换目标 class:
// 初始态 opacity 0,目标态 opacity 1 <div className={`panel ${open ? 'panel-open' : ''}`}>
另外一个细节:过渡属性要写清楚「过渡什么」。transition: all 会过渡所有可动画属性,看似省事,但一次状态变化会连带一堆无关属性动起来,既浪费性能又难控制。明确列出要过渡的属性(如 transition: opacity 0.5s, transform 0.3s),是更精细也更好的写法。
有人试图用 React 内联 style 动态改 opacity 来触发过渡——这在 React 里天然别扭。因为 React 的渲染机制是「整棵重渲染」,内联 style 每次都是整体替换,浏览器分不清「哪个属性在过渡」。CSS 过渡的正确姿势永远是「className 切换 + CSS 规则负责过渡」,React 只负责切换状态,动画逻辑留给 CSS。这条「React 管状态、CSS 管动画」的分工是动画实践的第一原则。
过渡只能处理「一个状态到另一个状态」,动画用 @keyframes 定义多阶段序列:
import './Animation.css'; function AnimationExample() { return ( <div className="container"> <div className="box animation-box"> 脉动的盒子 </div> </div> ); }
@keyframes pulse { 0% { transform: scale(1); } 100% { transform: scale(1.2); } } .animation-box { animation: pulse 2s infinite alternate; }
pulse 动画让盒子在 1 倍和 1.2 倍之间缩放,2 秒一个周期,infinite 无限循环,alternate 往返播放。@keyframes 的每个百分比点定义一个阶段的状态,浏览器负责阶段间插值。相比过渡,@keyframes 能表达「从 0% 到 50% 到 100%」的多阶段变化,动画中途还可以暂停、反转、调整播放次数。
CSS 动画能做过渡做不了的事:多阶段序列、循环、反向播放。但和 JS 的交互依然有限——动画触发后,JS 只能控制「要不要播、播到哪」。
CSS 动画有个痛点:组件卸载时动画「来不及播完」——React 直接把元素从 DOM 移除,淡出动画被截断。react-transition-group 解决「进出场动画和组件生命周期对齐」的问题。
import { useState } from 'react'; import { CSSTransition } from 'react-transition-group'; function Example() { const [isVisible, setIsVisible] = useState(false); return ( <div> <button onClick={() => setIsVisible(!isVisible)}> 切换显示 </button> <CSSTransition in={isVisible} timeout={300} classNames="fade" unmountOnExit mountOnEnter > <div>淡入淡出的内容</div> </CSSTransition> </div> ); }
配套 CSS:
.fade-enter { opacity: 0; } .fade-enter-active { opacity: 1; transition: opacity 300ms; } .fade-exit { opacity: 1; } .fade-exit-active { opacity: 0; transition: opacity 300ms; }
关键机制:classNames="fade" 让 CSSTransition 自动加后缀类——进入时依次挂 fade-enter、fade-enter-active,退出时挂 fade-exit、fade-exit-active。unmountOnExit 让动画结束后才卸载组件,解决了「卸载截断动画」的问题。timeout={300} 告诉 CSSTransition 动画持续多久,React 据此安排状态推进时机,CSS 与 JS 的时钟必须对得上,动画才不会错乱。
这是组件进出场动画的状态机:enter → enter-active → enter-done → exit → exit-active → exit-done,每对 enter/active 之间由 CSS transition 驱动视觉变化,React 负责状态推进。
| 优点 | 代价 |
|---|---|
| 进出场动画与生命周期对齐 | 需引入库 |
| 与 React 组件深度集成 | 配置相对繁琐 |
react-transition-group 还有兄弟组件 TransitionGroup,专治「列表项增删的动画」。它的作用是跟踪列表项的身份(靠 key),只对「新增或移除」的项播放进出场动画,其余项不动:
import { TransitionGroup, CSSTransition } from 'react-transition-group'; function TodoList({ todos }) { return ( <TransitionGroup> {todos.map(todo => ( <CSSTransition key={todo.id} timeout={300} classNames="item"> <li>{todo.text}</li> </CSSTransition> ))} </TransitionGroup> ); }
删除一个 todo 时,React 不会立刻卸载——先播完 fade-out,再真正移除。这正是第 2.4 节列表渲染的 key 机制在动画里的延伸:key 让 TransitionGroup 知道「谁进、谁出、谁没动」。没有 key 或 key 不稳定,列表动画会错乱。
react-spring 用「弹簧物理模型」驱动动画——动画不是固定时长,而是模拟真实的物理运动(弹跳、阻尼、惯性),效果更自然。
import { useState } from 'react'; import { useSpring, animated } from 'react-spring'; function SpringExample() { const [isToggled, setIsToggled] = useState(false); const props = useSpring({ opacity: isToggled ? 1 : 0, transform: isToggled ? 'translateX(0)' : 'translateX(-100px)', }); return ( <div> <button onClick={() => setIsToggled(!isToggled)}> 切换位置 </button> <animated.div style={props}> 物理动画的盒子 </animated.div> </div> ); }
useSpring 声明「目标值」(isToggled 决定 opacity 和 transform 的目标),react-spring 从当前值开始按物理模型模拟动画。animated.div 是 react-spring 提供的组件,用于应用动画值。它还支持 useTransition(列表进出场)、useTrail(元素依次入场)等进阶 Hook,覆盖大部分物理动画场景。
物理模型的价值:动画的缓动不再是死的 ease-in-out,而是带「超调」的真实感——盒子弹到位还会轻微晃动一下。这种质感是 CSS 缓动给不了的,也是 react-spring 难以被替代的理由。
| 优点 | 代价 |
|---|---|
| 动画自然流畅 | 需引入库 |
| API 简洁 | 学习曲线陡 |
| 可做复杂交互 | 概念(弹簧参数)陌生 |
framer-motion 是目前功能最全的 React 动画库:声明式动画、手势(拖拽、点击缩放)、页面转场、布局动画一应俱全。它用 motion.div 等组件替代普通标签:
import { motion } from 'framer-motion'; function MotionExample() { return ( <motion.div initial={{ opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }} > 出场动画 </motion.div> ); }
initial 定义初始状态,animate 定义目标状态,transition 定义时长与缓动。声明式、可读、开箱即用——这是它成为新项目首选的原因。
framer-motion 之所以被称为「全家桶」,是因为它把动画里最常见的能力都做成了声明式 API:
手势响应。hover、tap、drag 直接作为 props 接入,悬停缩放、按压缩放、拖拽排序都不用手写事件逻辑:
<motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} drag="x" dragConstraints={{ left: -50, right: 50 }} > 可拖拽按钮 </motion.button>
页面转场。配合路由做「进入时滑入、离开时滑出」的转场,framer-motion 提供成套方案,比手写 CSS 转场在处理「进出同步」上省心得多。
布局动画。给元素加 layout 属性,它改变位置时自动平滑过渡——列表排序、布局调整的动画一行搞定。这些能力如果手写,每一项都是一个小项目;framer-motion 把它们变成了「声明目标状态」而已。
选了 react-spring 或 framer-motion,要接受两件事:一是体积与首屏——动画库通常不小,配合路由级代码分割按需加载;二是团队成员都要会它的 API——动画库有学习曲线,团队里只有一个人会,动画代码就成了「看不懂的黑盒」。
这两个成本解释了第 3.1 节为什么反复强调「别为单次动画引库」:一个按钮 hover 用 CSS transition 三行解决,引 framer-motion 是给自己和团队添负担。动画库应该为「成规模、重复使用」的动效需求引入,而不是为「偶尔一次」的效果。
不是所有界面都值得做动画。经验上,三类动画对用户体验的回报最高:
状态切换的解释。折叠面板展开收起、弹窗出现关闭、列表项增删——这些「界面结构发生变化」的时刻,动画告诉用户发生了什么。没有动画的突兀变化,用户会困惑「刚才那个东西去哪了」。
操作反馈。按钮按压、提交 loading、成功/失败提示。反馈动画让用户知道「系统收到我的操作了」,是减少焦虑的隐形设计。
引导性动效。首屏入场、页面转场、滚动驱动的渐入。这类动画建立产品质感,但最容易被滥用——每屏都动,用户反而烦躁。
判断「这个动画该不该做」的标准:删掉它,用户体验会不会变差? 会,就做;不会,就别做。动画是「状态变化的信息载体」,不是装饰品——这句话是动画设计的第一原则。
动画本质上是「状态的连续过渡」,和 React 的状态管理天然相关。动手时的分工是:React state 决定「动画的起点和终点」,动画库/CSS 决定「中间过程怎么走」。 组件里只管切换状态(open 变 true、visible 变 false),动画的中间帧交给工具。这个分工如果混了——组件里手动控制每一帧——代码就会变成状态爆炸和性能灾难。
| 需求 | 方案 |
|---|---|
| 简单属性过渡 | CSS transition |
| 多阶段序列/循环 | CSS @keyframes |
| 组件进出场 | react-transition-group |
| 自然物理质感 | react-spring |
| 手势/页面转场/全家桶 | framer-motion |
💡 关键直觉:选型看「复杂度换复杂度」。一个按钮的 hover 效果用 CSS transition 就够,别引动画库;要做拖拽排序、页面转场这种重交互,framer-motion 值得。动画库是最后一个选项,不是第一个。
⚠️ 常见坑:滥用动画。每个元素都做动画,页面像在跳广场舞,用户烦、性能差。动画要服务于「状态变化的解释」——弹窗出现告诉用户「有东西进来了」,删除项收拢告诉用户「它走了」。没有信息量的动画都是噪音。
动画做多之前,先记住三条性能纪律:
优先 transform 和 opacity。这两个属性由 GPU 合成,动画不触发重排;改 width、top 这类布局属性会触发重排,卡顿。能动画 transform/opacity 就别动画其他属性。
避免同时动画大量元素。几十个元素同时动,即使走 GPU,合成层压力也大。节流动画元素的数量。
尊重用户的「减少动态效果」偏好。系统和浏览器提供「prefers-reduced-motion」设置,尊重它的应用会关闭非必要动画。用 CSS media query 或 JS 检测,是成熟产品的加分项。
@media (prefers-reduced-motion: reduce) { .box { transition: none; animation: none; } }
这段 CSS 在用户开启「减少动态效果」时,关闭所有过渡与动画。一行 CSS,换来对可访问性的尊重——值得加进每个项目。
动画出问题时,先分清是「动画没触发」还是「动画卡顿」。没触发:检查 className 是否真的切换了、transition 属性是否写对、初始态和目标态是否都有。卡顿:打开浏览器 Performance 面板录制,看动画帧率,若大量 Recalculate Style 说明动画了布局属性,改成 transform/opacity。
下一节看 TypeScript 与类型检查——动画让应用「好看」,类型让代码「可靠」。静态类型在编译期拦错,PropTypes 在运行时提醒,两条路线把「写错了没人管」变成「写错就报错」。这是大型项目从「能跑」走向「可维护」的必备能力。