目 录CONTENT

文章目录

React(二十一)动画

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 在动画方面有以下几个值得关注的更新:

  1. 更好的并发渲染支持useTransition 的自动批处理更强,动画状态更新不会被业务逻辑阻塞。
  2. useOptimistic 新 Hook:乐观更新(Optimistic UI)的原生支持,适合点赞、收藏等即时反馈类交互动画。
  3. onCA / onScroll 事件优化:滚动和点击事件在并发模式下响应更快,手势动画更跟手。
  4. 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)。

优化方案

优化手段 说明 示例
优先动画 transformopacity 这两个属性只触发合成(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.divinitial 只在首次挂载时生效 重新渲染不会触发 initial,除非修改 key 强制重新挂载
exit 动画需要配合 AnimatePresence 单独使用 motion.divexit 属性不生效
whileHoverwhileTap 仅对 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 的 transitionanimation 由浏览器渲染引擎直接驱动,在 GPU 合成层上运行,理论性能最优。Framer Motion 底层也是 CSS transitions/transforms,性能差距可以忽略不计,但提供了更丰富的 API 和手势支持。

Q5:React 19 的 useTransition 和动画有什么关系?

useTransition 不直接创建动画,但它能确保动画帧不被阻塞。在高负载场景下(如同时渲染大量列表和播放动画),把列表更新放在 startTransition 中可以让动画保持 60fps。


七、课后练习

练习题目:带交互动画的待办列表

目标:实现一个具备完整增删动画、手势反馈的 Todo List 组件。

基础要求

  1. 输入框和添加按钮,输入内容后添加到列表(带滑入动画)
  2. 每一项点击"完成"时打勾 + 划线 + 颜色变淡(带过渡动画)
  3. 每一项点击"删除"时先缩小再淡出(带退出动画)
  4. 空列表状态提示"还没有待办事项哦~"(带淡入动画)

进阶要求

  1. 列表项支持拖拽排序(Framer Motion 的 Reorder 组件)
  2. 使用 useOptimistic 实现乐观删除(点删除按钮立刻播放退出动画,后台异步请求 API)
  3. 所有动画在 60fps 下流畅运行(使用 transform 代替 height 动画)

提示

  • 推荐组合使用 Framer Motion 的 AnimatePresence + Reorder + whileHover
  • 乐观删除的逻辑应该在 useOptimistic 中处理。

八、练习参考答案思路

基本思路

  1. useState 管理 todos 数组,每个 todo 包含 { id, text, completed }
  2. 添加时往数组 push,AnimatePresence 自动触发 initial 动画。
  3. 完成切换用 CSS text-decorationopacity + transition 过渡。
  4. 删除时从数组中 filter 掉,AnimatePresence 触发 exit 动画。
  5. 拖拽排序用 Framer Motion 的 <Reorder.Group> + <Reorder.Item>
  6. 乐观删除:点击删除时立即从本地状态中移除 + 播放退出动画,同时发 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 中的动画能力可以总结为三个层面:

  1. 原生 CSS:最简单、性能最好,适合基础过渡效果。用 state 驱动 className,用 CSS transition/animation 实现动画。缺点是卸载动画需要手动管理中间态。
  2. Framer Motion:目前最推荐的 React 动画方案。motion 组件提供声明式 API,AnimatePresence 优雅解决卸载动画,手势支持开箱即用。适合绝大部分场景。
  3. React Transition Group:轻量级逻辑层方案,不绑定任何样式。适合想自己控制 CSS 类名、或需要与旧项目渐进集成的场景。

选择哪一个并不重要,重要的是理解 "动画 = 状态变化过程的平滑呈现" 这一核心原理。有了这个认知,无论 React 未来如何演进,你都能快速适应。

0
博主关闭了当前页面的评论