最近接手了一个维护了三年的 React 老项目,用户反馈首屏白屏时间越来越离谱,我随手 build 一次,产物里光 JS 就有接近 6MB。团队之前一直用"换个网络环境试试"来掩盖问题,直到要发新版本,连本地开发都明显卡顿,这才决定认真做一次打包优化。整个分析和动手的过程,核心工具就是 webpack-bundle-analyzer,前后花了一周多时间,产物体积降了约 65%,首屏加载从 4 秒多压到了 1.8 秒左右。这篇文章把我这次的完整思路、操作步骤和踩过的坑记录下来,如果你手上也有一个"能跑但越来越慢"的 React 老项目,正打算做打包优化,可以直接照着这条路线走。
1. 老项目动刀之前的三个准备工作:先看清现状,再想怎么优化
很多同学拿到老项目就直接装 webpack-bundle-analyzer、生成个报告,然后对着报告一顿乱拆,拆完发现构建报错、线上白屏、缓存全失效。我这次学乖了,动手前先做了三件事,这几步几乎决定了后续优化能不能顺利落地。
1.1 锁定项目当前的构建工具链版本
老项目最麻烦的地方在于,你不知道它在哪一年突然停更过。所以第一步不是装插件,而是先确认 webpack 和 React 的版本,这两个版本直接决定你能用哪些优化手段。
我一般这样排查:
- 看
package.json里的devDependencies,确认webpack主版本; - 执行
webpack --version确认命令行实际使用的版本; - 在
package.json里确认react和react-dom版本,这关系到能不能用React.lazy做路由懒加载; - 检查 webpack 配置里有没有
DllPlugin、CommonsChunkPlugin这类"历史遗留方案"。
这里有个容易忽略的点:webpack 3 时代流行的CommonsChunkPlugin,在 webpack 4 里已经废掉了,如果你项目里还有这个配置,同时又想用optimization.splitChunks,运行时会有冲突或者直接报错。我接手这个项目时,配置文件里就同时存在CommonsChunkPlugin和一堆手写的externals,这些都是早年用 CDN 方式引第三方库留下的,需要先理清楚哪些还生效,哪些其实已经是死代码。
另外,React 版本决定了懒加载方案。React 16.6 之前没有React.lazy,只能用react-loadable或者自己写高阶组件做异步加载。我的项目是 React 16.8,后来用React.lazy + Suspense比较顺。如果你手里的老项目还在 React 15.x,别急着抄后面的代码,得先解决 React 版本升级,或者改用react-loadable。
1.2 建立优化前的数据基线:没有基线,后面所有优化都没有说服力
第二件事,是在任何优化动作之前,把"优化前"的各项数据记录下来。这不是走形式,而是整个优化工程里最重要的参照物。没有基线,你拆完包之后说"感觉快了很多",那跟"换个网络环境试试"有什么区别?
我用 Chrome DevTools 调成 Slow 3G 网络,用无痕窗口打开线上页面,记录以下几项:
| 指标 | 记录值 | 说明 |
|---|---|---|
| 首屏请求的 JS 资源总大小 | 通过 Network 面板的 Transfer Size 合计 | 这是用户真实下载的字节数 |
| 首屏请求数 | Network 面板统计 | 老项目常见二三十个请求 |
| DOMContentLoaded 时间 | Performance 面板 | 粗略反映 HTML+脚本执行完的时间 |
| FCP(首次内容绘制) | Lighthouse 或 Performance | 用户感知到页面"有东西了"的时刻 |
| 构建产物体积总和 | webpack --profile或 build 输出 | 本地产物总大小 |
我当时记录的基线数据是这样的:首屏 JS 资源 transfer 大小约 1.9MB(gzip 后),请求数 27 个,FCP 在 Slow 3G 下是 4.2 秒。这些数字写下来之后,后面每做一步优化,都可以对照着看是否真的有效,避免"自我感觉良好"。
这里要额外提醒一句:测量工具本身会带来干扰。比如 Chrome DevTools 的 Network 面板如果开着缓存禁用,测出来的数字会偏大。建议统一用无痕窗口,并且固定设备模拟档位,保证前后对比在同一个环境下进行。
1.3 别急着清依赖:先整体过一遍 package.json 的重复依赖
老项目的依赖,几乎都是"能用就行"堆出来的。我在优化前先执行了npm ls --depth=0和npm ls lodash,发现项目里同时存在lodash和lodash-es,还有两套版本相差很大的moment(一个是业务代码直接用,另一个是被某个内部组件库间接依赖的)。这种重复依赖如果不提前发现,优化到一半很容易被"为什么拆了这个库包还是这么大"卡住。
这一步不需要把依赖全部理清,但至少要回答三个问题:项目里有没有同名不同版本的库?有没有功能重叠的库(moment和dayjs同时存在)?有没有通过externals从 CDN 引入的库?这三个问题的答案,会直接影响后面 splitChunks 的 cacheGroups 怎么设计。
2. 接入 webpack-bundle-analyzer:两种方式,各有利弊
工具接入本身不难,难的是选对方式。我在这个项目里两种方式都试过,一种是在 webpack 配置里直接挂插件,另一种是用stats.json配合命令行独立分析。下面把细节和适用场景都讲清楚。
2.1 方式一:作为 webpack 插件集成,构建完自动打开报告
最直接的方式,就是在 webpack 配置文件里加一个插件实例。我通常不会直接写死在生产配置里,而是用一个环境变量控制,避免团队每次构建都弹出浏览器窗口。
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); module.exports = { // ... 其他配置 plugins: [ process.env.ANALYZE ? new BundleAnalyzerPlugin({ analyzerMode: 'server', // server 模式会启动本地服务并自动打开浏览器 analyzerHost: '127.0.0.1', analyzerPort: 8888, reportFilename: 'bundle-report.html', openAnalyzer: true, generateStatsFile: false, // 如果只需要报告,不必生成 stats.json }) : null, ].filter(Boolean), };然后在package.json里加一条脚本:
{ "scripts": { "build:analyze": "cross-env ANALYZE=1 webpack --config webpack.prod.config.js" } }这样执行npm run build:analyze就会启动一个本地服务,浏览器自动打开127.0.0.1:8888,展示可视化的依赖树形图(treemap)。每个方块代表一个模块,方块越大,说明该模块占用的体积越大,颜色深浅则代表是否为 gzip 压缩后的大小。
插件方式的好处是集成简单,适合团队里所有人都能一键跑分析的场景。但它的缺点也很明显:BundleAnalyzerPlugin会作为 webpack 插件参与构建,虽然不影响产物,但会在构建过程中增加额外的统计开销,构建时间会长一些。而且如果 webpack 配置特别复杂,比如有多个环境配置文件,你需要确保插件加在了正确的那个配置文件里。
2.2 方式二:用 stats.json 配合命令行,不污染业务配置
第二种方式是我比较推荐的,尤其适合老项目——因为它完全不动 webpack 配置。webpack 本身就支持导出整个构建过程的 stats 信息,导出成 JSON 文件后,用webpack-bundle-analyzer这个命令行工具直接分析。
# 先构建并导出 stats 数据 webpack --config webpack.prod.config.js --json --profile > stats.json # 再启动分析器 npx webpack-bundle-analyzer stats.json这种方式有几个实际好处:
- 不需要在项目代码里引入任何插件,不影响正常构建;
stats.json是构建的完整快照,包含模块依赖、体积、耗时等信息,后续做对比分析时可以直接复用;- 可以配合 CI 流程,把每次构建的
stats.json归档,形成体积趋势图。
要注意的是,--json输出的文件很大,我这个项目大概 40 多 MB,所以用完记得从项目目录里删掉,或者用.gitignore排除。另外,如果.babelrc或tsconfig里配置了缓存,--json导出的是实际构建结果,不受缓存影响,这点可以放心。
2.3 拿到报告之后,先看这四个地方再动手
报告生成后,很多人的第一反应是盯着最显眼的那个大色块,准备开始拆它。我的建议是,先快速过四个关键点,这样你脑子里对项目整体的构成能有一个完整的图景:
- 看
parsed size还是gzip size。双击某个色块可以切换展示模式。parsed size是未压缩的原始大小,gzip size是压缩后的传输大小。判断是否值得优化时,应该以 gzip 为主要参考,因为线上服务器通常开了 gzip。 - 看入口 chunk 的大小分布。把报告左侧的 chunk 列表展开,关注哪些 chunk 是首屏加载时就要请求的(entry chunk),哪些是路由懒加载之后才会请求的(async chunk)。
- 看有没有异常大的单模块。有些库本身不算大,但因为引入了所有语言包、所有主题,体积会成倍膨胀,这类问题非常适合定向处理。
- 看重复模块。如果同一个库名出现在多个 chunk 里,说明业务代码对它的引用方式有问题,可能是按需引入没生效,也可能是 cacheGroups 没有正确聚合。
我当时看完报告,最直观的感受就是:这个项目不是"某一个库太大",而是"每一个库都没被好好控制"。这也为后面的优化定下了基调——不是做一两个大改动,而是系统性地把每一类依赖都重新过一遍。
3. 报告暴露出来的问题:React 老项目的五个典型通病
我的项目报告里,vendor.js这一个 chunk 的 parsed size 就达到了 4.6MB。如果你现在也正对着一份类似的报告发愁,不用慌,下面这五个问题在 React 老项目里几乎是"标配",而且都有成熟的解法。
3.1 全量引入 UI 组件库和图表库
我的项目里antd的 parsed size 是 1.8MB 左右。为什么这么大?因为业务代码里写的是import { Button } from 'antd',看似是按需引入,但如果 babel 没有配babel-plugin-import,这条语句最终会被编译成var Button = require('antd'),也就是把整个antd全部加载进来。本质原因就是:import { Button } from 'antd'这个语法本身具备 tree-shaking 的可能,但前提是antd的 package.json 里配置了sideEffects: false或module入口,而且 babel 转译时不能把模块系统直接转成 CommonJS。
图表库也是这样。项目里用了echarts,业务代码是import * as echarts from 'echarts',这等于把 echarts 全部图表类型、渲染器和组件都带上了,parsed size 超过 1MB。正确做法是echarts/core按需引入需要的图表和渲染器,这个在后面 4.3 节详细讲。
3.2 moment.js 把所有语言包都打进来了
moment是 React 老项目里最典型的"体积元凶"之一。默认情况下,moment会打包全部 locale 语言文件,即便你只需要中文。报告里你会看到moment的 parsed size 超过 700KB,但其中真正用的只有一小部分。专门的 locale 文件全部打进包里,属于典型的"用不到的体积"。
这类库的优化思路有两个方向:用IgnorePlugin剔除 locale 文件,或者干脆换dayjs这种体积小一个数量级的替代库。我最后选择了后者,细节在后面单独说。
3.3 lodash 全量引入导致 tree-shaking 失效
老项目里几乎不可能没有lodash。我的项目里lodash的 parsed size 是 400KB 左右,原因是大量代码里直接import _ from 'lodash'。lodash主包是 CommonJS 格式,现代打包工具很难对它做 tree-shaking,所以最佳习惯是改为import debounce from 'lodash/debounce'这样的按需路径引入,或者配置babel-plugin-lodash自动转换。
如果你在报告里看到lodash-es而不是lodash,那又是另一种情况:lodash-es是 ES module 版本,理论上可以被 tree-shaking,但前提是你的业务代码没有被 babel 转成 CommonJS。很多老项目的.babelrc里配置了@babel/preset-env,默认会把 ES module 转成 CommonJS,这会导致lodash-es的 tree-shaking 优势完全丧失。所以排查时不能只看库本身,还要看 babel 的配置链。
3.4 polyfill 全量引入,导致基础工具函数被重复打包
React 老项目里,@babel/polyfill或core-js全量引入的情况非常多。@babel/polyfill本质上是core-js和regenerator-runtime的合集,全量引入会让每个用到新 API 的页面都背上几百 KB 的 polyfill 成本。
正确的做法是按需要的特性引入core-js中的具体模块,或者用@babel/preset-env配合useBuiltIns: 'usage'实现按需 polyfill。老项目之所以容易踩这个坑,是因为当年写import '@babel/polyfill'的时候觉得省事,后面就再也没人记得去改。
同时,如果 babel 配置里没有采用@babel/plugin-transform-runtime,babel 转译时会在每个文件里都内联一部分辅助函数,造成大量重复。这个在报告里不容易一眼看到,因为每个重复模块都很小,但积少成多后总效果非常明显。
3.5 所有的路由页面都打包进了首屏入口 chunk
React 老项目普遍没有做路由懒加载。如果你的 App 里有十几个路由页面,它们会全部打包进一个入口 chunk 里,用户访问首页时,所有页面的代码都要先下载完。报告里体现为:入口 chunk 特别大,async chunk 数目为零。这是优化优先级最高的一项,因为它的收益几乎立竿见影。
4. 按优先级动手:我实际执行的五步优化
下面按我执行的顺序,把每一步的具体操作和理由讲清楚。这个顺序不是随便排的,每一步都会影响下一步的方案选择,所以建议大家按顺序来。
4.1 第一步:用 splitChunks 把 node_modules 里的公共依赖统一抽离
这是 webpack 4 之后最基础、也是收益最大的一步。把第三方依赖统一抽成独立的 chunk,一方面减少了模块在多个 chunk 之间的重复打包,另一方面利用浏览器缓存,让用户升级业务代码时不用重新下载体积庞大的第三方库。
我当时的 splitChunks 配置大致是这样:
optimization: { splitChunks: { chunks: 'all', maxInitialRequests: 4, maxAsyncRequests: 6, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, priority: -10, name: 'vendors' }, antd: { test: /[\\/]node_modules[\\/]antd[\\/]/, priority: 10, name: 'antd' }, echarts: { test: /[\\/]node_modules[\\/]echarts[\\/]/, priority: 10, name: 'echarts' }, common: { minChunks: 2, minSize: 30000, priority: -20, name: 'common' } } } }这里有几个细节值得展开。
chunks: 'all'表示同步引入和异步引入的代码都参与拆包。如果你只写chunks: 'initial',那么动态import()引入的模块不会被拆分,可能导致懒加载的 chunk 里又重复打了一遍 React 或 antd。这个参数是最容易配错的点。
priority决定多个 cacheGroup 匹配时谁优先。antd 和 echarts 的体积大,我希望它们能单独成 chunk,这样它们的 hash 只会在自身内容变化时才变化,业务代码更新不会导致这两个大 chunk 重新下载。如果不给它们单独分组的 priority,它们会被并进vendors,那样虽然拆包总数少,但 antd 一更新整个 vendors 都失效,缓存利用率会低很多。
name字段一定要固定。webpack 4 默认会给自动生成的 vendor chunk 按数字编号命名,当依赖顺序变化时,编号会漂移,导致 chunk hash 大面积变化,这是"明明没改代码,hash 却全变了"的经典原因。我踩过这个坑后,所有 cacheGroup 都显式指定 name。
如果你是从 webpack 3 升级上来,一定要先删掉原来配置里的CommonsChunkPlugin,否则它和splitChunks会同时生效,产生大量重复的小 chunk。这一步做完,我的vendor.js从 4.6MB 拆成了antd、echarts、vendors三个 chunk,加起来反而比原来小了不少,因为里面的重复模块被剥离了。
4.2 第二步:路由级代码分割,让首屏只加载当前页面需要的代码
拆完公共依赖后,入口 chunk 依然很大,因为十几个路由页面全都打包在里面。这一步的目标是把"所有页面的代码"变成"当前页面的代码 + 运行时按需加载的代码"。
如果你的 React 版本在 16.6 以上,直接用React.lazy加Suspense:
import { lazy, Suspense } from 'react'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import Loading from './components/Loading'; const Dashboard = lazy(() => import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')); const UserManage = lazy(() => import(/* webpackChunkName: "user" */ './pages/UserManage')); const Settings = lazy(() => import(/* webpackChunkName: "settings" */ './pages/Settings')); function App() { return ( <Router> <Suspense fallback={<Loading />}> <Switch> <Route exact path="/" component={Dashboard} /> <Route path="/user" component={UserManage} /> <Route path="/settings" component={Settings} /> </Switch> </Suspense> </Router> ); } export default App;关键点在于import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')里的webpackChunkName注释,它给动态生成的 chunk 起了一个有意义的文件名,否则你会在报告里看到一堆0.js、1.js,完全没法定位是哪个页面。
如果你项目还在 React 15.x,用react-loadable:
import Loadable from 'react-loadable'; const Dashboard = Loadable({ loader: () => import('./pages/Dashboard'), loading: Loading, delay: 200, });这一步做完,优化效果非常直观。首屏入口 chunk 从一个 近5MB 的庞然大物,变成了只包含 React 运行时、路由、布局框架等公共代码的 200KB 左右 chunk。每个路由页面独立成 chunk,用户访问哪个页面就加载哪个页面的代码。
这里有一个反直觉的坑:不要对首屏默认进入的那个页面做懒加载。如果首页本身就是落地页,把首页也做成动态 import,首屏会多发起一个 HTTP 请求,反而增加延迟。我在项目里把登录页和首页放在了入口 chunk 里,其余页面懒加载。
4.3 第三步:UI 库和图表库按需引入,让 tree-shaking 真正生效
路由拆分解决了"整体过大"的问题,接下来要解决"单个库过大"的问题。先是 antd,配置babel-plugin-import后,import { Button } from 'antd'会被自动转换为import Button from 'antd/es/button',连同样式也会按需加载。
.babelrc里这样配:
{ "presets": ["@babel/preset-react", ["@babel/preset-env", { "modules": false }]], "plugins": [ ["import", { "libraryName": "antd", "libraryDirectory": "es", "style": "css" }] ] }注意preset-env里的modules: false,这个配置让 babel 保留 ES module 语法,不转成 CommonJS,这样打包工具才能在后续做 tree-shaking。如果你项目里同时用了 TS,@babel/preset-typescript也要注意同样的设置。
style: "css"表示按需加载组件对应的 css 文件。如果你的项目用的是 less 定制主题,可以把style改成true,antd 会加载 less 文件。这个改动需要注意全局样式覆盖,如果你之前靠antd/dist/antd.css引入的全局 reset 样式,改成按需后要在公共入口处手动引一次。
echarts 的处理也类似,但思路不同。echarts 5 开始支持echarts/core方式按需注册:
import * as echarts from 'echarts/core'; import { BarChart, LineChart } from 'echarts/charts'; import { GridComponent, TooltipComponent } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, CanvasRenderer]);这一步实际上是把 echarts 从一个 1MB 的大包,缩小到只包含你用到的那几种图表。我项目里主要用柱状图和折线图,配完工具提示和网格,gzip 后只有原来的三分之一左右。
lodash 的处理我选了最保守的方式:不换库,直接把全量引入改成按路径引入。import _ from 'lodash'改成import debounce from 'lodash/debounce'。如果你的代码里用了大量 lodash API,可以在 babel 里加babel-plugin-lodash自动转换,省得手动改几十个文件。但是要留意这个插件对lodash-es和 CommonJS 混用的情况处理得不够理想,所以我最后还是手动改了一部分关键模块。
4.4 第四步:对 moment.js 这种"顽固分子"下手:先裁剪,再替换
moment 的问题前面说过了:默认打包全部 locale,体积大。最省事的操作是先用IgnorePlugin把 locale 文件剔除掉:
const webpack = require('webpack'); module.exports = { plugins: [ new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), ], };这个正则的意思是:匹配包名以moment开头、导入路径是./locale的模块,直接忽略。配置之后 moment 的 parsed size 大概能从 700KB 降到 300KB 左右,因为核心库本身还是保留的。别小看这个正则,IgnorePlugin的resourceRegExp和contextRegExp两个参数的顺序很容易写反,写反后可能导致整个 moment 都被忽略,运行时报错。
如果你希望体积缩得更狠,就把 moment 整体替换成 dayjs。dayjs 的核心只有 2KB 左右,API 和 moment 高度一致。我的替换步骤是这样的:
- 在
package.json里加入dayjs,暂时保留 moment; - 先用 webpack alias 把所有
import xxx from 'moment'指向 dayjs:
resolve: { alias: { moment: 'dayjs', }, },- 全局搜索业务代码里所有
moment用法,逐个处理 API 差异,最典型的差异是:
moment().format('YYYY-MM-DD')在两者中写法一致;moment().startOf('day')在 dayjs 里行为基本一致;moment.locale('zh-cn')需要改成import 'dayjs/locale/zh-cn'加上dayjs.locale('zh-cn');moment.isMoment()在 dayjs 里要改成dayjs.isDayjs()。
- 处理完所有业务代码后,最后再看报告确认项目里还有没有其他包(比如某个老版本 antd 或业务组件库)依赖 moment。我当时发现一个内部统计组件间接依赖了 moment 2.x,但它只是用它格式化日期,于是我把那个组件也改了。
这一步做完,moment 相关体积从 700KB 变成了 dayjs 的 7KB。甚至比很多同学在第一步做的拆包效果更明显。
4.5 第五步:输出文件加上 ContentHash,让之前拆出来的缓存真正生效
前面拆了那么多 chunk,如果输出文件名还是固定的bundle.js,那浏览器永远不知道这些文件更新了,会一直使用旧缓存。这次优化的最后一步,就是把输出文件改成带内容 hash 的形式。
output: { filename: '[name].[contenthash:8].js', chunkFilename: '[name].[contenthash:8].js', },[contenthash]是根据文件内容生成的 hash,文件内容变化时 hash 才变化。加上这个之后,业务代码更新时,只有业务 chunk 的 hash 会变,antd、echarts、vendors 这些第三方 chunk 的 hash 保持不变,浏览器可以直接走缓存。
但这里有一个 webpack 4 特有的坑:模块的 id 默认是自增数字,只要新增或删除一个模块,所有模块的 id 都可能变化,导致许多本来没变的 chunk 的 hash 跟着变。解决方式是加上optimization.moduleIds: 'hashed',让模块 id 基于模块路径生成,内容路径不变 id 就不变。webpack 5 已经默认用deterministic方案,不需要手动配。
optimization: { moduleIds: 'hashed', },这个配置本身其实也是优化的一部分。很多人只加了[contenthash]却漏了moduleIds,发现 hash 还是到处变,以为配置无效,其实问题就出在模块 id 不稳定。
另外,如果服务器还没开 gzip,这一步建议一并处理。最稳妥的方式是让运维在 Nginx 层开启 gzip 或 brotli;如果不方便改服务器配置,也可以用compression-webpack-plugin在构建时直接生成.gz文件,让服务器直接返回压缩文件。开启 gzip 后,JS 体积大概能再缩小 60% 到 70%,效果非常可观。
5. 拆包优化之后的隐藏坑:缓存稳定性、请求数与验证方式
优化做完不等于万事大吉。我这次在收尾阶段又踩了几个坑,都是拆包之后才会暴露出来的问题,专门写一节提醒后来的同学。
5.1 拆包的稳定性直接决定缓存命中率
拆包方案确定之后,最重要的一件事是保证"每次构建,相同的代码生成相同的 chunk 和 hash"。否则哪怕你没改代码,重新构建出来的 hash 也变了,缓存全部失效,优化等于白做。
保证稳定性的关键有三个:
- cacheGroup 里的
name必须显式指定,不要用 webpack 自动生成的数字编号; - 配置
optimization.moduleIds让模块 id 稳定; - 动态
import()必须在代码里用静态字符串路径,不要用变量拼接路径,否则 webpack 没法确定 chunk 边界,可能生成不可预测的 chunk。
我建议在优化完成后,连续构建两次,对比两次dist目录里的文件名是否完全一致。如果两次 hash 不同,说明配置里还有不稳定因素,不要急着上线。
5.2 拆得太碎,首屏请求数反而拖累加载
拆包有个陷阱:不是越细越好。HTTP/1.1 时代浏览器对同域名的并发请求数限制在 6 个左右,如果你把业务代码拆成三十个小 chunk,首屏要排队下载,反而更慢。即便现在普遍用 HTTP/2,每个请求也有 header 和连接开销,请求数过多依然会拖慢首屏。
我在这个项目里尝试过把每个页面里的业务模块进一步拆成细粒度 chunk,结果首屏请求数从十几个变成三十几个,FCP 反而从 1.8 秒涨回 2.1 秒。后来我把maxInitialRequests设置为 4,把首页相关的几个核心模块合并到一个 chunk 里,才回到理想状态。
如果你的服务器不支持 HTTP/2,尤其要注意不要拆太碎。老项目有时还挂着某种内网环境的旧浏览器,请求并发限制更严格,宁可单个 chunk 大一点,也别让首屏请求数失控。
5.3 优化效果的验证:同时看体积数字和真实加载表现
最后验证阶段,我又把 webpack-bundle-analyzer 生成了一份新的报告,和优化前的报告对比。同一份stats.json也可以直接复用,跑一次webpack-bundle-analyzer打开旧文件和 新文件,看颜色块的面积变化,非常直观。
我优化前后的关键数据对比:
| 项目 | 优化前 | 优化后 |
|---|---|---|
| 总构建产物 parsed size | 约 6.8MB | 约 2.4MB |
| gzip 后总传输体积 | 约 1.9MB | 约 700KB |
| 首屏 chunk 数量 | 1 个(所有页面都在一起) | 4 个 |
| FCP(Slow 3G) | 4.2 秒 | 1.8 秒 |
| 白屏时间(用户反馈) | 3 秒以上 | 基本感觉不到 |
但是要特别注意:构建产物小了,不一定代表线上真实体验就快了。还要在线上环境重新测一遍 FCP、LCP、请求数,用前面同样的网络条件。我见过有同学本地构建产物很小,但上线后因为服务器没开 gzip、或者某些 chunk 被错误的缓存策略缓存了,体验反而更差。所以验证一定要以真实线上环境为准。
回到开头说的那个问题:老项目不是不能优化,而是要有方法、有顺序、有验证。webpack-bundle-analyzer 的价值,在于把"我觉得项目很慢"变成"我知道项目慢在哪个模块",所有决策都建立在数据之上。顺着报告暴露的问题,按顺序解决,每一步改动都能在下一份报告里看到反馈,这才是打包优化最踏实的打开方式。
最后再分享一个我在收尾时保留的习惯:我把 webpack-bundle-analyzer 接进了 CI 的一个可选任务,每次发版前指定跑一次,报告以 HTML 形式归档。几个月后回头翻,就能看到项目体积的走势。只要新引入的依赖让体积明显反弹,下一次报告里立刻能看出来。这种"让数据持续说话"的做法,比任何一次性的优化都更管用。