@angular/material 是 Angular 官方根据 Material Design 设计语言提供的 UI 库,开发人员在开发 UI 库时发现不少 UI 组件有着共同的逻辑,因此他们把这些共同逻辑抽出来单独作一个包 @angular/cdk,这个包与 Material Design 设计语言无关,能够被任何人按照其余设计语言构建其余风格的 UI 库。学习 @angular/material 或 @angular/cdk 这些包的源码,主要是为了学习大牛们是如何高效使用 TypeScript 语言的;学习他们如何把 RxJS 这个包使用的这么出神入化;最主要是为了学习他们是怎么应用 Angular 框架提供的技术。只有深刻研究这些大牛们写的代码,才能更快提升本身的代码质量,这是一件事半功倍的事情。html
最近在学习 React 时,发现 React 提供了 Portals 技术,该技术主要用来把子节点动态的显示到父节点外的 DOM 节点上,该技术的一个经典用例应该就是 Dialog 了。设想一下在设计 Dialog 时所须要的主要功能点:当点击一个 button 时,通常须要在 body 标签前动态挂载一个组件视图;该 dialog 组件视图须要共享数据。由此看出,Portal 核心就是在任意一个 DOM 节点内动态生成一个视图,该 视图却能够置于框架上下文环境以外。那 Angular 中有没有相似相关技术来解决这个问题呢?react
Angular Portal 就是用来在任意一个 DOM 节点内动态生成一个视图,该视图既能够是一个组件视图,也能够是一个模板视图,而且生成的视图能够挂载在任意一个 DOM 节点,甚至该节点能够置于 Angular 上下文环境以外,也一样能够与该视图共享数据。该 Portal 技术主要就涉及两个简单对象:PortalOutlet 和 Portal。从字面意思就可知道,PortalOutlet 应该就是把某一个 DOM 节点包装成一个挂载容器供 Portal 来挂载,等同于 插头-插线板 模式的 插线板;Portal 应该就是把组件视图或者模板视图包装成一个 Portal 挂载到 PortalOutlet 上,等同于 插头-插线板 模式的 插头。这与 @angular/router 中 Router 和 RouterOutlet 设计思想很相似,在写路由时,router-outlet 就是个挂载点,Angular 会把由 Router 包装的组件挂载到 router-outlet 上,因此这个设计思想不是个新东西。git
Portal<T> 只是一个抽象泛型类,而 ComponentPortal<T> 和 TemplatePortal<T> 才是包装组件或模板对应的 Portal 具体类,查看两个类的构造函数的主要依赖,都基本是依赖于:该组件或模板对象;视图容器即挂载点,是经过 ViewContainerRef 包装的对象;若是是组件视图还得依赖 injector,模板视图得依赖 context 变量。这些依赖对象也进一步暴露了其设计思想。github
抽象类 BasePortalOutlet 是 PortalOutlet 的基本实现,同时包含了三个重要方法:attach 表示把 Portal 挂载到 PortalOutlet 上,并定义了两个抽象方法,来具体实现挂载组件视图仍是模板视图:api
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,若是对代码细节感兴趣可接着看下文。bash
如今已经知道了 @angular/cdk/portal 中最重要的两个核心,即 Portal 和 PortalOutlet,接下来写一个 demo 看看如何使用 Portal 和 PortalOutlet 来在 Angular 上下文以外 建立一个 ComponentPortal 和 TemplatePortal。app
Demo 关键功能包括:在 Angular 上下文内 挂载 TemplatePortal/ComponentPortal;在 Angular 上下文外 挂载 TemplatePortal/ComponentPortal;在 Angular 上下文外 共享数据。接下来让咱们逐一实现每一个功能点。框架
在 Angular 上下文内挂载 Portal 比较简单,首先须要作的第一步就是实例化出一个挂载容器 PortalOutlet,能够经过实例化 DomPortalOutlet 获得该挂载容器。查看 DomPortalOutlet 的构造依赖主要包括:挂载的元素节点 Element,能够经过 @ViewChild DOM 查询获得该组件内的某一个 DOM 元素;组件工厂解析器 ComponentFactoryResolver,能够经过当前组件构造注入拿到,该解析器是为了当 Portal 是 ComponentPortal 时解析出对应的 Component;当前程序对象 ApplicationRef,主要用来挂载组件视图;注入器 Injector,这个很重要,若是是在 Angular 上下文外挂载组件视图,能够用 Injector 来和组件视图共享数据。dom
第二步就是使用 ComponentPortal 和 TemplatePortal 包装对应的组件和模板,须要留意的是 TemplatePortal 还必须依赖 ViewContainerRef 对象来调用 createEmbeddedView() 来建立嵌入视图。ide
第三步就是调用 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。
从上文可知道,若是想要把 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 上下文外的 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。
不论是 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 方法。下面将分别查看两个方法的实现。
仍是以 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 生成视图容器。全部的视图都会依次做为子节点挂载到容器内。
根据上文的相似设计,挂载 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 是为了解决能够在 Angular 框架执行上下文以外动态建立子视图,首先须要先实例化出 PortalOutlet 对象,而后实例化出一个 ComponentPortal 或 TemplatePortal,最后把 Portal 挂载到 PortalOutlet 上。整个过程很是简单,可是难道 @angular/cdk/portal 没有提供什么快捷方式,避免让开发者写大量重复代码么?有。@angular/cdk/portal 提供了两个指令:CdkPortal 和 CdkPortalOutlet。该两个指令会隐藏全部实现细节,开发者只须要简单调用就行,使用方式能够查看官方 demo。
demo 实践过程当中,发现两个问题:组件视图都会多产生一个 p 标签;AppComponent 模板中挂载点做为 ViewContainerRef 时,挂载点还不能为 ng-template 和 ng-container,和印象中有出入。有时间在查找,谁知道缘由,也可留言帮助解答,先谢了。