原始碼分析 @angular/cdk 之 Portal

lx1036發表於2019-02-28

@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 技術主要就涉及兩個簡單物件:PortalOutletPortal。從字面意思就可知道,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 變數。這些依賴物件也進一步暴露了其設計思想。

抽象類 BasePortalOutletPortalOutlet 的基本實現,同時包含了三個重要方法: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。其中,還有一個重要類 DomPortalOutletBasePortalOutlet 的子類,可以在 Angular 上下文之外 建立一個 PortalOutlet,並把 Portal 掛載到該 PortalOutlet 上,比如將 body 最後子元素 div 包裝為一個 PortalOutlet,然後將元件檢視或模板檢視掛載到該掛載點上。這裡的的難點就是如果該掛載點在 Angular 上下文之外,那掛載點內的 Portal 如何與 Angular 上下文內的元件共享資料。 DomPortalOutlet 還實現了上面的兩個抽象方法:attachComponentPortalattachTemplatePortal,如果對程式碼細節感興趣可接著看下文。

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

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 掛載進來,具體掛載過程是怎樣的?檢視 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,和印象中有出入。有時間在查詢,誰知道原因,也可留言幫助解答,先謝了。

相關文章