前端知库

webpack 的 fullhash、chunkhash 和 contenthash 有什么区别?怎样做长效缓存?

fullhash 是整次编译的哈希,任何文件变了它都变;chunkhash 按 chunk 计算,包含 chunk 里所有类型的内容;contenthash 只算这个文件那一种内容。长效缓存用 [contenthash] 命名,再把运行时拆成单独 chunk、第三方库拆进 vendors,并固定 moduleIds。

参考答案

简答

上线后,main.js 想让浏览器缓存一年,但代码一改又要立刻拿到新文件。办法是把哈希写进文件名:内容变了文件名就变,HTML 引用新文件名,旧文件继续被缓存也没关系。

webpack 提供三种哈希。[fullhash] 是整次编译的哈希,任何一个文件改了,所有用它命名的文件都会变。[chunkhash] 按 chunk 算,包含这个 chunk 里的全部内容。[contenthash] 也按 chunk 算,但只算这个文件那一种内容类型,比如同一个 chunk 抽出来的 JS 和 CSS 各算各的。

所以长效缓存一般用 [name].[contenthash].js。再配合三件事:runtimeChunk: 'single' 把运行时拆出去,splitChunks 把 node_modules 拆成 vendors,moduleIds: 'deterministic' 让模块 ID 不随引入顺序变化。这样改业务代码时,vendors 的文件名保持不变。

要注意,带哈希的文件可以长期缓存,引用它们的 HTML 却不能,HTML 要每次协商,否则用户拿不到新文件名。

三种哈希分别按什么算

webpack 文档的 output.filename 模板字符串表是这样分的:[fullhash] 属于编译级别,是“整次编译的完整哈希”,旧名字 [hash] 已废弃;[chunkhash] 是“chunk 的哈希,包含 chunk 的所有元素”;[contenthash] 是“chunk 的哈希,只包含这一种内容类型的元素”。

先看 [fullhash]。整次编译只有一个值,只要项目里任何一个模块改了,它就变。如果所有文件都用它命名,改一行业务代码,第三方库的文件名也跟着变,用户得重新下载全部文件,缓存等于没做。

[chunkhash] 缩小到了 chunk。改了 about 页面的代码,只影响 about 这个 chunk,main 不受影响。可是一个 chunk 可能同时产出 JS 和 CSS,比如用 mini-css-extract-plugin 把样式抽成单独文件。[chunkhash] 包含 chunk 的所有元素,只改样式时,JS 文件的哈希也可能一起变。

[contenthash] 再细一层,只算这个文件那种内容。只改 CSS,就只有 CSS 文件名变,JS 文件名不动。所以文档的缓存指南直接用 [contenthash]。

哈希长度可以写成 [contenthash:8],默认 20 位,也能用 output.hashDigestLength 统一设置。

只用 contenthash 为什么还不够

缓存指南里有个现象:源码什么都没改,再构建一次,文件名还是可能变。原因是 webpack 会把运行时和 manifest 这些样板代码放进入口 chunk。运行时就是 webpack 加载、连接模块用的那段代码,manifest 记录了模块和 chunk 的对应关系,任何 chunk 变了,这段代码都可能变。

第二个现象更隐蔽:只在业务代码里新增一个模块,vendors 的文件名也变了。文档解释,这是因为模块 ID 按解析顺序分配(moduleIds: 'natural'),新模块插进来,后面模块的 ID 都往后挪,vendors 里引用的 ID 变了,内容也就变了。

所以文档给出的完整配置是这样的:

JavaScript
module.exports = {
  output: {
    filename: '[name].[contenthash].js',
    clean: true,
  },
  optimization: {
    moduleIds: 'deterministic',
    runtimeChunk: 'single',
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
};

runtimeChunk: 'single' 把运行时拆成一个单独的 runtime 文件,所有 chunk 共用它。第三方库改动少,单独打进 vendors,业务代码频繁发版时它的文件名不变,用户不用重新下载。

moduleIds: 'deterministic' 让模块 ID 不随解析顺序变化。文档说明,production 模式默认就是 'deterministic',development 模式默认是 'named',两者在多次构建之间都稳定,显式写上是为了不依赖 mode。文档按这套配置构建两次,一次引入新模块、一次不引入,vendors 的文件名完全相同。

拆出去以后,改业务代码时 main 和 runtime 变化是正常的:main 内容变了,runtime 里记录的模块也变了,只有 vendors 保持不变。

realContentHash 是做什么的

[contenthash] 受 optimization.realContentHash 影响。文档说明,开启后会在资源处理完以后再算一遍哈希,得到真正按最终内容计算的值;关掉时用内部数据计算,资源内容相同时哈希也可能不同。它在 production 模式默认开启,development 和 none 默认关闭。所以线上构建不用额外配置。

文件名带了哈希,服务器怎么配缓存

文件名带哈希以后,内容一变就是新地址,所以这些文件可以设置很长的强缓存。HTML 不同,它的地址不变,又负责引用最新的文件名,必须每次都回服务器确认。缓存头怎么写,见《Cache-Control 的 immutable、no-store 等指令怎样选?》和《强缓存与协商缓存如何配合?》。

常见追问

开发环境要不要用 contenthash?

一般不用。开发服务器不需要应对浏览器的长期缓存,用 [name].js 就够了。realContentHash 在 development 模式默认也是关闭的。

为什么改了一个页面,其他 chunk 的文件名也变了?

先看运行时有没有拆出去,没拆的话运行时在入口 chunk 里,任何 chunk 变化都会带着它变。再看 moduleIds 是不是 'natural',按解析顺序分配 ID 时,新增模块会让其他模块的 ID 跟着变。

哈希写在文件名里,还是写成查询参数 ?v=xxx?

写在文件名里。webpack 的缓存指南就是用 [name].[contenthash].js 让内容变化体现在文件名上,新旧版本是两个不同的文件,可以同时存在于服务器上,发版时旧页面还能继续加载旧文件。