React 基础体系 · 第 32/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React lazy 与 Suspense:代码加载、边界、错误和用户体验

React.lazy<Suspense> 经常同时出现,但它们解决的是两个不同层次的问题:

  • lazy 把一个组件的代码加载过程转换为 React 可以观察的“未完成状态”;
  • Suspense 决定这个未完成状态应该在哪个 UI 边界显示什么;
  • 错误边界处理加载失败、渲染失败等异常;
  • 过渡、预加载、缓存和边界位置则决定用户看到的是短暂占位、整页闪烁,还是连续可用的界面。

如果只把代码改成 lazy(() => import(...)),却没有理解边界、失败路径和状态变化,应用可能仍然出现白屏、整页闪烁、无限重试或难以诊断的加载错误。

一、先区分:代码加载、组件渲染和数据加载

浏览器执行 React 应用时,至少有三类不同的工作:

  1. 下载 JavaScript 代码:例如下载某个设置页面对应的 chunk;
  2. 执行组件函数并生成 React 元素
  3. 获取组件需要的数据:例如请求用户设置、订单或报表。

React.lazy 主要处理第一类问题。它不会自动请求组件的数据,也不会把任意异步函数变成可被 Suspense 使用的数据资源。

const SettingsPage = lazy(() => import("./SettingsPage"));

这里的 import("./SettingsPage") 返回一个 Promise。打包工具通常会把 SettingsPage 拆成独立 chunk。当 React 第一次尝试渲染 SettingsPage 时,代码可能尚未下载完成,于是这个组件暂时不能完成渲染。

Suspense 处理的是“子树当前无法完成渲染”的状态,而不是专门处理网络请求:

<Suspense fallback={<PageSkeleton />}>
  <SettingsPage />
</Suspense>

如果 SettingsPage 正在等待 lazy 模块,Suspense 会显示 fallback。如果组件已经加载完成但在 useEffect 中请求数据,普通情况下这个请求不会自动触发 Suspense;组件通常会自己维护 loading 状态。

因此,下面两种代码的语义不同:

// 代码分割:React.lazy + Suspense
const Chart = lazy(() => import("./Chart"));

<Suspense fallback={<ChartSkeleton />}>
  <Chart />
</Suspense>
// 普通客户端数据请求:需要自行维护状态
function UserPanel() {
  const [state, setState] = useState<{
    status: "loading" | "success" | "error";
    user?: User;
    error?: Error;
  }>({ status: "loading" });

  // useEffect 中请求数据,然后更新 state
}

React 生态中也存在基于 Suspense 的数据获取方案,但那是数据缓存层、框架或库提供的能力,不是 lazy 本身自动提供的能力。

二、React.lazy 的机制和约束

1. 基本形式

lazy 接收一个无参数函数。这个函数必须返回一个 Promise 或 thenable,其最终结果必须是一个包含 default 导出的模块对象。

import { lazy } from "react";

const ReportPage = lazy(() => import("./ReportPage"));

对应的模块应当类似:

// ReportPage.tsx
export default function ReportPage() {
  return <h1>报表</h1>;
}

可以把它抽象成如下过程:

第一次渲染 lazy 组件
        │
        ├─ 模块已完成:得到 default 组件,继续渲染
        │
        ├─ 模块未完成:组件暂时挂起,向上寻找 Suspense
        │
        └─ Promise 拒绝:抛出加载错误,向上寻找 Error Boundary

lazy 会缓存加载结果,包括已解决的模块和已拒绝的 Promise。它不是每次渲染都重新执行 import()。这也是为什么它通常应定义在模块顶层,而不是组件函数内部。

错误示例:

function App() {
  // 每次 App 重新渲染,都可能创建一个新的 lazy 组件类型
  const Panel = lazy(() => import("./Panel"));

  return <Panel />;
}

这会使 React 把重新创建的 Panel 视为不同的组件类型,导致子树卸载并重新挂载,状态可能丢失,甚至产生重复加载和难以理解的闪烁。

正确写法:

const Panel = lazy(() => import("./Panel"));

function App() {
  return <Panel />;
}

这里有两个不同的“缓存”概念:

  • lazy 缓存模块加载结果;
  • React 组件实例的状态依赖组件在树中的身份、位置和 key。

lazy 放在组件外部,首先是为了稳定组件类型;它不等于把业务数据永久缓存。

2. 命名导出不能直接作为 lazy 的结果

下面的模块只有命名导出:

// Chart.tsx
export function Chart() {
  return <div>图表</div>;
}

直接这样写通常不符合 lazy 的要求:

const Chart = lazy(() => import("./Chart"));

因为模块对象没有 default 属性。可以在模块中增加默认导出:

export default function Chart() {
  return <div>图表</div>;
}

也可以在适配函数中把命名导出映射成默认导出:

const Chart = lazy(() =>
  import("./Chart").then((module) => ({
    default: module.Chart,
  })),
);

这个 then 的作用只是改变模块对象的形状,不是错误处理。若动态导入失败,Promise 仍然会拒绝,错误仍需由 Error Boundary 处理。

3. 动态导入失败的原因

import() 可能因为以下原因失败:

  • 网络中断;
  • CDN 或静态资源服务器返回 404;
  • 发布后旧页面引用了已经被清理的 chunk;
  • 内容安全策略或跨域配置阻止加载;
  • 服务端返回了 HTML 而不是 JavaScript;
  • 浏览器缓存和部署版本不一致。

失败时,Suspense 不会把错误自动变成“加载失败”文字。Suspense 只负责等待中的 UI;拒绝的 Promise 属于错误路径。

三、Suspense 是什么:可挂起子树的显示边界

Suspense 的核心不是“显示 loading”,而是定义一个边界:

<Suspense fallback={<Loading />}>
  <SomeSubtree />
</Suspense>

SomeSubtree 在渲染过程中暂时无法完成时,React 可以渲染 fallback。这个“暂时无法完成”通常表现为组件向 React 抛出一个尚未完成的 thenable;使用 lazy 时,这个行为由 React 内部完成。

可以把它表示为状态转换:

stateDiagram-v2
    [*] --> Rendering
    Rendering --> Suspended: 子树等待未完成 thenable
    Suspended --> Rendering: thenable fulfilled
    Rendering --> Failed: 加载或渲染抛出错误
    Failed --> Recovered: Error Boundary 恢复并改变子树
    Rendering --> Revealed: 子树完成渲染
    Revealed --> Suspended: 更新时再次挂起
    Suspended --> Revealed: React 保留或恢复已显示内容

关键点是:Suspense 只观察它后代的挂起状态。

<Suspense fallback={<PageSkeleton />}>
  <MainLayout>
    <SettingsPage />
  </MainLayout>
</Suspense>

如果 SettingsPage 挂起,这个边界可以显示 PageSkeleton。但如果边界本身放在 SettingsPage 内部:

function SettingsPage() {
  const Panel = lazy(() => import("./Panel"));

  return (
    <Suspense fallback={<PanelSkeleton />}>
      <Panel />
    </Suspense>
  );
}

这种写法还有前面提到的 lazy 定义位置问题,而且它只能覆盖 Panel,无法覆盖 SettingsPage 自身加载阶段。更合理的结构是把 lazy 组件放到模块顶层,并在调用方按用户体验选择边界范围。

四、边界位置决定用户看到什么

假设页面由导航、标题和主内容组成:

function App() {
  return (
    <AppLayout>
      <Navigation />
      <Suspense fallback={<FullPageSkeleton />}>
        <ReportsPage />
      </Suspense>
    </AppLayout>
  );
}

ReportsPage 的代码正在下载时,导航仍然可以保留,只有主内容显示占位。这通常比下面的整页边界更连续:

function App() {
  return (
    <Suspense fallback={<FullPageSkeleton />}>
      <AppLayout>
        <Navigation />
        <ReportsPage />
      </AppLayout>
    </Suspense>
  );
}

边界越外层,覆盖的子树越大,fallback 越可能造成整页替换;边界越内层,保留的已显示内容越多,但需要设计更多局部占位。

可以嵌套边界:

function ReportsPage() {
  return (
    <section>
      <PageHeader />

      <Suspense fallback={<SummarySkeleton />}>
        <Summary />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <SalesChart />
      </Suspense>
    </section>
  );
}

它表达了两个独立的加载区域:

  • Summary 加载慢,不必隐藏 PageHeader
  • SalesChart 加载慢,不必隐藏 Summary
  • 两个区域可以分别显示符合最终布局的 skeleton。

这不是单纯的性能优化。边界实际上是 UI 故障和不确定性的隔离范围。

五、Suspense 不等于 Error Boundary

两者处理的状态不同:

状态 典型原因 负责显示什么
挂起 lazy 模块尚未完成,或某个 Suspense 数据源尚未完成 Suspense.fallback
错误 动态导入拒绝、组件渲染抛错 Error Boundary 的错误 UI
成功 组件完成渲染 正常 UI

一个可运行的错误边界可以这样写:

import { Component, type ErrorInfo, type ReactNode } from "react";

type ErrorBoundaryProps = {
  children: ReactNode;
  onError?: (error: Error, info: ErrorInfo) => void;
};

type ErrorBoundaryState = {
  error: Error | null;
};

export class ErrorBoundary extends Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { error: null };

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.onError?.(error, info);
  }

  render() {
    if (this.state.error) {
      return (
        <section role="alert">
          <h2>此区域加载失败</h2>
          <p>{this.state.error.message}</p>
          <button
            type="button"
            onClick={() => this.setState({ error: null })}
          >
            重试
          </button>
        </section>
      );
    }

    return this.props.children;
  }
}

然后组合使用:

import { lazy, Suspense } from "react";
import { ErrorBoundary } from "./ErrorBoundary";

const ReportsPage = lazy(() => import("./ReportsPage"));

export function ReportsRoute() {
  return (
    <ErrorBoundary
      onError={(error, info) => {
        console.error("Reports route failed", { error, info });
      }}
    >
      <Suspense fallback={<ReportsSkeleton />}>
        <ReportsPage />
      </Suspense>
    </ErrorBoundary>
  );
}

这里的顺序很重要:

  • Suspense 处理“还没准备好”;
  • ErrorBoundary 处理“已经失败”;
  • Error Boundary 包住 Suspense,能够接收 lazy 加载失败;
  • 只有设置 error: null,边界才会尝试重新渲染子树。

一个按钮清除错误状态并不保证重试一定成功。由于 lazy 会缓存拒绝结果,重新渲染同一个 lazy 组件可能仍然立即失败。生产环境的重试策略要根据框架和资源加载器决定,例如:

  • 先检测网络是否恢复;
  • 对版本不匹配做一次受控的页面刷新;
  • 由路由框架或模块加载器提供重新获取 chunk 的机制;
  • 避免每次渲染自动重试,防止错误循环和请求风暴。

Error Boundary 也有边界。它通常不能捕获:

  • 事件处理器中的错误;
  • 异步回调中的错误,例如 setTimeout
  • 服务端渲染阶段的所有错误;
  • Boundary 自己抛出的错误。

事件处理器需要自己处理:

function SaveButton() {
  const handleClick = async () => {
    try {
      await saveData();
    } catch (error) {
      console.error("保存失败", error);
    }
  };

  return <button onClick={handleClick}>保存</button>;
}

六、更新时再次挂起:为什么页面会闪回 fallback

首次加载和更新时加载的用户感受不同。

首次进入报表页时,显示骨架屏通常合理:

<Suspense fallback={<ReportsSkeleton />}>
  <ReportsPage />
</Suspense>

但如果用户已经看到了报表,点击筛选器导致新内容对应的代码或数据再次挂起,直接把整个已显示内容替换为 skeleton,界面会产生明显闪烁。

React 提供 startTransition,用于把某些更新标记为非紧急更新。对路由切换、筛选条件切换等场景,可以让 React 尽量保留当前可见内容,直到新内容准备好:

import { startTransition, useState } from "react";

function ReportTabs() {
  const [tab, setTab] = useState<"sales" | "users">("sales");

  function selectTab(nextTab: "sales" | "users") {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <button onClick={() => selectTab("sales")}>销售</button>
      <button onClick={() => selectTab("users")}>用户</button>
      <Suspense fallback={<ChartSkeleton />}>
        {tab === "sales" ? <SalesChart /> : <UsersChart />}
      </Suspense>
    </>
  );
}

这里需要区分两个事实:

  1. startTransition 不会加快网络下载;
  2. 它改变的是更新的优先级和可见内容处理方式。

如果更新是由输入框控制,不能不加区分地把所有输入更新都放进 transition。文本输入需要立即反映用户按键;可以把输入状态保持为紧急更新,再把昂贵的派生视图交给 useDeferredValue 或单独的 transition。

七、一个完整的客户端代码分割示例

下面示例假设使用现代 TypeScript 和支持动态 import() 的打包工具,例如 Vite、Webpack 或对应的框架构建系统。

目录结构:

src/
  main.tsx
  App.tsx
  ErrorBoundary.tsx
  pages/
    HomePage.tsx
    SettingsPage.tsx
    SettingsSkeleton.tsx

SettingsPage.tsx

export default function SettingsPage() {
  return (
    <main>
      <h1>设置</h1>
      <p>这里是按需加载的设置页面。</p>
    </main>
  );
}

SettingsSkeleton.tsx

export function SettingsSkeleton() {
  return (
    <main aria-busy="true" aria-label="设置正在加载">
      <div className="skeleton title" />
      <div className="skeleton line" />
      <div className="skeleton line short" />
    </main>
  );
}

App.tsx

import { lazy, Suspense, useState } from "react";
import { ErrorBoundary } from "./ErrorBoundary";
import { SettingsSkeleton } from "./pages/SettingsSkeleton";

const SettingsPage = lazy(() => import("./pages/SettingsPage"));

function HomePage() {
  return (
    <main>
      <h1>首页</h1>
      <p>首页代码随初始入口一起加载。</p>
    </main>
  );
}

export default function App() {
  const [route, setRoute] = useState<"home" | "settings">("home");

  return (
    <div>
      <nav aria-label="主导航">
        <button type="button" onClick={() => setRoute("home")}>
          首页
        </button>
        <button type="button" onClick={() => setRoute("settings")}>
          设置
        </button>
      </nav>

      <ErrorBoundary
        onError={(error, info) => {
          // 生产环境应发送结构化错误、路由和构建版本信息
          console.error("Route render/load error", { error, info, route });
        }}
      >
        <Suspense fallback={<SettingsSkeleton />}>
          {route === "home" ? <HomePage /> : <SettingsPage />}
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

运行时的步骤是:

  1. 初始入口加载 AppHomePage
  2. 用户点击“设置”;
  3. React 首次尝试渲染 SettingsPage
  4. 打包工具开始获取设置页面 chunk;
  5. chunk 未完成时,Suspense 显示 SettingsSkeleton
  6. chunk 成功后,React 使用模块的 default 导出渲染页面;
  7. chunk 失败时,错误向上到 ErrorBoundary
  8. 错误边界显示失败 UI,而不是继续显示加载骨架。

这个例子中的 Suspense 只覆盖当前内容区,而导航仍然保留。实际项目中,路由库通常会提供更完整的路由级 lazy 加载、预取、错误恢复和滚动恢复能力;手写路由时则必须自行决定这些行为。

八、用户体验不是“fallback 越漂亮越好”

fallback 的价值取决于它和最终布局的结构相似程度。下面的简单文字虽然可运行,但会造成内容尺寸跳变:

<Suspense fallback={<p>Loading...</p>}>
  <SettingsPage />
</Suspense>

如果最终页面有固定标题、表格和按钮,加载期间最好保留接近的空间:

function SettingsSkeleton() {
  return (
    <main aria-busy="true">
      <h1 className="placeholder-heading">设置</h1>
      <div className="placeholder-panel" />
      <div className="placeholder-panel" />
    </main>
  );
}

需要注意几个实际边界:

  • skeleton 只是视觉占位,不代表资源已经加载;
  • aria-busy="true" 可以向辅助技术表达区域仍在更新;
  • 如果 fallback 使用动画,应尊重 prefers-reduced-motion
  • 加载时间较短时,立即显示复杂 spinner 可能比保留旧内容更扰人;
  • 加载时间较长时,应提供明确的失败状态,而不是无限旋转;
  • 骨架结构如果和真实布局差异过大,仍会产生布局偏移。

可以用 CSS 限制动画:

.skeleton {
  background: #e5e7eb;
  border-radius: 4px;
  animation: pulse 1.2s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .skeleton {
    animation: none;
  }
}

代码分割也不是无条件收益。拆分 chunk 会减少初始 JavaScript,但会增加后续请求和加载时机的不确定性。如果一个组件几乎总是在首屏使用,把它拆成独立 chunk 可能只是把成本从初始阶段推迟到用户已经开始操作之后。是否拆分应结合页面访问路径、资源大小、缓存策略和真实监控数据判断,不能仅凭 chunk 数量下结论。

九、预加载、预取和 lazy 的启动时机

lazy(() => import("./SettingsPage")) 通常在组件真正需要渲染时启动动态导入。对于“用户已经点击后才开始下载”的页面,这可能产生可感知等待。

预加载可以在更早的时机启动,但要区分几个概念:

  • 预加载:尽早获取资源,通常带有较强优先级;
  • 预取:在预计未来可能使用时获取,优先级通常更低;
  • 真正渲染:React 仍需在组件树中使用 lazy 组件,才能产生 UI。

具体预加载方式依赖打包工具、框架和路由库。不能假设所有 lazy 对象都有一个标准的 .preload() 方法;这不是 React.lazy 的通用 API。框架可能基于路由链接、鼠标悬停、视口可见性或服务器生成的资源提示实现预取,但应使用该框架明确提供的能力。

一个常见的经验策略是:用户聚焦到“设置”导航项时预取设置 chunk,真正点击时仍由 Suspense 负责显示安全的加载状态。预取失败不能代替点击时的错误处理,因为资源可能在预取后失效,或用户网络状态已经改变。

十、客户端与服务端边界

1. 客户端渲染

在纯客户端应用中,动态导入的 chunk 通常由浏览器在运行时请求。Suspense 负责在 React 树中显示 fallback,错误边界负责处理失败。

这要求构建系统能够:

  • 识别动态 import()
  • 生成可访问的 chunk;
  • 在部署后保持入口文件、chunk 清单和 CDN 路径的一致性。

如果服务器部署后删除了旧 chunk,用户可能仍持有旧入口页面,点击某个路由时请求到 404。这是典型的“代码加载错误”,不是 React 组件逻辑错误。

2. 服务端渲染

现代 React 支持服务端流式渲染中的 Suspense 边界。服务端可以先输出已准备好的内容,并在异步子树完成后继续发送内容;客户端再进行 hydration。实际行为取决于使用的框架、渲染 API、组件类型和资源清单。

服务端 Suspense 的存在不意味着浏览器永远不需要下载 lazy chunk。客户端需要对交互组件进行 hydration,仍然需要相应的 JavaScript。

服务端环境还存在几个边界:

  • 浏览器专用 API 不能在服务端渲染阶段直接使用;
  • 服务端和客户端必须对组件树、边界和条件分支保持可协调;
  • 动态导入的服务端 chunk 清单、客户端 chunk 映射和预加载提示通常由框架负责;
  • 服务端渲染错误与客户端 hydration 错误的报告路径可能不同;
  • Error Boundary 不能被当成服务端所有异常的统一兜底机制。

因此,在 Next.js、Remix、React Router 框架能力或其他 SSR 方案中,应遵循对应框架关于客户端组件、服务端组件、路由 lazy 和错误边界的约定。React API 本身提供的是机制,不会替框架完成资源清单、HTTP 缓存和部署版本协调。

3. 服务端组件不是 lazy 的同义词

服务端组件可以减少发送到浏览器的客户端 JavaScript,但它们解决的是组件执行位置和数据流问题;lazy 解决的是模块按需加载。两者可能同时出现在一个应用中,但不能把“服务端组件”理解为“自动代码分割”,也不能把 Suspense 理解为只服务于客户端。

十一、失败表现和诊断路径

表现一:一直显示 fallback

可能原因包括:

  • 动态导入 Promise 没有完成;
  • chunk 请求被阻止或返回错误;
  • 子树中的其他 Suspense 数据源一直未完成;
  • fallback 实际上被更外层边界覆盖;
  • 组件条件逻辑不断切换,导致重复挂起。

诊断步骤:

  1. 在浏览器 Network 面板检查对应 .js chunk 的状态码和响应内容;
  2. 确认响应不是 HTML 错误页;
  3. 查看 Console 是否有 ChunkLoadError、模块解析错误或 CSP 报错;
  4. 在构建产物中确认该模块确实被生成;
  5. 检查部署系统是否保留了与入口 HTML 对应的 chunk;
  6. 确认 lazy 定义在模块顶层;
  7. 检查 Suspense 子树是否还包含其他会挂起的组件。

表现二:加载失败后点击重试仍然失败

这可能不是按钮逻辑错误,而是 lazy 已缓存拒绝的 Promise,或者服务器上的资源确实仍不可用。重试前应记录:

  • 当前应用构建版本;
  • chunk URL;
  • 用户浏览器和网络状态;
  • HTTP 状态码;
  • 是否发生过部署切换。

如果确认是旧版本页面引用旧 chunk,受控刷新可能恢复,但刷新本身会丢失未保存状态,不能无条件执行。对表单、编辑器和支付流程,应优先提示用户并保留本地草稿,而不是静默刷新。

表现三:组件状态莫名丢失

首先检查:

  • 是否在渲染函数内部创建了 lazy 组件;
  • 是否改变了组件的 key
  • Suspense fallback 出现时,是否实际上卸载了子树;
  • 路由切换是否改变了组件身份;
  • 初次挂起的组件是否尚未完成第一次挂载。

React 对“首次挂起且尚未完成挂载”的树可以直接放弃并在资源完成后重新尝试,因此不能把尚未成功提交的组件状态当作可靠的持久状态。需要跨加载过程保留的数据,应放在更稳定的父级、路由状态、外部 store 或持久化层中。

表现四:开发环境频繁加载或重复日志

开发模式可能启用更严格的检查、额外渲染或不同的缓存行为,不能直接把开发环境日志次数等同于生产请求次数。应同时检查:

  • lazy 工厂是否在模块顶层;
  • 是否在 Strict Mode 下观察到额外开发行为;
  • 是否有组件被反复挂载;
  • 浏览器缓存是否关闭;
  • 打包工具的 HMR 是否介入。

最终应以生产构建和真实浏览器 Network 数据验证资源请求行为。

十二、边界设计的推导原则

可以用一个简单模型理解边界选择。设页面子树为 TT,某个 Suspense 边界覆盖的子树为 SS

SS 中任一后代挂起时,React 需要为 SS 选择 fallback。于是:

  • SS 越大,被 fallback 替换的已显示内容越多;
  • SS 越小,保留的上下文越多,但边界数量和局部状态设计越复杂;
  • 如果 SS 包含导航、标题和主内容,主内容加载会导致导航一起消失;
  • 如果只让图表属于 SS,图表等待不会影响报表标题和筛选器。

因此,边界通常应放在“用户能独立理解、独立等待、独立失败”的 UI 区域外侧,而不是机械地每个组件包一层。这个判断同时考虑三个变量:

  1. 视觉连续性:哪些内容应该保持可见;
  2. 失败隔离:哪些内容失败时仍然可以使用;
  3. 状态归属:哪些状态应在 fallback 出现后继续保留。

例如,仪表盘中的“账户信息”“趋势图”“最近活动”可能适合三个局部边界;但一个只有单一内容的简单页面,整页边界反而更清晰。

十三、规范保证、实现差异和工程取舍

可以明确区分三层结论:

React API 层面的保证

  • lazy 接收返回模块 Promise 的加载函数;
  • 模块结果需要提供 default 组件;
  • Suspense 可以显示其后代挂起时的 fallback;
  • 加载或渲染错误需要由错误处理机制接管,而不是由 fallback 自动处理;
  • lazy 组件应保持稳定身份,通常定义在模块顶层。

打包工具和框架的常见实现

  • 动态 import() 通常被拆成独立 chunk;
  • 路由系统可能根据路由实现代码分割和预取;
  • SSR 框架可能生成模块清单、流式输出和资源预加载提示;
  • 部署平台可能通过文件名 hash 和 CDN 缓存控制 chunk 生命周期。

这些不是仅凭 React 就能保证的行为,配置错误时仍可能得到 404、缓存错配或 hydration 问题。

工程经验建议

  • 按用户可感知的 UI 区域设计边界;
  • 首次加载使用与最终布局接近的 skeleton;
  • 更新阶段考虑 transition,避免已显示内容不必要地闪回;
  • 为动态导入失败提供可理解的错误 UI;
  • 为失败事件记录 chunk URL、版本和路由;
  • 不要在组件函数中创建 lazy 类型;
  • 不要因为“拆 chunk”本身就断言性能一定变好;
  • 通过生产构建、网络面板和真实监控验证收益。

React.lazy 负责把“模块还没到”表达给 React;Suspense 负责把这种暂时不可用映射为一个 UI 边界;Error Boundary 负责把不可恢复或已失败的路径变成可诊断、可操作的界面。只有三者再结合稳定的组件身份、合理的边界粒度、过渡策略和部署资源管理,代码分割才会从一个语法改动变成完整的用户体验设计。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。