源碼剖析 @angular/cdk 之 Portal

@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<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 變量。這些依靠對象也進一步暴露了其想象頭腦。

籠統類 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-template
ng-container,和印象中有相差。有時間在查找,誰曉得緣由,也可留言協助解答,先謝了。

    原文作者:lx1036
    原文地址: https://segmentfault.com/a/1190000015097370
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞