[譯] 別再對 Angular Modules 感到迷惑

原文連接:Avoiding common confusions with modules in Angularhtml

Module

Angular Modules 是個至關複雜的話題,甚至 Angular 開發團隊在官網上寫了好幾篇有關 NgModule 的文章教程。這些教程清晰的闡述了 Modules 的大部份內容,可是仍欠缺一些內容,致使不少開發者被誤導。我看到不少開發者因爲不知道 Modules 內部是如何工做的,因此常常理解錯相關概念,使用 Modules API 的姿式也不正確。node

本文將深度解釋 Modules 內部工做原理,爭取幫你消除一些常見的誤解,而這些錯誤我在 StackOverflow 上常常看到有人提問。git

模塊封裝

Angular 引入了模塊封裝的概念,這個和 ES 模塊概念很相似(注:ES Modules 概念能夠查看 TypeScript 中文網的 Modules),基本意思是全部聲明類型,包括組件、指令和管道,只能夠在當前模塊內部,被其餘聲明的組件使用。好比,若是我在 App 組件中使用 A 模塊的 a-comp 組件:github

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

Angular 編譯器就會拋出錯誤:json

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

這是由於 App 模塊中沒有申明 a-comp 組件,若是我想要使用這個組件,就不得不導入 A 模塊,就像這樣:緩存

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

上面描述的就是 模塊封裝。不只如此,若是想要 a-comp 組件正常工做,得設置它爲能夠公開訪問,即在 A 模塊的 exports 屬性中導出這個組件:bash

@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 模塊。app

大多數初學者會認爲 providers 也有封裝規則,但實際上沒有。在 非懶加載模塊 中申明的任何 provider 均可以在程序內的任何地方被訪問,下文將會詳細解釋緣由。

模塊層級

初學者最大的一個誤解就是認爲,一個模塊導入其餘模塊後會造成一個模塊層級,認爲該模塊會成爲這些被導入模塊的父模塊,從而造成一個相似模塊樹的層級,固然這麼想也很合理。但實際上,不存在這樣的模塊層級。由於 全部模塊在編譯階段會被合併,因此導入和被導入模塊之間不存在任何層級關係。

就像 組件 同樣,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 來找到該模塊,從而能夠拿到該模塊提供的多種多樣的服務。

相關文章
相關標籤/搜索