源碼分析 @angular/cdk 之 Portal

@angular/material 是 Angular 官方根據 Material Design 設計語言提供的 UI 庫,開發人員在開發 UI 庫時發現不少 UI 組件有着共同的邏輯,因此他們把這些共同邏輯抽出來單獨作一個包 @angular/cdk,這個包與 Material Design 設計語言無關,能夠被任何人按照其餘設計語言構建其餘風格的 UI 庫。學習 @angular/material 或 @angular/cdk 這些包的源碼,主要是爲了學習大牛們是如何高效使用 TypeScript 語言的;學習他們如何把 RxJS 這個包使用的這麼出神入化;最主要是爲了學習他們是怎麼應用 Angular 框架提供的技術。只有深刻研究這些大牛們寫的代碼,才能更快提升本身的代碼質量,這是一件事半功倍的事情。html

Portal 是什麼

最近在學習 React 時,發現 React 提供了 Portals 技術,該技術主要用來把子節點動態的顯示到父節點外的 DOM 節點上,該技術的一個經典用例應該就是 Dialog 了。設想一下在設計 Dialog 時所須要的主要功能點:當點擊一個 button 時,通常須要在 body 標籤前動態掛載一個組件視圖;該 dialog 組件視圖須要共享數據。由此看出,Portal 核心就是在任意一個 DOM 節點內動態生成一個視圖,該 視圖卻能夠置於框架上下文環境以外。那 Angular 中有沒有相似相關技術來解決這個問題呢?react

Angular Portal 就是用來在任意一個 DOM 節點內動態生成一個視圖,該視圖既能夠是一個組件視圖,也能夠是一個模板視圖,而且生成的視圖能夠掛載在任意一個 DOM 節點,甚至該節點能夠置於 Angular 上下文環境以外,也一樣能夠與該視圖共享數據。該 Portal 技術主要就涉及兩個簡單對象:PortalOutletPortal。從字面意思就可知道,PortalOutlet 應該就是把某一個 DOM 節點包裝成一個掛載容器供 Portal 來掛載,等同於 插頭-插線板 模式的 插線板Portal 應該就是把組件視圖或者模板視圖包裝成一個 Portal 掛載到 PortalOutlet 上,等同於 插頭-插線板 模式的 插頭。這與 @angular/router 中 Router 和 RouterOutlet 設計思想很相似,在寫路由時,router-outlet 就是個掛載點,Angular 會把由 Router 包裝的組件掛載到 router-outlet 上,因此這個設計思想不是個新東西。git

如何使用 Portal

Portal<T> 只是一個抽象泛型類,而 ComponentPortal<T>TemplatePortal<T> 纔是包裝組件或模板對應的 Portal 具體類,查看兩個類的構造函數的主要依賴,都基本是依賴於:該組件或模板對象;視圖容器即掛載點,是經過 ViewContainerRef 包裝的對象;若是是組件視圖還得依賴 injector,模板視圖得依賴 context 變量。這些依賴對象也進一步暴露了其設計思想。github

抽象類 BasePortalOutletPortalOutlet 的基本實現,同時包含了三個重要方法: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。其中,還有一個重要類 DomPortalOutletBasePortalOutlet 的子類,能夠在 Angular 上下文以外 建立一個 PortalOutlet,並把 Portal 掛載到該 PortalOutlet 上,好比將 body 最後子元素 div 包裝爲一個 PortalOutlet,而後將組件視圖或模板視圖掛載到該掛載點上。這裏的的難點就是若是該掛載點在 Angular 上下文以外,那掛載點內的 Portal 如何與 Angular 上下文內的組件共享數據。 DomPortalOutlet 還實現了上面的兩個抽象方法:attachComponentPortalattachTemplatePortal,若是對代碼細節感興趣可接着看下文。bash

如今已經知道了 @angular/cdk/portal 中最重要的兩個核心,即 PortalPortalOutlet,接下來寫一個 demo 看看如何使用 PortalPortalOutlet 來在 Angular 上下文以外 建立一個 ComponentPortalTemplatePortalapp

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 來和組件視圖共享數據。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

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 掛載進來,具體掛載過程是怎樣的?查看 BasePortalOutletattach() 的源碼實現:

/** 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 類型分別調用 attachComponentPortalattachTemplatePortal 方法。下面將分別查看兩個方法的實現。

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 提供了兩個指令:CdkPortalCdkPortalOutlet。該兩個指令會隱藏全部實現細節,開發者只須要簡單調用就行,使用方式能夠查看官方 demo

demo 實踐過程當中,發現兩個問題:組件視圖都會多產生一個 p 標籤;AppComponent 模板中掛載點做爲 ViewContainerRef 時,掛載點還不能爲 ng-templateng-container,和印象中有出入。有時間在查找,誰知道緣由,也可留言幫助解答,先謝了。

相關文章
相關標籤/搜索