原文連接:Here is what you need to know about dynamic components in Angularnode
本文主要解釋如何在 Angular 中動態建立組件(注:在模板中使用的組件可稱爲靜態地建立組件)。git
若是你以前使用 AngularJS(第一代 Angular 框架)來編程,可能會使用 $compile
服務生成 HTML,並鏈接到數據模型從而得到雙向綁定功能:github
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 源碼裏你會常常發現這幾句話(注:爲清晰理解,不翻譯):api
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 做爲原材料,把你寫的組件/指令類編譯爲另外一個視圖工廠類。bash
回到剛剛的 JIT/AOT Compiler,若是 a.component.ngfactory.js 是在 build 階段生成的那就是 AOT Compiler,這個 Compiler 不會被打包到依賴包裏;若是是在 run 階段生成,那 Compiler 就須要被打包到依賴包裏,被用戶下載到本地,在運行時 Compiler 會編譯組件/指令類生成對應的視圖工廠類,僅此而已。下文將會看下這些 *.ngfactory.js 文件代碼是什麼樣的。app
至於 factory resolver,那就更簡單了,就是一個對象,經過它拿到那些編譯後的 factory 對象。框架
Angular 中每個組件是由組件工廠建立的,組件工廠又是由編譯器根據你寫的 @Component
裝飾器裏的元數據編譯生成的。若是你在網上讀了大量的 decorator 文章還有點迷惑,能夠參考我寫的這篇 Medium 文章 Implementing custom component decorator 。
Angular 內部使用了 視圖 概念,或者說整個框架是一顆視圖樹。每個視圖是由大量不一樣類型節點(node)組成的:元素節點,文本節點等等(注:可查看 譯 Angular DOM 更新機制)。每個節點都有其專門做用,這樣每個節點的處理只須要花不多的時間,而且每個節點都有 ViewContainerRef
和 TemplateRef
等服務供使用,還可使用 ViewChild/ViewChildren
和 ContentChild/ContentChildren
作 DOM 查詢這些節點。
注:簡單點說就是 Angular 程序是一顆視圖樹,每個視圖(view)又是有多種節點(node)組成的,每個節點又提供了模板操做 API 給開發者使用,這些節點能夠經過 DOM Query API 拿到。
每個節點包含大量信息,而且爲了性能考慮,一旦節點被建立就生效,後面不允許更改(注:被建立的節點會被緩存起來)。節點生成過程是編譯器蒐集你寫的組件信息(注:主要是你寫的組件裏的模板信息),並以組件工廠形式封裝起來。
假設你寫了以下的一個組件:
@Component({
selector: 'a-comp',
template: '<span>A Component</span>'
})
class AComponent {}
複製代碼
編譯器根據你寫的信息生成相似以下的組件工廠代碼,代碼只包含重要部分(注:下面整個代碼可理解爲視圖,其中 elementDef2
和 jit_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 組件工廠對象,繼續看。
儘管 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
標籤裏看到編譯後的組件工廠文件:
從上文中咱們知道,若是咱們能拿到組件工廠,就可使用組件工廠建立對應的組件對象,並插入到視圖裏。實際上,每個模塊都爲全部組件提供了一個獲取組件工廠的服務 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);
}
複製代碼
這是在兩個組件 AppComponent
和 BComponent
都定義在一個模塊裏才行,或者導入其餘模塊時該模塊已經有組件 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);
複製代碼
從上面代碼能夠總結動態建立視圖的通常流程以下:
模塊類也僅僅是帶有模塊裝飾器的普通類,組件類也一樣如此,而因爲裝飾器也僅僅是簡單地函數而已,在運行時可用,因此只要咱們須要,就可使用這些裝飾器如 @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';
})
}
複製代碼
爲了更好的調試信息,你可使用任何類來替換上面代碼中的匿名類。
上文中說到的編譯器說的是 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/compiler
的 JitCompilerFactory
類來實例化出一個編譯器工廠,而後經過標識 Compiler
來註冊編譯器工廠實例。以上就是所須要修改的所有代碼,就這麼點東西須要修改添加,很簡單不是麼。
若是你使用動態加載組件方式,最後須要注意的是,當父組件銷燬時,該動態加載組件須要被銷燬:
ngOnDestroy() {
if(this.cmpRef) {
this.cmpRef.destroy();
}
}
複製代碼
上面代碼將會從視圖容器裏移除該動態加載組件視圖並銷燬它。
對於全部動態加載的組件,Angular 會像對靜態加載組件同樣也執行變動檢測,這意味着 ngDoCheck
也一樣會被調用(注:可查看 Medium 這篇文章 If you think ngDoCheck means your component is being checked — read this article)。然而,就算動態加載組件申明瞭 @Input
輸入綁定,可是若是父組件輸入綁定屬性發生改變,該動態加載組件的 ngOnChanges
不會被觸發。這是由於這個檢查輸入變化的 ngOnChanges
函數,只是在編譯階段由編譯器編譯後從新生成,該函數是組件工廠的一部分,編譯時是根據模板信息編譯生成的。由於動態加載組件沒有在模板中被使用,因此該函數不會由編譯器編譯生成。
本文的全部示例代碼存放在 Github。
注:本文主要講了組件
b-comp
如何動態加載組件a-comp
,若是兩個在同一個module
,直接調用 ComponentFactoryResolver 等 API 就行;若是不在同一個module
,就使用 SystemJsNgModuleLoader 模塊加載器就行。