Appearance
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 的设计思想是将“数据尚未准备好”视为一种渲染状态,就像 null 或 undefined 一样。当组件需要的数据还未就绪时,它就“抛出”一个 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;
}
}设计解析:
- 检查
then方法:useHook 首先检查传入的usable是否是一个Thenable(即一个有.then方法的对象,如Promise)。 - 追踪
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.js 的 updateSuspenseComponent 函数中。
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
);
// ...
}
// ...
}
}
// ...
}工作流程解析:
- 尝试渲染
children:updateSuspenseComponent首先会正常尝试渲染<Suspense>的子组件。在这个过程中,如果子组件调用了use(promise)并且promise处于pending状态,useHook 就会抛出这个promise。 - 捕获
Promise:try...catch块会捕获到这个被抛出的promise。React 检查thrownValue是否是一个Thenable。 - 寻找
Suspense边界:确认是Promise后,React 会从当前 Fiber 节点开始,沿着return路径向上遍历,找到最近的<Suspense>组件(即SuspenseComponent类型的 Fiber),这个<Suspense>就是负责处理此次挂起的“边界”。 - 标记挂起与调度
fallback:- React 会给这个
Suspense边界 Fiber 添加一个DidCapture的flag,标记它捕获到了一个挂起事件。 - 然后,React 会“丢弃”之前尝试渲染的
children,转而使用Suspense组件的fallbackprop 来作为新的子节点进行reconcile。 - 同时,React 会将这个
Promise和当前的renderLanes附加到Suspense边界上,并创建一个wakeable。当Promise完成后,这个wakeable会被触发,调度一个新的更新来重新渲染组件。
- React 会给这个
- 恢复渲染:当
Promiseresolve后,React 会在一个新的渲染任务中,再次尝试渲染Suspense的children。这一次,use(promise)会成功返回值,渲染得以顺利完成,用户将看到最终的 UI。
4. 与并发特性的协同
Suspense 与 useTransition 结合使用时,可以实现非常强大的用户体验模式。
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”按钮时,
startTransition将setResource的更新标记为低优先级的过渡更新。 ProfilePage组件内部使用use(resource)来获取数据。由于resource是一个新的Promise,组件会挂起。- 因为这次更新是在一个
Transition中,React 会“知道”这是一个预期的挂起。它不会立即显示Suspense的fallback(这可能会导致 UI 的突然闪烁),而是会继续显示旧的 UI(isPending为true,可以显示一个内联的Spinner)。 - React 会在后台等待新的
resource加载完成。一旦完成,它会一次性地将新的ProfilePage渲染出来,实现平滑的过渡。
这种“先渲染旧 UI,后台准备新 UI”的模式,避免了在数据加载期间出现不必要的 fallback 闪烁,是 React 并发渲染提供卓越用户体验的核心体现。