webpack打包产物代码分析

前一阵子主要研究了webpack的源码实现,这篇文章用于记录webpack打包后代码的阅读,更多的是在代码中添加自己的注释理解,使用的webpack版本是4.5.0

主流程

先看一下最简单的代码在打包后的实现:

打包前代码:

1
2
3
4
// index.js

import { log } from './util';
log('abc');
1
2
3
4
5
// util.js

export function log(v) {
console.log(v);
}

webpack config:

1
2
3
4
5
6
7
8
9
10
11
const path = require('path');

module.exports = {
entry: './index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash].js',
},
mode: 'development',
devtool: 'cheap-source-map',
};

打包后代码:

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
(function(modules) {
// webpackBootstrap
// The module cache
var installedModules = {};

// The require function
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
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

// Flag the module as loaded
module.l = true;

// Return the exports of the module
return module.exports;
}

// expose the modules object (__webpack_modules__)
__webpack_require__.m = modules;

// expose the module cache
__webpack_require__.c = installedModules;

// define getter function for harmony exports
__webpack_require__.d = function(exports, name, getter) {
if (!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, { enumerable: true, get: getter });
}
};

// define __esModule on exports
__webpack_require__.r = function(exports) {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
}
Object.defineProperty(exports, '__esModule', { value: true });
};

// create a fake namespace object
// mode & 1: value is a module id, require it
// mode & 2: merge all properties of value into the ns
// mode & 4: return value when already ns object
// mode & 8|1: behave like require
__webpack_require__.t = function(value, mode) {
if (mode & 1) value = __webpack_require__(value);
if (mode & 8) return value;
if (mode & 4 && typeof value === 'object' && value && value.__esModule) return value;
var ns = Object.create(null);
__webpack_require__.r(ns);
Object.defineProperty(ns, 'default', { enumerable: true, value: value });
if (mode & 2 && typeof value != 'string')
for (var key in value)
__webpack_require__.d(
ns,
key,
function(key) {
return value[key];
}.bind(null, key),
);
return ns;
};

// getDefaultExport function for compatibility with non-harmony modules
__webpack_require__.n = function(module) {
var getter =
module && module.__esModule
? function getDefault() {
return module['default'];
}
: function getModuleExports() {
return module;
};
__webpack_require__.d(getter, 'a', getter);
return getter;
};

// Object.prototype.hasOwnProperty.call
__webpack_require__.o = function(object, property) {
return Object.prototype.hasOwnProperty.call(object, property);
};

// __webpack_public_path__
__webpack_require__.p = '';

// Load entry module and return exports
return __webpack_require__((__webpack_require__.s = './webpack-sourcecode/index.js'));
})({
'./webpack-sourcecode/index.js':
/*! no exports provided */
function(module, __webpack_exports__, __webpack_require__) {
'use strict';
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util */ './webpack-sourcecode/util.js');

Object(_util__WEBPACK_IMPORTED_MODULE_0__['log'])('abc');
},

'./webpack-sourcecode/util.js':
/*! exports provided: log */
function(module, __webpack_exports__, __webpack_require__) {
'use strict';
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, 'log', function() {
return log;
});

function log(v) {
console.log(v);
}
},
});

初一看眼花缭乱的,实际上就是一个IIFE,简化后的格式其实很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(function(modules) {
function __webpack_require__(moduleId) {
// ...
}

return __webpack_require__((__webpack_require__.s = './webpack-sourcecode/index.js'));
})({
'./webpack-sourcecode/index.js': function(module, __webpack_exports__, __webpack_require__) {
// ...
},

'./webpack-sourcecode/util.js': function(module, __webpack_exports__, __webpack_require__) {
// ...
},
});

函数的入参modules是一个对象,对象的key就是每个js模块的相对路径,value就是一个函数。IIFE会先require入口模块,然后入口模块会在执行时require其他模块例如util.js,接下来看看详细的实现。

__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
// The module cache
var installedModules = {};

// The require function
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
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

// Flag the module as loaded
module.l = true;

// Return the exports of the module
return module.exports;
}

函数的入参是模块的id,返回值module.exports是一个对象,modules[moduleId].call就是在执行模块对应的函数,如果模块有export的东西,会放到module.exports中。同时有一个缓存对象用于存放已经require过的模块。

所以这个__webpack_require__就是来模拟import一个模块,并在最后返回所有模块export的变量。 我们再看一个示范应该就能清楚了:

1
2
3
4
5
6
7
8
9
10
11
12
// './webpack-sourcecode/util.js'

function (module, __webpack_exports__, __webpack_require__) {
// ...
__webpack_require__.d(__webpack_exports__, 'log', function() {
return log;
});

function log(v) {
console.log(v);
}
}

__webpack_require__.d其实就是Object.defineProperty

1
2
3
4
5
6
7
8
9
10
11
// define getter function for harmony exports
__webpack_require__.d = function(exports, name, getter) {
if (!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, { enumerable: true, get: getter });
}
};

// Object.prototype.hasOwnProperty.call
__webpack_require__.o = function(object, property) {
return Object.prototype.hasOwnProperty.call(object, property);
};

所以util模块最后往__webpack_exports__导出了一个log变量实际上就是log函数。

其他辅助函数

__webpack_require__.r

用于标记一个ES模块:

1
2
3
4
5
6
7
// define __esModule on exports
__webpack_require__.r = function(exports) {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
}
Object.defineProperty(exports, '__esModule', { value: true });
};

修改了exportstoString方法,并添加了一个__esModule成员。

__webpack_require__.t

暂时不知道干嘛的,不过函数的作用在注释中已经很清楚了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// create a fake namespace object
// mode & 1: value is a module id, require it
// mode & 2: merge all properties of value into the ns
// mode & 4: return value when already ns object
// mode & 8|1: behave like require
__webpack_require__.t = function(value, mode) {
if (mode & 1) value = __webpack_require__(value);
if (mode & 8) return value;
if (mode & 4 && typeof value === 'object' && value && value.__esModule) return value;
var ns = Object.create(null);
__webpack_require__.r(ns);
Object.defineProperty(ns, 'default', { enumerable: true, value: value });
if (mode & 2 && typeof value != 'string')
for (var key in value)
__webpack_require__.d(
ns,
key,
function(key) {
return value[key];
}.bind(null, key),
);
return ns;
};

__webpack_require__.n

暂时也不知道干嘛的,不过应该是__webpack_require__.t配合使用的,因为都用到了同一个default变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
// getDefaultExport function for compatibility with non-harmony modules
__webpack_require__.n = function(module) {
var getter =
module && module.__esModule
? function getDefault() {
return module['default'];
}
: function getModuleExports() {
return module;
};
__webpack_require__.d(getter, 'a', getter);
return getter;
};

以上就是webpack打包产物的主流程代码。

code splitting 代码分割

涉及到代码分割时,打包产物有一些变化,直观感受是多出来一些js文件。代码分割的方式有多种,详见官网文档,这里我们以古老的require.ensure做示范:

1
2
3
4
5
6
7
8
// index.js

import { log } from './util';
log('log in entry');

require.ensure(['./runtime.js'], function() {
console.log('ensured');
});
1
2
3
4
5
6
7
8
9
// runtime.js

import { log } from './util';

log('log in runtime');

exports.t = function() {
console.log('runtime');
};

打包的产物与正常流程相比有一些不同之处,注意到此时有两个输出js文件了,在我的示范中一个是main.xxx.js,这是由入口文件entry生成的;另一个是0.xxx.js,这个就是我们通过require.ensure动态加载的chunk。通常我们称main的为主chunk

主 chunk

我们最关心的是require.ensure是如何生效的,因为这个其实是webpack独有的语法,noderequire函数上并没有这个属性。另外很容易发现主chunk中并没有runtime.js的代码,这是很自然的,放在一起就一起加载了。。

打包产物的关键代码:

1
2
3
4
5
6
7
8
9
10
// "./webpack-sourcecode/index.js"

_webpack_require__
.e(/*! require.ensure */ 0)
.then(
function() {
console.log('ensured');
}.bind(null, __webpack_require__),
)
.catch(__webpack_require__.oe);

很明显require.ensure转化为了一个Promise,这符合我们的直觉毕竟是异步加载。另外,如果require.ensure的参数数组有多个元素,打包后代码稍微有一点不一样:

1
Promise.all(/*! require.ensure */ [__webpack_require__.e(0), __webpack_require__.e(1)]).then(/**/);

__webpack_require__.oe就是一个很简单的打印错误不贴出来了,我们重点是_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
// 已加载的chunk缓存
var installedChunks = {
main: 0, // 0 means "already installed".
};

__webpack_require__.e = function requireEnsure(chunkId) {
var promises = []; // JSONP chunk loading for javascript

var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) {
// 0 means "already installed".

// a Promise means "currently loading".目标chunk正在加载
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// setup Promise in chunk cache,利用Promise去异步加载目标chunk
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push((installedChunkData[2] = promise)); // start chunk loading

// 模拟jsonp
var script = document.createElement('script');
var onScriptComplete;

script.charset = 'utf-8';
script.timeout = 120;
if (__webpack_require__.nc) {
script.setAttribute('nonce', __webpack_require__.nc);
}
// 获取目标chunk的地址,__webpack_require__.p 表示设置的publicPath,默认为空串
// __webpack_require__.p + "" + chunkId + "." + {"0":"dc72666a6c96f7eb55e0"}[chunkId] + ".js"
script.src = jsonpScriptSrc(chunkId);

onScriptComplete = function(event) {
// avoid mem leaks in IE.
script.onerror = script.onload = null;
clearTimeout(timeout);
var chunk = installedChunks[chunkId];
if (chunk !== 0) {
if (chunk) {
// 此时chunk为[resolve, reject, promise]表示还没有加载好
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); // reject 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);
};

上述代码加上注释应该不难懂了,核心就是将require.ensure转化为模拟jsonp去加载目标chunk文件。

下一步看看异步加载chunk的代码。

异步 chunk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 0.xxx.js

(window['webpackJsonp'] = window['webpackJsonp'] || []).push([
[0],
{
'./webpack-sourcecode/runtime.js': function(module, __webpack_exports__, __webpack_require__) {
'use strict';
__webpack_require__.r(__webpack_exports__);
var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./webpack-sourcecode/util.js');

Object(_util__WEBPACK_IMPORTED_MODULE_0__['log'])('log in runtime');

exports.t = function() {
console.log('runtime');
};
},
},
]);

这个chunk中只有一个module,其中的代码我们已经熟悉了,整个chunk的代码看起来很简单,就是往一个数组window['webpackJsonp']中塞入一个元素,这个数组是哪里来的呢,在哪里用到了呢? 其实它是在主chunk中有用到,主chunk除了require了入口module外,还有这么一段:

1
2
3
4
5
6
7
8
9
// main.xxx.js

var jsonpArray = (window['webpackJsonp'] = window['webpackJsonp'] || []);
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 保存原始的Array.prototype.push方法
jsonpArray.push = webpackJsonpCallback; // 将push方法的实现修改为webpackJsonpCallback
jsonpArray = jsonpArray.slice();
// 对已在数组中的元素依次执行webpackJsonpCallback方法
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;

这样我们在异步chunk中执行的window['webpackJsonp'].push其实是webpackJsonpCallback函数。

webpackJsonpCallback

看名字应该能猜到,它是我们上面说的模拟jsonp完成后执行的逻辑,注意script.onload/onerror会在webpackJsonpCallback执行完后再执行,所以onload/onerror其实是用来检查webpackJsonpCallback的完成度:有没有将installedChunks中对应的chunk值设为 0.

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

// 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];
// 0表示已加载完的chunk,所以此处是找到那些未加载完的chunk,他们的value还是[resolve, reject, promise]
if (installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0; // 标记为已加载
}
// 挨个将异步chunk中的module加入主chunk的modules数组中
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
// parentJsonpFunction: 原始的数组push方法,将data加入window["webpackJsonp"]数组。
// 因为动态chunk中的push方法即webpackJsonpCallback并没有执行这个步骤
if (parentJsonpFunction) parentJsonpFunction(data);

// 等到while循环结束后,__webpack_require__.e的返回值Promise得到resolve
while (resolves.length) {
resolves.shift()();
}
}

common chunk

这个其实也是一种code splitting方案,用于有多个entry的配置中提取公共代码的。为此我们需要修改下webpack config以及每个entry的代码,我们使用最新的SplitChunksPlugin来生成公共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
// webpack config

module.exports = {
entry: {
main: './index.js',
runtime: './runtime.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash].js',
chunkFilename: '[name].[chunkhash].js',
},
optimization: {
splitChunks: {
chunks: 'all',
minSize: 0,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
mode: 'development',
devtool: 'cheap-source-map',
};
1
2
3
4
// index.js

import { log } from './util';
log('log in entry');
1
2
3
4
// runtime.js

import { log } from './util';
log('log in runtime');

util.js中的代码不做修改,按照期望会有 3 个文件生成:main.xxx.jsruntime.xxx.js,公共代码抽取生成的chunk按照SplitChunksPlugin的配置应该是default~main~runtime.xxx.js

主 chunk 代码

生成的打包产物main.xxx.jsruntime.xxx.js基本一致,因为它俩现在地位相同都是主chunk:都不会有util.js的代码,同时相较于require.ensure生成的代码有了一些变化,我们先从如何加载default~main~runtime.xxx.js看起:

1
2
3
4
5
6
7
8
9
(function(modules) {
// ...
})({
'./index.js': function(module, __webpack_exports__, __webpack_require__) {
// ...
var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./util.js');
// ...
},
});

看起来像是__webpack_require__的实现有变化,但查看代码却发现不是这样,仔细对比发现是 IIFE 里的代码有不同,加载index.js的方式变了:

1
2
3
4
5
6
var deferredModules = [];

// add entry module to deferred list
deferredModules.push(['./webpack-sourcecode/index.js', 'default~main~runtime']);
// run deferred modules when ready
return checkDeferredModules();

这次又多出来个数组,看看checkDeferredModules

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function checkDeferredModules() {
var result;
for (var i = 0; i < deferredModules.length; i++) {
var deferredModule = deferredModules[i];
var fulfilled = true;
// 检查当前主chunk依赖的所有异步加载的common chunk,看是否都已加载完成,注意下标从1开始
for (var j = 1; j < deferredModule.length; j++) {
var depId = deferredModule[j];
if (installedChunks[depId] !== 0) fulfilled = false; // 依然是0表示加载完
}
if (fulfilled) {
// fulfilled表示这个主chunk所有依赖chunk均加载完,从数组中移除这个元素
deferredModules.splice(i--, 1);
// 此时才真正require这个主chunk
result = __webpack_require__((__webpack_require__.s = deferredModule[0]));
}
}
return result;
}

ok 现在我们明白了main.xxx.js这个chunk中的index.js什么时候才能执行了,就是等到default~main~runtime这个common chunk加载完才能执行。那么剩下的问题就是在何时会去加载default~main~runtime呢?因为checkDeferredModules并没有做这件事。

很遗憾整个主chunk中都没有找到这样的代码,连模拟jsonp的代码都没有,直到在插件文档中找到这样一段话:

You can combine this configuration with the HtmlWebpackPlugin. It will inject all the generated vendor chunks for you.

也就是说插件会自动将生成的chunk插入到html中,查看我们实际的某个项目发现确实如此:

1
2
3
4
5
6
7
8
9
10
11
12
// webpack config, 注意只有一个entry

const entry = {
index: ['src/main.js'],
};

const output = {
path: disPath,
publicPath: conf.conf.public_path,
filename: 'statics/js/[name].[hash:8].js',
chunkFilename: 'statics/js/[name].[chunkhash:8].js',
};
1
2
3
4
<!-- 模板: index.template.html -->
<body>
<div id="app"></div>
</body>
1
2
3
4
5
6
7
<!-- 打包产物 dist/index.html, 除了index.xxx.js是在webpack中配置的entry,其余script都是插件替我们添加的 -->

<div id="app"></div>
<script type="text/javascript" src="/statics/js/manifest.89177312.js"></script>
<script type="text/javascript" src="/statics/js/styles.abb25b55.js"></script>
<script type="text/javascript" src="/statics/js/vendor.7d0ff587.js"></script>
<script type="text/javascript" src="/statics/js/index.b7e452c8.js"></script></body>

TODO: SplitChunksPlugin源码分析。

终于真想大白,浏览器会替我们加载common chunk,接下来看看default~main~runtime

common chunk 代码

1
2
3
4
5
6
7
8
(window['webpackJsonp'] = window['webpackJsonp'] || []).push([
['default~main~runtime'],
{
'./util.js': function(module, __webpack_exports__, __webpack_require__) {
// ...
},
},
]);

这段代码很熟悉和require.ensure的产物一样就不说了。push实际调用的是webpackJsonpCallback,仔细相关其实需要在webpackJsonpCallback中再执行一次checkDeferredModules,因为只有这样我们的主chunk所有依赖chunk才能被标记为加载完成,后续才能requiremodule。实际上确实是这样:

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

绝大部分与require.ensure版本一样,executeModules应该是这个common chunk依赖的chunk??

与我们的猜测一致,每个common chunk在加载完之后,都会检查所属主chunk的所有依赖chunk是否都加载完成,之后才能去requiremodule

Tree Shaking

webpack 本身并不会删除任何多余的代码,删除无用代码的工作是 UglifyJS做的. webpack 做的事情是 unused harmony exports 的标记,也就是他会分析你的代码,把不用的 exports 删除,但是变量的声明并没有删除。

具体的 Tree shaking 这里就不叙述了,webpack 官网这篇博客都讲的很清楚。

注意: 在自己做测试时,发现需要webpack config添加一个额外的配置才起作用:

1
2
3
optimization: {
usedExports: true;
}

这个在webpack源码的WebpackOptionsApply.js中有标明:

1
2
3
if (options.optimization.usedExports) {
new FlagDependencyUsagePlugin().apply(compiler);
}