React 18: Suspense for Data Fetching

早在 React 16.6,React 就引入了 Suspense 组件,用于懒加载 React 组件(使用 React.lazy)。但直到 React 18,它才正式支持了更广泛的用途:数据加载。

尽管,在 React 18 的文档中,还是提到,当前版本的 Suspense 的唯一用途是懒加载组件,但它也提到了一些框架中已经用于数据加载,那么具体是怎么做的呢。

Today, lazy loading components is the only use case supported by <React.Suspense>[1]:

在 JavaScript 中,任何类型的值都可以被 throw 并被外层 catch,React 使用了这个特性,让 Suspense 可以捕捉内部抛出的任意 Promise。这个机制与 Error Boundary 相似,也有人称之为 Promise Boundary。

那么具体是怎么操作呢,来看一个例子

import { Suspense } from "react";

let isLoaded = false;

const Content = () => {
  if (!isLoaded) {
    throw new Promise((resolve) => {
      setTimeout(() => {
        isLoaded = true;
        resolve();
      }, 2000);
    });
  }
  return <div>111</div>;
};

export default function App() {
  return (
    <div id="App">
      <Suspense fallback={<div>Loading...</div>}>
        <Content />
      </Suspense>
    </div>
  );
}

这个例子的结果是,将会先看到 Loading…,两秒后重新渲染为111。这里的两秒延迟可以被替换为任意异步任务,例如从 API 获取数据等。

其实一些数据请求库已经支持了这一特性,例如 useSWR 支持 suspense 模式,其核心原理和这个例子是一样的。

下面从 React 源码分析一下 Promise 是怎么被 Suspense 处理的

首先,当一个错误被抛出时,会被 handleError 方法统一捕捉,判断抛出的值是否为一个 Promise,如果是,将会把组件标记为 Suspended 状态。随后,通过统一的 throwError 方法抛出。

if (
  thrownValue !== null &&
  typeof thrownValue === 'object' &&
  typeof thrownValue.then === 'function'
) {
  const wakeable: Wakeable = (thrownValue: any);
  markComponentSuspended(
    erroredWork,
    wakeable,
    workInProgressRootRenderLanes,
  );
} else {
  // 错误处理..略
}
throwException(
  root,
  erroredWork.return,
  erroredWork,
  thrownValue,
  workInProgressRootRenderLanes,
);

throwError 方法中,针对抛出的是 Promise 的情况,会找到最近的 Suspense Boundary,调度 Suspense Boundary 渲染 fallback 状态。这种抛出的 Promise 在 React 内部被称为 Wakeable。

const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
if (suspenseBoundary !== null) {
  suspenseBoundary.flags &= ~ForceClientRender;
  markSuspenseBoundaryShouldCapture(
    suspenseBoundary,
    returnFiber,
    sourceFiber,
    root,
    rootRenderLanes,
  );
  // We only attach ping listeners in concurrent mode. Legacy Suspense always
  // commits fallbacks synchronously, so there are no pings.
  if (suspenseBoundary.mode & ConcurrentMode) {
    attachPingListener(root, wakeable, rootRenderLanes);
  }
  attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
  return;
}

同时,会针对这些 Wakeable 注册一些钩子,在 Promise 被 resolve 的时候获得通知。这里分为 RetryListener 和 PingListener。具体的区别是什么呢

PingListener 针对的是 Concurrent Render 模式下,可能 fallback 还未渲染或正在渲染,Promise 就已经返回了结果,这种情况下会进行重新渲染。

if (
workInProgressRoot === root &&
isSubsetOfLanes(workInProgressRootRenderLanes, pingedLanes)
) {
// Received a ping at the same priority level at which we're currently
// rendering. We might want to restart this render. This should mirror
// the logic of whether or not a root suspends once it completes.

// TODO: If we're rendering sync either due to Sync, Batched or expired,
// we should probably never restart.

// If we're suspended with delay, or if it's a retry, we'll always suspend
// so we can always restart.
if (
  workInProgressRootExitStatus === RootSuspendedWithDelay ||
  (workInProgressRootExitStatus === RootSuspended &&
    includesOnlyRetries(workInProgressRootRenderLanes) &&
    now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
) {
  // Restart from the root.
  prepareFreshStack(root, NoLanes);
} else {
  // Even though we can't restart right now, we might get an
  // opportunity later. So we mark this render as having a ping.
  workInProgressRootPingedLanes = mergeLanes(
    workInProgressRootPingedLanes,
    pingedLanes,
  );
}

而 RetryListener 则适用于 fallback 已经被渲染的情况,此时会将该 Wakeable 加入组件的 updateQueue 当 Promise 返回时,React 会调度该组件重新渲染。

function attachRetryListener(
  suspenseBoundary: Fiber,
  root: FiberRoot,
  wakeable: Wakeable,
  lanes: Lanes,
) {
  // Retry listener
  //
  // If the fallback does commit, we need to attach a different type of
  // listener. This one schedules an update on the Suspense boundary to turn
  // the fallback state off.
  //
  // Stash the wakeable on the boundary fiber so we can access it in the
  // commit phase.
  //
  // When the wakeable resolves, we'll attempt to render the boundary
  // again ("retry").
  const wakeables: Set<Wakeable> | null = (suspenseBoundary.updateQueue: any);
  if (wakeables === null) {
    const updateQueue = (new Set(): any);
    updateQueue.add(wakeable);
    suspenseBoundary.updateQueue = updateQueue;
  } else {
    wakeables.add(wakeable);
  }
}

注意这里还没执行 listener 的绑定,而是到 commit 阶段再操作

case SuspenseComponent: {
  // .. 省略

  if (flags & Update) {
    try {
      commitSuspenseCallback(finishedWork);
    } catch (error) {
      captureCommitPhaseError(finishedWork, finishedWork.return, error);
    }
    attachSuspenseRetryListeners(finishedWork);
  }
  return;
}

等到任意 Wakeable 被 resolve 的时候,调度一次新的渲染

function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
  let retryLane = NoLane; // Default
  let retryCache: WeakSet<Wakeable> | Set<Wakeable> | null;
  switch (boundaryFiber.tag) {
    case SuspenseComponent:
      retryCache = boundaryFiber.stateNode;
      const suspenseState: null | SuspenseState = boundaryFiber.memoizedState;
      if (suspenseState !== null) {
        retryLane = suspenseState.retryLane;
      }
      break;

   // .. 省略
  }

  // .. 省略

  retryTimedOutBoundary(boundaryFiber, retryLane);
}

function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) {
  // The boundary fiber (a Suspense component or SuspenseList component)
  // previously was rendered in its fallback state. One of the promises that
  // suspended it has resolved, which means at least part of the tree was
  // likely unblocked. Try rendering again, at a new lanes.
  if (retryLane === NoLane) {
    // TODO: Assign this to `suspenseState.retryLane`? to avoid
    // unnecessary entanglement?
    retryLane = requestRetryLane(boundaryFiber);
  }
  // TODO: Special case idle priority?
  const eventTime = requestEventTime();
  const root = enqueueConcurrentRenderForLane(boundaryFiber, retryLane);
  if (root !== null) {
    markRootUpdated(root, retryLane, eventTime);
    ensureRootIsScheduled(root, eventTime);
  }
}

至此,Suspense 工作的大体流程已经清晰。下面是一些具体使用时需要注意的地方

  1. 你可能注意到,最开始的例子中,isLoaded 放在了组件外,这里为什么不用我们更熟悉的 useRef 保存状态呢。因为 Suspense 组件并不会保存任何状态,重新渲染时将完全重新渲染,无论是 useState 还是 useMemo 之类的 Hooks 都不会生效,useEffect 在 suspended 状态不会被执行。具体可以参考:https://github.com/facebook/react/issues/14563。因此,状态必须被保存在外部,可以使用上回文章提到的 useExternalStore(useSWR 就是这么做的)。
  2. 当同一位置存在多个 Suspense 组件时,必须考虑组件切换时是否会重新渲染出 fallback 状态。例如,一个 Tab 列表中多个 Tab 都是懒加载组件,并被 Suspense 包裹,切换 Tab 时我们可能不希望再显示初始的 Loading 状态,这时候可以配合 startTransition 进行切换。可以参考:https://github.com/reactwg/react-18/discussions/94

经过一定的实践,我们可以发现当前利用 Suspense 进行数据加载其实是比较简陋的。React 也会在 18 的后续版本增加更多用于数据加载的功能,可以持续关注一下。

参考

  1. https://reactjs.org/docs/react-api.html#reactsuspense

延伸阅读

  1. React Suspense RFC: https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md
  2. Suspense for Data Fetching 实例教学:https://blog.logrocket.com/react-suspense-data-fetching/

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注