webpack打包是如何运行的

  • 也可以称为,webpack是如何实现模块化的
  • CommonJS是同步加载模块,一般用于node。因为node应用程序运行在服务器上,程序通过文件系统可以直接读取到各个模块的文件,特点是响应快速,不会因为同步而阻塞了程序的运行;
  • AMD是异步加载模块,所以普遍用于前端。而前端项目运行在浏览器中,每个模块都要通过http请求加载js模块文件,受到网络等因素的影响如果同步的话就会使浏览器出现“假死”(卡死)的情况,影响到了用户体验。
  • ESModule 旨在实现前后端模块化的统一。而webpack就是把ES6的模块化代码转码成CommonJS的形式,从而兼容浏览器的。
  • 为什么webpack打包后的文件,可以用在浏览器:此时webpack会将所有的js模块打包到bundle.js中(异步加载的模块除外,异步模块后面会讲),读取到了内存里,就不会再分模块加载了。

webpack对CommonJS的模块化处理

  • 举例:
    • index.js文件,引入foo.js文件
    const foo = require('./foo');
    
    console.log(foo);
    console.log('我是高级前端工程师~');
    
    • foo.js文件
    module.exports = {
      name: 'quanquan',
      job: 'fe',
    };
    
  • 当我们执行webpack之后,打包完成,可以看到bundle.js内的代码
// modules 即为存放所有模块的数组,数组中的每一个元素都是一个函数
(function(modules) {
	// 安装过的模块都存放在这里面
    // 作用是把已经加载过的模块缓存在内存中,提升性能
	var installedModules = {};
	// 去数组中加载一个模块,moduleId 为要加载模块在数组中的 index
    // __webpack_require__作用和 Node.js 中 require 语句相似
	function __webpack_require__(moduleId) {
		// require 模块时先判断是否已经缓存, 已经缓存的模块直接返回
		if(installedModules[moduleId]) {
			return installedModules[moduleId].exports;
		}
		// 如果缓存中不存在需要加载的模块,就新建一个模块,并把它存在缓存中
		var module = installedModules[moduleId] = {
            // 模块在数组中的index
            i: moduleId,
            // 该模块是否已加载完毕
            l: false,
            // 该模块的导出值,也叫模块主体内容, 会被重写
			exports: {}
		};
		// 从 modules 中获取 index 为 moduleId 的模块对应的函数
        // 再调用这个函数,同时把函数需要的参数传入,this指向模块的主体内容
		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
		// 将模块标记为已加载
		module.l = true;
		// 返回模块的导出值,即模块主体内容
		return module.exports;
	}
    // 向外暴露所有的模块
	__webpack_require__.m = modules;
	// 向外暴露已缓存的模块
    __webpack_require__.c = installedModules;
	
	...
	...
	
    // Webpack 配置中的 publicPath,用于加载被分割出去的异步代码,这个暂时还没有用到
	__webpack_require__.p = "";
    // Load entry module and return exports
    // 准备工作做完了, require 一下入口模块, 让项目跑起来
    // 使用 __webpack_require__ 去加载 index 为 0 的模块,并且返回该模块导出的内容
    // index 为 0 的模块就是 index.js文件,也就是执行入口模块
    // __webpack_require__.s 的含义是启动模块对应的 index
	return __webpack_require__(__webpack_require__.s = 0);
})
/***** 华丽的分割线 上边时 webpack 初始化代码, 下边是我们写的模块代码 *******/
// 所有的模块都存放在了一个数组里,根据每个模块在数组的 index 来区分和定位模块
([
	/* 模块 0 对应 index.js */
	(function(module, exports, __webpack_require__) {
		// 通过 __webpack_require__ 规范导入 foo 函数,foo.js 对应的模块 index 为 1
		const foo = __webpack_require__(1);
		
		console.log(foo);
		console.log('我是高级前端工程师~');
	}),
	/* 模块 1 对应 foo.js */
	(function(module, exports) {
		// 通过 CommonJS 规范导出对象
		module.exports = {
		  name: 'quanquan',
		  job: 'fe',
		};
	})
]);
  • 上面是一个立即执行函数,简单点写:
(function(modules) {

  // 模拟 require 语句
  function __webpack_require__(index) {
	  return [/*存放所有模块的数组中,第index个模块暴露的东西*/]
  }

  // 执行存放所有模块数组中的第0个模块,并且返回该模块导出的内容
  return __webpack_require__(0);

})([/*存放所有模块的数组*/])
  • bundle.js 能直接运行在浏览器中的原因在于:
    • webpack通过 _webpack_require_ 函数(该函数定义了一个可以在浏览器中执行的加载函数)模拟了模块的加载(类似于Node.js 中的 require 语句),把定义的模块内容挂载到module.exports上;
    • 同时__webpack_require__函数中也对模块缓存做了优化,执行加载过的模块不会再执行第二次,执行结果会缓存在内存中,当某个模块第二次被访问时会直接去内存中读取被缓存的返回值。
  • 原来一个个独立的模块文件被合并到了一个单独的 bundle.js 的原因在于,浏览器不能像 Node.js 那样快速地去本地加载一个个模块文件,而必须通过网络请求去加载还未得到的文件。 如果模块数量很多,加载时间会很长,因此把所有模块都存放在了数组中,执行一次网络加载。

webpack对es6 Module模块化的处理

  • 举例
    • index.js文件,引入foo.js文件
    const foo = require('./foo');❎
    import foo from './foo';✅
    
    console.log(foo);
    console.log('我是高级前端工程师~');
    
    • foo.js文件
    module.exports = {❎
    export default {✅
      name: 'quanquan',
      job: 'fe',
    };
    
  • 打包完后bundle.js代码如下
(function(modules) {
	var installedModules = {};
	function __webpack_require__(moduleId) {
		if(installedModules[moduleId]) {
			return installedModules[moduleId].exports;
		}
		var module = installedModules[moduleId] = {
			i: moduleId,
			l: false,
			exports: {}
		};
		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
		module.l = true;
		return module.exports;
	}
	__webpack_require__.m = modules;
	__webpack_require__.c = installedModules;
	__webpack_require__.d = function(exports, name, getter) {
		if(!__webpack_require__.o(exports, name)) {
			Object.defineProperty(exports, name, {
				configurable: false,
				enumerable: true,
				get: getter
			});
		}
	};
	__webpack_require__.n = function(module) {
		var getter = module && module.__esModule ?
			function getDefault() { return module['default']; } :
			function getModuleExports() { return module; };
		__webpack_require__.d(getter, 'a', getter);
		return getter;
	};
	__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
	__webpack_require__.p = "";
	return __webpack_require__(__webpack_require__.s = 0);
})([相关模块]);
  • 打包好的内容和commonjs模块化方法差不多
function(module, __webpack_exports__, __webpack_require__) {
	"use strict";
	// 在__webpack_exports__上定义__esModule为true,表明是一个模块对象
	Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
	var __WEBPACK_IMPORTED_MODULE_0__foo__ = __webpack_require__(1);
	console.log(__WEBPACK_IMPORTED_MODULE_0__foo__["a"]);
	console.log('我是高级前端工程师~');
},
function(module, __webpack_exports__, __webpack_require__) {
	"use strict";
	__webpack_exports__["a"] = ({
		name: 'quanquan',
		job: 'fe',
	});
}
  • 和 commonjs 不同的地方
    • 首先, 包装函数的参数之前的 module.exports 变成了_webpack_exports_
    • 其次, 在使用了 es6 模块导入语法(import)的地方, 给__webpack_exports__添加了属性__esModule
    • 其余的部分和 commonjs 类似

webpack文件的按需加载

  • 以上webpack把所有模块打包到主文件中,所以模块加载方式都是同步方式。但在开发应用过程中,按需加载(也叫懒加载)也是经常使用的优化技巧之一。
  • 按需加载,通俗讲就是代码执行到异步模块(模块内容在另外一个js文件中),通过网络请求即时加载对应的异步模块代码,再继续接下去的流程。
  • 在给单页应用做按需加载优化时,一般采用以下原则:
    • 把整个网站划分成一个个小功能,再按照每个功能的相关程度把它们分成几类。
    • 把每一类合并为一个 Chunk,按需加载对应的 Chunk。
    • 对于用户首次打开你的网站时需要看到的画面所对应的功能,不要对它们做按需加载,而是放到执行入口所在的 Chunk 中,以降低用户能感知的网页加载时间。
    • 对于个别依赖大量代码的功能点,例如依赖 Chart.js 去画图表、依赖 flv.js 去播放视频的功能点,可再对其进行按需加载。
  • 被分割出去的代码的加载需要一定的时机去触发,也就是当用户操作到了或者即将操作到对应的功能时再去加载对应的代码。 被分割出去的代码的加载时机需要开发者自己去根据网页的需求去衡量和确定。
  • 由于被分割出去进行按需加载的代码在加载的过程中也需要耗时,你可以预言用户接下来可能会进行的操作,并提前加载好对应的代码,从而让用户感知不到网络加载时间。
  • 举个例子
    • 网页首次加载时只加载 main.js 文件,网页会展示一个按钮,main.js 文件中只包含监听按钮事件和加载按需加载的代码。当按钮被点击时才去加载被分割出去的 show.js 文件,加载成功后再执行 show.js 里的函数。
    • main.js 文件
    window.document.getElementById('btn').addEventListener('click', function () {
     // 当按钮被点击后才去加载 show.js 文件,文件加载成功后执行文件导出的函数
     import(/* webpackChunkName: "show" */ './show').then((show) => {
       show('Webpack');
     })
    });
    
    • show.js 文件
    module.exports = function (content) {
      window.alert('Hello ' + content);
    };
    
    • 代码中最关键的一句是 import(/* webpackChunkName: “show” / ‘./show’),Webpack 内置了对 import() 语句的支持,当 Webpack 遇到了类似的语句时会这样处理:
      • 以 ./show.js 为入口新生成一个 Chunk;
      • 当代码执行到 import 所在语句时才会去加载由 Chunk 对应生成的文件。
      • import 返回一个 Promise,当文件加载成功时可以在 Promise 的 then 方法中获取到 show.js 导出的内容。
  • webpack有个require.ensure api语法来标记为异步加载模块,最新的webpack4推荐使用新的import() api(需要配合@babel/plugin-syntax-dynamic-import插件)。

  • 因为require.ensure是通过回调函数执行接下来的流程,而import()返回promise,这意味着可以使用最新的ES8 async/await语法,使得可以像书写同步代码一样,执行异步流程。

按需加载输出代码分析

  • 举例
    • main.js
    // main.js
    import Add from './add'
    console.log(Add, Add(1, 2), 123)
    
    // 按需加载
    // 方式1: require.ensure
    // require.ensure([], function(require){
    //     var asyncModule = require('./async')
    //     console.log(asyncModule.default, 234)
    // })
    
    // 方式2: webpack4新的import语法
    // 需要加@babel/plugin-syntax-dynamic-import插件
    let asyncModuleWarp = async () => await import('./async')
    console.log(asyncModuleWarp().default, 234)
    
    • async.js
    // async.js
    export default function() {
        return 'hello, aysnc module'
    }
    
  • 打包后会生成两个chunk文件,分别是主文件执行入口文件 bundle.js 和 异步加载文件 0.bundle.js。
// 0.bundle.js
// 异步模块
// window["webpackJsonp"]是连接多个chunk文件的桥梁
// window["webpackJsonp"].push = 主chunk文件.webpackJsonpCallback
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
  [0], // 异步模块标识chunkId,可判断异步代码是否加载成功
  // 跟同步模块一样,存放了{模块路径:模块内容}
  {
  "./src/async.js": (function(module, __webpack_exports__, __webpack_require__) {
      __webpack_require__.r(__webpack_exports__);
      __webpack_exports__["default"] = (function () {
        return 'hello, aysnc module';
      });
    })
  }
]);
  • 异步模块打包后的文件中保存着异步模块源代码,同时为了区分不同的异步模块,还保存着该异步模块对应的标识:chunkId。以上代码主动调用window[“webpackJsonp”].push函数,该函数是连接异步模块与主模块的关键函数,该函数定义在主文件中,实际上window[“webpackJsonp”].push = webpackJsonpCallback,详细源码咱们看看主文件打包后的代码bundle.js:
(function(modules) {
	// 获取到异步chunk代码后的回调函数
	// 连接两个模块文件的关键函数
	function webpackJsonpCallback(data) {
	  var chunkIds = data[0]; //data[0]存放了异步模块对应的chunkId
	  var moreModules = data[1]; // data[1]存放了异步模块代码
	
	  // 标记异步模块已加载成功
	  var moduleId, chunkId, i = 0, resolves = [];
	  for(;i < chunkIds.length; i++) {
	    chunkId = chunkIds[i];
	    if(installedChunks[chunkId]) {
	      resolves.push(installedChunks[chunkId][0]);
	    }
	    installedChunks[chunkId] = 0;
	  }
	
	  // 把异步模块代码都存放到modules中
	  // 此时万事俱备,异步代码都已经同步加载到主模块中
	  for(moduleId in moreModules) {
	    modules[moduleId] = moreModules[moduleId];
	  }
	
	  // 重点:执行resolve() = installedChunks[chunkId][0]()返回promise
	  while(resolves.length) {
	    resolves.shift()();
	  }
	};
	
	// 记录哪些chunk已加载完成
	var installedChunks = {
	  "main": 0
	};
	
	// __webpack_require__依然是同步读取模块代码作用
	function __webpack_require__(moduleId) {
	  ...
	}
	
	// 加载异步模块
	__webpack_require__.e = function requireEnsure(chunkId) {
	  // 创建promise
	  // 把resolve保存到installedChunks[chunkId]中,等待代码加载好再执行resolve()以返回promise
	  var promise = new Promise(function(resolve, reject) {
	    installedChunks[chunkId] = [resolve, reject];
	  });
	
	  // 通过往head头部插入script标签异步加载到chunk代码
	  var script = document.createElement('script');
	  script.charset = 'utf-8';
	  script.timeout = 120;
	  script.src = __webpack_require__.p + "" + ({}[chunkId]||chunkId) + ".bundle.js"
	  var onScriptComplete = function (event) {
	    var chunk = installedChunks[chunkId];
	  };
	  script.onerror = script.onload = onScriptComplete;
	  document.head.appendChild(script);
	
	  return promise;
	};
	
	var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
	// 关键代码: window["webpackJsonp"].push = webpackJsonpCallback
	jsonpArray.push = webpackJsonpCallback;
	
	// 入口执行
	return __webpack_require__(__webpack_require__.s = "./src/main.js");
	})
	({
	"./src/add.js": (function(module, __webpack_exports__, __webpack_require__) {...}),
	
	"./src/main.js": (function(module, exports, __webpack_require__) {
	  // 同步方式
	  var Add = __webpack_require__("./src/add.js").default;
	  console.log(Add, Add(1, 2), 123);
	
	  // 异步方式
	  var asyncModuleWarp =function () {
	    var _ref = _asyncToGenerator( regeneratorRuntime.mark(function _callee() {
	      return regeneratorRuntime.wrap(function _callee$(_context) {
	        // 执行到异步代码时,会去执行__webpack_require__.e方法
	        // __webpack_require__.e其返回promise,表示异步代码都已经加载到主模块了
	        // 接下来像同步一样,直接加载模块
	        return __webpack_require__.e(0)
	              .then(__webpack_require__.bind(null, "./src/async.js"))
	      }, _callee);
	    }));
	
	    return function asyncModuleWarp() {
	      return _ref.apply(this, arguments);
	    };
	  }();
	  console.log(asyncModuleWarp().default, 234)
	})
});
  • webpack实现模块的异步加载有点像jsonp的流程。

    • 在主js文件中通过在head中构建script标签方式,异步加载模块信息;
    • 再使用回调函数webpackJsonpCallback,把异步的模块源码同步到主文件中,所以后续操作异步模块可以像同步模块一样。
  • 源码具体实现流程:

    • 遇到异步模块时,使用_webpack_require_.e函数去把异步代码加载进来。该函数会在html的head中动态增加script标签,src指向指定的异步模块存放的文件。
    • 加载的异步模块文件会执行webpackJsonpCallback函数,把异步模块加载到主文件中。
    • 所以后续可以像同步模块一样,直接使用_webpack_require_("./src/async.js")加载异步模块。
  • 这里的 bundle.js 和上面所讲的 bundle.js 非常相似,区别在于:

    • 多了一个 webpack_require.e 用于加载被分割出去的,需要异步加载的 Chunk 对应的文件;
    • 多了一个 webpackJsonp 函数用于从异步加载的文件中安装模块。
    • 在使用了 CommonsChunkPlugin 去提取公共代码时输出的文件和使用了异步加载时输出的文件是一样的,都会有 webpack_require.e 和 webpackJsonp。 原因在于提取公共代码和异步加载本质上都是代码分割。

总结

  • webpack对于ES模块/CommonJS模块的实现,是基于自己实现的webpack_require,所以代码能跑在浏览器中。
  • 从 webpack2 开始,已经内置了对 ES6、CommonJS、AMD 模块化语句的支持。但不包括新的ES6语法转为ES5代码,这部分工作还是留给了babel及其插件。
  • 在webpack中可以同时使用ES6模块和CommonJS模块。因为 module.exports很像export default,所以ES6模块可以很方便兼容 CommonJS:import XXX from ‘commonjs-module’。反过来CommonJS兼容ES6模块,需要额外加上default:require(‘es-module’).default。
  • webpack异步加载模块实现流程跟jsonp基本一致。

 

本面试题为前端常考面试题,后续有机会继续完善。我是歌谣,一个沉迷于故事的讲述者。

欢迎一起私信交流。

“睡服“面试官系列之各系列目录汇总(建议学习收藏)