从Webpack打包后的文件分析导入的原理


主流框架中,不论是 React 还是 Vue,都是使用 Webpack 进行资源的打包,这篇文章我们试着分析打包后的 bundle 文件来了解下其是如何进行静态和动态导入的。
文章目录结构如下:

  • bundle 文件分析
  • 静态导入文件
  • 动态导入文件

bundle 文件分析

下面是代码整体的目录结构:

markdown

入口文件 main.js

import a from './a'
import b from './b'

a.js

const asyncText = 'async'
export default asyncText

b.js

import c from './c'
const b = 'b'
export default b

c.js

export const c = 1

打包后的 bundle 文件过于冗长,删繁就简之后让我们看看他的主体部分:
我们看到整个文件就是一个自执行函数,所传入的参数是经过分析过后的文件路径,我们先来认识两个参数和一个函数:

  • modules:缓存 module 代码块,每个 module 有一个 id,开发环境默认以 module 所在文件的文件名标识,生产环境默认以一个数字标识。modules 是一个 object, key 为 module id,value 为对应 module 的源代码块。
  • installedModules:缓存已经加载过的 module,简单理解就是已经运行了源码中 import a from 'xxx'
    这样的语句。installedModules 是一个 object, key 为 module id,value 为对应 module 导出的变量。

  • __webpack_require__
    : 根据传入的 moduleId,判断是否加载过该模块,加载过则直接返回,未加载过则执行该模块代码。

静态导入文件

根据上面的内容分析,整个自执行函数执行的时候,传入的参数是根据我们文件内容生成的路径,参数内容如下。

{
"./src/a.js":
function (module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
const asyncText = "async";
__webpack_exports__["default"] = asyncText;
},
"./src/b.js":
function (module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
var _c__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
/*! ./c */ "./src/c.js"
);
const b = "b";
__webpack_exports__["default"] = b;
},

"./src/c.js":
function (module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(
__webpack_exports__,
"c",
function () {
return c;
}
);
const c = 1;
},
"./src/main.js":
function (module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
var _a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
"./src/a.js"
);
var _b__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(
"./src/b.js"
);
}
}

对象以当前文件路径为 key,文件内容为 value,发现文件内容里有 import,则转化成 __webpack_require__
函数执行。

回看我们的自执行函数,当函数执行的时候,会根据 webpack.config 配置首先去执行 moduleId 为 ./src/main.js
的入口文件的内容。

return __webpack_require__(
(__webpack_require__.s = "./src/main.js")
);

执行 key 为 ./src/main.js
的模块内容时发现其依赖 a.js、b.js,依次递归去执行对应的模块内容,由于 installedModules 的存在,不会再次去执行加载过的模块,依次类推,这就是 Webpack 导入静态依赖的过程。

动态导入文件

前面分析了静态文件的导入,我们现在来改下 main.js 的内容,如下:

main.js

import('./a').then((({ default: text}) => {
console.log(text)
}))

在 main.js 通过动态导入语法导入 a.js,生成的 dist 文件如下:

markdown

相比静态导入,动态导入多了个 0.bundle.js
,我们再来看看 bundle.js 文件的主体:

markdown

对比静态导入,动态导入多了几个重要的函数和对象 installedChunks 和 __webpack_require__.e
,我们还是先来说明下这两个新增成员的具体用途。

  • installedChunks:缓存已经加载过的 chunk,简单理解就是把其他 js 文件中的 chunk 包含的 modules 同步到了当前文件中。每个 chunk 有一个 id,默认以一个数字标识。installedChunks 也是一个对象,key 为 chunk id,value 有四种情况:
    • undefined:chunk not loaded
    • null:chunk preloaded/prefetched
    • Promise:chunk loading
    • 0:chunk loaded
  • __webpack_require__.e
    :根据 installedChunks 检查是否加载过该 chunk,假如没加载过,则发起一个 JSONP 请求去加载 chunk,
    设置一些请求的错误处理,然后返回一个 Promise。

我们还是来看看传入自执行函数中的参数

{
/***/ "./src/main.js": /***/ function (
module,
exports,
__webpack_require__
)
{
__webpack_require__.e(/*! import() */ 0)
.then(__webpack_require__.bind(null, "./src/a.js"))
.then(({ default: text }) => {
console.log(text);
});
/***/
}
/******/
}

有个新面孔, __webpack_require__.e
,函数逻辑如下:

__webpack_require__.e = function requireEnsure(chunkId) {
var promises = []; // JSONP chunk loading for javascript
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) {
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// setup Promise in chunk cache
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.charset = "utf-8";
script.timeout = 120;
if (__webpack_require__.nc) {
script.setAttribute("nonce", __webpack_require__.nc);
}
script.src = jsonpScriptSrc(chunkId); // create error before stack unwound to get useful stacktrace later
var error = new Error();
onScriptComplete = function (event) {
script.onerror = script.onload = null;
clearTimeout(timeout);
var chunk = installedChunks[chunkId];
if (chunk !== 0) {
if (chunk) {
var errorType =
event && (event.type === "load" ? "missing" : event.type);
var realSrc = event && event.target && event.target.src;
error.message =
"Loading chunk " +
chunkId +
" failed.\n(" +
errorType +
": " +
realSrc +
")";
error.name = "ChunkLoadError";
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;
document.head.appendChild(script);
}
return Promise.all(promises);
};

代码有点长,具体做了些什么前面已经说过,不再赘述,我们来看下重点返回的 Promise,下面这段代码里,如果你读完了上述代码会发现,我们将 promise 的 resolve 和 reject 保存了起来

installedChunkData = installedChunks[chunkId] = [
resolve,
reject
];

但是仅仅在脚本文件的 onerror 事件中调用了 reject,整个函数里并没有调用 resolve 的地方,那这边是在哪里调用了 resolve 让函数正常走下去呢?

我们先来看看我们请求的 0.bundle.js
的内容

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
[0],
{
"./src/a.js": function (module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
const asyncText = "async";
__webpack_exports__["default"] = asyncText;
}
}
]);

Webpack 会往 (window["webpackJsonp"] = window["webpackJsonp"] || [])
对象里 push 对应 chunk 的 chunkId 和对应的模块代码,而在 bundle 文件里,有一段很巧妙的代码

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]);
}

这里将 (window["webpackJsonp"] = window["webpackJsonp"] || [])
对象赋值给了 jsonpArray,当 Webpack 在 0.bundle.js
里执行了 (window["webpackJsonp"] = window["webpackJsonp"] || []).push
就相当于执行 webpackJsonpCallback 这个函数,现在我们再来看看 webpackJsonpCallback 这个函数里做了些什么

function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1]; // add "moreModules" to the modules object, // then flag all "chunkIds" as loaded and fire callback

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]);
}
installedChunks[chunkId] = 0;
}
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
if (parentJsonpFunction) parentJsonpFunction(data);

while (resolves.length) {
resolves.shift()();
}
}

传入的 data 就是我们 0.bundle.js
里 push 的参数,所以逻辑就很简单了,主要是做了两件事,第一个是往 modules 里 push 异步模块,第二件事就是我们要找的执行 resolve。最后还有一步就是要利用 __webpack_require__
将异步模块注册到我们的 installedModules 里,防止重复加载

__webpack_require__.e(/*! import() */ 0)
.then(__webpack_require__.bind(null, "./src/a.js"))
.then(({ default: text }) => {
console.log(text);
});

到这里我们就基本分析完了 Webpack 动态导入的过程,基本的逻辑就是将要加载的异步模块作为一个脚本文件发出新的请求,这样就避免了所有要导入的文件都要通过一个 js 文件去请求。
本文参考:

聊聊 webpack 异步加载 (
https://www.toutiao.com/i6790550018601255432/?group_id=6790550018601255432
)

webpack 是如何实现动态导入的 (
https://zhuanlan.zhihu.com/p/73325163
)

本月文章预告

预告下,接下来我们会陆续发布转转在 性能、多端SDK、移动端等基础架构和中台技术相关的实践与思考,欢迎大家关注,期望与大家多多交流