最近接手了一个维护了三年的 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.9MBgzip 后请求数 27 个FCP 在 Slow 3G 下是 4.2 秒。这些数字写下来之后后面每做一步优化都可以对照着看是否真的有效避免自我感觉良好。这里要额外提醒一句测量工具本身会带来干扰。比如 Chrome DevTools 的 Network 面板如果开着缓存禁用测出来的数字会偏大。建议统一用无痕窗口并且固定设备模拟档位保证前后对比在同一个环境下进行。1.3 别急着清依赖先整体过一遍 package.json 的重复依赖老项目的依赖几乎都是能用就行堆出来的。我在优化前先执行了npm ls --depth0和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 ANALYZE1 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-runtimebabel 转译时会在每个文件里都内联一部分辅助函数造成大量重复。这个在报告里不容易一眼看到因为每个重复模块都很小但积少成多后总效果非常明显。3.5 所有的路由页面都打包进了首屏入口 chunkReact 老项目普遍没有做路由懒加载。如果你的 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加Suspenseimport { 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-loadableimport 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。如果你项目里同时用了 TSbabel/preset-typescript也要注意同样的设置。style: css表示按需加载组件对应的 css 文件。如果你的项目用的是 less 定制主题可以把style改成trueantd 会加载 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指向 dayjsresolve: { 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.4MBgzip 后总传输体积约 1.9MB约 700KB首屏 chunk 数量1 个所有页面都在一起4 个FCPSlow 3G4.2 秒1.8 秒白屏时间用户反馈3 秒以上基本感觉不到但是要特别注意构建产物小了不一定代表线上真实体验就快了。还要在线上环境重新测一遍 FCP、LCP、请求数用前面同样的网络条件。我见过有同学本地构建产物很小但上线后因为服务器没开 gzip、或者某些 chunk 被错误的缓存策略缓存了体验反而更差。所以验证一定要以真实线上环境为准。回到开头说的那个问题老项目不是不能优化而是要有方法、有顺序、有验证。webpack-bundle-analyzer 的价值在于把我觉得项目很慢变成我知道项目慢在哪个模块所有决策都建立在数据之上。顺着报告暴露的问题按顺序解决每一步改动都能在下一份报告里看到反馈这才是打包优化最踏实的打开方式。最后再分享一个我在收尾时保留的习惯我把 webpack-bundle-analyzer 接进了 CI 的一个可选任务每次发版前指定跑一次报告以 HTML 形式归档。几个月后回头翻就能看到项目体积的走势。只要新引入的依赖让体积明显反弹下一次报告里立刻能看出来。这种让数据持续说话的做法比任何一次性的优化都更管用。
阅读完成 · 觉得有帮助?