Mmear's 😘.

Webpack 模块历险🚌

字数统计: 2.3k阅读时长: 10 min
2019/04/16 Share

引言

在参考了[webpack 进阶]前端运行时的模块化设计与实现这篇文章后, 打算对模块加载后发生的一系列过程进行总结, 与 Node读取模块路径->编译->执行不同, 出于用户体验考虑, Webpack 运行时采用暂存/注册模块代码->读取所需模块代码->执行的方法;

当我们已经获取了模块内容后(但模块还未执行),我们就将其暂存在 modules 对象中,键就是 webpack 的 moduleId;等到需要使用webpack_require引用模块时,发现缓存中没有,则从 modules 对象中取出暂存的模块并执行。

测试代码

下面是这次要用到的测试代码, 包含了动态导入的内容:

1
2
3
// index.js
import "./css/main.sass";
import(/* webpackChunkName: 'module' */ "./module.js").then(({ sub }) => sub());
1
2
3
4
// module.js
export const sub = function() {
console.log("Sub module has been loaded");
};

通过设置runtimeChunk: 'single'将运行时代码专门分离打包成一份; 打包结果如下:

其中 vendor 文件主要是style-loadercss-loader的内容, main 文件是入口代码, module 文件则是需动态导入的模块代码;大致关系可以理解为: main ->> [runtime, vendor, module]

build 代码分析

打包后的 main 和 vendor 代码, 大概精简一下之后就是这样(删去了关于css-loader的部分);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push(
[
["main"],
{
"e6Wu": /* index.js */(function(module, __webpack_exports__, __webpack_require__) { eval(...}}),
"hVTH": /* main.scss */ (function(module, exports, __webpack_require__) {eval(...)})
},
[["e6Wu","runtime","vendors"]]
]);

// vendor.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push(
[
["vendor"],
{
/* 各类loader的代码, 以键值对形式存储, 会被 main.js 中的代码 require */
/* 比如: __webpack_require__('782b') */
'782b': (function(module, __webpack_exports__, __webpack_require__) { eval(...}}),
/* ...其余省略 */
},
[]
]);

可以看到顶层属性window["webpackJsonp"]调用了push方法, 接受了一个数组作为参数, 其中:

  • 数组第一个元素是数组, 表明当前文件包含的 chunkId;
  • 数组第二个参数是一个对象, 其中 key 表明当前导入的 moduleId, 值为对应的代码;
  • 数组第三个参数也是一个数组, 每个数组的第一个元素表明要执行的模块, 剩余的元素为需要提前执行的 chunk;

通过开头提到的文章, 可以知道window["webpackJsonp"].push其实就是 runtime 中的webpackJsonpCallback函数,

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
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];
var executeModules = data[2];
// add "moreModules" to the modules object,
// then flag all "chunkIds" as loaded and fire callback
var moduleId,
chunkId,
i = 0,
resolves = [];
// 将 chunks 标记为已载入完成
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
// 重点: 取出所有处于 loading 状态的 chunk Promise 的 resolve
if (installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}
// 重点: 将模块注册到 modules中暂存, 等待 require 正式执行
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
// resolve 所有 promise
while (resolves.length) {
resolves.shift()();
}

// add entry modules from loaded chunk to deferred list
// 将入口模块以及其依赖的 chunk 数组加入 deferredModules 中
deferredModules.push.apply(deferredModules, executeModules || []);

// run deferred modules when all chunks ready
// 加载 deferred 模块
return checkDeferredModules();
}

再来看看构建完后的 html 会如何请求脚本(暂且不管动态导入的 module 文件), 使用了HtmlWebpackPlugin之后产生的 html 文件如下:

1
2
3
4
5
6
<body>
<img src="1a957d8e755177e4857610261a056c23.jpg" alt="test jpg" />
<script type="text/javascript" src="runtime-1a97347743150b987131.js"></script>
<script type="text/javascript" src="vendors-76f45934ea5383e55a5b.js"></script>
<script type="text/javascript" src="main-d1583fe8f9ff73e7a318.js"></script>
</body>

具体流程

从上文可以看出脚本是按照 runtime -> vendor -> main 这样的先后次序请求的, 也就是说, 在正式执行 main 脚本前, vendor 脚本已经注册完毕, 对应的 chunk 也已经加载完毕, 即:

1
2
3
4
5
6
7
installedChunks[Object object] {
'runtime': 0 // 0表示 chunk 已加载完毕
'vendor': 0,
}
modules[Object object] {
/* 包含了 vendor 注册的所有模块 */
}

那么正式执行 main 函数时是怎样的流程呢? 首先注意到之前webpackJsonpCallback函数中暂存模块的代码:

1
2
3
4
5
6
// 重点: 将模块注册到 modules中暂存, 等待 require 正式执行
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}

这里的 moreModules 中即之前谈到的数组中的第二个元素, main 将其导入的模块全部注册到 modules 中暂存, 然后执行了:

1
2
3
4
5
// 将入口模块以及其依赖的 chunk 加入 deferredModules 中
deferredModules.push.apply(deferredModules, executeModules || []);

// run deferred modules when all chunks ready
return checkDeferredModules();

这里便是关键, checkDeferredModules函数会检查每个入口模块依赖的 chunk 是否已经载入完毕, 如果载入完毕, 则直接执行__webpack_require__(当前入口模块);

由于 main 依赖的runtime, vendor chunk 在之前就已经加载完毕, 最后会直接 require 入口模块e6Wu;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function checkDeferredModules() {
var result;
for (var i = 0; i < deferredModules.length; i++) {
var deferredModule = deferredModules[i];
var fulfilled = true;
// [要加载的模块, 依赖的模块1, 依赖的模块2, ...]
// 具体到 main 为['e6Wu', 'runtime', 'vendor']
for (var j = 1; j < deferredModule.length; j++) {
var depId = deferredModule[j];
if (installedChunks[depId] !== 0) fulfilled = false;
}
if (fulfilled) {
// 将模块数组剔除
deferredModules.splice(i--, 1);
// 加载完毕则执行 require
result = __webpack_require__(
(__webpack_require__.s = deferredModule[0])
);
}
}
return result;
}

再来看看__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
27
28
29
30
function __webpack_require__(moduleId) {
// Check if module is in cache
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
//* 将新模块推入缓存
var module = (installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
});

// Execute the module function
//* 执行模块代码, 并返回exports, 注意每个模块代码只会被执行一次, 之后便只会从缓存中返回
modules[moduleId].call(
module.exports,
module,
module.exports,
__webpack_require__
);

// Flag the module as loaded
//* 将模块标记为已缓存/加载
module.l = true;

// Return the exports of the module
//* 返回exports
return module.exports;
}

思路很清晰, 首先查看缓存中是否有对应 moduleId 的模块, 有则直接返回该模块的 exports, 否则执行:

  1. 向缓存installedModules中添加新模块;
  2. 执行暂存区modules中的代码;
  3. 返回模块的exports;

而在第二步执行的代码中, 同样会调用__webpack_require__函数 require 其它已有模块, 其中包含 vendor 中注册的模块以及动态加载的 module;

动态加载

代码转换

main 文件中, 关于import的代码, 则被转换为:

1
2
3
4
5
6
7
 /*! import() | module */
__webpack_require__.e("module")
.then(__webpack_require__.bind(null, /*! ./module.js */ "oY72"))
.then(function (_ref) {
var sub = _ref.sub;
return sub();
});

可以看到__webpack_require__.e会返回一个 Promise, 第一个 then 方法是加载对应的模块, 第二个方法则是执行真正的源代码;
module 文件在被打包后, 结构与 main, vendor 类似:

1
2
3
4
5
6
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
["module"],
{
"oY72":/* module.js */(function(module, __webpack_exports__, __webpack_require__) {eval("");})
}
]);

代码加载

打开 chrome 的网络选项, 可以看到 module 文件是在 main.js 执行的过程中请求加载的:

这一功能是由 runtime 中的requireEnsure函数实现(即上文中的__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
70
71
72
73
74
function requireEnsure(chunkId) {
var promises = [];
// JSONP chunk loading for javascript
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) {
// 判断该chunk是否已经被加载,0表示已加载。
// installChunk中的状态:
//* undefined:chunk未进行加载,
//* null:chunk preloaded / prefetched
//* Promise:chunk正在加载中
//* 0:chunk加载完毕

if (installedChunkData) { // 不是 null 或 undefined, 说明是promise
promises.push(installedChunkData[2]);
} else { // 未加载
// setup Promise in chunk cache
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
//* installedChunkData: [resolve, reject, promiseInstance]
promises.push((installedChunkData[2] = promise));

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

script.charset = "utf-8";
script.timeout = 120;
if (__webpack_require__.nc) {
script.setAttribute("nonce", __webpack_require__.nc);
}
//* 根据 chunkId 获取对应的 chunk 路径 "../module-xxx.js"
script.src = jsonpScriptSrc(chunkId);

//* 注册脚本加载完成事件(主要是错误处理)
onScriptComplete = function(event) {
// avoid mem leaks in IE.
script.onerror = script.onload = null;
clearTimeout(timeout);
var chunk = installedChunks[chunkId];
//* 检测脚本是否加载完毕(1. 加载完成后调用此回调, 2.超时后调用此回调)
if (chunk !== 0) {
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;
chunk[1](error);
}
installedChunks[chunkId] = undefined;
}
};
var timeout = setTimeout(function() {
onScriptComplete({ type: "timeout", target: script });
}, 120000);
// 注册事件
script.onerror = script.onload = onScriptComplete;
// 添加script标签
document.head.appendChild(script);
}
}
// 返回Promise.all, 这样就能够通过 import('...').then() 形成调用
return Promise.all(promises);
};

该函数会先判断当前的 chunk 是否已经被加载至installedChunk中, 如果没有, 则实例化一个 Promise, 以[resolve, reject, promiseInstance]的形式加入installedChunk中, 随后通过jsonpScriptSrc函数找到 chunkId 对应的文件路径, 往 DOM 中动态添加<script>标签来加载脚本;

解析

那么加载了对应的 Promise, 会在什么时候 resolve 呢? 按照逻辑, 当然是在 module 脚本载入完成后再进行 resolve, 而module 脚本中又调用了webpackJsonpCallback, 其中有一段重要代码:

1
2
3
4
5
6
7
8
9
// 重点: 取出所有处于 loading 状态的 chunk Promise 的 resolve
if (installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
//...
// resolve 所有 promise
while (resolves.length) {
resolves.shift()();
}

于是, 动态导入的模块就顺利完成了加载, 之后再通过then方法取出(require)暂存在modules中的 module 代码即可;

CATALOG
  1. 1. 引言
  2. 2. 测试代码
  3. 3. build 代码分析
  4. 4. 具体流程
  5. 5. 动态加载
    1. 5.1. 代码转换
    2. 5.2. 代码加载
  6. 6. 解析