@angular/material 是 Angular 官方根據 Material Design 設計語言提供的 UI 庫,開發人員在開發 UI 庫時發現不少 UI 組件有着共同的邏輯,因此他們把這些共同邏輯抽出來單獨作一個包 @angular/cdk,這個包與 Material Design 設計語言無關,能夠被任何人按照其餘設計語言構建其餘風格的 UI 庫。學習 @angular/material 或 @angular/cdk 這些包的源碼,主要是爲了學習大牛們是如何高效使用 TypeScript 語言的;學習他們如何把 RxJS 這個包使用的這麼出神入化;最主要是爲了學習他們是怎麼應用 Angular 框架提供的技術。只有深刻研究這些大牛們寫的代碼,才能更快提升本身的代碼質量,這是一件事半功倍的事情。html
最近在學習 React 時,發現 React 提供了 Portals 技術,該技術主要用來把子節點動態的顯示到父節點外的 DOM 節點上,該技術的一個經典用例應該就是 Dialog 了。設想一下在設計 Dialog 時所須要的主要功能點:當點擊一個 button 時,通常須要在 body 標籤前動態掛載一個組件視圖;該 dialog 組件視圖須要共享數據。由此看出,Portal 核心就是在任意一個 DOM 節點內動態生成一個視圖,該 視圖卻能夠置於框架上下文環境以外。那 Angular 中有沒有相似相關技術來解決這個問題呢?react
Angular Portal 就是用來在任意一個 DOM 節點內動態生成一個視圖,該視圖既能夠是一個組件視圖,也能夠是一個模板視圖,而且生成的視圖能夠掛載在任意一個 DOM 節點,甚至該節點能夠置於 Angular 上下文環境以外,也一樣能夠與該視圖共享數據。該 Portal 技術主要就涉及兩個簡單對象:PortalOutlet 和 Portal。從字面意思就可知道,PortalOutlet 應該就是把某一個 DOM 節點包裝成一個掛載容器供 Portal 來掛載,等同於 插頭-插線板 模式的 插線板;Portal 應該就是把組件視圖或者模板視圖包裝成一個 Portal 掛載到 PortalOutlet 上,等同於 插頭-插線板 模式的 插頭。這與 @angular/router 中 Router 和 RouterOutlet 設計思想很相似,在寫路由時,router-outlet 就是個掛載點,Angular 會把由 Router 包裝的組件掛載到 router-outlet 上,因此這個設計思想不是個新東西。git
Portal<T> 只是一個抽象泛型類,而 ComponentPortal<T> 和 TemplatePortal<T> 纔是包裝組件或模板對應的 Portal 具體類,查看兩個類的構造函數的主要依賴,都基本是依賴於:該組件或模板對象;視圖容器即掛載點,是經過 ViewContainerRef 包裝的對象;若是是組件視圖還得依賴 injector,模板視圖得依賴 context 變量。這些依賴對象也進一步暴露了其設計思想。github
抽象類 BasePortalOutlet 是 PortalOutlet 的基本實現,同時包含了三個重要方法:attach 表示把 Portal 掛載到 PortalOutlet 上,並定義了兩個抽象方法,來具體實現掛載組件視圖仍是模板視圖:api
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,若是對代碼細節感興趣可接着看下文。bash
如今已經知道了 @angular/cdk/portal 中最重要的兩個核心,即 Portal 和 PortalOutlet,接下來寫一個 demo 看看如何使用 Portal 和 PortalOutlet 來在 Angular 上下文以外 建立一個 ComponentPortal 和 TemplatePortal。app
Demo 關鍵功能包括:在 Angular 上下文內 掛載 TemplatePortal/ComponentPortal;在 Angular 上下文外 掛載 TemplatePortal/ComponentPortal;在 Angular 上下文外 共享數據。接下來讓咱們逐一實現每一個功能點。框架
在 Angular 上下文內掛載 Portal 比較簡單,首先須要作的第一步就是實例化出一個掛載容器 PortalOutlet,能夠經過實例化 DomPortalOutlet 獲得該掛載容器。查看 DomPortalOutlet 的構造依賴主要包括:掛載的元素節點 Element,能夠經過 @ViewChild DOM 查詢獲得該組件內的某一個 DOM 元素;組件工廠解析器 ComponentFactoryResolver,能夠經過當前組件構造注入拿到,該解析器是爲了當 Portal 是 ComponentPortal 時解析出對應的 Component;當前程序對象 ApplicationRef,主要用來掛載組件視圖;注入器 Injector,這個很重要,若是是在 Angular 上下文外掛載組件視圖,能夠用 Injector 來和組件視圖共享數據。dom
第二步就是使用 ComponentPortal 和 TemplatePortal 包裝對應的組件和模板,須要留意的是 TemplatePortal 還必須依賴 ViewContainerRef 對象來調用 createEmbeddedView() 來建立嵌入視圖。ide
第三步就是調用 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。
從上文可知道,若是想要把 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 上下文外的 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。
不論是 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 方法。下面將分別查看兩個方法的實現。
仍是以 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 生成視圖容器。全部的視圖都會依次做爲子節點掛載到容器內。
根據上文的相似設計,掛載 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 是爲了解決能夠在 Angular 框架執行上下文以外動態建立子視圖,首先須要先實例化出 PortalOutlet 對象,而後實例化出一個 ComponentPortal 或 TemplatePortal,最後把 Portal 掛載到 PortalOutlet 上。整個過程很是簡單,可是難道 @angular/cdk/portal 沒有提供什麼快捷方式,避免讓開發者寫大量重複代碼麼?有。@angular/cdk/portal 提供了兩個指令:CdkPortal 和 CdkPortalOutlet。該兩個指令會隱藏全部實現細節,開發者只須要簡單調用就行,使用方式能夠查看官方 demo。
demo 實踐過程當中,發現兩個問題:組件視圖都會多產生一個 p 標籤;AppComponent 模板中掛載點做爲 ViewContainerRef 時,掛載點還不能爲 ng-template 和 ng-container,和印象中有出入。有時間在查找,誰知道緣由,也可留言幫助解答,先謝了。