Webpack探索【16】--- 懒加载构建原理详解(模块如何被组建&如何加载)&源码解读

时间:2023-03-08 16:15:26

本文主要说明Webpack懒加载构建和加载的原理,对构建后的源码进行分析。

一 说明

本文以一个简单的示例,通过对构建好的bundle.js源码进行分析,说明Webpack懒加载构建原理。

本文使用的Webpack版本是4.32.2版本。

注意:之前也分析过Webpack3.10.0版本构建出来的bundle.js,通过和这次的Webpack 4.32.2版本对比,核心的构建原理基本一致,只是将模块索引id改为文件路径和名字、模块代码改为了eval(moduleString)执行的方式等一些优化改造。

二 示例

1)Webpack.config.js文件内容:

 const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin'); module.exports = {
entry: {
app: './src/index.js'
},
output: {
filename: '[name].bundle.js',
chunkFilename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
title: 'Output Management'
})
],
mode: 'development' // 'production' 用于配置开发还是发布模式
};

2)创建src文件夹,添加入口文件index.js:

 function component() {
var element = document.createElement('div');
var button = document.createElement('button');
var br = document.createElement('br'); button.innerHTML = 'Click me and look at the console!';
element.innerHTML = 'Hello webpack'; // _.join(['Hello', 'webpack'], ' ');
element.appendChild(br);
element.appendChild(button); button.onclick = (
e => {
// 注意:下边的注释不写的话,打包出来的print文件包名就不是print.bundle.js,而是0.bundle.js
import(/* webpackChunkName: "print" */'./print').then(
module => {
var print = module.default;
print();
}
)
}
); return element;
} document.body.appendChild(component());

3)在src目录下创建print.js文件:

 export default () => {
console.log('Button Clicked: Here\'s "some text"!');
}

4)package.json文件内容:

{
"name": "webpack-demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"webpack": "webpack",
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"clean-webpack-plugin": "^0.1.18",
"html-webpack-plugin": "^3.2.0",
"webpack": "^4.32.2",
"webpack-cli": "^3.3.2"
},
"dependencies": {
"lodash": "^4.17.4"
}
}

三 执行构建

执行构建命令:npm run webpack

在dist目录下生成了两个文件:app.bundle.js和print.bundle.js。

app.bundle.js源码如下(下边代码是将注释去掉、压缩的代码还原后的代码):

 (function (modules) {
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 (installedChunks[chunkId]) {
resolves.push(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); while (resolves.length) {
resolves.shift()();
}
}; // The module cache
var installedModules = {}; // object to store loaded and loading chunks
// undefined = chunk not loaded, null = chunk preloaded/prefetched
// Promise = chunk loading, 0 = chunk loaded
var installedChunks = {
"app": 0
}; // script path function
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "" + ({"print": "print"}[chunkId] || chunkId) + ".bundle.js"
} // The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
}; // Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // Flag the module as loaded
module.l = true; // Return the exports of the module
return module.exports;
} // This file contains only the entry chunk.
// The chunk loading function for additional chunks
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = []; // JSONP chunk loading for javascript var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) { // 0 means "already installed". // a Promise means "currently loading".
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// setup Promise in chunk cache
var promise = new Promise(function (resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise); // start chunk loading
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); // create error before stack unwound to get useful stacktrace later
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.type = errorType;
error.request = realSrc;
chunk[1](error);
}
installedChunks[chunkId] = undefined;
}
};
var timeout = setTimeout(function () {
onScriptComplete({type: 'timeout', target: script});
}, 120000);
script.onerror = script.onload = onScriptComplete;
document.head.appendChild(script);
}
}
return Promise.all(promises);
}; // expose the modules object (__webpack_modules__)
__webpack_require__.m = modules; // expose the module cache
__webpack_require__.c = installedModules; // define getter function for harmony exports
__webpack_require__.d = function (exports, name, getter) {
if (!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, {enumerable: true, get: getter});
}
}; // define __esModule on exports
__webpack_require__.r = function (exports) {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'});
}
Object.defineProperty(exports, '__esModule', {value: true});
}; // create a fake namespace object
// mode & 1: value is a module id, require it
// mode & 2: merge all properties of value into the ns
// mode & 4: return value when already ns object
// mode & 8|1: behave like require
__webpack_require__.t = function (value, mode) {
if (mode & 1) value = __webpack_require__(value);
if (mode & 8) return value;
if ((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
var ns = Object.create(null);
__webpack_require__.r(ns);
Object.defineProperty(ns, 'default', {enumerable: true, value: value});
if (mode & 2 && typeof value != 'string') for (var key in value) __webpack_require__.d(ns, key, function (key) {
return value[key];
}.bind(null, key));
return ns;
}; // getDefaultExport function for compatibility with non-harmony modules
__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;
}; // Object.prototype.hasOwnProperty.call
__webpack_require__.o = function (object, property) {
return Object.prototype.hasOwnProperty.call(object, property);
}; // __webpack_public_path__
__webpack_require__.p = ""; // 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); // 复制一个数组的push方法,这个方法的this是jsonpArray
jsonpArray.push = webpackJsonpCallback; // TODO: 为什么要复写push,而不是直接增加一个新方法名?
jsonpArray = jsonpArray.slice(); // 拷贝一个新数组
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction; // Load entry module and return exports
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
/************************************************************************/
({
"./src/index.js": (function (module, exports, __webpack_require__) {
function component() {
var element = document.createElement('div');
var button = document.createElement('button');
var br = document.createElement('br'); button.innerHTML = 'Click me and look at the console!';
element.innerHTML = 'Hello webpack'; // _.join(['Hello', 'webpack'], ' ');
element.appendChild(br);
element.appendChild(button); button.onclick = (
e => {
__webpack_require__.e("print")
.then(__webpack_require__.bind(null, "./src/print.js"))
.then(
module => {
var print = module.default;
print();
}
)
}
); return element;
} document.body.appendChild(component());
})
});

print.bundle.js的源码如下:

 (window["webpackJsonp"] = window["webpackJsonp"] || []).push([ // 注意:这个push实际是webpackJsonpCallback方法
["print"],
{
"./src/print.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
__webpack_exports__["default"] = (() => {
console.log('Button Clicked: Here\'s "some text"!');
});
})
}
]);

四 源码解读

说明:懒加载构建和和上一篇的基础构建原理中有很多相同的代码,这里不再重复说明,本文主要详细说明其中增加的懒加载方面的内容。

app.bundle.js是构建好的入口文件,里边就是一个自执行函数,基本结构和上一篇基础构建源码中一致,这里不再详细说明。下边是使用懒加载模块构建后,增加的内容,这里详细说明这些内容:

 (function (modules) {
function webpackJsonpCallback(data) {...}; // The module cache
var installedModules = {}; // object to store loaded and loading chunks
// undefined = chunk not loaded, null = chunk preloaded/prefetched
// Promise = chunk loading, 0 = chunk loaded
var installedChunks = {
"app": 0
}; // script path function
function jsonpScriptSrc(chunkId) {...} // The require function
function __webpack_require__(moduleId) {...} // This file contains only the entry chunk.
// The chunk loading function for additional chunks
__webpack_require__.e = function requireEnsure(chunkId) {...}; // .... // 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); // 复制一个数组的push方法,这个方法的this是jsonpArray
jsonpArray.push = webpackJsonpCallback; // TODO: 为什么要复写push,而不是直接增加一个新方法名?
jsonpArray = jsonpArray.slice(); // 拷贝一个新数组
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction; // Load entry module and return exports
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
/************************************************************************/
({
"./src/index.js": (function (module, exports, __webpack_require__) {...})
});

我们详细分析下新增的这些代码。

4.1 installedChunks缓存变量

根据注释,该对象变量主要缓存各个独立的js文件模块的加载状态。

该对象的key就是chunkId,而chunkId实际就是文件名去掉.bundle.js后剩余的内容,例如:print.bundle.js的chunkId就是print。

根据值的不同标志着key对应的文件加载状态主要有以下几种:

undefined:key对应的文件未加载;

null:key对应的文件延迟加载;

数组:正在加载(注意,这里的注释有点不准确,这个数组实际存储的是一个promise的实例,以及对应的reject和resolve);

0:已经加载过了。

这个变量的核心作用:当一个懒加载模块被多个文件依赖时,如果该模块已经被加载过了,就不会被其它模块加载了。判断方法就是通过该缓存变量判断的。具体源码可以在__webpack_require__.e函数中看到:

 __webpack_require__.e = function requireEnsure(chunkId) {
var promises = []; // JSONP chunk loading for javascript
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) { // 0 means "already installed". // a Promise means "currently loading".
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// ...
// 创建一个<script>标签,将路径设置为懒加载文件路径,并插入HTML,实现该懒加载文件的加载。
}
}
return Promise.all(promises);
};

4.2 __webpack_require__.e函数

该函数主要作用就是创建一个<script>标签,然后将chunkId对应的文件通过该标签加载。

源代码如下:

 1 __webpack_require__.e = function requireEnsure(chunkId) {
2 var promises = [];
3
4 // JSONP chunk loading for javascript
5
6 var installedChunkData = installedChunks[chunkId];
7 if (installedChunkData !== 0) { // 0 means "already installed".
8
9 // a Promise means "currently loading".
10 if (installedChunkData) {
11 promises.push(installedChunkData[2]);
12 } else {
13 // setup Promise in chunk cache
14 var promise = new Promise(function (resolve, reject) {
15 installedChunkData = installedChunks[chunkId] = [resolve, reject];
16 });
17 promises.push(installedChunkData[2] = promise);
18
19 // start chunk loading
20 var script = document.createElement('script');
21 var onScriptComplete;
22
23 script.charset = 'utf-8';
24 script.timeout = 120;
25 if (__webpack_require__.nc) {
26 script.setAttribute("nonce", __webpack_require__.nc);
27 }
28 script.src = jsonpScriptSrc(chunkId);
29
30 // create error before stack unwound to get useful stacktrace later
31 var error = new Error();
32 onScriptComplete = function (event) {
33 // avoid mem leaks in IE.
34 script.onerror = script.onload = null;
35 clearTimeout(timeout);
36 var chunk = installedChunks[chunkId];
37 if (chunk !== 0) {
38 if (chunk) {
39 var errorType = event && (event.type === 'load' ? 'missing' : event.type);
40 var realSrc = event && event.target && event.target.src;
41 error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
42 error.type = errorType;
43 error.request = realSrc;
44 chunk[1](error);
45 }
46 installedChunks[chunkId] = undefined;
47 }
48 };
49 var timeout = setTimeout(function () {
50 onScriptComplete({type: 'timeout', target: script});
51 }, 120000);
52 script.onerror = script.onload = onScriptComplete;
53 document.head.appendChild(script);
54 }
55 }
56 return Promise.all(promises);
57 };

主要做了如下几个事情:

1)判断chunkId对应的模块是否已经加载了,如果已经加载了,就不再重新加载;

2)如果模块没有被加载过,但模块处于正在被加载的过程,不再重复加载,直接将加载模块的promise返回。

为什么会出现这种情况?

例如:我们将index.js中加载print.js文件的地方改造为下边多次通过ES6的import加载print.js文件:

 1 button.onclick = (
2 e => {
3
4 import('./print').then(
5 module => {
6 var print = module.default;
7 print();
8 }
9 );
10
11 import('./print').then(
12 module => {
13 var print = module.default;
14 print();
15 }
16 )
17 }
18 );

从上边代码可以看出,当第一import加载print.js文件时,还没有resolve,就又执行第二个import文件了,而为了避免重复加载该文件,就通过将这里的判断,避免了重复加载。

3)如果模块没有被加载过,也不处于加载过程,就创建一个promise,并将resolve、reject、promise构成的数组存储在上边说过的installedChunks缓存对象属性中。然后创建一个script标签加载对应的文件,加载超时时间是2分钟。如果script文件加载失败,触发reject(对应源码中:chunk[1](error),chunk[1]就是上边缓存的数组的第二个元素reject),并将installedChunks缓存对象中对应key的值设置为undefined,标识其没有被加载。

4)最后返回promise

注意:源码中,这里返回的是Promise.all(promises),分析代码发现promises好像只可能有一个元素。可能还没遇到多个promises的场景吧。留待后续研究。

4.3 自执行函数体代码分析

整个app.bundle.js文件是一个自执行函数,该函数中执行的代码如下:

1     var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
2 var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 复制一个数组的push方法,这个方法的this是jsonpArray
3 jsonpArray.push = webpackJsonpCallback; // TODO: 为什么要复写push,而不是直接增加一个新方法名?
4 jsonpArray = jsonpArray.slice(); // 拷贝一个新数组
5 for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
6 var parentJsonpFunction = oldJsonpFunction;

这段代码主要做了如下几个事情:

1)定义了一个全局变量webpackJsonp,改变量是一个数组,该数组变量的原生push方法被复写为webpackJsonpCallback方法,该方法是懒加载实现的一个核心方法,具体代码会在下边分析。

该全局变量在懒加载文件中被用到。在print.bundle.js中:

1 (window["webpackJsonp"] = window["webpackJsonp"] || []).push([ // 注意:这个push实际是webpackJsonpCallback方法
2 ["print"],
3 {
4 "./src/print.js": (function(module, __webpack_exports__, __webpack_require__) {...})
5 }
6 ]);

2)将数组的原生push方法备份,赋值给parentJsonpFunction变量保存。

注意:该方法的this是全局变量webpackJsonp,也就是说parentJsonpFunction('111')后,全局数组变量webpackJsonp就增加了一个'111'元素。

该方法在webpackJsonpCallback中会用到,是将懒加载文件的内容保存到全局变量webpackJsonp中。

3)上边第一步中复写push的原因?

可能是因为在懒加载文件中,调用了复写后的push,执行了原生push的功能,因此,为了更形象的表达该意思,因此直接复写了push。

但个人认为这个不太好,不易读。直接新增一个_push或者extendPush,这样是不是读起来就很简单了。

4.4 webpackJsonpCallback函数分析

该函数是懒加载的一个比较核心代码。其代码如下:

 1     function webpackJsonpCallback(data) {
2 var chunkIds = data[0];
3 var moreModules = data[1];
4
5 // add "moreModules" to the modules object,
6 // then flag all "chunkIds" as loaded and fire callback
7 var moduleId, chunkId, i = 0, resolves = [];
8 for (; i < chunkIds.length; i++) {
9 chunkId = chunkIds[i];
10 if (installedChunks[chunkId]) {
11 resolves.push(installedChunks[chunkId][0]);
12 }
13 installedChunks[chunkId] = 0;
14 }
15 for (moduleId in moreModules) {
16 if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
17 modules[moduleId] = moreModules[moduleId];
18 }
19 }
20 if (parentJsonpFunction) parentJsonpFunction(data);
21
22 while (resolves.length) {
23 resolves.shift()();
24 }
25 };

参数说明:

参数是一个数组。有两个元素:第一个元素是要懒加载文件中所有模块的chunkId组成的数组;第二个参数是一个对象,对象的属性和值分别是要加载模块的moduleId和模块代码函数。

该函数主要做的事情如下:

1)遍历参数中的chunkId:

判断installedChunks缓存变量中对应chunkId的属性值:如果是真,说明模块正在加载,因为从上边分析中可以知道,installedChunks[chunkId]只有一种情况是真,那就是在对应的模块正在加载时,会将加载模块创建的promise的三个信息搞成一个数组[resolve, reject, proimise]赋值给installedChunks[chunkId]。将resolve存入resolves变量中。

将installedChunks中对应的chunkId置为0,标识该模块已经被加载过了。

2)遍历参数中模块对象所有属性:

将模块代码函数存储到modules中,该modules是入口文件app.bundle.js中自执行函数的参数。

这一步非常关键,因为执行模块加载函数__webpack_require__时,获取模块代码时,就是通过moduleId从modules中查找对应模块代码。

3)调用parentJsonpFunction(原生push方法)将整个懒加载文件的数据存入全局数组变量window.webpackJsonp。

4)遍历resolves,执行所有promise的resolve:

当执行了promise的resolve后,才会走到promise.then的成功回调中,查看源码可以看到:

 1             button.onclick = (
2 e => {
3 __webpack_require__.e("print")
4 .then(__webpack_require__.bind(null, "./src/print.js"))
5 .then(
6 module => {
7 var print = module.default;
8 print();
9 }
10 )
11 }
12 );

resolve后,执行了两个then回调:

第一个回调是调用__webpack_require__函数,传入的参数是懒加载文件中的一个模块的moduleId,而这个moduleId就是上边存入到modules变量其中一个。这样就通过__webpack_require__执行了模块的代码。并将模块的返回值,传递给第二个then的回调函数;

第二个回调函数是真正的onclick回调函数的业务代码。

5)重要思考:

从这个函数可以看出:

调用__webpack_require__.e('print')方法,实际只是将对应的print.bundle.js文件加载和创建了一个异步的promise(因为并不知道什么时候这个文件才能执行完,因此需要一个异步promise,而promise的resolve会在对应的文件加载时执行,这样就能实现异步文件加载了),并没有将懒加载文件中保存的模块代码执行。

在加载对应print.bundle.js文件代码时,通过调用webpackJsonpCallback函数,实现触发加载文件时创建的promise的resolve。

resolve触发后,会执行promise的then回调,这个回调通过__webpack_require__函数执行了真正需要模块的代码(注意:如果print.bundle.js中有很多模块,只会执行用到的模块代码,而不是执行所有模块的代码),执行完后将模块的exports返回给promise的下一个then函数,该函数也就是真正的业务代码了。

综上,可以看出,webpack实际是通过promise,巧妙的实现了模块的懒加载功能。

5 懒加载构建原理图

Webpack探索【16】--- 懒加载构建原理详解(模块如何被组建&如何加载)&源码解读