【原创】Angular 路由的懒加载是如何实现的

3,631 阅读6分钟

Angular的路由功能是目前三大主流框架AVR中最强大的(没有之一),包含了多层嵌套路由、路由守护、路由模块懒加载等神奇的功能,完全能够满足各种复杂度的SPA需求了。我们来扒开这个强大功能的神秘面纱,今天就从路由懒加载开始。

在angular项目中配置懒加载路由

下面是一个典型的使用懒加载的配置方式:

  • AppModule中引入带loadChildren的路由。 loadChildren是一切的关键,如果写成component了,那就没有效果了:

const appRoutes = [
    {
        path: 'button',
        loadChildren: './button/demo.module#ButtonDemoModule'
    }
];

@NgModule({
    declarations: [AppComponent],
    imports: [
        RouterModule.forRoot(appRoutes)
    ],
    bootstrap: [AppComponent]
})
export class AppModule {
}
  • 在ButtonDemoModule中配置路由导航,下面的配置平淡无奇,写出来只是为了给大家一个上下文而已,关键部分在上面的loadChildren

const buttonDemoRoutes = [
    {
        path: 'basic', component: ButtonBasicDemoComponent
    }
];

@NgModule({
    declarations: [ButtonBasicDemoComponent],
    imports: [
        RouterModule.forChild(buttonDemoRoutes)
    ]
})
export class ButtonDemoModule {
}

这样的配置效果是,/button 这一层的路由是懒加载的,而 /button/* 这各级路由就是非懒加载的。这里是一个在线演示 http://rdk.zte.com.cn/component/,关键部分的源码在这里 http://t.cn/RKV5jph

如果你喜欢Jigsaw ( https://github.com/rdkmaster/jigsaw )组件库,请帮忙点个星星鼓励我们一下

下面这个图是给懒人准备的:

路由懒加载在Angular里的处理流程

我们深入学习了 Angular 路由的代码,发现我们在路由模块中的配置,实际上是一个 Route 数组,路由组件会根据浏览器的url和这个数组,
找到这段路由的配置信息,包含对应的组件、路由插座、是否有守护、数据等信息。

路由懒加载的关键一步是:当 Route 数组中出现 loadChildren 配置信息时,路由模块会调用注入 NgModuleFactoryLoader 服务,发起请求下载对应的包文件,然后再执行路由的后续加载组件视图的操作。

这是大概的处理流程图:

路由懒加载究竟是如何实现的

一开始我们走了弯路,以为是路由模块实现的懒加载,但是在路由的实现代码中,死活找不到具体实现的方式,到后来才发现,这完全是webpack和angular-cli的功劳。

首先有一点非常重要:angular-cli是使用了webpack进行打包的,因此有必要先看看webpack是如何打包和懒加载的。

webpack是如何打包和懒加载的

以webpack对js中引用了第三方插件的懒加载为例,在js代码中使用require.ensure,webpack可以通过require.ensure区分正常require进行切片。

require.ensure([], (require) => {
   require("bootstrap/dist/css/bootstrap.min.css");
   require("eonasdan-bootstrap-datetimepicker/build/css/bootstrap-datetimepicker.min.css");
   require("eonasdan-bootstrap-datetimepicker"); }, 'datepicker'); // datepicker就是定义的切片包名

在webpack.config.js中配置切片名

output: {
    path: helpers.root('dist'),
    filename: '[name].[hash].js',
    chunkFilename: '[name].[hash].chunk.js' // 切片命名
}

webpack的切片结果:

实际的运行效果如下,可以看到datepicker.chunk.js文件是懒加载下来的

喜欢刨根问底的同学会问,webpack是如何懒加载切片文件的呢?我们可以扒开打包后的文件来看看。

webpack打包后生成的文件里有三个重要的全局函数

  • webpackJsonp

  • __webpack_require__

  • __webpack_require__.e

下面逐个说明各自的作用。

webpackJsonp

webpack利用JSONP技术,先在浏览器定义好webpackJsonp这个函数,然后从后端下载用webpackJsonp进行封装的js,浏览器获取这样的js文件就可以立即执行了。webpackJsonp的源码如下:

(function(modules) {
   window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
       // add "moreModules" to the modules object,        // then flag all "chunkIds" as loaded and fire callback        /*......*/        for(moduleId in moreModules) {
           if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {                modules[moduleId] = moreModules[moduleId];            }        }
       /*......*/    }; })([])

webpackJsonp有三个参数

  • chunkIds 是包文件的标识;

  • moreModules 是包文件自带的modules,与moduleId一一对应,webpackJsonp会把包文件的modules复制到全局的modules;

  • executeModules 是要立即执行的模块;

webpack打包出来的文件都是用webpackJsonp这个函数封装起来的,类似于:

webpackJsonp([12, 33],{
   111:    (function(module, exports) {
       /*......*/    }),
   112:    (function(module, exports, __webpack_require__) {
       /*......*/    }),
   /*......*/
})

__webpack_require__

主要根据moduleId从全局的modules加载模块。

__webpack_require__.e

通过动态插入script标签的方式,下载对应的chunk包文件,可以看看源码

__webpack_require__.e = function requireEnsure(chunkId) {
   /*......*/    var head = document.getElementsByTagName('head')[0];
   var script = document.createElement('script');    script.type = 'text/javascript';    script.charset = 'utf-8';
   /*......*/    script.src = __webpack_require__.p + "" + chunkId + ".chunk.js";
   /*......*/    head.appendChild(script);
   return promise; }

angular-cli是如何打包的

angular-cli里面对webpack进行了定制化配置,不同于前面说的使用require.ensure进行切片打包,angular-cli
让webpack识别router里的 loadChildren关键字进行打包。让我们来看看打包后的文件。

main.bundle.js

var map = {
   "./button/demo.module": [
       919, 11    ],
   "app/demo/demo-list": [
       923, 23    ] };
function webpackAsyncContext(req) {
   var ids = map[req];
   if(!ids) {
       return Promise.reject(new Error("Cannot find module '" + req + "'."));    }
   // __webpack_require__.e就是webpack用于懒加载的函数    return __webpack_require__.e(ids[1]).then(function() {
       return __webpack_require__(ids[0]);    }); };

可以看到webpack根据路由生成了一个url与数组ids对应的对象,url是用户在路由中配的loadChildren参数,ids[1]代表chunkId,也就是对应的包文件,
ids[0]代表moduleId,即需要加载的模块。在webpackAsyncContext这个函数中,__webpack_require__.e 通过chunkId从后端下载到chunk包文件,然后
__webpack_require__ 通过moduleId加载到对应的模块。

button-demo-module对应的切片包 11.chunk.js

webpackJsonp([11,32],{
   919: (function(module, __webpack_exports__, __webpack_require__) {
       /* harmony export (binding) */        __webpack_require__.d(__webpack_exports__, "ButtonDemoModule", function() {
           return ButtonDemoModule;
       });
       var ButtonDemoModule = (function () {
           function ButtonDemoModule() {            }
           return ButtonDemoModule;        }());    }) })

最终结论

用户浏览器输入如下url时

http://localhost:4200/button/basic

RouterMoudule会通过注入的 NgModuleFactoryLoader
调取angular-cli的 webpackAsyncContext 函数。webpackAsyncContext 通过map拿到对应的chunkId,调用 __webpack_require__.e 的动态下载对应的chunk包文件,下载完成后,就像使用普通模块一样,调用 __webpack_require__ 执行对应的模块,于是在全局下就可以使用到这个模块了。

这个过程就是angilar-cli加载路由的 loadChildren 的过程。

看完了这个文章,我们发现Angular的代码和Angular-cli的关系是如此的紧(耦)密(合),所以请给我一个不使用angular-cli来初始化你的工程的理由!

题外话

这些文章都是我们在研发Jigsaw七巧板过程中的技术总结,如果你喜欢这个文章,请帮忙到 Jigsaw七巧板 (https://github.com/rdkmaster/jigsaw)的工程上点个星星鼓励我们一下,这样我们会更有动力写出类似高质量的文章。Jigsaw七巧板现在处于起步阶段,非常需要各位的呵护。