去年底双十一前一个月,我接手了公司那个已经跑了三年的电商大促后台项目。这个项目说起来挺有意思,最开始是三个团队各自独立开发的,后来合并成一个大项目,结果就是 Webpack 配置变成了“缝合怪”——从三个不同版本的项目里复制粘贴拼凑出来的。当时用的还是 Webpack 4.44.2,我接手的时候开发环境冷构建要 78 秒,热更新经常要等 6-8 秒,大促压测期间改个 bug 简直是煎熬。
最离谱的是,我第一次跑 npm run build 打生产包,居然花了 4 分 23 秒,打包出来的文件有 14.7MB,首屏加载光 JS 就占了 3.2MB。后来我翻了下 git 历史,发现这个配置从 2021 年就没动过,里面还残留着 extract-text-webpack-plugin 这种已经被废弃的插件,连 babel-loader 都还在用 cacheDirectory: true 这种老掉牙的缓存方式。
我决定先不急着升级到 Webpack 5,而是先搞清楚到底哪里慢。装了 speed-measure-webpack-plugin 跑了一次构建分析,结果让我挺意外的:babel-loader 处理 node_modules 里的某个工具库花了 12 秒,css-loader 处理一个 8000 行的遗留 CSS 文件花了 8 秒,还有个自定义的 api-generator-loader 居然每次都重新读取文件系统,单这一个 loader 就占了 15 秒。
当时我们那个订单管理模块有个很典型的问题:所有页面都打包在一个 main.js 里,哪怕用户只打开“退款审核”页面,也要加载“商品上下架”、“营销活动配置”这些完全不相关的代码。我查了下路由配置,发现之前的开发直接在路由文件里同步引入了所有组件,连 import() 动态导入都没用。
我做的第一件事就是清理 node_modules 的处理规则。原来的配置里 babel-loader 的 exclude 只排除了 node_modules,但没排除那些不需要转译的包。我加了个正则,把 @babel/runtime、core-js 这些已经编译过的包排除掉,光这一项就让构建时间少了 9 秒。
然后我把那个自定义 api-generator-loader 重写了。原来的逻辑是每次构建都去读 src/api/ 目录下所有文件,生成接口映射表,但它没做任何缓存,哪怕文件没变也重新生成。我给它加了个基于文件内容的 hash 缓存,只有当文件内容变了才重新生成,这个 loader 的耗时直接从 15 秒降到了 0.3 秒。
处理完这些,我试着升级到了 Webpack 5.90.0(2024 年 2 月刚发的稳定版)。升级过程比我想象中顺利,主要是把 file-loader 换成了内置的 Asset Modules,去掉了 hard-source-webpack-plugin(Webpack 5 自带持久化缓存了),然后把 optimization.splitChunks 重新配了一下。
我特意对比了下升级前后的数据:冷构建从 78 秒降到了 32 秒,热更新从 6-8 秒降到了 1.2 秒左右。但最让我头疼的是 ModuleConcatenationPlugin(Scope Hoisting)的问题——升级后有个工具函数的 this 指向变了,导致线上报了一堆 Cannot read property 'xxx' of undefined。排查了半天才发现,原来那个工具函数用了 this 获取模块上下文,开启 Scope Hoisting 后函数被提升到了顶层作用域,this 变成了 undefined。最后只能给那个模块单独关掉 Scope Hoisting 才解决。
这次复盘让我明白,配置复制粘贴最大的问题不是“能不能跑”,而是“跑得有多费劲”。很多团队喜欢直接抄 CRA 或者 Vue CLI 的配置,但没考虑自己的项目里有没有 node_modules 里的 ES6+ 代码、有没有遗留的大文件、有没有自定义 loader 的性能问题。现在 2024 年了,如果你还在新项目里用 Webpack 4,或者还在用 file-loader,真的该好好看看 Webpack 5 的新特性了——光是持久化缓存和 Asset Modules 就值得升级。
上个月我们团队那个 CRM 系统,新来的实习生跑 npm install 后第一次构建,等了 61 秒还没出结果,跑过来问我是不是电脑坏了。我当时挺尴尬的,因为那个项目虽然升级到了 Webpack 5.88.0,但我一直没认真调过缓存配置,只是默认开启了 cache: true。后来我花了两天时间专门研究 Webpack 5 的持久化缓存,最后把冷构建时间压到了 8.2 秒,这里说说我是怎么折腾的。
Webpack 5 的持久化缓存默认存在 node_modules/.cache/webpack 目录里,按理说应该很快,但我那个项目有个特殊情况:我们用了 monorepo 结构,主项目依赖了三个内部包,这三个包的代码经常改,但每次冷构建还是重新编译所有依赖。我翻了下 Webpack 5.90.0 的文档,发现缓存失效的原因有很多,比如 babel-loader 的 options 变了、环境变量变了、甚至 node_modules 里某个包的小版本更新都会导致缓存失效。
我遇到的第一个坑是 cache.buildDependencies。Webpack 5 默认会跟踪 webpack.config.js 和所有 loader 的依赖,但我那个项目里有个自定义的 config/env.js 文件,里面根据 NODE_ENV 输出不同的 API 地址,这个文件没被加到 buildDependencies 里,导致每次改环境变量,缓存就全失效了。我加了这段配置后,至少环境变量变化导致的缓存失效问题解决了:
第二个问题是 babel-loader 的缓存和 Webpack 缓存冲突。我之前在 babel-loader 里开了 cacheDirectory: true,结果 Webpack 缓存了一份,babel-loader 自己也存了一份,不仅占磁盘空间(多了 1.2GB),还经常因为两份缓存不一致导致构建结果不对。后来我把 babel-loader 的缓存关了,只保留 Webpack 的持久化缓存,反而更稳定了。
然后我处理了 node_modules 的缓存策略。Webpack 5 默认会缓存 node_modules 里的内容,但我们的 monorepo 里,内部包的源码是在 packages/ 目录下的,这些包通过 npm link 或者 workspace 依赖到主项目。Webpack 默认不会跟踪这些软链接文件的修改,导致改了内部包的代码,缓存没更新。我加了个 snapshot 配置,强制跟踪这些目录:
调完这些,我测了下冷构建时间:从 61 秒降到了 23 秒。但我觉得还不够,因为我们那个项目有 1200 多个模块,其中 800 多个是 node_modules 里的,这些模块其实不需要每次都重新编译。我查了下 Webpack 5 的 resolve 配置,发现 resolve.symlinks 默认是 true,会解析软链接,但我们的内部包都是软链接,每次解析都会重新计算路径,浪费时间。我把 resolve.symlinks 设为 false(因为我们内部包已经通过 workspace 正确安装了),模块解析时间少了 4 秒。
最后我加了个 thread-loader 做多线程编译,但要注意,thread-loader 和持久化缓存一起用时,缓存的 key 会包含线程信息,所以第一次构建可能反而慢,但第二次就快了。我只在 babel-loader 前面加了 thread-loader,因为 css-loader 和 style-loader 是 I/O 密集型的,多线程反而慢。
现在我们的冷构建时间稳定在 8-10 秒,热更新 0.8 秒左右。有个细节要注意:如果你用了 clean-webpack-plugin,记得在开发环境关掉,或者配置 cleanStaleWebpackAssets: false,不然每次构建都会删掉缓存目录,白忙活。
我后来还发现,Webpack 5.90.0 的持久化缓存在 Windows 和 Linux 下的表现不一样——Windows 下文件系统的 IO 慢,缓存读写会比 Linux 慢 30% 左右。如果你们团队有 Windows 开发机,可以把 cache.cacheDirectory 设到内存盘里,速度会快很多。
今年年初我们做微前端改造,选了 Webpack 5 的 Module Federation(模块联邦)。当时觉得这玩意儿比 single-spa 轻量,不用搞那么多构建配置,结果上线前一周出了两个大问题:一个是主应用和子应用用的 React 版本不一样,导致 useState 报“Invalid hook call”;另一个是子应用的 TypeScript 类型丢了,开发的时候 import 远程组件没有提示,还报错。
先说说版本冲突的问题。我们的主应用用的是 React 17.0.2,有个子应用因为是新搭的,用了 React 18.2.0。我在主应用里配了 shared: ['react', 'react-dom'],以为会自动共享,结果子应用加载的时候,Webpack 居然同时加载了 React 17 和 18 两个版本——因为子应用的 package.json 里 React 的版本范围是 ^18.0.0,主应用是 ^17.0.0,SemVer 不匹配,Module Federation 就认为这是两个不同版本的包,都加载了。
后来我查了 Module Federation 的 shared 配置文档,才发现要指定 singleton: true 和 requiredVersion 才能强制共享。我改成了这样:
这里有个坑:eager: true 会让共享模块在主应用入口就加载,可能会导致首屏变大,但如果不用 eager,子应用加载的时候可能会先加载自己的 React,再被主应用的覆盖,还是会出问题。我们当时的解决办法是,所有子应用统一用和主应用一样的 React 版本,然后在 requiredVersion 里写死主应用的版本号,比如 '17.0.2',这样就不会有版本不匹配的问题了。
然后是类型丢失的问题。我们子应用是用 TypeScript 写的,主应用也是 TypeScript,但主应用 import 子应用的组件时,完全不知道这个组件有什么 props,连 Ctrl+点击 都跳不过去。一开始我以为 Module Federation 不支持类型共享,后来发现是 remoteEntry.js 只导出了运行时代码,没有类型定义。
我试了社区里的 @module-federation/typescript 这个包,但配置太复杂,而且不支持 Webpack 5.90.0 的新特性。最后我们自己写了个简单的脚本,在子应用构建的时候,把 exposes 里导出的模块的类型定义提取出来,生成一个 remote.d.ts 文件,然后主应用通过 tsconfig.json 的 paths 指向这个文件。
子应用的类型生成脚本大概是这样:
然后在子应用的 package.json 里加个 postbuild 脚本:
主应用的 tsconfig.json 里加个 paths 配置:
这样主应用开发的时候就能拿到子应用的类型了。不过这个方案有个问题:如果子应用的类型变了,主应用需要重新拉取 remote.d.ts,不然类型会过期。我们后来把它集成到了 CI 流程里,子应用发布的时候,自动把 remote.d.ts 传到内部 npm 包里,主应用安装依赖的时候自动更新。
还有一个实际遇到的问题:子应用用了 antd 的 DatePicker,主应用也用了,但主应用的 antd 版本是 4.24.0,子应用是 4.23.0。一开始我以为 shared 里配了 singleton: true 就没事,结果子应用的 DatePicker 样式变了——因为 antd 的样式文件没被共享,子应用加载了自己的 antd.css,主应用也加载了自己的,两个样式冲突了。后来我们把 antd 的样式提取到主应用里,子应用不打包 antd 的样式,才解决这个问题。
现在我们的微前端架构跑了半年多,Module Federation 确实省了不少打包体积——主应用和三个子应用共享 React、React DOM、antd、lodash 这些依赖,总包体积从原来的 12MB 降到了 4.8MB,首屏加载快了 1.2 秒。但说实话,Module Federation 不是银弹,如果你只有两个应用,或者依赖版本经常变,用它反而会增加复杂度。我们当时选它,主要是因为主应用和子应用都是 Webpack 5 构建的,而且依赖版本能统一,不然我可能会考虑 Vite 的远程模块方案了。
上个月我们那个后台管理系统升级到 Webpack 5.90.0 之后,我顺手看了一眼打包报告,发现一个奇怪的现象:明明我在 package.json 里加了 "sideEffects": false,打包出来的 lodash-es 还是完整打进去了,体积一点没少。我当时第一反应是配置没生效,但翻遍了文档也没看出问题。
后来我花了两个小时,把整个依赖链捋了一遍,才搞清楚 Tree Shaking 到底是怎么“失效”的。
我们项目用的是 babel-loader 转译 ES6+ 代码,配置大概是这样:
问题就出在 modules: 'commonjs' 这个配置上。Babel 会把 ES Module 的 import/export 全部转成 require/module.exports,而 Webpack 的 Tree Shaking 依赖的是 静态的 ES Module 语法分析。一旦代码变成了 CommonJS,Webpack 就彻底没法判断哪些导出被用了、哪些没用了,只能全部打包进去。
我把配置改成这样之后,体积立刻降了下来:
改完之后,lodash-es 的引入方式从 import { debounce } from 'lodash-es' 终于能被正确摇树,最终打包体积从 1.2MB 降到了 860KB,首屏加载时间从 1.4s 缩短到 980ms。
还有一次更隐蔽的问题。我们有一个内部工具库 utils,里面有一个 index.js:
polyfill.js 里是这样的:
这个文件在加载时直接修改了全局 Array.prototype,属于典型的副作用代码。我在 package.json 里写了 "sideEffects": false,Webpack 会认为整个包都没有副作用,于是把 polyfill.js 也当成可删除的模块。结果线上用户一进页面就报错:unique is not a function。
后来我改成精确声明副作用文件:
Webpack 在构建时就会保留这个文件,其他无副作用的模块依然可以正常 Tree Shaking。
现在我在项目里判断 Tree Shaking 是否生效,基本看三点:
lodash-es 可以,lodash 不行)Webpack 5 的 Tree Shaking 确实比之前强很多,尤其是结合 Terser 的深层作用域分析之后,很多之前摇不掉的代码现在都能处理。但它不是“开了就万事大吉”,依赖链、转译工具、模块语法任何一环出问题,结果就是白忙活。
---
去年我们公司一个新项目立项,前端组里吵了两天:到底用 Vite 还是 Webpack。最后我们那个订单中台系统还是选了 Webpack 5.90.0,不是因为保守,而是真的算过账。
我们那个中台系统有 6 个子应用,用的是基于 Module Federation 的微前端方案。子应用之间要共享 React 18、React Router、Ant Design 这些大家伙。Vite 目前的 Module Federation 支持还在试验阶段,配置复杂不说,远程模块的类型丢失问题我们之前在另一个小项目里踩过一次,光类型声明就补了一整天。
Webpack 5 的 Module Federation 在我们的场景里是这样的:
这样配置之后,6 个子应用共享一份 React 和 Ant Design,每个子应用的首屏 JS 从 780KB 降到了 320KB,大促期间页面打开速度稳定在 1.2s 左右。Vite 目前没有同等成熟度的运行时模块共享方案,我们不敢在核心业务上冒险。
我们那个项目有 1400 多个模块,冷构建在 Webpack 5 里大概要 42 秒。但开启持久化缓存之后,二次构建只要 8 秒左右,提升非常明显。配置其实很简单:
Webpack 5 默认就开了文件系统缓存,比之前 memory cache 强在构建进程重启之后缓存还在。我们 CI 环境里,第二次构建直接从 38 秒降到 14 秒,流水线排队时间少了很多。
Vite 的 dev server 启动确实快,但在我们这种体量的项目里,热更新时依赖预构建的缓存一旦失效,重新扫描依赖的时间也不短。我们实际测过,改动一个深层组件,Vite 的 HMR 有时候要 3-4 秒,Webpack 那边只要 1 秒出头。
如果是新项目、没有微前端、不需要共享巨型依赖、团队对 ESM 原生支持接受度高,我会毫不犹豫选 Vite。但像我们这种有历史包袱、子应用多、共享依赖复杂、构建配置已经高度定制化的系统,Webpack 依然是更稳妥的选择。
2024 年不是“Vite 取代 Webpack”的年份,而是两者各自守好自己的地盘。Webpack 在社区讨论里被拿来和 Vite 对比的频率很高,但真正跑在生产环境里的大型应用,很多还是留在 Webpack 生态里,原因不是保守,是成本。
---
年初我们技术委员会讨论了一个话题:要不要把部分项目迁移到 Rspack。我负责做一个迁移评估,跑了一个真实子应用的数据,结果有点意思。
我们拿一个中等规模的子应用试了一下,大概 400 多个模块。原来的 Webpack 5.90.0 冷构建是 28 秒,换成 Rspack 之后,同样的配置思路,冷构建只用了 6 秒。配置写法几乎没变:
Rspack 兼容了大部分 Webpack 的 loader 和插件生态,我们原来用的 css-loader、postcss-loader 基本都能直接跑。但问题也有,比如我们原来用的一个内部 Webpack 插件,依赖 compiler.hooks.done 里的某些特定字段,Rspack 那边还没完全对齐,最后只能先注释掉。
我们最近在试用一个内部工具,基于 AI 分析 Bundle 内容,自动推荐 splitChunks 配置。原来我们的 splitChunks 是这么写的:
AI 工具分析完我们的 Bundle 之后,给出的建议是:把 antd 单独拆出来,因为它的使用频率高但更新频率低,单独缓存效果更好。我们照着改了:
改完之后,用户第二次访问页面时,antd 的 chunk 命中缓存比例从 40% 升到了 85%,首屏加载时间又降了 200ms 左右。
Rspack 现在适合在新项目或者构建性能瓶颈特别明显的老项目里试点,但别指望一夜之间把所有 Webpack 项目都换掉。AI 辅助优化现在还处于“辅助”阶段,它给的建议需要你结合业务判断,不能直接照单全收。
Webpack 5 在 2024-2026 年的趋势里,明显在往“更快的缓存、更智能的优化、和 Rust 工具链融合”这个方向走。我们现在的策略是:核心业务继续用 Webpack,新项目可以试 Rspack,AI 工具当参谋用,不盲从,也不排斥。
去年双十一前,我手头负责一个电商中台项目,光业务模块就四十多个。某天下午,我本地改一行样式,构建居然要等将近一分钟,团队里新来的小伙伴直接吐槽“改代码五分钟,编译半小时”。我盯着终端里一动不动的进度条,心里也发毛,这要是上线前改 bug,真得通宵。
我第一反应是开持久化缓存,结果配完发现没啥效果。后来我翻构建日志,发现有个同事引入了一个工具库,默认把 sideEffects 设成了 false,导致我这边 index.js 里明明只用了两个方法,结果整个库还是被打包进来了。我花了大半个下午,把那个库的引入方式改成按需加载,又顺手把 sideEffects 配置重新梳理了一遍,构建时间直接从 60 多秒掉到 8 秒左右。那天下午我喝掉三杯咖啡,但看到构建跑完的那一刻,真觉得值了。
* 持久化缓存确实好用,但别指望它解决所有问题。如果你的项目很小,构建本来就几秒,折腾这个反而增加配置复杂度。
* Module Federation 听起来很酷,但我现在只会在微前端、且团队多、模块复用需求强的时候才上。小项目硬上,光处理版本冲突就够你受的。
* Rspack 我最近在试,速度确实快,但生态和插件兼容性还没到能完全替代 Webpack 的程度,别盲目迁移。
别一上来就追求极致优化。先把 基础配置跑通,再去看那些进阶技巧。很多时候,构建慢不是因为 Webpack 不行,而是我们引入的依赖或者配置方式本身就有问题。慢慢来,比什么都强。