前端项目打包后,静态资源通常会部署到 CDN 并开启强缓存。理想情况是:文件内容不变,文件名不变,浏览器直接走本地缓存;文件内容一变,文件名跟着变,浏览器自动拉取新资源。要实现这套机制,Webpack 侧的缓存策略配置是关键,核心在于哈希值的选取、运行时代码的抽离以及公共模块的拆分。这篇文章把这套最佳实践完整梳理一遍。

一、选对哈希:hash、chunkhash 和 contenthash 的区别
Webpack 提供三种哈希占位符。[hash] 是整个构建的哈希,任何模块变动都会导致所有文件名变化,等于缓存全失效,生产环境基本不该用它。[chunkhash] 基于每个 chunk 自身包含的内容计算,某个 chunk 变了只有它和依赖它的产物名变化。[contenthash] 则是针对提取出来的文件内容计算,最典型的场景是 mini-css-extract-plugin 抽出来的 CSS 文件——CSS 内容不变,文件名就不变。
推荐的输出配置如下:
module.exports = {
output: {
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/'
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
chunkFilename: 'css/[name].[contenthash:8].chunk.css'
})
]
};注意哈希长度一般取 8 位即可,md5 前 8 位的碰撞概率在实际项目中可以忽略。还要确保线上服务器的缓存头设置足够长的 max-age,例如一年,配合文件名带哈希的机制才能发挥长期缓存的价值。如果哈希文件名配了却只设置了 no-cache,那就白折腾了。
二、抽离运行时:runtimeChunk 的作用
Webpack 打包产物里除了业务代码,还有一段运行时代码,负责模块的 require 逻辑、chunk 的加载映射表等。这段代码默认内联在入口 chunk 中。问题在于:哪怕业务代码一行没改,只要新增或删除了一个异步模块,模块 ID 映射关系就会变化,导致运行时代码变化,进而连带入口 chunk 的哈希变化,用户不得不重新下载整个入口文件。
解决办法是用 optimization.runtimeChunk 把运行时单独抽成一个文件:
module.exports = {
optimization: {
runtimeChunk: 'single', // 整个应用只生成一个 runtime 文件
// 或者 runtimeChunk: { name: 'runtime' }
}
};抽离之后,runtime 文件很小且变化频率相对可控,业务 chunk 的哈希不再受运行时变化牵连。为了进一步降低 runtime 本身变化的概率,可以配合模块 ID 的确定性算法:
module.exports = {
optimization: {
moduleIds: 'deterministic',
runtimeChunk: 'single'
}
};deterministic 是 Webpack 5 的默认值,它基于模块路径生成短小且稳定的数字 ID,增删模块时不会引起其他模块 ID 大面积变化。Webpack 4 时代常用的 hashedIdsPlugin 解决的是同一个问题。如果发现每次构建产物哈希都变,先检查 moduleIds 是否被覆盖成了默认的递增序号。
三、拆分公共模块:splitChunks 的缓存友好配置
长期缓存的另一个关键点是最大化 chunk 的复用率。如果第三方库和业务代码混在一个 chunk 里,每次改业务代码,体积巨大的 vendor 也要重新下载。合理的做法是把 node_modules 里的稳定依赖单独拆出来:
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: 25,
minSize: 20000,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: -10,
reuseExistingChunk: true
},
common: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
};更激进的策略是按包拆分,每个 npm 包一个 chunk,比如 react 一个、lodash 一个。这样哪个包升级只影响对应 chunk,其余库的缓存依然有效。可以借助 splitChunks 结合动态 import 实现,也可以在 Webpack 5 里用实验性的 cacheGroups 加函数形式的 test 来按模块名分组。代价是请求数增多,HTTP/2 场景下这个代价通常可以接受,HTTP/1.1 环境则要权衡。
另外提醒一点:不要把 Polyfill、首屏关键依赖拆得太碎后异步加载,否则会影响首屏渲染。缓存优化和加载性能之间需要拿捏平衡,建议结合构建分析工具观察每个 chunk 的体积和引用关系再调整。
四、常见踩坑点与排查方法
第一个常见问题是「没改代码但哈希变了」。排查方向有三个:是否在代码中使用了构建时间戳或版本号注入;moduleIds 或 chunkIds 是否不稳定;依赖的某个包在每次 install 时版本漂移(建议用 lock 文件锁死)。可以用两次构建产物做 diff,定位到具体变化的 chunk 再分析来源。
第二个问题是 CSS 文件哈希跟着 JS 变。这通常是因为用了 chunkhash 而不是 contenthash,或者样式被 style-loader 内联进了 JS。改成 mini-css-extract-plugin 加 [contenthash] 即可解决。
第三个问题是懒加载模块路径大小写不一致、Windows 与 Linux 构建产物不一致等平台差异,这类问题会导致不同环境构建出不同哈希。建议统一在 CI 环境出包,并保证 Node 版本、Webpack 版本一致。排查时可以对比两次构建的 stats 文件,检查模块 ID 和依赖图是否一致。把这些细节都处理到位,长期缓存体系才算真正落地。