由浅至深了解webpack异步加载
flytam Lv4

源自最近对业务项目进行 webpack 异步分包加载一点点的学习总结

提纲如下:

  • 相关概念
  • webpack 分包配置
  • webpack 异步加载分包如何实现

相关概念

  • module、chunk、bundle 的概念

先来一波名词解释。先上网上一张图解释:


通过图可以很直观的分出这几个名词的概念:

1、module:我们源码目录中的每一个文件,在 webpack 中当作module来处理(webpack 原生不支持的文件类型,则通过 loader 来实现)。module组成了chunk
2、chunkwebpack打包过程中的产物,在默认一般情况下(没有考虑分包等情况),x 个webpackentry会输出 x 个bundle
3、bundlewebpack最终输出的东西,可以直接在浏览器运行的。从图中看可以看到,在抽离 css(当然也可以是图片、字体文件之类的)的情况下,一个chunk是会输出多个bundle的,但是默认情况下一般一个chunk也只是会输出一个bundle

  • hashchunkhashcontenthash

这里不进行 demo 演示了,网上相关演示已经很多。

hash。所有的 bundle 使用同一个 hash 值,跟每一次 webpack 打包的过程有关

chunkhash。根据每一个 chunk 的内容进行 hash,同一个 chunk 的所有 bundle 产物的 hash 值是一样的。因此若其中一个 bundle 的修改,同一 chunk 的所有产物 hash 也会被修改。

contenthash。计算与文件内容本身相关。

tips:需要注意的是,在热更新模式下,会导致chunkhashcontenthash计算错误,发生错误(Cannot use [chunkhash] or [contenthash] for chunk in '[name].[chunkhash].js' (use [hash] instead) )。因此热更新下只能使用hash模式或者不使用hash。在生产环境中我们一般使用contenthash或者chunkhash

说了这么多,那么使用异步加载/分包加载有什么好处呢。简单来说有以下几点

1、更好的利用浏览器缓存。如果我们一个很大的项目,不使用分包的话,每一次打包只会生成一个 js 文件,假设这个 js 打包出来有 2MB。而当日常代码发布的时候,我们可能只是修改了其中的一行代码,但是由于内容变了,打包出来的 js 的哈希值也发生改变。浏览器这个时候就要重新去加载这个 2MB 的 js 文件。而如果使用了分包,分出了几个 chunk,修改了一行代码,影响的只是这个 chunk 的哈希(这里严谨来说在不抽离 mainifest 的情况下,可能有多个哈希也会变化),其它哈希是不变的。这就能利用到 hash 不变化部分代码的缓存

2、更快的加载速度。假设进入一个页面需要加载一个 2MB 的 js,经过分包抽离后,可能进入这个页面变成了加载 4 个 500Kb 的 js。我们知道,浏览器对于同一域名的最大并发请求数是 6 个(所以 webpack 的maxAsyncRequests默认值是 6),这样这个 4 个 500KB 的 js 将同时加载,相当于只是穿行加载一个 500kb 的资源,速度也会有相应的提高。

3、如果实现的是代码异步懒加载。对于部分可能某些地方才用到的代码,在用到的时候才去加载,也能很好起到节省流量的目的。

webpack 分包配置

在这之前,先强调一次概念,splitChunk,针对的是chunk,并不是module。对于同一个 chunk 中,无论一个代码文件被同 chunk 引用了多少次,它都还是算 1 次。只有一个代码文件被多个 chunk 引用,才算是多次。

webpack 的默认分包配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
module.exports = {
optimization: {
splitChunks: {
// **`splitChunks.chunks: 'async'`**。表示哪些类型的chunk会参与split。默认是异步加载的chunk。值还可以是`initial`(表示入口同步chunk)、`all`(相当于`initial`+`async`)。
chunks: "async",
// minSize 表示符合代码分割产生的新生成chunk的最小大小。默认是大于30kb的才会生成新的chunk
minSize: 30000,
// maxSize 表示webpack会尝试将大于maxSize的chunk拆分成更小的chunk,拆解后的值需要大于minSize
maxSize: 0,
// 一个模块被最少多少个chunk共享时参与split
minChunks: 1,
// 最大异步请求数。该值可以理解为一个异步chunk,被抽离出同时加载的chunk数不超过该值。若为1,该异步chunk将不会抽离出任意代码块
maxAsyncRequests: 5,
// 入口chunk最大请求数。在多entry chunk的情况下会用到,表示多entry chunk公共代码抽出的最大同时加载的chunk数
maxInitialRequests: 3,
// 初始chunk最大请求数。
// 多个chunk拆分出小chunk时,这个chunk的名字由多个chunk与连接符组合成
automaticNameDelimiter: "~",
// 表示chunk的名字自动生成(由cacheGroups的key、entry名字)
name: true,
// cacheGroups 表示分包分组规则,每一个分组会继承于default
// priority表示优先级,一个chunk可能被多个分组规则命中时,会使用优先级较高的
// test提供时 表示哪些模块会被抽离
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
// 复用已经生成的chunk
reuseExistingChunk: true
}
}
}
}
};

还有一个很重要的配置是output.jsonpFunction(默认是webpackJsonp)。这是用于异步加载 chunk 的时候一个全局变量。如果多 webpack 环境下,为了防止该函数命名冲撞产生问题,最好设置成一个比较唯一的值。

一般而言,没有最完美的分包配置,只有最合适当前项目场景需求的配置。很多时候,默认配置已经足够可用了。

通常来说,为了保证 hash 的稳定性,建议:

1、使用webpack.HashedModuleIdsPlugin。这个插件会根据模块的相对路径生成一个四位数的 hash 作为模块 id。默认情况下 webpack 是使用模块数字自增 id 来命名,当插入一个模块占用了一个 id(或者一个删去一个模块)时,后续所有的模块 id 都受到影响,导致模块 id 变化引起打包文件的 hash 变化。使用这个插件就能解决这个问题。

2、chunkid 也是自增的,同样可能遇到模块 id 的问题。可以通过设置optimization.namedChunks为 true(默认 dev 模式下为 true,prod 模式为 false),将chunk的名字使用命名chunk

1、2 后的效果如下。

3、抽离 css 使用mini-css-extract-plugin。hash 模式使用contenthash

这里以腾讯云某控制台页面以下为例,使用 webpack 路有异步加载效果后如下。可以看到,第一次访问页面。这里是先请求到一个总的入口 js,然后根据我们访问的路由(路由 1),再去加载这个路由相关的代码。这里可以看到我们异步加载的 js 数为 5,就相当于上面提到的默认配置项maxAsyncRequests,通过waterfall可以看到这里是并发请求的。如果再进去其它路由(路由 2)的话,只会加载一个其它路由的 js(或者还有当前没有加载过的 vendor js)。这里如果只修改了路由 1 的自己单独业务代码,vendor 相关的 hash 和其它路由的 hash 也不是不会变,这些文件就能很好的利用了浏览器缓存了

webpack 异步加载分包如何实现

我们知道,默认情况下,浏览器环境的 js 是不支持import和异步import('xxx').then(...)的。那么 webpack 是如何实现使得浏览器支持的呢,下面对 webpack 构建后的代码进行分析,了解其背后原理。

实验代码结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// webpack.js
const webpack = require("webpack");
const path = require("path");
const CleanWebpackPlugin = require("clean-webpack-plugin").CleanWebpackPlugin;
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
entry: {
a: "./src/a.js",
b: "./src/b.js"
},
output: {
filename: "[name].[chunkhash].js",
chunkFilename: "[name].[chunkhash].js",
path: **dirname + "/dist",
jsonpFunction: "\_**jsonp"
},
optimization: {
splitChunks: {
minSize: 0
}
// namedChunks: true
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin()
//new webpack.HashedModuleIdsPlugin()
],
devServer: {
contentBase: path.join(\_\_dirname, "dist"),
compress: true,
port: 8000
}
};

// src/a.js
import { common1 } from "./common1";
import { common2 } from "./common2";
common1();
common2();
import(/_ webpackChunkName: "asyncCommon2" _/ "./asyncCommon2.js").then(
({ asyncCommon2 }) => {
asyncCommon2();
console.log("done");
}
);

// src/b.js
import { common1 } from "./common1";
common1();
import(/_ webpackChunkName: "asyncCommon2" _/ "./asyncCommon2.js").then(
({ asyncCommon2 }) => {
asyncCommon2();
console.log("done");
}
);

// src/asyncCommon1.js
export function asyncCommon1(){
console.log('asyncCommon1')
}
// src/asyncCommon2.js
export function asyncCommon2(){
console.log('asyncCommon2')
}

// ./src/common1.js
export function common1() {
console.log("common11");
}
import(/_ webpackChunkName: "asyncCommon1" _/ "./asyncCommon1").then(
({ asyncCommon1 }) => {
asyncCommon1();
}
);

// src/common2.js
export function common2(){
console.log('common2')
}

在分析异步加载机制之前,先看下 webpack 打包出来的代码结构长啥样(为了便于阅读,这里使用 dev 模式打包,没有使用任何 babel 转码)。列出与加载相关的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 入口文件 a.js
(function() {
//.....
function webpackJsonpCallback(data){
//....
}

// 缓存已经加载过的module。无论是同步还是异步加载的模块都会进入该缓存
var installedModules = {};
// 记录chunk的状态位
// 值:0 表示已加载完成。
// undefined : chunk 还没加载
// null :chunk preloaded/prefetched
// Promise : chunk正在加载
var installedChunks = {
a: 0
};


// 用于根据chunkId,拿异步加载的js地址
function jsonpScriptSrc(chunkId){
//...
}

// 同步import
function __webpack_require__(moduleId){
//...
}

// 用于加载异步import的方法
__webpack_require__.e = function requireEnsure(chunkId) {
//...
}
// 加载并执行入口js
return __webpack_require__((__webpack_require__.s = "./src/a.js"));

})({
"./src/a.js": function(module, __webpack_exports__, __webpack_require__) {
eval( ...); // ./src/a.js的文件内容
},
"./src/common1.js": ....,
"./src/common2.js": ...
});

可以看到,经过 webpack 打包后的入口文件是一个立即执行函数,立即执行函数的参数就是为入口函数的同步import的代码模块对象。key 值是路径名,value 值是一个执行相应模块代码的eval函数。这个入口函数内有几个重要的变量/函数。

  • webpackJsonpCallback函数。加载异步模块完成的回调。
  • installedModules变量。 缓存已经加载过的 module。无论是同步还是异步加载的模块都会进入该缓存。key是模块 id,value是一个对象{ i: 模块id, l: 布尔值,表示模块是否已经加载过, exports: 该模块的导出值 }
  • installedChunks变量。缓存已经加载过的 chunk 的状态。有几个状态位。0表示已加载完成、 undefined chunk 还没加载、 null :chunk preloaded/prefetched加载的模块、Promise : chunk 正在加载
  • jsonpScriptSrc变量。用于返回异步 chunk 的 js 地址。如果设置了webpack.publicPath(一般是 cdn 域名,这个会存到__webpack_require__.p中),也会和该地址拼接成最终地址
  • __webpack_require__函数。同步 import的调用
  • __webpack_require__.e函数。异步import的调用

而每个模块构建出来后是一个类型如下形式的函数,函数入参module对应于当前模块的相关状态(是否加载完成、导出值、id 等,下文提到)、__webpack_exports__就是当前模块的导出(就是 export)、__webpack_require__就是入口 chunk 的__webpack_require__函数,用于import其它代码

1
2
3
4
function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval(模块代码...);// (1)
}

eval内的代码如下,以a.js为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// (1)
// 格式化为js后
__webpack_require__.r(__webpack_exports__);
var _common1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
"./src/common1.js"
);
var _common2__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(
"./src/common2.js"
);
// _common1__WEBPACK_IMPORTED_MODULE_0__是导出对象
// 执行导出的common1方法
// 源码js:
// import { common1 } from "./common1";
// common1();
Object(_common1__WEBPACK_IMPORTED_MODULE_0__["common1"])();

Object(_common2__WEBPACK_IMPORTED_MODULE_1__["common2"])();
__webpack_require__
.e("asyncCommon2")
.then(__webpack_require__.bind(null, "./src/asyncCommon2.js"))
.then(({ asyncCommon2 }) => {
asyncCommon2();
console.log("done");
});

于是,就可知道

  • 同步import最终转化成__webpack_require__函数
  • 异步import最终转化成__webpack_require__.e方法

整个 流程执行就是。

入口文件最开始通过__webpack_require__((__webpack_require__.s = "./src/a.js"))加载入口的 js,(上面可以观察到installedChunked变量的初始值是{a:0},),并通过eval执行 a.js 中的代码。

__webpack_require__可以说是整个 webpack 构建后代码出现最多的东西了,那么__webpack_require__做了啥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function __webpack_require__(moduleId) {
// 如果一个模块已经import加载过了,再次import的话就直接返回
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 之前没有加载的话将它挂到installedModules进行缓存
var module = (installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
});

// 执行相应的加载的模块
modules[moduleId].call(
module.exports,
module,
module.exports,
__webpack_require__
);

// 设置模块的状态为已加载
module.l = true;

// 返回模块的导出值
return module.exports;
}

这里就很直观了,这个函数接收一个moduleId,对应于立即执行函数传入参数的key值。若一个模块之前已经加载过,直接返回这个模块的导出值;若这个模块还没加载过,就执行这个模块,将它缓存到installedModules相应的moduleId为 key 的位置上,然后返回模块的导出值。所以在 webpack 打包代码中,import一个模块多次,这个模块只会被执行一次。还有一个地方就是,在 webpack 打包模块中,默认importrequire是一样的,最终都是转化成__webpack_require__

回到一个经典的问题,webpack环境中如果发生循环引用会怎样?a.js有一个import x from './b.js'b.js有一个import x from 'a.js'。经过上面对__webpack_require__的分析就很容易知道了。一个模块执行之前,webpack就已经先将它挂到installedModules中。例如此时执行a.js它引入b.js,b.js中又引入a.js。此时b.js中拿到引入a的内容只是在a.js当前执行的时候已经export出的东西(因为已经挂到了installedModules,所以不会重新执行一遍a.js)。

完成同步加载后,入口 chunk 执行a.js

接下来回到eval内执行的a.js模块代码片段,异步加载 js 部分。

1
2
3
4
5
6
7
8
9
// a.js模块
__webpack_require__
.e("asyncCommon2")
.then(__webpack_require__.bind(null, "./src/asyncCommon1.js")) // (1) 异步的模块文件已经被注入到立即执行函数的入参`modules`变量中了,这个时候和同步执行`import`调用`__webpack_require__`的效果就一样了
.then(({ asyncCommon2 }) => {
//(2) 就能拿到对应的模块,并且执行相关逻辑了(2)。
asyncCommon2();
console.log("done");
});

__webpack_require__.e做的事情就是,根据传入的chunkId,去加载这个chunkId对应的异步 chunk 文件,它返回一个promise。通过jsonp的方式使用script标签去加载。这个函数调用多次,还是只会发起一次请求 js 的请求。若已加载完成,这时候异步的模块文件已经被注入到立即执行函数的入参modules变量中了,这个时候和同步执行import调用__webpack_require__的效果就一样了(这个注入由webpackJsonpCallback函数完成)。此时,在promise的回调中再调用__webpack_require__.bind(null, "./src/asyncCommon1.js")(1) 就能拿到对应的模块,并且执行相关逻辑了(2)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// __webpack_require__.e 异步import调用函数
// 再回顾下上文提到的 chunk 的状态位
// 记录chunk的状态位
// 值:0 表示已加载完成。
// undefined : chunk 还没加载
// null :chunk preloaded/prefetched
// Promise : chunk正在加载
var installedChunks = {
a: 0
};

__webpack_require__.e = function requireEnsure(chunkId) {
//...只保留核心代码
var promises = [];
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) {
// chunk还没加载完成
if (installedChunkData) {
// chunk正在加载
// 继续等待,因此只会加载一遍
promises.push(installedChunkData[2]);
} else {
// chunk 还没加载
// 使用script标签去加载对应的js
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push((installedChunkData[2] = promise)); // start chunk loading

//
var script = document.createElement("script");
var onScriptComplete;

script.src = jsonpScriptSrc(chunkId);
document.head.appendChild(script);
//.....
}
// promise的resolve调用是在jsonpFunctionCallback中调用
return Promise.all(promises);
};

再看看异步加载 asyncCommon1 chunk(也就是异步加载的 js) 的代码大体结构。它做的操作很简单,就是往jsonpFunction这个全局数组push(需要注意的是这个不是数组的 push,是被重写为入口 chunk 的webpackJsonpCallback函数)一个数组,这个数组由 chunk名和该chunk的 module 对象 一起组成。

1
2
3
4
5
6
7
// asyncCommon1 chunk
(window["jsonpFunction"] = window["jsonpFunction"] || []).push([["asyncCommon1"],{
"./src/asyncCommon1.js":
(function(module, __webpack_exports__, __webpack_require__) {
eval(module代码....);
})
}]);

而执行webpackJsonpCallback的时机,就是我们通过script把异步 chunk 拿回来了(肯定啊,因为请求代码回来,执行异步 chunk 内的push方法嘛!)。结合异步 chunk 的代码和下面的webpackJsonpCallback很容易知道,webpackJsonpCallback主要做了几件事:

1、将异步chunk的状态位置 0,表明该 chunk 已经加载完成。installedChunks[chunkId] = 0;

2、对__webpack_require__.e 中产生的相应的 chunk 加载 promise 进行 resolve

3、将异步chunk的模块 挂载到入口chunk的立即执行函数参数modules中。可供__webpack_require__进行获取。上文分析 a.js 模块已经提到了这个过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];
var moduleId,
chunkId,
i = 0,
resolves = [];
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (
Object.prototype.hasOwnProperty.call(installedChunks, chunkId) &&
installedChunks[chunkId]
) {
resolves.push(installedChunks[chunkId][0]);
}
// 将当前chunk设置为已加载
installedChunks[chunkId] = 0;
}
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
// 将异步`chunk`的模块 挂载到入口`chunk`的立即执行函数参数`modules`中
modules[moduleId] = moreModules[moduleId];
}
}

// 执行旧的jsonPFunction
// 可以理解为原生的数组Array,但是这里很精髓,可以防止撞包的情况部分模块没加载!
if (parentJsonpFunction) parentJsonpFunction(data);

while (resolves.length) {
// 对__webpack_require__.e 中产生的相应的chunk 加载promise进行resolve
resolves.shift()();
}
}

简单总结:

1、经过 webpack 打包,每一个 chunk 内的模块文件,都是组合成形如

1
2
3
4
5
{
[moduleName:string] : function(module, __webpack_exports__, __webpack_require__){
eval('模块文件源码')
}
}

2、同一页面多个 webpack 环境,output.jsonpFunction尽量不要撞名字。撞了一般也是不会挂掉的。只是会在立即执行函数的入参modules上挂上别的 webpack 环境异步加载的部分模块代码。(可能会造成一些内存的增加?)

3、每一个 entry chunk 入口都是一个类似的立即执行函数

1
2
3
4
5
6
7
(function(modules){
//....
})({
[moduleName:string] : function(module, __webpack_exports__, __webpack_require__){
eval('模块文件源码')
}
})

4、异步加载的背后是用script标签去加载代码

5、异步加载没那么神秘,对于当项目大到一定程度时,能有较好的效果

因水平有限,如有错误欢迎拍砖)

  • Post title:由浅至深了解webpack异步加载
  • Post author:flytam
  • Create time:2019-12-14 22:02:30
  • Post link:https://blog.flytam.vip/由浅至深了解webpack异步加载.html
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.