[译] 别再对 Angular Modules 感到迷惑

原文连接: Avoiding common confusions with modules in Angular

Module

Angular Modules 是个至关复杂的话题,甚至 Angular 开发团队在官网上写了好几篇有关 NgModule 的文章教程。这些教程清晰的阐述了 Modules 的大部份内容,可是仍欠缺一些内容,致使不少开发者被误导。我看到不少开发者因为不知道 Modules 内部是如何工做的,因此常常理解错相关概念,使用 Modules API 的姿式也不正确。html

本文将深度解释 Modules 内部工做原理,争取帮你消除一些常见的误解,而这些错误我在 StackOverflow 上常常看到有人提问。node

模块封装

Angular 引入了模块封装的概念,这个和 ES 模块概念很相似(注:ES Modules 概念能够查看 TypeScript 中文网的 Modules),基本意思是全部声明类型,包括组件、指令和管道,只能够在当前模块内部,被其余声明的组件使用。好比,若是我在 App 组件中使用 A 模块的 a-comp 组件:git

@Component({
  selector: 'my-app',
  template: `
      <h1>Hello {{name}}</h1>
      <a-comp></a-comp>
  `
})
export class AppComponent { }

Angular 编译器就会抛出错误:github

Template parse errors: 'a-comp' is not a known element

这是由于 App 模块中没有申明 a-comp 组件,若是我想要使用这个组件,就不得不导入 A 模块,就像这样:json

@NgModule({
  imports: [..., AModule]
})
export class AppModule { }

上面描述的就是 模块封装。不只如此,若是想要 a-comp 组件正常工做,得设置它为能够公开访问,即在 A 模块的 exports 属性中导出这个组件:bootstrap

@NgModule({
  ...
  declarations: [AComponent],
  exports: [AComponent]
})
export class AModule { }

同理,对于指令和管道,也得遵照 模块封装 的规则:缓存

@NgModule({
  ...
  declarations: [
    PublicPipe, 
    PrivatePipe, 
    PublicDirective, 
    PrivateDirective
  ],
  exports: [PublicPipe, PublicDirective]
})
export class AModule {}

须要注意的是,模块封装 原则不适用于在 entryComponents 属性中注册的组件,若是你在使用动态视图时,像 译 关于 Angular 动态组件你须要知道的 这篇文章中所描述的方式去实例化动态组件,就不须要在 A 模块的 exports 属性中去导出 a-comp 组件。固然,还得导入 A 模块。网络

大多数初学者会认为 providers 也有封装规则,但实际上没有。在 非懒加载模块 中申明的任何 provider 均可以在程序内的任何地方被访问,下文将会详细解释缘由。app

模块层级

初学者最大的一个误解就是认为,一个模块导入其余模块后会造成一个模块层级,认为该模块会成为这些被导入模块的父模块,从而造成一个相似模块树的层级,固然这么想也很合理。但实际上,不存在这样的模块层级。由于 全部模块在编译阶段会被合并,因此导入和被导入模块之间不存在任何层级关系。ide

就像 组件 同样,Angular 编译器也会为根模块生成一个模块工厂,根模块就是你在 main.ts 中,以参数传入 bootstrapModule() 方法的模块:

platformBrowserDynamic().bootstrapModule(AppModule);

Angular 编译器使用 createNgModuleFactory 方法来建立该模块工厂(注:可参考 L274 -> L60 -> L109 -> L153-L155 -> L50),该方法须要几个参数(注:为清晰理解,不翻译。最新版本不包括第三个依赖参数。):

  • module class reference
  • bootstrap components
  • component factory resolver with entry components
  • definition factory with merged module providers

最后两点解释了为什么 providersentry components 没有模块封装规则,由于编译结束后没有多个模块,而仅仅只有一个合并后的模块。而且在编译阶段,编译器不知道你将如何使用 providers 和动态组件,因此编译器去控制封装。可是在编译阶段的组件模板解析过程时,编译器知道你是如何使用组件、指令和管道的,因此编译器能控制它们的私有申明。(注:providersentry components 是整个程序中的动态部分 dynamic content,Angular 编译器不知道它会被如何使用,可是模板中写的组件、指令和管道,是静态部分 static content,Angular 编译器在编译的时候知道它是如何被使用的。这点对理解 Angular 内部工做原理仍是比较重要的。)

让咱们看一个生成模块工厂的示例,假设你有 AB 两个模块,而且每个模块都定义了一个 provider 和一个 entry component

@NgModule({
  providers: [{provide: 'a', useValue: 'a'}],
  declarations: [AComponent],
  entryComponents: [AComponent]
})
export class AModule {}

@NgModule({
  providers: [{provide: 'b', useValue: 'b'}],
  declarations: [BComponent],
  entryComponents: [BComponent]
})
export class BModule {}

根模块 App 也定义了一个 provider 和根组件 app,并导入 AB 模块:

@NgModule({
  imports: [AModule, BModule],
  declarations: [AppComponent],
  providers: [{provide: 'root', useValue: 'root'}],
  bootstrap: [AppComponent]
})
export class AppModule {}

当编译器编译 App 根模块生成模块工厂时,编译器会 合并 全部模块的 providers,并只为合并后的模块建立模块工厂,下面代码展现模块工厂是如何生成的:

createNgModuleFactory(
    // reference to the AppModule class
    AppModule,

    // reference to the AppComponent that is used
    // to bootstrap the application
    [AppComponent],

    // module definition with merged providers
    moduleDef([
        ...

        // reference to component factory resolver
        // with the merged entry components
        moduleProvideDef(512, jit_ComponentFactoryResolver_5, ..., [
            ComponentFactory_<BComponent>,
            ComponentFactory_<AComponent>,
            ComponentFactory_<AppComponent>
        ])

        // references to the merged module classes 
        // and their providers
        moduleProvideDef(512, AModule, AModule, []),
        moduleProvideDef(512, BModule, BModule, []),
        moduleProvideDef(512, AppModule, AppModule, []),
        moduleProvideDef(256, 'a', 'a', []),
        moduleProvideDef(256, 'b', 'b', []),
        moduleProvideDef(256, 'root', 'root', [])
]);

从上面代码知道,全部模块的 providersentry components 都将会被合并,并传给 moduleDef() 方法,因此不管导入多少个模块,编译器只会合并模块,并只生成一个模块工厂。该模块工厂会使用模块注入器来生成合并模块对象(注:查看 L232),然而因为只有一个合并模块,Angular 将只会使用这些 providers,来生成一个单例的根注入器。

如今你可能想到,若是两个模块里定义了相同的 provider token,会发生什么?

第一个规则 则是导入其余模块的模块中定义的 provider 老是优先胜出,好比在 AppModule 中也一样定义一个 a provider

@NgModule({
  ...
  providers: [{provide: 'a', useValue: 'root'}],
})
export class AppModule {}

查看生成的模块工厂代码:

moduleDef([
     ...
     moduleProvideDef(256, 'a', 'root', []),
     moduleProvideDef(256, 'b', 'b', []),
 ]);

能够看到最后合并模块工厂包含 moduleProvideDef(256, 'a', 'root', []),会覆盖 AModule 中定义的 {provide: 'a', useValue: 'a'}

第二个规则 是最后导入模块的 providers,会覆盖前面导入模块的 providers。一样,也在 BModule 中定义一个 a provider

@NgModule({
  ...
  providers: [{provide: 'a', useValue: 'b'}],
})
export class BModule {}

而后按照以下顺序在 AppModule 中导入 AModuleBModule

@NgModule({
  imports: [AModule, BModule],
  ...
})
export class AppModule {}

查看生成的模块工厂代码:

moduleDef([
     ...
     moduleProvideDef(256, 'a', 'b', []),
     moduleProvideDef(256, 'root', 'root', []),
 ]);

因此上面代码已经验证了第二条规则。咱们在 BModule 中定义了 {provide: 'a', useValue: 'b'},如今让咱们交换模块导入顺序:

@NgModule({
  imports: [BModule, AModule],
  ...
})
export class AppModule {}

查看生成的模块工厂代码:

moduleDef([
     ...
     moduleProvideDef(256, 'a', 'a', []),
     moduleProvideDef(256, 'root', 'root', []),
 ]);

和预想同样,因为交换了模块导入顺序,如今 AModule{provide: 'a', useValue: 'a'} 覆盖了 BModule{provide: 'a', useValue: 'b'}

注:上文做者提供了 AppModule 被 @angular/compiler 编译后的代码,并针对编译后的代码分析多个 modules 的 providers 会被合并。实际上,咱们能够经过命令 yarn ngc -p ./tmp/tsconfig.json 本身去编译一个小实例看看,其中, ./node_modules/.bin/ngc@angular/compiler-cli 提供的 cli 命令。咱们可使用 ng new module 新建一个项目,个人版本是 6.0.5。而后在项目根目录建立 /tmp 文件夹,而后加上 tsconfig.json,内容复制项目根目录的 tsconfig.json,而后加上一个 module.ts 文件。 module.ts 内容包含根模块 AppModule,和两个模块 AModuleBModuleAModule 提供 AService{provide:'a', value:'a'}{provide:'b', value:'b'} 服务,而 BModule 提供 BService{provide: 'b', useValue: 'c'}AModuleBModule 按照前后顺序导入根模块 AppModule,完整代码以下:
import {Component, Inject, Input, NgModule} from '@angular/core';
import "./goog"; // goog.d.ts 源码文件拷贝到 /tmp 文件夹下
import "hammerjs";
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
export class AService {
}
@NgModule({
  providers: [
    AService,
    {provide: 'a', useValue: 'a'},
    {provide: 'b', useValue: 'b'},
  ],
})
export class AModule {
}
export class BService {
}
@NgModule({
  providers: [
    BService,
    {provide: 'b', useValue: 'c'}
  ]
})
export class BModule {
}
@Component({
  selector: 'app',
  template: `
    <p>{{name}}</p>
    <!--<a-comp></a-comp>-->
  `
})
export class AppComp {
  name = 'lx1036';
}

export class AppService {
}

@NgModule({
  imports: [AModule, BModule],
  declarations: [AppComp],
  providers: [
    AppService,
    {provide: 'a', useValue: 'b'}
  ],
  bootstrap: [AppComp]
})
export class AppModule {
}

platformBrowserDynamic().bootstrapModule(AppModule).then(ngModuleRef => console.log(ngModuleRef));
而后 yarn ngc -p ./tmp/tsconfig.json 使用 @angular/compiler 编译这个 module.ts 文件会生成多个文件,包括 module.jsmodule.factory.js
先看下 module.jsAppModule 类会被编译为以下代码,发现咱们在 @NgModule 类装饰器中写的元数据,会被赋值给 AppModule.decorators 属性,若是是属性装饰器,会被赋值给 propDecorators 属性:
var AppModule = /** @class */ (function () {
    function AppModule() {
    }
    AppModule.decorators = [
        { type: core_1.NgModule, args: [{
                    imports: [AModule, BModule],
                    declarations: [AppComp],
                    providers: [
                        AppService,
                        { provide: 'a', useValue: 'b' }
                    ],
                    bootstrap: [AppComp]
                },] },
    ];
    return AppModule;
}());
exports.AppModule = AppModule;
而后看下 module.factory.js 文件,这个文件很重要,本文关于模块 providers 合并就能够从这个文件看出。该文件 AppModuleNgFactory 对象中就包含合并后的 providers,这些 providers 来自于 AppModule,AModule,BModule,而且 AppModule 中的 providers 会覆盖其余模块的 providersBModule 中的 providers 会覆盖 AModuleproviders,由于 BModuleAModule 以后导入,能够交换导入顺序看看发生什么。其中,ɵcmf 是 createNgModuleFactory,ɵmod 是 moduleDef,ɵmpd 是 moduleProvideDefmoduleProvideDef 第一个参数是 enum NodeFlags 节点类型,用来表示当前节点是什么类型,好比 i0.ɵmpd(256, "a", "a", []) 中的 256 表示 TypeValueProvider 是个值类型。
Object.defineProperty(exports, "__esModule", { value: true });
var i0 = require("@angular/core");
var i1 = require("./module");

var AModuleNgFactory = i0.ɵcmf(
  i1.AModule,
  [],
  function (_l) {
    return i0.ɵmod([
      i0.ɵmpd(512, i0.ComponentFactoryResolver, i0.ɵCodegenComponentFactoryResolver, [[8, []], [3, i0.ComponentFactoryResolver], i0.NgModuleRef]),
      i0.ɵmpd(4608, i1.AService, i1.AService, []),
      i0.ɵmpd(1073742336, i1.AModule, i1.AModule, []),
      i0.ɵmpd(256, "a", "a", []),
      i0.ɵmpd(256, "b", "b", [])]
    );
  });
exports.AModuleNgFactory = AModuleNgFactory;

var BModuleNgFactory = i0.ɵcmf(
  i1.BModule,
  [],
  function (_l) {
    return i0.ɵmod([
      i0.ɵmpd(512, i0.ComponentFactoryResolver, i0.ɵCodegenComponentFactoryResolver, [[8, []], [3, i0.ComponentFactoryResolver], i0.NgModuleRef]),
      i0.ɵmpd(4608, i1.BService, i1.BService, []),
      i0.ɵmpd(1073742336, i1.BModule, i1.BModule, []),
      i0.ɵmpd(256, "b", "c", [])
    ]);
  });
exports.BModuleNgFactory = BModuleNgFactory;

var AppModuleNgFactory = i0.ɵcmf(
  i1.AppModule,
  [i1.AppComp], // AppModule 的 bootstrapComponnets 启动组件数据
  function (_l) {
    return i0.ɵmod([
      i0.ɵmpd(512, i0.ComponentFactoryResolver, i0.ɵCodegenComponentFactoryResolver, [[8, [AppCompNgFactory]], [3, i0.ComponentFactoryResolver], i0.NgModuleRef]),
      i0.ɵmpd(4608, i1.AService, i1.AService, []),
      i0.ɵmpd(4608, i1.BService, i1.BService, []),
      i0.ɵmpd(4608, i1.AppService, i1.AppService, []),
      i0.ɵmpd(1073742336, i1.AModule, i1.AModule, []),
      i0.ɵmpd(1073742336, i1.BModule, i1.BModule, []),
      i0.ɵmpd(1073742336, i1.AppModule, i1.AppModule, []),
      i0.ɵmpd(256, "a", "b", []),
      i0.ɵmpd(256, "b", "c", [])]);
  });
exports.AppModuleNgFactory = AppModuleNgFactory;
本身去编译实践下,会比只看文章的解释,效率更高不少。

懒加载模块

如今又有一个使人困惑的地方-懒加载模块。官方文档是这样说的(注:不翻译):

Angular creates a lazy-loaded module with its own injector, a child of the root injector… So a lazy-loaded module that imports that shared module makes its own copy of the service.

因此咱们知道 Angular 会为懒加载模块建立它本身的注入器,这是由于 Angular 编译器会为每个懒加载模块编译生成一个 独立的组件工厂。这样在该懒加载模块中定义的 providers 不会被合并到主模块的注入器内,因此若是懒加载模块中定义了与主模块有着相同的 provider,则 Angular 编译器会为该 provider 建立一份新的服务对象。

因此懒加载模块也会建立一个层级,可是注入器的层级,而不是模块层级。 在懒加载模块中,导入的全部模块一样会在编译阶段被合并为一个,就和上文非懒加载模块同样。

以上相关逻辑是在 @angular/router 包的 RouterConfigLoader 代码里,该段展现了如何加载模块和建立注入器:

export class RouterConfigLoader {

  load(parentInjector, route) {
    ...
    const moduleFactory$ = this.loadModuleFactory(route.loadChildren);
    return moduleFactory$.pipe(map((factory: NgModuleFactory<any>) => {
          ...

          const module = factory.create(parentInjector);
        ...
     }));
  }

  private loadModuleFactory(loadChildren) {
    ...
    return this.loader.load(loadChildren)
  }
}

查看这行代码:

const module = factory.create(parentInjector);

传入父注入器来建立懒加载模块新对象。

forRoot 和 forChild 静态方法

查看官网是如何介绍的(注:不翻译):

Add a CoreModule.forRoot method that configures the core UserService… Call forRoot only in the root application module, AppModule

这个建议是合理的,可是若是你不理解为何这样作,最终会写出相似下面代码:

@NgModule({
  imports: [
    SomeLibCarouselModule.forRoot(),
    SomeLibCheckboxModule.forRoot(),
    SomeLibCloseModule.forRoot(),
    SomeLibCollapseModule.forRoot(),
    SomeLibDatetimeModule.forRoot(),
    ...
  ]
})
export class SomeLibRootModule {...}

每个导入的模块(如 CarouselModuleCheckboxModule 等等)再也不定义任何 providers,可是我以为没理由在这里使用 forRoot,让咱们一块儿看看为什么在第一个地方须要 forRoot

当你导入一个模块时,一般会使用该模块的引用:

@NgModule({ providers: [AService] })
export class A {}

@NgModule({ imports: [A] })
export class B {}

这种状况下,在 A 模块中定义的全部 providers 都会被合并到主注入器,并在整个程序上下文中可用,我想你应该已经知道缘由-上文中已经解释了全部模块 providers 都会被合并,用来建立注入器。

Angular 也支持另外一种方式来导入带有 providers 的模块,它不是经过使用模块的引用来导入,而是传一个实现了 ModuleWithProviders 接口的对象:

interface ModuleWithProviders { 
   ngModule: Type<any>
   providers?: Provider[] 
}

上文中咱们能够这么改写:

@NgModule({})
class A {}

const moduleWithProviders = {
    ngModule: A,
    providers: [AService]
};

@NgModule({
    imports: [moduleWithProviders]
})
export class B {}

最好能在模块对象内使用一个静态方法来返回 ModuleWithProviders,而不是直接使用 ModuleWithProviders 类型的对象,使用 forRoot 方法来重构代码:

@NgModule({})
class A {
  static forRoot(): ModuleWithProviders {
    return {ngModule: A, providers: [AService]};
  }
}

@NgModule({
  imports: [A.forRoot()]
})
export class B {}

固然对于文中这个简单示例不必定义 forRoot 方法返回 ModuleWithProviders 类型对象,由于能够在两个模块内直接定义 providers 或如上文使用一个 moduleWithProviders 对象,这里仅仅也是为了演示效果。然而若是咱们想要分割 providers,并在被导入模块中分别定义这些 providers,那上文中的作法就颇有意义了。

好比,若是咱们想要为非懒加载模块定义一个全局的 A 服务,为懒加载模块定义一个 B 服务,就须要使用上文的方法。咱们使用 forRoot 方法为非懒加载模块返回 providers,使用 forChild 方法为懒加载模块返回 providers

@NgModule({})
class A {
  static forRoot() {
    return {ngModule: A, providers: [AService]};
  }
  static forChild() {
    return {ngModule: A, providers: [BService]};
  }
}

@NgModule({
  imports: [A.forRoot()]
})
export class NonLazyLoadedModule {}

@NgModule({
  imports: [A.forChild()]
})
export class LazyLoadedModule {}

由于非懒加载模块会被合并,因此 forRoot 中定义的 providers 全局可用(注:包括非懒加载模块和懒加载模块),可是因为懒加载模块有它本身的注入器,你在 forChild 中定义的 providers 只在当前懒加载模块内可用(注:不翻译)。

Please note that the names of methods that you use to return ModuleWithProviders structure can be completely arbitrary. The names forChild and forRoot I used in the examples above are just conventional names recommended by Angular team and used in the RouterModuleimplementation.(注:即 forRoot 和 forChild 方法名称能够随便修改。)

好吧,回到最开始要看的代码:

@NgModule({
  imports: [
    SomeLibCarouselModule.forRoot(),
    SomeLibCheckboxModule.forRoot(),
    ...

根据上文的理解,就发现没有必要在每个模块里定义 forRoot 方法,由于在多个模块中定义的 providers 须要全局可用,也没有为懒加载模块单独准备 providers(注:即本就没有切割 providers 的需求,但你使用 forRoot 强制来切割)。甚至,若是一个被导入模块没有定义任何 providers,那代码写的就更让人迷惑。

Use forRoot/forChild convention only for shared modules with providers that are going to be imported into both eager and lazy module modules

还有一个须要注意的是 forRootforChild 仅仅是方法而已,因此能够传参。好比,@angular/router 包中的 RouterModule,就定义了 forRoot 方法并传入了额外的参数:

export class RouterModule {
  static forRoot(routes: Routes, config?: ExtraOptions)

传入的 routes 参数是用来注册 ROUTES 标识(token)的:

static forRoot(routes: Routes, config?: ExtraOptions) {
  return {
    ngModule: RouterModule,
    providers: [
      {provide: ROUTES, multi: true, useValue: routes}

传入的第二个可选参数 config 是用来做为配置选项的(注:如配置预加载策略):

static forRoot(routes: Routes, config?: ExtraOptions) {
  return {
    ngModule: RouterModule,
    providers: [
      {
        provide: PreloadingStrategy,
        useExisting: config.preloadingStrategy ?
          config.preloadingStrategy :
          NoPreloading
      }

正如你所看到的,RouterModule 使用了 forRootforChild 方法来分割 providers,并传入参数来配置相应的 providers

模块缓存

Stackoverflow 上有段时间有位开发者提了个问题,担忧若是在非懒加载模块和懒加载模块导入相同的模块,在运行时会致使该模块代码有重复。这个担忧能够理解,不过没必要担忧,由于全部模块加载器会缓存全部加载的模块对象。

当 SystemJS 加载一个模块后会缓存该模块,下次当懒加载模块又再次导入该模块时,SystemJS 模块加载器会从缓存里取出该模块,而不是执行网络请求,这个过程对全部模块适用(注:Angular 内置了 SystemJsNgModuleLoader 模块加载器)。好比,当你在写 Angular 组件时,从 @angular/core 包中导入 Component 装饰器:

import { Component } from '@angular/core';

你在程序里多处引用了这个包,可是 SystemJS 并不会每次加载这个包,它只会加载一次并缓存起来。

若是你使用 angular-cli 或者本身配置 Webpack,也一样道理,它只会加载一次并缓存起来,并给它分配一个 ID,其余模块会使用该 ID 来找到该模块,从而能够拿到该模块提供的多种多样的服务。

相关文章
相关标签/搜索