Skip to content

9.5 Suspense for Data Fetching

Suspense 是 React 并发故事中至关重要的一环。最初,Suspense 主要用于代码分割(React.lazy),但在 React 19 中,它与新的 use Hook 结合,正式成为官方推荐的数据获取方案。它允许我们以声明式的方式处理异步数据加载,极大地改善了代码结构和用户体验。

1. 设计思想:将加载状态视为一种渲染状态

在传统的数据获取模式中,我们通常需要手动管理各种加载状态:

javascript
function MyComponent() {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchData()
      .then(setData)
      .catch(setError)
      .finally(() => setIsLoading(false));
  }, []);

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage />;
  return <DisplayData data={data} />;
}

这种模式存在几个问题:

  • 状态管理的复杂性:每个需要异步数据的组件都必须维护 isLoading, error, data 等多个状态。
  • 瀑布流问题(Waterfall):如果子组件也需要获取数据,必须等待父组件数据加载完成后才能开始,导致加载时间变长。
  • 竞态条件(Race Conditions):在组件卸载后,如果数据获取的回调仍在执行并尝试更新状态,会导致内存泄漏和应用错误。

Suspense 的设计思想是将“数据尚未准备好”视为一种渲染状态,就像 nullundefined 一样。当组件需要的数据还未就绪时,它就“抛出”一个 Promise。上层的 <Suspense> 组件会“捕获”这个 Promise,并渲染 fallback UI。当 Promise 完成后,React 会重新尝试渲染原来的组件。

2. use Hook:在渲染中读取 Promise

为了配合 Suspense,React 19 引入了一个新的 use Hook。use Hook 是一个革命性的 API,它允许你在渲染期间(包括在普通组件和自定义 Hook 中)直接读取 Promise 的值。

javascript
import { use } from 'react';

function MyComponent({ dataPromise }) {
  // 如果 dataPromise 还在 pending,`use` 会抛出这个 promise
  // 如果 dataPromise 已经 resolved,`use` 会返回其结果值
  // 如果 dataPromise 已经 rejected,`use` 会抛出其拒绝的原因
  const data = use(dataPromise);

  return <DisplayData data={data} />;
}

function App() {
  const dataPromise = fetchData();
  return (
    <Suspense fallback={<Spinner />}>
      <MyComponent dataPromise={dataPromise} />
    </Suspense>
  );
}

use Hook 源码分析

use Hook 的实现位于 ReactFiberHooks.js 中,其核心逻辑非常直观。

javascript
// /Users/xigua/Desktop/FE/react-book/react/packages/react-reconciler/src/ReactFiberHooks.js

function use<T>(usable: Usable<T>): T {
  if (usable !== null && typeof usable === 'object') {
    if (typeof usable.then === 'function') {
      // This is a thenable.
      const thenable: Thenable<T> = (usable: any);
      return trackUsedThenable(thenable);
    }
  }
  // ... 其他类型的 usable,如 Context
  throw new Error('An unsupported type was passed to use().');
}

function trackUsedThenable<T>(thenable: Thenable<T>): T {
  switch (thenable.status) {
    case 'fulfilled':
      return thenable.value;
    case 'rejected':
      throw thenable.reason;
    case 'pending':
    default:
      // This is a pending thenable. Throw it to suspend.
      throw thenable;
  }
}

设计解析:

  1. 检查 then 方法use Hook 首先检查传入的 usable 是否是一个 Thenable(即一个有 .then 方法的对象,如 Promise)。
  2. 追踪 Thenable 状态trackUsedThenable 函数会检查 Promise 的内部状态(通常由与 React 集成的数据获取库包装,如 react-cache)。
    • fulfilled:如果 Promise 已完成,直接返回结果值 thenable.value
    • rejected:如果 Promise 已失败,抛出错误 thenable.reason,这可以被上层的错误边界捕获。
    • pending:如果 Promise 还在进行中,直接将 thenable 自身抛出。这是触发 Suspense 机制的关键。

3. Suspense 的工作流程:捕获与恢复

use(promise) 抛出 Promise 时,React 的渲染流程会中断,并开始向上寻找 Suspense 边界。这个过程的核心位于 ReactFiberBeginWork.jsupdateSuspenseComponent 函数中。

javascript
// /Users/xigua/Desktop/FE/react-book/react/packages/react-reconciler/src/ReactFiberBeginWork.js (Conceptual)

function updateSuspenseComponent(current, workInProgress, renderLanes) {
  const nextProps = workInProgress.pendingProps;
  const suspenseState: SuspenseState | null = workInProgress.memoizedState;

  // 1. 渲染主内容 (children)
  try {
    renderWithHooks(current, workInProgress, ...);
    const nextChildren = workInProgress.child;
    // ...
    reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  } catch (thrownValue) {
    // 捕获到 use(promise) 抛出的值
    if (typeof thrownValue.then === 'function') {
      // 这是一个 Promise,意味着组件挂起了
      // ...
      // 2. 寻找 Suspense 边界并设置挂起状态
      const suspenseBoundary = findSuspenseBoundary(workInProgress);
      if (suspenseBoundary !== null) {
        // 标记此 Suspense 边界为“已捕获”
        suspenseBoundary.flags |= DidCapture;
        // ...

        // 3. 切换到 fallback
        // 将 workInProgress 的子节点切换为 fallback
        forceUnmountCurrentAndReconcile(
          current, 
          workInProgress, 
          nextProps.fallback, // 渲染 fallback
          renderLanes
        );
        // ...
      }
      // ...
    }
  }

  // ...
}

工作流程解析:

  1. 尝试渲染 childrenupdateSuspenseComponent 首先会正常尝试渲染 <Suspense> 的子组件。在这个过程中,如果子组件调用了 use(promise) 并且 promise 处于 pending 状态,use Hook 就会抛出这个 promise
  2. 捕获 Promisetry...catch 块会捕获到这个被抛出的 promise。React 检查 thrownValue 是否是一个 Thenable
  3. 寻找 Suspense 边界:确认是 Promise 后,React 会从当前 Fiber 节点开始,沿着 return 路径向上遍历,找到最近的 <Suspense> 组件(即 SuspenseComponent 类型的 Fiber),这个 <Suspense> 就是负责处理此次挂起的“边界”。
  4. 标记挂起与调度 fallback
    • React 会给这个 Suspense 边界 Fiber 添加一个 DidCaptureflag,标记它捕获到了一个挂起事件。
    • 然后,React 会“丢弃”之前尝试渲染的 children,转而使用 Suspense 组件的 fallback prop 来作为新的子节点进行 reconcile
    • 同时,React 会将这个 Promise 和当前的 renderLanes 附加到 Suspense 边界上,并创建一个 wakeable。当 Promise 完成后,这个 wakeable 会被触发,调度一个新的更新来重新渲染组件。
  5. 恢复渲染:当 Promise resolve 后,React 会在一个新的渲染任务中,再次尝试渲染 Suspensechildren。这一次,use(promise) 会成功返回值,渲染得以顺利完成,用户将看到最终的 UI。

4. 与并发特性的协同

SuspenseuseTransition 结合使用时,可以实现非常强大的用户体验模式。

javascript
function App() {
  const [resource, setResource] = useState(initialResource);
  const [isPending, startTransition] = useTransition();

  function handleNextClick() {
    startTransition(() => {
      setResource(fetchNextResource());
    });
  }

  return (
    <>
      <button onClick={handleNextClick} disabled={isPending}>
        Next
      </button>
      {isPending ? <Spinner /> : null}
      <Suspense fallback={<Spinner />}>
        <ProfilePage resource={resource} />
      </Suspense>
    </>
  );
}

在这个例子中:

  • 当用户点击“Next”按钮时,startTransitionsetResource 的更新标记为低优先级的过渡更新。
  • ProfilePage 组件内部使用 use(resource) 来获取数据。由于 resource 是一个新的 Promise,组件会挂起。
  • 因为这次更新是在一个 Transition 中,React 会“知道”这是一个预期的挂起。它不会立即显示 Suspensefallback(这可能会导致 UI 的突然闪烁),而是会继续显示旧的 UI(isPendingtrue,可以显示一个内联的 Spinner)。
  • React 会在后台等待新的 resource 加载完成。一旦完成,它会一次性地将新的 ProfilePage 渲染出来,实现平滑的过渡。

这种“先渲染旧 UI,后台准备新 UI”的模式,避免了在数据加载期间出现不必要的 fallback 闪烁,是 React 并发渲染提供卓越用户体验的核心体现。

Last updated: