磨人的Webpack Hash
这是一篇废话连篇的文章。
从接触Webpack以来,自己是做内部系统为主,每次拿起
chunkhash
就是干,所以对Webpack的文件编译并没有太深入的研究。直到最近踩了几个坑之后,我才重新梳理了一下Webpack的hash
。
为什么要使用hash?
日常开发编译打包生成静态资源文件时,我们总是会利用文件名带上hash的方式,保证浏览器能够持久化缓存。更具体地解释就是我们希望达到这样一个目的:
相关代码没有发生变化时,尽可能地利用浏览器缓存,而不是频繁地请求静态资源服务器。
Webpack的hash类型
说hash之前,我们先抛出 Webapck 里面的两个概念 chunk
和 module
。
简单地来说,一个或多个资源(js/css/img)组成module
,一个或多个module
又组成了chunk
,其中包括entry chunk
和normal chunk
。每个chunk
最终生成一个file
,就是我们的静态资源文件。也就是说,chunk
最终都一个hash
。
Webpack作为时下最主流的业务代码编译打包工具,内置了以下三种hash处理方式:
-
hash
Using the unique hash generated for every build
-
chunkhash
Using hashes based on each chunks' content
-
contenthash
Using hashes generated for extracted content
hash是根据每次编译生成,chunkhash
则是根据每个chunk
的内容生成,contenthash
用来对付css等其他资源。
由于我们的项目基本上都是多个entry
(入口),如果每一次编译所有的文件都生成一个全新的hash
,就会造成缓存的大量失效,这并不是我们期望的。我们最终想要达到的效果就是:
每当修改一个
module
时,只有引用到它的chunk
才会更新对应的hash
。
于是,chunkhash
脱颖而出了。
实际在使用chunkhash
时,由于对webpack
编译过程的不了解, chunkhash
并没有像我期望的那样工作,这也让我踩坑不少。
接下来通过一个循序渐进的例子来展示chunkhash
到底是个什么玩意儿。
准备数据
假设我们有入口文件 entry-a.js
entry-b.js
entry-c
,a
和 b
分别依赖 common-a.js
和common-b.js
,三个入口文件都依赖 common-abc.js
// entry-a.js
import ca from './common-a'
import cabc from './common-abc'
ca()
cabc()
console.log('I\'m entry a')
// entry-b.js
import cb from './common-b'
import cabc from './common-abc'
cb()
cabc()
console.log('I\'m entry b')
// entry-c.js
import cabc from './common-abc'
cabc()
console.log('I\'m entry c')
// common-a.js
export default function () {
console.log('I\'m common a')
}
// common-b.js
export default function () {
console.log('I\'m common b')
}
// common-abc.js
export default function () {
console.log('I am common-abc')
}
Webpack 配置如下:
// webpack.config.js
entry: {
'entry-a': './src/entry-a.js',
'entry-b': './src/entry-b.js',
'entry-c': './src/entry-c.js'
},
output: {
filename: '[name].[chunkhash].js',
chunkFilename: '[name].[chunkhash].js',
}
编译结果如下:
Asset Size Chunks Chunk Names
entry-a.d702a9dfe4bd9fd8d29e.js 1.14 KiB 0 [emitted] entry-a
entry-b.e349f63455e20b60f6d5.js 1.14 KiB 1 [emitted] entry-b
entry-c.f767774953520bfd7cea.js 1.11 KiB 2 [emitted] entry-c
[0] ./src/common-abc.js 64 bytes {0} {1} {2} [built]
[1] ./src/entry-c.js 69 bytes {2} [built]
[2] ./src/entry-a.js + 1 modules 171 bytes {0} [built]
| ./src/entry-a.js 104 bytes [built]
| ./src/common-a.js 62 bytes [built]
[3] ./src/entry-b.js + 1 modules 171 bytes {1} [built]
| ./src/entry-b.js 104 bytes [built]
| ./src/common-b.js 62 bytes [built]
module
-
test1:
entry-a
需要增加一个依赖common-a2
// common-a2.js
export default function () {
console.log('I\'m common a2')
}
编译结果
Asset Size Chunks Chunk Names
entry-a.fe41f6501454aaba37de.js 1.17 KiB 0 [emitted] entry-a
entry-b.e349f63455e20b60f6d5.js 1.14 KiB 1 [emitted] entry-b
entry-c.f767774953520bfd7cea.js 1.11 KiB 2 [emitted] entry-c
[0] ./src/common-abc.js 64 bytes {0} {1} {2} [built]
[1] ./src/entry-c.js 69 bytes {2} [built]
[2] ./src/entry-a.js + 2 modules 272 bytes {0} [built]
| ./src/entry-a.js 142 bytes [built]
| ./src/common-a.js 62 bytes [built]
| ./src/common-a2.js 63 bytes [built]
[3] ./src/entry-b.js + 1 modules 171 bytes {1} [built]
| ./src/entry-b.js 104 bytes [built]
| ./src/common-b.js 62 bytes [built]
一切都很顺利,entry-a
增加了一个依赖,只有entry-a
的 hash 发生了变化,从d702a9dfe4bd9fd8d29e
-> fe41f6501454aaba37de
,entry-b
和entry-c
依然不变,完美!
王菲有一个歌叫《暗涌》,我个人一直非常喜欢,给大家推荐一下。
上面这个实验表面上是很成功,可到此为止了吗?实际上就像暗涌一下,表面平静,底下却潮水涌动。
为了方便对比hash
的变化,我简单写了个plugin,去替代上面那种要对比两大坨编译结果才能定位到具体是哪个hash
发生了变化。
// ChunkPlugin.js
...
MyChunkPlugin.prototype.apply = function (compiler) {
compiler.hooks.thisCompilation.tap('MyChunkPlugin', compilation => {
compilation.hooks.afterOptimizeChunkAssets.tap('MyChunkPlugin', chunks => {
const chunkMap = {}
chunks.forEach(chunk => (chunkMap[chunk.name] = chunk.renderedHash))
const result = fs.readFileSync('./hash.js', 'utf-8')
const diff = [];
if (result) {
const source = JSON.parse(result);
Object.keys(chunkMap).forEach(key => {
if (source[key] && chunkMap[key] !== source[key]) {
diff.push(`${key}: ${source[key]} -> ${chunkMap[key]} `)
} else {
diff.push(`${key}: '' -> ${chunkMap[key]} `)
}
})
}
fs.writeFile('./hash.js', `${JSON.stringify(chunkMap, null, '\t')}`)
fs.writeFile('./diff.js', diff.length ? diff.join('\n') : 'nothing changed')
})
})
}
重复上面的操作后生成结果如下:
entry-a: d702a9dfe4bd9fd8d29e -> fe41f6501454aaba37de
-
test-2:
entry-b
移除依赖common-b
,让entry-b
只依赖于公共的模块common-abc
// entry-b.js
import cabc from './common-abc'
cabc()
console.log('I\'m entry b')
继续编译:
entry-a: fe41f6501454aaba37de -> 409266c0e175d92e5f40
entry-b: e349f63455e20b60f6d5 -> 45eed8a58e4742f5c01d
entry-c: f767774953520bfd7cea -> 3c651c9b9fa129486a53
很遗憾,事情并没有跟我们想象的那样进行,仅仅是减少了entry-b
的一个依赖之后,entry-a
和entry-c
的hash
也发生了变化。
原因其实很简单,contenthash
是根据计算的,生成的文件内容发生了变化,计算出来的hash
也就跟着变了。
那为什么在没有改变a
和c
及其依赖模块的内容时,它们最终生成的文件hash
也发生了变化。
- module id
每一个入口模块都会引入各个不同的被依赖模块,Webpack在编译文件时,会给所有的模块声明唯一的id,并生成一些函数,帮助入口模块去找到所有的依赖。
下面是entry-a
在没有压缩混淆下的部分生成代码:
//...
var _common_a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./common-a */ 1);
var _common_a2__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./common-a2 */ 2);
var _common_abc__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./common-abc */ 3);
//...
我们大概可以猜出Webpack为几个被依赖模块分别生成了 module id 1 2 3 ...
结合webpack文档可以发现默认情况下module id 是根据模块的调用顺序,以数字自增的方式赋值的。
如何保持module id
的稳定性?
HashedModuleIdsPlugin是webpack内置的一个适用于生产环境的插件。它根据每个模块的相对路径计算出一个四个字符的hash串,解决了数值型id不稳定的问题。
修改一下webpack配置文件:
// webpack.config.js
// ...
plugins: [
// ...
new webpack.HashedModuleIdsPlugin()
]
重复上一个实验,entry-b
依赖 common-b
// entry-b.js
import cb from './common-b'
import cabc from './common-abc'
cb()
cabc()
console.log('I\'m entry b')
------------------------------------------------------------
// 编译结果
Asset Size Chunks Chunk Names
entry-a.59fcd77ff264f62591d3.js 1.19 KiB 0 [emitted] entry-a
entry-b.408073538586b4495dd7.js 1.16 KiB 1 [emitted] entry-b
entry-c.1f28d5213db6b69b83ed.js 1.13 KiB 2 [emitted] entry-c
[F85t] ./src/common-abc.js 64 bytes {0} {1} {2} [built]
[GUDB] ./src/entry-a.js + 2 modules 272 bytes {0} [built]
| ./src/entry-a.js 142 bytes [built]
| ./src/common-a.js 62 bytes [built]
| ./src/common-a2.js 63 bytes [built]
[aIzb] ./src/entry-c.js 69 bytes {2} [built]
[grd8] ./src/entry-b.js + 1 modules 171 bytes {1} [built]
| ./src/entry-b.js 104 bytes [built]
| ./src/common-b.js 62 bytes [built]
去掉common-b
依赖:
//entry-b.js
import cabc from './common-abc'
cabc()
console.log('I\'m entry b')
------------------------------------------------
// 编译结果
Asset Size Chunks Chunk Names
entry-a.59fcd77ff264f62591d3.js 1.19 KiB 0 [emitted] entry-a
entry-b.a75ec2de235c6595507a.js 1.13 KiB 1 [emitted] entry-b
entry-c.1f28d5213db6b69b83ed.js 1.13 KiB 2 [emitted] entry-c
[F85t] ./src/common-abc.js 64 bytes {0} {1} {2} [built]
[GUDB] ./src/entry-a.js + 2 modules 272 bytes {0} [built]
| ./src/entry-a.js 142 bytes [built]
| ./src/common-a.js 62 bytes [built]
| ./src/common-a2.js 63 bytes [built]
[aIzb] ./src/entry-c.js 69 bytes {2} [built]
[grd8] ./src/entry-b.js 69 bytes {1} [built]
// diff
entry-b: 408073538586b4495dd7 -> a75ec2de235c6595507a
和我们期望的答案一样!(可以重复几次实验)
至此,收获持久化缓存第一招:
HashedModuleIdsPlugin
chunk
继续基于上面的实验
- test-3 这个实验我们分两步进行
1、给entry-a
增加异步加载chunkasync.js
// entry-a.js
import ca from './common-a'
import ca2 from './common-a2'
import cabc from './common-abc'
ca()
ca2()
cabc()
(async function () {
const asy = await import(/* webpackChunkName: "async" */ './async')
asy()
})()
console.log('I\'m entry a')
// async.js
export default function () {
console.log('I am async')
}
----------------------------------------------------------------------------------------------
// 编译结果
Asset Size Chunks Chunk Names
async.5411b81525bb7e4c771e.js 205 bytes 0 [emitted] async
entry-a.a9c2efa137c11a449854.js 9.45 KiB 1 [emitted] entry-a
entry-b.5f44a689594f78eb9b62.js 1.13 KiB 2 [emitted] entry-b
entry-c.220cbeddf5b77bf44a0d.js 1.13 KiB 3 [emitted] entry-c
[F85t] ./src/common-abc.js 64 bytes {1} {2} {3} [built]
[GUDB] ./src/entry-a.js + 2 modules 961 bytes {1} [built]
| ./src/entry-a.js 821 bytes [built]
| ./src/common-a.js 62 bytes [built]
| ./src/common-a2.js 63 bytes [built]
[TSF4] ./src/async.js 59 bytes {0} [built]
[aIzb] ./src/entry-c.js 69 bytes {3} [built]
[grd8] ./src/entry-b.js 69 bytes {2} [built]
+ 4 hidden modules
// diff.js
async: '' -> 5411b81525bb7e4c771e
entry-a: 59fcd77ff264f62591d3 -> a9c2efa137c11a449854
entry-b: a75ec2de235c6595507a -> 5f44a689594f78eb9b62
entry-c: 1f28d5213db6b69b83ed -> 220cbeddf5b77bf44a0d
2、在这个基础上再增加一个入口文件 entry-a2
:
// entry-a2.js
export default function () {
console.log('I\'m entry a2')
}
// webpack.config.js
entry: {
'entry-a': './src/entry-a.js',
'entry-a2': './src/entry-a2.js',
'entry-b': './src/entry-b.js',
'entry-c': './src/entry-c.js',
},
----------------------------------------------------------------------------------------------
// 编译结果:
Asset Size Chunks Chunk Names
async.5411b81525bb7e4c771e.js 205 bytes 0 [emitted] async
entry-a.a9c2efa137c11a449854.js 9.45 KiB 1 [emitted] entry-a
entry-a2.cbf75fa37ffde273148a.js 1.04 KiB 2 [emitted] entry-a2
entry-b.ed39f7105ea4f26b42e3.js 1.13 KiB 3 [emitted] entry-b
entry-c.adebd02c1ec23be8edeb.js 1.13 KiB 4 [emitted] entry-c
[F85t] ./src/common-abc.js 64 bytes {1} {3} {4} [built]
[GUDB] ./src/entry-a.js + 2 modules 961 bytes {1} [built]
| ./src/entry-a.js 821 bytes [built]
| ./src/common-a.js 62 bytes [built]
| ./src/common-a2.js 63 bytes [built]
[PV30] ./src/entry-a2.js 62 bytes {2} [built]
[TSF4] ./src/async.js 59 bytes {0} [built]
[aIzb] ./src/entry-c.js 69 bytes {4} [built]
[grd8] ./src/entry-b.js 69 bytes {3} [built]
+ 4 hidden modules
// diff
entry-a2: '' -> cbf75fa37ffde273148a
entry-b: 5f44a689594f78eb9b62 -> ed39f7105ea4f26b42e3
entry-c: 220cbeddf5b77bf44a0d -> adebd02c1ec23be8edeb
本来我们期望的结果应该是这样的:
- 给
entry-a
增加一个异步加载chunk
,entry-a
的hash
发生变化,其他entry
保持不变。 - 增加一个全新的
entry
,已有的chunk
(入口chunk
/异步加载chunk
)都应该保持不变。
但上面的实验得到的答案却是:
- 每次增加一个
chunk
,总是有部分毫不相干的chunk
受到了影响。
重复多次上述实验会发现这样一个规律:
chunk
跟module
一样,默认以数字自增的方式为所有chunk
分配一个id
,每次增加或减少一个chunk
,排在其后面的chunk
的id
受到了影响,进而其hash
也跟着发生了变化。
如何保持chunk id
的稳定性?
namedChunks是webpack的一个解决这个问题的配置,它用chunk
的name
替代了数字自增的方法为chunk id
赋值,从而让chunk
不受其他chunk id
影响。
// webpack.config.js
module.exports = {
//...
optimization: {
namedChunks: true
}
};
-
test-4 用
namedChunks
测试一下chunk id
是否能保持稳定
重复前面的实验 test -3:
// 原始编译结果
Asset Size Chunks Chunk Names
entry-a.0864367d249b191a3a0e.js 1.19 KiB entry-a [emitted] entry-a
entry-b.5c7b3532d418453241f4.js 1.13 KiB entry-b [emitted] entry-b
entry-c.6887e26445575eff0402.js 1.13 KiB entry-c [emitted] entry-c
[F85t] ./src/common-abc.js 64 bytes {entry-a} {entry-b} {entry-c} [built]
[GUDB] ./src/entry-a.js + 2 modules 272 bytes {entry-a} [built]
| ./src/entry-a.js 142 bytes [built]
| ./src/common-a.js 62 bytes [built]
| ./src/common-a2.js 63 bytes [built]
[aIzb] ./src/entry-c.js 69 bytes {entry-c} [built]
[grd8] ./src/entry-b.js 69 bytes {entry-b} [built]
1、给entry-a
增加异步加载chunkasync.js
// entry-a.js
import ca from './common-a'
import ca2 from './common-a2'
import cabc from './common-abc'
ca()
ca2()
cabc()
(async function () {
const asy = await import(/* webpackChunkName: "async" */ './async')
asy()
})()
console.log('I\'m entry a')
// async.js
export default function () {
console.log('I am async')
}
----------------------------------------------------------------------------------------------
// 编译结果
Asset Size Chunks Chunk Names
async.3b06cb8d92816f773b08.js 211 bytes async [emitted] async
entry-a.f201c1668ae5af4b9b59.js 9.47 KiB entry-a [emitted] entry-a
entry-b.5c7b3532d418453241f4.js 1.13 KiB entry-b [emitted] entry-b
entry-c.6887e26445575eff0402.js 1.13 KiB entry-c [emitted] entry-c
[F85t] ./src/common-abc.js 64 bytes {entry-a} {entry-b} {entry-c} [built]
[GUDB] ./src/entry-a.js + 2 modules 961 bytes {entry-a} [built]
| ./src/entry-a.js 821 bytes [built]
| ./src/common-a.js 62 bytes [built]
| ./src/common-a2.js 63 bytes [built]
[TSF4] ./src/async.js 59 bytes {async} [built]
[aIzb] ./src/entry-c.js 69 bytes {entry-c} [built]
[grd8] ./src/entry-b.js 69 bytes {entry-b} [built]
+ 4 hidden modules
// diff
async: '' -> 3b06cb8d92816f773b08
entry-a: 0864367d249b191a3a0e -> f201c1668ae5af4b9b59
从编译结果可以看到,增加了async
之后,只有引入它的entry-a
发生了hash
变化,其他的chunk
保持不变。
2、在这个基础上再增加一个入口文件 entry-a2
:
// entry-a2.js
export default function () {
console.log('I\'m entry a2')
}
----------------------------------------------------------------------------------------------
// 编译结果:
Asset Size Chunks Chunk Names
async.3b06cb8d92816f773b08.js 211 bytes async [emitted] async
entry-a.f201c1668ae5af4b9b59.js 9.47 KiB entry-a [emitted] entry-a
entry-a2.820dc92f91bed3570102.js 1.04 KiB entry-a2 [emitted] entry-a2
entry-b.5c7b3532d418453241f4.js 1.13 KiB entry-b [emitted] entry-b
entry-c.6887e26445575eff0402.js 1.13 KiB entry-c [emitted] entry-c
[F85t] ./src/common-abc.js 64 bytes {entry-a} {entry-b} {entry-c} [built]
[GUDB] ./src/entry-a.js + 2 modules 961 bytes {entry-a} [built]
| ./src/entry-a.js 821 bytes [built]
| ./src/common-a.js 62 bytes [built]
| ./src/common-a2.js 63 bytes [built]
[PV30] ./src/entry-a2.js 62 bytes {entry-a2} [built]
[TSF4] ./src/async.js 59 bytes {async} [built]
[aIzb] ./src/entry-c.js 69 bytes {entry-c} [built]
[grd8] ./src/entry-b.js 69 bytes {entry-b} [built]
+ 4 hidden modules
// diff
entry-a2: '' -> 820dc92f91bed3570102
这个编译结果依然符合我们的期望,增加了一个全新的entry
,已存在的所有chunk
都不会受到影响。
多重复几次实验,执行结果依然符合期望。
在这里,收获持久化缓存第二招:
optimization.namedChunks: true
在webpack
文档里其实对这个配置的定义是便于开发模式下调试,所以在development
模式下该配置默认是true
,而在production
下则相反。这里我其实是比较费解,仅仅是因为namedChunk
生成的chunk id
比默认的numeric id
的size
稍大一点,就降低了chunk id
的稳定性,但其带来的所谓size
的精简在硕大的工程里简直是无足轻重,感觉有点舍本逐末。
另外,在webpack 5之后,namedChunks
将会变成一个deprecated
配置,取而代之的是optimization.chunkIds: named
。
总结
在一大堆无聊的实验之,得到以下结论
- 给生成的文件名加入
[chunkhash]
- 使用
HashedModuleIdsPlugin
让module id
保持稳定 - 使用
namedChunks
让chunk id
保持稳定
webpack优化的方式其实还有很多,自己动手踩坑,看一下webpack生成后的代码还有官方文档,总是能发现并解决问题。就好比我做完上述实验,又发现了一个问题,等着下次解决吧。