说点什么
最近在做一个工程化强相关的项目-微前端,涉及到了基座项目和子项目加载,并存的问题;以前对webpack一直停留在配置,也就是常说的入门级。这次项目推动,自己不得不迈过门槛,往里面多看一点。
本文主要讲webpack构建后的文件,是怎么在浏览器运行起来的,这可以让我们更清楚明白webpack的构建原理。
文章中的代码基本只含核心部分,如果想看全部代码和webpack配置,可以关注工程,自己拷贝下来运行: demo地址
在读本文前,需要知道webpack的基础概念,知道chunk 和 module的区别
本文将循序渐进,来解析webpack打包后的文件,代码是怎么跑起来的,从以下三个步骤娓娓道来:
- 单文件打包,从IIFE说起;
- 多文件之间,怎么判断依赖的加载状态;
- 按需加载的背后,黑盒中究竟有什么黑魔法;
从最简单的说起:单文件怎么跑起来的
最简单的打包场景是什么呢,就是打包出来html文件只引用一个js文件,项目就可以跑起来,举个:
// 入口文件:index.js
import sayHello from './utils/hello';
import { util } from './utils/util';
console.log('hello word:', sayHello());
console.log('hello util:', util);
// 关联模块:utils/util.js
export const util = 'hello utils';
// 关联模块:utils/hello.js
import { util } from './util';
console.log('hello util:', util);
const hello = 'Hello';
export default function sayHello() {
console.log('the output is:');
return hello;
};
入门级的代码,简单来讲就是入口文件依赖了两个模块: util 与 hello,然后模块hello,又依赖了util,最后运行html文件,可以在控制台看到console打印。打包后的代码长什么样呢,看下面,删除了一些干扰代码,只保留了核心部分,加了注释,但还是较长,需要耐心:
(function(modules) { // webpackBootstrap
// 安装过的模块的缓存
var installedModules = {};
// 模块导入方法
function __webpack_require__(moduleId) {
// 安装过的模块,直接取缓存
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 没有安装过的话,那就需要执行模块加载
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// 上面说的加载,其实就是执行模块,把模块的导出挂载到exports对象上;
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 标识模块已加载过
module.l = true;
// Return the exports of the module
return module.exports;
}
// 暴露入口输入模块;
__webpack_require__.m = modules;
// 暴露已经加载过的模块;
__webpack_require__.c = installedModules;
// 模块导出定义方法
// eg: export const hello = 'Hello world';
// 得到: exprots.hello = 'Hello world';
__webpack_require__.d = function (exports, name, getter) {
if (!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, {
enumerable: true,
get: getter
});
}
};
// __webpack_public_path__
__webpack_require__.p = '';
// 从入口文件开始启动
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
"./webpack/src/index.js":
/*! no exports provided */
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
var _utils_hello__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils/hello */ "./webpack/src/utils/hello.js");
var _utils_util__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils/util */ "./webpack/src/utils/util.js");
console.log('hello word:', Object(_utils_hello__WEBPACK_IMPORTED_MODULE_0__["default"])());
console.log('hello util:', _utils_util__WEBPACK_IMPORTED_MODULE_1__["util"]);
}),
"./webpack/src/utils/hello.js":
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, "default", function() { return sayHello; });
var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util */ "./webpack/src/utils/util.js");
console.log('hello util:', _util__WEBPACK_IMPORTED_MODULE_0__["util"]);
var hello = 'Hello';
function sayHello() {
console.log('the output is:');
return hello;
}
}),
"./webpack/src/utils/util.js":
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, "util", function() { return util; });
var util = 'hello utils';
})
});
咋眼一看上面的打包结果,其实就是一个IIFE(立即执行函数),这个函数
就是webpack
的启动代码,里面包含了一些变量方法声明;而输入
是一个对象,这个对象描述的就是我们代码中编写的文件,文件路径为对面key,value就是文件中定义的代码,但这个代码是被一个函数包裹的:
/**
* module: 就是当前模块
* __webpack_exports__: 就是当前模块的导出,即module.exports
* __webpack_require__: webpack加载器对象,提供了依赖加载,模块定义等能力
**/
function(module, __webpack_exports__, __webpack_require__) {
// 文件定义的代码
}
加载的原理,在上面代码中已经做过注释了,耐心点,一分钟就明白了,还是加个图吧,在vscode中用drawio插件画的,感受一下:
除了上面的加载过程,再说一个细节,就是webpack怎么分辨依赖包是ESM还是CommonJs模块,还是看打包代码吧,上面输入模块在开头都会执行__webpack_require__.r(__webpack_exports__)
, 省略了这个方法的定义,这里补充一下,解析看代码注释:
// 定义模块类型是__esModule, 保证模块能被其他模块正确导入,
__webpack_require__.r = function (exports) {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module'
});
}
// 模块上定义__esModule属性, __webpack_require__.n方法会用到
// 对于ES6 MOdule,import a from 'a'; 获取到的是:a[default];
// 对于cmd, import a from 'a';获取到的是整个module
Object.defineProperty(exports, '__esModule', {
value: true
});
};
// esModule 获取的是module中的default,而commonJs获取的是全部module
__webpack_require__.n = function (module) {
var getter = module && module.__esModule ?
function getDefault() {
return module['default'];
} :
function getModuleExports() {
return module;
};
// 为什么要在这个方法上定义一个 a 属性? 看打包后的代码, 比如:在引用三方时
// 使用import m from 'm', 然后调用m.func();
// 打出来的代码都是,获取模块m后,最后执行时是: m.a.func();
__webpack_require__.d(getter, 'a', getter);
return getter;
};
最常见的:多文件引入的怎么执行
看完最简单的,现在来看一个最常见的,引入splitChunks,多chunk构建,执行流程有什么改变。我们常常会将一些外部依赖打成一个js包,项目自己的资源打成一个js包;
还是刚刚的节奏,先看打包前的代码:
// 入口文件:index.js
+ import moment from 'moment';
+ import cookie from 'js-cookie';
import sayHello from './utils/hello';
import { util } from './utils/util';
console.log('hello word:', sayHello());
console.log('hello util:', util);
+ console.log('time', moment().format('YYYY-MM-DD'));
+ cookie.set('page', 'index');
// 关联模块:utils/util.js
+ import moment from 'moment';
export const util = 'hello utils';
export function format() {
return moment().format('YYYY-MM-DD');
}
// 关联模块:utils/hello.js
// 没变,和上面一样
从上面代码可以看出,我们引入了moment与js-cookie两个外部JS包,并采用分包机制,将依赖node_modules中的包打成了一个单独的,下面是多chunk打包后的html文件截图:
再看看async.js 包长什么样:
// 伪代码,隐藏了 moment 和 js-cookie 的代码细节
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["async"],{
"./node_modules/js-cookie/src/js.cookie.js": (function(module, exports, __webpack_require__) {}),
"./node_modules/moment/moment.js": (function(module, exports, __webpack_require__) {})
})
咋一样看,这个代码甚是简单,就是一个数组push操作,push的元素是一个数组[["async"],{}]
, 先提前说一下,数组第一个元素数组,是这个文件包含的chunk name
, 第二个元素对象,其实就和第一节简单文件打包的输入一样,是模块名和包装后的模块代码;
再看一下index.js 的变化:
(function(modules) { // webpackBootstrap
// 新增
function webpackJsonpCallback(data) {
return checkDeferredModules();
};
function checkDeferredModules() {
}
// 缓存加载过的模块
var installedModules = {};
// 存储 chunk 的加载状态
// undefined = chunk not loaded, null = chunk preloaded/prefetched
// Promise = chunk loading, 0 = chunk loaded
var installedChunks = {
"index": 0
};
var deferredModules = [];
// on error function for async loading
__webpack_require__.oe = function(err) { console.error(err); throw err; };
// 加载的关键
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;
// 从入口文件开始启动
- return __webpack_require__(__webpack_require__.s = "./src/index.js");
// 将入口加入依赖延迟加载的队列
+ deferredModules.push(["./webpack/src/index.js","async"]);
// 检查可执行的入口
+ return checkDeferredModules();
})
({
// 省略;
})
从上面的代码看,支持多chunk执行,webpack 的bootstrap,还是做了很多工作的,我这大概列一下:
- 新增了
checkDeferredModules
,用于依赖chunk检查是否已准备好; - 新增
webpackJsonp
全局数组,用于文件间的通信与模块存储;通信是通过拦截push
操作完成的; - 新增webpackJsonpCallback,作为拦截
push的代理
操作,也是整个实现的核心; - 修改了
入口文件
执行方式,依赖deferredModules实现;
这里面文章很多,我们来一一破解:
### webpackJsonp push 拦截
// 检查window["webpackJsonp"]数组是否已声明,如果未声明的话,声明一个;
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
// 对webpackJsonp原生的push操作做缓存
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
// 使用开头定义的webpackJsonpCallback作为代码,即代码中执行indow["webpackJsonp"].push时会触发这个操作
jsonpArray.push = webpackJsonpCallback;
// 这不操作,其实就是jsonpArray开始是window["webpackJsonp"]的快捷操作,现在我们对她的操作已完,就断开了这个引用,但值还是要,用于后面遍历
jsonpArray = jsonpArray.slice();
// 这一步,其实要知道他的场景,才知道他的意义,如果光看代码,觉得这个数组刚声明,遍历有什么用;
// 其实这里是在依赖的chunk 先加载完的情况,但拦截代理当时还没生效;所以手动遍历一次,让已加载的模块再走一次代理操作;
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
// 这个操作就是个赋值语句,意义不大;
var parentJsonpFunction = oldJsonpFunction;
直接写上面注释了,webpackJsonpCallback在后面会解密。
代理 webpackJsonpCallback 干了什么
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];
var executeModules = data[2];
// add "moreModules" to the modules object,
var moduleId, chunkId, i = 0, resolves = [];
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
// 下一节再讲
installedChunks[chunkId] = 0;
}
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
// 将其他chunk中的模块加入到主chunk中;
modules[moduleId] = moreModules[moduleId];
}
}
// 这里才是原始的push操作
if(parentJsonpFunction) parentJsonpFunction(data);
while(resolves.length) {
// 下一节再讲
}
// 这一句在这里没什么用
deferredModules.push.apply(deferredModules, executeModules || []);
// run deferred modules when all chunks ready
return checkDeferredModules();
};
还记得前面push的数据是什么格式吗:
window["webpackJsonp"].push([["async"], moreModules])
拦截了push操作后,其实就做了三件事:
- 将数组第二个变量 moreModules 加入到index.js 立即执行函数的输入变量modules中;
- 将这个chunk的加载状态置成已完成;
- 然后checkDeferredModules,就是看这个依赖加载后,是否有模块在等这个依赖执行;
checkDeferredModules 干了什么
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++) {
// depId, 即指依赖的chunk的ID,,对于入口‘./webpack/src/index.js’这个deferredModule,depId就是‘async’,等async模块加载后就可以执行了
var depId = deferredModule[j];
if(installedChunks[depId] !== 0) fulfilled = false;
}
if(fulfilled) {
// 执行过了,就把这个延迟执行项移除;
deferredModules.splice(i--, 1);
// 执行./webpack/src/index.js模块
result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
}
}
return result;
}
还记得入口文件的执行替换成了: deferredModules.push(["./webpack/src/index.js","async"])
, 然后执行checkDeferredModules。
这个函数,就是检查哪些chunk安装了,但有些module执行,需要依赖某些
chunk,等依赖的chunk加载了,再执行这个module。上面的那一句代码就是./webpack/src/index.js
这个模块执行依赖async这个chunk。
小总结
到这里,似乎多chunk打包,文件的执行流程就算理清楚了,如果你能想明白在html中下面两种方式,都不会导致文件执行失败,你就真的明白了:
按需加载:动态加载过程解析
等多包加载理清后,再看按需加载,就没有那么复杂了,因为很多实现是在多包加载的基础上完成的,为了让理论更清晰,我添加了两处按需加载,还是那个节奏:
// 入口文件,index.js, 只列出新增代码
let count = 0;
const clickButton = document.createElement('button');
const name = document.createTextNode("CLICK ME");
clickButton.appendChild(name);
document.body.appendChild(clickButton);
clickButton.addEventListener('click', () => {
count++;
import('./utils/math').then(modules => {
console.log('modules', modules);
});
if (count > 2) {
import('./utils/fire').then(({ default: fire }) => {
fire();
});
}
})
// utils/fire
export default function fire() {
console.log('you are fired');
}
// utils/math
export default function add(a, b) {
return a + b;
}
代码很简单,就是在页面添加了一个按钮,当按钮被点击时,按需加载utils/math
模块,并打印输出的模块;当点击次数大于两次时,按需加载utils/fire
模块,并调用其中暴露出的fire函数。相对于上一次,会多打出两个js 文件:0.bundle_29180b93.js 与 1.bundle_42bc336c.js,这里就列其中一个的代码:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[1],{
"./webpack/src/utils/math.js":
(function(module, __webpack_exports__, __webpack_require__) {})
}]);
格式与上面的async chunk 格式一模一样。
然后再来看index.js 打包完,新增了哪些:
(function(modules) {
// script url 计算方法。下面的两个hash 是否似曾相识,对,就是两个按需加载文件的hash值
// 传入0,返回的就是0.bundle_29180b93.js这个文件名
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + ".bundle_" + {"0":"29180b93","1":"42bc336c"}[chunkId] + ".js"
}
// 按需加载script 方法
__webpack_require__.e = function requireEnsure(chunkId) {
// 后面详讲
};
})({
"./webpack/src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
// 只列出按需加载utils/fire.js的代码
__webpack_require__.e(/*! import() */ 0)
.then(__webpack_require__.bind(null, "./webpack/src/utils/fire.js"))
.then(function (_ref) {
var fire = _ref["default"];
fire();
});
}
})
在上一节的接触上,只加了很少的代码,主要涉及到两个方法jsonpScriptSrc
与 requireEnsure
,前者在注释里已经写得很清楚了,后者其实就是动态创建script标签,动态加载需要的js文件,并返回一个Promise
,来看一下代码:
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
var installedChunkData = installedChunks[chunkId];
// 0 意为着已加载.
if(installedChunkData !== 0) {
// a Promise means "currently loading": 意外着,已经在加载中
// 需要把加载那个promise:(即下面new的promise)加入到当前的依赖项中;
if(installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// setup Promise in chunk cache:new 一个promise
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
// 这里将promise本身记录到installedChunkData,就是以防上面多个chunk同时依赖一个script的时候
promises.push(installedChunkData[2] = promise);
// 下面都是动态加载script标签的常规操作
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);
// 下面的代码都是错误处理
var error = new Error();
onScriptComplete = function (event) {
// 错误处理
};
var timeout = setTimeout(function(){
onScriptComplete({ type: 'timeout', target: script });
}, 120000);
script.onerror = script.onload = onScriptComplete;
// 添加script到body
document.head.appendChild(script);
}
}
return Promise.all(promises);
};
相对来说requireEnsure的代码实现并没有多么特别,都是一些常规操作,但没有用常用的onload回调,而改用promise
来处理,还是比较巧妙的。模块是否已经加装好,还是利用前面的webpackJsonp的push代理来完成。
现在再来补充上面一节说留着下一节讲的代码:
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];
var executeModules = data[2];
var moduleId, chunkId, i = 0, resolves = [];
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
// installedChunks[chunkId] 在这里加载时,还是一个数组,元素分别是[resolve, reject, promise],这里取的是resolve回调;
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
// moreModules 注入忽略
while(resolves.length) {
// 这里resolve时,那么promise.all 就完成了
resolves.shift()();
}
}
}
所以上面的代码做的,还是利用了这个代理,在chunk加载完成时,来把刚刚产生的promise resolved
掉,这样按需加载的then就继续往下执行了,非常曲折的一个发布订阅。
总结
自此,对webpack打包后的代码执行过程就分析完了,由简入难,如果多一点耐心,还是比较容易就看懂的。毕竟wbepack的高深,是隐藏在webpack自身的插件系统中的,打出来的代码基本是ES5级别的,只是用了一些巧妙的方法,比如push的拦截代理。
如果有什么不清楚的,推荐clone项目,自己打包分析一下代码:demo地址: webpack项目