Webpack 打包的代码怎么在浏览器跑起来的?
最近在做一个工程化强相关的项目-微前端,涉及到了基座项目和子项目加载,并存的问题;以前对 webpack 一直停留在配置,也就是常说的入门级。这次项目推动,自己不得不迈过门槛,往里面多看一点。
本文主要讲 webpack 构建后的文件,是怎么在浏览器运行起来的,这可以让我们更清楚明白 webpack 的构建原理。
文章中的代码基本只含核心部分,如果想看全部代码和 webpack 配置,可以关注工程,自己拷贝下来运行: demo 地址:: webpack 项目
在读本文前,需要知道 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 中下面两种方式,都不会导致文件执行失败,你就真的明白了:
<!-- 依赖项在前加载 --> <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,这里就列其中一个的代码:
(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 的拦截代理。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论