Suspense 会在内部组件还没准备好时显示一个 fallback,比如一条加载提示。最常见的等待对象是 React.lazy:它在组件第一次渲染时才加载该组件的代码,让首次下载的体积保持较小。
点击 Show chart:Loading chart... 会出现一秒,然后显示图表。隐藏后再显示,图表会立刻出现,也没有新的日志,因为 lazy 会保留已经加载的模块。
用 lazy 做代码分割
编辑器把所有代码放在一个文件里,所以这个例子手动构造了一个慢模块:一个在一秒后 resolve 为带 default 导出的对象的 promise。在真实应用中,组件放在自己的文件里,你传入的是一个动态 import:
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./Chart.jsx'));
export default function Dashboard() {
return (
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
);
}
import('./Chart.jsx') 返回一个代表该模块的 promise。Vite 和 webpack 等打包工具看到动态 import 后,会把 Chart.jsx 以及只有它用到的代码放进一个单独的文件,在 <Chart /> 第一次渲染时下载。需要了解的规则:
- 模块需要默认导出。
lazy读取的是 promise resolve 结果的default属性。如果是具名导出,就做一次映射:lazy(() => import('./charts.js').then((m) => ({ default: m.LineChart })))。 - 在模块顶层调用
lazy。 放在组件内部,它会在每次渲染时创建一个新的组件类型,React 就会卸载旧组件、丢掉它的 state,再重新加载一次。 - 在用户本来就要等待的地方分割。 路由、弹窗、很少打开的面板以及较重的组件(编辑器、图表、地图)都是合适的选择。把每个小组件都拆开,只会多出请求和加载状态,没有任何好处。
在点击之前预加载
懒加载组件在第一次渲染时才开始加载,所以用户点击后至少要等一次下载。如果你能预料到用户要点击,就提前开始下载。把 import 函数存进变量,在鼠标悬停或获得焦点时调用它;浏览器会保留这个模块,所以之后 lazy 调用同一个 import 时,无需再次下载就能 resolve。
const loadChart = () => import('./Chart.jsx');
const Chart = lazy(loadChart);
<button onMouseEnter={loadChart} onFocus={loadChart} onClick={() => setShow(true)}>
Show chart
</button>
Suspense 如何决定显示什么
当 <Suspense> 内的某个组件还没准备好时,它会挂起:React 停止渲染这一部分,显示上方最近的 Suspense 的 fallback。被 fallback 替换的是这个边界里的全部内容,而不只是正在等待的那个组件。等它等待的东西准备好了,React 会重新渲染内容,替换掉 fallback。
所以边界放在哪里,是一个设计决策。把互相独立的部分放进各自的边界,这样每一部分都能在准备好时就显示出来:
一开始整个页面显示 Loading page...,因为 Header 属于外层边界。标题准备好后,文章出现,下面是 Loading comments...,评论最后才到。把 1500 改成 3000,预览会重新加载,只有评论的等待时间变长。删掉内层的 <Suspense>(保留 <Comments />),页面就要等评论加载完才显示任何内容。
React 19 中 Suspense 与 use() 的配合
在 React 19 中,组件可以用 use(promise) 读取 promise。如果 promise 还在 pending,组件就会挂起,最近的 Suspense 显示它的 fallback;promise resolve 后,use 返回结果值。use Hook 一页有完整介绍。下面这个假的 fetchUser 代替了真实的请求。
依次点击各个用户,再回到 User 1:它会立刻出现,控制台也没有新的 fetch 记录,因为这个 promise 已经在缓存里了。
为什么必须缓存 promise
这里的 cache Map 不是优化,而是必需的。挂起的组件不会保留那次尝试的任何东西:promise resolve 后,React 会从头重新渲染它。如果 Profile 直接调用 fetchUser(id),每次尝试都会创建一个新的 promise,发起一个新的请求,然后再次在它上面挂起。资料永远不会出现,控制台会被 fetching user 刷屏。所以 promise 必须来自一个比渲染活得更久的地方:
- 以请求为键的缓存,就像上面的 Map(TanStack Query 等数据请求库和框架的 loader 会替你做这件事);
- 一个只创建一次 promise 的父组件,在事件处理函数或 Server Component 中创建,再通过 prop 传下去。
注意,每切换一个新用户,资料仍会被 fallback 替换。如果你希望旧用户一直显示在屏幕上,直到下一个用户准备好,就把更新包在 transition 里:startTransition(() => setId(n))。对于 transition,React 不会隐藏已经显示的内容(见 useTransition)。
错误需要错误边界
Suspense 处理的是等待,而不是失败。如果懒加载的 import 失败了(用户断网了,或者新的部署删掉了旧的 chunk),或者传给 use 的 promise 被 reject,React 会把错误抛给最近的错误边界。如果没有错误边界,根节点以下的整棵树都会被卸载。错误边界目前仍然是类组件(见错误边界):
<ErrorBoundary fallback={<p>Could not load the chart.</p>}>
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
</ErrorBoundary>
Suspense 检测不到的情况
Suspense 只对会挂起的组件做出反应:lazy 组件、use(promise),以及为 Suspense 设计的数据源(框架的 loader、支持 Suspense 的库)。在 useEffect 里发起 fetch、完成后再设置 state,这种写法不会挂起,所以包在外面的 Suspense 边界永远不会显示 fallback。对于这种写法,你需要自己维护一个 loading state,就像数据请求那一页展示的那样。
常见问题
React Suspense 是什么?
<Suspense fallback={...}> 是一个组件,当它内部的任何组件在等待某样东西时(比如懒加载的代码或用 use 读取的数据),它会显示 fallback。等内部全部准备好,React 就把 fallback 换成真正的内容。
React.lazy 有什么作用?
lazy(() => import('./Chart.jsx')) 会创建一个组件,它的代码在第一次渲染时才下载。打包工具会把这个文件放进单独的 chunk,这样首次加载页面时需要的 JavaScript 就更少。
Suspense 能用于数据请求吗?
可以,前提是数据源支持它。在 React 19 中,组件可以用 use(promise) 读取 promise,并在它 resolve 之前挂起。Next.js 等框架也把 Suspense 集成进了它们的数据加载。在 useEffect 里发起的 fetch 不会触发 Suspense。
Suspense 如何处理错误?
Suspense 只负责等待。如果懒加载的 import 或 promise 失败,错误会交给最近的错误边界处理,所以要用错误边界包住 Suspense 边界(或它的父组件)。
lazy 应该在哪里调用?
在模块的顶层,任何组件之外。在组件内部调用 lazy 会在每次渲染时创建一个新的组件类型,导致它的 state 被重置并重新加载。