React Suspense 与并发特性

Suspense 机制原理、Concurrent Mode 的 startTransition 和 useDeferredValue、并发渲染的可中断性(Fiber 架构回顾),以及如何正确使用 Suspense 和并发特性。

React Suspense 与并发特性

概述

React 的 Suspense 和 Concurrent Mode(并发模式)是 React 16 之后最重要的架构演进。它们解决的核心问题是:如何在保持 UI 响应的同时,处理异步数据和耗时的渲染任务

传统的 React 渲染是"同步且不可中断"的。一旦开始渲染,必须等所有组件都渲染完才能响应用户交互。在数据量大或组件复杂时,这会导致界面卡顿——用户点击按钮,要等好几秒才能看到结果。

Suspense 和 Concurrent Mode 通过可中断的渲染架构从根本上改变了这一点。React 可以:

  • 在渲染过程中"暂停",优先处理用户交互
  • 在后台"预渲染"即将显示的内容
  • 避免不必要的 loading 状态闪烁

理解这些机制,不仅是为了用好这些 API,更是为了理解 React 为什么要做这些改变。


Suspense 机制原理

问题的本质

在 Suspense 出现之前,处理异步数据是 React 的痛点。常见模式有:

1. 状态管理法(Loading State)

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <Skeleton />;
  if (error) return <Error error={error} />;
  if (!user) return null;

  return <div>{user.name}</div>;
}

问题:每个需要数据的组件都要写一堆 loading/error 状态逻辑。

2. 外部状态管理法(Redux/Server State 库)

把数据放到外部 store,组件直接从 store 读取。减少了组件内的样板代码,但增加了全局复杂度。

Suspense 的核心思想

Suspense 的解决思路完全不同:不是让组件自己管理 loading 状态,而是让组件"声明"自己需要等待数据,然后让外层的 Suspense 边界统一处理 loading 状态

<Suspense fallback={<Loading />}>
  <UserProfile userId={id} />
</Suspense>

这个写法意味着:"如果 UserProfile 在渲染过程中需要暂停(等待数据),就显示 Loading,而不是让 UserProfile 自己处理 loading 状态"。

"Suspend" 意味着什么

当组件在渲染过程中需要"等待"某些异步资源时,它可以通过以下方式触发 Suspense:

  1. 抛出 Promise:数据获取库(如 Relay、React Query)在数据未就绪时抛出 Promise
  2. 使用 use() hook:React 官方提供的 use() hook 可以读取 Promise 或 Context
function UserProfile({ userPromise }) {
  // use() 会暂停渲染直到 Promise resolve
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

// 使用
<Suspense fallback={<Loading />}>
  <UserProfile userPromise={fetchUser(userId)} />
</Suspense>

Suspense 的工作流程

React 官方文档对 Suspense 的描述非常清晰:当组件"挂起"(suspend)时,React 会:

  1. 尝试渲染 children:React 正常渲染子组件
  2. 检测到 suspend:如果子组件在渲染中等待异步资源,React 知道它"suspended"
  3. 显示 fallback:立即显示 <Suspense fallback={...} /> 中的 fallback
  4. 继续等待:同时,React 继续在后台处理异步操作
  5. 数据就绪后:自动重新渲染,这次 children 不再 suspend,fallback 被隐藏
<Suspense fallback={<BigSpinner />}>
  <Dashboard />
</Suspense>

// Dashboard 内部
function Dashboard() {
  // 这个会暂停渲染直到数据就绪
  const recentPosts = use(fetchRecentPosts());

  return (
    <div>
      <NewStats />
      <Suspense fallback={<PostsGlimmer />}>
        <Posts posts={recentPosts} />
      </Suspense>
    </div>
  );
}

Suspense 触发的唯一来源

重要:React Suspense 只能通过以下方式触发:

  1. 数据获取库的 Suspense 支持:Relay、React Query(通过 suspense: true 选项)
  2. React.lazy():动态 import 的组件加载
  3. 使用 use() hook 读取未 resolved 的 Promise

以下情况不会触发 Suspense:

  • useEffect 中的数据获取
  • 事件处理器中的数据获取
  • 普通 Promise(不用 use()

嵌套 Suspense 与渐进加载

Suspense 支持嵌套,实现渐进加载:

<Suspense fallback={<PageSkeleton />}>
  <Dashboard />
</Suspense>

// Dashboard 内部
function Dashboard() {
  return (
    <div>
      {/* 这个 Suspense 在 Dashboard 内部 */}
      <Suspense fallback={<StatsGlimmer />}>
        <Stats />
      </Suspense>

      {/* 这个 Suspense 也是 */}
      <Suspense fallback={<PostsGlimmer />}>
        <Posts />
      </Suspense>
    </div>
  );
}

加载顺序:

  1. 初始显示 <PageSkeleton />
  2. Dashboard 渲染到 Stats 和 Posts 时,它们各自触发 Suspense
  3. Stats 就绪后显示,Posts 继续显示 <PostsGlimmer />
  4. Posts 就绪后,显示完整内容

避免不必要的 Fallback 显示

默认情况下,Suspense 会在组件 suspend 时立即显示 fallback。但有时我们希望不显示 fallback,直接显示旧内容,直到新内容就绪

这就是 useDeferredValue 的用武之地。


Concurrent Mode:并发渲染

什么是并发模式

Concurrent Mode 是 React 16 开始引入的新渲染架构。核心特性是可中断的渲染

在 Concurrent Mode 下,React 的渲染过程不再是"一气呵成",而是可以被"打断"的。这意味着:

  • React 可以在渲染中途暂停,优先处理用户输入
  • 可以同时"预渲染"多个版本的 UI
  • 可以延迟不紧急的更新,保证紧急更新的响应

为什么需要并发渲染

考虑一个场景:用户在一个搜索框输入文字,触发了大量搜索结果的重新渲染:

function SearchPage() {
  const [query, setQuery] = useState('');

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <BigList items={filterItems(allItems, query)} />
    </div>
  );
}

传统模式下:

  1. 用户输入 "a"
  2. React 重新渲染,触发 BigList 过滤和渲染
  3. 整个渲染过程不能中断——用户在这几秒内无法点击任何按钮
  4. 输入框也无法响应下一次 onChange

并发模式下:

  1. 用户输入 "a"
  2. React 开始重新渲染
  3. 渲染可以被中断——如果用户继续输入 "ab",React 可以暂停当前渲染,优先处理新的输入
  4. 最终只渲染 "ab" 的结果,而不是先渲染 "a" 再渲染 "ab"

startTransition

startTransition 是 React 提供的 API,用于标记非紧急的更新

import { startTransition } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [deferredQuery, setDeferredQuery] = useState('');

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);  // 紧急更新:反映用户输入

    startTransition(() => {
      setDeferredQuery(value);  // 非紧急更新:触发大量计算
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <BigList items={filterItems(allItems, deferredQuery)} />
    </div>
  );
}

关键点

  • setQuery(value) 是紧急更新——用户期望立即看到输入的字符
  • setDeferredQuery(value)startTransition 内,是非紧急更新——可以延迟

useTransition

useTransitionstartTransition 的 hook 版本,额外提供 isPending 状态:

import { useTransition } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [deferredQuery, setDeferredQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);

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

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {/* 用 isPending 显示加载状态 */}
      {isPending ? <Spinner /> : <BigList items={filterItems(allItems, deferredQuery)} />}
    </div>
  );
}

isPending 在 transition 进行中为 true,可以用于:

  • 显示 loading 指示器
  • 禁用某些 UI 元素
  • 提供视觉反馈

useDeferredValue

useDeferredValue 是另一个处理非紧急更新的 hook,适用于从 props 或 Hook 返回值获取值的场景:

import { useDeferredValue } from 'react';

function SearchPage({ query }) {
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <InputDisplay value={query} />
      <BigList items={filterItems(allItems, deferredQuery)} />
    </div>
  );
}

function InputDisplay({ value }) {
  // value 是紧急渲染
  // deferredQuery 是延迟渲染
  return <div>{value}</div>;
}

useDeferredValue vs startTransition

场景 useDeferredValue startTransition
值来自 props/hook
值来自 setState
需要 isPending ✅(用 useTransition)

并发渲染的可中断性

理解 Concurrent Mode 的关键是理解React 如何实现可中断渲染。这需要回顾 Fiber 架构。

Fiber 架构回顾

Fiber 是 React 16 引入的新协调器(Reconciler)架构。它的核心思想是将渲染工作拆分成可中断的工作单元

在 React 15 及之前,React 使用递归调和(Reconciliation),整个组件树被同步遍历和渲染,无法中断。

Fiber 的关键数据结构:

function FiberNode(tag, pendingProps) {
  // 关键字段
  this.return = null;      // 父 Fiber
  this.child = null;       // 子 Fiber
  this.sibling = null;      // 兄弟 Fiber
  this.alternate = null;    // work-in-progress branch
  this.effectTag = null;    // 副作用标记
  this.pendingProps = pendingProps;
  this.memoizedState = null;
}

Fiber 将工作分成两个阶段:

1. Render Phase(可中断)

  • 确定需要创建/更新的 React 元素
  • 可以被暂停和恢复
  • 可能被丢弃并重新开始

2. Commit Phase(不可中断)

  • 同步执行 DOM 变更
  • 副作用(useEffect)在这里执行
  • 必须完整执行,不能中断

优先级与调度

Fiber 使用 lanes(优先级车道)系统来管理更新优先级:

// 简化的优先级定义
const SyncLane = 0b0001;      // 同步优先级:用户输入、点击
const InputContinuousLane = 0b0100;  // 连续输入
const DefaultLane = 0b1000;   // 默认优先级
const TransitionLane = 0b10000;  // Transition 优先级
const IdleLane = 0b100000;    // 空闲优先级

React 调度器(Scheduler)根据 lane 优先级决定:

  • 哪个更新先处理
  • 哪个更新可以被中断
  • 何时让出主线程

正确使用 Suspense 和并发特性

不要用 Suspense 做所有事情

Suspense 适合数据获取代码分割,不适合:

  • 用户输入验证(应该同步处理)
  • 表单提交(应该用 mutation)
  • 动画(应该用 CSS 动画或 requestAnimationFrame)

组合使用 Suspense 和 startTransition

function ProfilePage({ userId }) {
  const [tab, setTab] = useState('overview');
  const [isPending, startTransition] = useTransition();

  const handleTabChange = (newTab) => {
    startTransition(() => {
      setTab(newTab);
    });
  };

  return (
    <div>
      <Tabs value={tab} onChange={handleTabChange} />
      {isPending ? (
        <TabSkeleton />
      ) : (
        <Suspense fallback={<TabContentSkeleton />}>
          <TabContent tab={tab} userId={userId} />
        </Suspense>
      )}
    </div>
  );
}

避免 Suspense 导致的布局抖动

当 Suspense fallback 和实际内容高度差异大时,会出现布局跳动(layout shift)。解决方案:

  1. 使用骨架屏占位:让 fallback 高度接近实际内容
  2. 固定容器高度:用 CSS 避免布局变化
  3. 使用 useDeferredValue:避免 fallback 显示
function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <div style={{ opacity: isStale ? 0.5 : 1 }}>
      <BigList items={filterItems(allItems, deferredQuery)} />
    </div>
  );
}

嵌套 Suspense 的最佳实践

// 外层:处理整体页面的加载
<Suspense fallback={<PageSkeleton />}>

  <Header />

  {/* 中层:处理复杂组件的加载 */}
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>

  {/* 内层:处理数据列表 */}
  <Suspense fallback={<PostListSkeleton />}>
    <PostList />
  </Suspense>

</Suspense>

层级越深,fallback 越具体,用户体验越平滑。

React 18 的并发特性

React 18 正式支持了并发特性,包括:

  1. Automatic Batching:所有状态更新自动批处理(包括 Promise、setTimeout)
  2. startTransition:标记非紧急更新
  3. useDeferredValue:延迟值更新
  4. Suspense on Server:服务端 Suspense 支持
  5. Streaming SSR with Suspense:流式服务端渲染

常见误区

误区 1:Suspense 是 loading state

Suspense 不是 loading state 的替代品。它是一种加载编排机制,用于处理组件树中异步资源的加载顺序。

误区 2:startTransition 让一切变快

startTransition 不加速任何东西。它只是改变优先级,让紧急更新先执行。

误区 3:用了 Concurrent Mode 就自动变快

Concurrent Mode 不是性能优化的银弹。它解决的是响应性问题(保持 UI 响应),不是计算效率问题。


延展阅读