
接手一个已经跑了三年的后台管理系统时webpack配置基本处于“能跑就没人动”的状态。每次npm run build要等将近两分钟开发模式下改一行代码热更新平均也要6到8秒偶尔误触全量编译站起来接杯水回来它还没好。团队抱怨得多了我便决定系统做一次 Webpack 性能调优。回头看这半年最值钱的不是某个具体插件而是把构建慢的账算清楚再对症下药。这篇文章不打算把所有网上流传的优化方案都罗列一遍只讲我实际动过、并且跑出数据的地方。适合两类人一类是已经被构建速度搞烦、准备对 webpack 下手的前端另一类是刚接手老项目、想建立性能基线的同学。文中所有数字都来自我手头一个真实的后台项目冷启动构建从 117 秒降到约 23 秒热更新稳定在 2 秒左右gzip 后的产物从 851KB 压到 520KB。下面按我的实际操作顺序展开。1. 把构建慢的账算清楚先量化再动手1.1 一次冷启动的耗时不是平摊的调优之前千万别凭直觉做“我觉得是某块慢了”。我见过不少同事上来就装一堆插件结果构建时间没降多少反而引入兼容性问题。正确的第一步是量化。我当时对项目做了一次冷启动统计清空node_modules/.cache然后用webpack --profile --json --output stats.json生成完整的构建报告再丢到webpack-bundle-analyzer和线上分析工具里拆解。同时我也挂了speed-measure-webpack-pluginSMP来记录每个 loader 和 plugin 的耗时。这里要提醒一句webpack5 下 SMP 已经有点老了对某些 plugin 包装不了但拿它看 loader 耗时仍然够用如果遇到兼容问题直接看--profile生成的 stats 信息也行。统计出来的结果大概长这样阶段耗时占比模块编译loader 处理47.2s40%代码压缩terser28.5s24%依赖解析与 resolve12.3s11%其他plugin、emit、optimization29.2s25%谁最耗时一目了然loader 和压缩。但真正让我意外的是“依赖解析”这一项它藏在每个模块的默认查找过程里平时很难直接发现。后面我把项目中大量../../../../utils/request这种长相对路径改成了 alias 短路径resolve 环节才降下来。1.2 别忽略“模块解析”这类隐形大户总量快到 120 秒依赖解析 12 秒似乎不算大头但它的成本是分摊在每个 import 语句上的。项目里几百个业务文件每个文件都要经过 webpack 的 resolver 一层层往上回溯找路径反复做文件系统探测。这种慢不是一朝一夕形成的而是“平均用力”地慢。我统计完当时的第一反应是先别急着上各种神奇插件老老实实按耗时排序来做。第一刀切 loader第二刀切压缩第三刀再碰 resolve。调优最忌讳的是一窝蜂同时改十几个配置最后出了问题根本不知道是哪个改动引发的。1.3 建立基准没有基线就没有说服力我把上面几个数记到项目的 README 里同时写了个 npm 脚本scripts: { build:profile: cross-env NODE_ENVproduction webpack --profile --json --output stats.json node ./scripts/parse-stats.js }脚本做的事很简单把 stats.json 里的modules、chunks、assets耗时汇总成一张表。以后每次改动配置先跑一次这个脚本拿基线再跑一次改后的数据两个一对比优化效果立刻就能量化。没有基线的调优最后会变成“凭感觉变好/变坏”很难说服团队 review。2. Loader 与模块解析真正被低估的重复劳动2.1 先管住 babel-loader 的 include/exclude多数项目的默认配置是这样写的{ test: /\.jsx?$/, loader: babel-loader, }这行配置最大的问题webpack 会拿所有 js 文件——包括node_modules里那几千个文件——去询问 babel-loader。实际上node_modules里的包在多数情况下不需要二次转译或者已经被发布方转译过。即便项目里确实有个别依赖是 ES6 语法也应该单独挑出来处理而不是闭着眼睛让 babel 把整个node_modules都跑一遍。我当时的改动{ test: /\.jsx?$/, exclude: /node_modules/, use: [ { loader: babel-loader, options: { cacheDirectory: true } }, ], }只加一个exclude模块编译时间从 47 秒降到了 31 秒左右。如果项目里有少数必须转译的包比如某个 UI 库可以用include精确指到那个包的目录让它单独走一条规则而不是在全局规则里放行整个依赖目录。2.2 cacheDirectory thread-loader缓存和多进程双管齐下当项目模块数量上千后单个 loader 再快重复执行几千次也不容小觑。babel-loader 自带cacheDirectory开启后每个文件的转译结果会被缓存到node_modules/.cache/babel-loader第二次构建命中缓存时能省掉大把 CPU 操作。这是改动成本最低、收益最稳定的一步。我不仅给 babel 开了缓存还加了 thread-loader 做并行。注意 thread-loader 必须在 loader 列表里放在最前面它相当于给后面所有 loader 开了一个 worker 池use: [ { loader: thread-loader, options: { workers: 2 } }, { loader: babel-loader, options: { cacheDirectory: true } }, ]workers: 2是经验值。开线程本身有开销线程开多了通信成本可能盖过收益。我先用了 2后来压测时发现调成 4 反而慢了 8%于是维持在 2。这里有一个非常关键的前提thread-loader 不能处理依赖 Node 原生模块的 loader比如某些会读写文件系统、调用child_process的扩展 loader。硬要塞进 worker 池就会遇到各种莫名其妙的报错这个坑我在第五章会单独讲。2.3 resolve.alias 与 resolve.modules别让 webpack 大海捞针webpack 解析模块时会沿着node_modules一层层往上找。如果你写了很多长相对路径或者 import 时全靠从项目根一层层向上回溯resolve 环节的耗时就会很明显。我当时做了两件事在resolve.alias里给 src 目录起个短名字让所有业务代码统一走/components/Button这种短路径减少回溯长度在resolve.modules里显式指定查找范围就是项目根目录的node_modules。resolve: { alias: { : path.resolve(__dirname, src), }, modules: [path.resolve(__dirname, node_modules)], extensions: [.js, .jsx, .json], },extensions我建议只保留确实在用的后缀。多一个后缀webpack 就会对所有没写后缀的引用多做一轮尝试。如果你平时 import 都写完整后缀甚至可以只留.js和.jsx。这个改完后resolve 环节从 12 秒降到了 9 秒左右不算巨大但胜在零成本。2.4 rules 里使用 oneOf一个文件只匹配一次webpack 的 rules 是逐个匹配的。如果你的 rules 写了五六条每个文件都要跟每条规则试一遍。用oneOf包住规则后文件一旦命中其中一条就不再往后匹配。这个改动在规则数量多、文件数量大的项目里效果很明显。我当时把样式、图片、转译规则都塞进了oneOf模块编译又降了一点。更重要的是这让规则语义更清晰新同学加规则时也会先想一下这条规则是不是已经可以被前面的规则覆盖而不是无脑堆。对我来说代码可维护性和性能一样重要。3. 缓存策略从 cache-loader 到 webpack5 持久化缓存3.1 webpack4 时代的缓存在今天还有多少参考价值webpack4 时代大家喜欢用cache-loader或者HardSourceWebpackPlugin。HardSource 可以缓存整个模块的中间结果第二次构建确实快但它的坑也不少有时候缓存文件损坏导致构建直接报错得手动删node_modules/.cache下对应目录而且 HardSource 对 webpack5 的兼容性并不好新项目再往这个方向折腾性价比很低。webpack5 把缓存做成了编译器的内置能力配置非常简单。它本质上就是把模块构建结果、模块解析结果、依赖关系图等信息持久化到磁盘下次构建时直接复用。对一个用过 HardSource 的老人来说这种“开箱即用”的体验确实幸福。3.2 实操配置filesystem cache 与 buildDependencies这是我webpack.prod.js里的简化配置const path require(path); module.exports { // ... cache: { type: filesystem, cacheDirectory: path.resolve(__dirname, node_modules/.cache/webpack), buildDependencies: { config: [__filename], }, version: 1.0.0, }, // ... };type: filesystem表示用文件系统缓存默认其实也是 filesystem但显式写出来团队成员看到更清楚buildDependencies.config表示当 webpack 配置文件本身变化时缓存自动失效。这非常重要否则你改了resolve.aliaswebpack 还拿旧解析结果构建会产生玄学问题version是给缓存做整体失效用的。如果你升级了某个影响构建结果的依赖比如 babel 版本、sass-loader 版本就手动改一下version强制全量缓存重建。3.3 缓存失效逻辑改了什么它才会重新构建很多人担心持久化缓存会把过时代码“冻”住其实不会。webpack5 的 filesystem 缓存会基于模块内容 hash 判断失效。文件内容变了hash 就变重新构建的也只是变动的模块以及受影响的上游模块其余全部命中缓存。这比全量重跑效率高得多。我给出一个实际数字启用 cache 前项目二次构建无任何修改耗时约 40 秒启用 filesystem 缓存后同样的二次构建约 9 秒。即便是只改了一个入口文件后的增量构建也从 11 秒压到了 3 秒左右。这个收益在开发体验上几乎是质的提升。3.4 缓存目录是灵异问题高发区先删为敬filesystem 缓存好用但它也是“灵异问题”高发区。比如你升级了 webpack 相关依赖但忘了改version或者 node 版本从 16 升到 18缓存里存的二进制结果可能与当前环境不完全兼容表现出来就是页面渲染异常、模块顺序错乱甚至编译报错。此时不要纠结删掉node_modules/.cache/webpack目录再来一次基本都能恢复。我们团队遇到过两次一次是升级 node 后热更新偶尔编译出空模块另一次是 CI 上恢复缓存后产物里少了某段公共代码。两者最后都靠删除 cache 解决了。所以我现在有个习惯只要升级了会影响编译结果的依赖版本就顺手改一下cache.version或者直接删缓存目录。这个动作比排查灵异问题快一个数量级。4. 产物瘦身代码分割、tree-shaking 与压缩配置4.1 splitChunks 把重复引用的代码单独抽出来构建变快只是一部分产物体积也是性能调优的重要战场。我们后台系统一个主 bundle 将近 2.8MBgzip 后还有 851KB首屏必然慢。我做的第一个调整是 splitChunksoptimization: { splitChunks: { chunks: all, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: vendor, priority: 10, }, common: { minChunks: 2, name: common, priority: 5, minSize: 30000, }, }, }, },注意chunks: all的效果异步加载的模块也会进入代码分割。如果业务页面没有做路由懒加载很多代码会被打进主 bundle。我在项目里把页面统一换成了React.lazy(() import(/pages/Page))配合 splitChunks首屏真正要下载的 JS 量一下就下来了。4.2 sideEffects 与 tree-shaking不是写了 ESModule 就会生效如果你的代码全是 CommonJSrequire/module.exportstree-shaking 基本无能为力。我们项目相对好组件都用了 ESModule 写法但 tree-shaking 还有个前提package.json里得声明sideEffects字段。{ name: admin-web, sideEffects: [*.css, *.scss] }如果你的项目是纯 ESModule没有副作用文件可以直接设成false。但我建议还是把 css/scss 列出来否则样式会被当成副作用摇掉。这个配置影响的是 webpack 对模块的保留判断。不配sideEffects即便写了import { Button } from ui-lib也可能把整个 UI 库打进去。我当时把主 UI 库改成按需引入后vendor 体积少了约 600KB。4.3 压缩并行TerserPlugin 的 parallel 与 cachewebpack5 默认压缩器还是 TerserPlugin单进程跑速度一般。我升级了配置用 TerserPlugin 并行压缩const TerserPlugin require(terser-webpack-plugin); const CssMinimizerPlugin require(css-minimizer-webpack-plugin); optimization: { minimize: true, minimizer: [ new TerserPlugin({ parallel: 4, extractComments: false, }), new CssMinimizerPlugin({ parallel: 4, }), ], },parallel: 4意味着同时有 4 个进程在压缩代码。在我们项目里压缩环节耗时从 28 秒降到 13 秒左右。extractComments: false还能避免生成一堆.LICENSE.txt注释文件产物目录干净很多。可能有人想说用 swc/esbuild 压缩更快。我当时没有急着换因为团队对浏览器支持版本有要求而且引入 esbuild 要做额外的 loader 切换风险大于收益。在 webpack5 生态里先用原生 terser 并行把链路盘清楚比把目标盯着“一秒压缩完”更务实。4.4 图片与静态资源别让构建去优化你不该优化的东西图片在这类后台项目里常见两种浪费一是全尺寸图被打包二是小图被用url-loader无脑 base64。我做了两个操作小图用 webpack5 的type: asset配合parser.dataUrlCondition.maxSize控制在 4KB 内转 base64其余走独立文件大图交给 CDN 或对象存储构建时不做任何内联。{ test: /\.(png|jpe?g|gif|svg)$/i, type: asset, parser: { dataUrlCondition: { maxSize: 4 * 1024, }, }, generator: { filename: images/[name]-[hash][ext], }, }小图内联减少 HTTP 请求大图外链不拖累构建产物这个边界拿捏好就行。不要试图让构建去压缩一张已经压缩过的 2MB 运营 Banner那是 CDN 该干的事。5. 三个容易翻车的调优细节线程、缓存与反直觉场景5.1 thread-loader 与 Node 原生模块的兼容性在第二章我说过 thread-loader 最好放在 loader 列表最前面。但如果你项目里某个 loader 依赖 Node 原生能力比如svgo-loader或者某个内部自定义 loader 要读写文件系统放进 thread-loader 作用范围后就可能报Cannot find module fs一类奇怪错误。原因是子 worker 环境里对 Node 全局对象的隔离和主进程不完全一致。遇到这种错误把对应 loader 从 thread-loader 后面摘出去或者单独给它开一条 rule避免它进入 worker 池。我的处理是业务代码的 babel-loader 走 thread-loader图片和样式的 loader 不涉及复杂转译索性不放到 thread 里避免踩兼容性坑。5.2 缓存目录出问题时的标准排查动作持久化缓存虽好但出了问题容易让人抓狂。印象最深刻的一次某天同事告诉我热更新后页面样式错乱明显是某个老的 scss 变量没被替换掉。查了几个小时连 node_modules 都重装过最后发现真正因素是 webpack 缓存里存了旧版本 sass-loader 的编译结果而配置文件里没有写version变更。当时只要执行下面这条命令问题立刻消失rm -rf node_modules/.cache然后再重新启动 dev server一切恢复正常。所以我现在遇到任何“改了代码但构建结果还是旧的”的诡异现象第一反应不是去翻配置而是先把缓存清一遍。这不一定能根治问题但能帮你快速排除掉最容易忽略的变量。5.3 “优化后构建快了产物却变大”的反直觉案例这是调优过程中最考验判断力的一块。我一度把 splitChunks 的chunks从async改成all结果构建速度确实提上来了因为 chunk 数量变多webpack 的串行工作变少但产物总大小反而涨了约 30%。原因很多几个页面才用一次的小公共模块被单独抽成了 chunk每个 chunk 又重复引用同一段内部代码浏览器端多一次请求就多一份执行开销。后来我在cacheGroups里加了一行minSize: 50000把小于 50KB 的公共模块默认留在原 chunk 里产物总大小才重新回到下降趋势。所以调优不能只看构建时间快和瘦是两件事一定要分开验证。6. 让优化不“人走茶凉”团队落地的几个建议6.1 把构建时长和包体积做进 CI 的预算检查个人调优再成功如果团队没有持续约束新同学改一下配置过一个月又会慢回去。我把这次调优的经验固化成了两个东西。第一在 CI 里加了一个性能预算脚本每次构建后输出 stats.json解析出 bundle 大小和构建时长。如果产物超过阈值比如 gzip 后 1.5MB或者构建超过 60 秒流水线直接打警告。虽然不至于阻断发布但每次 PR 都会显示数据大家就会主动关心。第二维护了一份 Webpack 调优清单把已经做过的配置、当前基线、以及遇到灵异问题时的排查顺序都写了进去。这份文档对组内新人尤其有用他们不用重新走一遍踩坑路。6.2 哪些优化手段不建议一上来就上调优也要讲性价比。我建议的顺序是先做 loader 的 exclude/include、缓存、resolve alias再处理 splitChunks 和压缩并行最后才考虑要不要上 swc/esbuild 这类偏激进的方案。不建议一上来就做这三件事别迷信 thread-loader 万能。它只适合 loader 密集的项目如果 loader 本来不多开线程反而增加通信开销表现是构建时长不减反增别把所有资源都交给 webpack 优化。大图、重音频直接走 CDN让构建只干它擅长的事别在一个大版本升级的节骨眼上同时做缓存改造和代码分割改造。一次只动一个变量出了问题才好定位。6.3 一个我自己调整时一直在用的小技巧如果你不确定某项改动是否有用可以在改动前先跑一次webpack --profile拿到基线然后在同一个环境里跑一次改动后的基线。环境保持一致很重要很多人喜欢开着热更新直接测构建结果拿到的数据可能是脏的。我当时习惯把每次优化前的数字记在一张表里配置项冷启动构建二次构建gzip 体积初始状态117s40s851KB加 exclude/cache89s26s841KB加 thread-loader72s23s845KB加 filesystem cache64s9s842KB加 splitChunks/压缩23s7s520KB这张表就是我和团队沟通时的底气。看着数字从 117s 一路降到 23s大多数人都会支持你把改动合入主干。最后说点实在的。Webpack 性能调优没有银弹我们的项目能顺利落地靠的是先量化、再动手、改完立刻验证这个小循环。如果你现在正被构建速度折磨着千万别急着把配置文件重写一遍先花半天时间把耗时构成和基线摆出来你会发现很多优化是“顺理成章”的。上面这些配置大多可以直接抄但抄完一定用数据验证它在你项目里的效果。等你也跑出一张漂亮的对比表再回来看这段调优经历会觉得很值。