从 webpack 4 升级到 5:持久化缓存、资源模块和几个真实的兼容性坑
webpack 5 去年十月就发布了正式版,团队一直没动,理由很朴素:4 用得好好的,没有非升级不可的痛点。但最近这段时间,"要不要升 5"这件事被重新摆上桌面,起因是构建时间。项目模块数早就过了千,每次改完代码等 webpack 重新编译,哪怕开着 cache-loader 和 thread-loader,冷启动依然要一分多钟,二次构建也常常十几秒起步。翻 webpack 5 的发布说明,第一条写的就是持久化缓存能力,这是这次升级评估的直接导火索。
5 的持久化缓存,和之前的缓存不是一回事
webpack 4 时代能用的缓存基本都是"外挂"的:cache-loader 缓存单个 loader 的处理结果,babel-loader 自带 cacheDirectory,HardSourceWebpackPlugin 缓存模块解析结果。这些方案各管一段,缓存粒度、失效逻辑都不统一,出问题时经常搞不清是哪一层缓存脏了。
webpack 5 把缓存做进了核心,配置极简:
1module.exports = { 2 cache: { 3 type: 'filesystem', 4 buildDependencies: { 5 config: [__filename], // 配置文件变了,缓存也要失效 6 }, 7 }, 8};
type: 'filesystem' 意味着缓存的不只是某个 loader 的转换结果,而是整个模块图和构建过程中的中间产物,序列化后写到 node_modules/.cache/webpack 目录。第二次构建时,webpack 先检查输入有没有变化——文件内容、配置、依赖版本,没变的部分直接从磁盘反序列化拿回来用,跳过重新解析和转换。
我们在项目分支上先跑了一组对照实验:同一份代码,webpack 4 的组合缓存(cache-loader + babel-loader 缓存)下二次构建大概十四秒;换成 webpack 5 默认的 cache: { type: 'filesystem' },什么都不改,二次构建降到四秒出头。这个提速不是靠某一个 loader 更快了,而是缓存粒度从"某个 loader 的输出"提升到了"整个模块图状态",跳过的工作量自然更大。
buildDependencies 这一项容易被忽略,但很关键。它告诉 webpack:"这些文件(比如 webpack 配置本身、postcss.config.js)如果变了,缓存要整体作废"。忘了加这一项,我们踩过一次坑:改了 babel.config.js 里的一个 preset 选项,构建却读了旧缓存,代码里明明已经不再需要的 polyfill 还在产物里,排查了小半天才想起缓存没跟着配置文件走。加上 config: [__filename] 之后,webpack 配置文件本身的修改也会正确触发缓存失效。
缓存并不是没有代价。第一次构建(缓存为空)时,filesystem 类型比 memory(webpack 5 之前的默认内存缓存)多了一步序列化写盘的开销,冷启动反而会比原来慢一点点。我们测下来大概多了两三秒,换来的是后续每一次二次构建都能命中,这笔账在开发环境里显然划算;但如果是 CI 上的一次性构建(拉个新容器、构建一次就扔),缓存写盘的成本纯粹是浪费,这种场景应该直接关掉:
1cache: process.env.CI ? false : { type: 'filesystem' },
还有一个我们差点漏掉的点:filesystem 缓存默认写在项目本地目录,CI 如果每次都是全新容器,这份缓存永远用不上,跟没开一样。如果 CI 环境本身支持跨构建持久化某个目录(很多云 CI 平台提供这类缓存路径配置),把 cache.cacheDirectory 指到那个可持久化的路径,才能真正在 CI 上也吃到这块红利。这一步我们暂时还没在 CI 上落地,先记在评估文档里。
缓存的版本管理也值得单独说一句。cache 配置里还有一个 version 字段,平时不太会用到,但如果团队里同时有多个功能分支在跑、彼此的依赖版本或者 loader 配置不一样,共用同一份缓存目录可能会读到"串味"的中间产物。这种场景可以给不同分支的缓存显式打上不同的版本号:
1module.exports = { 2 cache: { 3 type: 'filesystem', 4 version: `${process.env.GIT_BRANCH || 'local'}-v1`, 5 buildDependencies: { 6 config: [__filename], 7 }, 8 }, 9};
version 变化时,webpack 会认为这是一份全新的缓存空间,不会尝试去复用旧版本下的产物,相当于给缓存做了命名空间隔离。我们只在两个并行的大改动分支上短暂用过这个配置,日常单分支开发不需要操心这一项,但排查"缓存好像读了别的分支的东西"这类诡异问题时,这是第一个该想到的排查点。
Asset Modules:不用再纠结 file-loader 和 url-loader
webpack 4 处理图片、字体这类静态资源,标准做法是 file-loader 拷贝文件、url-loader 在小于阈值时转 base64、raw-loader 把文件内容当字符串导入。三个 loader 分别解决三个相邻的小问题,团队里每次配置这块都要现查一遍该用哪个、limit 该设多少。
webpack 5 把这几件事直接内置成了"资源模块"(Asset Modules),配置里不再需要装这三个 loader,用 type 字段声明就行:
1module.exports = { 2 module: { 3 rules: [ 4 { 5 test: /\.(png|jpe?g|gif|svg)$/, 6 type: 'asset', // 自动在内联和拷贝文件之间选择 7 parser: { 8 dataUrlCondition: { 9 maxSize: 4 * 1024, // 4KB 以下内联成 base64 10 }, 11 }, 12 }, 13 { 14 test: /\.txt$/, 15 type: 'asset/source', // 相当于原来的 raw-loader 16 }, 17 { 18 test: /\.pdf$/, 19 type: 'asset/resource', // 相当于原来的 file-loader,总是拷贝文件 20 }, 21 ], 22 }, 23};
asset 这个类型对应的正是原来 url-loader 加阈值判断的行为,asset/resource 对应 file-loader,asset/inline 强制内联不做判断,asset/source 对应 raw-loader。四种类型覆盖了原来三个 loader 的组合逻辑,好处不只是少装几个包——原来 url-loader 内部依赖 file-loader 做兜底(超过 limit 时会把请求转发给 file-loader),这种"loader 之间互相委托"的隐式依赖在资源模块里被显式的 type 字段取代了,配置项之间不再互相牵连,出问题时也更容易定位到底是哪条规则生效了。
迁移时有个真实的坑:项目里有条老规则用 url-loader 配了 esModule: false(因为有段遗留代码是用 require('./logo.png') 直接拿字符串路径,而不是解构 .default),资源模块默认是走 ES 模块导出的,直接替换后那几处遗留代码全部报错,返回的是一个模块对象而不是字符串。资源模块里对应的选项挪到了 generator 里:
1{ 2 test: /\.png$/, 3 type: 'asset/resource', 4 generator: { 5 // 对应旧 loader 的 esModule: false,配合仍在用 CommonJS 写法的老代码 6 filename: 'img/[name].[hash:8][ext]', 7 }, 8},
不过资源模块本身不再支持 esModule 这个选项名,它统一走 ES 模块导出,我们最终选择的做法是把那几处遗留的 require('./logo.png') 顺手改成 import logo from './logo.png',而不是找一个变通配置去兼容旧写法——升级本来就是清理技术债的机会,遇到只有个位数几处调用的旧写法,直接改比曲线兼容更省心。
另一个容易被忽略的细节是产物文件名。原来用 file-loader 时,文件名模板写在 loader 的 options.name 里;资源模块把这块配置整体挪到了 generator.filename,而且这一项既可以在具体某条规则里单独配,也可以在 output.assetModuleFilename 里统一给一个全局默认值:
1module.exports = { 2 output: { 3 assetModuleFilename: 'static/[hash][ext][query]', // 没有单独配 generator.filename 的资源模块都走这个模板 4 }, 5};
我们项目里图片和字体分别放在不同子目录,全局默认值不够用,最终是给每条规则单独写 generator.filename,只把 assetModuleFilename 当兜底值留着,防止哪天新增了一条资源规则却忘了配文件名模板,产物至少不会散落到输出目录根下。
asset 这个自动选择类型判断内联还是拷贝文件的依据只有一个:parser.dataUrlCondition.maxSize。这个阈值该怎么定没有标准答案,我们的做法是先按 webpack 5 文档给的默认值(8KB)跑一版,再拿 Chrome DevTools 的 Network 面板核对几张典型小图标——内联成 base64 之后单个请求确实省了,但 base64 编码本身会让体积膨胀大约三分之一,而且内联进 JS/CSS 之后不再享受浏览器对图片资源单独的缓存策略,一旦这段 JS/CSS 因为其他改动重新生成,图片也要跟着重新下载。综合下来我们把阈值从默认的 8KB 收紧到了 4KB,只让真正很小的图标走内联,稍大一点的图片宁可多一次请求,也保留独立缓存的收益。
Tree Shaking 这次真的能摇掉嵌套导出了
webpack 4 的 Tree Shaking 有个实际限制:只能分析到"顶层"的具名导出有没有被用到,如果一个模块导出的是一个对象、对象内部再嵌套导出多个方法,webpack 4 没办法看穿这层嵌套,只能把整个对象当成一个整体保留。
1// utils.js 2export const format = { 3 date(input) { /* ... */ }, 4 currency(input) { /* ... */ }, 5 percent(input) { /* ... */ }, 6}
1// 只用到了 date 方法 2import { format } from './utils' 3format.date(new Date())
webpack 4 下,currency 和 percent 即便完全没被调用,也会原封不动地留在产物里,因为它们不是"顶层导出",而是挂在 format 这个对象上的属性,webpack 静态分析不到属性访问这一层。webpack 5 增强了对这类嵌套导出的分析能力,配合 optimization.innerGraph(默认开启),可以识别出模块内部函数之间、以及导出对象属性之间的实际使用关系,currency 和 percent 这类没被用到的属性也有机会被摇掉。
我们在一个内部工具库上验证了这个变化:这个库导出的就是若干个按命名空间分组的工具函数对象,业务代码里往往只用其中一两个方法。用 webpack 4 打包时,npx webpack --mode production 完再 grep -r 'currency' dist/ 还能搜到那段代码;升到 webpack 5、开着默认的 innerGraph 之后,同样的构建、同样的 grep,搜不到了——说明真的被摇掉了。这个提升对我们这种"工具库导出一堆分组方法、业务只用几个"的场景收益比想象中明显,实测这个工具库相关的产物体积降了大概三成。
前提条件不变:模块本身仍然得是 ESM 写法,package.json 里该标的 sideEffects 字段也不能漏标,这些 webpack 4 时代的老规矩延续到了 5,只是分析的颗粒度变细了。
sideEffects 这个字段我们顺带也重新核对了一遍,因为它和这次嵌套导出优化是配合关系,不是互相替代。sideEffects: false 告诉 webpack 这个包里所有模块都没有副作用(不会在被导入时执行诸如注册全局样式、修改原型链这类和"是否被使用"无关的动作),可以放心按使用情况裁剪;如果包里只有个别文件有副作用(最常见的是引入了全局 CSS 的入口文件),可以用数组精确排除:
1{ 2 "name": "@team/ui-utils", 3 "sideEffects": ["*.css", "./src/polyfills.js"] 4}
我们内部工具库原来这个字段压根没写,相当于 webpack 默认按"可能有副作用"保守处理,即便嵌套导出分析能力再强,最外层的模块级别裁剪判断上不敢激进,Tree Shaking 的效果也会打折扣。补上这个字段之后,前面提到的三成体积下降里,有一部分就是靠这一步补齐才拿到的,单独开 innerGraph 而不管 sideEffects,效果会明显打折扣。
兼容性坑:一个老插件还没跟上
评估阶段最耗时间的不是核心配置改动,是确认项目里用的插件是不是都支持 webpack 5。大部分主流插件(html-webpack-plugin、mini-css-extract-plugin、copy-webpack-plugin)早在 webpack 5 发布前后就发了兼容的新版本,升级顺畅。真正卡住我们的是一个用来处理老项目里 SVG 雪碧图的第三方插件,issue 区里能看到有人反馈 webpack 5 下会报 Cannot read property 'tap' of undefined,翻源码发现它直接访问了 webpack 4 内部的一个私有 API,而这个 API 在 webpack 5 里被移除或者改了签名。
这类问题查起来比较费劲,因为报错栈指向的是插件内部、而不是我们自己的配置。判断"是不是插件不兼容 webpack 5"的笨办法但管用:先把可疑插件从 plugins 数组里临时注释掉,构建能不能正常跑完;能跑完,基本可以确认问题出在这个插件身上,再去它的仓库翻 issue,搜索关键词就用报错信息里那句话,十有八九已经有人踩过。这次的结果是作者在几个月前的一个分支上已经在尝试适配,但还没发正式版本,我们暂时把这部分雪碧图生成挪到了构建前的一个独立 npm 脚本里跑,不再依赖这个插件挂进 webpack 生命周期——功能上是等价的,只是从"构建时插件钩子里做"改成了"构建前单独跑一遍",牺牲了一点集成度,换来不必等这个插件发新版本才能升级核心。
webpack-cli 这次也顺带升级到了同步兼容 webpack 5 的版本,vue-loader、ts-loader 这类和核心结合紧密的 loader 都出过明确适配 webpack 5 的新版本,升级前对着 webpack.js.org 的迁移指南和各插件仓库的 CHANGELOG 挨个确认一遍支持状态,比升级后一个个撞见报错再回头查要省时间得多。
除了直接报错的插件,还有一类更隐蔽的兼容性问题:插件能跑,但行为悄悄变了。团队里一个负责统计打包分析数据的内部插件,升级后依然正常运行、不报任何错误,但产出的分析 JSON 里,chunk 之间的依赖关系字段却变成了空数组。定位这个问题花的时间比那个直接报错的雪碧图插件还长,因为没有任何异常提示可以顺藤摸瓜。最后是靠对照 webpack 5 的 Compilation 对象文档,一项项核对这个插件读取的字段名有没有在新版本里改过,才发现它读取的 chunk.parents 这个属性在 webpack 5 里已经废弃,正确的替代读法要通过 compilation.chunkGraph.getChunkParents(chunk) 这个方法拿。这类"字段还在、语义变了"或者"字段被废弃但没有立刻报错"的情况,比直接抛错误的兼容性问题更难发现,排查思路只能是怀疑哪个环节数据不对,就回去对照官方文档核实这个环节用到的每一个 API 签名。
长期缓存又准了一层:确定性的模块 ID
升级评估过程里,我们顺手核对了一遍长期缓存这块的表现,发现了一个 webpack 4 时代一直没太在意的细节:模块 ID。webpack 4 默认按数字自增分配模块 ID,0、1、2 这样往下排,顺序取决于模块被引用的先后。这带来一个不太直观的后果——项目里新增或者删除一个模块,哪怕跟其他模块毫无关系,也可能导致后面一大批模块的数字 ID 集体错位,进而让这些模块所在 chunk 的 [contenthash] 跟着变化,即便这些模块的实际代码内容一个字都没改。
我们之前对着这个问题束手无策,只当成"没办法,webpack 就这样"。webpack 4 后期版本其实已经支持 optimization.moduleIds: 'hashed' 这个选项来缓解,但很多项目(包括我们自己这个)一直没意识到要去手动开启。webpack 5 把这类"确定性"策略直接收进了默认行为:mode: 'production' 下 optimization.moduleIds 默认就是 'deterministic',模块 ID 由模块路径算出一个短哈希,只要模块自身内容和路径不变,无论项目里新增删除多少其他模块,它的 ID 都保持稳定。
1module.exports = { 2 optimization: { 3 moduleIds: 'deterministic', // webpack 5 生产模式下的默认值,显式写出来方便团队里新人看配置就懂 4 chunkIds: 'deterministic', 5 }, 6};
验证方法和前面验证 [contenthash] 的思路一样:在一个跟 utils/format.js 毫不相关的目录下新增一个空文件、随手 import 到入口里,构建两次,对比 dist/ 里各个 chunk 的文件名。webpack 4 默认配置下,vendor chunk 的 [contenthash] 段大概率会变——即便 vendor 涉及的第三方依赖代码一行没动,只是因为新模块插进来之后大家的数字 ID 全部往后错了一位,参与计算 hash 的模块顺序变了,hash 自然跟着变;换成 webpack 5 默认的 deterministic,同样的操作,vendor chunk 的文件名纹丝不动。这个差异在我们这种"业务代码里天天新增文件、vendor 里第三方依赖几个月不带动"的项目上意义不小,意味着升级之后用户浏览器对 vendor 包的缓存命中率会比 webpack 4 时代更稳定,不会被无关的业务改动误伤。
chunkIds: 'deterministic' 解决的是同一类问题在 chunk 层面的表现,逻辑和 moduleIds 是对称的:webpack 4 默认按数字顺序给 chunk 分配 ID,新增一个异步加载的路由,可能导致后面所有 chunk 的数字 ID 集体往后错位,进而影响一批本不该变化的文件名;deterministic 策略下 chunk ID 由这个 chunk 内部包含的模块集合算出稳定哈希,不再受制于"这是第几个被创建的 chunk"这种脆弱的顺序依赖。这两项配置我们最终都以显式写出来的方式留在了配置文件里,虽然是默认值,但显式写出来能让团队里后面接手配置的人一眼看出这是刻意的决定,而不是"不知道有这个选项、只是恰好没改到默认值"。
值得一提的是这两个策略在极少数场景下也有代价:由于 ID 是基于路径哈希算出来的,产物里看到的模块 ID 会是一串类似 "a3f9" 这样的短字符串,而不是 webpack 4 时代直观的 0、1、2 这种数字。刚开始团队里有同事在浏览 stats.json 或者用 webpack-bundle-analyzer 排查问题时,看着这种哈希 ID 有点不适应,多用了几次也就习惯了,毕竟它带来的缓存稳定性收益远比"数字 ID 看着直观"这点便利更重要。
chunk 加载运行时改用原生 Promise,体积也跟着瘦身
这一条是我们在对比两个版本产物体积时顺带发现的,不是提前计划要验证的点。同一份代码、同样的 splitChunks 配置,webpack 5 打出来的运行时代码(就是那段负责按需加载 chunk、维护模块缓存的胶水代码)比 webpack 4 明显小了一截。翻文档才知道,webpack 5 把内部大量异步加载相关的实现从早期兼容 ES5、手写的 Promise polyfill 风格代码,改成了直接使用原生 Promise、async/await。webpack 4 为了兼容一些还没有原生 Promise 的老环境,运行时里带了不少手写的异步调度代码;webpack 5 默认假设目标环境已经有原生 Promise(这一年 IE11 事实上已经很少作为构建目标出现在 browserslist 里了),运行时因此可以精简很多。
这个变化对我们几乎是纯收益,因为项目的 browserslist 早就不包含 IE 了。真要兼容一些还没有原生 Promise 的环境,得自己在入口引入 polyfill(比如 core-js 对应的 Promise 部分),webpack 5 不会像以前那样默认帮你把这层兼容补上。这一步我们在 browserslist 配置里确认过,not ie 11 已经是现状而不是新增限制,不需要额外处理。顺带验证了一下实际数字:一个中等大小的路由懒加载 chunk,运行时相关的胶水代码体积降了几百字节,单个 chunk 看着不起眼,但项目里有几十个这样的懒加载路由,累计下来对首屏之外的整体传输量也有实打实的减少。
一张实测数据表:升级前后到底差多少
评估文档里最终要交给团队的,不能只是"感觉快了",得有具体数字。我们在同一台机器上,对同一个分支分别切到 webpack 4 和 webpack 5 配置,各跑三次取中位数:
冷启动构建,webpack 4 组合方案(cache-loader + babel-loader 缓存 + thread-loader)大概七十秒;webpack 5 关闭持久化缓存(模拟第一次构建、缓存为空的场景)大概七十三秒,比 4 略慢一点,这是前面提到的序列化写盘开销;开启 cache: { type: 'filesystem' } 之后的第二次冷启动(也就是缓存已经写过一次盘、但项目没有任何改动的场景)降到十几秒,这是持久化缓存最直观的收益。
二次构建(改一个业务文件后重新构建),webpack 4 组合方案大概十四秒;webpack 5 默认缓存下降到四秒出头,这个差距是这次升级评估里最有说服力的一组数字,也是最终决定继续推进升级的核心依据。
生产构建产物体积,因为同时用上了资源模块和更细的 Tree Shaking,工具库相关的部分体积降了大概三成;chunk 运行时代码因为不再需要兼容非原生 Promise 的环境,累计减少的量虽然单个不大,但几十个懒加载路由叠加起来也有实际意义。
这些数字都写进了内部评估文档,附带跑测的分支和步骤,方便后续别的项目要做类似升级评估时能复用这套对照方法,而不是每次都从头摸索该测哪些维度。
Module Federation:这次评估先不用,但记下来
webpack 5 最被讨论的新特性是模块联邦(Module Federation),能让多个独立构建的应用在运行时共享模块,不用发布 npm 包、不用整体重新构建就能跨应用复用代码,官方给的场景通常是微前端下多个团队各自独立部署、又需要共享一份公共组件或者公共依赖。这次升级评估的重点是缓存和资源模块这两块能直接落地的收益,模块联邦目前团队还没有"多个独立部署的应用需要共享代码"这种真实场景,先记录下来,等有合适的微前端类需求时再单独评估,这里不展开配置细节。
升级过程中一个容易漏的默认值变化
webpack 5 把不少 Node.js 核心模块(path、crypto、stream 等)的自动 polyfill 去掉了。webpack 4 打包时,如果代码或者某个依赖里引用了 crypto 这类 Node 内置模块,webpack 会自动塞一个浏览器端的 polyfill 版本进去,业务代码基本感知不到。升到 webpack 5 之后,同样的代码构建时会直接报错:
1Module not found: Error: Can't resolve 'crypto'
这不是构建配置写错了,是 webpack 5 默认不再自动做这层 polyfill,需要的话得手动指定:
1module.exports = { 2 resolve: { 3 fallback: { 4 crypto: require.resolve('crypto-browserify'), 5 }, 6 }, 7};
我们项目里踩到这个问题,是因为一个依赖库内部用了 crypto.randomBytes 生成一个唯一 ID,这个库本身没打算在浏览器端用到这部分代码(只是某个条件分支里引用了,实际运行不会走到),但 webpack 静态分析看到 require('crypto') 就会尝试解析它。排查这类问题的思路和排查其他"模块找不到"报错类似:先看报错提示的模块名是不是 Node 内置模块,是的话大概率是这条新规则触发的,去官方文档查一下这个模块有没有推荐的浏览器端替代包,装上再配 fallback 就好;如果代码路径其实压根不会在浏览器端执行,也可以直接把 fallback 设成 false,让 webpack 遇到这个引用时直接跳过,不再警告。
这一类报错我们前后遇到了不止 crypto 一个,stream、path、buffer 都各自撞过一次,索性把排查过程沉淀成了一份内部对照表,遇到新的模块找不到报错时先照着表查一遍有没有现成结论:
1module.exports = { 2 resolve: { 3 fallback: { 4 crypto: require.resolve('crypto-browserify'), 5 stream: require.resolve('stream-browserify'), 6 path: require.resolve('path-browserify'), 7 buffer: require.resolve('buffer/'), 8 // 确认代码路径不会在浏览器端真正执行到的,直接关闭 polyfill 9 fs: false, 10 net: false, 11 }, 12 }, 13};
fs、net 这类完全没有合理浏览器端替代品的模块,直接设成 false 是更诚实的做法,强行 polyfill 一个空实现反而容易在运行时才暴露"某个功能悄悄失效了"这种更隐蔽的问题。判断一个依赖到底要不要装浏览器端替代包,还是直接关掉,我们的经验是先看这个引用是不是真的会在浏览器运行时执行到——很多时候只是依赖包为了同时支持 Node 和浏览器环境,在代码里做了 typeof window 之类的条件判断,webpack 静态分析阶段看不懂这类运行时判断,会把两个分支的 require 都当成需要解析的依赖,这种情况下 false 通常是更省心的选择。
那个雪碧图插件的坑,多说几句排查细节
前面提到的 SVG 雪碧图插件报错,值得把排查过程摊开讲讲,因为这类"插件内部访问了 webpack 私有 API"的兼容性问题,往后升级其他大版本工具时大概率还会遇到,排查思路是通用的。
最初的报错信息只有一句:
1TypeError: Cannot read property 'tap' of undefined 2 at SvgSpriteWebpackPlugin.apply (node_modules/svg-sprite-webpack-plugin/lib/index.js:23:41)
栈信息直接指向了插件内部的 apply 方法,第一反应是去看这一行代码在干什么。翻源码发现它写的是:
1compiler.hooks.compilation.tap('SvgSpriteWebpackPlugin', compilation => { 2 compilation.mainTemplate.hooks.requireEnsure.tap(/* ... */) 3})
问题出在 compilation.mainTemplate 这个对象上。mainTemplate 是 webpack 4 时代管理 chunk 加载模板代码的核心对象,插件想要往"加载 chunk"这个环节里插入自定义逻辑,都得挂在它的钩子上。webpack 5 为了实现前面提到的运行时精简、以及更灵活的 chunk 加载策略,把这一整套 mainTemplate 相关的钩子体系换成了新的 RuntimeModule 机制,compilation.mainTemplate 这个对象本身还保留着(为了照顾一部分没升级的老插件),但它上面很多具体的钩子已经名存实亡,requireEnsure 这个钩子直接就是 undefined,插件调用 .tap 自然报错。
确认问题出在这里之后,去插件的 GitHub 仓库搜 requireEnsure、mainTemplate 这类关键词,很快找到了对应的 issue,作者已经回复说在准备一个基于新的 RuntimeModule API 重写的分支,但还没发布。这一步印证了前面那条经验:报错信息本身往往不会直接告诉你"这是版本不兼容",但把报错栈定位到插件源码的具体某一行、再结合这行代码用到的 webpack 内部 API 名字去搜索,通常能比较快确认问题性质,不用自己去猜测。
绕过方案我们最终选的是"功能等价迁移",而不是死等插件更新或者自己去改插件源码。原来这个插件做的事情是:扫描一个目录下的所有 SVG 文件,合并成一份雪碧图,同时生成一份路径到雪碧图内 symbol id 的映射,这个过程其实跟 webpack 的模块依赖图没有实质关联,纯粹是"构建前的一次静态资源预处理"。既然如此,没有必要非得让它挂在 webpack 生命周期里跑,改成构建脚本的一部分,用一个独立的 npm script 在 webpack --mode production 之前先跑一遍:
1{ 2 "scripts": { 3 "build:sprite": "svg-sprite ./src/icons -o ./src/assets/sprite.svg", 4 "build": "npm run build:sprite && webpack --mode production" 5 } 6}
这个迁移的代价是雪碧图生成不再能感知到 webpack 的 watch 模式——开发环境下新增一个 SVG 图标,需要手动重跑一次 build:sprite 而不是自动触发。团队内部权衡下来,图标新增频率不算高,这个体验损失能接受,换来的是不再被这一个插件卡住整个升级进度。
持久化缓存会不会"骗"你,怎么验证它没有骗你
引入任何缓存机制,团队里最容易冒出的顾虑都是同一个:会不会哪天缓存脏了、改了代码却没生效,线上跑的是旧逻辑。这个顾虑在 cache-loader、babel-loader 缓存那个年代就存在,升级到 webpack 5 之后缓存做得更"深",这份担心只会更重,值得专门验证一遍。
验证思路和之前验证 [contenthash] 是否真的按内容变化是同一套方法论:制造一个能明确预期结果的最小场景,然后拿真实产物去对照,而不是凭感觉信任工具。具体做法是:先完整构建一次,把 dist/ 整个目录复制一份留底;然后故意改一行业务逻辑(比如把某个函数里的返回值改掉),再构建一次,对比新旧两份产物里对应文件的内容,改动应该原样体现在新产物里;接着把这行改动改回去,也就是让代码恢复到和第一次构建时完全一致,再构建第三次,这次期望的是能命中缓存、构建速度接近前面提到的四秒量级,同时产物内容应该和最初那份留底完全一致。这三步分别验证了"缓存不会让新改动消失"和"没有改动时缓存确实生效"这两件事,缺一不可。
我们还专门测试了一种容易被忽略的边界情况:只改注释、不改任何实际执行的代码,缓存是否会误判为"内容变了"从而白白重新编译一遍。测下来 webpack 5 的持久化缓存是按文件实际内容的哈希判断的,注释也算文件内容的一部分,改了注释确实会让这个文件对应的缓存失效、触发重新处理,这是符合预期的保守策略——它没有做"AST 级别忽略注释差异"这种更激进的优化,代价是极少数只改注释的场景也会触发一次不必要的重新编译,但换来的是不会有"只改了注释、逻辑代码却被缓存污染"这类更危险的假阳性。这类保守但可预测的行为,是我们判断"这份缓存机制可以放心交给团队所有人用、不需要人人都懂内部实现"的关键依据。
Node 版本和其他一些容易漏看的前提
webpack 5 对运行环境本身也提了新要求,官方文档写的是 Node.js 10.13.0 以上,但实际测下来,一些依赖(尤其是涉及 worker_threads 相关能力的插件)在更老的 Node 10 上会有奇怪的行为,团队内部统一意见是直接对齐到当时的 Node 14 LTS,不在这个版本要求上省力气去兼容更老的版本。这一步在升级评估清单里排得比较靠前,因为一旦发现 CI 机器或者某个同事本地的 Node 版本不达标,后面所有的验证工作都无从谈起,属于典型的"先看环境要求,再动手升级"。
另外一个容易漏看的地方是 package-lock.json 或者 yarn.lock 里锁定的一批 loader、plugin 版本。升级 webpack 主包本身很直接,npm install webpack@5 webpack-cli@4,但如果不顺手把 css-loader、style-loader、html-webpack-plugin 这些配套包也升到明确支持 webpack 5 的版本,很可能会出现"webpack 本体是 5,但某个 loader 内部还在用只有 webpack 4 才有的 API"这种半新半旧的诡异状态,报错信息也会显得莫名其妙。这次升级我们干脆写了一个小脚本,把 package.json 里所有跟 webpack 相关的依赖列出来,一个个去 npm 上确认它们最新版本的 peerDependencies 里对 webpack 版本号的要求,再统一批量升级,而不是升完主包之后见招拆招。
灰度升级的具体走法,不是一次性切主分支
核心构建工具的升级,最怕的走法是开一个和主分支差异巨大的长期分支,攒一堆改动之后一次性合并——这样风险全部堆到合并那一刻,出问题也很难定位是哪一步引入的。我们这次的做法是先挑团队里一个体量小、但结构和主项目类似的内部工具项目(前面提到的活动配置后台)完整走一遍升级流程:先跑通开发环境、再跑通生产构建、观察几天没有异常之后,再把这套配置和踩坑记录整理成模板,套到主项目的一个功能分支上。
主项目这边也没有直接替换原有的 webpack.config.js,而是新建了一份 webpack.v5.config.js,通过一个环境变量在 npm run dev 时二选一:
1{ 2 "scripts": { 3 "dev": "webpack-dev-server --config webpack.config.js", 4 "dev:v5": "webpack serve --config webpack.v5.config.js" 5 } 6}
这样团队里想尝鲜的同事可以随时切到 dev:v5 跑一跑,遇到问题反馈到升级评估的文档里,不影响其他人继续用现有配置正常开发。这个并行阶段大概维持了两周,陆续收集到几个前面没在小项目上暴露出来的问题:一个是某个同事本地全局装的旧版本 webpack-cli 覆盖了项目内的版本,导致他那边死活跑不出预期效果,后来在文档里补充了一条"确认用的是项目内 node_modules/.bin 下的 webpack-cli,而不是全局安装的版本";另一个是 webpack serve 这个新的命令写法(对应 webpack 4 时代的 webpack-dev-server 独立命令)在配置里如果还残留着一些 devServer 下已经改名的选项,会被直接忽略而不是报错,容易让人以为配置生效了、实际上根本没生效,这一条后来也补进了迁移检查清单。
webpack serve 这条命令行为上的变化本身也值得记一笔:webpack 4 时代 webpack-dev-server 是一个完全独立于 webpack-cli 的包,命令是 webpack-dev-server;webpack 5 配合新版 webpack-cli,开发服务器被整合成了 webpack serve 这个子命令,底层依然是 webpack-dev-server,但命令行参数解析、配置读取的方式统一收拢到了 webpack-cli 一边。这个变化对配置本身影响不大,主要是命令行脚本要跟着改名,团队里如果有同事写了自己的启动脚本或者 IDE 里配置了自定义的运行命令,这一步很容易被漏掉。
splitChunks 默认策略也变了,vendor 包被拆得更细
评估过程里还发现一个不算"新特性"、但足以让产物体积分布看起来完全不一样的变化:optimization.splitChunks 的默认预设换了。webpack 4 的默认策略偏保守,粗略地说就是"体积超过 30KB、被两个以上 chunk 引用的模块才拆出来",很多项目里第三方依赖最终会被打进一个体积不小的单一 vendors chunk。webpack 5 的默认预设做得更细致,官方文档里称之为 splitChunks.defaultSizeTypes 和一套新的默认 cacheGroups,会进一步区分 node_modules 里的依赖,尝试把不同来源、不同变化频率的第三方代码拆成体积更均衡的多个 chunk,而不是无脑塞进一个大文件。
我们在自己项目上直接对比了一次:什么都不改、只是升级 webpack 主包和相关依赖,npm run build 之后 dist/ 里原来一个几百 KB 的 vendors.[hash].js 变成了三四个体积更小的 chunk。这个变化本身对用户体验是有利的——单个 chunk 越大,任何一次内容变化导致的整包失效影响范围也越大;拆得更细之后,某个不常变的小依赖被单独放进一个 chunk,业务代码或者另一个依赖变化时,不会连带影响到它的缓存。
这也带来一个新的排查场景:升级之后如果去看 Network 面板,会发现首屏请求数比升级前多了几个,第一反应容易以为是"配置错了、chunk 拆得太碎"。我们特意核对了一遍每个新增 chunk 的体积和内容,确认都是合理的第三方依赖分组,不是误拆导致大量体积很小、请求很碎的问题——如果实际项目里发现拆得过碎、多出的请求数造成的开销超过了缓存收益,splitChunks.cacheGroups 依然可以手动收紧回更少的分组数量,只是 webpack 5 的默认值本身已经是一个经过官方重新调校、更适合大多数项目开箱即用的起点,不需要像 webpack 4 时代那样几乎每个项目都得自己重新调一遍 cacheGroups 才能拿到合理的拆分效果。
我们最终还是在默认预设的基础上加了一条自己的分组规则,原因是项目里有一个体积特别大、但几乎不怎么更新的图表库,默认预设按包名简单归类,这个图表库和其他几个中等体积的依赖被分到了同一个 chunk 里,导致这一个 chunk 的体积依然偏大。针对这类"体积大但更新频率极低"的依赖,单独开一个分组能拿到更稳定的长期缓存效果:
1module.exports = { 2 optimization: { 3 splitChunks: { 4 cacheGroups: { 5 // 保留 webpack 5 默认预设的其他分组,只额外补一条 6 chart: { 7 test: /[\\/]node_modules[\\/](echarts|zrender)[\\/]/, 8 name: 'chart-vendor', 9 chunks: 'all', 10 priority: 20, // 优先级要高于默认的 vendors 分组,否则会被默认规则先匹配走 11 }, 12 }, 13 }, 14 }, 15};
priority 这个字段容易被漏配,webpack 内部默认分组本身也带着优先级,自定义分组如果不显式给一个更高的数值,很可能被默认规则抢先命中,加了规则却看不出效果,这一步我们也是对着构建产物反复核对了几次分组结果才确认生效的。
生产环境的 stats 输出也精简了不少
升级过程里一个体验上的小细节:webpack 5 默认的构建日志输出比 4 简洁很多。webpack 4 默认会把每个模块、每个 chunk 的详细信息全部铺在终端里,项目模块数一多,npm run build 跑完屏幕上刷过去的内容根本看不过来,真正想关心的警告和报错反而容易被淹没。webpack 5 默认的 stats 预设换成了更克制的 errors-warnings 加上一份摘要,只列出真正需要关注的报错、警告和最终产物体积概览。
这个变化本身谈不上是什么核心能力提升,但对我们这种模块数上千的项目,日常构建时终端输出的可读性提升是实打实的。如果需要恢复到 webpack 4 那种"事无巨细全部打印"的详细程度,可以显式配置:
1module.exports = { 2 stats: 'verbose', // 需要排查具体某个模块的构建细节时临时开启 3};
平时留着精简的默认输出,真正要排查某个具体模块为什么被打进了某个 chunk、或者为什么体积异常时,再临时切换成 verbose 或者更细粒度的 stats 对象配置去看详细信息,两种模式来回切换比一直盯着刷屏的详细日志高效很多。
stats 其实不一定非要在 verbose 和默认预设之间二选一,更常用的是按需只打开自己关心的那几项:
1module.exports = { 2 stats: { 3 preset: 'errors-warnings', 4 assets: true, // 额外把产物资源列表带出来,排查体积问题时常用 5 chunkModules: true, // 看清楚某个 chunk 具体包含了哪些模块 6 timings: true, // 各阶段耗时,评估构建速度时有用 7 }, 8};
我们排查前面提到的图表库分组问题时,就是靠临时打开 chunkModules: true 才确认新的 chart-vendor 分组里确实装的是预期的那两个包,而不是连带着把别的依赖也裹了进去,验证完就把这几项配置改回去,不留在日常配置里,避免终端输出重新变得吵闹。
source map 相关的 devtool 预设也调整了命名
排查升级问题的过程里,我们把 devtool 这一项也顺带核对了一遍,发现 webpack 5 对几个常用预设做了改名和语义调整。cheap-module-eval-source-map 这个开发环境常用的组合在 webpack 5 里被拆开了,eval 相关的写法统一收敛成了 eval-cheap-module-source-map,语序换了一下,指向的实际行为大体保持一致(用 eval 包裹每个模块、映射到源码行级别、不含列信息),但如果配置文件里是直接复制粘贴旧值过来的,webpack 5 会给出一条提示,说明这个值已经不被识别或者行为有出入,需要对照最新文档换成新的命名。
这类"名字变了但行为差不多"的调整,排查起来比真正的 breaking change 更容易让人掉以轻心——构建过程往往不会直接报错,只是给一条容易被忽略的警告,实际效果却可能悄悄跟预期不一致(比如映射精度变了、或者 source map 里丢失了列信息)。我们这次的处理方式是把 webpack.dev.js 和 webpack.prod.js 里所有 devtool 相关的配置项都重新对照官方文档表格过了一遍,确认每一个环境用的预设名字和实际行为都是当前版本认可的写法,而不是继续沿用可能已经过时的历史配置。
生产环境这边我们坚持的原则和之前一样没有变化:不把完整 source map 直接暴露在公网可访问的产物目录里,hidden-source-map 这个选项在 webpack 5 下命名和行为都还是老样子,继续用来生成独立的 .map 文件、只上传给内部的错误监控平台。
开发环境这边我们最终选定的是 eval-cheap-module-source-map,权衡的是构建速度和调试体验:带 eval 前缀的预设生成速度最快,改一行代码之后重新构建、浏览器刷新的等待时间几乎感知不到;cheap 表示不生成列信息,只精确到行,省掉一部分计算量;module 表示会经过 loader 转换前的原始代码做映射,像 Babel 转换过的代码在 DevTools 里看到的还是转换前的样子,方便直接对照源码调试。生产环境则完全是另一套取舍,hidden-source-map 不在产物里插入指向 .map 文件的注释,浏览器不会主动去请求这份 source map,只有把 .map 文件单独喂给错误监控平台解析堆栈时才用得上,兼顾了"线上报错能定位到源码行"和"不把源码结构透露给普通访问者"这两个诉求。
持久化缓存在多人协作下要注意的一点
团队里不止一个人会在本地跑构建,node_modules/.cache/webpack 这个持久化缓存目录理所当然是各自本地独立的一份,不涉及跨机器共享,这一点在评估阶段就确认清楚,避免有人误以为这份缓存可以像 Git 仓库那样直接共享给同事、从而省下别人的首次构建时间——实际上把这个目录直接拷贝给另一台机器大概率是不可用的,因为缓存文件里记录的路径信息、部分依赖的绝对路径都是和生成它的那台机器绑定的,跨机器直接复用容易读出错乱的结果,webpack 内部虽然会做一些校验、大概率会检测到不匹配从而放弃使用缓存转而重新构建,但与其依赖这层兜底,不如从一开始就明确"这份缓存只在本机有效,谁的机器谁自己攒"。
多人协作下真正需要注意的反而是磁盘空间。持久化缓存写盘之后不会自动清理旧版本,随着依赖升级、代码演进,缓存目录会持续增长。我们这次顺带查了一下项目跑了两周之后这个目录的体积,大概涨到了几百 MB,对现在的开发机磁盘容量不算什么负担,但如果团队里有同事的机器磁盘本来就紧张,或者项目本身依赖体积特别庞大,这是一个值得留意的隐性成本,定期用 rm -rf node_modules/.cache/webpack 清一次、让它重新生成,是简单可行的应对办法,代价只是清理之后下一次构建要重新走一遍冷启动。
CI 上如果决定要用持久化缓存(前面提到过默认全新容器场景下这份缓存基本没用,但如果 CI 平台支持缓存目录跨构建保留),同样要有清理策略,不能只管写不管清。我们内部另一个项目在 CI 上试点跨构建缓存的时候,一度因为长期不清理,缓存目录体积涨到了将近两 GB,反而拖慢了每次构建开始前"恢复缓存"这一步的时间,得不偿失。后来加了一条简单的规则:缓存目录体积超过某个阈值,或者最近一次访问时间超过一定天数,直接清空重来,不追求让缓存永远命中,只追求大多数情况下能命中。
升级本身要不要现在做
评估到这一步,结论是"值得升,但不是本周就切"。持久化缓存和资源模块这两块收益已经验证清楚,兼容性坑目前只发现那一个雪碧图插件、且已经有绕过方案,风险可控。但这类核心构建工具的升级,不适合在临近发布窗口的时候动手——真正执行会挑一个没有紧急需求排期的间隙,先在一个非核心的内部项目上完整走一遍升级流程,跑几天观察有没有别的隐藏兼容性问题,再推广到主项目。这个节奏和几年前 3 到 4 的那次升级评估思路是一致的:核心包的版本号变了不可怕,可怕的是没验证过的周边依赖跟着一起翻车。