React 19 动画完全指南:从零到丝滑
一、React 中的动画是什么?
1.1 生活化类比:动画就是"可视化的故事"
想象你在翻一本连环画:
- 每一页是一个画面(对应 React 的一个状态)
- 快速翻页时,画面就"动"起来了(对应 React 的状态变化驱动视觉变化)
而 React 动画的本质就是:让组件的状态变化过程变得平滑、可见、有节奏感,而不是"唰"的一下瞬间切换。
举个例子:
- 没有动画:你点击删除按钮,待办事项瞬间消失。用户可能以为是 bug,按下没反应。
- 有动画:删除时待办事项先缩小 → 变淡 → 再消失。用户清楚地看到了"删除过程",体验更自然。
1.2 React 19 中动画的核心概念
在 React 中,动画的核心围绕着两个字:过渡。
无论你用什么方案实现,逻辑都是:
当前状态 ──(插值/渐变)──▶ 目标状态
│ │
组件进入DOM 组件离开DOM
或属性变化 或属性变化
React 19 在这一过程中提供了几个关键的原生能力:
| 能力 | 说明 |
|---|---|
useTransition() |
标记某个状态更新为"低优先级过渡",动画不会被阻塞 |
useDeferredValue() |
延迟渲染某个值,让高优先级更新(如输入)先完成 |
startTransition() |
手动包裹一段更新为过渡更新 |
| Concurrent Mode | 并发渲染模式下,React 可中断、可恢复渲染,动画更流畅 |
用人话说:React 19 的并发特性就像一个灵活的餐厅服务员 —— 他可以在给你端菜(渲染高优先级内容)的同时,顺便记录你的加单请求(低优先级的动画状态更新),而不是必须等一道菜上完才能处理下一件事。
1.3 三大动画方案对比
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 原生 CSS 动画 | 零依赖、性能最优、GPU 加速 | 组件卸载动画难处理、复杂编排弱 | 简单过渡、hover 效果、loading 动画 |
| Framer Motion | API 优雅、手势支持、AnimatePresence 解决卸载动画 | 包体积约 30KB | 中大型应用、复杂交互动画、手势拖拽 |
| React Transition Group | 轻量(5KB)、纯逻辑层、可搭配任意 CSS 方案 | 需手写 CSS 类名、API 稍旧 | 列表增删动画、组件挂载/卸载动画 |
1.4 React 19 对动画能力的原生增强
相比于 React 18,React 19 在动画方面有以下几个值得关注的更新:
- 更好的并发渲染支持:
useTransition的自动批处理更强,动画状态更新不会被业务逻辑阻塞。 useOptimistic新 Hook:乐观更新(Optimistic UI)的原生支持,适合点赞、收藏等即时反馈类交互动画。onCA/onScroll事件优化:滚动和点击事件在并发模式下响应更快,手势动画更跟手。- Suspense 与动画的深度整合:loading 状态到内容展示的过渡可以用
useDeferredValue平滑衔接。
划重点:React 19 并没有新增一个"动画 API",而是通过提升调度能力和渲染性能,让动画方案(CSS / Framer Motion / Transition Group)跑得更流畅。
二、方案一:原生 CSS 动画在 React 中的用法
2.1 核心思路
在 React 中操作 CSS 动画的方式是:用 state 驱动 className,用 className 触发 CSS transition / animation。
用户交互 → setState → className 改变 → CSS 过渡生效 → 视觉动画完成
2.2 基础过渡动画:侧边栏展开/收起
CSS 文件
/* styles.css */
.sidebar {
width: 250px;
overflow: hidden;
background: #2c3e50;
color: white;
/* transition 定义了 width 变化的过渡效果 */
transition: width 0.3s ease-in-out;
}
.sidebar.collapsed {
width: 0;
padding: 0;
}
React 组件
// Sidebar.jsx
import { useState } from "react";
import "./styles.css";
function Sidebar() {
// 用 state 控制展开/收起状态
const [collapsed, setCollapsed] = useState(false);
return (
<div style={{ display: "flex" }}>
{/* className 动态切换,触发 CSS transition */}
<div className={`sidebar ${collapsed ? "collapsed" : ""}`}>
{!collapsed && (
<div style={{ padding: 20 }}>
<h3>导航菜单</h3>
<p>菜单项一</p>
<p>菜单项二</p>
</div>
)}
</div>
<button onClick={() => setCollapsed(!collapsed)}>
{collapsed ? "展开" : "收起"}
</button>
</div>
);
}
export default Sidebar;
代码拆解:
| 代码行 | 作用 |
|---|---|
transition: width 0.3s ease-in-out |
CSS 属性:让 width 的变化用 0.3 秒平滑过渡 |
className={sidebar ${collapsed ? 'collapsed' : ''}} |
根据 state 动态拼接类名,驱动动画 |
setCollapsed(!collapsed) |
点击按钮切换状态,触发重新渲染和动画 |
2.3 CSS 关键帧动画:旋转 loading
/* styles.css */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #eee;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 1s linear infinite; /* 无限循环旋转 */
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
// Loading.jsx
function Loading() {
// 纯展示组件,没有交互逻辑
return (
<div style={{ textAlign: "center", padding: 50 }}>
<div className="spinner" /> {/* 无限旋转的 loading 圈 */}
<p className="fade-in">加载中...</p> {/* 文字淡入 */}
</div>
);
}
export default Loading;
2.4 入场与离场动画:条件渲染 + CSS
React 的难点在于:组件卸载时瞬间消失,CSS transition 来不及生效。
解决方案:用 useState 控制"退出中"这个中间态,配合 onTransitionEnd 事件。
// FadeBox.jsx - 入场/离场淡入淡出
import { useState } from "react";
import "./fade.css";
/* fade.css */
.fade-box {
opacity: 1;
transform: scale(1);
transition:
opacity 0.3s,
transform 0.3s;
}
.fade-box.exiting {
opacity: 0;
transform: scale(0.8);
}
import { useState, useCallback } from "react";
function FadeBox() {
const [visible, setVisible] = useState(false);
const [exiting, setExiting] = useState(false); // 退出中的中间态
const handleClose = useCallback(() => {
setExiting(true); // 先触发退出动画
// CSS transition 结束后再真正移除 DOM
setTimeout(() => {
setVisible(false);
setExiting(false);
}, 300); // 与 CSS transition 时长一致
}, []);
return (
<div>
<button onClick={() => setVisible(true)}>显示弹窗</button>
{visible && (
<div className={`fade-box ${exiting ? "exiting" : ""}`}>
<p>这是一个淡入淡出的弹窗</p>
<button onClick={handleClose}>关闭</button>
</div>
)}
</div>
);
}
原理图解:
用户点关闭
│
▼
setExiting(true) → className 变为 'fade-box exiting'
│
▼
CSS opacity 从 1 → 0,transform 从 scale(1) → scale(0.8)
│
▼
300ms 后 transition 完成
│
▼
setVisible(false) → DOM 真正移除
注意:这种手动管理"退出中"状态的方式比较繁琐,下一节的 Framer Motion 会优雅很多。
三、方案二:Framer Motion 在 React 19 中的集成
3.1 安装与认识 Framer Motion
# npm
npm install framer-motion
# yarn
yarn add framer-motion
# pnpm
pnpm add framer-motion
Framer Motion 的核心是一个名为 motion 的对象,它把普通 HTML 标签升级为"能动"的标签。
import { motion } from "framer-motion";
// <div> → <motion.div> (div 就会动了!)
// <h1> → <motion.h1> (h1 也会动了!)
3.2 基础动画:位移、缩放、透明度
// BasicAnimation.jsx
import { motion } from "framer-motion";
function BasicAnimation() {
return (
<div>
{/* animate 属性:定义动画的"目标状态" */}
{/* initial 属性:定义动画的"起始状态" */}
{/* transition 属性:定义动画的过渡方式 */}
<motion.div
initial={{ opacity: 0, x: -100 }} // 起始:透明且偏左
animate={{ opacity: 1, x: 0 }} // 终止:完全显示且居中
transition={{ duration: 0.8, ease: "easeOut" }} // 0.8秒缓出
style={{
width: 200,
height: 100,
background: "#3498db",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
margin: 10,
}}
>
从左边滑入
</motion.div>
{/* 缩放动画 */}
<motion.button
whileHover={{ scale: 1.1 }} // 悬停时放大到 110%
whileTap={{ scale: 0.95 }} // 点击时缩小到 95%
style={{
padding: "12px 24px",
background: "#e74c3c",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
悬停或点击我
</motion.button>
</div>
);
}
export default BasicAnimation;
核心 API 速查:
| 属性 | 作用 | 示例值 |
|---|---|---|
initial |
动画开始前的初始状态 | { opacity: 0, y: 50 } |
animate |
动画到达的目标状态 | { opacity: 1, y: 0 } |
transition |
过渡配置(时长、缓动) | { duration: 0.5, ease: 'easeInOut' } |
whileHover |
鼠标悬停时的状态 | { scale: 1.05 } |
whileTap |
鼠标/手指按下时的状态 | { scale: 0.95 } |
exit |
组件卸载时的动画 | { opacity: 0 } |
3.3 入场/离场动画:AnimatePresence
这是 Framer Motion 最强大的功能之一:组件从 DOM 中移除时也能执行动画。
// ToggleList.jsx
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
function ToggleList() {
const [items, setItems] = useState([]);
const addItem = () => {
const newItem = { id: Date.now(), text: `项目 ${items.length + 1}` };
setItems([...items, newItem]);
};
const removeItem = (id) => {
setItems(items.filter((item) => item.id !== id));
};
return (
<div style={{ padding: 20 }}>
<button onClick={addItem}>添加项目</button>
{/* AnimatePresence 包裹所有会动态增删的子元素 */}
<AnimatePresence>
{items.map((item) => (
<motion.div
key={item.id} // 必须有唯一 key,否则动画不生效
initial={{ opacity: 0, x: -50, height: 0 }} // 进入时从左边滑入
animate={{ opacity: 1, x: 0, height: "auto" }} // 正常显示
exit={{ opacity: 0, x: 50, height: 0 }} // 退出时向右滑出
transition={{ duration: 0.3 }}
style={{
background: "#ecf0f1",
padding: 12,
margin: "8px 0",
borderRadius: 6,
display: "flex",
justifyContent: "space-between",
overflow: "hidden",
}}
>
<span>{item.text}</span>
<button onClick={() => removeItem(item.id)}>删除</button>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
export default ToggleList;
关键点:
AnimatePresence的子元素必须设置key,否则 Framer Motion 无法追踪哪个元素被增删了。
3.4 手势交互动画:拖拽
// DragCard.jsx
import { motion } from "framer-motion";
function DragCard() {
return (
<motion.div
drag // 启用拖拽
dragConstraints={{ left: 0, right: 300, top: 0, bottom: 300 }} // 拖拽边界
dragElastic={0.2} // 弹性系数(0=无弹性,1=完全弹性)
whileDrag={{ scale: 1.05, boxShadow: "0 10px 30px rgba(0,0,0,0.2)" }}
style={{
width: 200,
height: 120,
background: "#9b59b6",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
cursor: "grab",
userSelect: "none",
}}
>
拖拽我试试
</motion.div>
);
}
export default DragCard;
业务价值:适合卡片排序、拖拽上传区域、滑动解锁等需要用户手势反馈的场景。
3.5 滚动触发动画
// ScrollReveal.jsx
import { motion } from "framer-motion";
function ScrollReveal() {
return (
<div>
{/* 占位空间,让页面可以滚动 */}
<div style={{ height: "150vh" }} />
{/* viewport 属性:元素进入视口时才触发动画 */}
<motion.div
initial={{ opacity: 0, y: 60 }}
whileInView={{ opacity: 1, y: 0 }} // 滚到可见区域时播放
viewport={{ once: true, amount: 0.3 }} // once: 只触发一次;amount: 露出30%就触发
transition={{ duration: 0.6 }}
style={{
padding: 40,
background: "#2ecc71",
borderRadius: 12,
color: "white",
textAlign: "center",
}}
>
<h2>滚动到此处触发动画</h2>
<p>常见于 Landing Page 的滚动展示效果</p>
</motion.div>
</div>
);
}
export default ScrollReveal;
四、方案三:React Transition Group
4.1 安装与核心概念
npm install react-transition-group
React Transition Group 是一个纯逻辑层的库:它不提供任何动画,只提供"当前处于哪个动画阶段"的状态信息。你需要自己写 CSS 来完成视觉动画。
核心组件:
| 组件 | 作用 |
|---|---|
Transition |
跟踪单个组件的进入/退出动画阶段 |
CSSTransition |
在 Transition 基础上自动添加 CSS 类名 |
TransitionGroup |
管理一组 CSSTransition,自动处理列表增删动画 |
SwitchTransition |
两个组件之间切换时的过渡动画 |
4.2 组件挂载/卸载动画
/* modal.css */
/* 动画的 6 个 CSS 类名,由 CSSTransition 自动添加 */
.modal-enter {
opacity: 0;
transform: scale(0.8);
}
.modal-enter-active {
opacity: 1;
transform: scale(1);
transition:
opacity 0.3s,
transform 0.3s;
}
.modal-exit {
opacity: 1;
transform: scale(1);
}
.modal-exit-active {
opacity: 0;
transform: scale(0.8);
transition:
opacity 0.3s,
transform 0.3s;
}
// Modal.jsx
import { useState } from "react";
import { CSSTransition } from "react-transition-group";
import "./modal.css";
function Modal() {
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(true)}>打开弹窗</button>
{/* in: 控制组件显隐;timeout: 动画持续时长;unmountOnExit: 退出后移除DOM */}
<CSSTransition in={show} timeout={300} unmountOnExit classNames="modal">
{/* 动画内容放在 CSSTransition 的子元素中 */}
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => setShow(false)}
>
<div
style={{
background: "white",
padding: 30,
borderRadius: 12,
}}
onClick={(e) => e.stopPropagation()} // 阻止冒泡,点击内容区不关闭
>
<h2>弹窗标题</h2>
<p>弹窗内容区域</p>
<button onClick={() => setShow(false)}>关闭</button>
</div>
</div>
</CSSTransition>
</div>
);
}
export default Modal;
6 个自动类名的执行顺序:
进入阶段: classNames-enter → classNames-enter-active (timeout 后移除)
退出阶段: classNames-exit → classNames-exit-active (timeout 后移除)
4.3 列表增删动画:TransitionGroup
// AnimatedList.jsx
import { useState } from "react";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import "./list.css";
/* list.css */
.list-item-enter {
opacity: 0;
transform: translateX(-30px);
}
.list-item-enter-active {
opacity: 1;
transform: translateX(0);
transition:
opacity 0.3s,
transform 0.3s;
}
.list-item-exit {
opacity: 1;
}
.list-item-exit-active {
opacity: 0;
transform: translateX(30px);
transition:
opacity 0.3s,
transform 0.3s;
}
function AnimatedList() {
const [items, setItems] = useState([
{ id: 1, text: "第一条数据" },
{ id: 2, text: "第二条数据" },
{ id: 3, text: "第三条数据" },
]);
const add = () => {
const newItem = { id: Date.now(), text: `新数据 ${items.length + 1}` };
setItems([...items, newItem]);
};
const remove = (id) => {
setItems(items.filter((item) => item.id !== id));
};
return (
<div>
<button onClick={add}>添加</button>
{/* TransitionGroup 自动为列表增删的子元素挂载 CSSTransition */}
<TransitionGroup>
{items.map((item) => (
<CSSTransition key={item.id} timeout={300} classNames="list-item">
<div
style={{
padding: 12,
margin: "8px 0",
background: "#f0f0f0",
borderRadius: 6,
display: "flex",
justifyContent: "space-between",
}}
>
<span>{item.text}</span>
<button onClick={() => remove(item.id)}>删除</button>
</div>
</CSSTransition>
))}
</TransitionGroup>
</div>
);
}
export default AnimatedList;
4.4 两组件切换动画:SwitchTransition
import { useState } from "react";
import { CSSTransition, SwitchTransition } from "react-transition-group";
function ToggleBox() {
const [on, setOn] = useState(false);
return (
<div>
<button onClick={() => setOn(!on)}>切换状态</button>
{/* SwitchTransition: 新组件入场时,旧组件也在离场,两者有重叠过渡 */}
<SwitchTransition>
<CSSTransition key={on ? "on" : "off"} timeout={300} classNames="fade">
<div
style={{
width: 120,
height: 120,
background: on ? "#27ae60" : "#e74c3c",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 24,
}}
>
{on ? "开" : "关"}
</div>
</CSSTransition>
</SwitchTransition>
</div>
);
}
export default ToggleBox;
五、开发与教学注意事项
5.1 并发渲染下的动画状态同步
问题表现:React 19 的并发模式下,使用 useTransition 包裹的动画可能看起来"卡了一下",因为过渡更新被推迟了。
错误原因:动画状态被标记为低优先级更新,React 在忙于渲染高优先级内容时暂不处理。
正确解法:
import { useState, useTransition } from "react";
function AnimatedSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
// 输入框的更新立即生效(高优先级)
setQuery(e.target.value);
// 搜索结果和加载动画标记为低优先级过渡
startTransition(() => {
const filtered = allData.filter((item) => item.includes(e.target.value));
setResults(filtered);
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <div className="spinner" />} {/* loading 动画 */}
{results.map((item) => (
<div key={item}>{item}</div>
))}
</div>
);
}
核心原则:用户直接操作的 UI(输入框、按钮反馈动画)不要包在 startTransition 里;可延迟的 UI(搜索结果、后台数据刷新)才适合放在过渡中。
5.2 过度动画导致的性能损耗
问题表现:页面动画多了以后出现卡顿、掉帧,移动端尤其明显。
根本原因:每次动画帧都触发浏览器的重排(Layout)和重绘(Paint)。
优化方案:
| 优化手段 | 说明 | 示例 |
|---|---|---|
优先动画 transform 和 opacity |
这两个属性只触发合成(Composite),不触发重排 | transform: translateX(...) 而不是 left: ... |
避免动画 width/height |
会触发完整的 Layout → Paint → Composite 管线 | 用 transform: scale() 替代 |
使用 will-change 提示 |
提前告知浏览器该元素会变化 | will-change: transform, opacity |
| 减少动画元素数量 | 虚拟列表 + 仅动画可视区域内的元素 | react-window + CSSTransition |
使用 requestAnimationFrame |
在浏览器准备重绘前更新 | 复杂自定义动画使用 |
/* Bad: 动画 left 属性,每次都要重新计算布局 */
.bad-animation {
transition: left 0.3s;
will-change: left;
}
/* Good: 动画 transform,由 GPU 处理,不触发重排 */
.good-animation {
transition: transform 0.3s;
will-change: transform;
}
生活化类比:
transform就像在一张透明的塑料片上移动图案——你不需要重新画背景。而left/top就像在一张纸上擦掉图案再重新画——整张纸都要重来。
5.3 服务端渲染(SSR)中的 hydration 冲突
问题表现:Next.js App Router 中,动画组件在服务端渲染的 HTML 和客户端 hydration 后的界面不一致,出现闪烁或报错。
错误原因:服务端无法执行"进入动画"(没有"首次渲染"的概念),而客户端首次渲染时会触发动画,导致两边 HTML 不匹配。
正确解法:
// 方案一:使用 next/dynamic 禁用 SSR
import dynamic from "next/dynamic";
const AnimatedHero = dynamic(() => import("./AnimatedHero"), { ssr: false });
// 方案二:延迟执行动画,hydration 完成后再启动
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
function SafeAnimation() {
const [ready, setReady] = useState(false);
// 等客户端 hydration 完成后再触发动画
useEffect(() => {
setReady(true);
}, []);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={ready ? { opacity: 1, y: 0 } : {}} // hydration 前不动
transition={{ duration: 0.5 }}
>
这个动画在 hydration 后播放
</motion.div>
);
}
// 方案三:Framer Motion 的 LazyMotion 按需加载
import { LazyMotion, domAnimation, m } from "framer-motion";
function App() {
return (
<LazyMotion features={domAnimation}>
<m.div animate={{ opacity: 1 }} />
</LazyMotion>
);
}
5.4 动画逻辑与业务状态的解耦规范
错误示范:在业务组件中混杂动画 state。
// ❌ Bad:动画状态和业务数据耦合在一起
function TodoItem({ todo, onDelete }) {
const [isDeleting, setIsDeleting] = useState(false); // 动画状态
const handleDelete = async () => {
setIsDeleting(true);
await onDelete(todo.id);
setIsDeleting(false);
};
return <div className={isDeleting ? "fade-out" : ""}>{todo.text}</div>;
}
正确规范:将动画逻辑封装为独立的自定义 Hook 或无头组件。
// hooks/useExitAnimation.js - 动画逻辑独立封装
import { useState, useCallback } from "react";
function useExitAnimation(onExit, duration = 300) {
const [exiting, setExiting] = useState(false);
const triggerExit = useCallback(() => {
setExiting(true);
setTimeout(() => {
onExit(); // 动画结束后再执行业务逻辑
setExiting(false);
}, duration);
}, [onExit, duration]);
return { exiting, triggerExit };
}
export default useExitAnimation;
// TodoItem.jsx - 业务组件只关心数据和交互
import useExitAnimation from "../hooks/useExitAnimation";
function TodoItem({ todo, onDelete }) {
const { exiting, triggerExit } = useExitAnimation(
() => onDelete(todo.id),
300,
);
return (
<div className={`todo-item ${exiting ? "todo-exit" : ""}`}>
<span>{todo.text}</span>
<button onClick={triggerExit}>删除</button>
</div>
);
}
5.5 教学中需重点强调的易错知识点
| 易错点 | 说明 |
|---|---|
AnimatePresence 的子元素必须设置 key |
没有 key,Frame Motion 无法追踪元素的进入/退出 |
CSS transition 的时长必须与 timeout 一致 |
Transition Group 的 timeout 值决定何时移除 DOM,必须和 CSS 动画时长匹配 |
motion.div 的 initial 只在首次挂载时生效 |
重新渲染不会触发 initial,除非修改 key 强制重新挂载 |
exit 动画需要配合 AnimatePresence |
单独使用 motion.div 的 exit 属性不生效 |
whileHover 和 whileTap 仅对 motion 组件生效 |
原生 <button> 不会响应这些属性 |
六、常见疑问 Q&A
Q1:三种方案怎么选?
简单来说:
- 简单过渡、hover 效果、loading → 原生 CSS 动画(零依赖)
- 中大型项目、复杂手势、组件进出场动画 → Framer Motion(推荐)
- 列表动画、纯逻辑控制、不想引入太大依赖 → React Transition Group
Q2:Framer Motion 包体积会影响性能吗?
Framer Motion v11+ 支持 Tree Shaking 和 LazyMotion 按需加载功能,实际使用只打包你用到的功能,可控制在 10-15KB 左右。
Q3:AnimatePresence 为什么需要 key?
因为 React 通过 key 判断"这个元素是新的还是旧的"。没有 key,React 会认为删除和添加的是同一个元素,不会触发 exit 动画。
Q4:CSS 动画和 Framer Motion 的 JS 动画哪个性能好?
CSS 的 transition 和 animation 由浏览器渲染引擎直接驱动,在 GPU 合成层上运行,理论性能最优。Framer Motion 底层也是 CSS transitions/transforms,性能差距可以忽略不计,但提供了更丰富的 API 和手势支持。
Q5:React 19 的 useTransition 和动画有什么关系?
useTransition 不直接创建动画,但它能确保动画帧不被阻塞。在高负载场景下(如同时渲染大量列表和播放动画),把列表更新放在 startTransition 中可以让动画保持 60fps。
七、课后练习
练习题目:带交互动画的待办列表
目标:实现一个具备完整增删动画、手势反馈的 Todo List 组件。
基础要求:
- 输入框和添加按钮,输入内容后添加到列表(带滑入动画)
- 每一项点击"完成"时打勾 + 划线 + 颜色变淡(带过渡动画)
- 每一项点击"删除"时先缩小再淡出(带退出动画)
- 空列表状态提示"还没有待办事项哦~"(带淡入动画)
进阶要求:
- 列表项支持拖拽排序(Framer Motion 的
Reorder组件) - 使用
useOptimistic实现乐观删除(点删除按钮立刻播放退出动画,后台异步请求 API) - 所有动画在 60fps 下流畅运行(使用
transform代替height动画)
提示:
- 推荐组合使用 Framer Motion 的
AnimatePresence+Reorder+whileHover。 - 乐观删除的逻辑应该在
useOptimistic中处理。
八、练习参考答案思路
基本思路
- 用
useState管理todos数组,每个 todo 包含{ id, text, completed }。 - 添加时往数组 push,
AnimatePresence自动触发initial动画。 - 完成切换用 CSS
text-decoration和opacity+transition过渡。 - 删除时从数组中 filter 掉,
AnimatePresence触发exit动画。 - 拖拽排序用 Framer Motion 的
<Reorder.Group>+<Reorder.Item>。 - 乐观删除:点击删除时立即从本地状态中移除 + 播放退出动画,同时发 API 请求。如果 API 失败,恢复该项。
关键技术点
// 核心结构示意
import { Reorder, AnimatePresence, motion } from "framer-motion";
<Reorder.Group values={todos} onReorder={setTodos}>
<AnimatePresence>
{todos.map((todo) => (
<Reorder.Item key={todo.id} value={todo}>
<motion.li
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20, transition: { duration: 0.2 } }}
>
{/* 内容 */}
</motion.li>
</Reorder.Item>
))}
</AnimatePresence>
</Reorder.Group>;
总结
React 19 中的动画能力可以总结为三个层面:
- 原生 CSS:最简单、性能最好,适合基础过渡效果。用
state驱动className,用 CSStransition/animation实现动画。缺点是卸载动画需要手动管理中间态。 - Framer Motion:目前最推荐的 React 动画方案。
motion组件提供声明式 API,AnimatePresence优雅解决卸载动画,手势支持开箱即用。适合绝大部分场景。 - React Transition Group:轻量级逻辑层方案,不绑定任何样式。适合想自己控制 CSS 类名、或需要与旧项目渐进集成的场景。
选择哪一个并不重要,重要的是理解 "动画 = 状态变化过程的平滑呈现" 这一核心原理。有了这个认知,无论 React 未来如何演进,你都能快速适应。