Webpack 打包的代码怎么在浏览器跑起来的?

发布于 2023-07-20 22:18:17 字数 17014 浏览 38 评论 0

最近在做一个工程化强相关的项目-微前端,涉及到了基座项目和子项目加载,并存的问题;以前对 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();
    });
  }
})

在上一节的接触上,只加了很少的代码,主要涉及到两个方法 jsonpScriptSrcrequireEnsure ,前者在注释里已经写得很清楚了,后者其实就是动态创建 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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

柒夜笙歌凉

暂无简介

0 文章
0 评论
357 人气
更多

推荐作者

13886483628

文章 0 评论 0

流年已逝

文章 0 评论 0

℡寂寞咖啡

文章 0 评论 0

笑看君怀她人

文章 0 评论 0

wkeithbarry

文章 0 评论 0

素手挽清风

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文