Angular进阶技术之模块化及懒加载

2023-10-29

前提摘要

有关Angular模块化知识的个人使用总结,读者了解需要掌握Angular基础知识。

模块化的场景

在Angular中,组件是一种核心的技术,组件能起到区分业务逻辑从而实现轻度解耦的Angular规范,针对不同业务,可以创建不同的组件,每个组件“大体”是互不影响的。之所以说是大体,是因为默认的情况下,每个组件的声明、依赖module的导入都会去依赖每个项目的核心组件–app组件,这个核心组件有一个app.module,默认条件下的Angular系统所有的组件、管道、服务、指令、第三方Angular体系的ui框架的模块等等都是在app.module中被声明(declarations ),引用(imports),服务提供商(provides),向外暴露(exports),这几种常见的变量数组被称为Angular模块中元数据。

@NgModule

关于@NgModule,Angular官方文档描述如下:

NgModules 用于配置注入器和编译器,并帮你把那些相关的东西组织在一起。

NgModule 是一个带有 @NgModule 装饰器的类。 @NgModule 的参数是一个元数据对象,用于描述如何编译组件的模板,以及如何在运行时创建注入器。 它会标出该模块自己的组件、指令和管道,通过 exports 属性公开其中的一部分,以便外部组件使用它们。 NgModule 还能把一些服务提供商添加到应用的依赖注入器中。

官方文档
但是官方文档并不详尽,太多的细节没有展示,一个基础的入口组件结构类似于:
在这里插入图片描述

通俗来说,@NgModule就是把一个ts类声明成Angular的模块,只要添加了这个装饰器的类,Angular就会将其作为模块来进行定义好的元数据操作,比如app.module,默认情况下,我们可以声明全部的组件,引用全部的第三方Angular体系ui框架模块,提供第三方的服务或者业务服务,唯独没有用到的是导出(向外暴露)—exports 这个元数据对象,原因无可厚非,当前已是根路径、根组件,并不需要再向上一层暴露什么了。

引发的思考

既然Angular默认情况的入口组件是一种定义了模块的形式,那么我的并非入口组件的其他组件能否也定义一个模块呢?答案是肯定的,Angular提供了这样一种模块化的规范,它将使你的Angular项目大大不同。

如何去定义模块和模块化的作用

想要定义一个Angular模块,当然需要使用@NgModule装饰器,类似的写法已在app.module中可以得到示范,此外模块是基于组件的,它的最终目的是让你的组件变成一个完全独立的"充电插头",虽然他的“材料”和"数据线"可能来自其他的制造商(第三方ui框架模块、子组件),但是在项目中它可以成为一个独立的个体,甚至离开这个项目,去到另外一个项目,操作仅仅只是复制文件夹到另一个项目!当然,前提是依赖的模块、子组件都应在新项目中也存在。例如一个通用的注册、登录,上传、下载等等一系列在一定程度能重用的组件,里面的内容或许只需修改几个字,换一个图标,换一个接口,“插到”新项目中,它就会瞬间完善你的项目。
当然,模块化的好处并不仅仅是在新项目时候“偷懒”,它还有一个至关重要的作用,就是优化项目启动速度,默认情况下的Angular的根组件的module“很累”,它要会很多东西,既要声明全部的组件,又要引入全部的依赖第三方模块,提供服务。并且是在系统刚进入(一般登录页面),业务组件页面时刷新等场景时不断一遍遍地不厌其烦地去做一整事,而且是全部的事。这样当然不行,这种默认情况下的Angular项目,到了一定的程度,就会出现登录页面加载缓慢,业务页面刷新缓慢等让用户觉得整个系统都很卡顿缓慢的问题,这些很直观的用户操作问题,会让用户体验大大降低,甚至怀疑系统的稳定性和性能问题。

Angular模块化以及路由懒加载

进入正题,如何将一个已有的组件模块化呢?
如图:
在这里插入图片描述
这里的登录组件,将当前组件的declarations放到当前模块中进行声明,引入的模块此处我已经整合了一个通用的引入并暴露的通用模块,意义等同于通用的依赖第三方模块库。

模块结构

在这里插入图片描述

share.module.ts

import {NgModule} from '@angular/core';
import {RootModule} from './root.module';
import {CookieService} from 'ngx-cookie-service';
import {TranslateModule, TranslateLoader, TranslateService} from '@ngx-translate/core';
import {TranslateHttpLoader} from '@ngx-translate/http-loader';
import {HttpClient} from '@angular/common/http';
import {en_US, NzI18nService, zh_CN, zh_TW} from 'ng-zorro-antd';
import {Title} from '@angular/platform-browser';

// ngx/translate/http-loader 国际化 使用本地json文件作为国际化资源
export function HttpLoaderFactory(http: HttpClient) {
  return new TranslateHttpLoader(http, './i18n/', '.json');
}

// 懒加载业务模块 包含核心封装模块root中的模块

@NgModule({
  declarations: [
    // 声明管道指令等 然后向外暴露或提供服务
  ],
  imports: [
    RootModule, // 基本封装模块
    TranslateModule.forRoot({ // @ngx/translate/core 国际化方案
      loader: {
        provide: TranslateLoader,
        useFactory: HttpLoaderFactory,
        deps: [HttpClient]
      }
    })
  ],
  exports: [
    RootModule,
    TranslateModule
  ],
  providers: [
    // 项目通用非核心service以及业务service
    CookieService, // cookie服务
  ]
})
export class ShareModule {
  constructor(
    private translate: TranslateService,
    private title: Title,
    private nzI18n: NzI18nService
  ) { // 国际化translate服务
    this.translate.addLangs(['zh-CN', 'en-US', 'zh-TW']);
    this.translate.setDefaultLang('en-US');
    const browserLang = this.translate.getBrowserCultureLang();
    if (localStorage.getItem('lang')) {
      const lang = localStorage.getItem('lang');
      this.translate.use(lang);
    } else {
      this.translate.use(browserLang.match(/en-US|zh-TW|zh-CN/) ? browserLang : 'en-US');
    }
    if (this.translate.currentLang === 'zh-CN') {
      this.nzI18n.setLocale(zh_CN);
    } else if (this.translate.currentLang === 'zh-TW') {
      this.nzI18n.setLocale(zh_TW);
    } else {
      this.nzI18n.setLocale(en_US);
    }
    this.translate.get('title').subscribe(res => {
      this.title.setTitle(res);
    });
  }

}

root.module.ts

import {NgModule} from '@angular/core';
import {NgZorroAntdModule} from 'ng-zorro-antd';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {CommonModule} from '@angular/common';
import {ScrollingModule} from '@angular/cdk/scrolling';
import {DragDropModule} from '@angular/cdk/drag-drop';
import {MatTabsModule} from '@angular/material/tabs';
import {MatIconModule} from '@angular/material/icon';
import {MatButtonModule, MatCardModule, MatGridListModule, MatMenuModule, MatToolbarModule, MatTooltipModule} from '@angular/material';

// 懒加载核心模块 整个项目中共用的ui框架 angular非运行必须模块等都需要import后export
@NgModule({
  imports: [ // 导入模块
    // angular 模块
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    // angular cdk
    ScrollingModule,
    DragDropModule,
    // angular material
    MatTabsModule,
    MatButtonModule,
    MatIconModule,
    MatToolbarModule,
    MatTooltipModule,
    MatMenuModule,
    MatCardModule,
    MatGridListModule,
    // ng-zorro 模块
    NgZorroAntdModule,
    // primeNg框架及其组件 由于primeNg提供了模块化的导入 具体使用时可以逐个添加需要导入的模块!
  ],
  exports: [ // 向外暴露模块
    // angular 模块
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    // angular cdk
    ScrollingModule,
    DragDropModule,
    // angular material
    MatTabsModule,
    MatButtonModule,
    MatIconModule,
    MatMenuModule,
    MatToolbarModule,
    MatTooltipModule,
    MatCardModule,
    MatGridListModule,
    // ng-zorro 模块
    NgZorroAntdModule,
    
  ],
})
export class RootModule {

}

我导入了Angular官方ui框架Angular-material 阿里ant-design ui框架 ng-zorro 使用了第三方国际化方案@ngx-translate,root.module是整合依赖的模块等,并exports向外暴露,当import root.module时,其中定义的东西能在引入层生效,share.module在引入root.module的情况下,做了其他的自定义服务提供、声明管道和指令等。至此,shareModule就可以作为一个通用依赖库来使用,如果想使用这些ui框架,当前组件的模块就引入该模块即可,这算是一种比较简便的做法。

回到当前login组件的模块,我们引入了share.module,在declarations中声明了它自己,imports中引入了路由模块,配置一个path为’'的路由路径,这个路径是路由懒加载下的默认路径,对应的是懒加载某个模块时配置的路径xxx下的空路径,路由在组件声明后才可使用该组件,组件整个系统中只能声明一次,否则会报多次声明错误,组件声明需要考虑作用域的问题,模块化中一般是自己声明自己,这样懒加载组件的时候就能声明并调用。
login.module.ts

import {NgModule} from '@angular/core';
import {LoginComponent} from './login.component';
import {RouterModule} from '@angular/router';
import {ShareModule} from '../share.module';
import {ThemeAndLangModule} from '../components/theme-and-lang/theme-and-lang.module';

@NgModule(
  {
    declarations: [LoginComponent], // 声明登录组件
    imports: [ShareModule, // 引入通用模块
      ThemeAndLangModule,  // 更改主题和语言的组件模块
      RouterModule.forChild([{path: '', component: LoginComponent}])], // 定义路由路径
    exports: [],
  }
)
export class LoginModule {

}

这是登录组件的写法,登录的组件我们必然会给他配一个路由的访问路径,默认情况下的Angular项目路由配置在app-routing.module中,它是一个基于AngularRouteModule模块的路由模块,使用forRoot来初始化路由配置。

import {NgModule} from '@angular/core';
import {Routes, RouterModule} from '@angular/router';

const routes: Routes = [ // 懒加载一级模块 登录和整体布局模块
  {path: '', redirectTo: 'login', pathMatch: 'full'},
  
 // 从Angular V8开始支持ES动态导入语法,替换掉最初的字符串导入模块的方式
  {path: 'login', loadChildren:()=>import('.login/login.module').then(m=>m.LoginModule)},
  {path: 'start', loadChildren:()=>import('.start/start.module').then(m=>m.StartModule)},

 // V8以前的字符串导入方式
 // {path: 'login', loadChildren: './login/login.module#LoginModule'},
 // {path: 'start', loadChildren: './start/start.module#StartModule'},
  {
    path: '**', redirectTo: 'login', pathMatch: 'full'
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {
}

可以看到,我的app-routing.module.ts和默认情况下的不同,默认情况下为path:'xxx’,component:‘xxxComponent’,是因为所有的component已在app.module中进行声明,那么我如果将组件模块不在app.module中声明了,我该如何去调用该组件?

这就是使用loadChildren的原因,它会根据路径去寻找模块,完成模块中的系列动作(声明、引入、导出、提供)之后就能根据路径去加载该组件,将该组件的路由引用配置到该模块当中,可以将"path"配置为空字符串’’,然后将真正意义上的"path"配置给引用的模块,更加有利对模块的路由区分和管理。

这样一种只按需加载通用模块,只声明自己并调用的模式,其他的任何组件在加载login页面时候,除了引入模块的关联以及子组件,其他的都和login没有关系,那么加载的速度必然大大提升。而login组件,则可以作为一个模块,进行移植,或者是其他工作。

延伸-子组件模块

从上文的login组件中看到,我还在其中引用一个themeAndLangModule,这个模块是我抽取出来作为切换语言和主体的组件同时在登录页面—login、业务模块—start中同时进行使用,因为用户可能在登录页面想要进行这些操作,也可能在业务页面进行这些操作,所以需要两处,但又出于开发效率来说,极其不推荐在多出地方重复多遍相同代码。

所以,子组件模式在Angular中也是经常用到。通常情况下,在app.module中声明,然后使用组件中的定义的标签 一行“<app-xx.><app-xx./>”就可以使用子组件,但是如果是模块化的方式,子组件显然不能在app.module中进行声明,那样和初衷不符,而我也不想去写两处代码,即使是简单的两个按钮和重复的逻辑,那么就将它变为子组件模块。

在这里插入图片描述

依然如此,声明自己并引用通用模块,区别在于子组件需要在父组件中进行使用,所以这个exports需要将组件暴露出去,很好理解,模块中向外暴露,上一层才能获得当前模块所声明的组件,否则就是不可见的,父组件写子组件的标签后会报错。如此,我就可以在login组件的模块中引用该模块,并在start组件中模块也引用该模块,之后两处组件都可以拥有相同的切换语言和主题按钮!

在这里插入图片描述
在这里插入图片描述

页面效果:
在这里插入图片描述
在这里插入图片描述
如此,一个不仅在不同项目还是在页面上到处可以安放的模块化子组件大功告成。

二级路由懒加载模块

看了我的app-routing.module 中的配置,有些读者可能会有疑问,如果login和starter都是懒加载的路由形式,那我的业务模块是如何加载在start组件之上的?有没有一种一懒到底的方式,如图:

懒加载
懒加载
starer或其子组件上的路由出口--懒加载
app组件中的路由出口
login组件
starter
业务组件

从app入口组件一懒到底,使用二级路由到达业务模块,当然是可行的。
只需给starter组件配置一个路由模块即可
starter-routing.module.ts

import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {StartComponent} from './start/start.component';
import {AuthGuard} from './angular-common/AuthGuard';
// 二级懒加载路由模块 需要放到根目录
const routes: Routes = [
  {
    path: '', component: StartComponent, canActivate: [AuthGuard], canActivateChild: [AuthGuard],
    children:
      [
        {path: '', redirectTo: 'test1', pathMatch: 'full'},
        {
        // V8开始懒加载方式更建议使用ES动态导入的方式,而不是之前的字符串寻址方式
          path: 'test1', loadChildren: './business/test/test.module#TestModule',
          data: {key: 'component.test1', module: 'test1', parentKey: 'component.test', parentModule: ''}
        },
        {
          path: 'test2', loadChildren: './business/test1/test1.module#Test1Module',
          data: {key: 'component.test2', module: 'test2', parentKey: 'component.test', parentModule: ''}
        },
        {
          path: 'test3', loadChildren: './business/test-menu/test-menu.module#TestMenuModule',
          data: {key: 'component.test', module: 'test3', parentKey: '', parentModule: ''}
        }
      ]
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class StartRoutingModule {
}


test.module.ts

import {NgModule} from '@angular/core';
import {TestComponent} from './test.component';
import {RouterModule} from '@angular/router';
import {ShareModule} from '../../share.module';
import {BpmnModule} from '../../components/bpmn/bpmn.module';

@NgModule({
  declarations: [TestComponent],
  imports: [ShareModule,
    RouterModule.forChild([{path: '', component: TestComponent}])
    , BpmnModule
  ],
  exports: [TestComponent]
})
export class TestModule {

}


加载组件的方式,和starer自身,如出一辙,业务组件生成一个模块,声明自己,引入通用依赖模块,
引入路由模块,注意了这里是forChild而不是forRoot,意味加载子路由,如此一懒到业务模块的路由懒加载方式完成,我的app.module.ts始终一如开头,寥寥一些Angular核心模块,其他的都是可以插拔的,甚至于在starter、login、业务模块中需要多次引用的ShareModule,其实就只在初次引用时初始化完成,因为每个模块只初始化一次,后续只是加载,也就是在login页面初始化完成,到了starter和业务组件只是调用而已,只是在组件的写法上需要多一个module,这只是模块化带来巨大好处的同时所带来的的小小麻烦吧。

注:Angular8之后,字符串形式的引用模块即将废弃,请使用ES6的动态引入,如下:

// home
        {path: '', redirectTo: 'home', pathMatch: 'full'},

        // pages
        {path: '', loadChildren: () => import('./main/pages/pages.module').then(m => m.PagesModule)},

        // apps
        {
            path: 'apps',
            canActivateChild: [AuthGuard],
            loadChildren: () => import('./main/apps/apps.module').then(m => m.AppsModule)
        },

模块化引申

此外,你也可以改造share.module.ts,甚至于抛弃这种写法,这是一种偷懒的做法,一次性加载通用的东西,你可以按需索物,正如我上面讲的,模块只初始化一次,后续都只是调用,完全不用担心多次引入的问题,那么继续细分下去,我只用到了Angular material的一个按钮,那我就只用Angular核心common等模块封装的通用模块加上一个小小Angular material 按钮模块就可以实现我的需求,这种做法是更加高效的,解耦度已经做到极限,甚至于复制整个文件夹到新项目它依然’活着’。
当整个Angular项目都采用这种做法的时候,每个业务功能的开发都能和模块相对应,此时每个业务可以看成Angular项目中’One App’或者’One Page’这样一个整体的成分。

一些命令和tips

当前路径下生成带路由和模块的组件生成指令:
1、“ng g c xxx” (ng generate component xxx) 生成组件;
2、之后同级路径下输入"ng g module xxx" 直接生成生成xxx的模块 ;
3、此外新建一个ts文件 然后使用@NgModule的修饰器以及"export class xxModule"的写法也是可以的。
如果在项目根目录中存在除app.module以外的模块,在新建组件时候不会在app.module自动声明导入,因为你已经使用多个模块。Angular/cli会提示需要skip import 忽略导入,此时创建组件需要cli命令"ng g c xxx --skip-import "不导入创建的组件。
最新版本Angular8中,如果只存在一个入口根模块app.module.ts,创建组件时会自动导入,需要“–skip-import”指令在需要组件模块化时不自动在根模块导入组件(自动导入了需要删除),如果根目录下存在多个模块,则不会自动导入,这算是新版本的一点细微差别(模块化已成主流)。

本地发布测试

无懒加载 ,只会编译app.module为一个main.js文件,极大,依赖包为vendor.js同样也是很大
在这里插入图片描述
在这里插入图片描述

使用模块化之后,main.js会变小,取而代之的是诸多模块,vendor.js也会随之变小,加载速度大幅提升

在这里插入图片描述

在这里插入图片描述

结语

有关Angular的模块化,大致如此,我喜欢多看多学,然而国内这些东西实在太少,而且大多模棱两可,写下来,作为回顾,也是一种学习。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Angular进阶技术之模块化及懒加载 的相关文章