Webpack探索【16】--- 懒加载构建原理详解(模块如何被组建&如何加载)&源码解读
本文主要说明Webpack懒加载构建和加载的原理,对构建后的源码进行分析。
一 说明
本文以一个简单的示例,通过对构建好的bundle.js源码进行分析,说明Webpack懒加载构建原理。
本文使用的Webpack版本是4.32.2版本。
注意:之前也分析过Webpack3.10.0版本构建出来的bundle.js,通过和这次的Webpack 4.32.2版本对比,核心的构建原理基本一致,只是将模块索引id改为文件路径和名字、模块代码改为了eval(moduleString)执行的方式等一些优化改造。
二 示例
1)Webpack.config.js文件内容:
1 const path = require('path'); 2 const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 const CleanWebpackPlugin = require('clean-webpack-plugin'); 4 5 module.exports = { 6 entry: { 7 app: './src/index.js' 8 }, 9 output: { 10 filename: '[name].bundle.js', 11 chunkFilename: '[name].bundle.js', 12 path: path.resolve(__dirname, 'dist') 13 }, 14 plugins: [ 15 new CleanWebpackPlugin(['dist']), 16 new HtmlWebpackPlugin({ 17 title: 'Output Management' 18 }) 19 ], 20 mode: 'development' // 'production' 用于配置开发还是发布模式 21 };
2)创建src文件夹,添加入口文件index.js:
1 function component() { 2 var element = document.createElement('div'); 3 var button = document.createElement('button'); 4 var br = document.createElement('br'); 5 6 button.innerHTML = 'Click me and look at the console!'; 7 element.innerHTML = 'Hello webpack'; // _.join(['Hello', 'webpack'], ' '); 8 element.appendChild(br); 9 element.appendChild(button); 10 11 button.onclick = ( 12 e => { 13 // 注意:下边的注释不写的话,打包出来的print文件包名就不是print.bundle.js,而是0.bundle.js 14 import(/* webpackChunkName: "print" */'./print').then( 15 module => { 16 var print = module.default; 17 print(); 18 } 19 ) 20 } 21 ); 22 23 return element; 24 } 25 26 document.body.appendChild(component());
3)在src目录下创建print.js文件:
1 export default () => { 2 console.log('Button Clicked: Here\'s "some text"!'); 3 }
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源码如下(下边代码是将注释去掉、压缩的代码还原后的代码):
1 (function (modules) { 2 function webpackJsonpCallback(data) { 3 var chunkIds = data[0]; 4 var moreModules = data[1]; 5 6 // add "moreModules" to the modules object, 7 // then flag all "chunkIds" as loaded and fire callback 8 var moduleId, chunkId, i = 0, resolves = []; 9 for (; i < chunkIds.length; i++) { 10 chunkId = chunkIds[i]; 11 if (installedChunks[chunkId]) { 12 resolves.push(installedChunks[chunkId][0]); 13 } 14 installedChunks[chunkId] = 0; 15 } 16 for (moduleId in moreModules) { 17 if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { 18 modules[moduleId] = moreModules[moduleId]; 19 } 20 } 21 if (parentJsonpFunction) parentJsonpFunction(data); 22 23 while (resolves.length) { 24 resolves.shift()(); 25 } 26 }; 27 28 // The module cache 29 var installedModules = {}; 30 31 // object to store loaded and loading chunks 32 // undefined = chunk not loaded, null = chunk preloaded/prefetched 33 // Promise = chunk loading, 0 = chunk loaded 34 var installedChunks = { 35 "app": 0 36 }; 37 38 // script path function 39 function jsonpScriptSrc(chunkId) { 40 return __webpack_require__.p + "" + ({"print": "print"}[chunkId] || chunkId) + ".bundle.js" 41 } 42 43 // The require function 44 function __webpack_require__(moduleId) { 45 // Check if module is in cache 46 if (installedModules[moduleId]) { 47 return installedModules[moduleId].exports; 48 } 49 // Create a new module (and put it into the cache) 50 var module = installedModules[moduleId] = { 51 i: moduleId, 52 l: false, 53 exports: {} 54 }; 55 56 // Execute the module function 57 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 58 59 // Flag the module as loaded 60 module.l = true; 61 62 // Return the exports of the module 63 return module.exports; 64 } 65 66 // This file contains only the entry chunk. 67 // The chunk loading function for additional chunks 68 __webpack_require__.e = function requireEnsure(chunkId) { 69 var promises = []; 70 71 // JSONP chunk loading for javascript 72 73 var installedChunkData = installedChunks[chunkId]; 74 if (installedChunkData !== 0) { // 0 means "already installed". 75 76 // a Promise means "currently loading". 77 if (installedChunkData) { 78 promises.push(installedChunkData[2]); 79 } else { 80 // setup Promise in chunk cache 81 var promise = new Promise(function (resolve, reject) { 82 installedChunkData = installedChunks[chunkId] = [resolve, reject]; 83 }); 84 promises.push(installedChunkData[2] = promise); 85 86 // start chunk loading 87 var script = document.createElement('script'); 88 var onScriptComplete; 89 90 script.charset = 'utf-8'; 91 script.timeout = 120; 92 if (__webpack_require__.nc) { 93 script.setAttribute("nonce", __webpack_require__.nc); 94 } 95 script.src = jsonpScriptSrc(chunkId); 96 97 // create error before stack unwound to get useful stacktrace later 98 var error = new Error(); 99 onScriptComplete = function (event) { 100 // avoid mem leaks in IE. 101 script.onerror = script.onload = null; 102 clearTimeout(timeout); 103 var chunk = installedChunks[chunkId]; 104 if (chunk !== 0) { 105 if (chunk) { 106 var errorType = event && (event.type === 'load' ? 'missing' : event.type); 107 var realSrc = event && event.target && event.target.src; 108 error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')'; 109 error.type = errorType; 110 error.request = realSrc; 111 chunk[1](error); 112 } 113 installedChunks[chunkId] = undefined; 114 } 115 }; 116 var timeout = setTimeout(function () { 117 onScriptComplete({type: 'timeout', target: script}); 118 }, 120000); 119 script.onerror = script.onload = onScriptComplete; 120 document.head.appendChild(script); 121 } 122 } 123 return Promise.all(promises); 124 }; 125 126 // expose the modules object (__webpack_modules__) 127 __webpack_require__.m = modules; 128 129 // expose the module cache 130 __webpack_require__.c = installedModules; 131 132 // define getter function for harmony exports 133 __webpack_require__.d = function (exports, name, getter) { 134 if (!__webpack_require__.o(exports, name)) { 135 Object.defineProperty(exports, name, {enumerable: true, get: getter}); 136 } 137 }; 138 139 // define __esModule on exports 140 __webpack_require__.r = function (exports) { 141 if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { 142 Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'}); 143 } 144 Object.defineProperty(exports, '__esModule', {value: true}); 145 }; 146 147 // create a fake namespace object 148 // mode & 1: value is a module id, require it 149 // mode & 2: merge all properties of value into the ns 150 // mode & 4: return value when already ns object 151 // mode & 8|1: behave like require 152 __webpack_require__.t = function (value, mode) { 153 if (mode & 1) value = __webpack_require__(value); 154 if (mode & 8) return value; 155 if ((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; 156 var ns = Object.create(null); 157 __webpack_require__.r(ns); 158 Object.defineProperty(ns, 'default', {enumerable: true, value: value}); 159 if (mode & 2 && typeof value != 'string') for (var key in value) __webpack_require__.d(ns, key, function (key) { 160 return value[key]; 161 }.bind(null, key)); 162 return ns; 163 }; 164 165 // getDefaultExport function for compatibility with non-harmony modules 166 __webpack_require__.n = function (module) { 167 var getter = module && module.__esModule ? 168 function getDefault() { 169 return module['default']; 170 } : 171 function getModuleExports() { 172 return module; 173 }; 174 __webpack_require__.d(getter, 'a', getter); 175 return getter; 176 }; 177 178 // Object.prototype.hasOwnProperty.call 179 __webpack_require__.o = function (object, property) { 180 return Object.prototype.hasOwnProperty.call(object, property); 181 }; 182 183 // __webpack_public_path__ 184 __webpack_require__.p = ""; 185 186 // on error function for async loading 187 __webpack_require__.oe = function (err) { 188 console.error(err); 189 throw err; 190 }; 191 192 var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; 193 var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 复制一个数组的push方法,这个方法的this是jsonpArray 194 jsonpArray.push = webpackJsonpCallback; // TODO: 为什么要复写push,而不是直接增加一个新方法名? 195 jsonpArray = jsonpArray.slice(); // 拷贝一个新数组 196 for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); 197 var parentJsonpFunction = oldJsonpFunction; 198 199 // Load entry module and return exports 200 return __webpack_require__(__webpack_require__.s = "./src/index.js"); 201 }) 202 /************************************************************************/ 203 ({ 204 "./src/index.js": (function (module, exports, __webpack_require__) { 205 function component() { 206 var element = document.createElement('div'); 207 var button = document.createElement('button'); 208 var br = document.createElement('br'); 209 210 button.innerHTML = 'Click me and look at the console!'; 211 element.innerHTML = 'Hello webpack'; // _.join(['Hello', 'webpack'], ' '); 212 element.appendChild(br); 213 element.appendChild(button); 214 215 button.onclick = ( 216 e => { 217 __webpack_require__.e("print") 218 .then(__webpack_require__.bind(null, "./src/print.js")) 219 .then( 220 module => { 221 var print = module.default; 222 print(); 223 } 224 ) 225 } 226 ); 227 228 return element; 229 } 230 231 document.body.appendChild(component()); 232 }) 233 });
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 "use strict"; 6 __webpack_require__.r(__webpack_exports__); 7 __webpack_exports__["default"] = (() => { 8 console.log('Button Clicked: Here\'s "some text"!'); 9 }); 10 }) 11 } 12 ]);
四 源码解读
说明:懒加载构建和和上一篇的基础构建原理中有很多相同的代码,这里不再重复说明,本文主要详细说明其中增加的懒加载方面的内容。
app.bundle.js是构建好的入口文件,里边就是一个自执行函数,基本结构和上一篇基础构建源码中一致,这里不再详细说明。下边是使用懒加载模块构建后,增加的内容,这里详细说明这些内容:
1 (function (modules) { 2 function webpackJsonpCallback(data) {...}; 3 4 // The module cache 5 var installedModules = {}; 6 7 // object to store loaded and loading chunks 8 // undefined = chunk not loaded, null = chunk preloaded/prefetched 9 // Promise = chunk loading, 0 = chunk loaded 10 var installedChunks = { 11 "app": 0 12 }; 13 14 // script path function 15 function jsonpScriptSrc(chunkId) {...} 16 17 // The require function 18 function __webpack_require__(moduleId) {...} 19 20 // This file contains only the entry chunk. 21 // The chunk loading function for additional chunks 22 __webpack_require__.e = function requireEnsure(chunkId) {...}; 23 24 // .... 25 26 // on error function for async loading 27 __webpack_require__.oe = function (err) { 28 console.error(err); 29 throw err; 30 }; 31 32 var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; 33 var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 复制一个数组的push方法,这个方法的this是jsonpArray 34 jsonpArray.push = webpackJsonpCallback; // TODO: 为什么要复写push,而不是直接增加一个新方法名? 35 jsonpArray = jsonpArray.slice(); // 拷贝一个新数组 36 for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); 37 var parentJsonpFunction = oldJsonpFunction; 38 39 // Load entry module and return exports 40 return __webpack_require__(__webpack_require__.s = "./src/index.js"); 41 }) 42 /************************************************************************/ 43 ({ 44 "./src/index.js": (function (module, exports, __webpack_require__) {...}) 45 });
我们详细分析下新增的这些代码。
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函数中看到:
1 __webpack_require__.e = function requireEnsure(chunkId) { 2 var promises = []; 3 4 // JSONP chunk loading for javascript 5 var installedChunkData = installedChunks[chunkId]; 6 if (installedChunkData !== 0) { // 0 means "already installed". 7 8 // a Promise means "currently loading". 9 if (installedChunkData) { 10 promises.push(installedChunkData[2]); 11 } else { 12 // ... 13 // 创建一个<script>标签,将路径设置为懒加载文件路径,并插入HTML,实现该懒加载文件的加载。 14 } 15 } 16 return Promise.all(promises); 17 };
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 懒加载构建原理图