源码分析 @angular/cdk 之 Portal

发布时间:2019-06-14 发布网站:脚本宝典
脚本宝典收集整理的这篇文章主要介绍了源码分析 @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 上,并定义了两个抽象方法,来具体实现挂载组件视图还是模板视图:

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

脚本宝典总结

以上是脚本宝典为你收集整理的源码分析 @angular/cdk 之 Portal全部内容,希望文章能够帮你解决源码分析 @angular/cdk 之 Portal所遇到的问题。

如果觉得脚本宝典网站内容还不错,欢迎将脚本宝典推荐好友。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
如您有任何意见或建议可联系处理。小编QQ:384754419,请注明来意。