源码分析 @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,和印象中有出入。有时间在查找,谁知道缘由,也可留言帮助解答,先谢了。

相关文章
相关标签/搜索