四年前端带你理解路由懒加载的原理

Posted 前端开发博客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了四年前端带你理解路由懒加载的原理相关的知识,希望对你有一定的参考价值。

原文:https://segmentfault.com/a/1190000022846552

前言

说起路由懒加载,大家很快就知道怎么实现它,但是问到路由懒加载的原理,怕有一部分小伙伴是一头雾水了吧。下面带大家一起去理解路由懒加载的原理。

路由懒加载也可以叫做路由组件懒加载,最常用的是通过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>
    <div>
        tip
    </div>
</template>
<script>
export default 
    data()
        return 
            tip:'欢迎使用Vue项目'
        
    

</script>

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,仅仅把下面这个数组pushwindow["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

  • jsonpArraypush方法赋值给oldJsonpFunction

  • webpackJsonpCallback函数拦截jsopArraypush方法,也就是说调用window["webpackJsonp"]push方法都会执行webpackJsonpCallback函数。

  • jsonpArray浅拷贝一下再赋值给jsonpArray

  • 因为执行chunk-vendors.js中的window["webpackJsonp"].pushpush方法还未被webpackJsonpCallback函数拦截,所以要循环jsonpArray,将每项作为参数传入webpackJsonpCallback函数并调用。

  • jsonpArraypush方法再赋值给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函数有什么作用,要先弄明白modulesinstalledChunksdeferredModules这三个变量的作用。

  • 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)这句代码在多入口项目中才有作用,在前面提到过jsonpArraypush方法被赋值给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。

推荐阅读:

前端常用的60多种JavaScript工具方法

JavaScript中的这些骚操作,你都知道吗?

Vue 3中令人激动的新功能:Composition API

掘金小册全网8折优惠入口

公众号后台回复【前端】加入群

好文和朋友一起看~

以上是关于四年前端带你理解路由懒加载的原理的主要内容,如果未能解决你的问题,请参考以下文章

React中路由懒加载与Suspense

Vue-cli4 对路由配置的一些理解

8年前端带你HTML+CSS入门到实战(附视频+源码)

图片懒加载实现原理?

Vue 路由懒加载

vue路由懒加载