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