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

React Bundle 分析:Tree Shaking、分包、预加载和性能预算

React 应用的性能问题,常常不是“某个组件渲染太慢”,而是浏览器在首次交互前下载、解析、编译和执行了过多 JavaScript。要判断这类问题,必须先区分几个层次:

  • Bundle:打包器根据模块依赖图生成的浏览器资源。
  • Tree Shaking:在构建阶段删除不会产生运行时影响的模块代码。
  • 分包(Code Splitting):把一个大的 Bundle 拆成多个可独立加载的资源。
  • 预加载(Preload):提高某个已知资源的获取优先级,或者提前建立相关加载关系。
  • 性能预算(Performance Budget):为资源体积、请求数量、解析执行时间或用户体验指标设置可验证的上限。

这些概念相互关联,但解决的问题不同。Tree Shaking 主要减少“最终需要保留的代码”;分包改变“代码何时、按什么边界加载”;预加载改变“资源的加载时机和优先级”;性能预算则规定“什么结果可以接受”。

React 本身不负责把 TypeScript 和 JSX 打包成浏览器资源。React 19 提供组件模型、Suspense、lazy 等运行时能力,实际的模块解析、Tree Shaking、代码分割和资源加载通常由 Vite、Webpack、Rspack、Rollup,以及 Next.js、React Router 等框架或工具链完成。


一、从源代码到浏览器执行:Bundle 到底是什么

假设应用入口是:

// src/main.tsx
import { createRoot } from 'react-dom/client';
import { App } from './App';

createRoot(document.getElementById('root')!).render(<App />);

App.tsx 又依赖:

// src/App.tsx
import { Dashboard } from './Dashboard';
import { formatCurrency } from './formatCurrency';

export function App() {
  return (
    <>
      <Dashboard />
      <p>{formatCurrency(1234)}</p>
    </>
  );
}

打包器会从入口文件开始,构造一个有向模块图:

main.tsx
 └── App.tsx
      ├── Dashboard.tsx
      └── formatCurrency.ts

如果所有模块最终都输出到一个 JavaScript 文件,结果可以近似表示为:

main.js = main.tsx + App.tsx + Dashboard.tsx + formatCurrency.ts + React 依赖...

实际构建还会包含以下处理:

  1. 解析 importexport
  2. 转换 TypeScript、JSX 和浏览器不支持的语法;
  3. 处理 npm 包的 package.json、条件导出和模块格式;
  4. 删除可证明无效的代码;
  5. 根据静态入口和动态入口生成一个或多个资源;
  6. 压缩、生成 source map,并计算资源哈希。

因此,Bundle 不是某个 React API 的概念,而是“模块图经过构建处理后得到的部署资源”。

1. ESM、CJS 和模块边界

现代 Tree Shaking 依赖 ES Modules 的静态结构:

import { add } from './math.js';

console.log(add(1, 2));

这里的导入关系在构建时可以确定。与之相对,CommonJS 经常使用运行时计算:

const name = getModuleName();
const module = require(name);

在构建阶段,打包器通常无法准确知道 name 的值,也就无法安全地判断应该保留哪些导出。

这不是说 CommonJS 一定不能被打包,而是说它通常需要:

  • 保守地保留更多代码;
  • 使用兼容转换;
  • 或依赖特定插件进行有限的静态分析。

因此,一个 npm 包是否提供 ESM 入口,会直接影响 Tree Shaking 的效果。package.json 中的 exportsmodulemainsideEffects 等字段都可能参与这个决策,但具体优先级由打包器和配置决定,不能只看某一个字段就推断结果。


二、Tree Shaking:删除什么,为什么能够删除

1. 形式化理解

设一个模块图为:

G=(V,E)G = (V, E)

其中:

  • VV 是模块或模块内部声明;
  • EE 是导入、调用或副作用依赖关系;
  • RR 是从入口模块可达的运行时根集合。

如果某个声明不在从 RR 出发的有效依赖闭包中,并且删除它不会改变程序可观察行为,那么它可以被删除。

“可观察行为”包括但不限于:

  • 返回值;
  • DOM 修改;
  • 网络请求;
  • 注册事件;
  • 修改全局变量;
  • 写入存储;
  • 抛出异常;
  • 修改导入模块的状态。

因此,Tree Shaking 不是简单的“没有调用就删除”。它必须同时判断:

  1. 这个导出是否被使用;
  2. 模块初始化是否有副作用;
  3. 删除代码是否会改变程序行为;
  4. 当前模块格式和配置是否允许这种分析。

2. 一个可以被 Tree Shake 的例子

// src/math.ts
export function add(a: number, b: number) {
  return a + b;
}

export function multiply(a: number, b: number) {
  return a * b;
}
// src/main.ts
import { add } from './math';

console.log(add(1, 2));

如果构建器能够静态分析这两个 ESM 文件,那么最终代码通常只需要保留 addmultiply 没有被使用,也没有模块初始化副作用,因此具备被删除的条件。

这并不意味着构建产物一定会出现“完全干净”的函数级删除结果。压缩器、Source Map、调试配置、库的模块格式和构建器策略都会影响最终文件。正确的验证方式是查看生产构建产物,而不是凭源代码导入形式猜测。

3. 失败反例:模块初始化本身有副作用

// register.ts
window.addEventListener('resize', () => {
  console.log(window.innerWidth);
});

export function registerFeature() {
  // ...
}
// main.ts
import './register';

这里虽然没有使用任何导出,但导入 ./register 的目的就是执行顶层注册逻辑。删除整个模块会导致窗口事件监听器消失,因此不能将其视为无效代码。

另一个例子是:

// polyfill.ts
import './install-global-polyfill';

这类“只为执行初始化而导入”的模块,不能按“没有导出使用”处理。

4. sideEffects 不是“请删除所有未使用代码”

npm 包可能在 package.json 中声明:

{
  "sideEffects": false
}

这表示包作者认为模块的顶层执行没有必须保留的副作用。打包器可以更积极地删除未使用模块。

也可以精确列出有副作用的文件:

{
  "sideEffects": [
    "*.css",
    "./src/register-polyfill.ts"
  ]
}

这里的风险很大:如果实际存在副作用,却错误声明了 sideEffects: false,生产构建可能删除必要代码,表现为:

  • CSS 没有生效;
  • 全局 polyfill 没有安装;
  • 自定义元素没有注册;
  • 埋点或插件初始化缺失;
  • 应用只在生产构建中失败。

因此,sideEffects 是包作者对模块语义的声明,不是性能开关。修改它后必须执行生产构建并验证初始化路径。

5. TypeScript 类型导入与运行时导入

类型只存在于编译阶段,应该使用类型导入明确表达这一点:

import type { User } from './types';
import { createUser } from './user';

如果把只用于类型的内容写成普通导入,TypeScript 和打包器可能需要通过编译配置判断是否可以删除。现代 TypeScript 项目可以考虑:

{
  "compilerOptions": {
    "verbatimModuleSyntax": true
  }
}

启用后,普通 importimport type 的语义边界更明确:类型导入不会被当作运行时依赖输出,运行时导入则不会被编译器静默改写成类型导入。

这并不能替代打包器的 Tree Shaking。它解决的是“类型是否进入运行时模块图”,而不是“运行时模块中的哪个函数可以删除”。

6. 常见误解:命名导入不一定等于小体积

以下导入形式看起来只取了一个函数:

import { debounce } from 'lodash';

但如果 lodash 使用 CommonJS 分发,打包器可能无法像处理纯 ESM 那样精确删除其余代码。可以优先使用提供 ESM 的版本,例如:

import debounce from 'lodash-es/debounce';

或者选择有明确 ESM 导出的库。最终仍然应以构建产物验证,因为:

  • 包的 exports 条件可能因浏览器、Node、开发或生产环境不同;
  • 某些库的导出文件内部仍会重新引入较大模块;
  • 压缩器可能合并或重排代码;
  • 依赖的依赖也会进入结果。

三、分包:把“必须加载的代码”和“以后可能需要的代码”分开

Tree Shaking 解决的是代码冗余;分包解决的是加载时机。

假设一个后台应用包含:

  • 首页:图表;
  • 设置页:富文本编辑器;
  • 报表页:PDF 导出库;
  • 登录页:不需要以上功能。

如果所有代码都放入一个入口 Bundle,用户访问登录页时也可能下载编辑器、图表和 PDF 库。分包的目标是将模块图切出多个加载边界:

入口资源
 ├── 登录页依赖
 ├── 首页依赖
 ├── 设置页依赖
 └── 报表页依赖

1. 静态导入不会形成异步边界

import { Editor } from './Editor';

这是静态依赖。只要当前入口能到达该语句,Editor 就属于该入口的同步依赖闭包。打包器可能把它放进主 Bundle,也可能放入共享 chunk,但浏览器通常需要在进入该入口时就获取它。

2. 动态导入形成异步边界

const module = await import('./Editor');

import() 返回 Promise,并明确告诉打包器:这个模块可以作为异步资源加载。

React 中常见的组件写法是:

import { lazy, Suspense } from 'react';

const Editor = lazy(() => import('./Editor'));

export function SettingsPage() {
  return (
    <Suspense fallback={<p>编辑器加载中……</p>}>
      <Editor />
    </Suspense>
  );
}

执行路径如下:

  1. 首次渲染 SettingsPage
  2. lazy 调用加载函数;
  3. 浏览器请求由打包器生成的编辑器 chunk;
  4. Promise 未完成时,组件树显示 fallback
  5. chunk 执行并得到默认导出;
  6. Suspense 边界重新渲染编辑器。

被动态导入的模块需要默认导出:

// Editor.tsx
export default function Editor() {
  return <textarea aria-label="编辑器" />;
}

如果模块只有命名导出,可以转换为默认导出:

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

3. Suspense 不是错误边界

网络失败、chunk 404、版本不一致等问题,不会因为存在 Suspense 就自动得到友好处理。Suspense 处理的是“等待中的 Promise”,而错误边界处理的是渲染错误或加载失败。

import { Component, lazy, Suspense } from 'react';

const Reports = lazy(() => import('./Reports'));

class LoadErrorBoundary extends Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

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

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <p>报表模块加载失败。</p>
          <button onClick={() => location.reload()}>刷新页面</button>
        </div>
      );
    }

    return this.props.children;
  }
}

export function ReportsRoute() {
  return (
    <LoadErrorBoundary>
      <Suspense fallback={<p>报表加载中……</p>}>
        <Reports />
      </Suspense>
    </LoadErrorBoundary>
  );
}

生产环境中常见的失败原因包括:

  • 新版本发布后旧页面引用了已删除的 chunk;
  • CDN 缓存了 HTML,但没有缓存对应的旧 JS;
  • 用户网络中断;
  • Service Worker 缓存状态不一致;
  • chunk 的公共路径配置错误。

“自动刷新”可以作为恢复策略,但不能无限刷新,否则会形成刷新循环。更稳妥的做法是记录一次恢复标记,超过次数后显示明确错误,并检查发布策略和缓存策略。


四、路由分包与组件分包的边界

路由通常是天然的异步边界:

const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
function AppRoutes({ path }: { path: string }) {
  const Page =
    path === '/settings'
      ? SettingsPage
      : path === '/reports'
        ? ReportsPage
        : DashboardPage;

  return (
    <Suspense fallback={<p>页面加载中……</p>}>
      <Page />
    </Suspense>
  );
}

这种分法的因果关系是:用户一次只访问一个主要路由,因此其他页面代码没有必要进入当前路径的关键加载链。

但不能把每个小组件都拆成独立 chunk。例如:

const Button = lazy(() => import('./Button'));
const Icon = lazy(() => import('./Icon'));
const Label = lazy(() => import('./Label'));

如果这些组件在首屏同时出现,拆分后会增加:

  • 请求和响应管理;
  • chunk 元数据;
  • Promise 调度;
  • Suspense 边界复杂度;
  • 资源解析和执行切换。

HTTP/2 和 HTTP/3 降低了多请求的连接成本,但没有消除每个资源的响应头、调度、解析和缓存管理成本。分包的目标不是“chunk 越多越先进”,而是让异步边界与用户行为边界一致。

共享依赖与重复打包

假设两个异步页面都使用图表库:

dashboard.js ── charting-library
reports.js    ── charting-library

打包器可能生成:

charting-library-shared.js
dashboard.js
reports.js

也可能因为版本、配置或依赖关系无法共享,导致库被重复打入两个 chunk。重复的代价包括:

  • 下载重复;
  • 解析执行重复;
  • 缓存命中率下降;
  • 单个路由 chunk 变小但总传输量变大。

因此分析 Bundle 时需要同时观察:

  • 首次加载传输了多少;
  • 单次路由导航新增多少;
  • 所有异步路径总共会下载多少;
  • 共享 chunk 是否真的被多个路径复用。

五、预加载:提前请求,不等于提前执行

1. preload 的语义

原生 HTML 中:

<link
  rel="preload"
  href="/assets/hero-image.webp"
  as="image"
  type="image/webp"
/>

preload 表示该资源很快会被当前页面使用,希望浏览器提高它的获取优先级。as 很重要,因为浏览器需要据此确定资源类型、优先级和缓存匹配方式。

对于脚本,常见形式是:

<link
  rel="preload"
  href="/assets/dashboard-C7x9.js"
  as="script"
/>

但对由 ESM import() 加载的 chunk,不能机械地把所有 JavaScript 都写成 preload。还需要确保:

  • URL 与实际动态导入生成的 URL 一致;
  • crossorigin 设置与实际请求一致;
  • 资源不会因缓存键不同而被重复请求;
  • 该 chunk 确实是近期高概率需要的资源。

现代构建器也可能生成模块预加载关系:

<link rel="modulepreload" href="/assets/dashboard-C7x9.js">

modulepreload 针对 ES module 依赖图,浏览器可以提前获取模块及其相关依赖。它与普通 preload 不是同一种资源提示,具体效果还受浏览器实现和构建器输出影响。

2. 预加载、预取和预连接的区别

机制 主要目的 适合场景
preload 当前页面很快要用,提高获取优先级 首屏关键字体、图片、已知关键脚本
modulepreload 提前获取模块及依赖 已确定即将执行的 ESM 模块
prefetch 为未来导航准备,优先级较低 用户很可能下一步访问的路由
preconnect 提前建立 DNS/TCP/TLS 连接 确定会访问的第三方源
dns-prefetch 仅提前 DNS 查询 连接成本较低或作为兼容补充

错误示例是:

<link rel="preload" href="/assets/editor.js" as="script">
<link rel="preload" href="/assets/report.js" as="script">
<link rel="preload" href="/assets/settings.js" as="script">

如果用户大概率只访问其中一个页面,那么这些 preload 会与首屏资源竞争带宽和浏览器调度槽位,可能反而推迟 LCP。预加载必须建立在“资源很快会被使用”的判断上,而不是“资源越早下载越好”。

3. React 19 中的资源提示

React DOM 在 React 19 体系中提供了资源提示相关 API,例如 preloadpreloadModulepreconnectprefetchDNS 等。它们的价值在于让 React 渲染流程或服务端输出能够表达资源依赖,尤其适合框架统一管理资源清单的场景。

但需要区分:

  • React 负责表达资源提示;
  • 打包器负责生成 chunk;
  • 框架负责把模块标识映射到带哈希的最终文件;
  • 浏览器负责解释提示并执行调度。

不要在源码中硬编码带哈希的 chunk 名称:

// 不推荐:构建后的文件名会变化
preload('/assets/dashboard-C7x9.js', { as: 'script' });

生产环境通常应由框架根据构建 manifest 生成正确 URL。若应用使用 Next.js、React Router 的框架集成或其他 SSR 工具,优先使用框架提供的链接预取和资源清单能力,而不是手工拼接产物路径。


六、客户端、服务端与 React Server Components 的边界

1. 客户端渲染

纯客户端应用通常经历:

HTML 壳
  → 下载入口 JS
  → 执行 React
  → 请求数据
  → 渲染页面

此时首屏 JS 既包含 React 运行时,也包含首屏组件和数据请求逻辑。路由分包可以减少首屏下载,但不能消除入口执行成本。

2. SSR 和流式渲染

服务端渲染的页面 HTML 可以更早到达浏览器,但交互仍然需要客户端 JavaScript 完成 hydration。简化流程如下:

服务端:
React 树
  → 生成 HTML
  → 通过构建 manifest 找到客户端 chunk
  → 输出资源提示和 HTML

客户端:
HTML 到达
  → 加载入口及相关 chunk
  → hydration
  → 事件处理器可交互

因此,SSR 不等于“不需要关注 Bundle”。它通常改善 HTML 到达时间,但过大的客户端 Bundle 仍会影响 hydration、INP 和主线程占用。

流式 SSR 加上 Suspense 时,服务端可以先发送已经准备好的部分 UI,再发送延迟内容。客户端仍需要拿到对应的客户端模块,除非该组件完全不参与客户端交互。

3. Server Components

React Server Components 可以让某些组件只在服务端执行,其代码不需要作为客户端交互 Bundle 发送。服务端组件与客户端组件的边界由框架定义;例如某些框架使用 "use client" 标记客户端边界。

// Server component:可以在服务端获取数据
export async function ProductPage() {
  const product = await getProduct();

  return (
    <>
      <h1>{product.name}</h1>
      <AddToCartButton productId={product.id} />
    </>
  );
}
'use client';

// Client component:需要浏览器事件和状态
import { useState } from 'react';

export function AddToCartButton({ productId }: { productId: string }) {
  const [pending, setPending] = useState(false);

  return (
    <button
      disabled={pending}
      onClick={() => {
        setPending(true);
        void addToCart(productId).finally(() => setPending(false));
      }}
    >
      加入购物车
    </button>
  );
}

这里的关键不是“把组件文件放在哪里”,而是客户端边界会把其依赖闭包纳入客户端资源。若一个客户端组件顶层导入了大型编辑器,那么编辑器可能随该边界进入客户端 Bundle。

Server Components 的具体协议、路由约定和构建方式属于框架能力,不应把某个框架的约定当成 React 核心 API。分析 Bundle 时,需要分别检查:

  • 服务端构建产物;
  • 客户端构建产物;
  • 客户端边界下的依赖闭包;
  • hydration 所需的交互代码;
  • 动态导入是否仍然形成有效异步边界。

七、一个完整的 React 分包示例

目录结构:

src/
├── main.tsx
├── App.tsx
├── pages/
│   ├── HomePage.tsx
│   ├── SettingsPage.tsx
│   └── ReportsPage.tsx
└── components/
    └── Editor.tsx

入口:

// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';

const rootElement = document.getElementById('root');

if (!rootElement) {
  throw new Error('缺少 #root 容器');
}

createRoot(rootElement).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

路由分包:

// src/App.tsx
import { lazy, Suspense, useState } from 'react';

const HomePage = lazy(() => import('./pages/HomePage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));

type Route = 'home' | 'settings' | 'reports';

export function App() {
  const [route, setRoute] = useState<Route>('home');

  const Page =
    route === 'settings'
      ? SettingsPage
      : route === 'reports'
        ? ReportsPage
        : HomePage;

  return (
    <>
      <nav>
        <button onClick={() => setRoute('home')}>首页</button>
        <button onClick={() => setRoute('settings')}>设置</button>
        <button onClick={() => setRoute('reports')}>报表</button>
      </nav>

      <Suspense fallback={<p>页面加载中……</p>}>
        <Page />
      </Suspense>
    </>
  );
}

设置页继续按交互需要拆分编辑器:

// src/pages/SettingsPage.tsx
import { lazy, Suspense } from 'react';

const Editor = lazy(() => import('../components/Editor'));

export default function SettingsPage() {
  return (
    <section>
      <h1>设置</h1>

      <Suspense fallback={<p>编辑器加载中……</p>}>
        <Editor />
      </Suspense>
    </section>
  );
}

这个设计形成两层边界:

  1. 首页、设置页、报表页按路由异步加载;
  2. 编辑器只在设置页加载。

如果设置页打开后编辑器必然立即出现,那么第二层拆分可能只增加一次等待。此时可以在用户即将进入设置页时预取:

function SettingsLink() {
  const loadSettings = () => {
    void import('./pages/SettingsPage');
  };

  return (
    <button
      onMouseEnter={loadSettings}
      onFocus={loadSettings}
      onClick={loadSettings}
    >
      设置
    </button>
  );
}

这里使用的是低侵入式的“按用户意图预取”:鼠标悬停或键盘聚焦时启动请求,但真正的路由切换仍由应用控制。它不是对所有设备都有效,触摸设备没有 hover,网络较差时也可能无法在点击前完成。


八、Bundle 分析:不能只看一个文件大小

1. 构建并生成可视化报告

以 Vite 为例,安装 Rollup Visualizer:

npm install -D rollup-plugin-visualizer

配置:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      filename: 'dist/stats.html',
      open: false,
      gzipSize: true,
      brotliSize: true,
    }),
  ],
});

构建:

npm run build

预期会得到类似:

dist/
├── index.html
├── assets/
│   ├── index-8d31.js
│   ├── SettingsPage-a12f.js
│   ├── ReportsPage-b73c.js
│   └── index-4c91.css
└── stats.html

然后打开:

npx vite preview

并在浏览器中查看 dist/stats.html

可视化报告中的面积通常表示模块在未压缩 Bundle 中的占比。它适合回答“谁占空间”,但不能直接等价于用户下载量。应同时观察:

  • 原始体积;
  • gzip 体积;
  • Brotli 体积;
  • 脚本数量;
  • 初始资源;
  • 动态 chunk;
  • 重复依赖;
  • source map 中的源码来源。

2. 原始、压缩、传输和执行是不同指标

设一个 JavaScript 文件有:

  • 原始体积 SrawS_{\text{raw}}
  • gzip 后体积 SgzipS_{\text{gzip}}
  • Brotli 后体积 SbrS_{\text{br}}
  • 下载耗时 TdownloadT_{\text{download}}
  • 解析和编译耗时 TparseT_{\text{parse}}
  • 执行耗时 TevalT_{\text{eval}}

用户体验中的主线程压力更接近:

Tmain-threadTparse+Tcompile+TevalT_{\text{main-thread}} \approx T_{\text{parse}} + T_{\text{compile}} + T_{\text{eval}}

而网络传输更接近:

TnetworkScompressedB+Tlatency+TserverT_{\text{network}} \approx \frac{S_{\text{compressed}}}{B} + T_{\text{latency}} + T_{\text{server}}

其中:

  • ScompressedS_{\text{compressed}} 是实际压缩后的传输字节数;
  • BB 是有效带宽;
  • TlatencyT_{\text{latency}} 是连接和往返延迟;
  • TserverT_{\text{server}} 是服务端响应时间。

压缩只能减少网络字节,不能按相同比例减少解析和执行成本。一个高度可压缩但包含大量复杂代码的 Bundle,可能传输不大,却仍然占用较长主线程时间。

反过来,一个体积较大的图片可能主要影响网络和 LCP,而不显著增加 JavaScript 执行时间。性能预算必须按资源类型拆分,不能只设置一个“总 KB”。


九、性能预算:把“快”转换为可检查的约束

性能预算是一组约束,而不是一句“Bundle 要小”。常见预算维度包括:

  • 初始 JavaScript 原始体积;
  • 初始 JavaScript gzip 或 Brotli 传输体积;
  • 首屏请求数;
  • 单个异步 chunk 的上限;
  • 页面全部关键资源体积;
  • 长任务数量;
  • LCP、INP、CLS 等用户体验指标。

1. 一个可推导的预算算例

假设某页面的目标是在特定网络条件下让首屏可交互。构建后观察到:

入口 JS:120 KB Brotli
React 与公共运行时:70 KB Brotli
首页业务代码:50 KB Brotli
首屏 CSS:18 KB Brotli
首页图片:160 KB Brotli
设置页异步 chunk:260 KB Brotli
报表页异步 chunk:310 KB Brotli

如果首页初始资源包含入口 JS 和首屏 CSS、图片,则初始传输预算为:

Binitial=120+18+160=298 KBB_{\text{initial}} = 120 + 18 + 160 = 298\text{ KB}

设置页首次访问相对于首页新增:

Bsettings=260 KBB_{\text{settings}} = 260\text{ KB}

报表页首次访问新增:

Breports=310 KBB_{\text{reports}} = 310\text{ KB}

如果团队设定:

初始 JS Brotli ≤ 130 KB
首屏 CSS Brotli ≤ 25 KB
首屏关键图片 Brotli ≤ 200 KB
单路由异步 JS Brotli ≤ 280 KB

那么当前结果中:

  • 初始 JS:通过;
  • 首屏 CSS:通过;
  • 首屏图片:通过;
  • 报表页 chunk:失败,超出 30 KB。

这说明“首页很轻”并不代表整个应用满足预算。报表页可能需要继续拆分 PDF 导出库,或者只有用户点击“导出”时才动态加载:

async function exportReport() {
  const { exportToPdf } = await import('./exportToPdf');
  await exportToPdf();
}

这里的推导是:导出功能不是报表查看的必要路径,所以它不应进入报表页面的初始异步 chunk。

2. 预算不能直接推出用户体验

即使两个页面的 Bundle 都是 200 KB,体验也可能不同,因为还受到以下变量影响:

  • 用户设备 CPU;
  • 网络 RTT 和吞吐;
  • 缓存命中率;
  • JavaScript 的复杂度;
  • 是否发生主线程长任务;
  • 是否与第三方脚本竞争;
  • 页面是否需要 hydration。

因此预算至少要分成两层:

资源预算构建阶段检查\text{资源预算} \rightarrow \text{构建阶段检查}

体验预算真实浏览器或现场数据检查\text{体验预算} \rightarrow \text{真实浏览器或现场数据检查}

资源预算回答“构建产物是否膨胀”;体验预算回答“真实用户是否受到影响”。前者不能替代后者。

3. 在 CI 中检查构建产物

可以写一个简单的 Node 脚本检查初始 JS 原始体积:

// scripts/check-bundle.mjs
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';

const assetsDir = path.resolve('dist/assets');
const files = await readdir(assetsDir);

const initialJs = files.filter(
  (file) => file.endsWith('.js') && !file.includes('lazy'),
);

const maxBytes = 250 * 1024;
let total = 0;

for (const file of initialJs) {
  const size = (await stat(path.join(assetsDir, file))).size;
  total += size;
  console.log(`${file}: ${size} bytes`);
}

console.log(`initial JS total: ${total} bytes`);

if (total > maxBytes) {
  console.error(
    `Bundle budget exceeded: ${total} > ${maxBytes} bytes`,
  );
  process.exit(1);
}

这个脚本只是示例,不能直接假设所有 .js 都是初始资源。更可靠的实现应该读取打包器的 manifest,按 HTML 入口和 import 关系确定初始资源。否则可能把异步 chunk 错误计入初始预算,或者遗漏通过 HTML 直接引用的资源。


十、分包和预加载的联合决策

可以把资源加载看成一个状态转换:

flowchart TD
    A[用户打开页面] --> B[加载入口资源]
    B --> C{当前路由是否需要异步模块}
    C -- 否 --> D[继续渲染]
    C -- 是 --> E[请求路由 chunk]
    E --> F{chunk 成功}
    F -- 是 --> G[Suspense 结束并渲染]
    F -- 否 --> H[错误边界显示恢复 UI]
    D --> I{用户是否表现出导航意图}
    I -- 是 --> J[prefetch 或动态 import 预取]
    I -- 否 --> K[不加载未来资源]
    J --> L[用户真正导航]
    L --> M[复用缓存中的 chunk]

关键路径有三种:

  1. 首屏关键资源:可以考虑 preload,但必须验证它不会和 HTML、字体、LCP 图片竞争;
  2. 用户即将访问的路由:更适合低优先级 prefetch 或基于用户意图的动态预取;
  3. 低概率、大体积功能:只在明确操作发生时 import(),不应提前下载。

一个常见失败方案是“分包后又全部 preload”。这在形式上保留了多个 chunk,但在行为上又把它们拉回初始加载阶段,等价于部分取消了分包收益。


十一、诊断失败 Bundle 的步骤

1. 先确认问题是网络、主线程还是渲染

在 Chrome DevTools 中:

  • Network 面板查看资源大小、压缩传输大小、优先级和请求瀑布;
  • Performance 面板查看脚本解析、编译、执行和长任务;
  • Coverage 面板查看加载后实际未执行的代码比例;
  • Lighthouse 或 CI 工具检查 LCP、INP、CLS;
  • 生产构建报告定位具体依赖来源。

Coverage 中大量“未使用字节”并不自动证明代码可以 Tree Shake。它可能表示:

  • 代码会在稍后交互时使用;
  • 代码位于当前入口,但需要通过动态边界重新组织;
  • 库有副作用,不能删除;
  • 依赖是 CommonJS,静态分析能力不足;
  • 代码被加载但没有执行,而不是构建阶段可删除。

2. 发现第三方库过大

排查顺序应是:

  1. 查看报告中占比最大的模块;
  2. 确认它来自哪个直接依赖;
  3. 检查是否可以使用 ESM 入口;
  4. 检查是否导入了整个功能集合;
  5. 判断是否可以移动到动态 import()
  6. 验证拆分后是否引入重复依赖;
  7. 对比构建前后的传输和执行成本。

例如:

// 可能扩大依赖闭包
import * as dateUtils from 'large-date-library';

// 更明确的按需导入,具体是否有效要看库的导出方式
import { format } from 'large-date-library/format';

只有当包真正提供可分析的模块入口时,第二种形式才有确定收益。不要仅凭路径更短就认定体积一定更小。

3. 发现 chunk 404

chunk 404 通常不是 React 组件错误,而是发布和缓存一致性问题。典型路径:

用户打开旧 HTML
  → HTML 引用旧入口 JS
  → 入口 JS 动态请求旧 chunk
  → CDN 已删除旧 chunk
  → import() 失败

恢复措施包括:

  • 保留多个版本的静态资源;
  • 使用内容哈希文件名;
  • 不要在部署时立即删除仍可能被引用的旧资源;
  • 保持 HTML、manifest 和资源发布顺序一致;
  • 对 chunk 加载失败提供有限次数刷新;
  • 检查 Service Worker 是否缓存了旧 manifest。

如果采用“先部署资源、再切换 HTML”策略,旧 HTML 引用的资源仍然存在,兼容窗口会更安全。清理旧资源应延迟到超过最长缓存和访问周期之后。


十二、哪些优化容易适得其反

1. 过度分包

把首屏同时需要的几十个组件全部异步化,会产生多个 Suspense 等待点。结果可能是:

  • 首屏出现多个 loading;
  • 资源请求交错;
  • 主线程执行被切成更多片段;
  • 组件边界变复杂;
  • 用户看到了占位符,却没有更早看到有意义内容。

分包边界应围绕路由、模态框、编辑器、图表、导出器等有明确使用时机的功能,而不是围绕文件数量。

2. 错误使用 preload

Preload 具有较高优先级。错误预加载低概率资源,可能挤压:

  • 主文档;
  • 首屏图片;
  • 关键字体;
  • 当前路由入口;
  • CSS。

如果资源只是“未来可能用到”,通常应选择 prefetch,或者等待用户产生导航意图后再动态导入。

3. 只看 gzip,不看执行时间

两个 Bundle 可能都有 100 KB gzip,但其中一个包含大量复杂解析器和运行时初始化代码,另一个只是简单组件。它们的解析和执行成本可能不同。

因此生产验证至少要分别记录:

资源传输大小
资源请求时间
脚本解析/编译时间
脚本执行时间
hydration 时间
LCP 与 INP

4. 把 React Compiler 当作 Bundle 优化器

React Compiler 主要针对组件代码的重新计算和记忆化问题。它不等价于:

  • 删除未使用 npm 导出;
  • 自动按路由生成 chunk;
  • 自动把任意组件放入异步边界;
  • 替代打包器的 Tree Shaking。

Bundle 优化仍然依赖模块格式、导入关系、动态导入和构建配置。渲染优化与资源优化需要分别测量。


十三、一个可执行的检查闭环

可以将一次 Bundle 优化拆成以下可验证步骤:

第一步:建立生产基线

npm run build

记录:

入口 JS 原始体积
入口 JS gzip/Brotli 体积
初始资源数量
最大异步 chunk
最大依赖模块
LCP、INP、CLS

开发模式不能作为基线,因为开发构建通常包含未压缩代码、调试信息、模块热更新和不同的依赖处理。

第二步:定位最大模块

打开可视化报告,确认最大体积来自:

  • React 运行时;
  • UI 组件库;
  • 图表或编辑器;
  • polyfill;
  • 重复依赖;
  • CommonJS 包;
  • 错误的静态导入。

不要从“某文件名很大”直接推断问题,应该沿模块图追溯“谁把它引入了初始闭包”。

第三步:选择机制

  • 未使用导出:检查 ESM 和副作用声明,改善 Tree Shaking;
  • 当前路径不需要:使用动态 import() 形成异步边界;
  • 用户即将导航:使用有条件的 prefetch;
  • 首屏明确且马上使用:谨慎使用 preload;
  • 低概率大功能:延迟到点击、打开或确认操作后加载;
  • 版本发布后 404:修复资源保留和缓存一致性,而不是在组件中反复重试。

第四步:重新构建并检查副作用

重点验证:

  • CSS 是否仍然加载;
  • polyfill 是否仍然执行;
  • 埋点和插件是否初始化;
  • SSR 与 hydration 是否一致;
  • 动态 chunk 是否可以访问;
  • 错误边界是否显示;
  • 首屏是否出现新的 loading 闪烁;
  • 共享依赖是否被重复打包。

第五步:在真实浏览器条件下测量

至少测试:

  • 冷缓存首次访问;
  • 热缓存重复访问;
  • 低端移动设备;
  • 高延迟网络;
  • 首次进入和路由切换;
  • 动态 chunk 请求失败;
  • 新版本发布后的旧页面。

最终目标不是让报告中的某个数字最小,而是在可接受的资源预算内,让真实用户更早获得内容、更早完成交互,并且在资源加载失败时能够恢复。

Bundle 优化的核心关系可以概括为:

Tree Shaking减少必须保留的代码\text{Tree Shaking} \Rightarrow \text{减少必须保留的代码}

Code Splitting减少当前时刻必须加载的代码\text{Code Splitting} \Rightarrow \text{减少当前时刻必须加载的代码}

Preload/Prefetch调整资源获取时机和优先级\text{Preload/Prefetch} \Rightarrow \text{调整资源获取时机和优先级}

Performance Budget将上述结果变成持续可检查的约束\text{Performance Budget} \Rightarrow \text{将上述结果变成持续可检查的约束}

只有先正确分析模块图和用户路径,再决定删除、拆分或提前加载,React 应用的 Bundle 优化才不会从“一个大文件”变成“许多更难诊断的小文件”。


系列导航与关联阅读

官方资料

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