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
最後兩點解釋了為何 providers 和 entry components 沒有模組封裝規則,因為編譯結束後沒有多個模組,而僅僅只有一個合併後的模組。並且在編譯階段,編譯器不知道你將如何使用 providers 和動態元件,所以編譯器去控制封裝。但是在編譯階段的元件模板解析過程時,編譯器知道你是如何使用元件、指令和管道的,所以編譯器能控制它們的私有申明。(注:providers 和 entry components 是整個程式中的動態部分 dynamic content,Angular 編譯器不知道它會被如何使用,但是模板中寫的元件、指令和管道,是靜態部分 static content,Angular 編譯器在編譯的時候知道它是如何被使用的。這點對理解 Angular 內部工作原理還是比較重要的。)
讓我們看一個生成模組工廠的示例,假設你有 A 和 B 兩個模組,並且每一個模組都定義了一個 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,並匯入 A 和 B 模組:
@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', [])
]);
複製程式碼
從上面程式碼知道,所有模組的 providers 和 entry 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 中匯入 AModule 和 BModule:
@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,和兩個模組 AModule 和 BModule,AModule 提供 AService 、{provide:'a', value:'a'} 和 {provide:'b', value:'b'} 服務,而 BModule 提供 BService 和 {provide: 'b', useValue: 'c'}。AModule 和 BModule 按照先後順序匯入根模組 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.js 和 module.factory.js。 先看下 module.js。AppModule 類會被編譯為如下程式碼,發現我們在 @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 會覆蓋其他模組的 providers,BModule 中的 providers 會覆蓋 AModule 的 providers,因為 BModule 在 AModule 之後匯入,可以交換匯入順序看看發生什麼。其中,ɵcmf 是 createNgModuleFactory,ɵmod 是 moduleDef,ɵmpd 是 moduleProvideDef,moduleProvideDef 第一個引數是 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 {...}
複製程式碼
每一個匯入的模組(如 CarouselModule,CheckboxModule 等等)不再定義任何 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
還有一個需要注意的是 forRoot 和 forChild 僅僅是方法而已,所以可以傳參。比如,@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 使用了 forRoot 和 forChild 方法來分割 providers,並傳入引數來配置相應的 providers。
模組快取
在 Stackoverflow 上有段時間有位開發者提了個問題,擔心如果在非懶載入模組和懶載入模組匯入相同的模組,在執行時會導致該模組程式碼有重複。這個擔心可以理解,不過不必擔心,因為所有模組載入器會快取所有載入的模組物件。
當 SystemJS 載入一個模組後會快取該模組,下次當懶載入模組又再次匯入該模組時,SystemJS 模組載入器會從快取裡取出該模組,而不是執行網路請求,這個過程對所有模組適用(注:Angular 內建了 SystemJsNgModuleLoader 模組載入器)。比如,當你在寫 Angular 元件時,從 @angular/core 包中匯入 Component 裝飾器:
import { Component } from '@angular/core';
複製程式碼
你在程式裡多處引用了這個包,但是 SystemJS 並不會每次載入這個包,它只會載入一次並快取起來。
如果你使用 angular-cli 或者自己配置 Webpack,也同樣道理,它只會載入一次並快取起來,並給它分配一個 ID,其他模組會使用該 ID 來找到該模組,從而可以拿到該模組提供的多種多樣的服務。