@angular/material 是 Angular 官方根據 Material Design 設計語言提供的 UI 庫,開發人員在開發 UI 庫時發現很多 UI 元件有著共同的邏輯,所以他們把這些共同邏輯抽出來單獨做一個包 @angular/cdk,這個包與 Material Design 設計語言無關,可以被任何人按照其他設計語言構建其他風格的 UI 庫。學習 @angular/material 或 @angular/cdk 這些包的原始碼,主要是為了學習大牛們是如何高效使用 TypeScript 語言的;學習他們如何把 RxJS 這個包使用的這麼出神入化;最主要是為了學習他們是怎麼應用 Angular 框架提供的技術。只有深入研究這些大牛們寫的程式碼,才能更快提高自己的程式碼質量,這是一件事半功倍的事情。
Portal 是什麼
最近在學習 React 時,發現 React 提供了 Portals 技術,該技術主要用來把子節點動態的顯示到父節點外的 DOM 節點上,該技術的一個經典用例應該就是 Dialog 了。設想一下在設計 Dialog 時所需要的主要功能點:當點選一個 button 時,一般需要在 body 標籤前動態掛載一個元件檢視;該 dialog 元件檢視需要共享資料。由此看出,Portal 核心就是在任意一個 DOM 節點內動態生成一個檢視,該 檢視卻可以置於框架上下文環境之外。那 Angular 中有沒有類似相關技術來解決這個問題呢?
Angular Portal 就是用來在任意一個 DOM 節點內動態生成一個檢視,該檢視既可以是一個元件檢視,也可以是一個模板檢視,並且生成的檢視可以掛載在任意一個 DOM 節點,甚至該節點可以置於 Angular 上下文環境之外,也同樣可以與該檢視共享資料。該 Portal 技術主要就涉及兩個簡單物件:PortalOutlet 和 Portal。從字面意思就可知道,PortalOutlet 應該就是把某一個 DOM 節點包裝成一個掛載容器供 Portal 來掛載,等同於 插頭-插線板 模式的 插線板;Portal 應該就是把元件檢視或者模板檢視包裝成一個 Portal 掛載到 PortalOutlet 上,等同於 插頭-插線板 模式的 插頭。這與 @angular/router 中 Router 和 RouterOutlet 設計思想很類似,在寫路由時,router-outlet 就是個掛載點,Angular 會把由 Router 包裝的元件掛載到 router-outlet 上,所以這個設計思想不是個新東西。
如何使用 Portal
Portal<T> 只是一個抽象泛型類,而 ComponentPortal<T> 和 TemplatePortal<T> 才是包裝元件或模板對應的 Portal 具體類,檢視兩個類的建構函式的主要依賴,都基本是依賴於:該元件或模板物件;檢視容器即掛載點,是通過 ViewContainerRef 包裝的物件;如果是元件檢視還得依賴 injector,模板檢視得依賴 context 變數。這些依賴物件也進一步暴露了其設計思想。
抽象類 BasePortalOutlet 是 PortalOutlet 的基本實現,同時包含了三個重要方法:attach 表示把 Portal 掛載到 PortalOutlet 上,並定義了兩個抽象方法,來具體實現掛載元件檢視還是模板檢視:
abstract attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
abstract attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C>;
複製程式碼
detach 表示從 PortalOutlet 中拆卸出該 Portal,而 PortalOutlet 中可以掛載多個 Portal,dispose 表示整體並永久銷燬 PortalOutlet。其中,還有一個重要類 DomPortalOutlet 是 BasePortalOutlet 的子類,可以在 Angular 上下文之外 建立一個 PortalOutlet,並把 Portal 掛載到該 PortalOutlet 上,比如將 body 最後子元素 div 包裝為一個 PortalOutlet,然後將元件檢視或模板檢視掛載到該掛載點上。這裡的的難點就是如果該掛載點在 Angular 上下文之外,那掛載點內的 Portal 如何與 Angular 上下文內的元件共享資料。 DomPortalOutlet 還實現了上面的兩個抽象方法:attachComponentPortal 和 attachTemplatePortal,如果對程式碼細節感興趣可接著看下文。
現在已經知道了 @angular/cdk/portal 中最重要的兩個核心,即 Portal 和 PortalOutlet,接下來寫一個 demo 看看如何使用 Portal 和 PortalOutlet 來在 Angular 上下文之外 建立一個 ComponentPortal 和 TemplatePortal。
Demo 關鍵功能包括:在 Angular 上下文內 掛載 TemplatePortal/ComponentPortal;在 Angular 上下文外 掛載 TemplatePortal/ComponentPortal;在 Angular 上下文外 共享資料。接下來讓我們逐一實現每個功能點。
Angular 上下文內掛載 Portal
在 Angular 上下文內掛載 Portal 比較簡單,首先需要做的第一步就是例項化出一個掛載容器 PortalOutlet,可以通過例項化 DomPortalOutlet 得到該掛載容器。檢視 DomPortalOutlet 的構造依賴主要包括:掛載的元素節點 Element,可以通過 @ViewChild DOM 查詢得到該元件內的某一個 DOM 元素;元件工廠解析器 ComponentFactoryResolver,可以通過當前元件構造注入拿到,該解析器是為了當 Portal 是 ComponentPortal 時解析出對應的 Component;當前程式物件 ApplicationRef,主要用來掛載元件檢視;注入器 Injector,這個很重要,如果是在 Angular 上下文外掛載元件檢視,可以用 Injector 來和元件檢視共享資料。
第二步就是使用 ComponentPortal 和 TemplatePortal 包裝對應的元件和模板,需要留意的是 TemplatePortal 還必須依賴 ViewContainerRef 物件來呼叫 createEmbeddedView() 來建立嵌入檢視。
第三步就是呼叫 PortalOutlet 的 attach() 方法掛載 Portal,進而根據 Portal 是 ComponentPortal 還是 TemplatePortal 分別呼叫 attachComponentPortal() 和 attachTemplatePortal() 方法。
通過以上三步,就可以知道該如何設計程式碼:
@Component({
selector: `portal-dialog`,
template: `
<p>Component Portal<p>
`
})
export class DialogComponent {}
@Component({
selector: `app-root`,
template: `
<h2>Open a ComponentPortal Inside Angular Context</h2>
<button (click)="openComponentPortalInsideAngularContext()">Open a ComponentPortal Inside Angular Context</button>
<div #_openComponentPortalInsideAngularContext></div>
<h2>Open a TemplatePortal Inside Angular Context</h2>
<button (click)="openTemplatePortalInsideAngularContext()">Open a TemplatePortal Inside Angular Context</button>
<div #_openTemplatePortalInsideAngularContext></div>
<ng-template #_templatePortalInsideAngularContext>
<p>Template Portal Inside Angular Context</p>
</ng-template>
`,
})
export class AppComponent {
private _appRef: ApplicationRef;
constructor(private _componentFactoryResolver: ComponentFactoryResolver,
private _injector: Injector,
@Inject(DOCUMENT) private _document) {}
@ViewChild(`_openComponentPortalInsideAngularContext`, {read: ViewContainerRef}) _openComponentPortalInsideAngularContext: ViewContainerRef;
openComponentPortalInsideAngularContext() {
if (!this._appRef) {
this._appRef = this._injector.get(ApplicationRef);
}
// instantiate a DomPortalOutlet
const portalOutlet = new DomPortalOutlet(this._openComponentPortalInsideAngularContext.element.nativeElement, this._componentFactoryResolver, this._appRef, this._injector);
// instantiate a ComponentPortal<DialogComponent>
const componentPortal = new ComponentPortal(DialogComponent);
// attach a ComponentPortal to a DomPortalOutlet
portalOutlet.attach(componentPortal);
}
@ViewChild(`_templatePortalInsideAngularContext`, {read: TemplateRef}) _templatePortalInsideAngularContext: TemplateRef<any>;
@ViewChild(`_openTemplatePortalInsideAngularContext`, {read: ViewContainerRef}) _openTemplatePortalInsideAngularContext: ViewContainerRef;
openTemplatePortalInsideAngularContext() {
if (!this._appRef) {
this._appRef = this._injector.get(ApplicationRef);
}
// instantiate a DomPortalOutlet
const portalOutlet = new DomPortalOutlet(this._openTemplatePortalInsideAngularContext.element.nativeElement, this._componentFactoryResolver, this._appRef, this._injector);
// instantiate a TemplatePortal<>
const templatePortal = new TemplatePortal(this._templatePortalInsideAngularContext, this._openTemplatePortalInsideAngularContext);
// attach a TemplatePortal to a DomPortalOutlet
portalOutlet.attach(templatePortal);
}
}
複製程式碼
查閱上面設計的程式碼,發現沒有什麼太多新的東西。通過 @ViewChild DOM 查詢到模板物件和檢視容器物件,注意該裝飾器的第二個引數 {read:},用來指定具體查詢哪種標識如 TemplateRef 還是 ViewContainerRef。當然,最重要的技術點還是 attach() 方法的實現,該方法的原始碼解析可以接著看下文。
完整程式碼可見 demo。
Angular 上下文外掛載 Portal
從上文可知道,如果想要把 Portal 掛載到 Angular 上下文外,關鍵是 PortalOutlet 的依賴 outletElement 得處於 Angular 上下文之外。這個 HTMLElement 可以通過 _document.body.appendChild(element) 來手動建立:
let container = this._document.createElement(`div`);
container.classList.add(`component-portal`);
container = this._document.body.appendChild(container);
複製程式碼
有了處於 Angular 上下文之外的一個 Element,後面的設計步驟就和上文完全一樣:例項化一個處於 Angular 上下文之外的 PortalOutlet,然後掛載 ComponentPortal 和 TemplatePortal:
@Component({
selector: `app-root`,
template: `
<h2>Open a ComponentPortal Outside Angular Context</h2>
<button (click)="openComponentPortalOutSideAngularContext()">Open a ComponentPortal Outside Angular Context</button>
<h2>Open a TemplatePortal Outside Angular Context</h2>
<button (click)="openTemplatePortalOutSideAngularContext()">Open a TemplatePortal Outside Angular Context</button>
<ng-template #_templatePortalOutsideAngularContext>
<p>Template Portal Outside Angular Context</p>
</ng-template>
`,
})
export class AppComponent {
...
openComponentPortalOutSideAngularContext() {
let container = this._document.createElement(`div`);
container.classList.add(`component-portal`);
container = this._document.body.appendChild(container);
if (!this._appRef) {
this._appRef = this._injector.get(ApplicationRef);
}
// instantiate a DomPortalOutlet
const portalOutlet = new DomPortalOutlet(container, this._componentFactoryResolver, this._appRef, this._injector);
// instantiate a ComponentPortal<DialogComponent>
const componentPortal = new ComponentPortal(DialogComponent);
// attach a ComponentPortal to a DomPortalOutlet
portalOutlet.attach(componentPortal);
}
@ViewChild(`_templatePortalOutsideAngularContext`, {read: TemplateRef}) _template: TemplateRef<any>;
@ViewChild(`_templatePortalOutsideAngularContext`, {read: ViewContainerRef}) _viewContainerRef: ViewContainerRef;
openTemplatePortalOutSideAngularContext() {
let container = this._document.createElement(`div`);
container.classList.add(`template-portal`);
container = this._document.body.appendChild(container);
if (!this._appRef) {
this._appRef = this._injector.get(ApplicationRef);
}
// instantiate a DomPortalOutlet
const portalOutlet = new DomPortalOutlet(container, this._componentFactoryResolver, this._appRef, this._injector);
// instantiate a TemplatePortal<>
const templatePortal = new TemplatePortal(this._template, this._viewContainerRef);
// attach a TemplatePortal to a DomPortalOutlet
portalOutlet.attach(templatePortal);
}
...
複製程式碼
通過上面程式碼,就可以在 Angular 上下文之外建立一個檢視,這個技術對建立 Dialog 會非常有用。
完整程式碼可見 demo。
Angular 上下文外共享資料
最難點還是如何與處於 Angular 上下文外的 Portal 共享資料,這個問題需要根據 ComponentPortal 還是 TemplatePortal 分別處理。其中,如果是 TemplatePortal,解決方法卻很簡單,注意觀察 TemplatePortal 的構造依賴,發現存在第三個可選引數 context,難道是用來向 TemplatePortal 裡傳送共享資料的?沒錯,的確如此。可以檢視 DomPortalOutlet.attachTemplatePortal() 的 75 行,就是把 portal.context 傳給元件檢視內作為共享資料使用,既然如此,TemplatePortal 共享資料問題就很好解決了:
@Component({
selector: `app-root`,
template: `
<h2>Open a TemplatePortal Outside Angular Context with Sharing Data</h2>
<button (click)="openTemplatePortalOutSideAngularContextWithSharingData()">Open a TemplatePortal Outside Angular Context with Sharing Data</button>
<input [value]="sharingTemplateData" (change)="setTemplateSharingData($event.target.value)"/>
<ng-template #_templatePortalOutsideAngularContextWithSharingData let-name="name">
<p>Template Portal Outside Angular Context, the Sharing Data is {{name}}</p>
</ng-template>
`,
})
export class AppComponent {
sharingTemplateData: string = `lx1035`;
@ViewChild(`_templatePortalOutsideAngularContextWithSharingData`, {read: TemplateRef}) _templateWithSharingData: TemplateRef<any>;
@ViewChild(`_templatePortalOutsideAngularContextWithSharingData`, {read: ViewContainerRef}) _viewContainerRefWithSharingData: ViewContainerRef;
setTemplateSharingData(value) {
this.sharingTemplateData = value;
}
openTemplatePortalOutSideAngularContextWithSharingData() {
let container = this._document.createElement(`div`);
container.classList.add(`template-portal-with-sharing-data`);
container = this._document.body.appendChild(container);
if (!this._appRef) {
this._appRef = this._injector.get(ApplicationRef);
}
// instantiate a DomPortalOutlet
const portalOutlet = new DomPortalOutlet(container, this._componentFactoryResolver, this._appRef, this._injector);
// instantiate a TemplatePortal<DialogComponentWithSharingData>
const templatePortal = new TemplatePortal(this._templateWithSharingData, this._viewContainerRefWithSharingData, {name: this.sharingTemplateData}); // <--- key point
// attach a TemplatePortal to a DomPortalOutlet
portalOutlet.attach(templatePortal);
}
...
複製程式碼
那 ComponentPortal 呢?檢視 ComponentPortal 的第三個構造依賴 Injector,它依賴的是注入器。TemplatePortal 的第三個引數 context 解決了共享資料問題,那 ComponentPortal 可不可以通過第三個引數注入器解決共享資料問題?沒錯,完全可以。可以構造一個自定義的 Injector,把共享資料儲存到 Injector 裡,然後 ComponentPortal 從 Injector 中取出該共享資料。檢視 Portal 的原始碼包,官方還很人性的提供了一個 PortalInjector 類供開發者例項化一個自定義注入器。現在思路已經有了,看看程式碼具體實現:
let DATA = new InjectionToken<any>(`Sharing Data with Component Portal`);
@Component({
selector: `portal-dialog-sharing-data`,
template: `
<p>Component Portal Sharing Data is: {{data}}<p>
`
})
export class DialogComponentWithSharingData {
constructor(@Inject(DATA) public data: any) {} // <--- key point
}
@Component({
selector: `app-root`,
template: `
<h2>Open a ComponentPortal Outside Angular Context with Sharing Data</h2>
<button (click)="openComponentPortalOutSideAngularContextWithSharingData()">Open a ComponentPortal Outside Angular Context with Sharing Data</button>
<input [value]="sharingComponentData" (change)="setComponentSharingData($event.target.value)"/>
`,
})
export class AppComponent {
...
sharingComponentData: string = `lx1036`;
setComponentSharingData(value) {
this.sharingComponentData = value;
}
openComponentPortalOutSideAngularContextWithSharingData() {
let container = this._document.createElement(`div`);
container.classList.add(`component-portal-with-sharing-data`);
container = this._document.body.appendChild(container);
if (!this._appRef) {
this._appRef = this._injector.get(ApplicationRef);
}
// Sharing data by Injector(Dependency Injection)
const map = new WeakMap();
map.set(DATA, this.sharingComponentData); // <--- key point
const injector = new PortalInjector(this._injector, map);
// instantiate a DomPortalOutlet
const portalOutlet = new DomPortalOutlet(container, this._componentFactoryResolver, this._appRef, injector); // <--- key point
// instantiate a ComponentPortal<DialogComponentWithSharingData>
const componentPortal = new ComponentPortal(DialogComponentWithSharingData);
// attach a ComponentPortal to a DomPortalOutlet
portalOutlet.attach(componentPortal);
}
複製程式碼
通過 Injector 就可以實現 ComponentPortal 與 AppComponent 共享資料了,該技術對於 Dialog 實現尤其重要,設想對於 Dialog 彈出框,需要在 Dialog 中展示來自於外部元件的資料依賴,同時 Dialog 還需要把資料傳回給外部元件。Angular Material 官方就在 @angular/cdk/portal 基礎上構造一個 @angular/cdk/overlay 包,專門處理類似覆蓋層元件的共同問題,這些類似覆蓋層元件如 Dialog, Tooltip, SnackBar 等等。
完整程式碼可見 demo。
解析 attach() 原始碼
不管是 ComponentPortal 還是 TemplatePortal,PortalOutlet 都會呼叫 attach() 方法把 Portal 掛載進來,具體掛載過程是怎樣的?檢視 BasePortalOutlet 的 attach() 的原始碼實現:
/** Attaches a portal. */
attach(portal: Portal<any>): any {
...
if (portal instanceof ComponentPortal) {
this._attachedPortal = portal;
return this.attachComponentPortal(portal);
} else if (portal instanceof TemplatePortal) {
this._attachedPortal = portal;
return this.attachTemplatePortal(portal);
}
...
}
複製程式碼
attach() 主要邏輯就是根據 Portal 型別分別呼叫 attachComponentPortal 和 attachTemplatePortal 方法。下面將分別檢視兩個方法的實現。
attachComponentPortal()
還是以 DomPortalOutlet 類為例,如果掛載的是元件檢視,就會呼叫 attachComponentPortal() 方法,第一步就是通過元件工廠解析器 ComponentFactoryResolver 解析出元件工廠物件:
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
let componentFactory = this._componentFactoryResolver.resolveComponentFactory(portal.component);
let componentRef: ComponentRef<T>;
...
複製程式碼
然後如果 ComponentPortal 定義了 ViewContainerRef,就呼叫 ViewContainerRef.createComponent 建立元件檢視,並依次插入到該檢視容器中,最後設定 ComponentPortal 銷燬回撥:
if (portal.viewContainerRef) {
componentRef = portal.viewContainerRef.createComponent(
componentFactory,
portal.viewContainerRef.length,
portal.injector || portal.viewContainerRef.parentInjector);
this.setDisposeFn(() => componentRef.destroy());
}
複製程式碼
如果 ComponentPortal 沒有定義 ViewContainerRef,就用上文的元件工廠 ComponentFactory 來建立元件檢視,但還不夠,還需要把元件檢視掛載到元件樹上,並設定 ComponentPortal 銷燬回撥,回撥包括需要從元件樹中拆卸出該檢視,並銷燬該元件:
else {
componentRef = componentFactory.create(portal.injector || this._defaultInjector);
this._appRef.attachView(componentRef.hostView);
this.setDisposeFn(() => {
this._appRef.detachView(componentRef.hostView);
componentRef.destroy();
});
}
複製程式碼
需要注意的是 this._appRef.attachView(componentRef.hostView);,當把元件檢視掛載到元件樹時會自動觸發變更檢測(change detection)。
目前元件檢視只是掛載到檢視容器裡,最後還需要在 DOM 中渲染出來:
this.outletElement.appendChild(this._getComponentRootNode(componentRef));
複製程式碼
這裡需要了解的是,檢視容器 ViewContainerRef、檢視 ViewRef、元件檢視 ComponentRef.hostView、嵌入檢視 EmbeddedViewRef 的關係。元件檢視和嵌入檢視都是檢視物件的具體形態,而檢視是需要掛載到檢視容器內才能正常工作,檢視容器內可以掛載多個檢視,而所謂的檢視容器就是包裝任意一個 DOM 元素所生成的物件。檢視容器可以通過 @ViewChild 或者當前元件構造注入獲得,如果是通過 @ViewChild 查詢拿到當前元件模板內某個元素如 div,那 Angular 就會根據這個 div 元素生成一個檢視容器;如果是當前元件構造注入獲得,那就根據當前元件掛載點如 app-root 生成檢視容器。所有的檢視都會依次作為子節點掛載到容器內。
attachTemplatePortal()
根據上文的類似設計,掛載 TemplatePortal 的原始碼 就很簡單了。在構造 TemplatePortal 必須依賴 ViewContainerRef,所以可以直接建立嵌入檢視 EmbeddedViewRef,然後手動強制執行變更檢測。不像上文 this._appRef.attachView(componentRef.hostView); 會檢測整個元件樹,這裡 viewRef.detectChanges(); 只檢測該元件及其子元件:
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
let viewContainer = portal.viewContainerRef;
let viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context);
viewRef.detectChanges();
複製程式碼
最後在 DOM 渲染出檢視:
viewRef.rootNodes.forEach(rootNode => this.outletElement.appendChild(rootNode));
複製程式碼
現在,就可以理解了如何把 Portal 掛載到 PortalOutlet 容器內的具體過程,它並不複雜。
Portal 快捷指令
讓我們重新回顧下 Portal 技術要解決的問題以及如何實現:Portal 是為了解決可以在 Angular 框架執行上下文之外動態建立子檢視,首先需要先例項化出 PortalOutlet 物件,然後例項化出一個 ComponentPortal 或 TemplatePortal,最後把 Portal 掛載到 PortalOutlet 上。整個過程非常簡單,但是難道 @angular/cdk/portal 沒有提供什麼快捷方式,避免讓開發者寫大量重複程式碼麼?有。@angular/cdk/portal 提供了兩個指令:CdkPortal 和 CdkPortalOutlet。該兩個指令會隱藏所有實現細節,開發者只需要簡單呼叫就行,使用方式可以檢視官方 demo。
demo 實踐過程中,發現兩個問題:元件檢視都會多產生一個 p 標籤;AppComponent 模板中掛載點作為 ViewContainerRef 時,掛載點還不能為 ng-template 和 ng-container,和印象中有出入。有時間在查詢,誰知道原因,也可留言幫助解答,先謝了。