@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<T>。從字面意義便可曉得,PortalOutlet 應當就是把某一個 DOM 節點包裝成一個掛載容器供 Portal 來掛載,等同於 插頭-插線板 形式的 插線板;Portal<T> 應當就是把組件視圖或許模板視圖包裝成一個 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,和印象中有相差。有時間在查找,誰曉得緣由,也可留言協助解答,先謝了。