React 基础体系 · 第 41/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 动画:CSS、Transition、Motion、布局动画和减少动态

React 本身不负责“把元素从位置 A 移动到位置 B”。React 的职责是根据状态计算 UI,提交 DOM 更新;真正的时间插值通常由 CSS、浏览器的 View Transitions 能力或第三方动画库完成。

因此,React 动画首先要区分三件事:

  1. 状态变化:例如 openfalse 变成 true
  2. DOM 生命周期:元素是继续存在但改变样式,还是被卸载。
  3. 视觉插值:浏览器或动画库如何在一段时间内计算中间帧。

如果只改变 display: none,没有可插值的中间状态;如果元素已经卸载,CSS 也没有机会播放退出动画。大多数动画问题都可以追溯到这两个条件没有被满足。


一、动画的基本模型:状态、提交和插值

设组件状态为 ss,React 根据状态生成 UI:

UI=render(s)UI = render(s)

例如:

const [open, setOpen] = useState(false);

return (
  <section className={open ? "panel panel-open" : "panel"}>
    内容
  </section>
);

当调用 setOpen(true) 时,过程可以简化为:

  1. React 更新状态;
  2. React 重新执行组件;
  3. 得到新的 className
  4. React 将差异提交到 DOM;
  5. 浏览器根据新旧 CSS 样式计算布局、绘制和合成;
  6. 如果属性配置了 transitionanimation,浏览器生成中间帧。

这里的关键是:React 只负责第 1~4 步,CSS 动画发生在 React 提交之后

动画可以表示为从起始值 v0v_0 到目标值 v1v_1 的插值:

v(t)=v0+E(t)(v1v0),0t1v(t) = v_0 + E(t)(v_1-v_0), \quad 0 \leq t \leq 1

其中:

  • tt 是归一化时间;
  • E(t)E(t) 是缓动函数,例如 ease-out
  • v(t)v(t) 是某一时刻的属性值。

并不是所有 CSS 属性都能这样插值。opacitytransform 通常适合动画;display: none 是离散状态,不能自然地在 noneblock 之间产生连续值。


二、CSS Transition:最简单、最可靠的 React 动画

2.1 transition 只描述“变化过程”

下面的组件通过 React 状态控制面板是否展开:

import { useState } from "react";
import "./Panel.css";

export function PanelExample() {
  const [open, setOpen] = useState(false);

  return (
    <div>
      <button
        type="button"
        aria-expanded={open}
        aria-controls="settings-panel"
        onClick={() => setOpen((value) => !value)}
      >
        {open ? "收起设置" : "展开设置"}
      </button>

      <section
        id="settings-panel"
        className={`panel ${open ? "panel--open" : ""}`}
      >
        <div className="panel__content">
          设置内容
        </div>
      </section>
    </div>
  );
}
.panel {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transform: translateY(-0.25rem);
  transition:
    grid-template-rows 220ms ease,
    opacity 160ms ease,
    transform 220ms ease;
}

.panel__content {
  min-height: 0;
  overflow: hidden;
}

.panel--open {
  grid-template-rows: 1fr;
  opacity: 1;
  transform: translateY(0);
}

这里没有让 React 逐帧更新状态。React 只切换 panel--open,浏览器负责计算动画。

grid-template-rows: 0fr1fr 是一种常见的“内容高度展开”技巧:

  • 0fr 使内部轨道没有可用空间;
  • 1fr 让轨道占据可用空间;
  • min-height: 0 防止内容自身的最小高度阻止收缩;
  • overflow: hidden 隐藏展开区域之外的内容。

它避免了直接动画 height: auto 的问题。传统 CSS 中,auto 通常不是一个可以直接插值的数值。若内容高度由文字、图片和响应式布局共同决定,硬编码 height: 300px 会产生截断或空白。

2.2 transition 的生命周期

.card {
  transition:
    opacity 200ms ease-out,
    transform 200ms ease-out;
}

.card:hover {
  opacity: 0.8;
  transform: translateY(-4px);
}

这段规则的含义是:

  • opacitytransform 的计算值发生变化时,启动过渡;
  • 过渡持续 200ms
  • 使用 ease-out 计算速度曲线;
  • 属性回到原值时,也会按同样规则反向过渡。

transition 本身不会触发变化。下面的代码没有效果:

.card {
  transition: transform 200ms ease;
}

如果没有 :hover、类名、内联样式或其他状态改变 transform,就没有动画可播放。

2.3 进入和退出动画要求元素仍在 DOM 中

以下写法不能播放退出动画:

return open ? <div className="dialog dialog--open">内容</div> : null;

open 变成 false 时,React 直接删除节点。浏览器收到的是“节点消失”,而不是“节点仍存在但 opacity 从 1 变成 0”。

最小的 CSS 退出动画写法是让节点保持挂载,再用状态控制视觉效果:

type ToastProps = {
  visible: boolean;
  onHidden: () => void;
};

export function Toast({ visible, onHidden }: ToastProps) {
  return (
    <div
      className={`toast ${visible ? "toast--visible" : "toast--hidden"}`}
      onTransitionEnd={(event) => {
        if (
          event.propertyName === "opacity" &&
          !visible
        ) {
          onHidden();
        }
      }}
      aria-hidden={!visible}
    >
      保存成功
    </div>
  );
}
.toast {
  pointer-events: none;
  opacity: 0;
  transform: translateY(8px);
  transition:
    opacity 180ms ease,
    transform 180ms ease;
}

.toast--visible {
  pointer-events: auto;
  opacity: 1;
  transform: translateY(0);
}

.toast--hidden {
  visibility: hidden;
}

真实应用中还要考虑两个边界:

  1. transitionend 可能因为用户切换页面、动画被覆盖或 prefers-reduced-motion 而不按预期触发;
  2. 过渡涉及多个属性时,事件可能触发多次,因此应检查 propertyName

如果退出后最终需要从 DOM 删除,就需要额外的状态,例如:

closed -> entering -> open -> exiting -> closed

其中 closed 才真正卸载节点。手动维护这套状态容易出现快速重复点击、组件卸载和事件竞态,第三方库通常专门处理这些问题。


三、使用哪些 CSS 属性:布局属性与合成属性

动画性能不能简单归结为“某个属性永远好,另一个属性永远坏”,但可以先理解浏览器工作的几个阶段:

  1. 样式计算:确定最终 CSS;
  2. 布局(layout/reflow):计算元素几何位置和尺寸;
  3. 绘制(paint):生成像素内容;
  4. 合成(composite):把图层组合到屏幕。

transformopacity 在许多浏览器实现中可以主要由合成阶段处理,因此常用于移动、缩放和淡入淡出:

.item {
  opacity: 0;
  transform: translateY(12px) scale(0.98);
  transition:
    opacity 180ms ease,
    transform 180ms ease;
}

.item--shown {
  opacity: 1;
  transform: translateY(0) scale(1);
}

但是,“使用 transform 就一定不卡”并不成立:

  • 元素内容过大仍可能造成绘制压力;
  • 频繁创建合成层可能增加显存消耗;
  • will-change 不是免费优化,不能大量添加;
  • 动画期间如果同时触发布局读取和写入,仍会造成性能问题。

例如,不应在每一帧都通过 React 状态更新来移动元素:

// 不推荐:每帧 setState,导致组件树持续重新渲染
useEffect(() => {
  let frame = 0;
  const tick = () => {
    setX((value) => value + 1);
    frame = requestAnimationFrame(tick);
  };

  frame = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(frame);
}, []);

如果确实需要逐帧控制,应优先直接操作动画库的值、CSS 自定义属性或 DOM,而不是让整个 React 树参与每一帧更新。React 状态更适合表示“动画开始、结束或当前业务阶段”,不适合承载高频的视觉时钟。


四、React 的 Transition:调度优先级,不是视觉过渡

React 的 useTransition 名称容易与 CSS Transition 混淆,但两者解决的问题不同。

import { useState, useTransition } from "react";

export function SearchResults() {
  const [query, setQuery] = useState("");
  const [deferredQuery, setDeferredQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  function handleChange(nextQuery: string) {
    setQuery(nextQuery);

    startTransition(() => {
      setDeferredQuery(nextQuery);
    });
  }

  return (
    <section>
      <label>
        搜索
        <input
          value={query}
          onChange={(event) => handleChange(event.target.value)}
        />
      </label>

      {isPending && <p role="status">正在更新结果……</p>}

      <SearchResultList query={deferredQuery} />
    </section>
  );
}

function SearchResultList({ query }: { query: string }) {
  return <p>结果:{query || "全部"}</p>;
}

这里的 startTransition 表示:deferredQuery 相关的更新优先级较低,在用户输入等紧急更新之后处理。它不会:

  • 自动增加 opacity
  • 自动移动元素;
  • 自动播放进入或退出动画;
  • 把一次 React 更新变成 CSS transition。

isPending 只是一个 React 状态,可以被用来显示加载指示器:

{isPending && <span className="spinner" aria-label="加载中" />}

但 spinner 的旋转仍由 CSS 或动画库完成。

4.1 为什么并发调度与动画可以组合,但不能互相替代

一个列表切换过程可能包含两条独立链路:

用户输入
  ├─ 紧急更新:输入框的 value 立即变化
  └─ Transition 更新:昂贵的结果列表稍后渲染
                         ↓
                    DOM 发生变化
                         ↓
                CSS/Motion 播放视觉动画

React Transition 决定“何时让这次渲染参与竞争”;CSS 或 Motion 决定“DOM 变更后如何呈现”。

如果把 startTransition 当作动画 API,常见失败表现是:代码没有报错,但界面只是延迟更新,完全没有过渡效果。

4.2 useTransition 的边界

Transition 更新不是事务锁,也不保证每个中间状态都被用户看到。React 可以合并、打断并重新执行低优先级渲染。如果渲染过程中触发副作用,可能导致重复或错误行为;渲染函数应保持纯粹,网络请求、订阅和 DOM 副作用应放在合适的 Effect 或数据层中。

此外,startTransition 不会自动包住异步函数中稍后发生的所有更新。跨越 await 后,如果需要把后续状态更新视为 Transition,应该根据当前 React 版本的 API 语义显式处理,而不能假设整个异步流程天然属于同一个 Transition。

React 19 的 Actions、useActionState 等能力可以帮助管理异步提交状态,但它们仍然不是视觉动画 API。加载、提交、错误和成功状态可以驱动 CSS 或 Motion,而不是被这些 API 自动动画化。


五、Motion:用状态声明进入、退出和布局变化

这里的 Motion 指现代 Motion 库提供的 React 动画能力,通常通过 motion/react 导入。它不是 React 内置 API。使用前需要安装对应包:

npm install motion

一个基本的进入动画如下:

import { motion } from "motion/react";

export function WelcomeCard() {
  return (
    <motion.div
      initial={{ opacity: 0, y: 16 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.2, ease: "easeOut" }}
    >
      欢迎回来
    </motion.div>
  );
}

这些属性的含义是:

  • initial:首次出现时的初始值;
  • animate:组件存在时的目标值;
  • transition:时间、缓动和其他动画参数;
  • y 通常映射为 transform: translateY(...)

与手写 CSS 相比,Motion 的优势不是“所有动画都更快”,而是把复杂的动画状态和生命周期封装成声明式组件,尤其适合:

  • 组件挂载时进入;
  • 组件卸载前退出;
  • 列表项增删;
  • 共享布局变化;
  • 手势和拖拽;
  • 多个动画值之间的协调。

5.1 退出动画与 AnimatePresence

import { useState } from "react";
import { AnimatePresence, motion } from "motion/react";

export function ModalExample() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>
        打开
      </button>

      <AnimatePresence>
        {open && (
          <motion.div
            key="modal"
            className="modal"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.18 }}
          >
            <div className="modal__body">
              <p>对话框内容</p>
              <button type="button" onClick={() => setOpen(false)}>
                关闭
              </button>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}
.modal {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  background: rgb(0 0 0 / 35%);
}

.modal__body {
  padding: 1rem;
  background: white;
  border-radius: 0.5rem;
}

关键机制是:

  1. opentrue,React 返回 motion.div
  2. Motion 播放 initial -> animate
  3. openfalse,React 的条件表达式不再返回该节点;
  4. AnimatePresence 拦截这次移除;
  5. Motion 先播放 exit
  6. 退出动画完成后,节点才真正从 DOM 移除。

这正好解决了手写 CSS 中“退出节点已经不存在”的生命周期问题。

key 也非常重要。对于列表或模式切换,React 通过 key 判断是不是同一个元素。如果错误地复用同一个 key,Motion 可能不会得到预期的进入和退出边界。

5.2 Motion 与 React 服务端渲染

在 Next.js 等框架中,使用事件、状态和 Motion 组件的文件通常需要客户端边界:

"use client";

import { motion } from "motion/react";

export function AnimatedButton() {
  return (
    <motion.button whileTap={{ scale: 0.96 }}>
      提交
    </motion.button>
  );
}

"use client" 的含义是:该模块及其客户端依赖从客户端组件边界开始执行,能够使用事件处理器和浏览器能力。它不是“让服务端完全不渲染 HTML”,也不是动画本身的开关。

服务端渲染时应保证初始结构和初始属性可预测:

  • 不要在渲染阶段直接读取 windowdocument
  • 不要根据服务端不存在的随机值决定 initial
  • 需要浏览器媒体查询时,用客户端 Effect 或库提供的安全机制同步;
  • 不要为了动画把整个页面都改成客户端组件。

否则可能出现 hydration mismatch,表现为控制台警告、初始闪烁或动画从错误的起点开始。


六、布局动画:位置变化与尺寸变化的真正难点

布局动画指元素的几何关系发生变化时,对位置、尺寸或布局重排进行平滑过渡。例如:

  • 网格从三列变为两列;
  • 列表删除一项,其他项向上移动;
  • 卡片内容展开导致下面的元素下移;
  • 同一个选中指示器从一个标签移动到另一个标签。

直接给 topleftwidthheight 加 CSS transition 有时可以工作,但复杂布局通常需要先得到变化前后的几何信息。典型的 FLIP 思路是:

  1. First:记录变化前的矩形;
  2. Last:应用新布局并记录变化后的矩形;
  3. Invert:通过反向 transform 把元素视觉上放回旧位置;
  4. Play:把反向变换过渡到 none

假设旧位置是 x0x_0,新位置是 x1x_1,动画开始时可设置:

translateX(x0x1)translateX(x_0-x_1)

这样虽然 DOM 已经位于新位置,视觉上却暂时回到旧位置;随后将 transform 过渡到 translateX(0),用户就看到它移动到新位置。

手动实现 FLIP 会遇到测量时机、滚动容器、元素删除和 React 提交顺序等问题。Motion 的 layout 属性可以处理常见布局变化:

import { useState } from "react";
import { motion } from "motion/react";

const items = ["A", "B", "C"];

export function LayoutExample() {
  const [selected, setSelected] = useState("A");

  return (
    <div className="tabs">
      {items.map((item) => (
        <button
          key={item}
          type="button"
          className="tab"
          onClick={() => setSelected(item)}
        >
          {item}
          {selected === item && (
            <motion.span
              layoutId="active-tab"
              className="active-tab"
              transition={{ type: "spring", bounce: 0.2 }}
            />
          )}
        </button>
      ))}
    </div>
  );
}
.tabs {
  display: flex;
  gap: 0.25rem;
}

.tab {
  position: relative;
  z-index: 0;
  padding: 0.5rem 0.75rem;
  border: 0;
  background: transparent;
}

.active-tab {
  position: absolute;
  z-index: -1;
  inset: 0;
  border-radius: 0.375rem;
  background: #dbeafe;
}

这里有两个不同概念:

  • layout:同一个组件的布局矩形发生变化时进行动画;
  • layoutId:不同 React 位置中具有相同标识的元素被视为同一个视觉对象,从旧位置移动到新位置。

布局动画通常依赖测量 DOM 矩形,因此比单纯的 opacity 动画更容易受到以下因素影响:

  • 父元素使用 overflow: hidden
  • CSS position 和堆叠上下文改变;
  • 滚动容器不是 window
  • 字体加载前后尺寸变化;
  • 图片没有固定宽高导致布局跳动;
  • 列表 key 不稳定;
  • 动画过程中又发生一次布局更新。

6.1 CSS View Transitions 的位置

浏览器 View Transitions API 也可以对文档或 DOM 状态变化生成过渡效果。它的思路不是逐个元素手写 FLIP,而是让浏览器捕获新旧视图并进行过渡。它适合页面级或视图级切换,但兼容性、交互细节和框架集成仍需按目标浏览器验证。

React 的具体 View Transition 集成属于版本和发布通道敏感能力,不能把它与稳定的 useTransition 混为一谈:

  • useTransition:React 调度低优先级更新;
  • CSS View Transitions:浏览器视图过渡机制;
  • Motion layout:第三方库通过布局测量和动画实现布局变化。

选择哪一种取决于目标浏览器、应用路由、组件粒度和对交互控制的要求。


七、减少动态:prefers-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;
  }
}

这段规则可以快速降低全局动态,但它也可能过于粗糙。例如,某些动画承担状态可见性职责,直接删除后可能出现内容突然跳变、焦点位置不清楚或状态无法感知。

更好的方法是区分:

  • 装饰性运动:可以删除;
  • 状态反馈:保留淡入、颜色变化或即时反馈;
  • 功能性运动:保留必要的空间关系,但缩短时间、减少位移。
.drawer {
  opacity: 0;
  transform: translateX(100%);
  transition:
    transform 240ms ease,
    opacity 240ms ease;
}

.drawer--open {
  opacity: 1;
  transform: translateX(0);
}

@media (prefers-reduced-motion: reduce) {
  .drawer {
    transition-duration: 1ms;
    transform: none;
  }

  .drawer--open {
    opacity: 1;
  }
}

7.1 Motion 中读取减少动态偏好

Motion 可以通过 useReducedMotion 读取用户偏好:

import { motion, useReducedMotion } from "motion/react";

export function ReducedMotionCard() {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={
        shouldReduceMotion
          ? { duration: 0.01 }
          : { duration: 0.2, ease: "easeOut" }
      }
    >
      内容
    </motion.div>
  );
}

这里需要意识到媒体查询结果可能在服务端不可知。服务端首先输出一个初始版本,客户端挂载后才能读取浏览器偏好。若初始动画配置与客户端配置差异过大,可能出现水合后的闪烁。因此,生产代码通常同时保留 CSS 级保护,并让 JS 级配置尽量具有稳定的初始结构。

不要仅通过 aria-hidden 隐藏仍可交互的动画元素。视觉隐藏、可访问性树隐藏和键盘焦点管理是不同问题:

  • opacity: 0 的元素仍可能可聚焦;
  • visibility: hidden 通常会影响可见性和交互;
  • display: none 会从布局和可访问性树中移除;
  • 对话框关闭时应同步处理焦点返回和背景内容的交互权限。

八、CSS、React Transition 和 Motion 如何选择

可以用“变化的来源”和“生命周期复杂度”判断:

场景 适合方案 原因
hover、focus、颜色、透明度 CSS Transition 不需要 React 逐帧参与
简单展开/收起 CSS Transition 状态只切换类名
组件挂载和卸载 Motion 或专门的 Transition 组件 需要延迟卸载
输入响应和昂贵列表渲染 React useTransition 解决更新优先级,不负责视觉
列表重排、共享元素、拖拽 Motion 布局能力 需要测量和协调几何变化
页面级路由切换 框架能力或 View Transitions 需要跨视图管理
无障碍适配 CSS 媒体查询 + JS 偏好 同时覆盖样式和动画库

一个常见误区是:所有动画都使用 Motion。这样会增加客户端 JavaScript、依赖体积和运行时复杂度;对于按钮 hover,CSS 已经足够。相反,另一个误区是:所有动画都手写 CSS。对于退出卸载、共享布局和复杂列表,这会把生命周期管理分散到多个事件处理器中,维护成本更高。


九、常见失败表现和诊断方法

9.1 动画完全不播放

按以下顺序检查:

  1. React 状态是否真的改变;
  2. 新旧元素是否是同一个 DOM 节点;
  3. 新旧 CSS 计算值是否不同;
  4. transition 是否写在变化前后都存在的基础类上;
  5. 属性是否为可插值属性;
  6. 是否被 prefers-reduced-motiondisplay: none 或其他规则覆盖。

浏览器开发者工具中的 Computed 面板可以确认最终的 transition-propertytransition-duration 和目标属性值,而不是只看源码。

9.2 进入动画第一次渲染就没有起点

如果服务端输出的 HTML 已经是最终可见状态,客户端挂载后才添加动画类,浏览器可能没有观察到一次有效的起始值变化。Motion 的 initial 可以显式声明起点;手写 CSS 则应确保初始类和目标类之间存在可观察的提交变化。

9.3 退出动画被截断

通常是条件渲染直接返回 null。解决路径有两类:

  • 保持节点挂载,动画完成后再清理;
  • 使用 AnimatePresence 等管理退出生命周期的组件。

还要检查父节点是否在子动画完成前被卸载,以及是否存在 overflow: hidden 截断移动区域。

9.4 布局动画跳跃

优先检查:

  • 列表是否使用稳定且唯一的 key
  • 是否在动画中改变了 display、定位方式或父级布局;
  • 图片和字体尺寸是否在动画期间才确定;
  • 是否在同一事件中同时修改了多个互相依赖的状态;
  • 是否误以为 useTransition 会产生视觉过渡。

9.5 动画期间界面变得更卡

可能原因不是动画属性本身,而是每一帧触发了 React 大范围重渲染、强制布局或大型绘制。可以在 Performance 面板查看是否出现连续的 Recalculate Style、Layout、Paint;在 React DevTools Profiler 中确认是否有无关组件反复渲染。优化方向应根据证据选择,而不是无条件添加 will-change 或把所有内容移入动画库。


十、一个完整的决策示例

假设要实现一个搜索结果页面:

  1. 输入框必须立即响应;
  2. 结果计算较昂贵;
  3. 结果列表切换时希望淡入;
  4. 删除结果时希望播放退出动画;
  5. 用户设置了减少动态时,不应出现明显位移。

可以拆成四层:

"use client";

import { useState, useTransition } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

export function SearchPage() {
  const [query, setQuery] = useState("");
  const [resultQuery, setResultQuery] = useState("");
  const [isPending, startTransition] = useTransition();
  const reduceMotion = useReducedMotion();

  function onQueryChange(value: string) {
    setQuery(value);

    startTransition(() => {
      setResultQuery(value);
    });
  }

  const results = getResults(resultQuery);

  return (
    <main>
      <input
        value={query}
        onChange={(event) => onQueryChange(event.target.value)}
        aria-label="搜索"
      />

      {isPending && <span role="status">更新中</span>}

      <AnimatePresence mode="popLayout">
        {results.map((result) => (
          <motion.article
            key={result.id}
            layout
            initial={
              reduceMotion
                ? { opacity: 0 }
                : { opacity: 0, y: 12 }
            }
            animate={{ opacity: 1, y: 0 }}
            exit={
              reduceMotion
                ? { opacity: 0 }
                : { opacity: 0, y: -8 }
            }
            transition={
              reduceMotion
                ? { duration: 0.01 }
                : { duration: 0.18 }
            }
          >
            {result.title}
          </motion.article>
        ))}
      </AnimatePresence>
    </main>
  );
}

type Result = {
  id: string;
  title: string;
};

function getResults(query: string): Result[] {
  const all: Result[] = [
    { id: "1", title: "React 基础" },
    { id: "2", title: "CSS 动画" },
  ];

  return query
    ? all.filter((item) => item.title.includes(query))
    : all;
}

这段代码的因果关系是:

  • query 是紧急状态,保证输入框立即更新;
  • resultQuery 放入 React Transition,允许昂贵结果渲染让出优先级;
  • AnimatePresence 保证离开的结果仍在 DOM 中,直到 exit 完成;
  • layout 处理其他结果因为删除而发生的位置变化;
  • useReducedMotion 减少位移,但仍保留很短的透明度反馈;
  • key={result.id} 让 React 和 Motion 正确识别结果实体。

这个示例仍有边界:真实搜索通常涉及异步请求、请求取消、错误状态和服务端数据缓存。网络请求的竞态不能靠动画解决;应使用请求库或显式的请求序列号、AbortController 等机制保证旧响应不会覆盖新查询结果。动画只负责结果已经确定后如何呈现。


结语

React 动画的核心不是“在组件里不断修改坐标”,而是把职责分开:

  • React 状态描述业务事实;
  • React useTransition 调整渲染更新的优先级;
  • CSS Transition 处理简单、局部且持续挂载的样式变化;
  • Motion 处理进入、退出、手势和布局测量;
  • 浏览器或框架级 View Transitions 适合更大范围的视图切换;
  • prefers-reduced-motion 决定动画是否应降低动态程度。

当动画失败时,先问三个问题:状态是否变化、节点是否仍存在、变化的属性是否可插值。再根据是否涉及卸载、布局测量和并发渲染选择工具,通常比盲目增加动画代码更容易得到正确结果。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。