一次 React 项目的性能优化实践:从包体积治理到请求时序踩坑
这段时间,我对一个运行了几年的 React 项目做了一轮性能优化。
一开始,我以为这次优化主要是拆包:找出主包里的大依赖,把页面和 SDK 拆出去,再配一下缓存策略,包体积自然就会降下来。
真正做完以后才发现,包体积只是其中一部分。路由加载方式、接口请求时机、App Bridge 初始化、Token 写入顺序,最后全部串在了一起。尤其是在灰度环境中,提前请求数据还暴露了一个之前已经存在、但没有稳定出现的 Token 竞态。
这篇文章记录这次优化做了哪些调整、哪些方案后来回退了,以及拆包如何一步步影响请求时序。
背景
这是一个运行在 App WebView 中的 React H5 项目。
它和普通网站有一点不一样:用户不一定先进入首页,再从首页跳转到其他页面。App 可以直接打开某一个账单页、交易页、活动页或者详情页。
也就是说,项目里的很多业务页面都可能成为用户看到的第一个页面。
同时,这些页面大多依赖接口数据。页面本身的静态结构并不多,通常需要经历下面这条链路:
- App 打开 WebView
- 下载并执行 JavaScript
- 初始化公共 SDK
- 通过 App Bridge 获取 Token
- 调用页面接口
- 接口返回后展示主要内容
只要其中一个步骤比较慢,用户看到的就是空白、Loading,或者一个还没有真实内容的页面。
随着项目不断迭代,构建产物里也逐渐积累了不少历史内容:
- 已经下线但仍然存在的路由页面
- 多套功能相同的 Toast 和弹窗依赖
- 从入口同步加载的大型 SDK
- 没有按功能导入的 Lodash、CryptoJS
- 重复图片和未使用字体
- 生产依赖和开发依赖混在一起
- 为了一个常量而被带入主包的业务组件
- 页面挂载后才开始调用的接口
所以,这次优化的目标不是简单地让 main.js 变小,而是尽量缩短用户从打开页面到看到有效内容的完整时间。
优化思路
我没有一开始就修改 Webpack 配置,而是先分析构建产物,再删除无效代码、重复依赖和静态资源。在确认剩余内容确实需要保留后,才继续做按需导入、路由拆分、公共依赖分包和请求前移,并在灰度环境验证完整链路。
这次优化遵循的顺序很简单:
先分析,再删除,然后才是拆分和重构。
能直接删掉的东西,不应该通过复杂的打包配置去解决。
实现方案
1. 用 Bundle Analyzer 找出真正的大头

优化开始时,我临时接入了 webpack-bundle-analyzer,用它查看构建产物中的模块组成。
这一步主要想确认几个问题:
- 主包里最大的依赖是什么
- 是否存在多个功能相同的 UI 库
- 哪些 SDK 被入口同步加载
- 是否存在整包导入
- PDF、字体和图片占了多少空间
- 公共工具文件是否意外引入了重依赖
- 页面拆分后,依赖是否真的离开了首屏资源
分析以后发现,项目的问题并不是某一个包特别大,而是很多小问题叠加在一起。
例如,项目中同时存在多种 Toast 实现;有的页面从 Lodash 根入口导入一个函数;DOMPurify 放在通用工具文件中;一些 SDK 虽然只有部分页面使用,却在应用启动时同步加载。
Bundle Analyzer 在这一阶段的价值主要是帮助定位。完成分析后,我删除了 analyzer 依赖和相关插件配置,不让分析工具长期留在正式构建链路里。
2. 统一功能重复的 UI 依赖
项目迭代时间比较长,Toast 和弹窗能力来自多个地方:
antd的messagerc-notification- 基于
rc-notification封装的业务 Toast react-hot-toastantd-mobile的 Toastreact-modal- 项目自己封装的 Popup 和 Modal
这些依赖解决的问题很接近,但调用方式、样式和销毁逻辑都不一样。
如果只是为了显示一条提示信息,同时保留三四套实现,不仅会增加依赖体积,也会增加维护成本。
这次优化中,我优先复用项目本来就大量使用的 antd-mobile:
- 将
antd message替换为antd-mobile Toast - 将自定义 Toast 的底层从
rc-notification改为antd-mobile Toast - 删除
rc-notification - 将
react-modal替换为antd-mobile Popup - 删除不再需要的
antd和react-modal
为了避免大范围修改业务调用方,可以在原来的接口外面保留一层适配:
import { Toast as MobileToast } from "antd-mobile";
type ToastOptions = {
message: string;
type?: "success" | "error";
};
export function showToast(options: ToastOptions | string) {
const value = typeof options === "string" ? { message: options } : options;
return MobileToast.show({
duration: 2000,
position: "center",
content: value.message,
});
}这样业务代码不需要立即全部改成新的调用方式,但底层能力已经统一。
这里我没有一次性删除所有 react-hot-toast。它仍然被部分历史页面使用,如果为了“依赖绝对统一”同时改动所有页面,测试范围会明显扩大。
所以这次采用的是渐进式处理:先删除明确重复、替换成本较低的依赖,剩余页面后续再逐步收敛。
3. 删除无效代码和静态资源

这一部分看起来没有拆包那么“高级”,但实际收益最直接。
项目中有 7 个已经下线的路由页面,虽然用户不会再进入,但对应代码和资源仍然存在。删除这些页面后,一次清理了六千多行代码。
除此之外,还处理了这些内容:
- 删除空文件和无效配置
- 清理未使用的变量和导入
- 删除重复 Logo 和弹窗图片
- 让多个页面复用同一份图片
- 使用 SVGO 优化 SVG
- 位图优先使用 WebP 等压缩格式
- 删除不再使用的字体字重
- 将 TTF 字体替换为 WOFF2
字体压缩的效果比较明显。
例如,项目中一个约 53 kB 的 TTF 字体,转换为 WOFF2 后约为 18 kB;另一个约 72 kB 的字体,转换后约为 30 kB。
单看一个字体不算特别大,但当页面包含多个字重、多个字体文件时,累计体积就很可观。
这也让我重新确认了一点:
性能优化中,删除资源通常比拆分资源更有效。
拆包只是决定资源什么时候加载,删除才是真正让资源不再加载。
4. 清理依赖并区分运行依赖和开发依赖
项目的 dependencies 中原来混入了很多只在开发和构建阶段使用的依赖,例如:
- Babel
- Webpack loader
- Jest 和测试工具
- TypeScript 类型声明
- ESLint
- 格式化工具
- 本地开发服务器相关依赖
这次把它们重新移动到了 devDependencies,并删除了一些已经不再使用的包,例如 Tailwind、Pug、Sass loader、过渡组件和旧构建工具。
需要说明的是,把依赖从 dependencies 移到 devDependencies,不一定会直接让浏览器里的 JavaScript 变小。
浏览器包体积最终取决于代码有没有被构建进去。这一步更主要的作用是:
- 让依赖职责更清楚
- 减少生产安装时不必要的依赖
- 降低部署环境的安装内容
- 避免后续开发误用构建依赖
PDF 相关依赖也做了一次整理。
项目同时存在 react-pdf 和单独安装的 pdfjs-dist,但实际能力存在重复,因此删除了单独声明的 pdfjs-dist。
中间也尝试升级过 react-pdf,但依赖升级会同时影响 Worker、构建配置和运行兼容性。为了避免把性能优化和版本兼容问题混在一起,验证后最终还是恢复到了项目原来稳定使用的版本。
版本更高不一定就更适合当前项目。性能、兼容性和改造成本需要一起评估。
5. 避免从根入口导入整个依赖

分析构建产物时,还发现了一些典型的整包导入。
例如,项目只使用 Lodash 的 debounce 或 throttle,却从 Lodash 根入口导入;CryptoJS 只使用 SHA256,也从整个包导入。
后来统一改成了按功能路径加载:
import debounce from "lodash/debounce";
import throttle from "lodash/throttle";
import SHA256 from "crypto-js/sha256";
const handleSearch = debounce(search, 300);
const encrypted = SHA256(value).toString();虽然现代构建工具支持 Tree Shaking,但并不是所有包都能稳定地被完整裁剪,尤其是仍然包含 CommonJS 的依赖。
按功能路径导入更明确,也更容易从构建结果中确认实际引入内容。
Lodash 还有两种可选的处理方式。一种是改用
lodash-es,通过 ESM 导出配合构建工具做 Tree Shaking;另一种是迁移到es-toolkit,它同样提供debounce、throttle等常用方法,并针对 ESM 和 Tree Shaking 做了优化。这两种方案都需要重新核对 API 行为和构建结果。尤其是从 Lodash 迁移到
es-toolkit时,部分参数和边界行为并不完全相同。为了控制本轮优化的改动范围,这个项目最终只采用了按功能路径导入,没有切换到lodash-es或es-toolkit。
DOMPurify 也有一个类似的问题。
它原来放在通用的 utils.ts 中,而这个文件被很多页面引用。即使某个页面只使用一个普通工具函数,也可能因为依赖关系把 DOMPurify 带入公共链路。
后来我把 HTML 安全处理方法拆到了单独的 html.ts,只有真正需要渲染富文本的页面才导入它。
同时,一些纯常量也从大型组件入口中拆了出来。否则 service 文件为了读取一个版本号或枚举,可能会顺着依赖关系把整个 UI 组件带进来。
优化 import 时,不能只看当前这一行代码,还要顺着依赖链继续往下看。
6. 页面按路由拆分

项目中的业务页面比较多,而用户一次通常只访问其中一个页面。
因此,我将页面组件统一改成通过 React.lazy 按路由加载:
const TransactionPage = React.lazy(() => import("./views/Transaction"));
const BillDetailPage = React.lazy(() => import("./views/BillDetail"));
const routes = [
{
path: "/transaction",
element: (
<Suspense fallback={<PageLoading />}>
<TransactionPage />
</Suspense>
),
},
];对于普通网站,首页通常是最明确的首屏。
但这个项目运行在 App WebView 中,App 可以直接打开任意业务路由,所以每个路由都需要被当作可能的首屏处理。
路由级拆分以后,用户进入交易页面时,不需要同时下载税务、活动、个人资料等其他页面代码。
不过,拆分也不是越细越好。
我最初还尝试把每个路由的 loader 也改成动态加载。这样确实能继续减少入口代码,但用户进入页面时会多一次 loader chunk 请求。
在 App WebView 和弱网环境中,额外请求的延迟有时会比减少的那部分 JavaScript 更明显。
最后保留了页面组件的懒加载,但将 loader 恢复为同步引入,在包体积和请求数量之间做了一个平衡。
7. 将非关键 SDK 移出主执行链路
项目入口中原来会同步初始化多个 SDK,包括国际化工具、安全签名和其他业务 SDK。
其中一些能力并不影响页面最初的结构展示,没有必要全部阻塞主执行链路。
因此,部分 SDK 改成了动态导入:
const HomePage = React.lazy(() => import("./views/Home"));
import("security-sdk")
.then(({ init }) => init())
.catch((error) => {
reportError("security-sdk.init", error);
});中间还尝试过切换部分 SDK 的 ESM 入口,希望减少 CommonJS 版本中被一起打包的兼容代码和本地配置。
但动态 import() 并不代表这个依赖一定会离开首屏。
如果某个同步 loader、公共工具或者其他入口仍然直接引用相同 SDK,Webpack 仍可能把它合并到首屏公共包中。
所以每次调整后,都需要重新查看构建产物,而不是看到代码里出现了 import() 就认为拆分已经成功。
8. 公共依赖分包和静态缓存
页面拆分解决的是“当前页面需要加载哪些业务代码”,公共依赖拆分则为稳定资源单独缓存创造条件。
项目最终按照依赖类型配置了几个公共包:
optimization: {
runtimeChunk: "single",
splitChunks: {
chunks: "all",
cacheGroups: {
framework: {
test: /node_modules[\\/](react|react-dom|react-router-dom|antd-mobile)/,
name: "framework",
priority: 40,
enforce: true,
},
sdk: {
test: /node_modules[\\/]@company/,
name: "sdk",
priority: 30,
},
pdf: {
test: /node_modules[\\/](react-pdf|pdfjs-dist)/,
name: "pdf",
priority: 20,
},
vendor: {
test: /node_modules/,
name: "vendor",
priority: 10,
},
},
},
}拆分后的主要收益不是让所有依赖消失,而是让不同类型的代码可以独立失效:
- 业务代码修改时,React 等框架依赖的 hash 尽量不变
- PDF 只在需要预览的页面加载
- SDK 和普通第三方依赖分开缓存
- Webpack runtime 独立,减少业务变更对其他 chunk 的影响
配合内容哈希、缓存响应头和 WebView 的实际缓存策略,这种拆分可以减少业务代码变更对稳定依赖缓存的影响。对于经常通过 App 打开页面的用户,这部分收益可能比单次冷启动的包体积数字更有价值。
当然,代价是文件数量增加。
如果拆得太细,弱网下的请求排队和连接开销可能抵消体积收益。所以最终还是要结合真实运行环境,而不是只追求更小的单个文件。
9. 提前请求页面数据

前面的优化主要解决资源加载问题,但页面最终还要等待接口数据。
旧实现通常是在组件挂载以后,通过 useEffect 调用接口:
下载页面代码 → 执行组件 → 挂载页面 → 调用接口 → 展示内容为了让接口更早发起,我将路由升级为 createBrowserRouter,并把部分页面初始化接口移动到 loader 中。
loader 执行时,可以立即发起多个请求,再通过 defer 把尚未完成的 Promise 交给路由管理:
import { defer } from "react-router-dom";
export function pageLoader() {
const detailPromise = requestDetail();
const optionsPromise = requestOptions();
return defer({
detail: detailPromise,
options: optionsPromise,
});
}两个请求之所以能够并发等待,是因为 requestDetail() 和 requestOptions() 在 loader 中立即执行;defer 负责让路由跟踪这些未完成的 Promise,并配合 Suspense 和 <Await> 渐进展示数据。
这里使用的
react-router-dom版本是 6.4.5,所以示例保留了defer。React Router 7.0.0 的官方 Changelog中,defer已被移除,loader 可以直接返回包含 Promise 的普通对象:export function pageLoader() { return { detail: requestDetail(), options: requestOptions(), }; }组件侧仍然可以通过
Suspense和<Await>处理尚未完成的数据。相比 6.x,7.x 省掉了defer()这一层,写法更直接。
进入路由后,接口不再等组件挂载完成才发起。在当前路由配置下,请求过程通常可以和页面 chunk 的加载发生重叠,但具体效果仍要以浏览器网络瀑布图为准。
从执行链路上看,它变成了:
进入路由 → 页面代码加载
↘ 接口同时请求 → 数据返回后展示这个改造对接口型页面比较有效,但它也改变了整个应用的请求时序。
后面的 Token 问题,就是在这里暴露出来的。
灰度环境踩坑记录
1. 首个接口没有携带 Token
优化进入灰度环境后,部分页面出现了首个接口没有携带 Token 的问题。
项目的 Token 不是直接从 URL 或 Cookie 中读取,而是通过 App Bridge 异步获取:
WebView 启动 → 调用 Bridge → App 返回 Token → 写入公共参数旧页面通常在组件挂载以后才调用接口。虽然代码里没有明确等待 Token,但组件渲染、状态初始化和副作用执行,无意中给了 App Bridge 一些时间。
大多数情况下,Token 会在接口请求之前写入,所以问题一直不明显。
loader 改造以后,请求被提前到了路由初始化阶段:
WebView 启动
├─ 调用 Bridge 获取 Token
└─ loader 立即请求接口这两个异步任务之间没有顺序保证。
如果 loader 更快,接口就会在没有 Token 的情况下发出。
所以,这次优化并没有制造新的 Token 问题。它只是改变了请求时机,把一个之前就存在的竞态问题暴露了出来。
2. 先回退,再定位
灰度出现问题后,我没有继续叠加修改,而是先回退了一部分比较激进的方案:
- loader 从动态加载恢复为同步引入
- 减少进入路由时额外产生的请求
- 页面组件的
React.lazy继续保留 - PDF 等存在兼容变量的依赖恢复到稳定版本
回退的目的不是放弃优化,而是减少变量,确认问题究竟来自拆包、loader 加载,还是接口本身。
最终确认,真正的问题是 Token 初始化和 loader 执行之间缺少同步机制。
3. 用共享 Promise 管理 Token 状态

最后的解决思路是:把 Token 初始化从一个没有状态的 Bridge 回调,变成一个全局共享的 Promise。
App 启动和所有 loader 复用同一个初始化任务。Token 获取成功时,Promise 会在公共参数写入后完成;Bridge 抛错、不回调或返回空值时,则在超时和重试后降级放行,避免页面永久等待。
下面是简化后的核心实现:
let tokenReadyPromise: Promise<string> | null = null;
function requestTokenOnce(timeout = 1500) {
return new Promise<string>((resolve) => {
let settled = false;
const finish = (token = "") => {
if (settled) return;
settled = true;
window.clearTimeout(timer);
resolve(token);
};
const timer = window.setTimeout(() => finish(""), timeout);
try {
AppBridge.getToken((token) => finish(token || ""));
} catch {
finish("");
}
});
}
function delay(ms: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms));
}
export function initializeToken() {
if (tokenReadyPromise) return tokenReadyPromise;
tokenReadyPromise = (async () => {
for (let attempt = 0; attempt < 2; attempt += 1) {
const token = await requestTokenOnce();
if (token) {
setCommonParam("token", token);
return token;
}
if (attempt === 0) await delay(100);
}
// 本轮初始化失败,允许后续导航重新尝试。
tokenReadyPromise = null;
return "";
})();
return tokenReadyPromise;
}
function withTokenReady(routes: RouteObject[]): RouteObject[] {
return routes.map((route) => ({
...route,
loader: route.loader
? async (args) => {
await initializeToken();
return route.loader!(args);
}
: undefined,
children: route.children ? withTokenReady(route.children) : undefined,
}));
}这个实现处理了几个边界:
- 同一轮初始化中,App 和 loader 不会重复调用 Bridge
- Token 获取成功时,loader 会等公共参数写入后再请求
- Bridge 首次失败后会重试一次
- 单次调用只会完成一次,超时后的迟到回调会被忽略
- Bridge 不回调时会通过超时继续执行
- 最终失败会降级放行,并允许后续导航重新初始化
这里也有取舍。
如果 Bridge 真的异常,loader 最长可能需要等待两次超时和一次重试间隔。这会增加异常场景下的尾部耗时,但相比立刻发送一个没有 Token 的请求,等待后再按明确策略降级更可控。
整体收益与取舍
这次优化同时观察了构建产物体积和页面完全加载完成时间。下面的数据是当时留下的大致记录,适合用来判断前后变化,不作为精确的基准测试结果。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 全量构建产物,未压缩 | 约 7.2 MB | 约 4.3 MB |
| 全量构建产物,gzip 后 | 约 1 MB | 600 多 kB |
| 页面完全加载完成时间 | 约 4.6 秒 | 约 2.x 秒 |
未压缩的全量构建产物从约 7.2 MB 降到约 4.3 MB,大约减少了四成。gzip 后则从约 1 MB 降到 600 多 kB。这部分变化主要来自删除废弃代码和重复依赖、压缩字体与图片,以及调整资源格式和导入方式。
优化后的 4.3 MB 中,公司 SDK 和 PDF.js 相关代码分别占了 1.x MB。它们被拆成独立 chunk 后,仍然会计入全量构建产物,但不代表每个页面都要在首屏加载全部 4.3 MB。PDF 相关代码只在预览页面使用,稳定的 SDK 也可以借助内容哈希和 CDN 缓存,减少业务版本更新时的重复传输。
SplitChunks 本身不会直接减少全量代码,甚至会增加一点(相比全部放一起,压缩策略),它改变的是资源如何拆分、什么时候加载,以及哪些文件可以独立缓存。CDN 也不会让构建产物变小,主要影响传输和缓存命中。最终的体积下降,还是来自代码和资源本身的清理与压缩。
方案上线后,页面完全加载完成时间从约 4.6 秒降到了 2.x 秒。这个结果来自整条页面加载链路,不能单独归因于 loader 或某一次拆包,但说明资源治理、按路由加载、CDN 缓存和请求前移组合起来产生了实际收益。
这次优化也有明确代价:
- 拆分后的资源文件数量增加
- 弱网下多请求可能抵消部分体积收益
- loader 提前请求增加了初始化时序复杂度
- Token 异常时需要等待重试和降级
- 部分重复依赖为了控制风险仍然保留
最终方案追求的不是最小的单个文件,而是包体积、请求数量、缓存效果和稳定性之间的平衡。
总结
这次性能优化从分析包体积开始,最后却落到了请求时序和 App 初始化上。
能删除的页面、资源和重复依赖先删除,剩余内容再拆分。动态导入之后还要回到构建产物确认结果,因为依赖链中的任意同步引用,都可能让目标模块重新进入首屏公共资源。评价效果时也不能只看 main.js,首屏总体积、请求数量和缓存行为更接近用户真正承担的成本。
这次更重要的教训来自请求时序。把接口从组件挂载后提前到路由 loader,缩短了串行链路,也要求 Token、语言、城市和 SDK 等初始化状态拥有明确的同步机制。灰度中出现的 Token 问题不是优化制造的新问题,而是原有竞态在新的执行顺序下更容易发生。
出现问题后,先回退一部分方案、减少变量,再确认根因,比继续叠加修复更容易控制风险。最终保留下来的版本没有追求最小的单文件体积,但它更符合这个 App WebView 项目的运行环境。
以上是这次性能优化的过程记录,也包括我在拆包、缓存和请求时序上的一些思考。项目后续还会继续迭代,这篇先记录到这里。
喜欢这篇文章嘛,觉得文章不错的话,奖励奖励我!


评论