记一次对webpack打包后的代码的失败探究

记得4月新出webpack4,上个月刚好没什么事情,用webpack4又重新去搭了一遍自己的项目。在搭项目的途中,忽然对webpack模块化之后的代码起了兴趣,于是想搞清楚我们引入的文件到底是怎么运行的。

1、基本版——单入口引入一个js文件

所谓的基本版,就是我只引入了一个test.js,代码只有一行var a = 1。打包之后,发现生成的文件main.js并没有多少代码,只有90行不到。

截取出真正执行的代码就更加少了,只有下面4行。我们接下去就从这几行代码中看下打包出来的文件的执行流程是怎么样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(function(modules) {
//新建一个对象,记录导入了哪些模块
var installedModules = {};
// The require function 核心执行方法
function __webpack_require__(moduleId){/*内容暂时省略*/}
// expose the modules object (__webpack_modules__) 记录传入的modules作为私有属性
__webpack_require__.m = modules;
// expose the module cache 缓存对象,记录了导入哪些模块
__webpack_require__.c = installedModules;
// Load entry module and return exports 默认将传入的数组第一个元素作为参数传入,这个s应该是start的意思了
return __webpack_require__(__webpack_require__.s = 0);
})([(function(module, exports, __webpack_require__) {
/* 0 */
var a = 1;
/***/ })
/******/ ])

首先很明显,整个文件是个自执行函数。传入了一个数组参数modules

这个自执行函数内部一开始新建了一个对象installedModules,用来记录打包了哪些模块。

然后新建了函数__webpack_require__,可以说整个自执行函数最核心的就是__webpack_require____webpack_require__有许多私有属性,其中就有刚刚新建的installedModules

最后自执行函数return__webpack_require__,并传入了一个参数0。因为__webpack_require__的传参变量名称叫做moduleId,那么传参传进来的也就是模块id*。所以我大胆猜测这个0可能是某个模块的id。

这时候我瞄到下面有一行注释/* 0 */。可以发现webpack会在每一个模块导入的时候,会在打包模块的顶部写上一个id的注释。那么刚才那个0就能解释了,就是我们引入的那个模块,由于是第一个模块,所以它的id是0。

那么当传入了moduleId之后,__webpack_require__内部发生了什么?

webpack_require解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function __webpack_require__(moduleId) {
// Check if module is in cache
// 检查缓存对象中是否有这个id,判断是否首次引入
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache) 添加到.c缓存里面
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// Execute the module function 执行通过moduleId获取到的函数
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// Flag the module as loaded
// 表示module对象里面的模块加载了
module.l = true;
// Return the exports of the module
return module.exports;
}

首先通过moduleId判断这个模块是否引入过。如果已经引入过的话,则直接返回。否则installedModules去记录下这次引入。这样子如果别的文件也要引入这个模块的话,避免去重复执行相同的代码。

然后通过modules[moduleId].call去执行了引入的JS文件。

看完这个函数之后,大家可以发现其实webpack打包之后的文件并没有什么很复杂的内容嘛。当然这很大一部分原因是因为我们的场景太简单了,那么接下来就增加一点复杂性。

2、升级版——单入口引入多个文件

接下来我修改一下webpack入口,单个入口同时下引入三个个文件

1
entry: [path.resolve(__dirname, '../src/test.js'),path.resolve(__dirname, '../src/test2.js'),path.resolve(__dirname, '../src/test3.js')],

三个文件的内容分别为var a = 1,var b = 2,var c = 3。接下来我们可以看看打包之后的代码

打包之后的文件main.js核心内容并没有发生变化,和上面一模一样。但是这个自执行函数传入的参数却发生了变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(function(modules) {
/*这部分内容省略,和前面一模一样*/
})([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
__webpack_require__(1);
__webpack_require__(2);
module.exports = __webpack_require__(3);
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
var a = 1;
/***/ }),
/* 2 */
/***/ (function(module, exports, __webpack_require__) {
var b = 2;
/***/ })
/* 3 */
/***/ (function(module, exports, __webpack_require__) {
var c = 3;
/***/ })
/******/ ]);

前面说过,自执行函数默认将传入的参数数组的第一个元素传入__webpack_require__执行代码。

我们可以看一下传入第一个参数的内容,在上一章中是我们引入的文件内容var a = 1,但是这里却不是了。而是按模块引入顺序执行函数__webpack_require__(1),__webpack_require__(2),__webpack_require__(3),通过__webpack_require__函数去执行了我们引入的代码。

大家可以先想一下这里的1,2,3是怎么来的,为什么可以函数调用的时候,直接传参1,2,3

不过到这里还不明白,module.exports到底起了什么作用,如果起作用,为什么又只取最后一个呢?

3.升级版——多入口,多文件引入方式

因为好奇如果多入口多文件是怎么样的,接下去我又将入口改了一下,变成了下面这样

1
2
3
4
entry: {
index1: [path.resolve(__dirname, '../src/test1.js')],
index2: [path.resolve(__dirname, '../src/test2.js'),path.resolve(__dirname, '../src/test3.js')],
},

打包生成了index1.jsindex2.js。发现index1.js和第一章讲的一样,index2.js和第二个文件一样。并没有什么让我很意外的东西。

4、进阶版——引入公共模块

在前面的打包文件中,我们发现每个模块id似乎是和引入顺序有关的。而在我们日常开发环境中,必然会引入各种公共文件,那么webpack会怎么处理这些id呢

于是我们在配置文件中新增了webpack.optimize.SplitChunksPlugin插件。

webpack2和3版本中是webpack.optimize.CommonsChunkPlugin插件。但是在webpack4进行了一次优化改进,想要了解的可以看一下这篇文章webpack4:代码分割CommonChunkPlugin的寿终正寝。所以这里的代码将是使用webpack4打包出来的。

然后修改一下配置文件中的入口,我们开了两个入口,并且两个入口都引入了test3.js这个文件

1
2
3
4
entry: {
index1: [path.resolve(__dirname, '../src/test.js'),path.resolve(__dirname, '../src/test3.js')],
index2: [path.resolve(__dirname, '../src/test2.js'),path.resolve(__dirname, '../src/test3.js')],
},

可以看到,打包后生成了3个文件。

1
2
3
<script type="text/javascript" src="scripts/bundle.4474bdd2169853ce33a7.js"></script>
<script type="text/javascript" src="scripts/index1.4474bdd2169853ce33a7.js"></script>
<script type="text/javascript" src="scripts/index2.4474bdd2169853ce33a7.js"></script>

首先bundle.js(文件名自己定义的)很明显是一个公共文件,里面应该有我们提取test3.js出来的内容。打开文件后,发现里面的代码并不多,只有下面几行。

1
2
3
4
5
6
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[2],{
/***/ 2:
/***/ (function(module, exports, __webpack_require__) {
var c = 1;
/***/ })
}]);

单纯看文件内容,我们大概能推测出几点:

  • window全局环境下有一个名为webpackJsonp的数组
  • 数组的第一个元素仍然是数组,记录了数字2,应该是这个模块的id
  • 数组第二个元素是一个记录了形式为{模块id:模块内容}的对象。
  • 对象中的模块内容就是我们test3.js,被一个匿名函数包裹

webpack2中,采用的是{文件路径:模块内容}的对象形式。不过在升级到webpack3中优化采用了数字形式,为了方便提取公共模块。

注意到一点,这个文件中的2并不像之前一样作为注释的形式存在了,而是作为属性名。但是它为什么直接就将这个模块id命名为2呢,目前来看,应该是这个模块是第二个引入的。带着这个想法,我接下去看了打包出来的index1.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
36
37
38
39
40
41
42
43
44
45
46
47
48
// index1.js
(function(modules) { // webpackBootstrap
// install a JSONP callback for chunk loading
function webpackJsonpCallback(){
/*暂时省略内容*/
return checkDeferredModules
}
function checkDeferredModules(){/*暂时省略内容*/}
// The module cache
var installedModules = {};
// object to store loaded and loading chunks
// undefined = chunk not loaded, null = chunk preloaded/prefetched
// Promise = chunk loading, 0 = chunk loaded
var installedChunks = {
0: 0
};
var deferredModules = []; //
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;
// add entry module to deferred list
deferredModules.push([0,2]);
// run deferred modules when ready
return checkDeferredModules();
})([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
__webpack_require__(1);
module.exports = __webpack_require__(2);
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
var a = 1;
/***/ })
/******/ ]);

在引入webpack.optimize.SplitChunksPlugin之后,核心代码在原来基础上新增了两个函数webpackJsonpCallbackcheckDeferredModules。然后在原来的installedModules基础上,多了一个installedModules,用来记录了模块的运行状态;一个deferredModules,暂时不知道干嘛,看名字像是存储待执行的模块,等到后面用到时再看。

此外,还有这个自执行函数最后一行代码调用形式不再像之前一样。之前是通过调用__webpack_require__(0),现在则变成了checkDeferredModules。那么我们便顺着它现在的调用顺序再去分析一下现在的代码。

在分析了不同之后,接下来就按照运行顺序来查看代码,首先能看到一个熟悉的变量名字webpackJsonp。没错,就是刚才bundle.js中暴露到全局的那个数组。由于在html中先引入了bundle.js文件,所以我们可以直接从全局变量中获取到这个数组。

前面已经简单分析过window["webpackJsonp"]了,就不细究了。接下来这个数组进行了一次for循环,将数组中的每一个元素传参给了方法webpackJsonpCallback。而在这里的演示中,传入就是我们bundle.js中一个包含模块信息的数组[[2],{2:fn}}]

接下来就看webpackJsonpCallback如何处理传进来的参数了

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
/******/ function webpackJsonpCallback(data) {
/******/ var chunkIds = data[0]; // 模块id
/******/ 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 = [];
/******/
/******/ 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];
/******/ }
/******/ }
/******/ if(parentJsonpFunction) parentJsonpFunction(data);
/******/
/******/ while(resolves.length) {
/******/ resolves.shift()();
/******/ }
/******/
/******/ // add entry modules from loaded chunk to deferred list
/******/ deferredModules.push.apply(deferredModules, executeModules || []);
/******/
/******/ // run deferred modules when all chunks ready
/******/ return checkDeferredModules();
/******/ };

这个函数中主要干了两件事情,分别是在那两个for循环中。

一是在installedChunks对象记录引入的公共模块id,并且将这个模块标为已经导入的状态0

1
installedChunks[chunkId] = 0;

然后在另一个for循环中,设置传参数组modules的数据。我们公共模块的id是2,那么便设置modules数组中索引为2的位置为引入的公共模块函数。

1
2
modules[moduleId] = moreModules[moduleId];
//这段代码在我们的例子中等同于 modules[2] = (function(){/*test3.js公共模块中的代码*/})

其实当看到这段代码时,心里就有个疑问了。因为index1.js中设置modulesp[2]这个操作并不是一个push操作,如果说数组索引为2的位置已经有内容了呢?暂时保留着心中的疑问,继续走下去。心中隐隐感觉到这个打包后的代码其实并不是一个独立的产物了。

我们知道modules是传进来的一个数组参数,在第二个章节中可以看到,我们会在最后执行函数__webpack_require__(0),然后依顺序去执行所有引入模块。

不过这次却和以前不一样了,可以看到webpackJsonpCallback最后返回的代码是checkDeferredModules。前面也说了整个自执行函数最后返回的函数也是checkDeferredModules,可以说它替代了__webpack_require__(0)。接下去就去看看checkDeferredModules发生了什么

checkDeferredModules简析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/******/ function checkDeferredModules() {
/******/ var result;
/******/ for(var i = 0; i < deferredModules.length; i++) {
/******/ var deferredModule = deferredModules[i];
/******/ var fulfilled = true;
/******/ for(var j = 1; j < deferredModule.length; j++) {
/******/ var depId = deferredModule[j];
/******/ if(installedChunks[depId] !== 0) fulfilled = false;
/******/ }
/******/ if(fulfilled) {
/******/ deferredModules.splice(i--, 1);
/******/ result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
/******/ }
/******/ }
/******/ return result;
/******/ }

这个函数关键点似乎是在deferredModules,但是我们刚才webpackJsonpCallback唯一涉及到这个的只有这么一句,并且executeModules其实是没有内容的,所以可以说是空数组。

1
deferredModules.push.apply(deferredModules, executeModules || []);

既然没有内容,那么webpackJsonpCallback就只能结束函数了。回到主线程,发现下面马上是两句代码,得,又绕回来了。

1
2
3
4
// add entry module to deferred list
deferredModules.push([0,2]);
// run deferred modules when ready
return checkDeferredModules();

不过现在就有deferredModules这个数组终于有内容了,一次for循环下来,最后去执行我们模块的代码仍然是这一句

1
result = __webpack_require__(__webpack_require__.s = deferredModule[0]);

很熟悉,有木有,最后还是回到了__webpack_require__,然后就是熟悉的流程了

1
2
__webpack_require__(1);
module.exports = __webpack_require__(2);

但是当我看到这个内容竟然有这行代码时__webpack_require__(2);还是有点崩溃的。为什么?因为它代码明确直接执行了__webpack_require__(2)。但是2这个模块id是通过在全局属性webpackJsonp获得的,代码不应该明确知道的啊。

我原来以为的运行过程是,每个js文件通过全局变量webpackJsonp获得到公共模块id,然后push到自执行函数传参数组modules。那么等到真正执行的时候,会按照for循环依次执行数组内的每个函数。它不会知道有1,2这种明确的id的。

为什么我会这么想呢?因为我一开始认为每个js文件都是独立的,想交互只能通过全局变量来。既然是独立的,我自然不知道公共模块id是2事实上,webpackJsonp的确是验证了我的想法。

可惜结果跟我想象的完全不一样,在index1.js直接指定执行哪些模块。这只能说明一个事情,其实webpack内部已经将所有的代码顺序都确定好了,而不是在js文件中通过代码来确定的。事实上,当我去查看index2.js文件时,更加确定了我的想法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/******/ (function(modules) {/*内容和index1.js一样*/})
/************************************************************************/
/******/ ([
/* 0 */,
/* 1 */,
/* 2 */,
/* 3 */
/***/ (function(module, exports, __webpack_require__) {
__webpack_require__(4);
module.exports = __webpack_require__(2);
/***/ }),
/* 4 */
/***/ (function(module, exports, __webpack_require__) {
var b = 2;
/***/ })
/******/ ]);
//# sourceMappingURL=index2.19eeab4e90ee99ee1ce4.js.map

仔细查看自执行函数的传参数组,发现它的第0,1,2位都是undefined。我们知道这几个数字其实就是每个模块本身的Id。而这几个id恰恰就是index1.jsbundle.js中的模块。理论上来说在浏览器下运行,index2.js应该无法得知的,但是事实却完全相反。

走到这一步,我对webpack打包后的代码也没有特别大的欲望了,webpack内部实现才是更重要的了。好了,不说了,我先去看了,等我搞明白了,再回来写续集。