全部优化由AI完成
写在前面
某天收到反馈:线上后台(一个基于 React + Webpack、依赖较多 SDK 的管理应用)打开特别慢,首页 Load 要 27 秒 ,Network 面板显示传输了 22.3MB。但同一份代码部署的 dev 环境却快得多。
同一份代码,为什么两个环境差这么多?这篇文章完整复盘排查过程和最终的优化手段,涉及:服务端压缩、首屏依赖拆分、CJS 包的 tree-shaking 陷阱、Webpack splitChunks 调优 ,以及一套用 stats.json 精准定位首屏体积的方法。
最终首屏入口体积从 22.3MiB 降到 3.1MiB ,配合压缩后实际下载 brotli ≈ 2.8MB / gzip ≈ 3.1B ,节省约 86%。
一、先定位,别瞎猜:用数据说话
1.1 一个决定性的信号
打开 Chrome DevTools 的 Network 面板,底部有两个数字:
22.3 MB transferred 22.3 MB resources
transferred(实际传输)和 resources(解压后大小)完全相等 。这意味着服务器返回的 JS 没有经过任何 HTTP 压缩------浏览器拿到的就是原始的 22MB。
优化前:首屏 22.3MB transferred、Load 约 27s,单个 bundle 高达 3-4MB转存失败

测试环境

1.2 对比两个环境的响应头
直接 curl 抓同一个 JS 文件的响应头:
bash
# 线上(自建 nginx)
curl -sI -H "Accept-Encoding: br, gzip" https://prod.example.com/runtime.bundle.js
# → Content-Type: application/javascript
# → Content-Length: 8357
# → (没有 Content-Encoding!)
# dev(挂了 Cloudflare)
curl -sI -H "Accept-Encoding: br, gzip" https://dev.example.com/runtime.bundle.js
# → content-encoding: gzip
# → cache-control: public, max-age=7200
# → server: cloudflare
真相大白:
| 线上 自建 nginx | dev Cloudflare | |
|---|---|---|
| JS 压缩 | 无 | gzip 自动 |
| 缓存头 | 无 | max-age=7200 |
| CDN | 无 | 有(边缘节点 + 缓存) |
结论一:dev 快不是因为代码,而是 Cloudflare 在边缘自动做了压缩 + 缓存 + 就近加速;线上裸 nginx 既没压缩 JS 也没缓存。
经验:性能问题先看 Network 面板的
transferred vs resources、再curl抓响应头。两分钟就能判断"是不是压缩没开",比盲目改代码高效得多。
二、三个根因
根因 1:服务端没开压缩
nginx 默认的 gzip on 只对 text/html 生效,gzip_types 不含 application/javascript,所以所有 JS 裸传。
根因 2:重型依赖被打进首屏
即便压缩了,首屏依然有十几 MB 的原始体积 。这个应用依赖了一堆区块链 SDK:ethers、@mysten/sui、@ton/ton、@ledgerhq/*、@suiet/wallet-kit......动辄几百 KB 到几 MB。
路由层虽然用了 React.lazy 懒加载,但入口文件 App.tsx 在顶层同步引入了整套钱包栈:
tsx
// App.tsx ------ 这是入口,每次都会加载
import { WalletProvider, useWallet } from "@suiet/wallet-kit";
import { setWalletAdapter } from "@org/wallet-lib";
结果:用户哪怕只是打开登录页,也要下载 sui / ton / ledger 全套 SDK。
根因 3:splitChunks 配置把异步包"拽"进了首屏
js
splitChunks: {
maxInitialRequests: 3, // 太小!
maxAsyncRequests: 5, // 太小!
// ...
}
请求数上限过低,Webpack 为了不超限,会把本该独立的异步 vendor chunk 合并进首屏的初始 chunk 。于是只被懒加载路由用到的 @ton、zod 等也跑到了首屏。
三、对症下药
手段 1:开启压缩(构建预压 + 服务端下发)
构建时预压缩 :用 compression-webpack-plugin 在打包时生成 .gz 和 .br,用最高压缩级别(gzip 9 / brotli 11),运行时零 CPU。
js
// webpack.production.js
const zlib = require("zlib");
const CompressionPlugin = require("compression-webpack-plugin");
const test = /\.(js|css|html|svg|json|wasm)$/;
module.exports = merge(common, {
plugins: [
new CompressionPlugin({ filename: "[path][base].gz", algorithm: "gzip", test, threshold: 1024, minRatio: 0.8 }),
new CompressionPlugin({
filename: "[path][base].br",
algorithm: "brotliCompress",
test, threshold: 1024, minRatio: 0.8,
compressionOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } },
}),
],
});
服务端下发 :光生成不够,nginx 要用 *_static 指令命中预压文件。
nginx
gzip_static on; # 下发 .gz,Ubuntu nginx-core 默认支持,无需重编译
gzip on; # 动态 API/HTML 实时压缩兜底
gzip_types application/javascript application/json text/css image/svg+xml;
关于 brotli:
brotli_static需要ngx_brotli模块,apt 装的 nginx 默认没有,要重编译。不值得 ------gzip 已经拿到 90%+ 收益(首屏 gzip 0.97MB vs brotli 0.67MB,只差 0.3MB)。真想要 brotli,最省事的是挂 CDN(如 Cloudflare),由边缘自动做 ,源站只管gzip_static。
构建时压缩 vs 运行时压缩 :不是二选一,而是配合。静态产物用构建预压 + gzip_static(压缩率高、零 CPU);动态响应(API JSON)只能靠 gzip on 实时压。
手段 2:把重型依赖移出首屏
关键是找到懒加载边界 。登录 / 注册 / 忘记密码这些页面根本不连钱包,于是把 WalletProvider 从全局入口 App.tsx 下放到登录后的布局 PageLayout(它本身就是懒加载的,且跨路由保持挂载):
tsx
// App.tsx ------ 去掉钱包栈,只保留必要的 Provider
export const App = () => (
<ConfigProvider>
<Suspense fallback={<Loading />}>
<RouterProvider router={Routers} />
</Suspense>
</ConfigProvider>
);
// layout/index.tsx(登录后才加载)------ 钱包栈搬到这里
const PageLayout = () => (
<WalletProvider>
<WalletAdapterSync />
{/* ...布局... */}
</WalletProvider>
);
效果:ethers / @mysten/sui / @org/wallet-lib 全部移出登录页,登录后进入业务页才异步加载。
手段 3:警惕 CJS 包的 import type 陷阱
排查中发现一个工具文件被全站引用,它顶部有一行:
ts
import { WalletType } from "@org/wallet-lib"; // 其实只当类型用
而 @org/wallet-lib 是个纯 CJS 包 (package.json 里 main: dist/cjs/index.js,没有 module 字段,没有 sideEffects)。这意味着 Webpack 无法对它 tree-shake ------任何一处普通的 import { X } 都会把整包(含 ethers/ton/ledger)拖进对应 chunk。
修复非常简单,但收益巨大:
ts
// 改成 import type,编译后这行被完全擦除,不产生运行时引用
import type { WalletType } from "@org/wallet-lib";
// 完全没用到的,直接删掉
经验:被广泛引用的
utils/types文件里,凡是只当类型用的导入,一律import type。尤其当依赖是不可 tree-shake 的 CJS 包时,一行死导入就能把几百 KB 灌进首屏。
手段 4:splitChunks 调优
js
optimization: {
moduleIds: "deterministic", // 生产环境用确定性 id,缓存更稳
splitChunks: {
chunks: "all",
maxInitialRequests: 30, // 调高,避免异步包被合并进首屏
maxAsyncRequests: 30,
cacheGroups: {
react: { test: /[\\/]node_modules[\\/](react|react-dom|react-router.*|@reduxjs\/toolkit)[\\/]/, name: "react", priority: 20 },
// 重型链上 SDK:强制只进异步 chunk,禁止进入首屏
blockchain: {
test: /[\\/]node_modules[\\/](@ton|ethers|@mysten|@ledgerhq|...)[\\/]/,
name: "blockchain",
chunks: "async", // 关键:只对异步 chunk 生效
priority: 25,
},
antd: { test: /[\\/]node_modules[\\/](antd|rc-[^\\/]+|@rc-component)[\\/]/, name: "antd", priority: 15 },
vendors: { test: /[\\/]node_modules[\\/]/, priority: -10 },
},
},
}
两个易踩的坑:
maxInitialRequests别设太小,否则异步包被合并进首屏(这正是本次根因 3)。HTTP/2 下多几个请求无所谓,30 完全 OK。- 别滥用
maxSize。一开始我加了maxSize: 500KB,结果把 antd 拆成了 17 个小块------它们总是一起变更,拆开对缓存毫无收益,反而平添 16 个请求。去掉后 antd 保持单块,从 17 个文件降回 1 个。
番外:怎么知道首屏里到底有什么?
不要靠猜。生成生产构建的 stats,再脚本化分析"哪些包在 initial chunk 里":
bash
webpack --config webpack.production.js --json stats.json
js
// 解析 stats.json:找出所有 initial chunk,按包聚合体积
const stats = require("./stats.json");
const initial = new Set(stats.chunks.filter(c => c.initial).map(c => c.id));
const pkgSize = new Map();
for (const m of stats.modules) {
if (!m.chunks?.some(id => initial.has(id))) continue;
const pkg = /node_modules\/((@[^/]+\/)?[^/]+)/.exec(m.name)?.[1] || "(app)";
pkgSize.set(pkg, (pkgSize.get(pkg) || 0) + m.size);
}
console.log([...pkgSize].sort((a,b)=>b[1]-a[1]).slice(0,20));
更进一步,用模块的 reasons(谁引用了它)反查"为什么这个包还在首屏"。本次就是靠它定位到:某个第三方组件库在自己的 barrel 入口里静态引用了 @ton,导致这部分始终无法移出首屏------属于库自身的打包问题 ,需要在库侧做代码分割或开 sideEffects: false。
四、成果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首屏入口(未压缩) | 10.3 MiB | brotli 2.8MB (↓87.5%)/ gzip 3.1 MiB(↓86%) |
| 重型链上 SDK | 全在首屏 | 移出,按需异步加载 |
| 首屏 JS 文件数 | 少量超大块(3-4MB) | 6 个合理大小的块 |
优化后本地预览(启用压缩)的 Network 面板,首屏 JS 实际传输约 781 KB ,Load 仅几百毫秒: 优化前 见 1.1
优化后:首屏 JS 传输约 781KB,单个 bundle 经 brotli 压缩后大幅缩小

gzip

五、方法论总结
一套可复用的首屏优化清单:
- 先量后改 :Network 面板看
transferred vs resources,curl抓Content-Encoding,两分钟定位是不是压缩问题。 - 压缩是性价比最高的一步 :构建预压(gzip 9 / brotli 11)+ 服务端
gzip_static,或直接挂 CDN 让边缘做。静态预压 + 动态实时压配合用。 - 守住懒加载边界:重型依赖别在全局入口同步 import;按"未登录 / 已登录""核心页 / 边缘功能"切分懒加载点。
import type+ 警惕 CJS :类型导入一律import type;不可 tree-shake 的 CJS 包,一行死导入就能拖垮首屏。splitChunks别想当然 :maxInitialRequests给够,重型库用chunks: "async"隔离,慎用maxSize。- 用 stats + reasons 精准定位,而不是凭感觉拆包。
- 缓存别忘 :带 hash 的产物
immutable长缓存,index.html用no-cache。
性能优化没有银弹,但有方法论:先定位瓶颈,再按收益排序逐个击破,每一步都用数据验证。