Felt like the weight of the world was on my shoulders…
Pressure to break or retreat at every turn;
Facing the fear that the truth I discovered;
No telling how all this will work out;
But I've come too far to go back now.
~I am looking for freedom,
Looking for freedom…
And to find it cost me everything I have.
Well I am looking for freedom,
Looking for freedom...
And to find it may take everything I have!
—— Freedom by Anthony Hamilton
對于一個系統的框架設計來說,業務是一種桎梏,如果在框架中做了太多業務有關的事情,那么這個框架就變得狹隘且難以復用,它變成了你業務邏輯的一部分。在從會寫代碼開始,許多人就在追求代碼上的自由:動態、按需加載你需要的部分。此時框架才滿足足夠抽象和需求無關的這種條件。所以高度抽象的前提是高度動態,今天我們先來聊聊關于Angular動態加載組件(這里的所有組件均指Component,下同)相關的問題。
Angular如何在組件中聲明式加載組件
在開始之前,我們按照管理,通過angular-cli
創建一個工程,并且生成一個a
組件。
ng new dynamic-loader
cd dynamic-loader
ng g component a
使用ng serve
運行這個工程后,我們可以看到一行app works!
的文字。如果我們需要在app.comonent
中加載a.component
,會在app.comonent.html
中加入一行<app-a></app-a>
(這個selector
也是由angular-cli
進行生成),在瀏覽器中打開http://localhost:4200
,可以看到兩行文字:
app works!
a works!
第二行文字(a.component
是由angular-cli
進行生成,通常生成的HTML中是a works!
)就是組件加載成功的標志。
Angular如何在組件中動態加載組件
在Angular中,我們通常需要一個宿主(Host)來給動態加載的組件提供一個容器。這個宿主在Angular中就是<ng-template>
。我們需要找到組件中的容器,并且將目標組件加載到這個宿主中,就需要通過創建一個指令(Directive)來對容器進行標記。
我們編輯app.comonent.html
文件:
app.comonent.html
<h1>
{{title}}
</h1>
<ng-template dl-host></ng-template>
可以看到,我們在<ng-template>
上加入了一個屬性dl-host
(為了方便理解,解釋一下這其實就是dynamic-load-host的簡寫),然后我們添加一個用于標記這個屬性的指令dl-host.directive
:
dl-host.directive.ts
import { Directive, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[dl-host]'
})
export class DlHostDirective {
constructor(public viewContainerRef: ViewContainerRef) { }
}
我們在這里注入了一個ViewContainerRef
的服務,它的作用就是為組件提供容器,并且提供了一系列的管理這些組件的方法。我們可以在app.component
中通過@ViewChild
獲取到dl-host
的實例,因此進而獲取到其中的ViewContainerRef
。另外,我們需要為ViewContainerRef
提供需要創建組件A的工廠,所以還需要在app.component
中注入一個工廠生成器ComponentFactoryResolver
,并且在app.module
中將需要生成的組件注冊為一個@NgModule.entryComponent
:
app.comonent.ts
import { Component, ViewChild, ComponentFactoryResolver } from '@angular/core';
import { DlHostDirective } from './dl-host.directive';
import { AComponent } from './a/a.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'app works!';
@ViewChild(DlHostDirective) dlHost: DlHostDirective;
constructor(private componentFactoryResolver: ComponentFactoryResolver) { }
ngAfterViewInit() {
this.dlHost.viewContainerRef.createComponent(
this.componentFactoryResolver.resolveComponentFactory(AComponent)
);
}
}
app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { AComponent } from './a/a.component';
import { DlHostDirective } from './dl-host.directive';
@NgModule({
declarations: [AppComponent, AComponent, DlHostDirective],
imports: [BrowserModule, FormsModule, HttpModule],
entryComponents: [AComponent],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
這里就不得不提到一句什么是entry component
。以下是文檔原文:
An entry component is any component that Angular loads imperatively by type.
所有通過類型進行命令式加載的組件都是入口組件。
這時候我們再去驗證一下,界面展示應該和聲明式加載組件相同。
Angular中如何動態添加宿主
我們不可能在每一個需要動態添加一個宿主組件,因為我們甚至都不會知道一個組件會在哪兒被創建出來并且被添加到頁面中——就比如一個模態窗口,你希望在你需要使用的時候就能打開,而并非受限與宿主。在這種需求的前提下,我們就需要動態添加一個宿主到組件中。
現在,我們將app.component
作為宿主的載體,但是并不提供宿主的顯式聲明,我們動態去生成宿主。那么就先將app.comonent.html
文件改回去。
app.comonent.html
<h1>
{{title}}
</h1>
現在這個界面什么都沒有了,就只剩下一個標題。那么接下來我們需要往DOM中注入一個Node,例如一個<div>
節點作為頁面上的宿主,再通過工廠生成一個AComponent
并將這個組件的根節點添加到宿主上。這種情況下我們需要通過工廠直接創建組件,而不是ComponentContanerRef
。
app.comonent.ts
import {
Component, ComponentFactoryResolver, Injector, ElementRef,
ComponentRef, AfterViewInit, OnDestroy
} from '@angular/core';
import { AComponent } from './a/a.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnDestroy {
title = 'app works!';
component: ComponentRef<AComponent>;
constructor(
private componentFactoryResolver: ComponentFactoryResolver,
private elementRef: ElementRef,
private injector: Injector
) {
this.component = this.componentFactoryResolver
.resolveComponentFactory(AComponent)
.create(this.injector);
}
ngAfterViewInit() {
let host = document.createElement("div");
host.appendChild((this.component.hostView as any).rootNodes[0]);
this.elementRef.nativeElement.appendChild(host);
}
ngOnDestroy() {
this.component.destroy();
}
}
這時候我們再去驗證一下,界面展示應該也和聲明式加載組件相同。
但是通過這種方式添加的組件有一個問題,那就是無法對數據進行臟檢查,比如我們對a.component.html
以及a.component.ts
做點小修改:
a.comonent.html
<p>
{{title}}
</p>
a.comonent.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-a',
templateUrl: './a.component.html',
styleUrls: ['./a.component.css']
})
export class AComponent {
title = 'a works!';
}
這個時候你會發現并不會顯示a works!
這行文字。因此我們需要通知應用去處理這個組件的視圖,對這個組件進行臟檢查:
app.comonent.ts
import {
Component, ComponentFactoryResolver, Injector, ElementRef,
ComponentRef, ApplicationRef, AfterViewInit, OnDestroy
} from '@angular/core';
import { AComponent } from './a/a.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnDestroy {
title = 'app works!';
component: ComponentRef<AComponent>;
constructor(
private componentFactoryResolver: ComponentFactoryResolver,
private elementRef: ElementRef,
private injector: Injector,
private appRef: ApplicationRef
) {
this.component = this.componentFactoryResolver
.resolveComponentFactory(AComponent)
.create(this.injector);
appRef.attachView(this.component.hostView);
}
ngAfterViewInit() {
let host = document.createElement("div");
host.appendChild((this.component.hostView as any).rootNodes[0]);
this.elementRef.nativeElement.appendChild(host);
}
ngOnDestroy() {
this.appRef.detachView(this.component.hostView);
this.component.destroy();
}
}
如何與動態添加后的組件進行通信
組件間通信在聲明式加載組件中通常直接寫在了組件的屬性中:[]
表示@Input
,()
表示@Output
,動態加載組件也是同理。比如我們期望通過外部傳入a.component
的title
,并在title
被單擊后由外部可以知道。所以我們先對動態加載的組件本身進行修改:
a.comonent.html
<p (click)="onTitleClick()">
{{title}}
</p>
a.comonent.ts
import { Component, Output, Input, EventEmitter } from '@angular/core';
@Component({
selector: 'app-a',
templateUrl: './a.component.html',
styleUrls: ['./a.component.css']
})
export class AComponent {
@Input() title = 'a works!';
@Output() onTitleChange = new EventEmitter<any>();
onTitleClick() {
this.onTitleChange.emit();
}
}
然后再來修改外部組件:
app.comonent.ts
import {
Component, ComponentFactoryResolver, Injector, ElementRef,
ComponentRef, ApplicationRef, AfterViewInit, OnDestroy
} from '@angular/core';
import { AComponent } from './a/a.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnDestroy {
title = 'app works!';
component: ComponentRef<AComponent>;
constructor(
private componentFactoryResolver: ComponentFactoryResolver,
private elementRef: ElementRef,
private injector: Injector,
private appRef: ApplicationRef
) {
this.component = this.componentFactoryResolver
.resolveComponentFactory(AComponent)
.create(this.injector);
appRef.attachView(this.component.hostView);
(<AComponent>this.component.instance).onTitleChange
.subscribe(() => {
console.log("title clicked")
});
(<AComponent>this.component.instance).title = "a works again!";
}
ngAfterViewInit() {
let host = document.createElement("div");
host.appendChild((this.component.hostView as any).rootNodes[0]);
this.elementRef.nativeElement.appendChild(host);
}
ngOnDestroy() {
this.appRef.detachView(this.component.hostView);
this.component.destroy();
}
}
查看頁面可以看到界面就顯示了a works again!
的文字,點擊這行文字,就可以看到console中輸入了title clicked
。
寫在后面
動態加載這項技術本身的目的是為了完成“框架業務無關化”,在接下來的相關文章中,還會圍繞如何使用Angular實現框架設計的業務解耦進行展開。盡情期待。