这篇文章主要介绍使用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
该路由的入口组件是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; // 传递参数
}
}
复制代码
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…
这里先对Portal相关的内容作一个简单的说明,后面会有两个使用示例,原本这块内容准备放到最后的,最终仍是决定放在前面,能够先对Portals有一个简单的了解,若是其中有翻译不许确请见谅。
地址:material.angular.io/cdk/portal/…
-------- 文档开始
portals 提供渲染动态内容到应用的可伸缩的实现,其实就是封装了Angular动态建立组件的过程
这个Portal指是能动态渲染一个指定位置的
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>
复制代码
----- 文档完毕
这里首先使用新的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…
上面只是使用Portal的最简单用法,下面讨论下它的源码实现,以便更好的理解
首先咱们先看一下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提供了附加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能够把一个Portal插入到一个Angular应用上下文以外的DOM中,想一想咱们前面的例子,不管本身实现仍是使用CdkPortalOutlet都是把一个模板或者组件插入到一个Angular上下文中的宿主ViewContainerRef中,而DomPortalOutlet就是
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,下面简单说明下逻辑结构,有兴趣的可看完整示例。
文件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
用于获取任务列表数据,并保存在属性tasks中
在模板中直接绑定taskListService.tasks属性数据
由于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
@ViewChild('taskListContainer', { read: ViewContainerRef })
taskListViewContainerRef: ViewContainerRef;
复制代码
也就是新的组件的注入器来自于TaskListComponentcustomerTokens.set(TaskEntity, task);
。task-model.component.ts
constructor(
public task: TaskEntity,
private taskListService: TaskListService
) {}
复制代码
没错,是经过注入器注入的方式获取TaskEntity实例和服务TaskListService的实例taskListService。
这个例子相对复杂,只是想说明能够给动态建立的组件传入特定的injector。
想写Portals的使用主要是看了咱们组件库中模态框ThyDialog的实现,以为这些用法比较巧妙,因此想分享出来。
示例仓储:github.com/pubuzhixing…
组件库仓储:github.com/worktile/ng…
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里面。
属性
最初由于不了解WeakMap而对这个实现迷惑不解,查了WeakMap的相关资料
WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键名必须是对象,而值能够是任意的。
键名是对象的弱引用,当对象被回收后,WeakMap自动移除对应的键值对,WeakMap结构有助于防止内存泄漏。
能够与Map对比理解,Map中key能够是各类类型,而WeakMap必须是对象。
这样WeakMap就能够用来在不修改原引用类型对象的基础上,而扩充该对象的属性值,而且不影响引用类型对象的垃圾回收,随该对象的消失,扩充属性随之消失。
本文做者:Worktile工程师 杨振兴