Демистификация принципа загрузки веб-пакета по требованию

Webpack

​ Когда файл на странице слишком велик и не обязательно используется, мы хотим начать загрузку, когда он используется, то есть загрузку по требованию. Чтобы добиться загрузки по запросу, мы обычно думаем о методе: динамически создаем тег script и указываем атрибут src на соответствующий путь к файлу. Но в процессе реализации возникают следующие проблемы:

  1. Как сделать так, чтобы один и тот же файл загружался только один раз?
  2. Как я могу узнать, загружен ли файл?
  3. После того, как файл загружен, как уведомить все места, куда файл импортирован?

Загрузка веб-пакета по требованию прекрасно решила вышеуказанные проблемы.С целью обучения я решил глубоко изучить принцип реализации загрузки веб-пакета по требованию.

Когда дело доходит до динамического разделения кода, webpack предлагает два похожих метода. Для динамического импорта первым и предпочтительным способом является использованиеПредложение ECMAScriptизimport()грамматика. Во-вторых, использовать специфичные для веб-пакетаrequire.ensure. Эта статья основана на официальной рекомендацииimport()грамматика

Начнем с самого простого примера

пример

Есть два файла.В входном файле index.js файл a.js импортируется асинхронно с помощью метода import().

основная среда

webpack 4.43.0

веб-пакет настроен как:

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;

После того, как пакет станет кодом веб-пакета для двух файлов:

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. определитьмассив обещаний, используемый для хранения промисов.
  2. Определите, был ли он загружен, если он загружен, верните пустой массив promise.all().
  3. Если загружается, вернутьсяСохраненное обещание, соответствующее этому файлу.
  4. Если не загружен, сначалаОпределите обещание, затем создайте тег script, загрузите этот js и определите обратные вызовы успеха и отказа.
  5. вернуть обещание

Просто взглянув на эту функцию, у нас могут возникнуть некоторые вопросы:

  1. Судить о том, был ли он загружен или нет, можно с помощью сужденияinstalledChunks[chunkId]имеет значение 0, но вscript.onerror/script.onloadФункция обратного вызова не ставитinstalledChunks[chunkId]значение установлено на 0
  2. promiseПучокresolveа такжеrejectвсе депонированоinstalledChunks, не удается получить асинхронные чанки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, выполнено.resolveметод

Наконец, давайте реорганизуем реализацию загрузки веб-пакета по требованию:

Суммировать

Наконец, давайте ответим на три вопроса в начале статьи:

1. Как сделать так, чтобы один и тот же файл загружался только один раз?

А: ОпределениеinstalledChunksОбъект, который хранит обратный вызов обещания асинхронного js или возвращает пустой массив, если он был загруженpromise.all([]), если он находится в процессе загрузки, верните обещание, соответствующее этому сохраненному файлу.

2. Как определить, что загрузка файла завершена?

Ответ: 1. Определить глобальный массив в основном файле, переписать его метод push и выполнить метод push этого глобального массива в асинхронном файле.

2. Выполните обратный вызов разрешения обещания в переопределенном методе.

3. После того, как файл загружен, как оповестить все места, куда файл импортирован?

Ответ: То же, что и 2