ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Webpack Content Hashing 实战:从前端缓存失效到哈希稳定配置

Webpack Content Hashing 实战:从前端缓存失效到哈希稳定配置 做前端发布最怕什么怕的是包发上去了用户那边还是旧版本。以前我排查线上缓存问题时十有八九都回到同一个根上Webpack的Content Hashing配置不对。文件名没带上内容指纹或者三种哈希占位符用混了导致浏览器把旧资源缓存得死死的。这篇我就把Webpack里的Content Hashing从原理到配置、从翻车现场到排查手册完整写一遍适合所有需要手动维护构建配置、关心打包产物和缓存命中率的前端同学。不管你用的是Webpack 4还是Webpack 5只要走“构建后部署静态资源”这条路就一定绕不开文件指纹这个话题。Content Hashing的设计初衷很简单让每个产物文件的名字只由文件内容决定内容不变文件名绝对不变内容一旦有变化文件名必然变化。这样浏览器就可以放心大胆地做长缓存因为只要HTML引用的文件名变了就说明后端又发布了新代码CDN会去源站拉新文件用户也会自然下载新资源。今天这篇文章没有太多云里雾里的理论全是能直接帮你省事的配置和排查经验。1. 哈希到底是解决什么问题先搞懂Content Hashing的定位1.1 文件指纹和浏览器缓存之间的关系浏览器缓存是性能利器但也可能是上线事故的温床。以JS文件为例如果服务端对main.js配置了Cache-Control: max-age31536000那么一年内浏览器都不会再向服务器发起这个文件的请求。你在代码仓库里改了业务逻辑、重新打包、重新发布但HTML里引用的还是main.js浏览器一看文件名没变直接命中本地缓存服务器上那个新文件它根本不知道。线上改了啥用户完全感知不到这就是“上线了但用户还在旧版本”的经典死循环。解决办法的本质是让文件“改名”。内容变了文件名也变了HTML里引用的地址自然更新浏览器才会去请求新文件。具体到Webpack就是在输出文件名中加入内容指纹。这也就是Content Hashing这个机制存在的根本原因。回看整个前端的缓存策略设计通常的做法是HTML走协商缓存或极短缓存因为它是整个更新链条的“出口”必须保证用户每次访问都能拿到最新HTML而JS、CSS、图片等带指纹的静态资源则可以放心地设置一年强缓存文件名变了旧缓存自动失效文件名没变说明内容没变直接用本地缓存即可。这套组合才是前端上线后缓存不出乱子的地基。1.2 hash、chunkhash、contenthash三种哈希的区别Webpack中一共存在三种和文件命名相关的哈希占位符很多刚接触的同学会把它们混为一谈。其实分辨起来并不难关键在于看它们的“粒度”。[hash]构建级别的哈希。它对应一次Webpack编译compilation只要本次构建中有任何输入发生变化所有产物文件名都会跟着变动哪怕你只改动了一个组件里的字符所有JS、CSS文件名全变。这是粒度最粗的一种生产环境用它来管理缓存基本等于放弃缓存命中。[chunkhash]chunk级别的哈希。同一个chunk内产出的所有文件共享同一个哈希。所谓chunk你可以暂时理解为“一次模块归并单元”一个入口及其同步依赖通常会打成一个chunk。如果CSS是从JS中抽出来的它和JS仍然属于同一个chunk所以会共享chunkhash。这意味着你只改了一段CSSJS主文件的名字也会变。[contenthash]文件内容级别的哈希。Webpack在产物文件生成之后对文件的具体字节内容计算摘要内容不变哈希就不变。这是粒度最细、最贴合缓存策略的一种。用一个生活化的类比来记hash像“班级合影”班里任何人变了全班就得重新照一张chunkhash像“小组合影”组里任何人变了整组重照contenthash像“单人证件照”只有自己变了自己才重拍。后者显然是最适合用于长时间浏览器缓存的。1.3 contenthash的生成原理与细节有些同学可能会误以为contenthash是直接对源码做摘要其实它的计算对象是构建后的最终产物内容。也就是说在产物落盘之前Webpack把该文件最终的字节序列交给哈希算法计算摘要再从摘要中截取片段拼接到文件名里。Webpack 4和Webpack 5默认的哈希算法是md4摘要默认长度是20位可以通过output.hashFunction更换算法、通过output.hashDigestLength控制截取长度。生产配置里常见的形式是contenthash:8意思是截取8位十六进制字符作为文件名指纹。为什么是8位8位十六进制意味着约42亿种组合绝大多数业务体量下碰撞概率是可以忽略的。如果项目特别大或者CDN缓存节点特别多、想追求更低碰撞率可以提到10位甚至12位代价只是文件名稍微长一点基本不影响体验。这里有一个容易被忽略的细节因为contenthash基于最终产物内容而非源码内容所以凡是会影响产物内容的因素比如压缩插件配置、代码分割方式、注释剥离规则都可能让哈希发生变化即使源码一行没动。这不算Bug反而是特性——同一个字节序列永远对应同一个文件名缓存一致性有保证。2. 配置实操给项目打上稳定的Content Hash指纹2.1 入口JS文件的基础配置在Webpack的output配置里filename就是入口文件的命名模板。最基础的配置是这样output: { filename: js/[name].[contenthash:8].js, }[name]对应entry中配置的键名比如入口叫main产物就是js/main.4201f3a8.js。多个入口各自生成互不影响。除了入口文件还有一类文件不能漏就是异步模块被拆出来的chunk它需要使用chunkFilename配置output: { filename: js/[name].[contenthash:8].js, chunkFilename: js/[name].[contenthash:8].chunk.js, },这里的[name]通常来自异步模块的魔法注释比如import(/* webpackChunkName: about */ ./pages/about.js)产物就是js/about.9a2fef34.chunk.js。如果没有魔法注释Webpack会尝试从模块路径推断名字实在推断不出来就只能用数字ID这对缓存稳定和排查都不友好。我给异步页面写魔法注释的习惯从那时一直保留到现在简单一行注释哈希稳定性和可读性都上来了。2.2 CSS文件的Content Hash配置CSS这类样式文件在Webpack里天然依赖MiniCssExtractPlugin来抽取。很多项目一开始用的是插件默认配置结果发现只改了一行样式JS入口文件的哈希也变了。原因就在前面讲的chunkhash联动CSS与JS属于同一个chunk整个chunk内容一变共享的哈希就全变。标准做法是给CSS单独指定contenthash让它和JS解耦const MiniCssExtractPlugin require(mini-css-extract-plugin); module.exports { plugins: [ new MiniCssExtractPlugin({ filename: css/[name].[contenthash:8].css, chunkFilename: css/[name].[contenthash:8].chunk.css, }), ], };这样CSS文件哈希只由CSS内容决定JS文件哈希只由JS内容决定。同属一个chunk但文件名各管各的真正做到“谁变了谁改名”。开发环境则不需要这么较真直接用style-loader热更新更快也就不存在CSS独立哈希的需求。这也是为什么我通常会把Webpack配置拆成dev和prod两份开发环境减少复杂哈希计算生产环境才启用完整指纹策略。2.3 图片和字体的哈希配置静态资源的处理策略在Webpack 5里通过asset模块统一了默认小于8KB的资源会内联成Base64超过8KB的走独立文件。图片、字体这类独立资源默认输出位置可以通过output.assetModuleFilename统一控制output: { assetModuleFilename: assets/[name].[hash:8][ext], },这里占位符用的是[hash]但不要担心资产模块里的[hash]是资产内容的指纹不是构建总哈希它的语义其实和contenthash一致。想要分门别类管理就写generatormodule: { rules: [ { test: /\.(png|jpe?g|gif|svg)$/i, type: asset/resource, generator: { filename: images/[name].[hash:8][ext], }, }, { test: /\.(woff2?|eot|ttf|otf)$/i, type: asset/resource, generator: { filename: fonts/[name].[hash:8][ext], }, }, ], },有一点值得提醒小图和小字体内联成Base64之后会塞进JS或CSS文件里当它对应的JS或CSS内容变化内联资源的“实例”也跟着变了这符合预期。但如果你有很多小图片每次改动都会导致嵌入它的文件哈希变化间接降低了缓存的稳定度。所以具体情况要具体分析小资源内联不一定总是最优解。2.4 一份可以直接抄的生产级Webpack 5配置直接给一份我目前在用的精简生产配置覆盖了JS、CSS、图片、字体哈希策略统一是内容指纹// webpack.prod.js const path require(path); const HtmlWebpackPlugin require(html-webpack-plugin); const MiniCssExtractPlugin require(mini-css-extract-plugin); module.exports { mode: production, entry: { main: ./src/index.js, }, output: { path: path.resolve(__dirname, dist), filename: js/[name].[contenthash:8].js, chunkFilename: js/[name].[contenthash:8].chunk.js, assetModuleFilename: assets/[name].[hash:8][ext], clean: true, publicPath: /, }, module: { rules: [ { test: /\.css$/, use: [MiniCssExtractPlugin.loader, css-loader], }, { test: /\.(png|jpe?g|gif|svg)$/i, type: asset/resource, generator: { filename: images/[name].[hash:8][ext], }, }, ], }, plugins: [ new HtmlWebpackPlugin({ template: ./src/index.html, }), new MiniCssExtractPlugin({ filename: css/[name].[contenthash:8].css, chunkFilename: css/[name].[contenthash:8].chunk.css, }), ], optimization: { moduleIds: deterministic, chunkIds: deterministic, runtimeChunk: single, splitChunks: { chunks: all, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10, chunks: initial, }, }, }, }, };optimization里的几项和下一部分关系很大下面拆开讲。output.clean替代了老版本的CleanWebpackPlugin构建前自动清空dist省得每次手动删文件。3. 哈希漂移的三大元凶为什么上线后缓存还在3.1 元凶一模块ID不稳定contenthash本身很简单但搭配不当会出现一个怪现象这次构建明明没改任何文件产物的哈希变了上次只加了一个小工具函数结果所有JS文件名都变了。问题多半出在模块ID上。Webpack构建时会给每个模块分配一个ID。Webpack4以前的默认规则是natural按模块被解析的顺序依次分配数字1、2、3、4这样排下去。源文件一多你在项目中间新增一个模块原本排在它后面的所有模块ID全要往后挪。模块ID变了引用这些模块的代码内容也就变了而contenthash基于最终产物内容计算于是连锁反应就发生了。Webpack 5已经默认把moduleIds改成了deterministic模块ID基于模块路径和内容生成短哈希和解析顺序无关。Webpack 4可以通过配置开启类似能力optimization: { moduleIds: deterministic, }把“按座位号找人”换成“按身份证号找人”这句话很好记座位号会随人数增减变动身份证号一生不变。模块ID如果不稳定内容哈希就是无根之木。3.2 元凶二Chunk ID不稳定模块ID搞定后异步chunk还有类似的坑。在没有魔法注释的情况下Webpack给chunk生成的ID是数字经常是0、1、2、3这样的顺序编号。一份代码里异步路由是按顺序声明的如果你在已有路由中间插入一个新页面Webpack会分配一个新的chunk ID原本排在新页面后面的chunk ID就会依次改变。Chunk ID变了chunk文件名和内容里的映射关系就会变最终导致相关chunk的contenthash变化。解决办法同样是把ID生成规则固定下来optimization: { chunkIds: deterministic, }同时我强烈建议给每一个异步模块加上魔法注释。名字固定了Webpack就不太容易因为自动推断或数字编号出岔子排查产物时一眼就能看出哪个chunk对应哪个业务模块。实测下来这一步对“稳定老异步chunk哈希”的作用非常直接。3.3 元凶三runtime代码搅动全局第三个坑藏得更深。每个Webpack产物文件里不仅有业务代码还有一段webpack runtime模块查找表、加载函数、异步chunk映射关系等。默认情况下这段runtime会被重复写进每个入口文件中。当模块ID或chunk ID有变动每个入口文件里内嵌的runtime就跟着变于是所有入口文件的contenthash同时漂移。生产配置里一定要把runtime单独拆出来optimization: { runtimeChunk: single, }runtime被抽成独立的runtime.js后业务入口文件就不再包含那套加载逻辑只有业务模块代码本身能做到“哪个模块变了哪个文件换名”。runtime文件本身因为保存着整个项目的模块映射每次更新可能都会变但它就是个几十KB的小文件让它变远比让所有入口文件失效划算。3.4 组合拳contenthash加这三个optimization配置到这里长期缓存最核心的四项配置已经齐了filename/chunkFilename使用contenthash占位符optimization.moduleIds设为deterministicoptimization.chunkIds设为deterministicoptimization.runtimeChunk设为single四者缺一不可。只配contenthashmoduleId一变照样全局刷新只配moduleIdsruntime没拆照样入口全变都不配contenthash能发挥的威力很有限。我在一个老项目里就亲历过当时只加了contenthash和runtimeChunkmoduleIds没管每次发版main.js都在变两天排查下来才意识到是模块ID问题。四个配置一起加上之后文件名变动立刻收敛线上缓存命中率也就稳住了。4. 打包优化配置建设可长期复用的缓存友好产物4.1 SplitChunks拆包把公共依赖和业务代码分开contenthash要真正发挥作用前提是业务代码与第三方依赖、共享代码尽量分开。如果不拆分第三方库和业务代码混在同一个main.js里。第三方库可能一个月才升级一次业务代码却天天在改两者在同一文件内业务一变整个main.js的哈希就变用户下载的绝大部分内容其实是没变化的第三方库代码。这对大项目来说每次发版都相当于让用户把公共依赖重新下载一遍。于是生产环境基本都会用optimization.splitChunks拆包最经典的做法是把node_modules里的第三方库单独打成一份vendorsoptimization: { splitChunks: { chunks: all, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10, chunks: initial, reuseExistingChunk: true, }, }, }, },好处很直接业务代码频繁变动时vendors指纹纹丝不动第三方依赖升级时也只影响vendors自身。两个区域的缓存不再互相“污染”。如果第三方依赖特别多、vendors体积过大还可以按依赖包继续拆分比如React全家桶单独一个chunk、工具函数库单独一个。这个需要结合bundle分析报告来定没有绝对最优。这里还要多提醒一句vendors哈希稳定的前提是依赖版本真的不变。项目里必须使用package-lock.json或yarn.lock并且构建机不要随手执行无中生有的安装命令否则即使没人改依赖锁文件差异也可能导致vendors内容变化哈希漂移又悄悄回来了。我见过不止一次“明明没改业务发版后所有缓存全失效”最后定位到是某个同事在新环境里重新解析了依赖树。4.2 验证哈希变化的正确姿势配置写完后我建议至少在测试环境做一次“双构建验证”。第一次构建后把dist目录里的文件名记录成清单。然后修改一个业务模块再构建对照两次清单正常应该看到只有被修改业务模块所在的JS文件哈希变化CSS、第三方库、其他未涉及模块的哈希保持不变如果新增了异步页面新页面会产生一个新chunk老异步chunk哈希不变runtime的哈希可能会变。再模拟一次“只升级第三方依赖”的构建此时只有vendors相关哈希变化业务文件不受影响。这套验证可以手工做也可以写成脚本固化在CI里。我见过一次哈希配置回归是某次提优化时有人顺手把contenthash改成了hash构建不报错发布后所有静态资源文件名全变静态资源缓存直接废掉。后来加了“构建两次对比文件名”的检查项这种问题才断根。4.3 CDN发布与publicPath的配合技巧文件名里的contenthash不只是给浏览器看的也是给CDN节点看的。CDN的缓存key基本就是完整URLURL变了节点对旧URL的缓存自然失效命中新URL时CDN会回源站拉新资源。所以有三件事要盯紧。一是publicPath要配置成CDN的绝对地址或相对地址Webpack生成HTML时会把资源引用替换成带CDN域名的URL。二是静态资源的缓存时间可以设置成一年甚至更长反正哈希已经保证了内容与URL一一对应。三是HTML文件绝对不能跟着长缓存最好设置成no-cache或几分钟的短缓存否则HTML引用不到新资源URL哈希策略就白做了。我在服务端框架里见过一个很隐蔽的事故一条Cache-Control规则把HTML也缓存了一小时结果发完版本用户访问到的还是旧HTML再由旧HTML跳到旧静态资源URL等于整体失效。这个坑和哈希本身无关但一旦发生比哈希问题复杂得多。5. 常见问题排查实录哈希问题速查手册5.1 症状与原因对照表这些年我遇到的哈希问题基本可以收敛进下面这张表每条背后都有真实案例支撑现象最可能原因建议处理所有产物文件名带同一个哈希一次构建全变使用了[hash]占位符换成contenthash改了CSSJS文件哈希也跟着变CSS与JS共享chunkhash或MiniCssExtractPlugin没单独配contenthash检查CSS的filename/chunkFilename配置没改代码多次构建文件名却不一样moduleIds/chunkIds不稳定或构建环境不一致设置deterministic统一Node环境和依赖锁文件只加了一个异步页面老异步chunk哈希全变chunkId非deterministic设置chunkIds: deterministic给异步模块加魔法注释随便改哪个模块所有入口JS哈希都变runtime没有拆出被打进每个入口设置runtimeChunk: single改了源码构建后文件名的确变了但线上用户还是旧资源HTML缓存过长或CDN没刷新修HTML缓存策略手动刷新CDN节点contenthash时稳时不稳偶尔这个文件动、偶尔那个文件动splitChunks配置不合理公共模块归属漂移固定cacheGroups的name与test范围避免模块在不同组间移动第七行特别提一下。公共模块归属漂移意思是同一个公共模块这次构建被归到cacheGroups里的A组下次由于源码顺序变化被归到B组它所在的文件哈希自然变化。解决办法是让cacheGroups的条件足够刚性不要依赖相对路径推断。5.2 几个容易忽略的细节关于contenthash还有一些细节容易被忽视。第一Webpack 5虽然默认moduleIds和chunkIds都是deterministic但老项目升级后可能还保留着旧配置显式的natural会把新默认值覆盖掉记得检查。第二哈希长度不是越长越好但也不是越短越好。8位在大多数业务规模下够用做微前端或者管理超多子应用时建议统一提到12位以上所有子应用用相同长度否则不同子应用之间同名资源会发生无谓覆盖。第三开发模式千万别用contenthash。开发环境的核心诉求是热更新速度和构建速度文件名稳定没有任何价值反而会在磁盘里堆积大量历史构建产物。开发环境保持development的默认命名规则就好。第四不要手动改已发布文件名。文件名一旦作为缓存的一部分进入用户和CDN你改文件名而不改HTML引用等于人为制造404。文件名必须由构建工具统一管理要调整就重新构建发布。还有一个容易被忽略的点Webpack 5的filesystem cache等构建缓存理论上只影响构建速度不影响最终产物内容。但如果缓存损坏或者Node版本不一致导致模块遍历顺序变化也可能让哈希漂移。如果遇到“莫名其妙哈希变了”先在干净目录里做一次全新构建对照排除缓存和环境的干扰。5.3 关于内容哈希我的几条实战心法最后分享几条这些年折腾出来的心得。长期缓存策略的完整闭环其实是“contenthash文件指纹加runtime独立加稳定moduleId/chunkId加合理的SplitChunks加HTML短缓存加CDN正确回源”少了任何一环整体命中率都会打折。不要以为配好contenthash就万事大吉真正到线上就是几块拼图一起生效。如果你所在团队维护的构建配置是共享的强烈建议把“构建两次对比文件名”做成自动检查。要防的不是哈希算法而是人在长期迭代中无意间改坏了配置。我吃过一次亏之后这个检查就再也没落下。第三个体会是关于哈希长度的。小项目无所谓但项目一旦变大文件名的长度和一致性会直接影响发布效率。把哈希长度、拆包粒度、runtime拆分方式写进工程规范后面的人接手时不会瞎改。这个细节看着小实际影响的是每次发版后用户端真实下载的流量。我个人觉得Content Hashing配置得好发版就像换软链接一样顺滑改到哪哪个文件换地址浏览器和CDN自动跟着走。配置不好发版就是猜谜游戏。希望这篇长文能帮你把谜底一次解开。
返回列表