Menu

React Suspense 和 lazy:加载状态与代码分割

Suspense 会在内部组件还没准备好时显示 fallback,React.lazy 则在组件第一次渲染时才加载它的代码。学习代码分割、嵌套边界、React 19 中 Suspense 与 use() 的配合,以及错误处理。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

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 被重置并重新加载。

Coddy 编程语言插图

和 Coddy 一起学编程

开始