源码分析 @angular/cdk 之 Portal
Portal 是什么
最近在学习 React 时,发现 React 提供了 Portals 技术,该技术主要用来把子节点动态的显示到父节点外的 DOM 节点上,该技术的一个经典用例应该就是 Dialog 了。设想一下在设计 Dialog 时所需要的主要功能点:当点击一个 button 时,一般需要在 body 标签前动态挂载一个组件视图;该 dialog 组件视图需要共享数据。由此看出,Portal 核心就是在任意一个 DOM 节点内动态生成一个视图,该 视图却可以置于框架上下文环境之外。那 Angular 中有没有类似相关技术来解决这个问题呢?
Angular Portal 就是用来在任意一个 DOM 节点内动态生成一个视图,该视图既可以是一个组件视图,也可以是一个模板视图,并且生成的视图可以挂载在任意一个 DOM 节点,甚至该节点可以置于 Angular 上下文环境之外,也同样可以与该视图共享数据。该 Portal 技术主要就涉及两个简单对象:PortalOutlet 和 Portal<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 变量。这些依赖对象也进一步暴露了其设计思想。
抽象类 BasePortalOutlet 是 PortalOutlet 的基本实现,同时包含了三个重要方法: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。其中,还有一个重要类 DomPortalOutlet 是 BasePortalOutlet 的子类,可以在 Angular 上下文之外 创建一个 PortalOutlet,并把 Portal 挂载到该 PortalOutlet 上,比如将 body 最后子元素 div 包装为一个 PortalOutlet,然后将组件视图或模板视图挂载到该挂载点上。这里的的难点就是如果该挂载点在 Angular 上下文之外,那挂载点内的 Portal 如何与 Angular 上下文内的组件共享数据。 DomPortalOutlet 还实现了上面的两个抽象方法:attachComponentPortal 和 attachTemplatePortal,如果对代码细节感兴趣可接着看下文。
现在已经知道了 @angular/cdk/portal 中最重要的两个核心,即 Portal 和 PortalOutlet,接下来写一个 demo 看看如何使用 Portal 和 PortalOutlet 来在 Angular 上下文之外 创建一个 ComponentPortal 和 TemplatePortal。
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 挂载进来,具体挂载过程是怎样的?查看 BasePortalOutlet 的 attach() 的源码实现:
/** 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 类型分别调用 attachComponentPortal 和 attachTemplatePortal 方法。下面将分别查看两个方法的实现。
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 提供了两个指令:CdkPortal 和 CdkPortalOutlet。该两个指令会隐藏所有实现细节,开发者只需要简单调用就行,使用方式可以查看官方 demo。
demo 实践过程中,发现两个问题:组件视图都会多产生一个 p 标签;AppComponent 模板中挂载点作为 ViewContainerRef 时,挂载点还不能为 ng-template 和 ng-container,和印象中有出入。有时间在查找,谁知道原因,也可留言帮助解答,先谢了。