FORMA

构建与分包

通过代码分割与构建配置减小首屏 JavaScript 体积。运行时优化见 runtime

核心概念

「构建优化」和「运行时优化」解决的是不同阶段的问题:构建优化关心用户第一次打开页面要下载多少 JS/CSS、多久能看到内容;运行时优化关心页面已经加载完成后,交互是否流畅。两者常被混为一谈,但优化手段完全不同——构建侧的核心武器是代码分割:把不是「首屏必须」的代码拆成独立文件,延迟到真正需要时才加载。

React.lazy 与 Suspense

tsx
import { lazy, Suspense } from "react";

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

function Page() {
  return (
    <Suspense fallback={<p>图表加载中…</p>}>
      <Chart />
    </Suspense>
  );
}

import() 由打包工具(Vite、Webpack)拆成独立 chunk,访问路由或条件满足时再加载。

路由级分割

React Router 配置中对页面组件使用 lazy,是 SPA 最常见的分割方式:

tsx
import { createBrowserRouter } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/dashboard",
    lazy: async () => {
      const { DashboardPage } = await import("./pages/DashboardPage");
      return { Component: DashboardPage };
    },
  },
]);

每个路由对应的页面代码只在用户访问该路由时才下载,首屏只需加载入口路由的代码。

组件级分割:按交互延迟加载

tsx
// 富文本编辑器体积通常很大,仅在用户点击"编辑"后才需要
const RichTextEditor = lazy(() => import("./RichTextEditor"));

function ArticleDetail() {
  const [editing, setEditing] = useState(false);
  return (
    <div>
      <button onClick={() => setEditing(true)}>编辑</button>
      {editing && (
        <Suspense fallback={<Spinner />}>
          <RichTextEditor />
        </Suspense>
      )}
    </div>
  );
}

对话框、图表、富文本编辑器、地图等「体积大但不总是需要」的组件,都适合用这种「按需触发」的懒加载模式,而不仅限于路由级别。

Tree Shaking

使用 ESM 导入,生产构建开启压缩;避免 import entire library

ts
// 较好:按需
import { debounce } from "lodash-es";

// 注意:部分库需查文档是否支持 tree-shaking
ts
// 反例:整包导入,即使只用一个函数也会打入全部代码(若库不支持良好的 tree-shaking)
import _ from "lodash";
_.debounce(fn, 300);

// 更好:明确从 ESM 版本按需导入
import debounce from "lodash-es/debounce";

判断一个库是否支持良好的 tree-shaking,可查看其 package.json 是否有 "sideEffects": false 声明,以及是否提供 ESM 格式的产物(module 字段)。

Vite 生产构建

ts
// vite.config.ts 片段
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ["react", "react-dom"],
        },
      },
    },
  },
});

manualChunks 将 React 等稳定依赖单独打包,利于长期缓存(具体策略按项目体积分析调整)。

分析构建产物

bash
pnpm add -D rollup-plugin-visualizer
ts
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  plugins: [react(), visualizer({ open: true, gzipSize: true })],
});

构建后自动打开一份可视化的 bundle 分析报告,用矩形面积直观展示各模块占比,快速定位「哪个依赖占用了大量体积」。常见发现包括:误打入整个 UI 库、日期库(如 moment 完整语言包)、重复打包的多份相同依赖。

图片与静态资源优化

tsx
// Vite 支持在导入时进行部分转换/内联小文件
import logo from "./logo.svg?url";
import iconRaw from "./icon.svg?raw";

大图片建议转 WebP/AVIF 并结合 CDN 处理裁剪与压缩,而不是把原图直接打进构建产物;小于阈值(默认 4KB)的资源 Vite 会自动 base64 内联,减少请求数但增加 JS/CSS 体积,需要权衡。

最佳实践

  • 路由级懒加载是「投入产出比最高」的第一步优化,几乎所有 SPA 都该做。
  • 大体积、非首屏必需的组件(富文本、图表、地图)按交互时机懒加载,不要一次性打进首屏 bundle。
  • 定期跑一次 bundle 分析(可作为 CI 步骤,体积超过阈值时告警),而不是等用户反馈"加载慢"才排查。
  • manualChunks 只对变化频率低的依赖(React、UI 库)有意义,业务代码频繁改动,拆多了反而增加请求数。
  • 优先选择原生支持 ESM/tree-shaking 的库(date-fns 优于 momentlodash-es 优于 lodash)。

常见坑

现象常见原因处理
首屏 JS 体积异常大未做路由级分割,所有页面代码打进一个 bundle对页面组件使用 lazy + Suspense
bundle 分析发现某个库占比异常高整包导入了只用到一小部分功能的库改为按需导入,或换更轻量的替代库
manualChunks 配置后加载变慢拆分粒度过细,导致请求数暴增收敛拆分策略,只拆真正稳定且体积大的依赖
图片资源体积过大拖慢首屏直接打包原始大图,未压缩/转格式使用 WebP/AVIF、CDN 处理、按需加载
Suspense fallback 频繁闪烁懒加载的 chunk 体积小但网络延迟高,加载时间短却仍触发 fallback评估是否需要懒加载该组件,或添加最短展示时长

延伸阅读

参考文献

以下链接在编写时均可正常访问:

资料说明
React:lazy懒加载
React:Suspense边界
Vite:构建生产版本生产构建
rollup-plugin-visualizer构建体积分析
web.dev:Code splitting代码分割实践

Series

performance

1 / 2