likes
comments
collection
share

揭秘webpack按需加载原理

作者站长头像
站长
· 阅读数 16

​ 当页面中一个文件过大并且还不一定用到的时候,我们希望在使用到的时才开始加载,这就是按需加载。要实现按需加载,我们一般想到的方法:动态创建script标签,并将src属性指向对应的文件路径。但是在实现过程中,存在下面问题:

  1. 怎么保证相同的文件只加载一次?
  2. 怎么判断文件加载完成?
  3. 文件加载完成后,怎么通知所有引入文件的地方?

​ webpcak 的按需加载已经完美解决了上述问题,本着学习的态度,我决定深入探究一下webpack按需加载的实现原理。

​ 当涉及到动态代码拆分时,webpack 提供了两个类似的技术。对于动态导入,第一种,也是优先选择的方式是,使用符合 ECMAScript 提案import() 语法。第二种,则是使用 webpack 特定的 require.ensure。本文基于官方推荐的import() 语法

下面我们从一个最简单的例子开始

例子

​ 有两个文件,在入口文件index.js中,通过import()方法,异步引入a.js文件。

基本环境

webpack 4.43.0

webpack配置为:

const path = require('path');

module.exports = {
  mode: 'development',
  entry: './index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

代码

index.js

import('./a').then((data) => {
  console.log(data);
});

a.js

const a = 'a模块';
export default a;

webpack 打包后变成了两个文件 的代码为:

bundle.js

//  bundle.js 把index.js中的import()语句变成了这个样子
__webpack_require__
  .e(/*! import() */ 0)
  .then(__webpack_require__.bind(null, /*! ./a */ './a.js'))
  .then((data) => {
    console.log(data);
  });

分析

​ 我们可以看到webpack打包后的代码把import()语句换成了webpack自定义的webpack_require.e 函数,下面我们就从这个函数看起:

webpack_require.e

		// 定义installedChunks,用来存储加载过的js信息
/******/ 	var installedChunks = {
/******/ 		"main": 0
/******/ 	};

/******/ 	__webpack_require__.e = function requireEnsure(chunkId) {
/******/     	        // 定义一个存储promise的数组
/******/ 		var promises = [];
/******/
/******/ 		// JSONP chunk loading for javascript
/******/		// installedChunks为一个对象,用来存储加载过的js信息
/******/ 		var installedChunkData = installedChunks[chunkId];
/******/ 		if(installedChunkData !== 0) { // 0代表已经加载过了
/******/
/******/ 			// 如果已经存在不为0,则代表正在加载
/******/ 			if(installedChunkData) {
    					// installedChunkData[2]存储的是正在加载中的promise
/******/ 				promises.push(installedChunkData[2]);
/******/ 			} else {
/******/ 				// 定义一个promise
/******/ 				var promise = new Promise(function(resolve, reject) {
/******/ 					installedChunkData = installedChunks[chunkId] = [resolve, reject];
/******/ 				});
    					// 存储promise
/******/ 				promises.push(installedChunkData[2] = promise);
/******/
/******/ 				// 创建script标签,开始加载js
/******/ 				var script = document.createElement('script');
/******/ 				var onScriptComplete;
/******/
/******/ 				script.charset = 'utf-8';
    					// 设置一个超时时间
/******/ 				script.timeout = 120;
/******/ 				if (__webpack_require__.nc) {
/******/ 					script.setAttribute("nonce", __webpack_require__.nc);
/******/ 				}
    					// 获取src,并赋值
/******/ 				script.src = jsonpScriptSrc(chunkId);
/******/
/******/ 				// 创建一个error,在加载出错后返回
/******/ 				var error = new Error();
    					// 定义加载完成后的时间
/******/ 				onScriptComplete = function (event) {
/******/ 					// avoid mem leaks in IE.
/******/ 					script.onerror = script.onload = null;
/******/ 					clearTimeout(timeout);
    						// 判断是否加载成功
/******/ 					var chunk = installedChunks[chunkId];
    						// 不成功,进行错误处理
/******/ 					if(chunk !== 0) {
/******/ 						if(chunk) {
/******/ 							var errorType = event && (event.type === 'load' ? 'missing' : event.type);
/******/ 							var realSrc = event && event.target && event.target.src;
/******/ 							error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
/******/ 							error.name = 'ChunkLoadError';
/******/ 							error.type = errorType;
/******/ 							error.request = realSrc;
/******/ 							chunk[1](error);
/******/ 						}
/******/ 						installedChunks[chunkId] = undefined;
/******/ 					}
/******/ 				};
/******/ 				var timeout = setTimeout(function(){
/******/ 					onScriptComplete({ type: 'timeout', target: script });
/******/ 				}, 120000);
    					// 加载成功和失败都走onScriptComplete,具体原因看下文
/******/ 				script.onerror = script.onload = onScriptComplete;
/******/ 				document.head.appendChild(script);
/******/ 			}
/******/ 		}
    			// 返回promise
/******/ 		return Promise.all(promises);
/******/ 	};

这段代码总共干了这几件事:

  1. 定义一个promise数组,用来存储promise.
  2. 判断是否已经加载过,如果加载过,返回一个空数组的promise.all().
  3. 如果正在加载中,则返回存储过的此文件对应的promise.
  4. 如果没加载过,先定义一个promise,然后创建script标签,加载此js,并定义成功和失败的回调
  5. 返回一个promise

只看这个函数,我们可能还有一下疑问:

  1. 判断有无加载过是通过判断installedChunks[chunkId]的值是否为0,但在script.onerror/script.onload回调函数中并没有把installedChunks[chunkId]的值置为0
  2. promiseresolvereject 全部存入了 installedChunks 中, 并没有在获取异步chunk成功的onload 回调中执行 resolve,那么,resolve 是什么时候被执行的呢?

针对这两个问题,我们需要看一下打包后的另一个文件:

0.bundle.js

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0],{

/***/ "./a.js":
/*!**************!*\
  !*** ./a.js ***!
  \**************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\nconst a = 'a模块';\r\n/* harmony default export */ __webpack_exports__[\"default\"] = (a);\n\n//# sourceURL=webpack:///./a.js?");

/***/ })

}]);

​ 我们看到,在此文件中,会执行window["webpackJsonp"].push()方法,即每次加载完一个文件,就会执行全局的webpackJsonp数组的push方法,此push方法就是关键:

bundle.js

/******/    // 定义全局数组window["webpackJsonp"],并重写window["webpackJsonp"]的push方法 	
/******/    var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
/******/ 	var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
/******/ 	jsonpArray.push = webpackJsonpCallback;

		// 重写window["webpackJsonp"]的push方法 
/******/ 	function webpackJsonpCallback(data) {
/******/ 		var chunkIds = data[0];
/******/ 		var moreModules = data[1];
/******/
/******/
/******/ 		// add "moreModules" to the modules object,
/******/ 		// then flag all "chunkIds" as loaded and fire callback
/******/ 		var moduleId, chunkId, i = 0, resolves = [];
/******/ 		for(;i < chunkIds.length; i++) {
/******/ 			chunkId = chunkIds[i];
/******/ 			if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
    					// 获取此js文件对应的promise中的resolve方法数组
/******/ 				resolves.push(installedChunks[chunkId][0]);
/******/ 			}
    				// 把installedChunks[chunkId] 置为0,代表已经加载过
/******/ 			installedChunks[chunkId] = 0;
/******/ 		}
/******/ 		for(moduleId in moreModules) {
/******/ 			if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
/******/ 				modules[moduleId] = moreModules[moduleId];
/******/ 			}
/******/ 		}
/******/ 		if(parentJsonpFunction) parentJsonpFunction(data);
/******/		
    			// 执行此js文件对应的promise中的resolve方法
/******/ 		while(resolves.length) {
/******/ 			resolve.shift()();
/******/ 		}
/******/
/******/ 	};
bundle.js的这几段代码干了这几件事:

  1. 定义全局数组window["webpackJsonp"],并重写window["webpackJsonp"]push方法
  2. 在新的push方法中,把installedChunks[chunkId]置为0,代表已经加载过,并执行js对应的promise的resolve方法

最后,我们重新梳理一下webpack按需加载的实现:

揭秘webpack按需加载原理

总结

最后,我们再回答 一下文章开头的三个问题:

1、怎么保证相同的文件只加载一次?

​ 答:定义installedChunks对象,存储异步js的promise回调,如果已经加载过,则返回一个空数组的promise.all([]),如果在加载过程中,则返回已经存储过的此文件对应的promise。

2、怎么判断文件加载完成?

​ 答:1、在主文件中定义一个全局数组,并重写其push方法,在异步文件中执行此全局数组的push方法。

​ 2、在重写的方法中执行promise的resolve回调。

3、文件加载完成后,怎么通知所有引入文件的地方?

​ 答:同2