[譯] 關於 Angular 動態元件你需要知道的

lx1036發表於2019-02-16

原文連結:Here is what you need to know about dynamic components in Angular

Create Components Dynamically

本文主要解釋如何在 Angular 中動態建立元件(注:在模板中使用的元件可稱為靜態地建立元件)。

如果你之前使用 AngularJS(第一代 Angular 框架)來程式設計,可能會使用 $compile 服務生成 HTML,並連線到資料模型從而獲得雙向繫結功能:

const template = `<span>generated on the fly: {{name}}</span>`
const linkFn = $compile(template);
const dataModel = $scope.$new();
dataModel.name = `dynamic`;

// link data model to a template
linkFn(dataModel);

AngularJS 中指令可以修改 DOM,但是沒法知道修改了什麼。這種方式的問題和動態環境一樣,很難優化效能。動態模板當然不是 AngularJS 效能慢的主要元凶,但也是重要原因之一。

我在看了 Angular 內部程式碼一段時間後,發現這個新設計的框架非常重視效能,在 Angular 原始碼裡你會經常發現這幾句話(注:為清晰理解,不翻譯):

Attention: Adding fields to this is performance sensitive!

Note: We use one type for all nodes so that loops that loop over all nodes of a ViewDefinition stay monomorphic!

For performance reasons, we want to check and update the list every five seconds.

所以,Angular 設計者決定犧牲靈活性來獲得巨大的效能提升,如引入了 JIT 和 AOT Compiler,靜態模板(static templates),指令/模組工廠(ComponentFactory),工廠解析器(ComponentFactoryResolver)。對 AngularJS 社群來說,這些概念很陌生,甚至充滿敵意,不過不用擔心,如果你之前僅僅是聽說過這些概念,但現在想知道這些是什麼,繼續閱讀本文,將讓你茅塞頓開。

注:實際上,JIT/AOT Compiler 說的是同一個 Compiler,只是這個 Compiler 在 building time 階段還是在 running time 階段被使用而已。

至於 factory,是 Angular Compiler 把你寫的元件如 a.component.ts 編譯為 a.component.ngfactory.js,即 Compiler 使用 @Component decorator 作為原材料,把你寫的元件/指令類編譯為另一個檢視工廠類。

回到剛剛的 JIT/AOT Compiler,如果 a.component.ngfactory.js 是在 build 階段生成的那就是 AOT Compiler,這個 Compiler 不會被打包到依賴包裡;如果是在 run 階段生成,那 Compiler 就需要被打包到依賴包裡,被使用者下載到本地,在執行時 Compiler 會編譯元件/指令類生成對應的檢視工廠類,僅此而已。下文將會看下這些 *.ngfactory.js 檔案程式碼是什麼樣的。

至於 factory resolver,那就更簡單了,就是一個物件,通過它拿到那些編譯後的 factory 物件。

元件工廠和編譯器

Angular 中每一個元件是由元件工廠建立的,元件工廠又是由編譯器根據你寫的 @Component 裝飾器裡的後設資料編譯生成的。如果你在網上讀了大量的 decorator 文章還有點迷惑,可以參考我寫的這篇 Medium 文章 Implementing custom component decorator

Angular 內部使用了 檢視 概念,或者說整個框架是一顆檢視樹。每一個檢視是由大量不同型別節點(node)組成的:元素節點,文字節點等等(注:可檢視 譯 Angular DOM 更新機制)。每一個節點都有其專門作用,這樣每一個節點的處理只需要花很少的時間,並且每一個節點都有 ViewContainerRefTemplateRef 等服務供使用,還可以使用 ViewChild/ViewChildrenContentChild/ContentChildren 做 DOM 查詢這些節點。

注:簡單點說就是 Angular 程式是一顆檢視樹,每一個檢視(view)又是有多種節點(node)組成的,每一個節點又提供了模板操作 API 給開發者使用,這些節點可以通過 DOM Query API 拿到。

每一個節點包含大量資訊,並且為了效能考慮,一旦節點被建立就生效,後面不容許更改(注:被建立的節點會被快取起來)。節點生成過程是編譯器蒐集你寫的元件資訊(注:主要是你寫的元件裡的模板資訊),並以元件工廠形式封裝起來。

假設你寫了如下的一個元件:

@Component({
  selector: `a-comp`,
  template: `<span>A Component</span>`
})
class AComponent {}

編譯器根據你寫的資訊生成類似如下的元件工廠程式碼,程式碼只包含重要部分(注:下面整個程式碼可理解為檢視,其中 elementDef2jit_textDef3 可理解為節點):

function View_AComponent_0(l) {
  return jit_viewDef1(0,[
      elementDef2(0,null,null,1,`span`,...),
      jit_textDef3(null,[`My name is `,...])
    ]

上面程式碼基本描述了元件檢視的結構,並被用來例項化一個元件。其中,第一個節點 elementDef2 就是元素節點定義,第二個節點 jit_textDef3 就是文字節點定義。你可以看到每一個節點都有足夠的引數資訊來例項化,而這些引數資訊是編譯器解析所有依賴生成的,並且在執行時由框架提供這些依賴的具體值。

從上文知道,如果你能夠訪問到元件工廠,就可以使用它例項化出對應的元件物件,並使用 ViewContainerRef API 把該元件/檢視插入 DOM 中。如果你對 ViewContainerRef 感興趣,可以檢視 譯 探索 Angular 使用 ViewContainerRef 操作 DOM。應該如何使用這個 API 呢(注:下面程式碼展示如何使用 ViewContainerRef API 往檢視樹上插入一個檢視):

export class SampleComponent implements AfterViewInit {
    @ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;

    ngAfterViewInit() {
        this.vc.createComponent(componentFactory);
    }
}

好的,從上面程式碼可知道只要拿到元件工廠,一切問題就解決了。現在,問題是如何拿到 ComponentFactory 元件工廠物件,繼續看。

模組(Modules)和元件工廠解析器(ComponentFactoryResolver)

儘管 AngularJS 也有模組,但它缺少指令所需要的真正的名稱空間,並且會有潛在的命名衝突,還沒法在單獨的模組裡封裝指令。然而,很幸運,Angular 吸取了教訓,為各種宣告式型別,如指令、元件和管道,提供了合適的名稱空間(注:即 Angular 提供的 Module,使用裝飾器函式 @NgModule 裝飾一個類就能得到一個 Module)。

就像 AngularJS 那樣,Angular 中的元件是被封裝在模組中。元件自己並不能獨立存在,如果你想要使用另一個模組的一個元件,你必須匯入這個模組:

@NgModule({
    // imports CommonModule with declared directives like
    // ngIf, ngFor, ngClass etc.
    imports: [CommonModule],
    ...
})
export class SomeModule {}

同樣道理,如果一個模組想要提供一些元件給別的模組使用,就必須匯出這些元件,可以檢視 exports 屬性。比如,可以檢視 CommonModule 原始碼的做法(注:檢視 L24-L25):

const COMMON_DIRECTIVES: Provider[] = [
    NgClass,
    NgComponentOutlet,
    NgForOf,
    NgIf,
    ...
];

@NgModule({
    declarations: [COMMON_DIRECTIVES, ...],
    exports: [COMMON_DIRECTIVES, ...],
    ...
})
export class CommonModule {
}

所以每一個元件都是繫結在一個模組裡,並且不能在不同模組裡申明同一個元件,如果你這麼做了,Angular 會丟擲錯誤:

Type X is part of the declarations of 2 modules: ...

當 Angular 編譯程式時,編譯器會把在模組中 entryComponents 屬性註冊的元件,或模板裡使用的元件編譯為元件工廠(注:在所有靜態模板中使用的元件如 <a-comp></a-comp>,即靜態元件;在 entryComponents 定義的元件,即動態元件,動態元件的一個最佳示例如 Angular Material Dialog 元件,可以在 entryComponents 中註冊 DialogContentComp 元件動態載入對話方塊內容)。你可以在 Sources 標籤裡看到編譯後的元件工廠檔案:

Component Factory

從上文中我們知道,如果我們能拿到元件工廠,就可以使用元件工廠建立對應的元件物件,並插入到檢視裡。實際上,每一個模組都為所有元件提供了一個獲取元件工廠的服務 ComponentFactoryResolver。所以,如果你在模組中定義了一個 BComponent 元件並想要拿到它的元件工廠,你可以在這個元件內注入這個服務並使用它:

export class AppComponent {
  constructor(private resolver: ComponentFactoryResolver) {
    // now the `factory` contains a reference to the BComponent factory
    const factory = this.resolver.resolveComponentFactory(BComponent);
  }

這是在兩個元件 AppComponentBComponent 都定義在一個模組裡才行,或者匯入其他模組時該模組已經有元件 BComponent 對應的元件工廠。

動態載入和編譯模組

但是如果元件在其他模組定義,並且這個模組是按需載入,這樣的話是不是完蛋了呢?實際上我們照樣可以拿到某個元件的元件工廠,方法同路由使用 loadChildren 配置項按需載入模組很類似。

有兩種方式可以在執行時載入模組第一種方式 是使用 SystemJsNgModuleLoader 模組載入器,如果你使用 SystemJS 載入器的話,路由在載入子路由模組時也是用的 SystemJsNgModuleLoader 作為模組載入器。SystemJsNgModuleLoader 模組載入器有一個 load 方法來把模組載入到瀏覽器裡,同時編譯該模組和在該模組中申明的所有元件。load 方法需要傳入檔案路徑引數,並加上匯出模組的名稱,返回值是 NgModuleFactory

loader.load(`path/to/file#exportName`)

注:NgModuleFactory 原始碼是在 packages/core/linker 資料夾內,該資料夾裡的程式碼主要是粘合劑程式碼,主要都是一些介面類供 Core 模組使用,具體實現在其他資料夾內。

如果沒有指定具體的匯出模組名稱,載入器會使用預設關鍵字 default 匯出的模組名。還需注意的是,想要使用 SystemJsNgModuleLoader 還需像這樣去註冊它:

providers: [
    {
      provide: NgModuleFactoryLoader,
      useClass: SystemJsNgModuleLoader
    }
  ]

你當然可以在 provide 裡使用任何標識(token),不過路由模組使用 NgModuleFactoryLoader 標識,所以最好也使用相同 token。(注:NgModuleFactoryLoader 註冊可檢視原始碼 L68,使用可檢視 L78

模組載入並獲取元件工廠的完整程式碼如下:

@Component({
  providers: [
    {
      provide: NgModuleFactoryLoader,
      useClass: SystemJsNgModuleLoader
    }
  ]
})
export class ModuleLoaderComponent {
  constructor(private _injector: Injector,
              private loader: NgModuleFactoryLoader) {
  }

  ngAfterViewInit() {
    this.loader.load(`app/t.module#TModule`).then((factory) => {
      const module = factory.create(this._injector);
      const r = module.componentFactoryResolver;
      const cmpFactory = r.resolveComponentFactory(AComponent);
      
      // create a component and attach it to the view
      const componentRef = cmpFactory.create(this._injector);
      this.container.insert(componentRef.hostView);
    })
  }
}

但是在使用 SystemJsNgModuleLoader 時還有個問題,上面程式碼的 load() 函式內部(注:參見 L70)其實是使用了編譯器的 compileModuleAsync 方法,該方法只會為在 entryComponents 中註冊的或者在元件模板中使用的元件,去建立元件工廠。但是如果你就是不想要把元件註冊在 entryComponents 屬性裡,是不是就完蛋了呢?仍然有解決方案 —— 使用 compileModuleAndAllComponentsAsync 方法自己去載入模組。該方法會為模組裡所有元件生成元件工廠,並返回 ModuleWithComponentFactories 物件:

class ModuleWithComponentFactories<T> {
    componentFactories: ComponentFactory<any>[];
    ngModuleFactory: NgModuleFactory<T>;

下面程式碼完整展示如何使用該方法載入模組並獲取所有元件的元件工廠(注:這是上面說的 第二種方式):

ngAfterViewInit() {
  System.import(`app/t.module`).then((module) => {
      _compiler.compileModuleAndAllComponentsAsync(module.TModule)
        .then((compiled) => {
          const m = compiled.ngModuleFactory.create(this._injector);
          const factory = compiled.componentFactories[0];
          const cmp = factory.create(this._injector, [], null, m);
        })
    })
}

然而,記住,這個方法使用了編譯器的私有 API,下面是原始碼中的 文件說明

One intentional omission from this list is @angular/compiler, which is currently considered a low level api and is subject to internal changes. These changes will not affect any applications or libraries using the higher-level apis (the command line interface or JIT compilation via @angular/platform-browser-dynamic). Only very specific use-cases require direct access to the compiler API (mostly tooling integration for IDEs, linters, etc). If you are working on this kind of integration, please reach out to us first.

執行時動態建立元件

從上文中我們知道如何通過模組中的元件工廠來動態建立元件,其中模組是在執行時之前定義的,並且模組是可以提前或延遲載入的。但是,也可以不需要提前定義模組,可以像 AngularJS 的方式在執行時建立模組和元件。

首先看看上文中的 AngularJS 的程式碼是如何做的:

const template = `<span>generated on the fly: {{name}}</span>`
const linkFn = $compile(template);
const dataModel = $scope.$new();
dataModel.name = `dynamic`

// link data model to a template
linkFn(dataModel);

從上面程式碼可以總結動態建立檢視的一般流程如下:

  1. 定義元件類及其屬性,並使用裝飾器裝飾元件類
  2. 定義模組類,在模組類中申明元件類,並使用裝飾器裝飾模組類
  3. 編譯模組和模組中所有元件,拿到所有元件工廠

模組類也僅僅是帶有模組裝飾器的普通類,元件類也同樣如此,而由於裝飾器也僅僅是簡單地函式而已,在執行時可用,所以只要我們需要,就可以使用這些裝飾器如 @NgModule()/@Component() 去裝飾任何類。下面程式碼完整展示如何動態建立元件:

@ViewChild(`vc`, {read: ViewContainerRef}) vc: ViewContainerRef;

constructor(private _compiler: Compiler,
            private _injector: Injector,
            private _m: NgModuleRef<any>) {
}

ngAfterViewInit() {
  const template = `<span>generated on the fly: {{name}}</span>`;

  const tmpCmp = Component({template: template})(class {
  });
  const tmpModule = NgModule({declarations: [tmpCmp]})(class {
  });

  this._compiler.compileModuleAndAllComponentsAsync(tmpModule)
    .then((factories) => {
      const f = factories.componentFactories[0];
      const cmpRef = this.vc.createComponent(tmpCmp);
      cmpRef.instance.name = `dynamic`;
    })
}

為了更好的除錯資訊,你可以使用任何類來替換上面程式碼中的匿名類。

Ahead-of-Time Compilation

上文中說到的編譯器說的是 Just-In-Time(JIT) 編譯器,你可能聽說過 Ahead-of-Time(AOT) 編譯器,實際上 Angular 只有一個編譯器,它們僅僅是根據編譯器使用在不同階段,而採用的不同叫法。如果編譯器是被下載到瀏覽器裡,在執行時使用就叫 JIT 編譯器;如果是在編譯階段去使用,而不需要下載到瀏覽器裡,在編譯時使用就叫 AOT 編譯器。使用 AOT 方法是被 Angular 官方推薦的,並且官方文件上有詳細的 原因解釋 —— 渲染速度更快並且程式碼包更小。

如果你使用 AOT 的話,意味著執行時不存在編譯器,那上面的不需要編譯的示例仍然有效,仍然可以使用 ComponentFactoryResolver 來做,但是動態編譯需要編譯器,就沒法執行了。但是,如果非得要使用動態編譯,那就得把編譯器作為開發依賴一起打包,然後程式碼被下載到瀏覽器裡,這樣做需要點安裝步驟,不過也沒啥特別的,看看程式碼:

import { JitCompilerFactory } from `@angular/compiler`;

export function createJitCompiler() {
  return new JitCompilerFactory([{
    useDebug: false,
    useJit: true
  }]).createCompiler();
}

import { AppComponent }  from `./app.component`;

@NgModule({
  providers: [{provide: Compiler, useFactory: createJitCompiler}],
  ...
})
export class AppModule {
}

上面程式碼中,我們使用 @angular/compilerJitCompilerFactory 類來例項化出一個編譯器工廠,然後通過標識 Compiler 來註冊編譯器工廠例項。以上就是所需要修改的全部程式碼,就這麼點東西需要修改新增,很簡單不是麼。

元件銷燬

如果你使用動態載入元件方式,最後需要注意的是,當父元件銷燬時,該動態載入元件需要被銷燬:

ngOnDestroy() {
  if(this.cmpRef) {
    this.cmpRef.destroy();
  }
}

上面程式碼將會從檢視容器裡移除該動態載入元件檢視並銷燬它。

ngOnChanges

對於所有動態載入的元件,Angular 會像對靜態載入元件一樣也執行變更檢測,這意味著 ngDoCheck 也同樣會被呼叫(注:可檢視 Medium 這篇文章 If you think ngDoCheck means your component is being checked — read this article)。然而,就算動態載入元件申明瞭 @Input 輸入繫結,但是如果父元件輸入繫結屬性發生改變,該動態載入元件的 ngOnChanges 不會被觸發。這是因為這個檢查輸入變化的 ngOnChanges 函式,只是在編譯階段由編譯器編譯後重新生成,該函式是元件工廠的一部分,編譯時是根據模板資訊編譯生成的。因為動態載入元件沒有在模板中被使用,所以該函式不會由編譯器編譯生成。

Github

本文的所有示例程式碼存放在 Github

注:本文主要講了元件 b-comp 如何動態載入元件 a-comp,如果兩個在同一個 module,直接呼叫 ComponentFactoryResolver 等 API 就行;如果不在同一個 module,就使用 SystemJsNgModuleLoader 模組載入器就行。

相關文章