Open closertb opened 4 years ago
最近在做一个工程化强相关的项目-微前端,涉及到了基座项目和子项目加载,并存的问题;以前对webpack一直停留在配置,也就是常说的入门级。这次项目推动,自己不得不迈过门槛,往里面多看一点。
本文主要讲webpack构建后的文件,是怎么在浏览器运行起来的,这可以让我们更清楚明白webpack的构建原理。
文章中的代码基本只含核心部分,如果想看全部代码和webpack配置,可以关注工程,自己拷贝下来运行: demo地址:: webpack项目
在读本文前,需要知道webpack的基础概念,知道chunk 和 module的区别;
本文将循序渐进,来解析webpack打包后的代码是怎么在浏览器跑起来的。将从以下三个步骤解开黑盒:
最简单的打包场景是什么呢,就是打包出来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就是文件中定义的代码,但这个代码是被一个函数包裹的:
函数
webpack
输入
/** * module: 就是当前模块 * __webpack_exports__: 就是当前模块的导出,即module.exports * __webpack_require__: webpack加载器对象,提供了依赖加载,模块定义等能力 **/ function(module, __webpack_exports__, __webpack_require__) { // 文件定义的代码 }
加载的原理,在上面代码中已经做过注释了,耐心点,一分钟就明白了,还是加个图吧,在vscode中用drawio插件画的,感受一下:
除了上面的加载过程,再说一个细节,就是webpack怎么分辨依赖包是ESM还是CommonJs模块,还是看打包代码吧,上面输入模块在开头都会执行__webpack_require__.r(__webpack_exports__), 省略了这个方法的定义,这里补充一下,解析看代码注释:
__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, 第二个元素对象,其实就和第一节简单文件打包的输入一样,是模块名和包装后的模块代码;
[["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
webpackJsonp
拦截push
push的代理
修改了入口文件执行方式,依赖deferredModules实现;
入口文件
这里面文章很多,我们来一一破解:
// 检查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在后面会解密。
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操作后,其实就做了三件事:
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。
deferredModules.push(["./webpack/src/index.js","async"])
./webpack/src/index.js
到这里,似乎多chunk打包,文件的执行流程就算理清楚了,如果你能想明白在html中下面两种方式,都不会导致文件执行失败,你就真的明白了:
<!-- 依赖项在前加载 --> <script type="text/javascript" src="async.bundle_9b9adb70.js"></script> <script type="text/javascript" src="index.4f7fc812.js"></script> <!-- 或依赖项在后加载 --> <script type="text/javascript" src="index.4f7fc812.js"></script> <script type="text/javascript" src="async.bundle_9b9adb70.js"></script>
等多包加载理清后,再看按需加载,就没有那么复杂了,因为很多实现是在多包加载的基础上完成的,为了让理论更清晰,我添加了两处按需加载,还是那个节奏:
// 入口文件,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,这里就列其中一个的代码:
utils/math
utils/fire
(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,来看一下代码:
jsonpScriptSrc
requireEnsure
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代理来完成。
promise
现在再来补充上面一节说留着下一节讲的代码:
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就继续往下执行了,非常曲折的一个发布订阅。
promise resolved
自此,对webpack打包后的代码执行过程就分析完了,由简入难,如果多一点耐心,还是比较容易就看懂的。毕竟wbepack的高深,是隐藏在webpack自身的插件系统中的,打出来的代码基本是ES5级别的,只是用了一些巧妙的方法,比如push的拦截代理。
如果有什么不清楚的,推荐clone项目,自己打包分析一下代码:demo地址: webpack项目
说点什么
最近在做一个工程化强相关的项目-微前端,涉及到了基座项目和子项目加载,并存的问题;以前对webpack一直停留在配置,也就是常说的入门级。这次项目推动,自己不得不迈过门槛,往里面多看一点。
文章中的代码基本只含核心部分,如果想看全部代码和webpack配置,可以关注工程,自己拷贝下来运行: demo地址:: webpack项目
在读本文前,需要知道webpack的基础概念,知道chunk 和 module的区别;
本文将循序渐进,来解析webpack打包后的代码是怎么在浏览器跑起来的。将从以下三个步骤解开黑盒:
从最简单的说起:单文件怎么跑起来的
最简单的打包场景是什么呢,就是打包出来html文件只引用一个js文件,项目就可以跑起来,举个🌰:
入门级的代码,简单来讲就是入口文件依赖了两个模块: util 与 hello,然后模块hello,又依赖了util,最后运行html文件,可以在控制台看到console打印。打包后的代码长什么样呢,看下面,删除了一些干扰代码,只保留了核心部分,加了注释,但还是较长,需要耐心:
咋眼一看上面的打包结果,其实就是一个IIFE(立即执行函数),这个
函数
就是webpack
的启动代码,里面包含了一些变量方法声明;而输入
是一个对象,这个对象描述的就是我们代码中编写的文件,文件路径为对面key,value就是文件中定义的代码,但这个代码是被一个函数包裹的:加载的原理,在上面代码中已经做过注释了,耐心点,一分钟就明白了,还是加个图吧,在vscode中用drawio插件画的,感受一下:
除了上面的加载过程,再说一个细节,就是webpack怎么分辨依赖包是ESM还是CommonJs模块,还是看打包代码吧,上面输入模块在开头都会执行
__webpack_require__.r(__webpack_exports__)
, 省略了这个方法的定义,这里补充一下,解析看代码注释:最常见的:多文件引入的怎么执行
看完最简单的,现在来看一个最常见的,引入splitChunks,多chunk构建,执行流程有什么改变。我们常常会将一些外部依赖打成一个js包,项目自己的资源打成一个js包;
还是刚刚的节奏,先看打包前的代码:
从上面代码可以看出,我们引入了moment与js-cookie两个外部JS包,并采用分包机制,将依赖node_modules中的包打成了一个单独的,下面是多chunk打包后的html文件截图:
再看看async.js 包长什么样:
咋一样看,这个代码甚是简单,就是一个数组push操作,push的元素是一个数组
[["async"],{}]
, 先提前说一下,数组第一个元素数组,是这个文件包含的chunk name
, 第二个元素对象,其实就和第一节简单文件打包的输入一样,是模块名和包装后的模块代码;再看一下index.js 的变化:
从上面的代码看,支持多chunk执行,webpack 的bootstrap,还是做了很多工作的,我这大概列一下:
checkDeferredModules
,用于依赖chunk检查是否已准备好;webpackJsonp
全局数组,用于文件间的通信与模块存储;通信是通过拦截push
操作完成的;push的代理
操作,也是整个实现的核心;修改了
入口文件
执行方式,依赖deferredModules实现;这里面文章很多,我们来一一破解:
webpackJsonp push 拦截
直接写上面注释了,webpackJsonpCallback在后面会解密。
代理 webpackJsonpCallback 干了什么
还记得前面push的数据是什么格式吗:
拦截了push操作后,其实就做了三件事:
checkDeferredModules 干了什么
还记得入口文件的执行替换成了:
deferredModules.push(["./webpack/src/index.js","async"])
, 然后执行checkDeferredModules。 这个函数,就是检查哪些chunk安装了,但有些module执行,需要依赖某些 chunk,等依赖的chunk加载了,再执行这个module。上面的那一句代码就是./webpack/src/index.js
这个模块执行依赖async这个chunk。小总结
到这里,似乎多chunk打包,文件的执行流程就算理清楚了,如果你能想明白在html中下面两种方式,都不会导致文件执行失败,你就真的明白了:
按需加载:动态加载过程解析
等多包加载理清后,再看按需加载,就没有那么复杂了,因为很多实现是在多包加载的基础上完成的,为了让理论更清晰,我添加了两处按需加载,还是那个节奏:
代码很简单,就是在页面添加了一个按钮,当按钮被点击时,按需加载
utils/math
模块,并打印输出的模块;当点击次数大于两次时,按需加载utils/fire
模块,并调用其中暴露出的fire函数。相对于上一次,会多打出两个js 文件:0.bundle_29180b93.js 与 1.bundle_42bc336c.js,这里就列其中一个的代码:格式与上面的async chunk 格式一模一样。
然后再来看index.js 打包完,新增了哪些:
在上一节的接触上,只加了很少的代码,主要涉及到两个方法
jsonpScriptSrc
与requireEnsure
,前者在注释里已经写得很清楚了,后者其实就是动态创建script标签,动态加载需要的js文件,并返回一个Promise
,来看一下代码:相对来说requireEnsure的代码实现并没有多么特别,都是一些常规操作,但没有用常用的onload回调,而改用
promise
来处理,还是比较巧妙的。模块是否已经加装好,还是利用前面的webpackJsonp的push代理来完成。现在再来补充上面一节说留着下一节讲的代码:
所以上面的代码做的,还是利用了这个代理,在chunk加载完成时,来把刚刚产生的
promise resolved
掉,这样按需加载的then就继续往下执行了,非常曲折的一个发布订阅。总结
自此,对webpack打包后的代码执行过程就分析完了,由简入难,如果多一点耐心,还是比较容易就看懂的。毕竟wbepack的高深,是隐藏在webpack自身的插件系统中的,打出来的代码基本是ES5级别的,只是用了一些巧妙的方法,比如push的拦截代理。
如果有什么不清楚的,推荐clone项目,自己打包分析一下代码:demo地址: webpack项目