尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Webpack持久化缓存实战:从contenthash到构建提速的完整指南

Webpack持久化缓存实战:从contenthash到构建提速的完整指南 做前端构建配置的人迟早会碰上一个问题代码明明只改了一行重新打包后用户却要重新下载几百KB甚至几MB的JS文件缓存形同虚设。我最早遇到这个情况是在一个后台管理系统里第三方库占了打包产物体积的大头每次发布只是改了某个页面的文案结果所有访问者都要重新拉一遍vue、axios和element-plus服务器带宽和用户等待时间双双爆炸。后来我系统地把webpack的持久化缓存机制梳理了一遍才算真正把这块理顺。这篇东西就是我从原理到实操的完整记录希望能帮到正在被打包缓存问题折磨的人。先说清楚本文讨论的“webpack持久化缓存”包含两层意思一层是webpack构建过程本身的持久化缓存也就是把编译中间结果保存到磁盘下次构建直接复用另一层是打包产物的HTTP缓存也就是通过文件名指纹让浏览器命中强缓存只有在内容真正变化时才重新下载。这两层互相配合才是完整的持久化缓存方案。1. 先搞清楚Webpack里的“缓存”到底指哪一层1.1 两个容易混淆的缓存构建缓存和HTTP缓存很多文章把这两件事混在一起讲导致读者配置了半天也不知道自己到底在优化什么。我习惯把它们分开理解构建缓存是为了提升本地开发和CI构建速度。webpack需要把ES6转ES5、编译less/scss、压缩JS这些过程都很耗时。如果每次构建都重新做一遍一个中型项目冷启动可能要三四十秒开发时每改一行代码就等半分钟体验非常差。构建缓存会把模块编译的结果存下来源码没变的模块直接复用之前的产物。**HTTP缓存产物缓存**则关系到线上用户加载页面的速度。打包出来的JS/CSS通过script和link标签引入时浏览器会按照响应头里的Cache-Control和ETag等策略决定是否复用本地文件。如果我们把文件名改成带hash的形式——比如app.7a3f2d.js——那么文件内容没变时文件名不变浏览器会一直用本地缓存文件内容变了文件名跟着变浏览器才会请求新文件。这一层直接影响用户实际体验。持久化缓存方案要做的就是让这两层都处于最优状态构建缓存尽量命中让打包变快HTTP缓存尽量准确让用户不重复下载没变的资源。1.2 持久化缓存为什么值得单独拿出来说webpack本身一直有各种缓存能力但早年的实现要么不彻底要么用起来麻烦。webpack 4时代我们常用的手段是cache-loader、hard-source-webpack-plugin还有把依赖打进dll的DllPlugin。这些方案各有各的问题cache-loader只能缓存loader阶段的产物粒度粗配起来侵入性强hard-source-webpack-plugin当时对webpack 4支持还不错但升级webpack 5之后基本没人用了DLL方案更是很多人用过都说后悔配置复杂不说维护成本极高webpack官方后来也明确不推荐。webpack 5把“持久化缓存”做成了开箱即用的能力也就是在配置里加一行cache: { type: filesystem }构建中间结果就会自动写到磁盘上的node_modules/.cache目录里下次构建自动复用。再加上optimization.moduleIds和chunkIds的确定性策略、contenthash指纹的配合webpack 5可以说从工具层面就把缓存需要的大部分东西都备齐了。这也是为什么现在谈持久化缓存基本都是在webpack 5的语境下聊的。我在实际项目里见过很多人在用webpack 4时已经上了contenthash但构建缓存还是靠hard-source-webpack-plugin撑着。升级到webpack 5之后这些插件基本都可以撤掉用内置能力替换。节省的不只是依赖体积还少了很多插件带来的不确定性。2. 原理拆解文件指纹和Hash才是缓存的根2.1 hash、chunkhash、contenthash的区别想让HTTP缓存生效核心就一句话**文件内容变了文件名要变文件内容没变文件名绝不能变。**webpack给我们提供了三种hash很多新手在这块栽跟头我直接讲结论。hash是整个构建过程生成的一次性hash只要项目里有任何一个文件变了所有产物的hash都会变。如果配了filename: [name].[hash].js那改了任意一处代码全部JS文件都得重新下载。这个基本只适合小型demo项目线上环境这么配等于没做缓存。chunkhash是根据chunk内容生成的hash每个chunk有自己的hash。这个比hash强但它有个问题一个chunk里如果混了业务代码和第三方库业务逻辑一变整个chunk的hash就变第三方库的代码又得跟着重新下载一遍。contenthash是更细粒度的hash它是根据单个文件内容生成的。同一个chunk里如果JS和CSS是通过MiniCssExtractPlugin单独抽出来的那JS的hash只跟JS内容有关CSS的hash只跟CSS内容有关。这样改样式时JS文件还能继续用缓存只改JS逻辑时CSS也不受影响。生产环境打包文件名指纹就选contenthash。我习惯这么配置output: { filename: static/js/[name].[contenthash:8].js, chunkFilename: static/js/[name].[contenthash:8].chunk.js, }:8的意思是只取hash前8位文件名不至于太长。实际用下来8位足够冲突概率极低。2.2 模块ID和Chunk ID的稳定性问题很多人配好了contenthash却发现一个诡异的现象这次改动只影响了一个业务组件但最后生成的vendor包hash也变了。原因往往出现在模块IDModule ID和Chunk ID上。webpack在构建时会为每个模块分配一个内部ID。在webpack 4及更早版本里如果不手动配置模块ID默认是根据模块解析顺序依次递增的数字。那么你新增一个文件或者调整import顺序后面的模块ID就会整体往后挪。体现在产物里就是每个模块对外暴露的__webpack_require__引用ID变了导致chunk的contenthash跟着变。第三方库的代码一个字没改但包里记录的模块ID全变了用户依然要重新下载。另一个类似的问题是Chunk ID。异步加载的chunk在webpack 4里默认也可能是自增数字比如0.js、1.js这样。你新加一个异步路由后面的chunk ID全变引用它们的入口文件hash也跟着变。解决办法分时代webpack 4需要手动引入HashedModuleIdsPlugin和NamedChunksPluginwebpack 5直接用内置的确定性策略即可。optimization: { moduleIds: deterministic, chunkIds: deterministic, }deterministic会基于模块的路径和内容生成一个短hash作为模块ID。这样只要模块路径和内容没变模块ID就不变vendor的hash也就稳住了。2.3 Webpack 5的确定性ID策略我这里再多说一点deterministic的原理。它本质上是在“可读性”和“稳定性”之间取了个折中。开发环境你肯定希望模块ID可读方便调试生产环境为了缓存稳定牺牲一点可读性完全值得。还有一种说法是用moduleIds: natural这是webpack 5默认给mode: development用的策略模块ID就是按解析顺序来的数字不缓存优先只图清晰。生产环境完全不建议用。另外optimization.realContentHash也是webpack 5的一个提升。它默认是true会重新计算一次最终生成 assets 的真实 content hash避免因为source map注释、导出变量顺序等细微差异造成hash不够精确。这个配置保持默认即可只有在构建性能特别紧张的时候才考虑关掉但一般不建议因为缓存正确性比那点构建时间重要得多。3. 实操上手指南把缓存配置落到项目里3.1 开启Webpack内置的持久化构建缓存webpack 5配置构建缓存非常简单在webpack.config.js里加module.exports { // ... cache: { type: filesystem, buildDependencies: { config: [__filename], }, }, };这里面的关键是buildDependencies。它告诉webpack如果配置文件本身变了那缓存就全部失效。因为配置文件变化意味着构建参数可能变了之前的中间结果不能瞎复用。除了配置文件我还会把package-lock.json或者yarn.lock放进去因为依赖版本变化时resolve出来的模块列表会变缓存同样应该失效。const path require(path); cache: { type: filesystem, buildDependencies: { config: [ __filename, path.resolve(__dirname, package-lock.json), ], }, }开了这个之后node_modules/.cache/webpack目录会慢慢变大这是正常现象。磁盘空间吃紧的公司可以考虑在CI里每次构建后清理但我个人建议保留因为这就是持久化缓存的代价和收益所在。3.2 给文件名打上稳定的内容指纹构建缓存解决的是“打包快不快”HTTP缓存解决的是“加载快不快”。要让线上用户命中缓存必须给文件名加指纹。入口文件和异步chunk分别配置output: { filename: static/js/[name].[contenthash:8].js, chunkFilename: static/js/[name].[contenthash:8].chunk.js, assetModuleFilename: static/media/[name].[hash:8][ext], }CSS通过MiniCssExtractPlugin抽出来时也要配contenthashnew MiniCssExtractPlugin({ filename: static/css/[name].[contenthash:8].css, chunkFilename: static/css/[name].[contenthash:8].chunk.css, })注意图片、字体这类静态资源我用的还是[hash]而不是[contenthash]。因为asset模块的hash是基于文件内容生成的天然就是内容指纹没必要再区分。如果配了assetModuleFilenamewebpack会自动给每个静态资源生成hash。提示生产环境的output.filename不要用[name]配合hash一定要用contenthash。我见过不止一个项目就是因为用了全局hash导致发布时所有文件全部失效缓存优化等于白做。3.3 提取运行时代码和公共依赖运行时代码是webpack生成的启动代码包含模块查找表、异步chunk的加载逻辑等。这些代码里记录了chunk ID和模块ID的映射关系。如果不单独抽出来它会打进入口文件里。一旦某个异步chunk的hash变了入口文件的runtime部分也可能跟着变导致入口文件缓存失效。解决办法是把runtime单独抽成一个文件optimization: { runtimeChunk: single, }这样会生成一个独立的runtime.[contenthash:8].js文件通常体积很小就几KB。入口文件里剩下的业务代码就不再包含运行时逻辑只要业务没改入口文件的hash就能保持稳定。公共依赖的提取靠splitChunks。我通常这样配置optimization: { splitChunks: { chunks: all, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10, enforce: true, }, }, }, }这样所有来自node_modules的代码会打包成一个vendors.[contenthash:8].js。只要第三方库的版本不变这个文件在每次构建里的hash都应该保持不变用户也就不会重复下载。3.4 loader层面的缓存与DLL的取舍babel-loader、ts-loader这类负责代码转换的loader本身也支持缓存。它们的缓存目标是跳过“源码没变但又要重新转译”的过程和webpack的filesystem cache是两码事。babel-loader开启缓存很简单{ test: /\.(js|mjs|jsx)$/, exclude: /node_modules/, loader: babel-loader, options: { cacheDirectory: true, cacheCompression: false, }, }cacheDirectory设置为true后babel-loader会把转译结果缓存到node_modules/.cache/babel-loader里。cacheCompression: false是关闭缓存文件的gzip压缩压缩能省点磁盘空间但会增加读写耗时本地开发建议关掉CI上可以开。至于DLL我的态度很明确webpack 4时代它是没办法的办法webpack 5有了内置缓存和persistent cache之后DLL的收益已经微乎其微还平白增加配置复杂度。如果你正在维护一个webpack 4 DLL的老项目我建议尽早规划升级而不是在新项目里继续引入DLL模式。补充webpack 5的filesystem cache其实已经覆盖了loader缓存的大部分作用。因为模块编译结果会被缓存loader重复执行的情况大大减少。所以cacheDirectory更多算双保险。真正卡脖子的时候建议先用webpack内置缓存不够再叠加loader缓存。4. 进阶优化让缓存命中率从“能用”到“高效”4.1 稳定的依赖版本和lock文件这一步看起来和webpack配置没直接关系但实际影响极大。如果你的package.json里依赖写的是vue: ^3.2.0这种带范围的形式那么今天安装的可能是3.2.5明天安装的可能是3.2.9。每次依赖版本变化vendors包的内容就变hash也跟着变用户重新下载整个vendor包。解决办法就是提交package-lock.json或者yarn.lock保证团队成员和CI环境装出来的依赖完全一致。如果你想更严格还可以把依赖锁定为精确版本号。这个属于团队规范层面的事但直接决定了持久化缓存的稳定性。我见过有项目、lock文件没提交成功导致CI每次构建拉到的依赖都不一样vendor hash每次都在变缓存命中率几乎为零。4.2 利用cacheGroups的优先级控制分包splitChunks配置有一个容易忽略的细节cacheGroups里的priority决定了分组优先级。多个分组匹配同一个模块时优先级高的生效。比如我想把react和react-dom单独拎出来其他node_modules的包打进另一个vendorsplitChunks: { chunks: all, cacheGroups: { react: { test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/, name: react, priority: 20, }, vendor: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10, }, }, }这样react和react-dom在react.[hash].js里其余第三方库在vendors.[hash].js里。当你的业务代码只依赖一个很小的库时这个库单独变更不会拖累整个vendor大包。还有一个常见的需求是业务公共模块提取。多个页面公用的业务代码可以单独分一个group{ common: { test: /src[\\/]components|utils|hooks/, name: common, minChunks: 2, priority: 5, }, }minChunks: 2代表至少被两个chunk引用才提取。这样可以避免把小模块打得过于碎片化也避免把一次性模块塞进common导致缓存失效范围变大。4.3 CDN部署与缓存头设置的配合持久化缓存最后一步在服务器/CDN侧。文件名带了contenthash意味着“内容不变则URL不变内容变化则URL变化”。这种情况下服务器对静态资源设置的缓存策略可以非常激进。我一般给带hash的静态资源设置Cache-Control: public, max-age31536000, immutable一年强制缓存加上immutable告诉浏览器这个URL的内容永远不会变因为变了URL就会跟着变可以一直用本地缓存。这样用户第二次访问时除了HTML其余资源全部走本地缓存加载速度飞快。而不带hash的HTML文件则设置Cache-Control: no-cache表示每次请求HTML都要向服务器验证一下拿到最新版本后再根据HTML里的新资源URL去加载新的JS/CSS。no-cache不是不缓存而是缓存前必须回源验证这样能保证用户拿到的是最新HTML又不错过那些没变但可以继续用的静态资源。我遇到过不少团队只做了文件名hash忘了调整服务器缓存头导致浏览器还是用旧的强缓存策略即使是新URL也没去请求。所以这一层一定要检查。5. 常见问题与实战避坑5.1 为什么改了业务代码vendor的hash也变了这是我在技术群里被问过最多的问题。排查步骤基本如下第一确认moduleIds和chunkIds是否设置为deterministic。webpack 5生产模式默认是deterministic但如果你显式改成了natural或者sizevendor hash就可能不稳定。第二确认是否有代码把业务模块和第三方库混在同一个chunk里。比如你在入口文件里直接import了某个库同时又在这个入口写了大量业务代码webpack可能把它们打进同一个chunk。这时候改业务代码整个chunk的hash都会变。解决办法就是靠splitChunks把node_modules彻底分离出去。第三检查是否使用了会往模块里注入当前时间戳或构建信息的插件。比如有些版本号插件把构建时间写进包体每次构建时间都变hash自然跟着变。第四检查是否配置了optimization.realContentHash为false。关掉这个可能导致hash计算不够精确出现内容没变但hash变了的假象。5.2 构建缓存不生效或越构越慢开启了cache: { type: filesystem }但似乎没生效最可能的原因是node_modules/.cache目录被清理了或者文件权限不对。CI环境尤其常见很多CI流水线会执行rm -rf node_modules那么缓存自然也跟着没了。正确做法是把node_modules/.cache作为CI缓存目录持久化起来比如GitHub Actions里用actions/cache或者公司自己的CI缓存机制。还有人说构建一次比一次慢。这种情况我去查多数是缓存目录过大、磁盘IO成为瓶颈。解决思路有两个一是定期清理但这就失去持久化的意义了二是确认是不是真的命中缓存webpack控制台会输出[webpack-cache] ...类似日志或者用stats字段查看构建过程。我遇到过一次是因为在CI里同时跑两个构建任务它们共用同一个缓存目录结果webpack的缓存锁互相竞争构建时间反而上升。解决办法是给不同任务分配不同的cache目录比如cache: { type: filesystem, cacheDirectory: path.resolve(__dirname, .cache/webpack- process.env.NODE_ENV) }。5.3 本地缓存和CI缓存不一致开发机上的缓存和CI上的缓存最好分开。webpack的filesystem cache可以配置cacheDirectory区分环境。如果不区分有可能出现本地跑得好好的CI构建却报一些奇怪的错误原因就是两个环境的依赖版本、操作系统路径分隔符不同缓存的中间结果不兼容。更稳妥的做法是给开发和CI分别指定缓存目录同时在CI里把构建缓存也纳入CI的持久化缓存机制。这样CI每次构建都能复用上次的中间产物也不需要担心跨平台问题。5.4 关于缓存失效范围我的几个判断标准踩了这么多年坑我总结出几条经验分享给你判断文件名指纹合不合理就看“修改一行代码后哪些文件的hash会变”。理想情况是只有那行代码所在chunk的hash变runtime不变、vendor不变、其他业务chunk不变。如果你的项目改了任意一行代码一大片hash都在变说明配置还有优化空间。判断构建缓存合不合理就看“清空node_modules/.cache后重新构建的耗时”和“不清空直接构建的耗时”差多少。差距越大构建缓存收益越明显。如果差距不大说明你的项目本来构建就不慢那也别花太多精力在构建缓存调优上优先做HTTP缓存。另外很多团队喜欢把构建缓存和HTTP缓存混为一谈导致发布流程设计有问题。我这里再强调一遍构建缓存是开发/CI阶段的性能优化HTTP缓存才是线上用户体验的优化。前者影响的是你们团队的开发效率后者影响的是所有用户的访问速度。两者都要做但解决问题的阶段完全不同。写在最后的经验之谈我接手维护老项目的经验是webpack持久化缓存不是一个“配置项”而是一套组合拳。构建侧要开启cache: { type: filesystem }把编译中间结果留在磁盘产物侧要保证文件名指纹用的是contenthash并且模块ID、chunk ID保持确定性代码组织上要通过splitChunks把第三方库和业务代码分离尽量缩小一次改动影响到的文件范围部署侧要把缓存头和文件名指纹配合好让浏览器敢于长期缓存带hash的资源。其中任何一环掉了链子缓存效果都会打折扣。尤其是“为什么我配了contenthashvendor还是变了”这类问题十有八九是模块ID不稳定或者运行时公共代码没抽干净。最后分享一个我常用的验证方法构建完成后打开生成的dist目录把index.html里引用的JS文件名抄下来然后用git stash临时改一行业务代码再构建一次对比两次的文件名列表。如果发现某个你完全没动过的文件hash变了那它就是你需要继续排查的目标。这个方法比看文档和论坛都直接也最能帮你建立“代码变化和缓存失效范围”之间的直觉。
返回列表