#

jump-to-webpack-my-friends

上篇讨论中 我们探索了 Webpack 是如何整合模块并让其有序地运行在浏览器中的,为了简化流程,我们只涉及了两个模块。

这样两个小模块,将它们打包进一个 main.js 文件,总体积也不会发生数量级的变化,但实际项目中,需要用到的模块数量和大小都足以让 main.js 爆发式膨胀,如果我们此时还坚持这个模式,main.js 会集中消耗太多网络资源,导致用户需要等待很久才可以开始与网页交互。

zhe-shui-ding-de-zhu-a

一般的解决方式是:根据需求降低首次加载文件的体积,在需要时(如切换前端路由器,交互事件回调)异步加载其他文件并使用其中的模块。

异步加载模块

上一节解决方式的前提是不同模块拆分到不同的文件中,这就涉及到 Webpack 的另一个概念 —— 代码块(chunk)在本文范畴内,我们可以把 chunk 与 Webpack 导出的 打包文件(bundle) 一一对应,比如在上篇文章的 demo1 示例中dist/main.js 就是一个 chunk。

xing-ba

Webpack 推荐用 ES import() 规范来异步加载模块,我们根据 ES 规范修改一下入口模块的 import 方式,让其能够异步加载模块:

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// import {plus, minus} from './utils/math.js';

document.writeln('Hello webpack!');
// document.writeln('1 + 2: ', plus(1, 2));
// document.writeln('1 - 2: ', minus(1, 2));

window.setTimeout(() => {

import('./utils/math').then(mathUtil => {
document.body.append('1 + 2: ' + mathUtil.plus(1, 2));
document.body.append('1 - 2: ' + mathUtil.minus(1, 2));
});
}, 2000);

工具模块(src/utils/math.js)依然不变,在 webpack 配置里,我们指定一下资源文件的公共资源路径(publicPath),后面的探索过程中会遇到。

build/webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
const path = require('path');
const resolve = relativePath => path.resolve(__dirname, relativePath);

module.exports = {
mode: 'development',
devtool: 'source-map',
entry: resolve('../src/index.js'),
output: {
path: resolve('../dist'),
publicPath: '/dist/' // 指定 publicPath
}
};

接着执行一下打包,可以看到除了 dist/main.js 外,又多了一个 dist/0.js ./src/utils/math.js。模块从 main chunk 迁移到了 0 chunk 中。而与 demo1 不同的是,main chunk 中添加了一些用于异步加载的代码,我们概览一下:

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
// webpackBootstrap
(function (modules) {
// 加载其他 chunk 后的回调函数
function webpackJsonpCallback(data) {
// ...
}

// ...

// 用于缓存 chunk 的加载状态,0 为已加载
var installedChunks = {
"main": 0
};

// 拼接 chunk 的请求地址
function jsonpScriptSrc(chunkId) {
// ...
}

// 同步 require 函数,内容不变
function __webpack_require__(moduleId) {
// ...
}

// 异步加载 chunk,返回封装加载过程的 promise
__webpack_require__.e = function requireEnsure(chunkId) {
// ...
}

// ...

// defineProperty 的包装,内容不变
__webpack_require__.d = function (exports, name, getter) {}

// ...

// 根据配置文件确定的 publicPath
__webpack_require__.p = "/dist/";

/**** JSONP 初始化 ****/
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;
/**** JSONP 初始化 ****/

return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
"./src/index.js": (function(module, exports, __webpack_require__) {

document.write('Hello webpack!\n');

window.setTimeout(() => {
__webpack_require__.e(/*! import() */ 0).then(__webpack_require__.bind(null, /*! ./utils/math */ "./src/utils/math.js")).then(mathUtil => {
document.body.append('1 + 2: ' + mathUtil.plus(1, 2));
document.body.append('1 - 2: ' + mathUtil.minus(1, 2));
});
}, 2000);

})
})

可以看到 webpackBootstrap 的函数体部分增加了一些内容,参数部分移除了 "./src/utils/math.js" 模块。跟着包裹函数的执行顺序,我们先聚焦到「JSONP 初始化」部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 存储 jsonp 的数组,首次运行为 []
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];

// 保存 jsonpArray 的 push 函数,首次运行为 Array.prototype.push
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);

// 将 jsonpArray 的 push 重写为 webpackJsonpCallback (加载其他 chunk 后的回调函数)
jsonpArray.push = webpackJsonpCallback;

// 将 jsonpArray 重置为正常数组,push 重置为 Array.prototype.push
jsonpArray = jsonpArray.slice();

// 由于 jsonpArray 为 [],不做任何事
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);

// Array.prototype.push
var parentJsonpFunction = oldJsonpFunction;

初始化结束后,变化就是 window 上挂载了一个 webpackJsonp 数组,它的值为 [];此外,这个数组的 push 被改写为 webpackJsonpCallback 函数,我们在后面会提到这些准备工作的作用。

接着是同步 __webpack_require__ 入口模块,由于 __webpack_require__ 函数没有改变,我们继续观察入口模块执行函数有了什么变化。显然,import('../utils/math.js') 转化为了 __webpack_require__.e(0).then(__webpack_require__.bind(null, "./src/utils/math.js"))。0 是 ./src/utils/math.js 所在 chunk 的 id,「同步加载模块」的逻辑拆分成了「先加载 chunk,完成后再加载模块」。

我们翻到 __webpack_require__.e 的定义位置:

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
// JS chunk 加载函数
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];

// installedChunks 是在 webpackBootstrap 中维护的 chunk 缓存
var installedChunkData = installedChunks[chunkId];

// chunk 未加载
if(installedChunkData !== 0) {

// installedChunkData 为 promise 表示 chunk 加载中
if(installedChunkData) {
promises.push(installedChunkData[2]);
} else {
/*** 首次加载 chunk: ***/
// 初始化 promise 对象
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);

// 创建 script 标签加载 chunk
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
var onScriptComplete;

// ... 省略一些 script 属性设置

// src 根据 publicPath 和 chunkId 拼接
script.src = jsonpScriptSrc(chunkId);

// 加载结束回调函数,处理 script 加载完成、加载超时、加载失败的情况
onScriptComplete = function (event) {
script.onerror = script.onload = null; // 避免 IE 内存泄漏问题
clearTimeout(timeout);
var chunk = installedChunks[chunkId];

// 处理 script 加载完成,但 chunk 没有加载完成的情况
if(chunk !== 0) {
// chunk 加载中
if(chunk) {
var errorType = event && (event.type === 'load' ? 'missing' : event.type);
var realSrc = event && event.target && event.target.src;
var error = new Error('Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')');
error.type = errorType;
error.request = realSrc;

// reject(error)
chunk[1](error);
}

// 统一将没有加载的 chunk 标记为未加载
installedChunks[chunkId] = undefined;
}
};

// 设置 12 秒超时时间
var timeout = setTimeout(function(){
onScriptComplete({ type: 'timeout', target: script });
}, 120000);

script.onerror = script.onload = onScriptComplete;
head.appendChild(script);

/*** 首次加载 chunk ***/
}
}
return Promise.all(promises);
};

tou-pi-fa-ma

看起来有点长,我们一步步剖析,先从第一行和最后一行来看,整个函数将异步加载的过程封装到了 promise 中,最终导出。

接着从第二行开始,installedChunkData 从缓存中取值,显然首次加载 chunk 时此处是 undefined。接下来,installedChunkDataundefined 值触发了第一层 if 语句的判断条件。紧接着进行到第二层 if 语句,此时根据判断条件走入 else 块,这里 if 块里的内容我们先战略跳过,else 里主要有两块内容,一是 chunk 脚本加载过程,这个过程创建了一个 script 标签,使其请求 chunk 所在地址并执行 chunk 内容;二是初始化 promise ,并用 promise 控制 chunk 文件加载过程。

不过,我们只在这段 else 代码块中找到了 reject 的使用处,也就是在 chunk 加载异常时 chunk[1](error) 的地方,但并没发现更重要的 resolve 的使用地点,仅仅是把 resolve 挂在了缓存上(installedChunks[chunkId] = [resolve, reject])。

这里的 chunk 文件加载下来会发生什么呢?让我们打开 dist/0.js 一探究竟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[/* chunk id */ 0], {
"./src/utils/math.js": (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "plus", function() { return plus; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "minus", function() { return minus; });

const plus = (a, b) => {
return a + b;
};

const minus = (a, b) => {
return a - b;
};

}))

a-ha

我们发现了:

  • 久违的 ./src/utils/math.js 模块
  • window[“webpackJsonp”] 数组的使用地点

chunk 脚本加载成功后,这段代码开始执行,把异步加载相关的 chunk id 与模块传给 push 函数。而前面已经提到过,window[“webpackJsonp”] 数组的 push 函数已被重写为 webpackJsonpCallback 函数,它的定义位置在 webpackBootstrap 中:

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
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];

// then flag all "chunkIds" as loaded and fire callback
var moduleId, chunkId, i = 0, resolves = [];

// 将 chunk 标记为已加载
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}

// 把 "moreModules" 加到 webpackBootstrap 中的 modules 闭包变量中。
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}

// parentJsonpFunction 是 window["webpackJsonp"] 的原生 push
// 将 data 加入全局数组,缓存 chunk 内容
if(parentJsonpFunction) parentJsonpFunction(data);

// 执行 resolve 后,加载 chunk 的 promise 状态变为 resolved,then 内的函数开始执行。
while(resolves.length) {
resolves.shift()();
}

};

走进这个函数中,意味着异步加载的 chunk 内容已经拿到,这个时候我们要完成两件事,一是让依赖这次异步加载结果的模块继续执行,二是缓存加载结果。

关于第一点,我们回忆一下之前 __webpack_require__.e 的内容,此时 chunk 还处于「加载中」的状态,也就是说对应的 installedChunks[chunkId] 的值此时为 [resolve, reject, promise]。 而这里,chunk 已经加载,但 promise 还未决议,于是 webpackJsonpCallback 内部定义了一个 resolves 变量用来收集 installedChunks 上的 resolve 并执行它。

接下来说到第二点,就要涉及几个层面的缓存了。

首先是 chunk 层面,这里有两个相关操作,操作一将 installedChunks[chunkId] 置为 0 可以让 __webpack_require__.e 在第二次加载同一 chunk 时返回一个立即决议的 promise(Promise.all([]));操作二将 chunk data 添加进 window["webpackJsonp"] 数组,可以在多入口模式时,方便地拿到已加载过的 chunk 缓存。通过以下代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...
/*** 缓存执行部分 ***/
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
// ...
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;
/*** 缓存执行部分 ***/

/*** 缓存添加部分 ***/
function webpackJsonpCallback(data) {
//...
// 此处的 parentJsonpFunction 是 window["webpackJsonp"] 原生 push
if (parentJsonpFunction) parentJsonpFunction(data);
//...
}
/*** 缓存添加部分 ***/

而在 modules 层面,chunk 中的 moreModules 被合入入口文件的 modules 中,可供下一个微任务中的 __webpack_require__ 同步加载模块。

现在我们回到入口模块的执行函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...
{
"./src/index.js": (function(module, exports, __webpack_require__) {

document.write('Hello webpack!\n');

window.setTimeout(() => {
__webpack_require__.e(0).then(__webpack_require__.bind(null, "./src/utils/math.js")).then(mathUtil => {
document.body.append('1 + 2: ' + mathUtil.plus(1, 2));
document.body.append('1 - 2: ' + mathUtil.minus(1, 2));
});
}, 2000);

})
}
// ...

__webpack_require__.e(0) 返回的 promise 决议后,__webpack_require__.bind(null, "./src/utils/math.js") 可以加载到 chunk 携带的模块,并返回模块作为下一个微任务函数的入参,接下来就是 Webpack Loader 翻译过的其他业务代码了。

到这里,异步加载模块的原理就翻阅完了。在小结之前,或许会有好奇的同学问,如果在异步加载的模块里再同步加载模块,或者在非入口的同步模块里异步加载其他模块,Webpack 又是怎么处理的呢?

这时候两种原理组合的设计魅力就体现了出来。第二个问题更容易推衍,对于 __webpack_require__ 来说,入口模块和非入口模块都直接存在 webpackBootstrap 的 modules 变量中,区别仅仅是模块函数执行的顺序,所以并没有区别;而对于第一个问题,异步加载的模块及其同步加载的其他模块会统一存在一个 chunk 的 moreModules 中,异步加载 chunk 文件成功后,moreModules 合入 webpackBootstrap 的 modules,此后无论怎么同步加载模块,都与简单的同步加载无异。我们可以打开示例仓库 切换到 demo2 分支,运行 npm run build 查看输出的文件 dist/main.jsdist/0.js 验证一下推测。

小结

今天我们讨论了 Webpack 异步加载模块是如何实现的,现在把异步加载流程加入上次小结的流程图~

webpack-module-implementation-async

参考

Webpack 术语表 - Bundle
Webpack 术语表 - Chunk
Webpack 术语表 - Module