Angular动态建立组件之Portals

 这篇文章主要介绍使用Angular api 和 CDK Portals两种方式实现动态建立组件,另外还会讲一些跟它相关的知识点,如:Angular多级依赖注入、ViewContainerRef,Portals能够翻译为 门户 ,我以为放到这里叫 入口 更好,能够理解为动态建立组件的入口,相似于小程序或者Vue中的Slot.css

cdk全名Component Development Kit 组件开发包,是Angular官方在开发基于Material Design的组件库时抽象出来单独的一个开发包,里面封装了一些开发组件时的公共逻辑而且跟Material Design 设计无关,能够用来封装本身的组件库或者直接在业务开发中使用,里面代码抽象程度很是高,很是值得学习,如今我用到的有Portals、Overlay(打开浮层相关)、SelectionModel、Drag and Drop等.
官方:material.angular.io/
中文翻译:material.angular.cnhtml

动态建立组件

想一想应用的路由,通常配置路由地址的时候都会给这个地址配置一个入口组件,当匹配到这个路由地址的时候就在指定的地方渲染这个组件,动态建立组件相似,在最页面未接收到用户行为的时候,我不知道页面中这块区域应该渲染那个组件,当页面加载时根据数据库设置或者用户的操做行为才能肯定最终要渲染的组件,这时候就要用代码动态建立组件把目标组件渲染到正确的地方。
示例截图
git

image.png

使用Angular API动态建立组件

该路由的入口组件是PortalsEntryConponent组件,如上面截图所示右侧有一块虚线边框的区域,里面具体的渲染组件不肯定。github

第一步

先在视图模板中定义一个占位的区域,动态组件就要渲染在这个位置,起一个名称#virtualContainer
文件portals-entry.component.html数据库

<div class="portals-outlet" >
    <ng-container #virtualContainer>
    </ng-container>
</div>
复制代码

第二步

经过ViewChild取到这个container对应的逻辑容器
文件portals-entry.component.ts小程序

@ViewChild('virtualContainer', { read: ViewContainerRef })
  virtualContainer: ViewContainerRef;
复制代码

第三步

处理单击事件,单击按钮时动态建立一个组件,portals-entry.component.ts完整逻辑api

import { TaskDetailComponent } from '../task/task-detail/task-detail.component';
@Component({
  selector: 'app-portals-entry',
  templateUrl: './portals-entry.component.html',
  styleUrls: ['./portals-entry.component.scss'],
  providers: [
  ]
})
export class PortalsEntryComponent implements OnInit {
  @ViewChild('virtualContainer', { read: ViewContainerRef })
  virtualContainer: ViewContainerRef;
  constructor(
    private dynamicComponentService: DynamicComponentService,
    private componentFactoryResolver: ComponentFactoryResolver,
    private injector: Injector,
  ) { }
  ngOnInit() {
  }
  openTask() {
    const task = new TaskEntity();
    task.id = '1000';
    task.name = '写一篇关于Portals的文章';
    const componentFactory = this.componentFactoryResolver.resolveComponentFactory(TaskDetailComponent);
    const componentRef = this.virtualContainer.createComponent<TaskDetailComponent>(
      componentFactory,
      null,
      this.virtualContainer.injector
    );
    (componentRef.instance as TaskDetailComponent).task = task; // 传递参数
  }
}
复制代码

代码说明

  1. openTask()方法绑定到模板中按钮的单击事件
  2. 导入要动态建立的组件TaskDetailComponent
  3. constructor注入injector、componentFactoryResolver 动态建立组件须要的对象,只有在组件上下文中才能够拿到这些实例对象
  4. 使用api建立组件,现根据组件类型建立一个ComponentFactory对象,而后调用viewContainer的createComponent建立组件
  5. 使用componentRef.instance获取建立的组件实例,这里用来设置组件的task属性值

其它

ViewContainerRef除了createComponent方法外还有一个createEmbeddedView方法,用于建立模板bash

@ViewChild('customTemplate')
customTemplate: TemplateRef<any>;
this.virtualContainer.createEmbeddedView(this.customTemplate, { name: 'pubuzhixing' });
复制代码

createEmbeddedView方法的第二个参数,用于指定模板的上下文参数,看下模板定义及如何使用参数app

<ng-template #customTemplate let-name="name">
  <p>自定义模板,传入参数name:{{name}}</p>
</ng-template>
复制代码

此外还能够经过ngTemplateOutlet直接插入内嵌视图模板,经过ngTemplateOutletContext指定模板的上下文参数dom

<ng-container [ngTemplateOutlet]="customTemplate" [ngTemplateOutletContext]="{ name:'pubuzhixing' }"></ng-container>
复制代码

小结

分析下Angular动态建立组件/内嵌视图的API,动态建立组件首先须要一个被建立的组件定义或模板声明,另外须要Angular上下文的环境来提供这个组件渲染在那里以及这个组件的依赖从那获取,viewContainerRef是动态组件的插入位置而且提供组件的逻辑范围,此外还须要单独传入依赖注入器injector,示例直接使用逻辑容器的injector,是否是很好理解。
示例仓储:github.com/pubuzhixing…

CDK Portal 官方文档介绍

这里先对Portal相关的内容作一个简单的说明,后面会有两个使用示例,原本这块内容准备放到最后的,最终仍是决定放在前面,能够先对Portals有一个简单的了解,若是其中有翻译不许确请见谅。
地址:material.angular.io/cdk/portal/…

-------- 文档开始
portals 提供渲染动态内容到应用的可伸缩的实现,其实就是封装了Angular动态建立组件的过程

Portals

这个Portal指是能动态渲染一个指定位置的

UI块
到页面中的一个
open slot

UI块
指须要被动态渲染的内容,能够是一个组件或者是一个模板,而
open slot
是一个叫作PortalOutlet的开放的占位区域。
Portals和PortalOutlets是其它概念中的低级的构造块,像overlays就是在它基础上构建的

Portal<T> 包括动态组件的抽象类,能够是TemplatePortal(模板)或者ComponentPortal(组件)
复制代码
方法 描述
attach(PortalOutlet): T 把当前Portal附加到宿主上
detach(): void 把Portal从宿主上拆离
isAttached: boolean 当前Portal是否已经附加到宿主上
PortalOutlet 动态组件的宿主
复制代码
方法 描述
attach(Portal): any 附加指定Portal
detach(): any 拆离当前附加Portal
dispose(): void 永久释放宿主资源
hasAttached: boolean 当前是否已经装在Portal

代码片断说明

CdkPortal

<ng-template cdkPortal>
  <p>The content of this template is captured by the portal.</p>
</ng-template>
<!-- OR -->
<!-- 经过下面的结构指令语法能够获得一样的结果 -->
<p *cdkPortal>
  The content of this template is captured by the portal.
</p>
复制代码

能够经过ViewChild、ViewChildren获取到该Portal,类型应该是CdkPortal,以下所示:

// 模板中的Portal
@ViewChild(CdkPortal) templateCDKPortal: TemplatePortal<any>;
复制代码

ComponentPortal
组件类型的Portal,须要当前组件在NgModule的entryComponents中配置才能动态建立该组件。

this.userSettingsPortal = new ComponentPortal(UserSettingsComponent);
复制代码

CdkPortalOutlet
使用指令能够把portal outlet添加到一个ng-template,cdkPortalOutlet把当前元素指定为PortalOutlet,下面代码把userSettingsPortal绑到此portal-outlet上

<!-- Attaches the `userSettingsPortal` from the previous example. -->
<ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>
复制代码

----- 文档完毕

Portals使用示例

这里首先使用新的api完成和最上面示例同样的需求,在一样的位置动态渲染TaskDetailComponent组件。

第一步

一样是设置一个宿主元素用于渲染动态组件,可使用指令cdkPortalOutlet挂载一个PortalOutlet在这个ng-container元素上

<div class="portals-outlet">
   <ng-container #virtualContainer cdkPortalOutlet>
   </ng-container>
</div>
复制代码

第二步

使用Angular API动态建立组件 一节使用同一个逻辑元素做为宿主,只不过这里的获取容器的类型是CdkPortalOutlet,代码以下

@ViewChild('virtualContainer', { read: CdkPortalOutlet })
virtualPotalOutlet: CdkPortalOutlet;
复制代码

第三步

建立一个ComponentPortal类型的Portal,而且将它附加上面获取的宿主virtualPotalOutlet上,代码以下

portalOpenTask() {
    this.virtualPotalOutlet.detach();
    const taskDetailCompoentPortal = new ComponentPortal<TaskDetailComponent>(
      TaskDetailComponent
    );
    const ref = this.virtualPotalOutlet.attach(taskDetailCompoentPortal);
    // 此处一样能够 经过ref.instance传递task参数
  }
复制代码

小结

这里是使用ComponentPortal的示例实现动态建立组件,Portal还有一个子类TemplatePortal是针对模板实现的,上节 CDK Portal 官方文档介绍 中有介绍,这里就不在赘述了。总之使用Portals能够很大程度上简化代码逻辑。
示例仓储:github.com/pubuzhixing…

Portals 源码分析

上面只是使用Portal的最简单用法,下面讨论下它的源码实现,以便更好的理解

ComponentPortal

首先咱们先看一下ComponentPortal类的建立,上面的例子只是指定了一个组件类型做为参数,其实它还有别的参数能够配置,先看下ComponentPortal的构造函数定义

export class ComponentPortal<T> extends Portal<ComponentRef<T>> {   
  constructor(
      component: ComponentType<T>,
      viewContainerRef?: ViewContainerRef | null,
      injector?: Injector | null,
      componentFactoryResolver?: ComponentFactoryResolver | null) {
    super();
    this.component = component;
    this.viewContainerRef = viewContainerRef;
    this.injector = injector;
    this.componentFactoryResolver = componentFactoryResolver;
  } 
}
复制代码

ComponentPortal构造函数的另外两个参数

viewContainerRef
injector

viewContainerRef
参数非必填默认附到PortalOutlet上,若是传入viewContainerRef参数,那么ComponentPortal就会附到该viewContaierRef上,而不是当前PortalOutlet所在的元素上。
injector
参数非必填,默认使用PortalOutlet所在的逻辑容器的injector,若是传入injector,那么动态建立的组件就使用传入的injector做为注入器。

BasePortalOutlet

BasePortalOutlet提供了附加ComponentPortal和TemplatePortal的部分实现,咱们看下attach方法的部分代码(仅仅展现部分逻辑)

/** Attaches a portal. */
  attach(portal: Portal<any>): any {
    if (!portal) {
      throwNullPortalError();
    }
    if (portal instanceof ComponentPortal) {
      this._attachedPortal = portal;
      return this.attachComponentPortal(portal);
    } else if (portal instanceof TemplatePortal) {
      this._attachedPortal = portal;
      return this.attachTemplatePortal(portal);
    }
    throwUnknownPortalTypeError();
  }
复制代码

attach处理前先根据Portal的类型是确实是组件仍是模板,而后再进行相应的处理,其实最终仍是调用了ViewContainerRef的createComponent或者createEmbeddedView方法,对这块感兴趣看查看源代码文件portal-directives.ts

DomPortalOutlet

DomPortalOutlet能够把一个Portal插入到一个Angular应用上下文以外的DOM中,想一想咱们前面的例子,不管本身实现仍是使用CdkPortalOutlet都是把一个模板或者组件插入到一个Angular上下文中的宿主ViewContainerRef中,而DomPortalOutlet就是

脱离Angular上下文
的宿主,能够把Portal渲染到任意dom中,咱们经常有这种需求,好比弹出的模态框、Select浮层。
在cdk中Overlay用到了DomPortalOutlet,而后material ui的MatMenu也用到了DomPortalOutlet,MatMenu比较容易理解,简单看下它是如何建立和使用的DomPortalOutle( 查看所有

if (!this._outlet) {
    this._outlet = new DomPortalOutlet(this._document.createElement('div'),
    this._componentFactoryResolver, this._appRef, this._injector);
}
const element: HTMLElement = this._template.elementRef.nativeElement;
element.parentNode!.insertBefore(this._outlet.outletElement, element);
this._portal.attach(this._outlet, context);
复制代码

上面的代码先建立了DomPortalOutlet类型的对象_outlet,DomPortalOutlet是一个DOM宿主它不在Angular的任何一个ViewContainerRef中,如今看下它的四个构造函数参数

参数名 类型 说明
outletElement
Element 建立的document元素
_componentFactoryResolver
ComponentFactoryResolver 刚开始一直不理解这个实例对象是干什么的,后来查了资料,它大概的做用是对要建立的组件或者模板进行编译
_appRef
ApplicationRef 当前Angular应用的一个关联对象
_defaultInjector
Injector 注入器对象

说明:这节讲的

脱离Angular上下文
是不太准肯定,任何模板或者组件都不能脱离Angular的运行环境,这里应该是脱离了实际渲染的Component Tree,单独渲染到指定dom中。

复杂示例

为ComponentPortal传入PortalInjector对象,PortalInjector实例对象配置一个其它业务组件的injector而且配置tokens,下面简单说明下逻辑结构,有兴趣的可看完整示例

业务组件TaskListComponent

文件task-list.component.ts

@Component({,
  selector: 'app-task-list',
  templateUrl: './task-list.component.html',
  styleUrls: ['./task-list.component.scss'],
  providers: [TaskListService]
})
export class TaskListComponent implements OnInit {
  constructor(public taskListService: TaskListService) {}
}
复制代码

组件级提供商配置了TaskListService

定义TaskListService

用于获取任务列表数据,并保存在属性tasks中

TaskListComponent模板

在模板中直接绑定taskListService.tasks属性数据

修改父组件PortalsEntryComponent

由于PortalOutlet是在父组件中,因此单击任务列表建立动态组件的逻辑是从父组件响应的
portals-entry.component.ts

@ViewChild('taskListContainer', { read: TaskListComponent })
  taskListComponent: TaskListComponent; 
  ngOnInit() {
    this.taskListComponent.openTask = task => {
      this.portalCreatTaskModel(task);
    };
  }
portalCreatTaskModel(task: TaskEntity) {
    this.virtualPotalOutlet.detach();
    const customerTokens = new WeakMap();
    customerTokens.set(TaskEntity, task);
    const portalInjector = new PortalInjector(
      this.taskListViewContainerRef.injector,
      customerTokens
    );
    const taskModelCompoentPortal = new ComponentPortal<TaskModelComponent>(
      TaskModelComponent,
      null,
      portalInjector
    );
    this.virtualPotalOutlet.attach(taskModelCompoentPortal);
  }
复制代码

给ComponentPortal的构造函数传递了PortalInjector类型的参数portalInjector,PortalInjector继承自Injector

PortalInjector构造函数的两个参数

  1. 第一个参数是提供一个基础的注入器injector,这里使用了taskListViewContainerRef.injector,taskListViewContainerRef就是业务TaskListComponent组件的viewContainerRef
    @ViewChild('taskListContainer', { read: ViewContainerRef })
    taskListViewContainerRef: ViewContainerRef;
    复制代码
    也就是新的组件的注入器来自于TaskListComponent
  2. 第二个参数是提供一个tokens,类型是WeakMap,其实就是key/value的键值对,只不过它的key只能是引用类型的对象,这里把类型TaskEntity做为key,当前选中的实例对象做为value,就能够实现对象的传入,使用set方法customerTokens.set(TaskEntity, task);

新的任务详情组件TaskModelComponent

task-model.component.ts

constructor(
    public task: TaskEntity,
    private taskListService: TaskListService
  ) {}
复制代码

没错,是经过注入器注入的方式获取TaskEntity实例和服务TaskListService的实例taskListService。

小结

这个例子相对复杂,只是想说明能够给动态建立的组件传入特定的injector。

总结

想写Portals的使用主要是看了咱们组件库中模态框ThyDialog的实现,以为这些用法比较巧妙,因此想分享出来。
示例仓储:github.com/pubuzhixing…
组件库仓储:github.com/worktile/ng…

拓展

ViewContainerRef

angula.cn解释:表示能够将一个或多个视图附着到组件中的容器,能够包含宿主视图(当用 createComponent() 方法实例化组件时建立)和内嵌视图(当用 createEmbeddedView() 方法实例化 TemplateRef 时建立)。
我这里的理解ViewContainerRef是Angular中的一个逻辑单元,简单理解它与组件或者页面中的html元素一一对应只是逻辑形态不一样,它也有层级只是层级与组件树的层级不是一一对应,这点我的感受有些难理解,就拿Portals里面ComponentPortal的实现来讲,构造函数里面能够传入一个viewContainerRef,代码片断

/**
 * A `ComponentPortal` is a portal that instantiates some Component upon attachment.
 */
export class ComponentPortal<T> extends Portal<ComponentRef<T>> {
  /**
   * [Optional] Where the attached component should live in Angular's *logical* component tree. * 可选参数 关联的组件应该寄宿的逻辑组件树的位置 * This is different from where the component *renders*, which is determined by the PortalOutlet. * 这跟组件真正渲染的位置是不一样的,真正的位置由PortalOutlet决定 * The origin is necessary when the host is outside of the Angular application context. * 当宿主是在Angular上下文环境以外这个参数是必填项 */ viewContainerRef?: ViewContainerRef | null; constructor( component: ComponentType<T>, viewContainerRef?: ViewContainerRef | null, injector?: Injector | null, componentFactoryResolver?: ComponentFactoryResolver | null) { // ... } } 复制代码

对其中viewContainerRef的注释进行了简单的翻译,但仍是不知道它是怎么实现逻辑组件树与真实渲染组件树设置不一样层级,通过本身的尝试当设置viewContainerRef后,组件就渲染在了传入的viewContainerRef里面。
属性

element
injector

element
的类型是ElementRef,用来标识本容器在父容器中的位置与html中的元素一一对应
injector
的类型是Injector,它是容器的一个依赖注入器对象,咱们在组件的constructor中注入的服务以及获取关联的对象都要经过它来查找,在ViewContainer的逻辑树中注入器对象有一个 注入器冒泡 机制,当一个组件申请得到一个依赖时,Angular 先尝试用该组件容器本身的注入器来知足它,在该组件的容器中找不到实例而且也没有配置注入器提供商(providers),他就会在把这个申请转给它父组件的注入器来处理。因此在动态建立组件的时候能够单独配置这个injector能够子组件传递数据、共享实例对象。

WeakMap

最初由于不了解WeakMap而对这个实现迷惑不解,查了WeakMap的相关资料

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键名必须是对象,而值能够是任意的。
键名是对象的弱引用,当对象被回收后,WeakMap自动移除对应的键值对,WeakMap结构有助于防止内存泄漏。
能够与Map对比理解,Map中key能够是各类类型,而WeakMap必须是对象。
这样WeakMap就能够用来在不修改原引用类型对象的基础上,而扩充该对象的属性值,而且不影响引用类型对象的垃圾回收,随该对象的消失,扩充属性随之消失。


本文做者:Worktile工程师 杨振兴

文章来源:Worktile技术博客

欢迎访问交流更多关于技术及协做的问题。

文章转载请注明出处。

相关文章
相关标签/搜索