首页 / 资讯中心 / 文章详情

React打包体积优化:webpack-bundle-analyzer实战记录

React打包体积优化:webpack-bundle-analyzer实战记录 ★ FEATURED ARTICLE
接手这个活儿的场景先描述一下一个跑了三年多的 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 模块用了axios0.19B 模块的某个子依赖又锁定了axios0.21webpack 的 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 ANALYZERtrue webpack --config webpack.config.js --mode production } }如果你的老项目还在用 Windows 跑构建cross-env必须加否则ANALYZERtrue在 cmd 和 PowerShell 下会直接报环境变量语法错误这个坑我踩过。Linux 和 macOS 的 CI 机器上则可以直接用ANALYZERtrue。这里的原理是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 lodashwebpack 会把整个 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 包里每个函数都有自己的独立入口文件你直接从子路径 importwebpack 就只打包那个入口文件对应的一小段代码。类似的效果还可以用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 行就能跑起来。然后按需加载 localeimport 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 srchttps://cdn.example.com/react/17.0.2/umd/react.production.min.js/script script srchttps://cdn.example.com/react-dom/17.0.2/umd/react-dom.production.min.js/script script srchttps://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同时你的业务代码里用了 JSXbabel 编译后默认还是会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编译成requireTree Shaking 直接失效。很多老项目体积大就是死在这个细节上——babel 配置项里写着裸的babel/preset-envmodules 默认是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?limit4096, 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: gzipcurl -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-age31536000, 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: 3000030KB如果某个模块只有 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-analyzer4.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 结构。这样当某天产品说“最近页面怎么慢了好多”时你打开报告扫一眼就能在五分钟之内说出准确答案而不是跟着一起猜。
阅读完成 · 觉得有帮助?
咨询建站