技术博客

Angular动态创建组件之Portals


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

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

动态创建组件

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

image.png

使用Angular API动态创建组件

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

第一步

先在视图模板中定义一个占位的区域,动态组件就要渲染在这个位置,起一个名称#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完整逻辑

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方法,用于创建模板

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

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

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

此外还可以通过ngTemplateOutlet直接插入内嵌视图模板,通过ngTemplateOutletContext指定模板的上下文参数

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

小结

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

CDK Portal 官方文档介绍

这里先对Portal相关的内容做一个简单的说明,后面会有两个使用示例,本来这块内容准备放到最后的,最终还是决定放在前面,可以先对Portals有一个简单的了解,如果其中有翻译不准确请见谅。
地址:https://material.angular.io/cdk/portal/overview

-------- 文档开始
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附加指定Portaldetach(): any拆离当前附加Portaldispose(): void永久释放宿主资源hasAttached: boolean当前是否已经装在Portal#### 代码片段说明CdkPortal```

The content of this template is captured by the portal.<p *cdkPortal>The content of this template is captured by the portal.

```可以通过ViewChild、ViewChildren获取到该Portal,类型应该是CdkPortal,如下所示:```// 模板中的Portal@ViewChild(CdkPortal) templateCDKPortal: TemplatePortal;```ComponentPortal组件类型的Portal,需要当前组件在NgModule的entryComponents中配置才能动态创建该组件。```this.userSettingsPortal = new ComponentPortal(UserSettingsComponent);```CdkPortalOutlet使用指令可以把portal outlet添加到一个ng-template,cdkPortalOutlet把当前元素指定为PortalOutlet,下面代码把userSettingsPortal绑到此portal-outlet上```<ng-template [cdkPortalOutlet]="userSettingsPortal">```----- 文档完毕

** 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可以很大程度上简化代码逻辑。
示例仓储:https://github.com/pubuzhixing8/angular-cdk-demo

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中,现在看下它的四个构造函数参数

参数名类型说明outletElementElement创建的document元素_componentFactoryResolverComponentFactoryResolver刚开始一直不理解这个实例对象是干什么的,后来查了资料,它大概的作用是对要创建的组件或者模板进行编译_appRefApplicationRef当前Angular应用的一个关联对象_defaultInjectorInjector注入器对象

说明:这节讲的 脱离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

  1. 第二个参数是提供一个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的实现,觉得这些用法比较巧妙,所以想分享出来。
示例仓储:https://github.com/pubuzhixing8/angular-cdk-demo
组件库仓储:https://github.com/worktile/ngx-tethys

拓展

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工程师 杨振兴

智齿客服