每次 build 都变个 hash,我们翻了一遍 webpack、esbuild、Rspack 的默认策略,看谁的指纹最稳
如果只追求「内容不变、哈希不变」,webpack、esbuild、Rspack 三者的默认配置都做不到,但翻完源码和默认策略后,结论很明确:Rspack 在指纹稳定性上最接近 webpack 的语义,esbuild 则完全不适合需要稳定 contenthash 的场景。
前端工程里,静态资源的哈希指纹直接决定 CDN 缓存命中率。理想情况是:文件内容没变,哈希就不能变;内容变了,哈希必须变。但真实世界里,构建工具默认行为往往会引入一些「非内容因素」,导致每次 build 出来的 hash 都不同。这篇文章把三个工具翻了一遍,对比它们的默认指纹策略、踩坑点和配置方案。
webpack:contenthash 是「内容哈希」,但模块 ID 会捣乱
webpack 的 output.filename 支持 [contenthash],语义上它应该只依赖文件内容。但实际上,webpack 的 contenthash 是基于模块最终产物计算的,而模块 ID 的分配会影响产物内容。默认情况下,optimization.moduleIds 是 'deterministic'(webpack 5 起),这已经比 webpack 4 的递增数字 ID 稳定得多——deterministic ID 基于模块路径做哈希,不随新增/删除模块大幅漂移。
但真正让 contenthash 不稳定的是 runtime 和 chunk 之间的引用关系。webpack 默认会把 runtime 代码内联进每个 entry chunk,而 runtime 里包含模块 ID 映射表。只要模块 ID 变了,entry chunk 的 contenthash 就会变。即便业务代码一行没改,新增一个无关模块也可能导致 entry chunk 的 hash 变化。
解决方案是把 runtime 单独抽出来:optimization.runtimeChunk: 'single'。这样业务 chunk 只包含自己的模块代码,contenthash 基本稳定。另外,CSS 提取需要配合 mini-css-extract-plugin 的 contenthash,而 JS 里引用的 CSS 文件名变化会导致 JS chunk 内容变化——这是另一个常见的「JS hash 跟着 CSS 变」的坑。
webpack 的 contenthash 还有一个历史遗留问题:它计算的是「模块代码 + 模块 ID 引用」的哈希,不是纯文件内容的哈希。所以即使抽了 runtime,如果某个模块的 ID 因为 deterministic 策略的边界情况变化了,contenthash 还是会变。概率很低,但存在。
esbuild:hash 是「本次构建的哈希」,内容稳定是奢望
esbuild 的 entryNames 和 assetNames 支持 [hash],但需要特别注意:这个 [hash] 默认是 整个构建的哈希,不是单个文件的内容哈希。只要本次构建中任何一个输入文件发生变化,所有输出文件的 [hash] 都会变。
这意味着在 esbuild 里,你改了 A 页面的一行代码,B 页面的 JS 文件 hash 也会跟着变。对于多 entry 的项目,这几乎等于放弃了长期缓存。esbuild 官方文档也承认这一点,并建议使用 [contenthash]——但 esbuild 直到 0.19.0 版本(2023 年 8 月)才正式支持 [contenthash],而且这个 contenthash 的计算范围是「该输出文件对应的所有输入文件」,不是「该输出文件的内容本身」。
具体来说,esbuild 的 contenthash 是基于输出文件内容的,但它有一个额外的设计:如果启用了 code splitting,一个 chunk 的 contenthash 会包含它 import 的所有 chunk 的 hash。这是为了让 chunk 之间的引用关系变化时,引用方的 hash 也能更新。但副作用是:被引用的 chunk 内容变了,所有引用它的 chunk 的 hash 都会变。这比「整个构建的哈希」好一些,但依然比 webpack 的 contenthash 传播范围大。
还有一个细节:esbuild 的 CSS 输出如果和 JS 分离(通过 loader 配置),CSS 文件的 contenthash 是独立的,但 JS 里对 CSS 文件名的引用不会写进 JS 内容——esbuild 不做 CSS 提取,它只是把 CSS 当成独立入口文件处理。所以 JS 和 CSS 的 hash 解耦,这反而是个优点。
Rspack:对齐 webpack 的 contenthash 语义,但默认更稳
Rspack 的 output.filename 支持 [contenthash],语义跟 webpack 基本一致——考虑到 Rspack 定位就是 webpack 的兼容替代品,这个对齐是刻意的。Rspack 的 moduleIds 默认也是 'deterministic',runtimeChunk 默认不拆分,所以默认配置下的指纹稳定性跟 webpack 相同:如果不抽 runtime,entry chunk 的 hash 会因为 runtime 里的模块 ID 映射变化而波动。
但 Rspack 有一个 webpack 没有的优势:它的 contenthash 计算更纯粹。Rspack 的 contenthash 直接基于最终 chunk 的代码内容计算,不包含模块 ID 的分配逻辑。这意味着即使模块 ID 因为 deterministic 策略变化了,只要最终产物代码不变,contenthash 就不变。这一点在 Rspack 的源码注释里有明确说明:contenthash 的计算对象是「rendered chunk content」,而不是「module graph 的某种中间表示」。
实测中,Rspack 抽了 runtimeChunk 之后,业务 chunk 的 contenthash 稳定性非常好。改一个无关模块,其他 chunk 的 hash 不变;改一个被多个 chunk 引用的共享模块,只有直接引用它的 chunk 会变(以及 runtime 会变),传播范围比 esbuild 小得多。
三者的指纹稳定性对比
| 维度 | webpack | esbuild | Rspack |
|---|---|---|---|
| contenthash 是否默认可用 | ✅ | ⚠️ 0.19+ 支持 | ✅ |
| 默认 hash 是否稳定 | ❌ 需抽 runtime | ❌ 构建级 hash | ❌ 需抽 runtime |
| 配置后稳定性 | 高(偶发模块 ID 漂移) | 中(chunk 引用传播) | 高(纯产物内容哈希) |
| hash 传播范围 | 最小 | 较大 | 最小 |
一个需要特别注意的点:esbuild 的 [hash] 在 0.19 之前是构建级哈希,这个行为让很多早期使用 esbuild 做多 entry 项目的团队踩了坑。如果你在 2023 年之前用 esbuild 做生产构建,你的缓存策略大概率是失效的——每次发版所有文件 hash 全变,CDN 缓存命中率趋近于零。
实操建议:怎么配才能让指纹最稳
webpack 用户:必须设置 optimization.runtimeChunk: 'single',同时确保 optimization.moduleIds: 'deterministic'(这是默认值,别改)。如果用了 CSS 提取,CSS 文件名用 [contenthash],JS 里不要把 CSS 文件名硬编码进业务代码。最后,检查是否有插件在构建时往产物里注入时间戳或版本号——这类插件是 hash 不稳定的头号元凶。
esbuild 用户:升级到 0.19+ 并用 [contenthash] 替代 [hash]。如果项目是多 entry 且共享模块较多,接受「被引用 chunk 变化会传播到引用方」这个事实。如果无法接受,考虑用外部脚本做二次哈希——构建完成后,用文件内容的 SHA-256 重命名文件,并替换 HTML 里的引用。这虽然绕开了 esbuild 的哈希机制,但在 esbuild 生态里是常见做法。
Rspack 用户:和 webpack 一样设置 optimization.runtimeChunk: 'single'。Rspack 的 contenthash 计算更纯粹,抽了 runtime 之后基本可以放心。唯一需要注意的是:如果用了 Rspack 的实验性功能(如 experiments.css),确认该功能下 contenthash 的计算范围是否符合预期——实验性功能的哈希行为可能在版本间变化。
常见问题
为什么我抽了 runtimeChunk,webpack 的 hash 还是偶尔变?
最可能的原因是模块 ID 漂移。deterministic 模块 ID 基于模块的相对路径计算,如果项目里模块数量较多(超过 10000 个),或者模块路径发生了变化(比如移动了文件位置),ID 可能重算。另一个常见原因是某个 loader 或插件在产物里注入了构建元数据。用 webpack --stats json 对比两次构建的产物,diff 出具体变化的内容,就能定位到是模块 ID 还是注入内容导致的变化。
esbuild 的 contenthash 和 webpack 的 contenthash 是一回事吗?
不是。esbuild 的 contenthash 计算的是「输出文件内容 + 它引用的其他 chunk 的哈希」,传播范围比 webpack 大。webpack 的 contenthash 只计算该 chunk 自身的最终代码内容。所以同样一个共享模块变化,esbuild 会更新所有引用它的 chunk 的 hash,webpack 只更新直接包含该模块代码的 chunk。
Rspack 的 contenthash 和 webpack 完全兼容吗?
语义上兼容,但底层计算逻辑不同。Rspack 基于最终渲染产物计算,webpack 基于模块图计算后再渲染。这导致在极少数边界情况下(比如模块 ID 变化但最终代码相同),Rspack 的 hash 保持不变,webpack 的 hash 会变。反过来,如果最终代码因为某种原因不同但模块图相同,结果也可能不同。实际项目中,两者在「内容不变 hash 不变」这个目标上的表现,Rspack 略优。
有没有办法让三个工具的 hash 完全一致?
没有,也不应该追求这个。不同工具的产物代码本身就不同(webpack 的 runtime 包装、esbuild 的模块格式、Rspack 的 tree shaking 结果),hash 自然不同。追求的是「同一个工具、同一份代码、两次构建,hash 一致」,而不是跨工具的一致性。