早在 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 工作的大体流程已经清晰。下面是一些具体使用时需要注意的地方
- 你可能注意到,最开始的例子中,isLoaded 放在了组件外,这里为什么不用我们更熟悉的
useRef
保存状态呢。因为 Suspense 组件并不会保存任何状态,重新渲染时将完全重新渲染,无论是useState
还是useMemo
之类的 Hooks 都不会生效,useEffect
在 suspended 状态不会被执行。具体可以参考:https://github.com/facebook/react/issues/14563。因此,状态必须被保存在外部,可以使用上回文章提到的useExternalStore
(useSWR 就是这么做的)。 - 当同一位置存在多个 Suspense 组件时,必须考虑组件切换时是否会重新渲染出 fallback 状态。例如,一个 Tab 列表中多个 Tab 都是懒加载组件,并被 Suspense 包裹,切换 Tab 时我们可能不希望再显示初始的 Loading 状态,这时候可以配合
startTransition
进行切换。可以参考:https://github.com/reactwg/react-18/discussions/94。
经过一定的实践,我们可以发现当前利用 Suspense 进行数据加载其实是比较简陋的。React 也会在 18 的后续版本增加更多用于数据加载的功能,可以持续关注一下。
参考
- https://reactjs.org/docs/react-api.html#reactsuspense
延伸阅读
- React Suspense RFC: https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md
- Suspense for Data Fetching 实例教学:https://blog.logrocket.com/react-suspense-data-fetching/