本文基于 Webpack@4.28.3 版本

在现代前端工程中,为了更有效的利用浏览器和 CDN 的缓存,我们通常会给静态资源设置一个比较长的缓存时间(cache-control等),在有内容更新的时候,通过在静态资源路径中加入 hash 的方式来实现。具体可以参考这里: 大公司里怎样开发和部署前端代码?

但是在 Webpack 中,实现可靠的长期缓存并不容易。在 github 上,关于这个问题的 issue 已经讨论了三年之久。在 Webpack 4 时代,我们终于看到了一点希望。

我们从一个最基础的 Webpack 配置开始:

基础配置

1
2
3
4
5
6
7
8
9
10
11
12
// webpack.config.js 
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: {
main: './src/app',
},
output: {
path: path.join(__dirname, 'build'),
filename: '[name].[hash:8].js'
}
};

构建一下,得到如下结果:

1
2
3
          Asset       Size  Chunks             Chunk Names
app.23b699d1.js 115 KiB 0 [emitted] app
index.html 253 bytes [emitted]

看起来好像很完美。

Vendor Chunks

在实际项目中,我们通常会选择将公共模块提取成一个单独的文件,在 Webpack 3 时代,我们一般会选用 CommonsChunkPlugin,在 Webpack 4 中,我们则可以使用 splitChunks 来实现这一需求。这里我们将来自 node_modules 的模块提取成 vendors.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// webpack.config.js 
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: {
main: './src/main',
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].[hash:8].js'
},
optimization: {
splitChunks: {
cacheGroups: {
commons: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
chunks: "all"
}
}
}
}
};

打包结果如下:

1
2
3
4
              Asset       Size  Chunks             Chunk Names
app.6105d149.js 2.3 KiB 0 [emitted] app
index.html 319 bytes [emitted]
vendors.6105d149.js 114 KiB 1 [emitted] vendors

你可能已经注意到了,我们的 app 和 vendors 的 hash 是相同的。这样打包,对 app 模块的任何更改同时也会导致我们的 vendors 模块的 hash 失效。

要解决这个问题,我们必须在文件名中把 hash 改成 chunkhash。这是因为 hash 会为我们构建的所有内容生成一个全局哈希,而 chunkhash 只会使用它自己的模块中的内容来生成哈希值。

修改 webpack 配置如下,再次构建,我们得到了两个不同的哈希值:

1
2
3
4
5
6
7
// webpack.config.js 
// ...
output: {
path: path.join(__dirname, 'build'),
filename: '[name].[chunkhash:8].js',
},
// ...
1
2
3
4
Asset       Size  Chunks             Chunk Names
app.41d05796.js 2.3 KiB 0 [emitted] app
index.html 319 bytes [emitted]
vendors.c2375741.js 114 KiB 1 [emitted] vendors

现在,更改 app 模块中的内容, vendor 模块不会再受影响了(最新版本的 Webpack 中,不再需要为了 hash 的稳定单独提取出 runtime 了)。

我们来添加一行代码测试一下:

1
2
3
// app.js
// ...
console.log("Hello World");

打包之后,很完美:

1
2
3
4
 Asset       Size  Chunks             Chunk Names
app.2a740abf.js 2.33 KiB 0 [emitted] app
index.html 319 bytes [emitted]
vendors.c2375741.js 114 KiB 1 [emitted] vendors

增加依赖

随着项目的增长,我们不可避免的出现了更多的依赖:

1
2
3
// app.js
// ...
import hello from './hello';

我们再来构建一次。在这次构建中,显然我们只希望 app.js 的 hash 值更新,但是事情总是不尽如人意的:

1
2
3
4
Asset       Size  Chunks             Chunk Names
app.adcbdfaa.js 2.44 KiB 0 [emitted] app
index.html 319 bytes [emitted]
vendors.a7e68620.js 114 KiB 1 [emitted] vendors

尽管我们的 vendors 模块没有任何变化,它的 hash 还是又一次改变了。原因又是一个 Webpack 中的细节: Webpack 会为每个 chunk 按顺序给出一个依次增加的 chunk id,随着新依赖的增加,chunk 顺序也可能会发生变化,于是 chunk id 也会随之更新。

为了解决这个问题,我们引入了一个新的插件: NamedChunksPlugin。这是在 Webpack 2.4 版本中加入的一项特性,借助它我们可以让模块有自己的名字,而不是冷冰冰的数字(Webpack 4 中, development mode下已默认开启)。

1
2
3
4
5
// weback.config.js 
// ...
plugins: [
new webpack.NamedChunksPlugin(),
]

这样配置,Webpack 将使用唯一的 chunk 名称而不是其 id 来标识一个 chunk。

在最新版的 Webpack 中,我们也可以使用 optimization.chunkIds 来达成同样的效果:

1
2
3
4
5
// weback.config.js 
// ...
optimization: {
chunkIds: 'named'
}

我们在添加和不添加 hello.js 的情况下分别再构建一次,应该就可以看到,vendor 块的哈希是保持不变的了。

1
2
3
4
5
6
7
8
9
10
// 不添加 hello.js
Asset Size Chunks Chunk Names
app.25a10650.js 2.43 KiB app [emitted] app
index.html 319 bytes [emitted]
vendors.83fb4100.js 114 KiB vendors [emitted] vendors
// 添加 hello.js
Asset Size Chunks Chunk Names
app.e66a4b27.js 2.34 KiB app [emitted] app
index.html 319 bytes [emitted]
vendors.1a4b3a3e.js 114 KiB vendors [emitted] vendors

好吧,并没有。

这是因为和 chunk id 类似,Webpack 同样会对 module 使用自增数字命名。类似地,我们可以使用 NamedModulesPluginHashedModuleIdsPlugin 来命名 module,从而使 hash 固定。其中 NamedModulesPlugin 使用 module 的路径来命名,生成的名字更可读,但是和使用 module 路径生成的4位(可能出现重复的情况下位数会增加) hash 命名 module 的 HashedModuleIdsPlugin 相比,会明显增大文件体积,适合用于开发环境,生产环境适合使用 HashedModuleIdsPlugin。

和 chunkIds 类似,在最新版本的 Webpack 中,我们也可以使用 optimization.moduleIds 来配置这一功能(development 模式下 optimization.namedModules 已默认开启)。

继续更新我们的 Webpack 配置:

1
2
3
4
5
6
// webpack.config.js 
// ...
optimization: {
moduleIds: 'hashed'
}
// ...

再次更改 app 模块的内容打包,我们可以看到,更新前后其余文件的 hash 是不变的了。

1
2
3
4
5
6
7
8
9
10
// 不添加 hello.js
Asset Size Chunks Chunk Names
app.ca09737b.js 10.6 KiB app [emitted] app
index.html 1.38 KiB [emitted]
vendors.9d671f37.js 335 KiB vendors [emitted] [big] vendors
// 添加 hello.js
Asset Size Chunks Chunk Names
app.9dc8a07a.js 10.8 KiB app [emitted] app
index.html 1.38 KiB [emitted]
vendors.9d671f37.js 335 KiB vendors [emitted] [big] vendors

异步模块

为了首屏性能等需求,我们不可避免的需要在项目中使用异步模块。我们添加一个异步模块再次打包:

1
2
3
4
// router.js
// ...
path: '/async',
component: () => import('./async.vue')
1
2
3
4
5
 Asset      Size   Chunks                    Chunk Names
0.03d30b56.js 2.04 KiB 0 [emitted]
app.63c81cf2.js 13.6 KiB app [emitted] app
index.html 1.38 KiB [emitted]
vendors.2a461436.js 335 KiB vendors [emitted] [big] vendors

可以看到 vendors 的 hash 又更新了。打开对应的文件,发现这样一段内容:

1
window["webpackJsonp"] || []).push([[0],

显然,对新加入的这个异步模块的命名失效了,又变成了从 0 开始的自增序列。

查看源码可以得知,NamedChunksPlugin 仅对有 name 的 chunk 有效,但是可以通过自定义 nameResolver 的方式来实现我们需要的功能:

1
2
3
4
5
6
new webpack.NamedChunksPlugin(chunk => {
if (chunk.name) {
return chunk.name;
}
return Array.from(chunk.modulesIterable, m => m.id).join("_");
});

还有一个更简单的方案则是使用 Webpack 的魔法注释来给异步模块命名:

1
2
3
4
// router.js
// ...
path: '/async',
component: () => import(/* webpackChunkName: "async" */ './async.vue')

打包结果如下:

1
2
3
4
5
Asset      Size   Chunks                    Chunk Names
app.d10d68b9.js 13.6 KiB app [emitted] app
async.4e35edb1.js 2.05 KiB async [emitted] async
index.html 1.38 KiB [emitted]
vendors.9d671f37.js 335 KiB vendors [emitted] [big] vendors

可以看到,vendors 的 hash 恢复到了之前的状态。

是不是感觉好像还缺点什么?

CSS模块

在生产环境中,我们通常会将 css 模块打包为独立文件。我们增加一个 css 模块来看一下:

1
2
3
// app.js
// ...
import './hello.css';
1
2
3
4
5
6
7
8
9
// webpack.config.js 
// ...
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[chunkhash:8].css",
chunkFilename: "[name].[chunkhash:8].css"
})
]
// ...

改变一下 hello.css 的内容,打包两次,对比如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 Asset      Size   Chunks                    Chunk Names
app.884f7d50.css 28 bytes app [emitted] app
app.884f7d50.js 13.8 KiB app [emitted] app
async.4e35edb1.js 2.05 KiB async [emitted] async
index.html 1.43 KiB [emitted]
vendors.9d671f37.js 335 KiB vendors [emitted] [big] vendors


Asset Size Chunks Chunk Names
app.1bedc677.css 29 bytes app [emitted] app
app.1bedc677.js 13.8 KiB app [emitted] app
async.4e35edb1.js 2.05 KiB async [emitted] async
index.html 1.43 KiB [emitted]
vendors.9d671f37.js 335 KiB vendors [emitted] [big] vendors

只修改了 CSS 文件,app.js 的 hash 值又变了。很容易理解,毕竟它们属于同一 chunk。
为了解决这个问题,Webpack 4.3 中引入了一个新的概念: contenthash
修改 Webpack 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// webpack.config.js 
// ...
output: {
filename: "app.[contenthash:8].js",
chunkFilename: "[name].[contenthash:8].js"
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[contenthash:8].css",
chunkFilename: "[name].[contenthash:8].css"
})
]
// ...

再次打包对比更改 css 文件前后的变化,只有 css 文件的 hash 改变了。

1
2
3
4
5
6
7
8
9
10
11
12
13
Asset      Size   Chunks                    Chunk Names
app.a8ec5b81.js 13.8 KiB app [emitted] app
app.eb9b9ca0.css 28 bytes app [emitted] app
async.ca0780b4.js 2.05 KiB async [emitted] async
index.html 1.43 KiB [emitted]
vendors.bf514953.js 335 KiB vendors [emitted] [big] vendors

Asset Size Chunks Chunk Names
app.a8ec5b81.js 13.8 KiB app [emitted] app
app.da6259d4.css 29 bytes app [emitted] app
async.ca0780b4.js 2.05 KiB async [emitted] async
index.html 1.43 KiB [emitted]
vendors.bf514953.js 335 KiB vendors [emitted] [big] vendors

external 模块

有些场景下,我们需要从 CDN 引入一个模块,以 jQuery 为例,一般会如下配置:

1
2
<!-- index.html -->
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
1
2
3
4
5
6
//webpack.config.js
module.exports = {
externals: {
jquery: 'jQuery'
}
};
1
2
// app.js
import $ from 'jquery';

和异步模块类似,NamedChunksPlugin 也不会对 externals 模块起作用,我们可以参考这里,使用 NameAllModulesPlugin 来做一些相关的配置,从而为它命名。

展望

目前,Webpack 已经发布了 v5.0.0-alpha.3 版本。

在 Webpack 5 中,采用了全新的算法来生成 chunkIds 和 moduleIds,在打包后的文件大小和控制缓存之间有了一个更好的平衡。

在 Webpack 4 的更新说明中,开发团队提到了 Webpack 5 中会有开箱即用的长缓存配置,在这个 alpha 版本中,我们也看到了 cache: { type: "filesystem" } 这样的配置,然而,这个策略依然是实验性质的。希望在正式版本中能一劳永逸的解决这个问题。

参考

https://medium.com/webpack/predictable-long-term-caching-with-webpack-d3eee1d3fa31
https://webpack.js.org/guides/caching/
https://zhuanlan.zhihu.com/p/38456425
https://segmentfault.com/a/1190000016355127
https://segmentfault.com/a/1190000015919928
https://github.com/pigcan/blog/issues/9