序:
5月份的时候我的好朋友(@杨鹏)看了 github 上的博客年少时的孤芳自赏,特意跑来夸奖一番。他期待我更新,我回复到6月会更一篇。但是我的整个 6 月都在忙(懒)着一个项目的重构,导致只要有点时间就去 B 站消遣去了。确实好久不写了,杨鹏的夸赞当勉励,也当是督促。这一篇给杨鹏,祝好!
1. demo 代码
本篇所用 webpack 为 v4.x 版本
- webpack.config.js
var path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
bundle: './src/a.js'
},
devtool: 'none',
output: {
path: __dirname + '/dist',
filename: '[name].[chunkhash:4].js',
chunkFilename: '[name].[chunkhash:8].js'
},
mode: 'development',
plugins: [new HtmlWebpackPlugin()],
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/env']
}
}
]
}
]
}
};
- 入口 a.js
import { add } from './b';
import('./c.js').then(m => m.minus(2, 1));
const A_NUM = 1;
let r = add(3, 2 + A_NUM);
console.log(r);
- 模块 b.js
export const SOME_VAR = 'SOME_VAR'
export function add(a, b) {
return a + b
}
- 模块 c.js
import('./b.js').then(m => m.add(200, 100));
export function minus(a, b) {
return a - b;
}
- 模块 d.js
export const L = 'Aragaki Yui'
export function times(a, b) {
return a * b
}
2. 打包输出文件
注意打包的模式是开发模式,不要混淆代码,我们还要读这些代码,输出文件如下
- 0.a619de3d.js (下称 chunk)
- bundle.b05e.js (下称 bundle)
- index.html
3. 删除空注释
这一步是降低心理难度的重要手段,很多人都是被这一大堆的注释劝退的;所以把类似下面的注释都替换成空,没错,用你的IDE,Cmd + R;暂时移除以下注释:bundle 和 chunk 的处理相同。
- bundle 里面的空注释,示例:
/******/
/***/
- 模块前的注释,示例
个注释是用来提示模块导出了那些内容,暂时忽略
/*!******************!*\
!*** ./src/a.js ***!
\******************/
/*! no exports provided */
4. bundle (bundle.b05e.js)结构
4.1 整体结构
(function (modules) {
// webpack runtime 代码
})({
// 这个是模块对象,下称为 modules, 注意提到 modules 就要想到这个对象!!!!
// key 是模块的路径,注意,如果同一个模块使用了不同 loader,webpack 会认为这是两个模块,这个差别会体现在 key 上,key 包含了使用的 loader(如有)
// value 就是被 webpack 包装处理过后的模块
"./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {}),
"./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {})
})
通过上面的代码块可以看出来,这个结构就是一个自执行函数(IIFE),它定义形参 modules,接收实参为一个对象,这个对象中 key 是模块路径,value 则是被 webpack 包装后的模块;
看具体的代码前,先要了解一个概念———— runtime;我们来看下中文官方文档的定义:
runtime,.....主要是指:在浏览器运行过程中,webpack 用来连接模块化应用程序所需的所有代码。它包含:在模块交互时,连接模块所需的加载和解析逻辑。包括:已经加载到浏览器中的连接模块逻辑,以及尚未加载模块的延迟加载逻辑。
简言之,就是 webpack 用来处理连接、加载、执行 webpack 模块的代码;这些就是 bundle 中自执行函数的主要内容,这一段信息量有点大,我们还是由外入内的介绍一下这些变量、方法、以及方法上的属性的大致作用;
4.2 runtime 概览
1、 webpackJsonpCallback 方法
(function(modules) { // webpack 启动代码
// 这个 webpackJsonpCallback 是通过 JSONP 加载那些按需加载(import(some-file.js).then(...))的 chunk 时的 JSONP 的回调 callback;
// JSONP 就是创建一个 script 标签去加载 js 文件,而 JOSNOP callback 就是加载回来以后要做的事情
function webpackJsonpCallback(data) {};
2、 installedModules
// 模块缓存,已经安装过的模块们,如果已经加安装过了就缓存在这个对象中,下次再访问这个模块走缓存就可以了
// 下面的 __webpack_require__ 就是用来安装模块的
var installedModules = {};
3、 installedChunks
// 这个已经安装过的 chunks,这个就有点复杂了;后面会细说加载异步 chunk 的过程;
// installedChunks 这个对象以 key-value 的形式保存已经安装的 chunk,key 是 chunk id,关于 value 有以下几种情况:
// value = undefined,表示该 chunk 未被加载过
// value = 0,chunk 已经加载完毕
// value = <Array> [Promise resolveFn, Promise rejectFn, Promise] 表示 chunk 正在加载中,关于为啥搞成这个数组结构后面的加载异步chunk 会细说
// value = null 表示 chunk preload 或者 prefetch
var installedChunks = {
"bundle": 0
};
4、 jsonpScriptSrc 方法
// 为 script 标签 src 属性拼接 __webpack_require_.p,这个 p 属性就是 webpack.config.js 中 output.publicPath
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + "." + {"0":"a619de3d"}[chunkId] + ".js"
}
5、 _webpack_require_ 方法
// webpack 运行时的主要方法,其作用创建并缓存 module 对象()后,执行这个 module 中的代码;
// 创建 module 是啥意思嘞?就是 __webpack_require__ 中的 { i: moduleId, l: false, exports: {} } 对象
function __webpack_require__(moduleId) {}
6、 __webpack_require__.e 静态属性
// 用于加载额外 chunk 的函数,比如按需加载的 chunk,这个里面就会有创建 script 标签然后去加载代码的具体逻辑,后面细说
__webpack_require__.e = function requireEnsure(chunkId) {};
7、 __webpack_require__.m 静态属性
// 暴露这个 runtime 接收到的 modules 对象(这个自执行函数接收到参数对象,看上面 4.1 )
__webpack_require__.m = modules;
8、 __webpack_require__.c 静态属性
// 暴露缓存的已经安装的模块们
__webpack_require__.c = installedModules;
9、 __webpack_require__.d 静态方法
// 在模块对象(module 上面__webpack_require__ 中创建的 module 对象,下同)的 exports 对象上增加属性,
// 以 getter 的形式定义导出(就是实现你代码中的通过 export 导出一个变量/常量/函数等)
__webpack_require__.d = function(exports, name, getter) {};
10、 __webpack_require__.r 静态方法
// 在模块对象(module) 增加 __esModule 属性,用于标识这个模块是个 ES6 模块
__webpack_require__.r = function(exports) {};
11、 __webpack_require__.t 静态方法
// 这个 t 先忽略掉,暂时用不到
__webpack_require__.t = function(value, mode) {};
12、 __webpack_require__.n 静态方法
// 得到 getDefaultExport 方法,抹平 ES6 的模块和非 ES6 模块的默认导出
__webpack_require__.n = function(module) {};
13、 __webpack_require__.o 静态方法
// 调用 hasOwnProperty 判断对象是否有某一属性
__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
14、 __webpack_require__.p 静态属性
// 这个就是 webpack.config.js 中 output.publicPath,上面 jsonpScriptSrc 方法就是拼接的这个值
__webpack_require__.p = "";
15、 __webpack_require__.oe 静态方法
// 错误处理,忽略
__webpack_require__.oe = function(err) { console.error(err); throw err; };
16、 JSONP 初始化
// JSONP 初始化,这是个精彩的部分,后面讲异步 chunk 加载的时候细说,但是先来看看这几步骤都在干啥
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; // 初始化 window[webpackJsonp] 对象
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 暂存数组 push 方法,这个 push 就是 Array.prototype.push
jsonpArray.push = webpackJsonpCallback; // 重写 jsonpArray.push 方法(注意,这么重写不会改写数组原型)
jsonpArray = jsonpArray.slice(); // 赋值 jsonpArrray,这个复制不带 push 方法!
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); // 若chunk先于 bundle 这个入口加载,这个 jsonpArray 里面就不是空的,此时,遍历并调用 webpackJsonpCallback,相当于手动触发 jsonp callback
var parentJsonpFunction = oldJsonpFunction; // 旧 push 暂存于 parentJsonpFunciton
17、 __webpack_require__() 加载入口启动应用
// 加载入口 module 并返回 webpack_require__ 执行后的 exports 对象
// 从这里算是真正的开始跑我们开发的模块了
return __webpack_require__(__webpack_require__.s = "./src/a.js");
})
/************************************************************************/
({
// 这就是 webpack runtime 的自执行函数接收到 modules 对象:提到 modules 请联想到这个对象
"./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {}),
"./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {})
});
4.3 __webpack_require__ 方法
前面的删除注释、把函数体里面的代码删除,都是在剔除支线剧情,让我们更多注意力放在我想表达的重点上,你可以更好的跟着作者的写作思路,如果你还在读,你会发现我已经由外入内了,从整个 js 文件到 webpack runtime 概览,从这个小的主题我就要进入到一个方法的代码块。
我们来重复一下 _webpack_require_ 方法的作用:接收指定 moduleId 作为参数,创建并缓存 module 对象,加载并执行 moduleId 代码,是 webpack runtime 的重要部分;
在上面 webpack runtime 概览的最后发现,在 runtime 的最后调用了 __webpack_require__(webpack_require.s = './src/a.js');接下来看看方法里面发生了什么:
function __webpack_require__ (moduleId) {
// 查看缓存,如果缓存中有了即返回缓存即可:installedModules 在上面 4.2 runtime 概览(2)
if (installedModules[moduleId]) {
return installedModules[moduleId].exports
}
// 创建并缓存 module 对象,后面被赋值的这个对象称为 module 对象
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
}
// 执行 module 中的代码:
// modules 就是 webpack runtime 这个自执行函数接收到的模块对象,
// moduleId 是什么?从上面 runtime 最后调用 __webpack_require__ 的时候可以发现接收的参数即 moduleId,即 ./src/a.js,这就是说 moduleId 可以认为是个路径
// call 则把模块中的 this 修改成 module.exports 并执行,执行时传入参数:module, module.exports, __webpack_require__ (有没有 node.js 中 CMD 的感觉了)
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
// 标记 module 已经加载过,l 我猜猜是 loaded 的缩写
module.l = true
// 把 module 的 exports 导出
return module.exports
}
接着我们看看 modules[moduleId] 即 modules["./src/a.js"] 里面是什么:
- modules
// 1. webpack runtime 接收到的 modules 就是这个样子:
var modules = ({
"./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _b__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./b */ "./src/b.js");
__webpack_require__.e(/*! import() */ 0)
.then(__webpack_require__.bind(null, /*! ./c.js */ "./src/c.js"))
.then(function (m) {return m.minus(2, 1);});
var A_NUM = 1;
var r = Object(_b__WEBPACK_IMPORTED_MODULE_0__["add"])(3, 2 + A_NUM);
console.log(r);
}),
"./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {})
});
- 取 ./src/a.js
// 2. modules[moduleId]
// 从上面可以看出 modules[./src/a.js] 得到的一个函数
function (module, __webpack_exports__, __webpack__require__) {
"use strict";
__webpack_require__.r(__webpack_exports__); // 利用上面的 __webpack_require\__.r 方法将该模块的 exports 对象上增加 __esModule 属性,定性为 ES6 module 对象
/* harmony import */ var _b__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./b */ "./src/b.js"); // 实现 import { add } from './b';
// 实现 import('b.js').then(m => m.minus(2, 1);
__webpack_require__.e(/*! import() */ 0)
.then(__webpack_require__.bind(null, /*! ./c.js */ "./src/c.js"))
.then(function (m) {return m.minus(2, 1);});
var A_NUM = 1;
var r = Object(_b__WEBPACK_IMPORTED_MODULE_0__["add"])(3, 2 + A_NUM);
console.log(r);
}
- 和 src/a.js 源码对比一下
import { add } from './b';
import('./c.js').then(m => m.minus(2, 1));
const A_NUM = 1;
let r = add(3, 2 + A_NUM);
console.log(r);
我们可以轻松发现 webpack 都对代码做了些什么:
1)给模块包了一层函数 function (module, __webpack_exports_, __webpack_require_) {};这么做则是为了实现模块化,用闭包做隔离, webpack 自己实现了一个 CommonJS 规范;
2)利用上面的 __webpack_require__.r 方法将该模块的 exports 对象上增加 __esModule 属性,定性为 ES6 module 对象;看看 .r 方法(上面 4.2 runtime 概览(10)):
__webpack_require__.r = function (exports) {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
}
Object.defineProperty(exports, '__esModule', { value: true })
}
3)实现 import { add } from './b'
; 导入 b 模块上的 add 方法,我们发现 import
关键字被 __webpack_require__
实现了,我们发现源码中的 add() 调用被处理成了 _b__WEBPACK_IMPORTED_MODULE_0__['add']
,这说明 add 这个方法被放到了 _b__WEBPACK_IMPORTED_MODULE_0__
对象上,那么这里思考一下,b 模块的导出怎么去了这个对象上的呢?
4)实现 import('c.js').then(m => m.minus(2, 1)
; import(c.js) 变成了 __webpack_require\__.e(/*! import() */ 0)
,而且增加了一个 then(__webpack_require\__.bind(null, 'c.js''))
这又是在搞什么呢?这个后面揭晓;
5)调用得到的 add 方法,忽略
6)log 输出,忽略
- 我们看看 webpack 处理后的 b.js 的样子:
b.js 同样在 modules 中,如下:
// moduels
var modules = ({
"./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {}),
"./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SOME_VAR", function() { return SOME_VAR; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "add", function() { return add; });
var SOME_VAR = 'SOME_VAR';
function add(a, b) {
return a + b;
}
})
})
对比一下 b.js 的源码
export const SOME_VAR = 'SOME_VAR'
export function add(a, b) {
return a + b
}
我们可以轻易看出 webpack 对 b.js 做了些什么:
1)__webpack_require\__.r
标识该模块是一个 ES6 的模块;
2)实现 export const SOME_VAR = 'SOME_VAR'
;我们发现 webpack 是通过 __webpack_require__.d 实现的 export 关键字,即导出,接着我们看 .d 方法(4.2 runtime 概览(9));d 方法就是通过在 module.exports 对象(_webpack_require_ 创建的)配置 getter 实现 export;这么做的好处在哪里?这种 export 一个变量(包括函数),好处是这个 getter 访问的都是原来模块作用域中的变量,自动连接到模块的作用域,若此设计不可谓不机智;
__webpack_require__.d = function (exports, name, getter) {
// __webpack_require__.o 是 hasOwnProperty 方法,判断是否有私有属性的
if (!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, { enumerable: true, get: getter })
}
}
- 简单回顾一下,webpack 怎么让代码跑起来的?
1)webpack 有自己的runtime,其中的 _webpack_require_ 可以创建、缓存 module,然后加载并运行 module。在 runtime 的末尾传入入口模块的路径并执行 _webpack_require_ 方法;
2)webpack 的 runtime 接受到了一个 modules 对象,其中 key 是模块路径,value 则是被 webpack 处理过的函数,这个函数就是模块主体;
3)webpack 通过 __webpack_require_.r 定义 ES6 模块,通多 __webpack_require_.d 方法实现 export,通过 _webpack_require_ 实现 import 等措施实现了一个 CommonJS 规范的模块系统;
5. 非入口 chunk 的加载
5.1 细说 webpack_require.e 方法
就说说 webpack 是如何加载非入口 chunk,非入口 chunk 的加载需要 __webpack_require__.e 方法。在前面的 demo 中,a.js 源码中通过 import(c.js) 语法实现按需加载,被按需加载的模块会以一个单独的 chunk 生成一个文件 ———— 0.a619de3d.js;在看这个 chunk 之前,我们先看看 a.js 又是如何实现的 import() 语法:
var modules = {
"./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {
// ...
// 这里就是实现的 import(c.js).then((m) => ....)
__webpack_require__.e(/*! import() */ 0)
.then(__webpack_require__.bind(null, /*! ./c.js */ "./src/c.js"))
.then(function (m) {
return m.minus(2, 1);
});
}),
}
从上面的代码可以看出,__webpack_require_.e(/*! import() */ 0).then(__webpack_require_.bind(null, /*! ./c.js */ "./src/c.js"))
实现的 import(c.js)
;接下来就该看看 e 方法的真实面目:
__webpack_require__.e = function requireEnsure(chunkId) {
// 1. 参数 chunkId: 用于加载的 chunk 的 chunkId,从上面调用该方法中可以看出要的加载 c.js 的对应的 chunkId 是 0,这个 chunkId 是 webpack 生成的,这里不需要关心;细心的朋友发现 chunk 文件除了 chunkId 0 后面还有 hash,这个 hash 哪里来的呢?这个后面下面揭晓
// 2. promise 队列,可以加载多个 chunk;
var promises = [];
// 3. 尝试从已经安装过的 chunk 中取用参数 chunkId,这个 installedChunks 的作用在上文 4.2 runtime 概览(3)
var installedChunkData = installedChunks[chunkId];
if(installedChunkData !== 0) { // 这里判断非0,因为 0 表示已经安装过了
// 如果从 installedChunks 中取到的 installedChunkData 是个 promise 则说明正在加载这个 chunk;
if(installedChunkData) {
// 这里有个精巧的设计,回到上面 4.2 runtime 概览(3)中关于 installedChunk 的 value 的详述中,
// 只有一种情况不是 falsy 的值,其他的value如 0,undefined、null 都是 falsy,
// 所以这里他敢判断 if(installedChunData) 而不用做细致判断
// 就一个字,绝
promises.push(installedChunkData[2]);
} else {
// else 说明这里就说明都是些 falsy 的值,说明该去乖乖的加载这个 chunkId 对应的 chunk 文件
// 这里就要回达 4.2 runtime 概览(3)中为啥 installedChunks 的 value 有一种情况是一个数组:
// [Promise resolveFn, Promise, rejectFn, promise]
// 就是从 这个 __webpack_require__.e 方法的这里创建的,就在下面的这几行代码
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject]; //就是这里给数组中加上 resolve, reject~~~
});
promises.push(installedChunkData[2] = promise); // 这里给数组加上 promise 对象
// 所谓 JSONP 就是新建个 script 标签去加载这个 chunkId 对应的文件(你是不是想说:就这?。。。jq 直呼内行)
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 属性赋值,浏览器就会去发起一个 get 请求去加载 src 对应的资源;
// 还记得这个方法开头我们说只有 chunkId 0,还有 hash 来着,hash 从哪儿来?这里就是在 jsonpScriptSrc 方法里面拼接的;
script.src = jsonpScriptSrc(chunkId);
// create error before stack unwound to get useful stacktrace later
var error = new Error();
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;
}
};
// 人肉处理超时, 120s 后手动超时
var timeout = setTimeout(function(){
onScriptComplete({ type: 'timeout', target: script });
}, 120000);
script.onerror = script.onload = onScriptComplete;
// 把 script 标签插入到 dom 中
document.head.appendChild(script);
}
}
// 调用 Promise.all() 等所有被加载的 chunk 都完成了才会 resolve
// 最后返回的是一个 promise
return Promise.all(promises);
};
总结一下上面这段异步按需加载模块的操作:
1)首先源码中 import(c.js).then 被 webpack 处理成了 __webpack_require__.e(chunkId).then(__webpack_require__.bind(null, c.js))
2)__webpack_require__.e 则会通过 JSONP 加载对应的 chunk 并执行,返回加载 chunk 时创建的 promise 对象;
3)上面 1)中的 then 的回调 __webpack_require\__.bind(null, c.js)
接着执行,这个回调的作用其实就是调用 __webpack_require\__
去加载 c.js;
4)接着上面的从 chunk 加载按需加载的 c.js
,紧接着再次调用 then 方法,这个 then 方法中的回调才会真正调用 c.js 中的方法,到这里被按需加载的 c.js 中的方法完成调用;
你以为到这里就结束了是么,怎(我)么(也)可(想)能(阿)?
我们来思考一个问题:__webpack_require__
是从 modules
对象或者 installedModules
缓存中获取模块的,那么这个 chunk
代码做了什么,runtime
又做了什么,才使得 then(__webpack_require__.bind(null, c.js))
取到预期的方法呢?
这个问题的答案要着眼于两方面:
- chunk 里面的代码;
- runtime 中 JSONP 处理;
5.2 chunk 概览及 JSONP
1、chunk 中的代码
// 1. 确保 window[webpackJsonp] 这个数组的存在,如果没有就赋值一个,有的话就取用
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0],{
"./src/c.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "minus", function() { return minus; });
Promise.resolve(/*! import() */).then(__webpack_require__.bind(null, /*! ./b.js */ "./src/b.js")).then(function (m) {
return m.add(200, 100);
});
function minus(a, b) {
return a - b;
}
})
}]);
// 2. 接着就是向 window[webpackJsonp] 这个数组中 push 了一项,这里需要重点关注一下这个数组的 push 方法和被 push 的数据结构:
// 2.1 这个 push 方法不一定是数组原生的 push 方法(Array.prototype.push),这一点在后面的 runtime 处详述;
// 2.2 这个数组项是个数组,数组第一项又是个数组 [0], 第二项是个对象,对象的结构和 modules 相同,key 是资源路径,value 是个webpack 包装函数:
// 所以 window[webpackJsonp] 的大致结构:[[[0], { ./src/c.js: (function(module, __....) {}) }]]
2、runtime 中的 JSONP 处理
在上面 4.2 runtime 概览(16)
这个小标题,说到了 JSONP 初始化,看看这段代码:
(function (modules) {
// ... webpack runtime 代码
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; // 初始化 window[webpackJsonp],如果没有就初始化
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 暂存数组 push 方法,这个 push 就是 Array.prototype.push
jsonpArray.push = webpackJsonpCallback; // 重写 jsonpArray.push 方法(注意,这么重写不会改写数组原型)
jsonpArray = jsonpArray.slice(); // 赋值 jsonpArrray,这个复制不带 push 方法!
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); // 本例子中,执行到这里时,jsonpArray 中是空的,所以这个 for 循环暂时不执行;若chunk先于 bundle 这个入口加载,这个 jsonpArray 里面就不是空的,此时,遍历并调用 webpackJsonpCallback,相当于手动触发 jsonp callback;
var parentJsonpFunction = oldJsonpFunction; // 旧 push 暂存于 parentJsonpFunciton
// ... other runtime processing
})
这里有必要强调一点,在本文对应的 demo 中,runtime 所在的入口 bundle 的执行顺序是先于后面按需加载的 chunk 的。所以执行过这段代码后,window[webpackJsonp]
已经初始化,同时 jsonpArray
的 push
方法被改写成了 webpackJsonpCallback
方法;
所以,等到后面 chunk 被加载回来后,执行 chunk 代码:(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0],{..
时,执行的这个 push 其实是 webpackJsonpCallback
方法。
你一定在想,为什么会有这个操作?对于我这个搬砖的人来说,只能说:绝!
其实他改写 push 是配合着下面的 for 循环用的,目的在于不管是入口 bundle 先下载还是 chunk 先加载,都能让代码得到正确的执行。先加载 bundle,就是咱们上面一直说的情况,这个不多说,chunk 的 push 方法就是 webpackJsonpCallback 方法,调用 push 就是调用 webpackJsonpCallback。
如果先加载 chunk(例如某些预加载之类的技术下),此时 chunk 中的代码先执行了,这个时候 chunk 会初始化 window[webpackJsonp]
这个数组,而 push 方法就是原生的数组方法 push,很朴素的把数组项添加到 window[webpackJsonp]
数组中,chunk 的使命暂时完成了;接下就等 bundle 加载并执行 runtime 了,等到执行到上面的 runtime 的时候,window[webpackJsonp]
数组已经被初始化,所以后面对 jsonpArray 的 for 循环就可以工作了,这个时候,对 window[webpackJsonp]
中的每一项调用一次 webpackJsonpCallback
;
这就保证了,不管 chunk 何时加载,都能让代码以预期的方式工作。绝,连城诀!接下来看看这个 webpackJsonpCallback 做了些什么吧:
function webpackJsonpCallback(data) {
// 1. 这个 data 就是上面说 chunk 代码的时 push 的数组项,结构类似:[[[0], { ./src/c.js: (function(module, __....) {}) }]]
var chunkIds = data[0]; // chunkIds 就是上面的 [0]
var moreModules = data[1]; // moreModules 就是上面的 { ./src/c.js: (fun....) } 这个对象
// 2. 遍历 chunkIds,如果在 installedChunks 是个非 falsy 的值,说明这个 chunkId 对应的 chunk 正在加载了,
// 把它的 resolve push 到 resolves 这个数组(注意:installedChunks 中若 chunkId 所对应 chunk 正在加载,其value [Promise resolveFn, Promise rejectFn, promsie ])
// 把 installedChunks[chunkId] 的值修改为 0,标识该 chunk 已经被加载过!
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;
}
// 这里回答:runtime 做了啥,才能让 chunk 代码里面的 module 可以被 runtime 中的 __webpack_require__ 获取到 的问题:
// 是因为在这里会把 chunk 中所携带的 module 整合到 webpack runtime 接收到 modules 对象中,在下一个事件循环中 __webpack_require__ 从 modules 中取自然就可以取到了
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
// 3. 这一步就是用来保证 bundle 先执行的时候,由于 runtime 执行,
// 数组 push 被改写成 webpackJsonpCallback 后,chunk 加载后,能够正常的把 chunk 相关信息添加到 window[webpackJsonp] 这个数组中
if(parentJsonpFunction) parentJsonpFunction(data);
// 4. resolves 中存放的都是加载 chunk时 __webpack_require__.e 方法创建的特殊数组项:
// (形如[Promise resolve, Promise reject, promise])的第 0 项,即 resolve,
// 它决定了 __webpack_require__.e 方法最后 return 出去的 Promise.all(promise) 的 promise 对象是否 resolve,
// 进而决定了编译后的 a.js 模块中: __wepack_require__.e(0).then(__webpack_require__.bind(c.js)).then(...) 的执行
// 如果 resolves 不为空,则清空这个队列,使得 __webpack_require__.e 方法最后 return 出去的 Promise.all(promise) promise 得以 resolve
while(resolves.length) {
resolves.shift()();
}
};
6. splitChunks 之后的输出文件
6.1 修改项目代码
- 修改 webpack.config.js 追加 splitChunk 配置(optimization.splitChunks):
var path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
m1: './src/m1.js',
m2: './src/m2.js'
},
devtool: 'none',
output: {
path: __dirname + '/dist',
filename: '[name].[chunkhash:4].js',
chunkFilename: '[name].[chunkhash:8].js'
},
mode: 'development',
plugins: [new HtmlWebpackPlugin()],
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/env']
}
}
]
}
]
},
optimization: {
splitChunks: {
chunks: 'all',
minSize: 0,
maxSize: 0,
minChunks: 1,
name: true,
automaticNameDelimiter: '~',
cacheGroups: {
default: {
chunks: 'all',
minChunks: 2,
priority: -10
}
}
},
// runtimeChunk: { name: 'runtime' }
}
};
- 在 src 下追加两个文件m1.js 、m2.js,作为多入口:
1、 m1.js
import { times } from './d';
console.log(times(10, 4));
2、 m2.js
import { times } from './d';
console.log(times(2, 12));
6.2 打包输出文件
这两个入口都依赖了 d.js
这个模块,这个模块将会被拆成一个单独的 chunk —— dd~m1~m2.a7ba197d.js
;下列就是修改配置后的文件输出:
- index.html
- m1.33c1.js
- m2.50e9.js
- dd
m1m2.a7ba197d.js
我们使用的 html-webpack-plugin,它会帮我你把 js 按顺序插入到 html 文件里;这里需要先看下 index.html 代码,关注一下这些 js 被引入的顺序;
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Webpack App</title>
</head>
<body>
<script type="text/javascript" src="dd~m1~m2.a7ba197d.js"></script>
<script type="text/javascript" src="m1.33c1.js"></script>
<script type="text/javascript" src="m2.50e9.js"></script>
</body>
</html>
很明显的看出这个公共的 chunk 被最先引入,代码执行的时候也是先去加载并执行它;这和我们前面的例子略有区别,主要体现在:
-
chunk 被优先引入,而入口 bundle 被后置;
-
在新生成的 bundle m1.xxx.js 和 m2.xxx.js 的 runtime 的最后不再返回
__webpack_require__(入口)
,而是返回了checkDeferredModules([m1.33c1.js, dd~m1....js])
; -
runtime 新增
checkDeferredModules
方法,同时 webpackJsonpCallback 中也作出了相应的调整,在末尾增加了两行代码:
deferredModules.push.apply(deferredModules, executeModules || []);
return checkDeferredModules();
6.3 执行过程分析
1、首先加载并执行 dd~m1~m2.a7ba197d.js
文件,代码如下:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["dd~m1~m2"],{
"./src/d.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "L", function() { return L; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "times", function() { return times; });
var L = 'Aragaki Yui';
function times(a, b) {
return a * b;
}
})
}]);
分析发现,这个 chunk 和前面例子中按需加载的 chunk 没有特别大的差别,仍然是通过向 window[webpackJsonp]
中添加数组项;值得强调的是,这里因为 chunk 先于入口加载并执行,所以这个 window[webpackJsonp]
是在 chunk 中初始化,同时这个 push
方法也是数组原生的 push
方法;
2、加载入口文件 m1.33c1.js
,代码如下:
我们简化了这个入口文件,把注释和与前面未进行 splitChunks 相同的方法和变量删除掉,我们聚焦于不同点:
- 变量 deferredModules
- webpackJsonpCallback 方法
- checkDeferredModules 方法
(function(modules) { // webpackBootstrap
// install a JSONP callback for chunk loading
function webpackJsonpCallback(data) {
// ... 同前 webpackJsonpCallback 逻辑,略去
var executeModules = data[2];
// 这一行代码目前还没看出啥作用,看起来是这个 webpackJsonpCallback 被调用时,比如 window[webpackJsonp].push 或者
// webpackJsonpCallback(jsonpArray[i]) 的时候可以传入一个 defferedModule 项;
// webpack 的原文注释字面意思是"从已经加载过 chunk 中把 entry modules 加入到 deferredModules"
deferredModules.push.apply(deferredModules, executeModules || []);
// webpackJsonpCallback 是加载 chunk 后执行的,意在每次加载 chunk 后都 check 一下 defrredModules 中的依赖们是否加载完了,加载完了就调用入口 module 执行;
// 这样有个好处就是不管加载入口还是先加载依赖,最后的代码执行顺序都能得正确执行;
return checkDeferredModules();
};
function checkDeferredModules() {
// 方法作用:
// deferredModules 中的每一项都是一个数组形如:[入口 chunk1, 依赖chunk2, 依赖 chunk3...]
// checkDeferredModules 的作用就是检查 defferredModules 中的每一项,的依赖部分(从第二项开始)是否都已经安装完成(installedChunks[chunkIed] === 0)
// 如果都安装完成了,则调用数组项的入口 chunk
// 变量 result,用于接收入口 chunk 执行后的结果,最后返回它
var result;
// 遍历 deferredModules,这个 deferredModules 可以有很多项,所以需要遍历一下
for(var i = 0; i < deferredModules.length; i++) {
var deferredModule = deferredModules[i]; // defferredModule 形如: [入口 chunk1, 依赖chunk2, 依赖 chunk3...]
var fulfilled = true; // 标识符,标识所有的依赖 chunk 都已经被安装
for(var j = 1; j < deferredModule.length; j++) {
var depId = deferredModule[j];
if(installedChunks[depId] !== 0) fulfilled = false; // installedChunks 中对应这一项的值不为 0 说明没有安装完,安装完这个值就是 0 了,关于 installedChunks 在上文 4.2 runtime 概览(3、installedChunks)
}
// 经历了上面的 for 循环,如果 fulfilled 还是 true 说明所有的依赖 chunk 都已经加载完成了,就可以执行入口 chunk 了
if(fulfilled) {
// 此时从 deferredModules(注意有 s,不是 deferredMdoule) 中删除掉这一项,因为这一项已经 check 过了,
// 下面一行代码立刻就会执行这一项的入口,所以 checkDeferredModules 对它的工作已经完成了;
// 如果不删会怎样?后面只要有新的入口,比如 m2.js,添加到 defferedModules 数组就会触发重新 check,如果不删除就会让已经执行过 chunk 再执行一遍,相当于同一段逻辑执行两次;
deferredModules.splice(i--, 1);
// __webpack_require__ 调用入口模块,并将其返回结果赋值给 result
result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
}
}
// 开开心心拿着入口 module 返回结果返回
return result;
}
// 这个变量时因为 slitChunks 多出来的,这个变量是一个数组,其第一项表示要加载的 module,从第二项开始后面的都是第一项依赖的模块;
// 从这个例子上来说,第一项是入口 chunk(例如本例中的 m1.xxx.js),后面的是入口依赖的 chunk(如 dd~m1~m2.....js)
// 现在这个数组是空的,什么时候里面才会值呢?在 runtime 结尾处,会把入口 ./src/m1.js 和 依赖的 dd~m1~m2 push 进去;
var deferredModules = [];
function __webpack_require__(moduleId) { /* 同前 __webpack_require__ 略去 */ }
// JSONP 初始化.....
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;
// 这里!把入口 ./src/m1.js 和 依赖的 chunk pushu 到 deferredModules 中;
// 紧接着调用 checkDeferredModules 方法,接着去这个方法里面看看这个方法都发生了啥
deferredModules.push(["./src/m1.js","dd~m1~m2"]);
return checkDeferredModules();
})
/************************************************************************/
({
// 这个对象也是 modules
"./src/m1.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _d__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./d */ "./src/d.js");
console.log(Object(_d__WEBPACK_IMPORTED_MODULE_0__["times"])(10, 4));
})
});
另一个入口 m2.js 同理,在此不再赘述。
- TIPS: 关于先加载 chunk 还是先加载入口,可以去
index.html
中调整一下 chunk 和 入口的 script 标签顺序,就可以发先控制台的输出仍然是正常的;
7. 总结
本篇详述了 webpack 打包输出文件,又赏析了他设计的精妙:
1)巧妙实现 CMD 规范 __webpack_require__
,__webpack_require__.d
,shim 浏览器的模块系统;
2)通过 runtime 的 改写 push
方法 并结合 for 循环调用 webpackJsonpCallback 实现无论入口和chunk的顺序如何,最后代码都能顺利执行
3)webpack 实现细节的精妙,比如 installedChunked 的数据结构的设计,只有一种正在加载的是数组,其余都是 falsy 值;
4)本篇篇幅有点长,建议阅读 2-3 遍;
5)码字不容易,看完了,点个赞呗