1. 为什么构建产物需要做内容哈希
1.1 从一次“线上没刷新”事故说起
前两年我在负责某业务中台的前端工程化改造,做过一次发布,只是改了三行样式,结果用户端持续反馈“页面没更新”,客服那边截图过来还是旧界面。排查下来问题不在代码本身,而是发布脚本没有改文件名,旧文件还躺在浏览器缓存里,新版本代码根本没被拉取到客户端。
这就是 Webpack 构建产物要做 Content Hashing(内容哈希)的核心原因。把文件内容经过哈希计算,生成一串固定长度的摘要,拼进文件名,比如app.a1b2c3d4.js。内容变了,哈希值就变,文件名跟着变,浏览器发现这是一个从未见过的 URL,就会发起新请求;内容没变,文件名不变,浏览器继续命中缓存。听起来简单,但在 Webpack 生态里,从hash、chunkhash到contenthash的选型差异,直接影响整个项目的缓存命中率和发版效率。
这篇内容适合正在配置或想要优化 Webpack 构建产物的前端开发者,尤其是手动搭建过脚手架、被“明明改了代码却不生效”困扰过的同事。我会把三种哈希的差异讲清楚,给出实际项目里的配置片段,也把我踩过的坑和排查方法列出来,帮你少走几个版本的弯路。
1.2 哈希在版本管理里扮演什么角色
接着上面的场景说。没有哈希的时候,大家惯用的做法是手动在 URL 后面加查询参数,比如app.js?v=20230101,或者交给运维去改静态资源的引用地址。这种方案能顶一阵,但问题很明显:第一,版本号靠人记,漏一次就废一次;第二,哪怕只改了一个字节,版本号也得手动改,否则浏览器照样拿旧文件;第三,多页面项目里不同入口的依赖关系复杂,人工很难判断哪个文件需要更新。
Content Hashing 解决的就是这三件事。哈希本质上是基于文件内容的一次不可逆摘要计算:同一个文件内容,计算出来的摘要永远一致;文件内容只要有一个字节不同,摘要就不一样。你可以把它理解成内容的“指纹”。浏览器和服务器都不需要“记得”你改了哪次版本,只要比对新 URL 就知道该不该重新下载。这比人工维护版本号靠谱得多,也让“长缓存”这个性能优化策略真正有了落地的条件。
这里顺带澄清一个误区:很多人以为给文件名加哈希是为了安全或者防篡改,其实主要目的不是这个,而是“可寻址性”。哈希让每个文件的 URL 和内容形成一一对应关系,资源管理器可以放心地把旧版本文件继续缓存、把新版本文件当作新资源分发。它解决的是工程上的资源版本管理问题,和安全加密是两码事。
2. 三种哈希值,到底差在哪
2.1 [hash]:一次构建一个值
Webpack 最基础的占位符是[hash]。它的计算粒度是整次构建(compilation):所有输出文件共享同一个哈希值。也就是说,这一次构建里产生了 100 个文件,这 100 个文件的文件名里都会带上相同的哈希。
这个方案的缺陷很明显:只要任意一个文件内容变化,重新构建后所有文件名的哈希都会变化,等同于所有静态资源的缓存全部失效。假设你只改了一个按钮的颜色,主页的index.js哈希变了,连带着第三方依赖、图片、字体文件全部换上新的文件名。用户相当于把整个站点的静态资源重新下了一遍。这在中小项目里可能还能忍,一旦项目体量上去了,全量下载的流量和时间成本非常可观。所以在生产环境里,[hash]一般只适合做一些示例项目,或者明确知道自己“不在乎缓存复用”的场景。
2.2 [chunkhash]:按入口模块区分
既然全站一个哈希粒度太粗,Webpack 接着提供了[chunkhash]。这个占位符按照模块分块(chunk)来计算,每个入口(entry)对应的 chunk 有自己独立的哈希。比如多页应用里分了首页、列表页、详情页三个入口,三个入口的 JS 文件哈希互相独立,改了列表页的东西,首页和详情页的文件名不会跟着变。
听起来已经很合理了,但它有个绕不开的问题:chunkhash 计算的是整个 chunk 的内容。如果是单页应用,所有业务代码通常会打包进一个 chunk,那这个 chunk 内部任何一个模块变动,整个 chunk 的哈希就变。再加上把 CSS 也通过 JS 打包的情况下,chunkhash 的缓存命中率并不理想。下面这个场景你应该不陌生:业务方反映“我明明只改了 CSS,为什么 JS 文件也重新下了”。用 chunkhash 就可能出这种情况,因为 CSS 被 style-loader 注入为 JS 模块,CSS 一变,JS chunk 的内容和哈希全变了。
2.3 [contenthash]:以文件内容为准的指纹
[contenthash]的粒度进一步细到“单个文件内容”。它是在文件内容确定之后,针对文件本身计算出来的哈希,和它在哪个 chunk 里没有直接关系。只要这个文件的字节没有变化,不管构建多少次,文件名里的哈希都不会变。
举个实际例子:项目里有main.js和styles.css,CSS 用mini-css-extract-plugin单独提取成文件。你用[contenthash]之后,修改一行 CSS,只有styles.css的文件名会变,main.js的文件名保持原样。反过来,只改 JS 逻辑,CSS 文件名也不会被牵连。这个特性正是解决缓存失效问题的关键。
| 占位符 | 计算粒度 | 变更影响 | 缓存命中情况 | 推荐场景 |
|---|---|---|---|---|
| [hash] | 一次构建 | 一个文件变化,全部文件换名 | 差,全量重新下载 | 演示、临时构建 |
| [chunkhash] | 按 chunk | chunk 内任何模块变化 | 一般,CSS 容易牵连 JS | 多入口且模块互不干扰 |
| [contenthash] | 按文件内容 | 仅该文件自身内容变化 | 好,CSS/JS 互不影响 | 生产环境首选 |
顺带说一个容易混淆的点:CSS 文件用[contenthash]时也有讲究。同一个 chunk 的 JS 里如果通过import './styles.css'引用了样式,而这个 CSS 又是被独立提取出来的文件,这时候两个文件算的是各自的内容哈希,互不影响。但如果你用的是style-loader,样式被打包进 JS,那 JS 的 contenthash 自然会跟着 CSS 内容变——表现上看起来像是“CSS 哈希没变但 JS 哈希变了”,实际上是因为样式内容已经成为 JS 文件内容的一部分。所以配置生产构建时,CSS 和 JS 的耦合关系一定先想清楚。
3. 实际项目中的配置实操
3.1 最基础的内容哈希配置
现在来看配置。Webpack 5 里,最常在output.filename和optimization.splitChunks周围动刀。一个比较常规的生产配置是这样的:
// webpack.prod.js const MiniCssExtractPlugin = require('mini-css-extract-plugin'); module.exports = { mode: 'production', entry: { index: './src/index.js', }, output: { path: path.resolve(__dirname, 'dist'), filename: 'js/[name].[contenthash:8].js', chunkFilename: 'js/[name].[contenthash:8].js', assetModuleFilename: 'assets/[name].[contenthash:8][ext]', clean: true, }, module: { rules: [ { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'], }, ], }, plugins: [ new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css', chunkFilename: 'css/[name].[contenthash:8].css', }), ], optimization: { moduleIds: 'deterministic', runtimeChunk: 'single', }, };这里[contenthash:8]里的:8表示取哈希值的前 8 位。8 位十六进制大概有 40 亿种组合,对于绝大多数项目已经够用。如果你做的是高流量平台、文件数量特别大,可以改成 10 位或 12 位,代价只是文件名长一点,影响不大。clean: true会让 Webpack 在每次构建前自动清空旧文件,避免 dist 目录里堆满历史版本。
3.2 多入口项目的拆分布局
单入口简单,多入口更值得聊。下面这个布局来自我之前维护的某管理后台项目,这个结构可以当作参考模板,具体文件按自己目录调整:
dist/ ├── index.html ├── css/ │ ├── index.3f9a2b1c.css │ └── vendor.77c0d3e4.css ├── js/ │ ├── index.5e7a8b9c.js │ ├── vendor.1a2b3c4d.js │ └── runtime.9f8e7d6c.js这个结构有几个值得注意的点:
vendor是抽出来的第三方库 bundle,比如框架、工具库。这些代码更新频率低,把它们和业务代码分开,业务上线的频繁迭代不会让这个文件频繁换名。runtime是单独拆出来的运行时文件,包含 chunk 之间的映射关系和各模块的 manifest。不拆出来的话,每次新增或删除一个异步模块,主 bundle 的 contenthash 都可能跟着变,原因在于主 bundle 要记录模块表。- 异步路由组件通过
import()加载时,会生成独立的 chunk 文件,文件名同样带 contenthash。这样某个页面组件改了,只影响它自己的异步 chunk,其余文件全部保持原样。
3.3 用构建日志和浏览器验证效果
配置完之后不要急着上线,先验证。最简单的验证方法分两步:
第一步,执行npm run build,看输出日志。Webpack 5 默认会列出一份dist目录下的文件清单,注意比对 JS、CSS、图片各自文件名的哈希是否独立。试一下只改src/index.js里的一个字符串再构建,确认只有对应的业务 JS 变名,vendor 和 CSS 文件名保持不变。
第二步,在本地起一个静态服务,浏览器打开index.html,打开开发者工具 Network 面板。刷新页面时,请求过的资源第二次会被浏览器从缓存读取,状态码前面会有(from disk cache)或(from memory cache)的提示。如果你改动了代码、重新构建、刷新页面,能看到只有文件名变化的资源(比如index.5e7a8b9c.js换成index.a8b7c6d5.js)会重新请求,其他资源维持缓存状态,说明缓存策略已经生效。
4. 那些年踩过的坑:内容没改,hash 却变了
4.1 改了一个入口,其他入口也被连坐
先排除掉打包顺序造成的假象。有些同事改了src/foo.js,重新构建完发现bar.js的哈希也变了,第一反应是“这个哈希是随机的”。其实不是。最常见的原因是模块 ID 不稳定。Webpack 4 时代,默认模块 ID 会按照模块被解析的顺序分配成自增数字。你新增或删除一个模块,它在依赖图里的位置就变了,后面的模块 ID 整体往后顺延或往前压缩。模块 ID 一变,整个 chunk 的拼接顺序、内容就都变了,即便业务逻辑一个字没改。
解决思路很明确:把模块 ID 从“顺序编号”改成“基于模块路径或内容生成的可预测编号”。Webpack 5 里直接用optimization.moduleIds: 'deterministic';Webpack 4 里可以用HashedModuleIdsPlugin达到类似效果。配置好之后,模块在依赖图里的相对位置变化不会再引发大面积哈希波动。
4.2 程序集脚本变动,竟然动了 vendor
另一种常见情形:你只是调整了入口文件里的代码结构,结果vendor的哈希也变了。这通常和runtimeChunk以及公共代码抽取配置有关。默认情况下,Webpack 会把模块之间的引用关系写进 bundle 内部,当 chunk 数量、顺序或模块列表变化时,这些引用信息也会变。把runtime单独拎出来、使用splitChunks抽取公共模块之后,业务代码变化产生的“涟漪”会被限制在业务 bundle 和 runtime 文件里,不会再扩散到 vendor。
我一般这样配置 splitChunks:
optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor', priority: 10, enforce: true, }, }, }, runtimeChunk: 'single', moduleIds: 'deterministic', }chunks: 'all'会同时处理异步 chunk 里的公共模块,效果更彻底。priority是优先级,公共模块既满足默认条件又满足 vendor 条件时,优先进 vendor 组。
4.3 CSS 被牵连:不是 CSS 变了,是 CSS 进了 JS
前面说过 style-loader 的情况。再补充一个很隐蔽的坑:用mini-css-extract-plugin时,如果两个页面入口共同引用了一段公共 CSS,而这个公共 CSS 被打进了一个公共 chunk,那任何页面引用公共 chunk 的模块列表变动,都会让这个公共 CSS 的哈希变化。表面上看是“改了首页,详情页的公共样式文件名变了”,深层原因还是 chunk 归属关系发生了变化。
这种问题靠 contenthash 本身救不回来,得靠架构层面的拆分。把“全局基础样式”和“页面局部样式”分开,全局样式的提取路径尽量稳定,避免公共 chunk 频繁增删模块。对项目来说,改动文件后查看构建产物,如果出现非预期的大范围文件名变动,优先怀疑模块归属,而不是哈希算法出了问题。
4.4 排查哈希波动的小工具清单
排查时我喜欢把构建产物做成前后对比。比较省事的办法:构建完在 dist 目录里跑一遍文件清单导出:
find dist -type f -exec md5sum {} + | sort > build_before.txt改动代码后再构建一次,再导一份build_after.txt,用 diff 对比哪个文件哈希变了、哪个没变。这样能快速锁定问题文件,再针对单个文件去分析它属于哪个 chunk、引用了哪些模块。
如果是分析 chunk 内部模块占比,可以用webpack-bundle-analyzer亮出可视化依赖图,或者用webpack --profile --json > stats.json导出构建统计信息,配合在线分析工具查看各 chunk 包含了哪些模块。这一套流程下来,绝大多数哈希异常都能定位到具体模块层级,而不至于对着构建产物抓瞎。
5. 配置完成后,怎么和服务端缓存配合
5.1 长缓存与 immutable 的用法
哈希解决了“文件名和内容对应”的问题,服务端如果能配合好,效果会再上一个台阶。带 contenthash 的资源,可以在请求响应头里设置非常长的缓存时间,常见策略是Cache-Control: public, max-age=31536000, immutable。
这里immutable值得说道一下:它告诉浏览器,这个 URL 在过期之前绝对不需要重新验证。因为文件名已经包含内容哈希了,文件名不变就代表内容不变,内容不变就无需重新请求。反过来,文件名变了就是一个全新的 URL,浏览器会自然发起新请求。所以这个设置是安全的,不必担心用户更新不了。
注意:
index.html这类 HTML 文件绝对不能做长缓存。HTML 里引用带哈希的资源文件,如果 HTML 被浏览器缓存了,那你发布新版本时用户拿到的还是老 HTML,引用的还是旧资源。HTML 的响应头应该设置成no-cache,意思是“每次使用前都向服务器确认一次”,确认后如果文件没变,服务器会返回 304,开销很小。
5.2 服务端保留多版本与回滚
Content Hashing 的另一个好处是天然支持多版本共存。服务器上可以同时存在index.a1b2c3d4.js和index.e5f6a7b8.js——它们没有重名冲突,因为文件名不同。发布新版本时不用删旧文件,正在用旧版本页面的用户继续从缓存读取老资源;万一发布出现问题需要回滚,只要把 HTML 指回旧的资源 URL,整个站点就回到旧版本了。
基于这个机制,我建议在部署流程里保留最近几个版本的构建产物,不要每次部署都清空 dist 目录。清理策略可以按时间或按版本数量,比如保留最近 5 个版本。这样能最大限度降低回滚复杂度,也让 CDN 缓存刷新不用做得太激进。
5.3 一个关于哈希长度的教训
我在早期项目里为了图美观用过[hash:6]。6 位十六进制的组合数是 1600 万左右,当时觉得“世界嘛,哪有那么多文件”。直到有一次部署后线上出现极低概率的资源串号问题:某个文件哈希恰好撞上了另一个文件的哈希,CDN 上缓存了新内容,但部分节点仍旧拉着旧内容给客户端。
那次之后我把所有生产配置统一改成了至少 8 位,并且增加了一道构建校验脚本,在构建完成时检查产物中是否有重名文件。重名文件出现的概率虽然已经极低,但校验成本几乎为零,却能避免一次灾难性事故。这件事给我的教训是:在缓存策略这种基础环节上,别为了追求文件名短小而去压缩安全余量。
5.4 我自己会额外做的一件事
最后分享一个习惯:在 CI 上跑构建后,我会生成一份“版本号与产物哈希对照表”,比如version-2024-06-01.json,记录这次构建的版本信息、每个入口文件对应的哈希、build 时间等。这样线上出问题时,前端可以拿着服务器上的实际文件名迅速反查当时发布的是哪个版本,而不是翻聊天记录找谁说“我改过什么”。
这套流程跑顺之后,再看 Webpack 构建输出的那一堆带哈希的文件名,会觉得每一步都有迹可循。配置前先想清楚三种哈希的粒度差异,配置完再去浏览器和服务端双重验证,中间遇到的哈希波动问题也基本都能用模块依赖关系解释清楚。Content Hashing 这个知识点不大,但确实值得花点时间一次配置到位——至少我现在看一个项目,第一件事就是看它的产物文件名有没有合理遵守这个规则。