接手这个活儿的场景先描述一下:一个跑了三年多的 React 老项目,路由二十几个,业务模块一堆,Webpack 还是 4.x 的老配置,每次npm run build都要两分钟起步,首屏加载白屏时间能让人喝半杯水。代码写得久了,依赖也叠了不少,没人知道最终打出来的 bundle 里到底装了什么。直到某天我在 devtools 里看到那个 3.8 MB 的 app.js 时,才意识到这已经到了必须动手的地步。我用的第一个工具就是 webpack-bundle-analyzer,一个把打包产物解剖得明明白白的可视化分析插件。这篇文章记录的就是我用它给老 React 项目做打包优化的完整过程,包括怎么接进去、怎么看报告、怎么定位问题、怎么逐层把体积砍下来,以及那些文档里不会写但实际一定会踩的坑。适合手里有 React 老项目、被构建体积和首屏速度困扰的同学参考。
1. 为什么老项目的打包体积会失控
老项目的问题从来不是“某一个依赖太大”,而是“没人知道整个 bundle 里到底装了什么”。新项目初期大家还比较克制,但随着时间推移,团队更替、需求堆叠,依赖只增不减,配置越来越乱,打包体积就成了一个被慢慢默认接受的包袱。这一步先把问题的根源拆开看。
1.1 老项目的典型“增肥”路径
一个 React 技术栈的老项目,体积膨胀通常不是某个单一原因造成的,而是沿着几条固定路径一点一点涨上去的。
第一类是第三方库被整体引入。最典型的就是import * as _ from 'lodash'、import moment from 'moment',或者import { Button } from 'antd'这种写法。ES Module 的 Tree Shaking 在 Webpack 4 里已经默认开启了,但对 CommonJS 风格的库、对带有副作用的库,摇树效果非常有限,打包后该带上的代码全给你带上。很多老项目用的是 antd 3.x、lodash 4.x、moment 2.x,这几个库单个压缩后都还在 100KB 以上,能进前三名。
第二类是重复依赖。业务代码多了以后,不同的子模块经常各拉一份自己的依赖树。比如 A 模块用了axios@0.19,B 模块的某个子依赖又锁定了axios@0.21,webpack 的 resolve 逻辑会在 node_modules 里分成两个目录来装,打包时也是两份代码各自进包。表面看package.json里没有重复声明,但体积分析会告诉你axios出现了两份。
第三类是业务代码没有做代码分割。老项目最常见的形式就是“一个入口、整棵路由树全部静态 import”。Webpack 配置里没有做splitChunks,或者做了但只分了vendor,结果就是所有业务模块全都塞进同一个 chunk,首屏加载一个 3MB 的 JS 文件,用户网络稍微差一点就是几秒白屏。
第四类是图片和静态资源处理不当。早年很多人喜欢把 UI 切图直接import logo from './logo.png'然后塞进代码里,小图没转 base64,大图也没做压缩和懒加载。Webpack 的 asset 配置里如果没有合理的limit值,几百 KB 的图片就会直接作为独立文件发出,或者全部内联到 bundle 里。
这几条路径叠加起来,打包体积翻倍几乎是可以预见的。别急着骂项目垃圾,这其实是绝大多数业务项目的真实演变轨迹,没有专门的工程化负责人在持续维护,体积就是会这么涨。
1.2 体积大不只是数字难看,它会直接影响线上体验
很多同学觉得“体积大点就大点,反正用户有宽带”,大错特错。bundle 体积和用户体验之间不是线性关系,而是指数级的挫败感。
首屏加载时间是最直接的受害者。一个 3MB 的 gzip 前 JS 文件,gzip 后大概也有 800KB 到 1MB,加上请求并发限制和网络握手,3G 网络下用户首屏秒开基本无望,4G 下也要等个两三秒。移动端浏览器解析和执行大 JS 的耗时也很可观,老手机低端机型尤其明显。
缓存策略也会被体积拖累。如果你把业务代码和第三方库打在一起,只要业务代码有一个字节的改动,整个文件 hash 就变了,用户就得重新下载所有代码。大 chunk 会直接拉低回访用户的命中率,等于把“二次访问快多了”这个天然优势也干掉了。
构建速度同样受影响。webpack 对 3MB 的 bundle 做压缩和代码生成时,CPU 占用会非常夸张,CI 流水线里每次构建多出来的一两分钟都是可以被量化成成本的。优化打包体积,表面上是在处理产物,实际上连 CI 耗时、开发体验、部署频率这些环节全都一起被改善了。
这个项目我接手时先做的第一件事,就是看build之后生成的dist/static/js/目录。那个app.xxx.js文件的大小直接告诉了我问题的严重程度。没有工具辅助的时候,人眼只能看出“大”,看不出“哪里大”“为什么大”,于是 webpack-bundle-analyzer 就该出场了。
2. webpack-bundle-analyzer 的接入与原理
工具本身的配置很简单,但如果不理解它背后是怎么工作的,分析报告拿在手里也不知道怎么读。这块先把它拆清楚。
2.1 它是怎么“看穿”打包产物的
webpack-bundle-analyzer 的本质是一个静态分析器,它监听 webpack 的emit阶段,在打包完成后读取 webpack 的 stats 数据。webpack 在完成编译时,会生成一份包含所有模块信息、chunk 信息、依赖关系的统计对象,里面记录了每个模块的路径、所属 chunk、模块大小、各 chunk 之间的关系等结构化数据。
插件做的三件事是:
- 解析这份 stats 数据,整理出模块到 chunk 的映射关系。
- 计算每个模块在不同 chunk 中被哪些入口引用、是否被重复打包。
- 在前端用 tree-map 的方式把数据可视化,模块的体积通过矩形面积呈现,嵌套关系则用颜色和层级体现。
它有两种主要输出方式:一种是启动一个本地 HTTP 服务,自动打开浏览器展示交互式分析报告;另一种是生成一个静态 HTML 文件,可以保存到服务器或 CI 产物里,随时打开查看。老项目里我推荐后者,因为交互式服务需要你保持终端不退出,而静态 HTML 可以留存在构建产物里,每次都生成一份,方便前后对比和追溯。
插件算出的大小默认是模块的“原始大小”(未压缩)。如果你mode: 'production'下开了压缩,报告里还有 gzip 前后对照值,切换按钮在侧边栏。我在分析老包的时候习惯同时看原始大小和 gzip 大小,因为有些库压缩前后体积差异巨大,分析定位到具体库之后再决定值不值得替换。
2.2 老项目里怎么最低成本地接入并保证不动线上
老项目接入工具的第一原则:不要为了分析而改动业务代码,也不要在分析阶段破坏原有构建流程。最稳妥的方式是用环境变量控制,只在分析时开启插件,正常构建时完全不影响。
npm install --save-dev webpack-bundle-analyzer然后在webpack.config.js里加一段分支逻辑:
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const isAnalyzer = process.env.ANALYZER === 'true'; module.exports = { // ... 原有配置保持不变 plugins: [ // ... 原有插件 isAnalyzer && new BundleAnalyzerPlugin({ analyzerMode: 'static', reportFilename: 'report.html', openAnalyzer: false, generateStatsFile: true, statsFilename: 'stats.json', logLevel: 'info' }) ].filter(Boolean) };注意filter(Boolean)这步,防止isAnalyzer为 false 时往 plugins 数组里塞一个false导致 Webpack 报错。generateStatsFile: true会把 stats.json 也生成出来,这个文件后续做自动化体积对比脚本时会非常有价值,如果你只想看报告,可以关掉它省一点构建时间。
然后在package.json的 scripts 里加一条专用命令:
{ "scripts": { "build:analyzer": "cross-env ANALYZER=true webpack --config webpack.config.js --mode production" } }如果你的老项目还在用 Windows 跑构建,cross-env必须加,否则ANALYZER=true在 cmd 和 PowerShell 下会直接报环境变量语法错误,这个坑我踩过。Linux 和 macOS 的 CI 机器上则可以直接用ANALYZER=true。
这里的原理是:optimization.splitChunks等优化配置只在mode: production下发挥全部作用,所以你分析时也必须用生产构建模式,才能看到和线上一致的体积结构。有些同学用npm run dev跑分析,看到的报告和线上完全两个样,因为 development 模式根本不会做代码分割和压缩。
命令跑起来后,dist/目录里多了一个report.html。把它在浏览器里打开,你会看到一张密密麻麻的色块图。别慌,下一步就是教你怎么读懂它。
3. 从分析报告里读出真实问题
报告一打开,大多数人第一反应都是“这些彩色方块是什么玩意”。而且老项目的报告通常还有个特点:靠左上角的几个大块头特别显眼,右边的长条色的区块堆得很密。这些视觉信息背后其实映射了明确的工程问题。
3.1 报告页面上的元素到底在说什么
整个界面可以分成三块信息:
左侧树状图就是模块分布的可视化表示。每个矩形代表一个模块,矩形面积越大,说明这个模块在 bundle 里占用的体积越大。嵌套矩形代表模块经由它引用的子模块,颜色深浅帮助区分不属于同一目录的依赖。点击某个矩形,右侧会联动显示它的详细路径、所属 chunk、原始大小和压缩后大小。
侧边栏的 top-level modules 列表会按体积排序展示所有顶层模块,这个列表甚至比树状图本身更有用。老项目里我基本是直接看这个列表的前 20 名,就能确定体积大头是谁。如果你想看某个模块到底被哪些 chunk 引用了,点击模块后,它会在底部列出所有引用它的 chunk 名称,同时高亮整体图里对应的位置。这对查重复依赖非常关键,因为同一个模块名出现了两次,你光靠看树状图很难发现,但点击后能看到它在两个 chunk 里各占一份色块。
右上角有筛选器(Filter),支持chunk、module、size等多种维度过滤。我常用的组合是:只看node_modules里的模块,按 gzip 后大小降序,迅速定位第三方库的体积排名;或者只看某个 chunk,排查大 chunk 内部的构成。分析老项目时,我强烈建议先按 chunk 过滤一遍,先看哪个 chunk 最大,再进这个 chunk 看内部由什么构成,思路比全局瞎点清晰得多。
报告底部还会标注每个 chunk 对应的入口文件路径和它在整个构建产物体积里的占比。老项目通常会看到 chunk 数量不多但每个都很大——这是架构层面没有代码分割的直接证据。
3.2 老项目里最常撞见的四类体积大块头
以我这个项目为例,打开报告后我看到的景象几乎可以称为老项目的经典模板。
排名第一的是react-dom本身。React 技术栈的老项目,react-dom的打包后原始体积加起来在两三百 KB 级别,gzip 后也有七八十 KB,这是框架基础,躲不掉,但你要知道它有多重,才能在做“体积预算”时心里有数。如果你在报告里看到某个依赖的体积超过了react-dom,那这个依赖本身就需要被认真审视了。
第二类是moment加它的 locale 全量代码。老项目里凡是涉及日期时间处理的,基本都直接整包引moment。它自带的几十个语言 locale 文件会被全部打入 bundle,而业务代码通常只用到zh-cn和en两种。这个属于“忠诚但烧钱”的典型。
第三类是lodash的全量引用。如果代码里到处都是import _ from 'lodash',webpack 会把整个 lodash 主模块塞进包体里,即使你只用了_.get、_.debounce这几个函数。Tree Shaking 对 lodash 这种 CJS 风格库几乎无效,本质是把五千多个函数全带上,然后大多数都躺在压缩包里吃灰。
第四类是重复打包的antd或者echarts。我在这个项目里看到echarts被打了两次,一次在appchunk 里,一次在某个子模块的异步 chunk 里。原因就是这个子模块自己又npm install了一份 echarts 到它的 node_modules 里,版本不同但接口类似,结果同样的图表库在最终产物里占了两份体积,总共接近 1MB 原始大小。这种问题报告里一眼就能看穿,因为你会看到两个面积差不多的大色块,名字都叫echarts,分别在树的不同的分支上。
还有一类不常见但一旦出现就很致命的是重复的react本身。某个组件库把react作为它的peerDependencies之外的直接依赖装了,或者被 lock 文件里锁到了不同版本,导致你的项目里存在两份 React。这种情况不光浪费体积,还往往伴随 hooks 状态错乱、组件各种诡异报错的运行时问题。分析报告里如果看到react.production.min.js出现两次,优先级最高,必须立刻处理。
这一节最后补一个实操心得:分析老项目时别盯着最大的模块看太久,先把所有超过 100KB 的模块记下来,然后逐个想三个问题——它是什么、业务里真的用到了它多少功能、有没有体积更小的替代品。这三个问题过一遍,优化的优先级自然就排出来了。
4. 优化实操:从依赖到代码逐层减负
定位到问题之后,就可以按影响面从大到小、风险从低到高的顺序动手了。优化打包体积不是一锤子买卖,而是几个层面的操作叠加起来的效果,每一刀砍下去都要有数据支撑。
4.1 第一刀:按需引入和替换重型第三方库
这一步专治 3.2 里提到的体积巨头。针对lodash,最省事的方案是改成子路径按需引入:
// 改之前 import _ from 'lodash'; _.debounce(fn, 300); _.get(obj, 'a.b.c'); // 改之后 import debounce from 'lodash/debounce'; import get from 'lodash/get';原理是 lodash 的 npm 包里每个函数都有自己的独立入口文件,你直接从子路径 import,webpack 就只打包那个入口文件对应的一小段代码。类似的效果还可以用babel-plugin-lodash做自动化转换,但这种老项目我倾向于人肉手动改,因为涉及的业务文件数量其实有限,而且自动化插件遇到奇怪写法时容易出现转换错误,还得回头排查,不如直接改清楚。
针对moment,优先替换成dayjs。dayjs 和 moment 的 API 在 95% 的常规场景下是无缝替换的,体积却只有 moment 的 1/50 左右。替换步骤很简单:先安装 dayjs,然后把代码里的import moment from 'moment'改成import dayjs from 'dayjs',再全局搜一下用到 moment 的 API——moment()、moment.format()、moment.add()这些在 dayjs 里都有同名方法,绝大多数业务文件只需要改 import 行就能跑起来。然后按需加载 locale:
import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn');如果你要处理的是老项目里更暴力的antd全量引入,需要先确定你用的 antd 版本。如果是 antd 3.x,推荐引入babel-plugin-import,在.babelrc里配置:
{ "plugins": [ ["import", { "libraryName": "antd", "libraryDirectory": "es", "style": true }] ] }这样你写的import { Button } from 'antd'会被 Babel 编译成import Button from 'antd/es/button',配合 style 属性还能自动加载组件的样式文件。不过要注意,antd 3.x 的按需加载配置到 Webpack 4 时,如果遇到样式重复引入或变量缺失问题,多半是因为没有配less-loader的 modifyVars,或者 babel-plugin-import 的 style 配置与你的 less 版本冲突。这些细枝末节最容易消耗精力,所以如果你的项目是 antd 4.x,直接走按需引入就是 Tree Shaking 自带的功能,只需要确认没有在某个地方import 'antd/dist/antd.css'整包引入样式即可。
4.2 第二刀:路由级代码分割与动态 import
这是老项目体积优化里收获最大、风险也相对可控的一步。核心思路就是不把所有路由组件一次性打进主 chunk,而是让每个路由对应的业务模块在访问时才加载。React 老项目配合react-router时,最常见的写法是:
// 改之前 import Home from './pages/Home'; import UserList from './pages/UserList'; import OrderDetail from './pages/OrderDetail'; // 改之后 const Home = React.lazy(() => import('./pages/Home')); const UserList = React.lazy(() => import('./pages/UserList')); const OrderDetail = React.lazy(() => import('./pages/OrderDetail'));Webpack 遇到import()动态语法时,会自动为每个动态导入的文件生成一个独立的 chunk,而不是把它们全塞进主 bundle。这个操作在主 chunk 大几百 KB 的老项目里效果立竿见影:首屏只加载当前路由对应的代码,其他路由的代码等用户真正跳转过去时才被请求。
配React.lazy时别忘了同时用Suspense包一层,在异步 chunk 没加载完时展示 loading 占位,否则 React 会直接抛错误:
<React.Suspense fallback={<PageLoading />}> <Switch> <Route path="/home" component={Home} /> <Route path="/user" component={UserList} /> </Switch> </React.Suspense>路由改成懒加载后,老项目的entry文件会明显瘦身,主 chunk 从几 MB 降到几百 KB。业务模块各自成包后,还有一个隐性好处:用户只访问 A 模块时,B 模块的代码永远不会被下载,流量和解析开销都省了。
有一个容易踩的坑是,如果路由组件里有某些模块被多个路由共用,webpack 会把共用模块提取到 common chunk。这时候如果splitChunks配置不对,可能反而产生很多零碎的小 chunk,每个几十 KB,数量一大也会影响加载性能。这个我在后面第 6 节的问题排查里细说。
4.3 第三刀:用 externals 把固定依赖挪到 CDN
如果你分析过后发现react、react-dom、echarts这些库的体积确实大,而且这些库在业务中非常核心、几乎不会发生破坏性升级,那么把它们放到 CDN 上可以显著减少 bundle 体积,还能利用浏览器缓存让跨页面共用。
Webpack 的externals配置就是干这个的:它告诉 webpack 不要把这些模块打进 bundle,构建产物里保留对全局变量的引用。比如在webpack.config.js里配:
module.exports = { externals: { react: 'React', 'react-dom': 'ReactDOM', echarts: 'echarts' } };然后在你的 HTML 模板里通过 script 标签从 CDN 引用这些库:
<script src="https://cdn.example.com/react/17.0.2/umd/react.production.min.js"></script> <script src="https://cdn.example.com/react-dom/17.0.2/umd/react-dom.production.min.js"></script> <script src="https://cdn.example.com/echarts/5.3.2/dist/echarts.min.js"></script>这样构建产物体积会立刻砍掉几个大块头。这里要说清楚一个限制:externals 只适用于 UMD/全局变量形式的库。如果某个库只提供 ESM 或 CJS 格式,没有在全局挂载自己名字的话,externals 就不生效。判断方法很简单——你在浏览器 console 里能不能直接访问到这个变量名,能访问就能用 externals。
但 externals 并不是所有老项目都推荐。它的代价是你必须在 HTML 里维护好 CDN script 的版本号,并且 CDN 的可用性和稳定性直接决定线上功能是否可用。我个人的建议是:只有当你确认 CDN 资源比较稳定、团队能维护版本同步时,才用 externals。否则优先采用 4.1 和 4.2 的方式,更安全。
另外一点,如果你把react放到 externals,同时你的业务代码里用了 JSX,babel 编译后默认还是会require('react'),此时 externals 的配置让 webpack 把这个请求指向全局React变量,所以不需要额外改业务代码。但要注意 webpack 4 的 externals 支持root、commonjs这种按环境区分的形式,如果配得过于复杂反而容易出错,建议保持简单的字符串映射形式。
4.4 第四刀:压缩、作用域提升与 Tree Shaking 的补全
前面的操作主要解决“打了什么”的问题,这一节解决“同一份代码怎么打得更小”的问题。老项目构建配置里,总有一些默认开启但被忽略,或者默认没开启需要手动补上的选项。
mode: 'production'下 webpack 4 会自动开启TerserPlugin做 JS 压缩,同时开启ModuleConcatenationPlugin(作用域提升),这些不需要刻意配置。但有几个点容易漏:
第一,确保optimization.minimize没有被人为关掉。老项目里偶尔会有早期为了排查问题而把压缩关闭的配置残留,比如某个开发者调试时把minimize: false写进去之后忘了改回来。如果关了压缩,产物体积会直接翻倍以上,而这个问题 analyzer 报告里的原始大小和压缩大小对比会一眼暴露:如果 gzip 前后差距非常小,说明代码压根没被压缩。
第二,补上optimization.splitChunks的合理配置。Webpack 4 默认只对异步 chunk 做splitChunks,对同步代码里的公共部分提取效果有限。老项目想控制 chunk 结构,可以显式配置:
optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /node_modules/, name: 'vendors', priority: 10, chunks: 'all' }, common: { name: 'common', minChunks: 2, priority: 5, chunks: 'all' } } } }chunks: 'all'的意思是把同步和异步模块里的公共部分都纳入缓存组的分割逻辑。vendors缓存组会把所有 node_modules 里的依赖打进一个独立的vendorschunk,这样业务代码和第三方库分离,浏览器缓存命中率会高很多。common缓存组则是把被至少两个 chunk 引用的业务模块提取到common里,避免同一段代码在不同异步 chunk 里重复携带。
第三,开启babel对具体业务场景的优化。如果你的老项目还在用@babel/preset-env,检查一下是否配置了modules: false,这个选项让 Babel 保留 ES Module 语法,webpack 才能正确执行 Tree Shaking。如果不配,Babel 会把import编译成require,Tree Shaking 直接失效。很多老项目体积大就是死在这个细节上——babel 配置项里写着裸的@babel/preset-env,modules 默认是'auto',在 Babel 7 里会对 commonjs 环境自动转译,结果 webpack 拿到的是 CJS 代码,没法摇树。
正确姿势是在.babelrc或babel.config.js里加上:
{ "presets": [ ["@babel/preset-env", { "modules": false, "useBuiltIns": "usage", "corejs": 3 }] ] }useBuiltIns: 'usage'配合corejs: 3以后,Babel 只会为用到的 ES 新 API 补充 polyfill,不会再像以前那样整个babel-polyfill或core-js全量打进包体。老项目里的@babel/polyfill如果还在用,可以删掉换这个方案,体积差距非常大。
4.5 几个立竿见影的配置小改动
这节列几个不需要大动干戈、改完立刻见效的小改动,很适合作为第一天动手时不费力气的“保底”工作。
图片资源处理。如果项目里还在用file-loader或url-loader,建议检查一下limit值,现在的 Webpack 4 中url-loader配limit: 8192会把 8KB 以下的小图转成 base64 内联到 JS/CSS 里,减少请求数。但如果你的 bundle 里因此塞入了大量 7KB 的小图,反而会增大 JS/CSS 体积。合理做法是把limit调低到 4096,并开启图片压缩 loader,比如image-webpack-loader:
{ test: /\.(png|jpe?g|gif|svg)$/, use: [ 'url-loader?limit=4096', 'image-webpack-loader' ] }注意image-webpack-loader在 CI 环境安装时依赖的imagemin可能会拉取二进制文件失败,遇到这种情况可以在 npm 配置里换镜像源,或者把它作为devDependencies单独手动安装,并确保网络环境能访问到下载地址。
关闭生产环境的 source map。老项目里如果devtool配置的是'source-map',构建产物里会生成完整的.map文件,每个几 MB 到几十 MB 不等。生产环节建议改成'nosources-source-map',这个值会在保留错误堆栈定位信息的同时,不把源码内容整个放进 map 文件,体积降一大截,安全性也更好:
module.exports = { devtool: process.env.NODE_ENV === 'production' ? 'nosources-source-map' : 'eval-source-map' };开启 gzip 预压缩。如果你用 Nginx 做静态服务,可以在构建时直接用compression-webpack-plugin生成.gz文件,然后 Nginx 配置gzip_static on;,这样服务器不用每次请求都动态压缩,直接把预压缩好的文件发给客户端,性能和体积双收益:
const CompressionPlugin = require('compression-webpack-plugin'); plugins: [ new CompressionPlugin({ test: /\.(js|css)$/, threshold: 10240, minRatio: 0.8 }) ]这个插件的原理是它会在输出阶段读取 webpack 生成的 JS/CSS 文件,用 gzip 算法预压缩一份副本。Webpack 4 时代这个插件版本要选对,v6以后的版本适配 webpack 5,老项目要锁定v5.x,否则就会遇到插件和 webpack 版本不兼容直接报错的鬼问题。
5. 优化效果验证与上线前的检查项
优化不是拍脑袋做完了事,每一步改动都要通过前后数据对比来确认收益。老项目积累多、改动面广,更要做好验证,防止优化完体积是降了、功能却崩了。
5.1 怎么科学对比优化前后的数据
第一步,在优化前就保存一份 basline 报告。我建议每次调整完都重新跑一次npm run build:analyzer,生成的report.html和stats.json按日期保存到一个目录里,比如reports/20240601/。这样几轮优化之间能随时翻旧账。
第二步,以 gzip 后体积为准对比。原始大小适合排查具体模块是否重复打包,但用户实际从网上下载的是 gzip 后的文件,所以线上收益评估用 gzip 值更贴合真实体验。看三个核心指标:
- 主入口 chunk 的 gzip 体积。
- 首屏需要加载的请求总数(chunk 数量)。
- 所有 chunk 的累计 gzip 体积之和。
第三步,用 Chrome DevTools 的 Network 面板直接观察。我优化完成后习惯在本地起一个静态服务,把dist目录模拟部署,然后用 Chrome 无痕模式打开首页,在 Network 里看首屏加载的 JS 文件列表和各自的下载耗时、解析耗时。如果看到“主 chunk 小了很多,但多出了十几个小 chunk”,说明代码分割和 splitChunks 的配置没有协调好,要结合实际做取舍。
这个项目我做了三轮优化后的数据是:主 chunk 从 gzip 后 1.2MB 降到 280KB,整站所有 chunk 累计 gzip 体积从 2.6MB 降到 1.1MB,构建时间从 130 秒降到 70 秒左右。这个结果听起来很夸张,实际就是按 4.1 到 4.5 这几步老老实实做完后的正常收益。老项目里的水分远比想象中多,只是之前一直没有人把它挤出来而已。
5.2 上线前必须检查的三件事
第一件:功能回归。路由级懒加载改动后,一定要把所有路由都手动点一遍。我之前在某个项目里改懒加载后,发现一个页面组件里存在循环依赖,导致那个异步 chunk 在加载时报错、路由白屏,在 dev 环境由于 HMR 的容错性根本发现不了,上了生产才炸——这种事故必须在上线前拦住。循环依赖用 webpack 4 的circular-dependency-plugin能提前发现,配到构建里做警告即可。
第二件:检查 CDN 资源可用性和版本一致性。如果你用了 externals 方案,千万要确认 HTML 模板里的 CDN script 版本和你 build 时锁定的版本完全一致。externals 映射的是全局变量名,万一 CDN 上的 react 是 16.x 而你项目里某些组件用了 17.x 的新 API,线上运行时就会冒出各种匪夷所思的报错。稳妥起见,把 CDN 脚本地址放到自己的对象存储上做静态托管,自己控制版本,不依赖第三方 CDN 的稳定性。
第三件:验证 gzip 或 Brotli 压缩是否真的生效。用 curl 命令模拟请求,看响应头里有没有Content-Encoding: gzip:
curl -I -H "Accept-Encoding: gzip" https://your-domain.com/js/app.xxxx.js如果 Nginx 开启了gzip_static on,返回头里应该直接带 gzip 标记,且文件大小和构建目录里预压缩文件接近。看不到 gzip 标记的话,说明服务器配置有问题,前端优化攒下的体积优势会在网络传输环节被原样返还给用户。
还有一个很容易被忽视的细节:懒加载之后,异步 chunk 的文件通常比较多,要确保静态资源服务器对 JS 文件的缓存头设置合理。Cache-Control: public, max-age=31536000, immutable这种一年期缓存只适用于带 hash 的文件名。你这个项目里 chunk 名如果是0.js、1.js这种不带 hash 的,千万别开长缓存,否则用户更新后浏览器会继续用旧 chunk 混搭新主包,页面表现会非常诡异。
6. 常见问题与排查技巧实录
老项目优化过程中遇到的问题是五花八门的,这里把我实操过程中踩过的坑和排查思路整理成速查表,方便你遇到同类状况时按图索骥。
6.1 analyzer 打不开或构建直接报错
报错场景最多的就是插件版本和 webpack 版本不匹配。webpack-bundle-analyzer 4.x 适配 webpack 4 没问题,但如果你误装了 5.x(默认最新的 npm 包),它内部依赖的 webpack 会要求 5.x,老项目直接报TypeError: Cannot read property 'tap' of undefined。排查方式很简单:npm ls webpack-bundle-analyzer查看版本,如果是 5.x,降级回npm i -D webpack-bundle-analyzer@^4.10.2,锁死版本再跑。
另一个常见问题是在配置了generateStatsFile: true后,构建输出里会生成stats.json,这个文件如果被纳入 eslint 检查的范围,会导致自定义规则误报一堆路径错误。解决的常规做法是在.eslintrc或.eslintignore里把dist目录和stats.json排除掉。
6.2 代码分割后 chunk 数量爆炸
在用chunks: 'all'配合cacheGroups后,有时会出现几十个甚至上百个小 chunk。这通常发生在node_modules里的依赖既有同步引用又有异步引用,或者缓存组的minSize设置过小的情况下。Webpack 4 默认minSize: 30000(30KB),如果某个模块只有 20KB 且被多个 chunk 引用,它宁可重复打包也不单独拆出来,避免产生太多碎文件。所以只要你没有手动改低minSize,一般不会出现特别夸张的碎片化。
真遇上了,检查重点是你的 cacheGroups 里name字段。如果很多异步 chunk 各自带了公共的依赖,缓存组没有把它们聚合起来,就会产生“每个 chunk 都重复打了一部分公共模块”的结果。把common缓存组的minChunks调低到 2,并确认name: 'common'没有写错,一般能缓解。
6.3 动态 import 导致路由切换时白屏或闪烁
React.lazy的异步 chunk 在首次加载时必然有一段网络等待时间,如果Suspense的 fallback 是空白或者样式不够明显,用户会有“卡死”的错觉。解决方向有两个:一是 fallback 用全局统一的 loading 组件,比如项目里已有的PageLoading,别用空标签;二是对首屏路由做特殊处理,首屏组件不要 lazy,直接用静态 import,避免用户打开站点那一瞬间出现闪白。
另外,如果你的路由组件内部有export default connect(...)(Component)这种写法,React.lazy 返回的是模块对象,它需要默认导出组件。如果老项目里有些组件用的是export const,动态 import 后 reslove 出来的没有default字段,就报错白屏。这时候要么改导出方式为export default,要么用const Comp = React.lazy(() => import('./Comp').then(m => ({ default: m.Comp })))做适配。这个坑在处理老项目时非常常见,因为老项目历史代码风格不一,命名导出和默认导出混着用很容易撞上。
6.4 externals 配置了却没生效
最常见的表现是:报告里模块体积确实没了,但浏览器打开页面直接报React is not defined。这基本可以断定是 HTML 模板里的 script 没有正确引入对应库,或者引入了但变量名不对。React 的 UMD 全局变量名是React和ReactDOM,如果你配的是react: 'React',那么 HTML 里也要用同名的全局变量。有人会顺手配成react: 'window.React'这种带前缀的形式,在 webpack 4 里有时反而无效,保持一个简单全局变量名最安全。
另一个隐蔽问题是脚本加载顺序。如果你把 CDN 的 react script 放在了业务 JS 后面,那浏览器执行业务代码的时候全局变量还不存在,同样会报错。HTML 的 script 顺序必须是:CDN 全局库先加载,然后才是 webpack 打包出来的带 hash 的业务 JS。用外链方式接入老 HTML 模板时,检查一下是否被某些平台模板引擎自动调整了顺序,这个容易防不胜防。
下面把这个章节里提到的典型问题整理成一个快速排查表,方便直接对照使用。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| analyzer 构建报错 | 插件版本和 webpack 版本不匹配 | 锁定webpack-bundle-analyzer@4.x |
| 打开报告后找不到模块 | 用了 development 模式构建 | 必须用 production 模式跑分析 |
| lodash 全量进包 | 顶层import _ from 'lodash' | 改成子路径按需引入 |
| moment locale 庞大 | 全量 locale 被打包 | 换成 dayjs 并按需引 locale |
| 同一依赖出现多次 | 依赖被多处锁定或重复安装 | 用npm dedupe或统一版本号 |
| 懒加载路由白屏 | 组件不是默认导出 | 用then(m => ({ default: m.xxx }))包装 |
| CDN 全局变量未定义 | script 漏引或顺序错误 | 把全局库 script 放在业务 JS 之前 |
| gzip 后报告差异不大 | 压缩被关闭或 babel 转译 CJS | 确认minimize开启,加modules: false |
| 异步 chunk 数量过多 | splitChunks 缓存组配置不当 | 检查minSize和minChunks设置 |
这个表里的每一行,都是我在这类项目里真实撞过墙之后才记住的。最大的感受是:老项目优化的难处不在于“怎么做”,而在于“做完之后验证了什么”。很多问题在本地跑得好好的,一上线就露出马脚,所以每一步之后的验证环节千万别省。
我个人现在的习惯是,每两个月固定给项目跑一次 analyzer,把报告生成一份放到 CI 产物里存着,跟两个月之前的对比一下大小走势。这不是为了给自己找活干,而是为了避免优化成果在后续的日常迭代里悄悄回退。体积管理跟代码质量一样,是一个需要持续盯着的指标,而不是一次性的活动。最后再分享一个小技巧:把report.html文件放到内部静态服务上,每次发版后自动更新,你打开浏览器随时能看线上最新版本的 bundle 结构。这样当某天产品说“最近页面怎么慢了好多”时,你打开报告扫一眼,就能在五分钟之内说出准确答案,而不是跟着一起猜。