webpack 中的 hash 与优化

使用 webpack 进行打包,每个资源都可以生成一个带 hash 的路径。浏览器可以利用该 hash 能够做到资源缓存,访问性能提升。

Q:为什么文件需要hash?

A:如果不使用文件hash,那么就不能设置缓存(如果你设置缓存,因为你的代码可能会变更,但是浏览器会命中缓存,从而获取不到最新的代码),每次都必须从服务器中获取,造成资源浪费不说,用户打开网站的速度也可能缓慢。但是我们使用hash,在代码(hash)没有发生变更之前,只要设置的缓存时间没有到期,都可以利用缓存来帮助我们做优化。

我们可以通过 output.filename 进行配置,配置如下?

// webpack.config.js
{
 output: {
 filename: '[name].[fullhash].js'
 }
}

如果我们对添加 hash 的资源设置长期强缓存,一般为一年时间,那么就可以大幅度提高该网站的http缓存能力,从而提高网站的二次加载性能。

通过在服务端对资源设置以下 Response Header,进行强缓存一年时间(长期缓存)

Cache-Control: public,max-age=31536000,immutable

或者nginx中配置expires1y

# 配置带 hash 的文件强缓存一年
# static 为目录
location /static {
 expires 1y;
}

当源文件的hash发生变化时,生成新的可永久缓存的资源地址,原本的 hash 地址就会失效,因此我们对打包后带 hash 的资源文件设置长期缓存后,需要使文件的 hash (即文件名称)尽量少的被修改,这样才能使得缓存利用最大化。

下面通过实际效果看一下缓存未命中与缓存命中时的文件获取时间来感受一下它的作用吧?

webpack 中三种 hash 生成特性

在 webpack 中有三种 hash 生成方式分别是:fullhashchunkhashcontenthash。下面我们通过代码来看下它们生成 hash 的规律是什么。实验代码如下?

/*********** src/index.js ************/
import(/* webpackChunkName: "sum" */ './sum').then(m => {
 console.log(m.default(1, 2))
})
/*********** src/sum.js *************/
// 引入了 css
import "./sum.css"
import(/* webpackChunkName: "add" */ './add').then(m => {
 console.log(m.default(1,2))
})
const sum = (...args) => args.reduce((x, y) => x + y, 0)
export default sum
/*********** src/add.js ************/
const add = (x, y) => x + y
export default add
/*********** sum.css ***************/
// 随便写点儿~

配置 webpack 根据这三种 hash 方式,做不同的输出,配置如下?

// webpackconfig.js
const webpack = require('webpack')
const path = require('path')
// 为了更好的体现 chunkhash 与 contenthash,所以使用到了 css
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
const moduleConfig = {
 rules: [
 {
 test: /\.css$/,
 use: [
 MiniCssExtractPlugin.loader,
 "css-loader",
 ]
 },
 ]
}
function f2() {
 return webpack([
 // fullhash
 {
 entry: './src/index.js',
 mode: 'none',
 module: moduleConfig,
 output: {
 filename: '[name].[fullhash].js',
 path: path.resolve(__dirname, 'dist/fullhash'),
 clean: true
 },
 plugins: [
 new MiniCssExtractPlugin({
 // css 单独输出成文件
 filename: '[name].[fullhash].css'
 })
 ]
 },
 // chunkhash
 {
 entry: './src/index.js',
 mode: 'none',
 module: moduleConfig,
 output: {
 filename: '[name].[chunkhash].js',
 path: path.resolve(__dirname, 'dist/chunkhash'),
 clean: true
 },
 plugins: [
 new MiniCssExtractPlugin({
 // css 单独输出成文件
 filename: '[name].[chunkhash].css'
 })
 ]
 },
 // contenthash
 {
 entry: './src/index.js',
 mode: 'none',
 module: moduleConfig,
 output: {
 filename: '[name].[contenthash].js',
 // chunkFilename: '[name].[contenthash].chunk.js',
 path: path.resolve(__dirname, 'dist/contenthash'),
 clean: true
 },
 plugins: [
 new MiniCssExtractPlugin({
 // css 单独输出成文件
 filename: '[name].[contenthash].css'
 })
 ]
 },
 ])
}
f2().run(() => {
 console.log('✅')
})

这里因为我们需要使用到css-loadermini-css-extract-plugin来处理 css,所以需要安装这两个依赖。

npm install css-loader mini-css-extract-plugin -D

安装好以后就可以进行打包了。

node webpackconfig.js

我们分别来看这三种 hash 方式的构建产物文件

根据构建产物结果,可以看出不同的 hash 方式之间的特性?

  • fullhash:所有构建产物均使用相同的 hash。这意味着修改任何一个文件,所有文件的 hash 值都将会被改变
  • chunkhash:同一 chunk 下的文件使用相同的 hash。修改在同一个chunk中的文件,同属该 chunk 的所有文件 hash 值都会改变
  • contenthash:不同的文件使用不同的 hash。每个文件都会根据自己的文件内容生成 hash 值,互不干扰。

    选择最优的 contenthash 配合缓存策略

    既然我们知道文件 hash 值被改动的频率越小,浏览器缓存利用的效果越佳,那么我们就可以利用这个特性尽量不让打包出来的文件名产生变化。

上面我们已经得知了contenthash能做到文件级别的hash生成,那么我们在使用webpack打包时自然是使用contenthash最优,这样能更好的配合浏览器的缓存策略来进行优化。

moduleId/chunkId 的变化会导致 hash 变化

实际上,除了上面说的内容产生变化以外,moduleIdchunkId的变化也会导致文件的 hash 产生变化。

moduleId

新建一个文件other.js,随便在里面写点什么就好。在sum.js中添加import o from 'other',按照上面说的,本次 hash发生变化的应该是 sum.js ,但是打包后却发现没有被修改的add.js的hash值也改变了。
这是因为我们的模块顺序产生了变更,我们看在加入other之前的add构建出来的moduleId3

再看添加other以后的add构建出来的moduleId变成了4

由此可见,因为moduleId的不确定会导致后续所有模块的hash值都被改变。

对于moduleId,你也可以通过配置打包文件的 output.chunkLoading: 'import' 和 chunkFormat: 'module' 来更加方便的观测moduleId。
另外,细心的朋友可能发现不止是add的hash发生了变化,main的hash也发生了变化,这个下面会讲怎么优化。

那么也就是如果我们有很多的文件,突然在入口文件中新添加了一个模块,就会导致后续所有文件的hash产生变化,这将导致下次在浏览器中所有的文件都不能通过缓存访问。

chunkId

为了更直观的观测差异,将import时的魔法注释删除掉,同时将import o from 'other'修改为import('other')。打包以后可以看到addchunkId发生了修改,同时文件的hash值也产生了变化。

通过配置 deterministic 来确定Id

在webpack中配置如下就可以将每个模块及chunk的id确定,解决掉moduleIdchunkId的不确定导致文件的hash值变化的问题了。

{
 optimization: {
 moduleIds: 'deterministic',
 chunkIds: 'deterministic'
 }
}

但是在生产环境下,这两个配置并不需要手动配置,webpack默认配置就是deterministic

runtimeChunk

上面我们说过,如果你仔细观察的够仔细,你会发现每次其他chunk的hash发生变化时,我们的main.xxx.js文件的hash也会随之发生变化,这个问题的产生就在于__webpack_reuqire__.u函数。

__webpack_require__.u = (chunkId) => {
 return "chunk." + chunkId + "." + chunkId + "." + {"1":"02a3ae21ad797ac66a78","2":"c520e70d5d3c3b193954"}[chunkId] + ".js";
};

可以看到,因为hash值是固定的,而index.js打包后的内容与webpack的运行时代码都在main.xxx.js中,为了使hash的影响最小化,我们可以配置runtimeChunk:truewebpack的运行时代码与index.js分开

optimization: {
 runtimeChunk: true
}

打包以后原本的webpack运行时代码就被分割到runtime~main.xxx.js中去了,如下?

我们在index.html中引入这两个main文件

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <meta http-equiv="X-UA-Compatible" content="IE=edge">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <title>Document</title>
</head>
<body>
 <script src="./dist/runtimeChunk/main.df2faefa8a088c461932.js"></script>
 <script src="./dist/runtimeChunk/runtime~main.c60f9aea8ddad8b04f58.js"></script>
</body>
<script>
</script>
</html>

这里有个比较有意思的点,就是这两个文件可以不分先后顺序的引入。

这是因为在runtime~main 当中有一句chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0))
再配合上模块中的 (self["webpackChunk"] = self["webpackChunk"] || []).push()

a) 如果是runtime~main.xxx.js先加载,那么这里的push就是webpackJsonpCallback

b) 如果是main.xxx.js先加载,这个self["webpackChunk"].push是数组的push,此时先放到数组里面去了,在runtime~main.xxx.js加载时通过chunkLoadingGlobal.forEach执行一次webpackJsonpCallback
如此,就不用考虑这两个文件的加载顺序了

下面我们来看下runtime被单独分离出去以后是如何继续运行代码的
先看runtime~main.xxx.js的内容

var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
 var [chunkIds, moreModules, runtime] = data;
 var moduleId, chunkId, i = 0;
 if(chunkIds.some((id) => (installedChunks[id] !== 0))) {
 // ...省略部分无关代码
 // 如果包含第三个参数,则传递 __webpack_require__,加载入口模块
 if(runtime) var result = runtime(__webpack_require__);
 }
 // ...省略部分无关代码
}

再来看main.xxx.js,其中数组下标2的内容对应的就是上面的runtime,你可以把它看成__webpack_require__(138)即可,也就是加载index.js这个模块的内容。

(self["webpackChunk"] = self["webpackChunk"] || []).push(
 [
 [179],
 {
 // index.js 的代码打包结果
 138: ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
 __webpack_require__.e(/* import() */ 548).then(__webpack_require__.bind(__webpack_require__, 548)).then(m => {
 console.log(m.default(1, 2))
 })
 })
 },
 // runtime
 __webpack_require__ => { // webpackRuntimeModules
 var __webpack_exec__ = (moduleId) => (__webpack_require__(__webpack_require__.s = moduleId))
 var __webpack_exports__ = (__webpack_exec__(138));
 }
 ]
);

选择更快的hash函数

上面hash进行优化主要是利用浏览器缓存机制进行的,其实我们还可以针对打包时的hash值的计算进行优化。在webpack中md4是作为默认的hash计算函数,我们可以选择更加快速的xxhash64函数减少 CPU 消耗,并提升打包速度。

output: {
 // ......
 hashFunction: 'xxhash64'
}

下面这个网站针对一些hash函数的性能进行了介绍,可以了解一下
https://aras-p.info/blog/2016/08/09/More-Hash-Function-Tests/

本节我们学习了以下内容:

  1. 因为浏览器利用访问路径做的缓存特点,利用文件hash化,既能使得我们拿到最新的文件内容,又能在缓存有效期内利用缓存的特性对网站加载速度做优化。
  2. 在webpack中有fullhashchunkhashcontenthash三种生成 hash 的方式。其中**contenthash**能更加好的利用浏览器的缓存特性
  3. 文件内容变更moduleId / chunkId 的变化会导致 hash 变更。通过设置optimization.moduleId: 'deterministic'optimization.chunkId: 'deterministic'可以使moduleId/chunkId固定,从而减少对hash影响(生产模式默认)。
  4. 配置optimization.runtimeChunk: true将 webpack 运行时代码(webpack骨架)与入口文件代码分离出来,将 hash 的影响降到更小(只要入口文件的代码没有修改,就可以再次被缓存命中)。
  5. 选择xxhash64能够减少计算hash的时间,提升打包速度
作者:拜托啦俊酱丶

%s 个评论

要回复文章请先登录注册