前言
前端开发常常涉及到webpack的使用,而打包之后的bundle.js为何能直接通过script标签通过src引入之后,就能够正常使用呢?
我们知道,浏览器和Node环境不同,不支持Commonjs的导入规范,也就是说Node中的require函数无法直接在浏览器中生效,需要进行转换。我们可以通过browserify等转换工具对其进行转换,达到在浏览器中正常运行的目的。
运行在浏览器当中的js文件,我们通常是打包在同一个文件当中的,然后通过script标签一并引入,然后便可以正常运行。webpack是如何解决模块化,并最终使得我们的代码生效的呢,本文将通过对打包之后的bundle.js的代码进行分析,理解webpack打包的代码逻辑,以及使用import函数进行按序加载后,bundle.js会发生的变化。
本文源代码来自于《深入浅出webpack》,有感兴趣的朋友可以自行查阅该书第五章原理部分。
一、目录结构和代码内容
1.1、目录结构
test文件夹下面有index.html,main.js,show.js和bundle.js四个文件。其中html是我们要打开浏览的页面,这和我们通常会遇到的情况类似,通过htmlWebpackPlugin打包html模板过来一个html用于我们页面的展示。
main.js是js文件的主入口,也就是在webpack当中entry所描述的文件,webpack的打包从这里开始,递归找出所有的依赖,进行打包。
show.js内部有一个函数,被main.js所引用。
bundle.js是打包之后的文件。此处我们不讨论webpack的配置,因为我们重点关注的是为何bundle.js能直接在浏览器中运行。另外,webpack的配置如何,最终打包出来的bundle.js,其实结构都类似。随着webpack版本的更迭,最终打包出来的形式可能有所差异,但以本文的例子来理解,对今后理解wbepack也是有好处的。
1.2、index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="./bundle.js"></script>
</body>
</html>
1.3、main.js
const show = require('./show.js');
show('Webpack');
1.4、show.js
function show(content) {
window.document.getElementById('app').innerText = 'Hello,' + content;
}
module.exports = show;
1.5、bundle.js
(
function(modules) {
var installedModules = {};
function __webpack__require__(moduleId) {
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
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;
}
__webpack__require__.p = "";
return __webpack__require__(__webpack__require__.s = 0);
}(
[
(function(module,exports,__webpack__require__) {
const show = __webpack__require__(1);
show('Webpack');
}),
(function(module,exports) {
function show(content) {
window.document.getElementById('app').innerText = 'Hello,' + content;
}
module.exports = show;
})
]
)
)
二、分析bundle.js
2.1、初步理解bundle.js的结构
第一眼看到bundle.js,可能会觉得很乱,尤其是看到webpack_require这种带下划线的命名,但其实仔细看,这个bundle.js的结构,就是一个立即执行函数(IIFE),该立即执行函数的参数,是一个数组,数组内包含了bundle.js依赖的所有模块,模块用function包裹,所以我们可以看到,模块在参数中的样子,其实就是模块文件外,套了一个function的“壳子”一样。
function(modules) {
var installedModules = {};
function __webpack__require__(moduleId){};
__webpack__require__.p = "";
return __webpack__require__(__webpack__require__.s = 0);
}([module1,module2])
首先,在这个立即执行函数内,定义了一个webpack_require函数,webpack_require函数的参数,是moduleId,也就是模块的索引值。比如后面module1的索引是0,module2的索引是1。在立即执行函数内的最后一句,return webpack_require(webpack_require.s = 0),也就是说返回webpack_require(0)的结果。
其次,在这个立即执行函数内,定义了一个installedModules对象,这个对象,是用来缓存加载过的模块的,在执行webpack_require的时候,会优先查询一遍installedModules里面有没有本模块,如果有的话,直接取他的exports出来就完成了导入,而不必再去执行。
也就是说,这个立即执行函数,其实就是执行了一下webpack_require(0),然后返回webpack_require(0)的执行结果,作为这个立即执行函数的结果返回。(但我们可以看见,该立即执行函数,并没有任何变量来接收返回的结果,之所以这样,是因为我们在打包的过程中,没有配置library,如果配置了library的值为name,那么bundle.js将会产生一个var name来接收我们立即执行函数的返回值,但这不是本文的重点,此处略过)。
webpack_require.p中的p,是指的publicPath,这部分将在后面按序加载的部分详解,此处根本用不着,此处略过。
2.2、webpack_require到底做了什么
注意,不要弄混淆这里的几个变量,modules,是立即执行函数的参数,也就是模块数组。module,是指在webpack_require函数当中定义的的一个对象,他的作用是去接收模块的exports,并且缓存在installedModules当中。
function __webpack__require__(moduleId) {
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
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,也就是模块在数组中的索引为参数,以方便我们去modules数组(参数)中找到对应的模块(也就是包裹了一个function“壳子”的模块)。
前文也提到过,此处使用了一个installedModules来缓存读取到的模块。
modules[moduleId].call(module.exports,module,module.exports,__webpack__require__);
上述代码中,modules[moduleId]拿到对应模块,然后使用call方法,传入当前module对象(也就是第五行定义的,用于接收模块导出内容的对象)等参数。
也就是说,上述代码就是执行了一遍模块代码,这也是为什么导入这个bundle.js之后,会执行我们期望引入模块代码的原因了。
接着往下走,在执行这个id为0的module的代码的时候,其实是没有exports的内容的,也就是没有导出值,我们只是在执行下面的代码。
(function(module,exports,__webpack__require__) {
const show = __webpack__require__(1);
show('Webpack');
})
这里我们发现,之前代码中的require,变成了__webpack_require,去引入moduleId为1的模块,拿到其exports出来的内容show函数之后,我们执行了它。__webpack_require(1),我们一样就好理解了,执行了moduleId为1的模块代码,并且取出了它的exports的值。
2.3、小结
webpack_require其实和我们commonJS的require差不多,我们可以理解为他做了以下三件事情(以webpack_require(0)为例)。
①、执行moduleId为0的模块代码。
②、拿到了moduleId为0的模块exports出来的代码并返回。
③、将加载后的模块,缓存在了installedModules中,以方便下次使用。
前两节基于打包之后的bundle.js的文件内容,分析了webpack能够让同步引入的模块代码在浏览器当中正常执行的原因,下一节将分析异步加载的内容。
三、异步加载
我们知道,当采用import函数,是异步加载其他模块的,这会导致webpack在打包的时候,将被异步加载的模块从原来的bundle当中拆分出来,形成一个单独的chunk。
那在异步加载的情况下,webpack是如何保证正常加载和使用的呢?
//bundle.js
(function (modules) {
window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
var moduleId, chunkId, i = 0, resolves = [], result;
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (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];
}
}
while (resolves.length) {
resolves.shift()();
}
};
var installedModules = {};
var installedChunks = {
1: 0
};
function __webpack_require__(moduleId) {
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
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;
}
__webpack_require__.e = function requireEnsure(chunkId) {
var installedChunkData = installedChunks[chunkId];
if (installedChunkData === 0) {
return new Promise(function (resolve) {
resolve();
});
}
if (installedChunkData) {
return installedChunkData[2];
}
var promise = new Promise(function (resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
installedChunkData[2] = promise;
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.type = 'text/javascript';
script.charset = 'utf-8';
script.async = true;
script.timeout = 120000;
script.src = __webpack_require__.p + "" + chunkId + ".bundle.js";
var timeout = setTimeout(onScriptComplete, 120000);
script.onerror = script.onload = onScriptComplete;
function onScriptComplete() {
script.onerror = script.onload = null;
clearTimeout(timeout);
var chunk = installedChunks[chunkId];
if (chunk !== 0) {
if (chunk) {
chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
}
installedChunks[chunkId] = undefined;
}
};
head.appendChild(script);
return promise;
};
__webpack_require__.p = '';
return __webpack_require__(__webpack_require__.s = 0);
})
(
[
(function (module, exports, __webpack_require__) {
__webpack_require__.e(0).then(__webpack_require__.bind(null, 1)).then((show) => {
show('Webpack');
});
})
]
);
// 0.bundle.js
webpackJsonp([0],[
/* 0 */,
/* 1 */
/***/ (function(module, exports) {
function show(content) {
window.document.getElementById('app').innerText = 'Hello,' + content;
}
// 通过 CommonJS 规范导出 show 函数
module.exports = show;
/***/ })
]);
书中bundle.js和0.bundle.js在书籍勘误方面有疏忽,一些关键细节有问题,导致示例程序无法直接运行,本文中的代码经过了修正,可以直接运行。其中,出现问题的地方有两处。
第一、在整个大的自执行函数的函数体末尾,return _webpack_require(webpack_require.s = 0)这一句之前,缺少了一个webpack_require.p = ''。在异步加载的示例中,由于script标签需要一个src,而src是加上了这个p的,也就是publicPath(可以被Express中间件代理然后隐藏资源真实位置信息)。
第二、在0.bundle.js当中,webpackJsonp的第二个参数,也就是chunks数组,其实是[,(function(){})]的形式,也就是说function前面有一个逗号。如果这里没有逗号,那么该chunk会安装到modules的0号位置上,而1号位置则为空,后面就会报undefined的错误。
由于是异步加载,show函数的文件被单独拆分成了一个0.bundle.js,注意,此时0.bundle.js其实是chunk,这是理解chunk和module差别的一个好机会。chunk,翻译成中文就是“块”,也就是代码块,也就是“一堆模块”。module,是模块,webpack当中一个文件就是一个模块,chunk其实就是module的集合,chunk内部有多个module,这些module是等待被“安装”到modules数组(就是最外层大的自执行函数的参数)中的。
接下来跟着代码执行的顺序,来理解打包后的代码。
3.1、理解0.bundle.js
0.bundle.js,是拆分出去的一个chunk,以方便我们按需加载。0.bundle.js是webpackJsonp(),也就是执行webpackJsonp函数,其参数有两个,第一个是[0],第二个是一个数组[,function(){}],注意这里的逗号,非常关键。[0],也就是第一个参数,他的成员,代表的是,当本文件(也就是0.bundle.js)被加载了之后,哪些chunk可以被标记为“已安装”。这样说可能有点抽象,在整个自执行函数内,有一个installedChunks和一个installedModules,installedChunks意思就是已经安装了的chunk,如果安装成功,那么chunk的键对应的值就是0,比如。
var installedChunks = {
0:0
1:[function(){},function(){},promise实例]
}
上述这个installedChunks对象的键0,对应的值是0,代表的就是0这个下标的chunk已经安装好了。
先小结一下,0.bundle.js这种chunk文件,作用就是把内部的模块,安装到modules(也就是外层自执行函数的参数)数组中。
3.2、理解bundle.js
bundle.js就是我们打包之后的代码,它的形式是这样的:
function(){
...
}(
[
(function (module, exports, __webpack_require__) {
__webpack_require__.e(0).then(__webpack_require__.bind(null, 1)).then((show) => {
show('Webpack');
});
})
]
)
这个自执行函数开始执行。首先会执行
return __webpack_require__(__webpack_require__.s = 0);
对第二节比较熟悉就可以知道,这里是在同步加载0号模块,接下来就执行webpack_require函数了。webpack_require函数就是同步加载模块的方法,只不过做了一个缓存。通过执行模块代码,然后用installedModules的去接收其exports的内容达到引入的目的。
所以,就会执行到这个0号的module,也就是上面的:
__webpack_require__.e(0).then(__webpack_require__.bind(null, 1)).then((show) => {
show('Webpack');
});
这里开始,执行了一个webpack_require.e(0),这个e函数,也就是requireEnsure,从上面完整代码可以看出,他的作用,是通过script标签,去引入这个0号chunk,也就是0.bundle.js,并且返回一个promise。
这里是一个比较关键的地方,script有一个onload事件,onerror,和一个超时的时间,其回调函数都是onScriptComplete,作用就是让这个promise有一个确定的态。
注意,当script标签指定src后,就开始下载文件了,这里思考一下,script标签下载完之后,会先执行onScriptComplete,还是script标签内部自己的代码呢?
答案就是script标签自己的代码,script标签自己的代码,也就是webpackJsonp函数的执行,会在script标签加载完之后立马运行,而onload会在其后运行。
webpackJsonp一运行,就会给installedChunks打上标记,那些被加载过的chunk,就会标记为0,也就是加载过了,并且会改变modules参数,modules数组,就会像之前第二节一样,show函数的代码,将会出现在数组的index为1的位置上。
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
在自执行函数内部,有一个这个步骤,他的作用,就是我们上述提到的,把chunk当中的模块,安装到modules数组当中,所以之前,在下面的代码中:
// 0.bundle.js
webpackJsonp([0],[
/* 0 */,
/* 1 */
/***/ (function(module, exports) {
function show(content) {
window.document.getElementById('app').innerText = 'Hello,' + content;
}
// 通过 CommonJS 规范导出 show 函数
module.exports = show;
/***/ })
]);
第二个参数,代表的是需要安装的模块,而且在数组当中的顺序,决定了他会安装在modules的哪个位置,所以必须有那个逗号,才会保证它会被安装在modules的1号位置。
另外,再解释一下下面这段代码
window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
var moduleId, chunkId, i = 0, resolves = [], result;
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (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];
}
}
while (resolves.length) {
resolves.shift()();
}
};
其中
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}
...
while (resolves.length) {
resolves.shift()();
}
它的逻辑,就是把installedChunks打上标记,表示已经加载过了,那么再执行webpack_require.e(0),的时候,就可以直接返回一个fullfilled态的promise,直接执行then就可以。因为0号chunk的东西都已经加载过了,而且都放在了modules数组里。
那么当0号chunk加载完毕之后,自然加载这个chunk的promise就该变为fullfilled态了,所以才遍历了之后,再全部resolve掉。也就是说,这个webpackJsonp的作用,就是在告诉那些想要引入0号chunk的promise,已经加载ok,可以执行下一步了。当然,0.bundle.js也可能引入其他的chunk,当然chunkIds可能就不只是[0]了。
3.3、理解chunk加载后的行为
(function (module, exports, __webpack_require__) {
__webpack_require__.e(0).then(__webpack_require__.bind(null, 1)).then((show) => {
show('Webpack');
});
})
3.2当中,我们花了很多的篇幅说完了webpack_require.e(0)做的事情,modules中已经有了chunk安装过来的代码,我们就可以放心的then了,由于1号module是我们需要的show函数,这里调用了webpack_require.bind(null, 1),再进行then的,为什么这里要用bind而不用call或者apply呢,因为这里是then的第一个参数,也就是onfullfilled,也就是成功的回调,需要的是一个函数,而不是函数的执行,因此bind传给他一个函数就可以了,这个函数会作为成功的回调直接执行。
这里不太熟悉promise的小伙伴可能有一点晕,可以看一下promise/A+规范,就会很清楚了。webpack_require是一个同步的加载函数,执行之后,会返回module.exports,这里解释一下,先给一个例子。
new Promise((resolve) => {
resolve(1);
}).then(() => {
return 2;
}).then(res => {
console.log(res);
// 2
})
上面的第二个then,其fullefilled函数,返回的是2,这个2会被resolve,然后传递给下一个then,类似的。
.then(__webpack_require__.bind(null, 1)).then((show) => {show('webpack')})
这个webpack_require.bind(null, 1)作为一个fullfiiled参数,返回的是module.exports,也就是show函数,会像上面的例子一样,传递给下一个then,因此show就能够正常拿到并且使用了。