Description
如何分析 Webpack 打包
准确地说,本文是分析 Webpack 打包的结果,目的是看看 Webpack 如何将每个模块(文件)组合起来,在浏览器中是如何执行的打包代码,包括如何加装异步的块。
因此,只要写个简单的项目,分析其打包出来的代码即可。
准备一个精简的项目
按照 Webpack 官方 getting started 教程初始化一个项目,然后写入以下文件:
src/index.js
:
// 同步引入
import syncHello from './sync-hello'
document.querySelector('#text').innerText += `${syncHello}\n`
// 异步引入
import(/* webpackChunkName: "async" */ './async-hello').then(({ default: asyncHello }) => {
document.querySelector('#text').innerText += `${asyncHello}\n`
})
src/sync-hello.js
:
import generateHello from './sync-util'
export default generateHello('sync code')
src/sync-util.js
:
const generateHello = (source) => {
return `Hello from ${source}`
}
export default generateHello
src/async-hello.js
:
import generateHello from './sync-util'
export default generateHello('async code')
就这么简单的四个文件。
分析模块依赖关系
显然,每个文件作为一个模块的话,四个文件有以下关系:
index.js(入口)
/ \
sync-hello async-hello
/ /
| /
sync-util
执行打包
在 package.json
中添加脚本:
webpack --mode development --config webpack.config.js
mode
记得设置为 development
,否则打包出来的代码会是压缩混淆过的,难以分析。
在 webpack.config.js
中,把 sourceMap 改一下:
module.exports = {
devtool: 'inline-source-map'
}
这是防止 Webpack 使用 eval
来打包模块,也是为了方便分析模块的代码。
最后打包出来有两个文件,一个 main.js
,一个 async.js
分析打包结果
打开 dist/main.js
文件,可以看到内容充满了各种注释,四个加起来不到 20 行的代码,一个入口就有 200 多行,不过这些都是实现模块化的必要代码,让我们慢慢来分析。
整体结构
首先,从整体来看,可以发现整个 main.js
外层是一个自执行函数的结构:
(function (modules) {
// webpack bootstrap
})({
// modules
})
main.js
文件被加载到浏览器后,就会执行这个函数,这个函数也就相当于是 Webpack 的引导程序。
函数的参数 modules
显然是我们书写的各个模块,在后面以一个对象的形式传入,接着来看看我们写的模块被转换成什么样子
模块参数 modules
转到传入的参数 modules
,发现是个对象:
{
"./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {
// 模块内容
})
"./src/sync-hello.js": (function (module, __webpack_exports__, __webpack_require__) {
// 模块内容
})
"./src/sync-util.js": (function (module, __webpack_exports__, __webpack_require__) {
// 模块内容
})
}
可以看到一共是三个模块,都是同步加载的模块,异步的模块不在 main.js
里面。
我们从 "./src/sync-hello.js"
这个模块入手,因为它既有 import
也有 export
{
"./src/sync-hello.js": (function(module, __webpack_exports__, __webpack_require__) {
// 模块默认是严格模式
"use strict";
// 在 exports 中加上 __esModule 属性,表示是 ES 模块
__webpack_require__.r(__webpack_exports__);
// 导入 sync-util 模块
var _sync_util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/sync-util.js");
// 导出此模块
__webpack_exports__["default"] = (Object(_sync_util__WEBPACK_IMPORTED_MODULE_0__["default"])('sync code'));
})
}
可以看到,我们在文件中写的 ES6 import
跟 export
都没了,变成了使用 __webpack_require__
, __webpack_exports__
这两个参数,个人觉得,因为所有的同步模块都被打包到了同一个文件中,所以就不能再用 ES6 的模块导入导出方法,需要 Webpack 内部自己实现从同一个文件中引入不同的模块。
其导入导出的风格类似 CommonJS
导入类似 require
函数,不过参数并不是路径,而是一个 id ,就是 modules
参数对象的 key 值
导出则是在 __webpack_exports__
上挂上导出的内容,类似 exports
对象。这里导出了一个 default
属性。
其他的模块也是类似的,修改了 import
与 export
引导函数
看完参数,就该看看函数本体了
从上述模块的改写可以知道,重点在于如何导入导出模块,因此我们重点看看 __webpack_require__
这个函数
(function (modules) {
// 模块缓存
var installedModules = {}
// The require function
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;
// 返回模块的导出(exports)
return module.exports;
}
// ...定义了一堆东西
// Load entry module and return exports
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
// modules
})
在 __webpack_require__
中,可以看到,每个模块其实是一个对象 module
,其属性 exports
就是模块的导出, __webpack_require__
的功能其实就是,根据 moduleId
在缓存中查找对应模块执行的结果,如果没有找到,则执行 modules
参数中对应 moduleId
的函数,把 module.exports
作为 __webpack_exports__
参数传入,函数内部对 __webpack_exports__
的修改,就是对 module.exports
的修改。 __webpack_require__
最终返回的是 moduleId
对应模块的导出,也就是 module.exports
定义好 __webpack_require__
后,引导函数最后执行了导入入口模块 ./src/index.js
,至此,一个 Webpack 应用就开始执行了。
异步模块怎么办
可能都知道, Webpack 导入异步模块是用了 jsonp ,那具体是个什么样的过程呢?
我们先看看有用到异步导入的模块,也就是 ./src/index.js
,在代码中是这么写的
import(/* webpackChunkName: "async" */ './async-hello').then(({ default: asyncHello }) => {
document.querySelector('#text').innerText += `${asyncHello}\n`
})
经过 Webpack 打包,变成了:
__webpack_require__.e(/*! import() | async */ "async").then(__webpack_require__.bind(null, /*! ./async-hello */ "./src/async-hello.js")).then(({ default: asyncHello }) => {
document.querySelector('#text').innerText += `${asyncHello}\n`
})
也就是说, import()
被转换成了:
__webpack_require__.e(chunkId).then(__webpack_require__.bind(null, moduleId))
那么我们来看看 __webpack_require__.e
是何方神圣
下面我把异步加载 jsonp 相关的代码都揪出来了,这段代码都在顶层的自执行函数,也就是引导函数中:
// 加载代码块的 jsonp 回调
function webpackJsonpCallback (data) {
var chunkIds = data[0];
var moreModules = data[1];
// 把 data 参数数组的第二个元素添加到 modules 对象中(也就是引导函数的 modules 参数)
// 把 chunkIds 里的元素都标记为已加载(即在 installedChunks 中置为 0 )
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()();
}
};
// 存储已加载或正在加载的块(chunk)
// undefined = chunk 未加载, null = chunk preloaded/prefetched
// Promise = chunk 正在加载, 0 = chunk 已加载
var installedChunks = {
"main": 0
};
// script path function
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "" + ({"async":"async"}[chunkId]||chunkId) + ".js"
}
// 由于 main.js 只包含入口的块(chunk),因此提供以下函数
// 加载额外(异步)块的函数
__webpack_require__.e = function requireEnsure (chunkId) {
var promises = [];
// JSONP chunk loading for javascript
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) { // 0 表示 "已安装".
// 一个 Promise 表示 "正在加载".
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// installedChunks[chunkId] === undefined , 表示此 chunk 未加载
// 因此将 installedChunks[chunkId] 赋值为 Promise 表示正在加载这个 chunk
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();
// script 标签下载、执行完成后的回调
onScriptComplete = function (event) {
// avoid mem leaks in IE.
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;
}
};
// 2 分钟超时
var timeout = setTimeout(function () {
onScriptComplete({ type: 'timeout', target: script });
}, 120000);
script.onerror = script.onload = onScriptComplete;
// 把 script 标签插入 html ,开始下载异步模块
document.head.appendChild(script);
}
}
return Promise.all(promises);
};
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
// 重写 push 方法,因此 script 标签下载完成时执行的 jsonp 回调就是 webpackJsonpCallback
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
// parentJsonpFunction 就是数组原来的 push 方法
var parentJsonpFunction = oldJsonpFunction;
直接讲一下遇到异步模块的时候是怎么一个流程吧。
- 在加载入口之前,会先定义好 jsonp 回调
webpackJsonpCallback
, 异步导入方法__webpack_require__.e
还有window["webpackJsonp"]
这个全局的变量,并重写window["webpackJsonp"].push
方法为webpackJsonpCallback
- 当在文件中导入异步模块,也就是调用了
import()
,由于 Webpack 打包前的转换,会变成调用__webpack_require__.e
- 在
__webpack_require__.e
中,对于没有下载的异步模块,会用 JS 新建 script 标签的方式去下载模块的代码,并创建一个 Promise ,把 Promise 存在一个缓存中 - script 标签下载完成后,会自动执行下载的脚本,而在异步的脚本中,代码是这样的:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["async"], {
"./src/async-hello.js": (function (module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
var _sync_util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/sync-util.js");
__webpack_exports__["default"] = (Object(_sync_util__WEBPACK_IMPORTED_MODULE_0__["default"])('async code'));
})
}]);
其中会执行 window["webpackJsonp"].push
方法,也就是 webpackJsonpCallback
- 在
webpackJsonpCallback
中,会把异步代码中的模块都保存到modules
参数中,并且 resolve 存在缓存中对应块的 Promise - 异步脚本执行完成后,在
__webpack_require__.e
中定义的 script 标签回调onScriptComplete
就开始处理后续,如果异步 chunk 没有成功加载,则把缓存里,即installedChunks[chunkId]
置为 undefined ,表示未加载,下次会重新再去下载这个 chunk 。
根据上述步骤,可以得到一些结论:
__webpack_require__.e
负责将异步的代码块(chunk ,里面包含异步模块)通过 script 标签下载下来webpackJsonpCallback
jsonp 回调,在异步代码下载完成后,负责把异步模块注册到modules
里,并 resolve 对应的 Promise
所以, __webpack_require__.e(chunkId)
返回的是一个 Promise ,当它 resolve 的时候,表示异步模块已经被注册到 modules
中,可以 require 了
因此 __webpack_require__.e(chunkId)
后, Webpack 内部还要再执行一个 then
:
__webpack_require__.e(chunkId).then(__webpack_require__.bind(null, moduleId))
相当于:
__webpack_require__.e(chunkId).then(() => {
return __webpack_require__(moduleId)
})
最终返回一个新的 Promise , resolve 的值就是 __webpack_require__(moduleId)
,这样,下一个 then
就能接收到异步模块导出的值了。(关于在 then
里面 return 一个值会如何处理,参照 #26 )
跑起来看看?
在 dist
文件夹下新建一个 index.html
,引入打包后的 main.js
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="text"></div>
<script src="./main.js"></script>
</body>
</html>
直接用浏览器打开这个文件,可以看到正确的结果
在开发者工具的 Network 面板中,可以看到请求了三个文件: index.html
, main.js
, async.js
在 Elements 面板中,展开 head
标签,可以看到多了个 src 为 async.js
的 script
标签
一切正常。