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

lx1036發表於2018-06-13

原文連結:Avoiding common confusions with modules in Angular

Module

Angular Modules 是個相當複雜的話題,甚至 Angular 開發團隊在官網上寫了好幾篇有關 NgModule 的文章教程。這些教程清晰的闡述了 Modules 的大部分內容,但是仍欠缺一些內容,導致很多開發者被誤導。我看到很多開發者由於不知道 Modules 內部是如何工作的,所以經常理解錯相關概念,使用 Modules API 的姿勢也不正確。

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

模組封裝

Angular 引入了模組封裝的概念,這個和 ES 模組概念很類似(注:ES Modules 概念可以檢視 TypeScript 中文網的 Modules),基本意思是所有宣告型別,包括元件、指令和管道,只可以在當前模組內部,被其他宣告的元件使用。比如,如果我在 App 元件中使用 A 模組的 a-comp 元件:

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

Angular 編譯器就會丟擲錯誤:

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

這是因為 App 模組中沒有申明 a-comp 元件,如果我想要使用這個元件,就不得不匯入 A 模組,就像這樣:

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

上面描述的就是 模組封裝。不僅如此,如果想要 a-comp 元件正常工作,得設定它為可以公開訪問,即在 A 模組的 exports 屬性中匯出這個元件:

@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 都可以在程式內的任何地方被訪問,下文將會詳細解釋原因。

模組層級

初學者最大的一個誤解就是認為,一個模組匯入其他模組後會形成一個模組層級,認為該模組會成為這些被匯入模組的父模組,從而形成一個類似模組樹的層級,當然這麼想也很合理。但實際上,不存在這樣的模組層級。因為 所有模組在編譯階段會被合併,所以匯入和被匯入模組之間不存在任何層級關係。

就像 元件 一樣,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 來找到該模組,從而可以拿到該模組提供的多種多樣的服務。

相關文章