第 17 章

代码分割、懒加载与 Suspense 边界

第17章:代码分割、懒加载与 Suspense 边界

你的应用加载得再快,如果用户要先下载 3MB 的 JavaScript 才能看到内容,他们已经走了。

本章核心问题:代码分割的 ROI 如何量化?Suspense 边界应该放在哪里? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

为什么初始包体积决定用户的第一印象

你的应用加载得再快,如果用户要先下载 3MB 的 JavaScript 才能看到任何内容,他们已经走了。Google 的数据显示,页面加载时间每增加 1 秒,移动端转化率下降 20%。这不是性能优化的问题,这是产品存活的问题。

传统的 SPA 构建方式把所有路由、所有组件的代码打包进一个 bundle。用户访问首页,却被迫下载管理后台的代码、图表库的代码、PDF 预览器的代码——即使他们这辈子都不会用到这些功能。代码分割就是解决这个问题的答案:只把用户当前需要的代码发给他们

核心指标:理解 TTI 和 LCP

在讨论代码分割之前,先明确我们在优化什么:

一个真实案例:某电商应用优化前后对比:

优化前:
  初始 bundle:1.2MB (gzip 压缩后 380KB)
  TTI:6.8s (4G 网络)
  LCP:4.2s

优化后(路由级代码分割):
  初始 bundle:320KB (gzip 压缩后 98KB)
  TTI:2.1s (4G 网络)  ← 减少 69%
  LCP:2.8s            ← 减少 33%

Suspense 边界:放在哪里

React.lazy 必须配合 <Suspense> 使用。当懒加载的组件正在下载时,React 会向上寻找最近的 <Suspense> 边界,渲染其 fallback

边界放太高:用户体验差

// ❌ Suspense 在顶层:整个应用在加载任何路由时都显示 spinner
function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

问题:导航到 /dashboard 时,整个页面内容(包括导航栏、侧边栏)都消失,替换为 loading 状态。用户感知到的是页面"闪烁",体验极差。

边界放太低:细碎的 loading 状态

// ❌ 每个组件都有自己的 Suspense:用户看到多个 spinner 依次出现
function Dashboard() {
  return (
    <div>
      <Suspense fallback={<Spinner />}><Chart /></Suspense>
      <Suspense fallback={<Spinner />}><Table /></Suspense>
      <Suspense fallback={<Spinner />}><Sidebar /></Suspense>
    </div>
  );
}

问题:多个 spinner 依次出现消失("瀑布式"加载),视觉上像是 bug,不像是有意的设计。

合理的边界位置

// ✅ 路由级别:保留导航,替换内容区
function App() {
  return (
    <div className="app-shell">
      <Nav />  {/* 始终可见 */}
      <Suspense fallback={<PageSkeleton />}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </div>
  );
}

// ✅ 功能区级别:只替换真正延迟加载的功能模块
function Dashboard() {
  return (
    <div className="dashboard">
      <DashboardHeader />  {/* 立即渲染的部分 */}
      <Suspense fallback={<ChartSkeleton />}>
        <LazyChart />       {/* 只有这部分显示 loading */}
      </Suspense>
    </div>
  );
}

原则:Suspense 边界应该匹配用户感知的"内容单元"边界,而不是技术实现的边界。导航栏、面包屑、页面框架应该始终可见;核心内容区域是合适的边界位置。

Suspense + Error Boundary:双重保护

网络请求可能失败。React.lazy 加载失败(网络断开、资源 404)会导致 Promise reject,这个错误会传播到最近的 Error Boundary。

import { Component, ReactNode } from 'react';

class ChunkErrorBoundary extends Component<
  { children: ReactNode; fallback?: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    // 上报到监控系统
    reportError(error);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? (
        <div className="error-state">
          <p>页面加载失败</p>
          <button onClick={() => this.setState({ hasError: false })}>
            重试
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// 使用:Suspense 在内,Error Boundary 在外
function SafeLazyRoute({ component: LazyComponent }: { component: React.ComponentType }) {
  return (
    <ChunkErrorBoundary fallback={<RouteErrorPage />}>
      <Suspense fallback={<PageSkeleton />}>
        <LazyComponent />
      </Suspense>
    </ChunkErrorBoundary>
  );
}

顺序很重要:Error Boundary 包裹 Suspense,这样加载失败时错误能被捕获;Suspense 包裹实际组件,处理正常的加载中状态。

预取策略:悬停时预加载

代码分割的副作用是"首次导航有延迟"。用户点击链接,浏览器才开始下载 chunk,体验上有明显等待。

解决方案:在用户"意图导航"的时刻预取——最佳时机是鼠标悬停在链接上。

// 手动预取:在用户悬停时触发 import()
function NavLink({ to, label, lazyImport }: {
  to: string;
  label: string;
  lazyImport: () => Promise<unknown>;
}) {
  const handleMouseEnter = () => {
    // import() 会触发浏览器开始下载,但不会执行组件
    lazyImport();
  };

  return (
    <Link to={to} onMouseEnter={handleMouseEnter}>
      {label}
    </Link>
  );
}

// 使用
<NavLink
  to="/dashboard"
  label="仪表盘"
  lazyImport={() => import('./Dashboard')}
/>

当用户悬停在链接上,import() 被调用,浏览器开始下载 Dashboard.js。研究显示,从悬停到点击平均有 150-400ms 的间隔——足够下载一个中等大小的 chunk(即使在 4G 网络上)。点击时,chunk 已经在缓存中,导航几乎是瞬间完成的。

对于 React Router 应用,可以使用 react-router-domprefetch 属性(v7+),或者封装一个通用的预取组件:

// 通用预取钩子
function usePrefetch(importFn: () => Promise<unknown>) {
  const prefetch = useCallback(() => {
    importFn().catch(() => {
      // 预取失败是安全的,不影响后续正常加载
    });
  }, [importFn]);
  return prefetch;
}

路由级 vs 组件级分割

路由级分割

最高 ROI 的分割策略。不同路由的代码几乎不会同时需要:

// React Router + React.lazy
const Home = React.lazy(() => import('./pages/Home'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
const AdminPanel = React.lazy(() => import('./pages/AdminPanel')); // 普通用户永远不用

const router = createBrowserRouter([
  { path: '/', element: <Home /> },
  { path: '/dashboard', element: <Dashboard /> },
  { path: '/settings', element: <Settings /> },
  { path: '/admin', element: <AdminPanel /> },
]);

组件级分割

针对体积大但不总需要的组件:

// 富文本编辑器:打包后通常 200-500KB,大多数用户不需要立即使用
const RichEditor = React.lazy(() => import('./RichEditor'));

function PostEditor({ isEditing }: { isEditing: boolean }) {
  return (
    <div>
      <PostHeader />
      {isEditing && (
        <Suspense fallback={<EditorSkeleton />}>
          <RichEditor />
        </Suspense>
      )}
    </div>
  );
}

// PDF 预览器、图表库、代码编辑器(Monaco)、地图组件——这些都是组件级分割的好候选。

Vite 的 manualChunks 配置

对于第三方库,可以在 vite.config.ts 中手动控制 chunk 分组:

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 把常用但不变的库分组,利用浏览器缓存
          'vendor-react': ['react', 'react-dom', 'react-router-dom'],
          'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-select'],
          'vendor-charts': ['recharts', 'd3'],
        },
      },
    },
  },
});

分包的目标:让不常变化的代码(第三方库)和常变化的代码(业务逻辑)分离,让用户能从缓存中复用已下载的 vendor chunk。


Level 2 · 它是怎么运行的(3-5年经验)

React.lazy + dynamic import():实现机制

React.lazy 是 React 官方的代码分割 API,它与 JavaScript 的 import() 动态导入语法配合工作。

// ❌ 静态 import:代码打包时就合并进主 bundle
import HeavyDashboard from './HeavyDashboard';

// ✅ 动态 import:代码分割,按需加载
const HeavyDashboard = React.lazy(() => import('./HeavyDashboard'));

React.lazy 接受一个函数,这个函数在组件首次被渲染时(不是在模块加载时)调用,返回一个解析为 { default: Component } 的 Promise。

在 Vite 中,这个 import() 调用会自动生成一个独立的 chunk 文件:

// 打包输出
dist/
  index.js          # 主 bundle(路由、应用框架)
  HeavyDashboard-Bx9aK2.js   # 按需加载的 chunk
  ChartLibrary-Cz7mQ1.js     # 另一个 chunk

可以通过注释控制 chunk 名称(Vite 和 Webpack 都支持):

const HeavyDashboard = React.lazy(
  () => import(/* webpackChunkName: "dashboard" */ './HeavyDashboard')
);
// 或者在 Vite 中:import('/* @vite-ignore */ ...')
// Vite 推荐在 vite.config 中配置 manualChunks

Level 3 · 规范怎么定义的(资深)

Fallback UI 设计原则

好的 fallback 不是一个旋转的 spinner。它应该:

  1. 占位,防止布局抖动:fallback 的尺寸应尽量接近实际内容的尺寸
  2. 传递内容结构:骨架屏(Skeleton)比 spinner 更能帮助用户预期内容
  3. 符合品牌风格:loading 状态也是产品体验的一部分
// ❌ 简单 spinner:无法防止布局抖动,没有内容预期
<Suspense fallback={<div className="spinner" />}>

// ✅ 骨架屏:保持布局稳定,传递内容结构
function ProductCardSkeleton() {
  return (
    <div className="product-card skeleton">
      <div className="skeleton-image" style={{ height: 200 }} />
      <div className="skeleton-text" style={{ width: '60%' }} />
      <div className="skeleton-text" style={{ width: '40%' }} />
    </div>
  );
}

<Suspense fallback={<ProductCardSkeleton />}>
  <LazyProductCard productId={id} />
</Suspense>

骨架屏的 CSS 使用 animation: shimmer 1.5s infinite 的渐变动画,让用户感知到"正在加载"而不是"卡住了"。

React 19 的 Suspense 改进

React 19 对 Suspense 的核心改进是并发渲染下的行为优化

在 React 18 中,如果一个 Suspense 边界内的某个组件 suspend(如数据请求),整个边界都会显示 fallback,即使其他子组件已经准备好了。React 19 改进了这一行为:在并发模式下,React 会尝试尽量多地渲染"已就绪"的内容,只把真正等待中的部分替换为 fallback 占位符。

此外,React 19 的 use() API 让 Suspense 与数据请求的结合更自然:

// React 19: use() API 让 Suspense 驱动数据加载
import { use, Suspense } from 'react';

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  // use() 在 Promise pending 时 suspend 组件,resolved 后继续渲染
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

function App() {
  const userPromise = fetchUser(userId); // 在组件外或用 useTransition 触发
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

Level 4 · 边界与陷阱(所有人)

生产环境常见问题

在实际项目中,本章涵盖的概念最常见的问题包括:

  1. 忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。

  2. 过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。

  3. 文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。

本章评分
4.9  / 5  (13 评分)

💬 留言讨论