an ckick pictrue

吃饭不洗...segmentfault 专栏

webpack 打包的代码怎么在浏览器跑起来的?看不懂算我输

Issue链接更新于:2020-05-18

说点什么

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

20200516121057

除了上面的加载过程,再说一个细节,就是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文件截图:

20200516132542

再看看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的拦截代理。

如果有什么不清楚的,推荐clone项目,自己打包分析一下代码:demo地址: webpack项目