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 依赖...
实际构建还会包含以下处理:
- 解析
import和export; - 转换 TypeScript、JSX 和浏览器不支持的语法;
- 处理 npm 包的
package.json、条件导出和模块格式; - 删除可证明无效的代码;
- 根据静态入口和动态入口生成一个或多个资源;
- 压缩、生成 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 中的 exports、module、main、sideEffects 等字段都可能参与这个决策,但具体优先级由打包器和配置决定,不能只看某一个字段就推断结果。
二、Tree Shaking:删除什么,为什么能够删除
1. 形式化理解
设一个模块图为:
其中:
- 是模块或模块内部声明;
- 是导入、调用或副作用依赖关系;
- 是从入口模块可达的运行时根集合。
如果某个声明不在从 出发的有效依赖闭包中,并且删除它不会改变程序可观察行为,那么它可以被删除。
“可观察行为”包括但不限于:
- 返回值;
- DOM 修改;
- 网络请求;
- 注册事件;
- 修改全局变量;
- 写入存储;
- 抛出异常;
- 修改导入模块的状态。
因此,Tree Shaking 不是简单的“没有调用就删除”。它必须同时判断:
- 这个导出是否被使用;
- 模块初始化是否有副作用;
- 删除代码是否会改变程序行为;
- 当前模块格式和配置是否允许这种分析。
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 文件,那么最终代码通常只需要保留 add。multiply 没有被使用,也没有模块初始化副作用,因此具备被删除的条件。
这并不意味着构建产物一定会出现“完全干净”的函数级删除结果。压缩器、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
}
}
启用后,普通 import 和 import 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>
);
}
执行路径如下:
- 首次渲染
SettingsPage; lazy调用加载函数;- 浏览器请求由打包器生成的编辑器 chunk;
- Promise 未完成时,组件树显示
fallback; - chunk 执行并得到默认导出;
- 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,例如 preload、preloadModule、preconnect、prefetchDNS 等。它们的价值在于让 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>
);
}
这个设计形成两层边界:
- 首页、设置页、报表页按路由异步加载;
- 编辑器只在设置页加载。
如果设置页打开后编辑器必然立即出现,那么第二层拆分可能只增加一次等待。此时可以在用户即将进入设置页时预取:
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 文件有:
- 原始体积 ;
- gzip 后体积 ;
- Brotli 后体积 ;
- 下载耗时 ;
- 解析和编译耗时 ;
- 执行耗时 。
用户体验中的主线程压力更接近:
而网络传输更接近:
其中:
- 是实际压缩后的传输字节数;
- 是有效带宽;
- 是连接和往返延迟;
- 是服务端响应时间。
压缩只能减少网络字节,不能按相同比例减少解析和执行成本。一个高度可压缩但包含大量复杂代码的 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、图片,则初始传输预算为:
设置页首次访问相对于首页新增:
报表页首次访问新增:
如果团队设定:
初始 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。
因此预算至少要分成两层:
资源预算回答“构建产物是否膨胀”;体验预算回答“真实用户是否受到影响”。前者不能替代后者。
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]
关键路径有三种:
- 首屏关键资源:可以考虑 preload,但必须验证它不会和 HTML、字体、LCP 图片竞争;
- 用户即将访问的路由:更适合低优先级 prefetch 或基于用户意图的动态预取;
- 低概率、大体积功能:只在明确操作发生时
import(),不应提前下载。
一个常见失败方案是“分包后又全部 preload”。这在形式上保留了多个 chunk,但在行为上又把它们拉回初始加载阶段,等价于部分取消了分包收益。
十一、诊断失败 Bundle 的步骤
1. 先确认问题是网络、主线程还是渲染
在 Chrome DevTools 中:
- Network 面板查看资源大小、压缩传输大小、优先级和请求瀑布;
- Performance 面板查看脚本解析、编译、执行和长任务;
- Coverage 面板查看加载后实际未执行的代码比例;
- Lighthouse 或 CI 工具检查 LCP、INP、CLS;
- 生产构建报告定位具体依赖来源。
Coverage 中大量“未使用字节”并不自动证明代码可以 Tree Shake。它可能表示:
- 代码会在稍后交互时使用;
- 代码位于当前入口,但需要通过动态边界重新组织;
- 库有副作用,不能删除;
- 依赖是 CommonJS,静态分析能力不足;
- 代码被加载但没有执行,而不是构建阶段可删除。
2. 发现第三方库过大
排查顺序应是:
- 查看报告中占比最大的模块;
- 确认它来自哪个直接依赖;
- 检查是否可以使用 ESM 入口;
- 检查是否导入了整个功能集合;
- 判断是否可以移动到动态
import(); - 验证拆分后是否引入重复依赖;
- 对比构建前后的传输和执行成本。
例如:
// 可能扩大依赖闭包
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 优化的核心关系可以概括为:
只有先正确分析模块图和用户路径,再决定删除、拆分或提前加载,React 应用的 Bundle 优化才不会从“一个大文件”变成“许多更难诊断的小文件”。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React DevTools 与 Profiler:提交、更新来源、火焰图和诊断
- 下一篇:React PWA:Service Worker、缓存更新、离线和安装体验
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论