相信不少团队都遇到过这样的场景:同一段代码,在开发同事的电脑上构建出来的文件哈希和 CI 服务器上的对不上,diff 工具一比较,发现两个 bundle 内容几乎一样,只是模块顺序不同。这种非确定性构建不仅让缓存失效,还会导致灰度发布、增量比对、产物审计等一系列工程问题。Webpack 5 在这方面做了大量改进,用一套明确的规则取代了过去依赖遍历顺序和内部计数器的模块 ID 分配方式,社区里也有人把这套机制形象地称为构建产物的法治化,一切按规则来,而不是看运气。

一、什么是确定性构建,为什么它重要
确定性构建指的是在输入完全相同的前提下,无论在什么机器、什么时间、什么操作系统上执行构建,输出的产物都应该是字节级别一致的。这看似是一个基本要求,但在 Webpack 4 时代却很难做到。原因在于 Webpack 4 默认使用数字型模块 ID,ID 的分配顺序取决于模块在依赖图中被发现的顺序,而这个顺序又可能受到文件系统返回目录项的顺序、并行处理的调度等因素影响,结果就是同一个模块在不同环境下可能拿到不同的数字 ID,进而导致整个 bundle 的内容发生偏移。
产物不确定带来的连锁反应相当多。首先是长效缓存失效:内容哈希一旦变化,CDN 上的旧缓存全部作废,用户需要重新下载完整文件。其次是增量发布困难:如果你想只发布变化的 chunk,必须依赖产物的稳定性。再者,安全审计也需要可复现的构建,审计人员才能验证线上运行的代码确实来自源码仓库,而不是被人篡改过的版本。
Webpack 5 的解决思路是让模块 ID 和 chunk ID 的生成完全由内容与路径决定,与构建顺序解耦。你可以通过下面的配置明确指定算法:
module.exports = {
optimization: {
// 模块 ID 采用确定性算法,基于相对路径生成
moduleIds: 'deterministic',
// chunk ID 同样基于内容确定
chunkIds: 'deterministic'
}
};deterministic 是 Webpack 5 的默认值,它会对模块的相对路径做哈希运算后映射到一个小范围的数字 ID,ID 集合越小,压缩后产物体积越小,Webpack 在内部做了一个权衡,默认控制在 5 位数字以内。相比 Webpack 4 时代的 optimization.namedModules(用路径字符串当 ID,产物体积膨胀且暴露目录结构),这个方案兼顾了稳定性与体积。
二、持久化缓存:让二次构建飞起来
如果说确定性 ID 解决的是产物稳定问题,那么持久化缓存解决的就是构建速度问题。Webpack 4 的缓存只有内存模式,进程一退出缓存就没了,CI 环境里每次冷启动都要从零开始。Webpack 5 引入了基于文件的持久化缓存,把模块解析结果、依赖关系、转换后的代码等中间产物直接序列化到磁盘上,下次构建时先比对缓存版本与文件指纹,没变化的部分直接复用。
开启方式非常简单,只需要在配置里加几行:
module.exports = {
cache: {
type: 'filesystem',
// 缓存目录,默认在 node_modules/.cache/webpack
cacheDirectory: path.resolve(__dirname, '.webpack_cache'),
buildDependencies: {
// 把 webpack 配置文件本身纳入依赖追踪,配置变了缓存自动失效
config: [__filename]
}
}
};这里有个关键点值得展开讲:buildDependencies 的作用是声明哪些文件会影响构建结果但不属于模块图的一部分。webpack.config.js、postcss.config.js、babel.config.js 这类配置文件修改后,模块本身的内容可能没变,但转换逻辑变了,如果不把这些文件纳入追踪,就会出现改了配置但缓存依然命中、产物没更新的诡异问题。这是持久化缓存使用中最常见的坑。
实测下来,中大型项目的二次冷构建提速通常能达到 60% 到 90%,项目越大收益越明显。另外注意 Webpack 5.74 之后还支持了 cache.allowCollectingMemory 等更细粒度的选项,升级版本后可以关注 changelog 调优。CI 场景下可以把缓存目录上传为 artifact,配合流水线缓存机制,实现跨任务的缓存复用。
三、长效缓存优化与真实内容哈希
Webpack 4 的 [contenthash] 其实名不副实,它计算的是 chunk 级别的哈希,模块引用的 ID 变化会影响最终哈希值。这就导致一个经典问题:只改了一个模块的代码,引用它的多个 chunk 的哈希全部变化,浏览器缓存大面积失效。
Webpack 5 把哈希计算下沉到了模块级别,同时对运行时代码做了拆分优化。配合 splitChunks 与模块联邦使用时,改动一个业务模块不再引起 vendor 包哈希波动。再配合前面提到的确定性 ID,整个缓存链条就完整了:模块 ID 稳定、模块哈希稳定、引用它的 chunk 哈希只在内容真正变化时才变。
推荐的输出配置如下:
module.exports = {
output: {
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js'
},
optimization: {
// 运行时代码单独拆分,避免业务改动牵连 runtime 变化
runtimeChunk: 'single',
moduleIds: 'deterministic',
chunkIds: 'deterministic'
}
};把 runtimeChunk 设为 single 后,webpack 的运行时代码被抽成独立文件,这部分代码本身也是确定性生成的,只要 webpack 版本不变它就保持稳定,业务代码的增删不会导致所有页面的 JS 缓存失效。
四、迁移建议与常见问题排查
从 Webpack 4 迁移过来时,有几个兼容性变化需要留意。第一,hashDigestLength 默认从 20 位缩短为 16 位,如果你们的发布系统对文件名长度有校验逻辑,需要提前确认。第二,Webpack 5 移除了 Node.js polyfill 的自动注入,依赖路径解析的确定性反而更好了,但一些老依赖可能会在浏览器端报 process is not defined,需要手动按需注入。第三,持久化缓存对 webpack 版本敏感,小版本升级后缓存会自动失效重建,这属于正常行为,不要误判为缓存失效。
排查构建不确定性问题时,可以先用 --stats detailed 导出两次构建的模块 ID 对照表,确认是 ID 漂移还是模块内容差异。如果确认是 ID 漂移,检查是否有插件在 modify chunks 阶段做了非确定性的排序操作,比如依赖 Object.keys 的返回顺序。自己编写 loader 或插件时,也要养成对输入排序后再遍历的习惯,比如文件列表先按路径 sort 一下,这是保证确定性的基本功。
总的来说,Webpack 5 这一系列规则化改造,把过去隐式依赖运行环境的构建行为变成了显式依赖内容与配置的确定过程。模块 ID 有法可依,缓存命中有据可查,产物哈希只在内容变化时才更新。对于追求工程确定性的团队来说,把这些配置落地后,构建系统才算真正做到了可预期、可复现、可信赖。