『Webpack系列』—— 路由懒加载的原理
前言
说起路由懒加载,大家很快就知道怎么实现它,但是问到路由懒加载的原理,怕有一部分小伙伴是一头雾水了吧。下面带大家一起去理解路由懒加载的原理。
路由懒加载也可以叫做路由组件懒加载,最常用的是通过import()
来实现它。
function load(component) {
return () => import(`views/${component}`)
}
然后通过Webpack编译打包后,会把每个路由组件的代码分割成一一个js文件,初始化时不会加载这些js文件,只当激活路由组件才会去加载对应的js文件。
在这里先不管Webpack是怎么按路由组件分割代码,只管在Webpack编译后,怎么实现按需加载对应的路由组件js文件。
一、准备工作
1、搭建项目
想要理解路由懒加载的原理,建议从最简单的项目开始,用Vue Cli3搭建一个项目,其中只包含一个路由组件。在main.js只引入vue-router,其它统统不要。
main.js
import Vue from 'vue';
import App from './App.vue';
import Router from 'vue-router';
Vue.use(Router);
//路由懒加载
function load(component) {
return () => import(`views/${component}`)
}
// 路由配置
const router = new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: load('Home'),
meta: {
title: '首页'
}
},
]
});
new Vue({
router,
render: h => h(App)
}).$mount('#app')
views/Home.vue
<template>
{{tip}}
</template>
export default {
data(){
return {
tip:'欢迎使用Vue项目'
}
}
}
2、webpackChunkName
利用webpackChunkName,使编译打包后的js文件名字能和路由组件一一对应,修改一下load函数。
function load(component) {
return () => import(/* webpackChunkName: "[request]" */ `views/${component}`)
}
3、去掉代码压缩混淆
去掉代码压缩混淆,便于我们阅读编译打包后的代码。在vue.config.js中配置
module.exports={
chainWebpack:config => {
config.optimization.minimize(false);
},
}
4、npm run build
执行命令npm run build
,编译打包后的dist文件结构如下所示
其中Home.67f3cd34.js就是路由组件Home.vue编译打包后对应的js文件。
二、分析index.html
从上面我们可以看到,先用link定义Home.js、app.js、chunk-vendors.js这些资源和web客户端的关系。
ref=preload
:告诉浏览器这个资源要给我提前加载。rel=prefetch
:告诉浏览器这个资源空闲的时候给我加载一下。as=script
:告诉浏览器这个资源是script,提升加载的优先级。
然后在body里面加载了chunk-vendors.js、app.js这两个js资源。可以看出web客户端初始化时候就加载了这个两个js资源。
三、分析chunk-vendors.js
chunk-vendors.js可以称为项目公共模块集合,代码精简后如下所示,
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["chunk-vendors"],{
"01f9":(function(module,exports,__webpack_require__){
...//省略
})
...//省略
}])
从代码中可以看出,执行chunk-vendors.js,仅仅把下面这个数组push
到window["webpackJsonp"]
中,而数组第二项是个对象,对象的每个value值是一个函数表达式,不会执行。就这样结束了,当然不是,我们带着window["webpackJsonp"]
去app.js中找找。
四、分析app.js
app.js可以称为项目的入口文件。
app.js里面是一个自执行函数,通过搜索window["webpackJsonp"]
可以找到如下相关代码。
(function(modules){
//省略...
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;
//省略...
}({
0:(function(module, exports, __webpack_require__) {
module.exports = __webpack_require__("56d7");
})
//省略...
}))
- 先把
window["webpackJsonp"]
赋值给jsonpArray
。 - 把
jsonpArray
的push
方法赋值给oldJsonpFunction
。 - 用
webpackJsonpCallback
函数拦截jsopArray
的push
方法,也就是说调用window["webpackJsonp"]
的push
方法都会执行webpackJsonpCallback
函数。 - 将
jsonpArray
浅拷贝一下再赋值给jsonpArray
。 - 因为执行chunk-vendors.js中的
window["webpackJsonp"].push
时push
方法还未被webpackJsonpCallback
函数拦截,所以要循环jsonpArray
,将每项作为参数传入webpackJsonpCallback
函数并调用。 - 将
jsonpArray
的push
方法再赋值给parentJsonpFunction
。
1、webpackJsonpCallback函数
接下来我们看一下webpackJsonpCallback
这个函数。
(function(modules){
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];
var executeModules = data[2];
var moduleId, chunkId, i = 0, resolves = [];
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (Object.prototype.hasOwnProperty.call(installedChunks, chunkId)
&& 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()();
}
deferredModules.push.apply(deferredModules, executeModules || []);
return checkDeferredModules();
};
var installedChunks = {
"app": 0
};
//省略...
}({
0:(function(module, exports, __webpack_require__) {
module.exports = __webpack_require__("56d7");
})
//省略...
}))
想知道webpackJsonpCallback
函数有什么作用,要先弄明白modules
、installedChunks
、deferredModules
这三个变量的作用。
- module是指任意的代码块,chunk是webpack处理过程中被分组的module的合集。
modules
缓存所有的module(代码块),调用modules
中的module就可以执行里面的代码。installedChunks
缓存所有chunk的加载状态,如果installedChunks[chunk]
为0,代表chunk已经加载完毕。deferredModules
中每项也是一个数组,例如[module,chunk1,chunk2,chunk3]
,其作用是如果要执行module,必须在chunk1、chunk2、chunk3都加载完毕后才能执行。
if (parentJsonpFunction) parentJsonpFunction(data)
这句代码在多入口项目中才有作用,在前面提到过jsonpArray
的push
方法被赋值给parentJsonpFunction
,调用parentJsonpFunction
是真正把chunk中push方法中的参数push到window["webpackJsonp"]
这个数组中。
比如说现在项目有两个入口,app.js和app1.js,app.js中缓存一些module,在app1.js就可以通过window["webpackJsonp"]
来调用这些module,调用代码如下。
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
再来理解webpackJsonpCallback
函数是不是清楚了很多,接下来看一下checkDeferredModules
这个函数。
2、checkDeferredModules函数
var deferredModules = [];
var installedChunks = {
"app": 0
}
function checkDeferredModules() {
var result;
for (var i = 0; i < deferredModules.length; i++) {
var deferredModule = deferredModules[i];
var fulfilled = true;
for (var j = 1; j < deferredModule.length; j++) {
var depId = deferredModule[j];
if (installedChunks[depId] !== 0) fulfilled = false;
}
if (fulfilled) {
deferredModules.splice(i--, 1);
result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
}
}
return result;
}
- 循环
deferredModules
,创建变量fulfilled
表示deferredModule
中的chunk加载情况,true
表示全部加载完毕,false
表示未全部加载完毕。 - 从
j=1
开始循环deferredModule
中的chunk,因为deferredModule[0]
是module,如果installedChunks[chunk]!==0
,则这个chunk未加载完毕,把变量fulfilled
设置为false
。循环结束后返回result。 - 经循环
deferredModule
中的chunk并判断chunk的加载状态后,fulfilled
还是为true,则调用__webpack_require__
函数,将deferredModule[0]
(module)作为参数传入执行。 deferredModules.splice(i--, 1)
,删除满足条件的deferredModule,并将i减一,其中i--
是先使用i,然后在减一。
因为在webpackJsonpCallback
函数中deferredModules
为[]
,所以回到主体函数继续往下看。
deferredModules.push([0, "chunk-vendors"]);
return checkDeferredModules();
按上面逻辑分析后,会执行__webpack_require__(0)
,那么来看一下__webpack_require__
这个函数。
3、__webpack_require__函数
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__
就是一个执行module的方法。
installedModules
用来缓存module的执行状态。- 通过moduleId在modules(在
webpackJsonpCallback
函数中缓存所有module的集合)获取对应的module用call方法执行。 - 将执行结果赋值到module.exports并返回。
所以执行__webpack_require__(0)
,其实就是执行下面的代码。
(function (module, exports, __webpack_require__) {
module.exports = __webpack_require__("56d7");
}),
在里面又用__webpack_require__
执行id为56d7的module,我们找到对应的module继续看,看一下里面关键的代码片段。
function load(component) {
return function () {
return __webpack_require__("9dac")("./".concat(component));
};
}
var routes = [{
path: '/',
name: 'home',
component: load('Home'),
meta: {
title: '首页'
}
}, {
path: '*',
redirect: {
path: '/'
}
}];
看到这里是不是非常熟悉了,就是配置路由的地方。load
还是作为加载路由组件的函数,里面用__webpack_require__("9dac")
返回的方法来执行加载路由组件,我们来看一下__webpack_require__("9dac")
。
(function (module, exports, __webpack_require__) {
var map = {
"./Home": [
"bb51",
"Home"
],
"./Home.vue": [
"bb51",
"Home"
]
};
function webpackAsyncContext(req) {
if (!__webpack_require__.o(map, req)) {
return Promise.resolve().then(function () {
var e = new Error("Cannot find module '" + req + "'");
e.code = 'MODULE_NOT_FOUND';
throw e;
});
}
var ids = map[req], id = ids[0];
return __webpack_require__.e(ids[1]).then(function () {
return __webpack_require__(id);
});
}
webpackAsyncContext.keys = function webpackAsyncContextKeys() {
return Object.keys(map);
};
webpackAsyncContext.id = "9dac";
module.exports = webpackAsyncContext;
})
4、webpackAsyncContext函数
其中的关键函数为webpackAsyncContext
,调用load('Home')
时,req
为'./Home'
,__webpack_require__.o
方法为
__webpack_require__.o = function (object, property) {
return Object.prototype.hasOwnProperty.call(object, property);
};
这个方法就是判断在变量map
中有没有key为./Home
的项,如果没有抛出Cannot find module './Home'
的错误。有执行__webpack_require__.e
方法,参数为Home
。
5、webpack_require.e方法
var installedChunks = {
"app": 0
}
__webpack_require__.p = "/";
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "js/" + ({ "Home": "Home" }[chunkId] || chunkId) +
"." + { "Home": "37ee624e" }[chunkId] + ".js"
}
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) {
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
var promise = new Promise(function (resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
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);
var error = new Error();
onScriptComplete = function (event) {
// 避免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);
script.onerror = script.onload = onScriptComplete;
document.head.appendChild(script);
}
}
return Promise.all(promises);
};
__webpack_require__.e
方法是实现懒加载的核心,在这个方法里面处理了三件事情。
- 使用JSONP模式加载路由对应的js文件,也可以称为chunk。
- 设置chunk加载的三种状态并缓存在
installedChunks
中,防止chunk重复加载。 - 处理chunk加载超时和加载出错的场景。
chunk加载的三种状态
installedChunks[chunkId]
为0
,代表该chunk已经加载完毕。installedChunks[chunkId]
为undefined
,代表该chunk加载失败、加载超时、从未加载过。installedChunks[chunkId]
为Promise
对象,代表该chunk正在加载。
chunk加载超时处理
script.timeout = 120;
var timeout = setTimeout(function () {
onScriptComplete({ type: 'timeout', target: script });
}, 120000);
script.timeout = 120
代表该chunk加载120秒后还没加载完毕则超时。
用setTimeout
设置个120秒的计时器,在120秒后执行onScriptComplete({ type: 'timeout', target: script })
。
在看一下onScriptComplete
函数
var onScriptComplete = function (event) {
// 避免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;
}
};
此时chunkId为Home
,加载是Home.js,代码是
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["Home"],{
"bb51":(function(module, __webpack_exports__, __webpack_require__){
//省略...
})
}]))
在前面有提到window["webpackJsonp"]
的push方法被webpackJsonpCallback
函数拦截了,如果Home.js加载成功会自动执行,随后会执行webpackJsonpCallback
函数,其中有installedChunks[chunkId] = 0;
会把installedChunks['Home']
的值置为0。
也就是说,如果Home.js加载超时了,就不能执行,就不能将installedChunks['Home']
的值置为0,所以此时installedChunks['Home']
的值还是Promise
对象。那么就会进入以下代码执行,最后chunk[1](error)
将错误抛出去。
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);
}
}
chunk[1]
其实就是reject函数,在以下代码中给它赋值的。
var promise = new Promise(function (resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
chunk加载失败处理
加载失败分为两种情况,一是Home.js资源加载失败,二是资源加载成功了,但是执行Home.js里面代码出错了导致失败,所以chunk加载失败处理的代码要这么写
script.onerror = script.onload = onScriptComplete;
后面处理的方式和处理加载超时的一样。
__webpack_require__.e
最后返回是一个Promise
对象。回到webpackAsyncContext
函数中
return __webpack_require__.e(ids[1]).then(function () {
return __webpack_require__(id);
});
__webpack_require__.e(ids[1])
执行成功后,执行 __webpack_require__(id);
,此时id为bb51。那么又回到__webpack_require__
函数中了。在前面提过__webpack_require__
函数的作用就是执行module。id为bb51的nodule是在Home.js内,在webpackJsonpCallback
函数有以下代码
function webpackJsonpCallback(data) {
var moreModules = data[1];
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
}
五、分析Home.js
Home.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["Home"],{
"bb51":(function(module, __webpack_exports__, __webpack_require__){
//省略...
})
}]))
可以看出moreModules就是{"bb51":(function(module, __webpack_exports__, __webpack_require__){})}
,
循环moreModules,把Home.js里面的module缓存到app.js里面的modules中。
再看__webpack_require__
函数中有这段代码
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
这样就执行了Home.js里面的module,在module里面有渲染页面的一系列的方法,就把Home.vue这个路由组件页面渲染出来了。
到这里路由组件懒加载的整个流程就结束了,也详细介绍了怎么加载chunk和怎么执行module。