源码分析 @angular/cdk 之 Portal

3,116 阅读11分钟

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