Angular Material 18+ 高级教程 – CDK Portal
前言
CDK Portal 是 Angular Material 对 Angular Dynamic Component (ViewContainerRef,TemplateRef,createComponent,createEmbeddedView 那一套) 的上层封装。
要想深入理解 CDK Portal,我们最好先具备 Angular Dynamic Component 的基础知识,请先阅读这三篇:
CDK Portal 虽然有点像 ngComponentOutlet 指令 和 ngTemplateOutlet 指令,但它们封装的目的是完全不一样的,所以我们千万不要带着 ngOutlet 指令的思想去了解 CDK Portal 哦。
准备好了吗,让我们来揭开 CDK Portal 神秘的面纱吧🚀。
Portal 与 PortalOutlet 简单介绍
CDK Portal 有 2 大主角 -- Portal 和 PortalOutlet。
Portal 指的是要传送的东西,Outlet 指的是传送最终的出口。
用 Angular Dynamic Component 来比喻的话,Portal 是 TemplateRef 或 ComponentType,Outlet 则是 ViewContainerRef (注:只是比喻,不完全精准)。
Portal 有 4 个类型:
-
TemplatePortal
它里面装了 TemplateRef
-
ComponentPortal
它里面装了 ComponentType
-
DomPortal
它是 CDK Portal 独有的,Angular Dynamic Component 没有这个概念,它里面装了 Element。(下一 part 会深入讲解)
-
CdkPortal
它是 TemplatePortal 的派生类,同时它是一个指令,上面 3 个都是普通 class 而已。
它没有什么特别,把它当 TemplatePortal 看待就可以了。
PortalOutlet 有 2 个类型:
-
CdkPortalOutlet
它是一个指令,它里面装了 ViewContainerRef
-
DomPortalOutlet
它不是指令,里面也没有 ViewContainerRef,它里面装的是 Element。(下一 part 会深入讲解)
除了 DomPortal 和 DomPortalOutlet 以外,我相信大家对其它 class 大体上怎么运作应该已经猜到七七八八,
没错,你猜的都是正确的,那这里我就不概述了,直接下一 part 进入细节吧🚀。
DomPortal 配 DomPortalOutlet
先阐明一点:
4 种类型的 Portal 和 2 种类型的 PortalOutlet 是笛卡尔积的配对关系。
并不是说一定要 DomPortal 配 DomPortalOutlet。
DomPortal 配 CdkPortalOutlet 或 TempletePortal 配 DomPortalOutlet 也都是可以的。
DomPortal 配 DomPortalOutlet 可以把一个 Element 传送到另一个 Element 里面。
看例子:
create DomPortalOutlet
首先创建一个 Element 和 DomPortalOutlet,并把 Element 放入 DomPortalOutlet
const outletElement = document.createElement('div'); const outlet = new DomPortalOutlet(outletElement);
create DomPortal
接着创建 Element 和 DomPortal,并把 Element 放入 DomPortal。
const h1Container = document.createElement('div'); const h1 = document.createElement('h1'); h1.textContent = 'Hello World'; h1Container.appendChild(h1); const portal = new DomPortal(h1);
注意,被放入 DomPortal 的是 <h1> element,而且这个 <h1> element 一定要有 parent element (下面会讲解为什么)。
用 HTML 来描述上面的例子,大概长这样
<div> <h1 domPortal>Hello World</h1> </div> <div domPortalOutlet></div>
attach DomPortal
接着把 DomPortal attach 到 DomPortalOutlet
portal.attach(outlet);
outlet.attach(portal);
两种写法是等价的,谁 attach to 谁都可以,效果一样。
attach 了以后 HTML 结构会变成这样
<div> <!-- dom-portal --> </div> <div domPortalOutlet> <h1 domPortal>Hello World</h1> </div>
DomPortal Element (<h1 domPortal>) 被 cut and paste 到了 DomPortalOutlet Element (<div domPortalOutlet>) 里面 (注:是里面而不是上面哦,这点和 ViewContainerRef 不一样),
同时在原本 <h1 domPortal> 的位置多了一个 Comment 节点 (<!-- dom-portal -->)。
why need Document?
如果你运行上面的代码会遭遇一个 Error:Cannot attach DOM portal without _document constructor parameter
这是因为在 attach 的时候,DomPortalOutlet 需要 document 对象才能创建出 Comment 节点。
我们必须在初始化 DomPortalOutlet 时提供 document 对象。
const outlet = new DomPortalOutlet(outletElement, undefined, undefined, undefined, window.document);
在第五个参数传入 document 对象。
如果不想直接依赖 window (有做 Server-side Rendering 的话,最好不要直接依赖 window),可以使用 inject(DOCUMENT)。
export class AppComponent { constructor(){ const document = inject(DOCUMENT); const outletElement = document.createElement('div'); const outlet = new DomPortalOutlet(outletElement, undefined, undefined, undefined, document); } }
有了 document 对象,PortalOutlet attach 时就不会报错了。
你可能会想为什么 document 参数不是 required,这是因为只有在 attach DomPortal 时,DomPortalOutlet 才需要用到 document 对象来创建 Comment 节点。
在 attach TemplatePortal 和 ComponentPortal 时是不需要的,因此 document 对象不是 required。
提醒:Portal 和 PortalOutlet 是笛卡尔积配对关系,DomPortalOutlet 也可以 attach 非 DomPortal。
我们来验证一下结果
确实如上所说。
detech DomPortal
接着我们使用 detach 还原位置
两个知识点:
-
一个 PortalOutlet 只能够 attach 一个 Portal。
它不像 ViewContainerRef 那样可以一直插入,它像 ngTemplateOutlet 和 ngComponentOutlet 指令。
-
因为有还原位置的概念,所以在 DomPortal Element 原处需要插入一个 Comment 节点做记号,也因为这样,
DomPortal Element 一定要有 parent element,不然没有办法执行 parent.insertBefore(comment, portalElement)。
总结
DomPortal 和 DomPortalOutlet 主要是针对 DOM 的移位处理,这一点 Angular 是没有 built-in 方案的。
Angular 只有 TemplateRef,ComponentType 配 ViewContainerRef,若想处理 DOM 我们只能像 CDK Portal 那样自己做 DOM Manipulation 或者直接用 CDK Portal 更好。
DomPortalOutlet 配 TemplatePortal
DomPortalOutlet 配 TemplatePortal 可以创建 TemplateRef EmbeddedView 并且把它传送到 DomPortalOutlet Element 里面。
看例子:
create DomPortalOutlet
首先创建一个 Element 和 DomPortalOutlet,并把 Element 放入 DomPortalOutlet
const outletElement = document.createElement('div'); const outlet = new DomPortalOutlet(outletElement);
create TemplatePortal
接着 viewChild TemplateRef 和创建 TemplatePortal,并把 TemplateRef 放入 TemplatePortal。
<ng-template #template> <h1>Hello World</h1> </ng-template>
export class AppComponent implements OnInit { private readonly templateRef = viewChild.required('template', { read: TemplateRef }); private readonly viewContainerRef = inject(ViewContainerRef); private readonly injector = inject(Injector); ngOnInit() { const divContainer = document.createElement('div'); const outlet = new DomPortalOutlet(divContainer); const templateContext = {}; const embeddedViewInjector = Injector.create({ providers: [], parent: this.injector }); const portal = new TemplatePortal(this.templateRef(), this.viewContainerRef, templateContext, embeddedViewInjector); } }
这里有几个知识点:
-
viewChild 的 TemplateRef 至少要到 OnInit 阶段才拿的到,constructor 阶段是 undefined 哦。
-
在 createEmebededView 和把 EmebededView 插入到 ViewContainerRef 时,我们可以做 3 件事:
a. 选择 Injector Tree 位置
b. 选择 LView Tree 位置
c. 提供 Template Context
注:不熟悉这块的朋友,请复习 Component 组件 の ng-template
在初始化 TemplatePortal 时,我们就需要把这三件事决定好 (虽然它不会马上 createEmebededView)
传入 ViewContainerRef 决定了 LView Tree 的位置,
传入 EmbeddedView Injector 决定了 Injector Tree 的位置。
ViewContainerRef 是必须的,Template Context 和 Embedded Injector 是 optional。
因为,在 attach 的时候,DomPortalOutlet 会使用 TemplatePortal 的 ViewContainerRef 创建 EmbeddedView,所以 ViewContainerRef 是 required。
attach TemplatePortal
const embeddedViewRef = outlet.attach(portal);
在 attach TemplatePortal 时,DomPortalOutlet 会调用 TemplatePortal 的 ViewContainerRef.createEmbeddedView 方法,
这个方法会做 3 件事:
-
创建 EmbeddedView
-
把 EmbeddedView 插入 LView Tree
-
把 DOM 插入 DOM Tree
接着 DomPortalOutlet 还会做多一件事,它会 cut and paste 把 EmbeddedView 的 DOM 移动到 DomPortalOutlet Element 里面。
提醒:这也导致了 LView Tree 的结构和 DOM Tree 结构会不一样。相关源码在 dom-portal-outlet.ts
效果
detech TemplatePortal
outlet.detech();
detech 内部使用了 TemplatePortal 的 ViewContainerRef.remove 方法
总结
TemplatePortal 配 DomPortalOutlet 主要是使用了 Angular built-in 的 TemplateRef 配 ViewContainerRef。
只是 DomPortalOutlet 多加了一步,把最终的 DOM 从 ViewContainerRef append 的位置 move 去了 DomPortalOutlet Element 里面。
一个典型的使用场景是,我们想把 EmbeddenView DOM 插入到 document.body,由于 body 已经超出了 App Root 的管理范围,我们无法通过 ViewContainerRef 做插入,
所以只能先插入到 App 内然后通过 DOM Manipulaton 把它 cut and paste to body。
提醒:Change Detection 是依据 LView Tree 结构做 refreshView 的,所以即使 DOM 被移到了 body 也不会影响 LView 的 binding 刷新。
DomPortalOutlet 配 ComponentPortal
ComponentPortal 和 TemplatePortal 大同小异,它们内部都使用 Angular built-in 的方法。
DomPortalOutlet 配 ComponentPortal 就是 createComponent(ComponentType) 配 ViewContainerRef。
最后再把 ComporentRef.location.nativeElement cut and paste 到 DomPortalOutlet Element 里面。
看例子:
create DomPortalOutlet
首先创建一个 Element 和 DomPortalOutlet,并把 Element 放入 DomPortalOutlet
const outletElement = document.createElement('div'); const outlet = new DomPortalOutlet(outletElement);
create ComponentPortal
接着创建 ComponentPortal 并把 ComponentType 放进去。
const portal = new ComponentPortal(HelloWorldComponent);
如果此时执行 outlet.attach(portal) 会报错。
和 attach DomPortal 需要传入 document 对象,attach TemplatePoral 需要传入 ViewContainerRef 一样,ComponentPortal 也有一些调用浅规则。
-
ComponentFactoryResolver
private readonly componentFactoryResolver = inject(ComponentFactoryResolver);
ComponentFactoryResolver 其实已经被 Angular 废弃了,但是 Angular Material 还没有跟进。
ComponentPortal 或 DomPortalOutlet 其中一个需要传入 ComponentFactoryResolver。
const outlet = new DomPortalOutlet(outletContainer, this.componentFactoryResolver); const portal = new ComponentPortal(HelloWorldComponent, undefined, undefined, this.componentFactoryResolver);
提醒:其中一个传入就可以了,如果两个都有,它会优先使用 ComponentPortal 的 ComponentFactoryResolver。
- ApplicationRef 或者 ViewContainerRef
private readonly appRef = inject(ApplicationRef);
把 ApplicationRef 传入 DomPortalOutlet,或者把 ViewContainerRef 传入 ComponentPortal
const outlet = new DomPortalOutlet(outletContainer, this.componentFactoryResolver, this.appRef); const portal = new ComponentPortal(HelloWorldComponent, this.viewContainerRef);
提醒:其中一个传入就可以了,如果两个都有,它会优先使用 ComponentPortal 的 ViewContainerRef。
为什么 ApplicationRef 可以代替 ViewContainerRef,下一 part 我们再讲解,这里先死背规则。
-
Component Injector
Component Injector 是 optional,没有传它,createComponent 时会用 NullInjector。
const componentInjector = Injector.create({ providers: [], parent: this.injector }) const outlet = new DomPortalOutlet(outletContainer, this.componentFactoryResolver, this.appRef, componentInjector); const portal = new ComponentPortal(HelloWorldComponent, undefined, componentInjector, undefined);
提醒:其中一个传入就可以了,如果两个都有,它会优先使用 ComponentPortal 的 Component Injector。
new ComponentPortal 不会 createComponent,就如如同 new TemplatePortal 不会 createEmbeddedView 一样,创建和插入都是在 attach 的时候发生的。
attach ComponentPortal
const componentRef = outlet.attach(portal);
直接看源码了解过程
简而言之就是 createComponent > 插入 LView Tree > move DOM to DomPortalOutlet Element。
效果
detech ComponentPortal
detech ComponentPortal 和 detech TemplatePortal 大同小异。
内部使用 componentRef.destroy() 销毁组件,这个方法也会把 LView remove from LView Tree。
总结
DomPortalOutlet 配 ComponentPortal 和配 TemplatePortal 大同小异。
一个 createComponent 另一个 createEmbeddedView 的区别而已。
DomPortalOutlet 配 DomPortal,TemplatePortal,ComponentPortal 总结
调用 required 潜规则
先背一下它们的调用 required 潜规则:
-
DomPortalOutlet 配 DomPortal
初始化 DomPortalOutlet 需要传入 document 对象
-
DomPortalOutlet 配 TemplatePortal
初始化 TemplatePortal 需要传入 ViewContainerRef
-
DomPortalOutlet 配 ComponentPortal
其中一个需要传入 Angular 废弃了的 ComponentFactoryResolver
其中一个需要传入:ComponentPortal 传入 ViewContainerRef 或者 DomPortalOutlet 传入 ApplicationRef
两个都传优先使用 ComponentPortal 的。
为什么 TemplatePortal 和 ComponentPortal 需要 ViewContainerRef 或 ApplicationRef?
Template 和 Component 因为需要 Change Detection 所以创建后一定要使用 ViewContainerRef 或 ApplicationRef 插入 LView Tree。
而 DomPortal 里的 Element 是没有 binding 的 (有的话请用 <ng-template>),所以它不需要 Change Detection,也就不需要插入 LView Tree。
为什么 DomPortal 需要 document 和 parent node?
TemplatePortal 和 ComponentPortal 在 DomPortalOutlet.detech 时,只需要把 EmbeddedView 和 ComponentRef destory 就可以了,因为它们本来是不存在的。
但是 DomPortal 不同,它的 Element 是已存在的,所以它需要还原位置,而要还原就必须提前在原位做记号,这个记号就是 Comment 节点 <!--dom-portal-->。
要创建 Comment 节点就需要 document 对象,另外 insertBefore 插入 Comment 节点到原位就需要 DomPortal Element 有 parent node。
DomPortalOutlet 最大的意义
DomPortalOutlet 最重要的职责是把 DOM cut and paste 到 DomPortalOutlet Element 里面。
不管这个 DOM 来自 DomPortal 的 Element 还是 EmbeddedView.rootNodes 或者 ComporentRef.location.nativeElement。
为什么 CDK Portal 接口那么复杂?
CDK Portal 的接口设计的很差,比如上面的潜规则 required,比如使用了废弃的 ComponentFactoryResolver,比如 ComponentPortal 可以用 ApplicationRef 但 TemplatePortal 却不行。
这些是因为 CDK 本来就是比较底层的功能,是因为 Angular Material 有用到它们才抽象上来变成 CDK,如果 Angulart Material 本身没有需求它们就不会过度设计。
我没有听说过哪一个 Angular UI Component Library 是基于 Angular CDK 开发的。
CDKTemplatePortal 配 DomPortalOutlet
CDKTemplatePortal 是一个指令
它继承了 TemplatePortal。没有什么特别的,只是通过 DI 注入了 templateRef 和 ViewContainerRef,然后 new TemplatePortal 而已。
用回我们上面的例子的话,那相等于这样
export class AppComponent implements OnInit { private readonly templateRef = viewChild.required('template', { read: TemplateRef }); // 1. <ng-template> 也是可以作为 ViewContainerRef 的 private readonly viewContainerRef = viewChild.required('template', { read: ViewContainerRef }); private readonly injector = inject(Injector); ngOnInit() { const outletElement = document.createElement('div'); const outlet = new DomPortalOutlet(outletElement); const templateContext = {}; const embeddedViewInjector = Injector.create({ providers: [], parent: this.injector }); const portal = new TemplatePortal(this.templateRef(), this.viewContainerRef(), templateContext, embeddedViewInjector); } }
CDKPortalOutlet
CDKPortalOutlet 是一个指令。
它和 DomPortalOutlet 都继承了 BasePortalOutlet 类
所以它也可以 attach DomPortal,TemplatePortal,ComponentPortal。
CDKPortalOutlet vs DomPortalOutlet
它和 DomPortalOutlet 有几个区别:
-
实例化的方式不同
DomPortalOutlet 是我们自己 new DomPortalOutlet 实例化的
CDKPortalOutlet 是指令,它是在 renderView 的时候通过 DI 方式实例化的
这里最大的区别就是一个有 DI 可以用 inject 另一个没有。
CDKPortalOutlet 可以通过 inject 获取到 ComponentFactoryResolver,ViewContainerRef 和 document 对象。
而 DomPortalOutlet 我们需要自己背 required 潜规则,在初始化时传入需要的相关参数。
-
Scope 不同
CDKPortalOutlet 是指令,意味着它一定是 under App 组件的。
DomPortalOutlet 可以超越 App 组件,去到 document.body 也行。
如果你需要把 Dom 移到 document.body 那就只能用 DomPortalOutlet,其余情况用 CDKPortalOutlet 会更方便一下。
-
没有 ApplicationRef
这只是一个无伤大雅的小的区别,CDKPortalOutlet 只能用 ViewContainerRef,无法使用 ApplicationRef,DomPortalOutlet 可以用 ApplicationRef。
当 CDKPortalOutlet (<ng-container />) 遇上 DomPortal
App Template
<p>header</p> <ng-container cdkPortalOutlet /> <p>footer</p>
App 组件 (提醒:记得 imports PortalModule 才可以使用 CdkPortalOutlet 指令)
export class AppComponent implements OnInit { cdkPortalOutlet = viewChild.required(CdkPortalOutlet); ngOnInit() { const h1Container = document.createElement('div'); const h1 = document.createElement('h1'); h1.textContent = 'Hello World'; h1Container.appendChild(h1); const portal = new DomPortal(h1); this.cdkPortalOutlet().attach(portal); } }
没什么特别的,创建 Element 放入 DomPortal 然后 attach 到 CDKPortalOutlet。
效果
奇怪,为什么不是在 header 和 footer 的中间呢?
因为 CDKPortalOutlet attach DomPortal 的方式和它 attach ComponentPortal 和 TemplatePortal 是不同的。
attach ComponentPortal 和 TemplatePortal 用的是 ViewContainerRef,ViewContainerRef 是把 DOM 插入到 previousSibling。
attach DomPortal 用的是 append to child。
上面这个例子,CDKPortalOutlet Element 是 <ng-container /> 它 compile 后是一个 Comment 节点,Comment 节点不是 Element 类型,无法 append child,
于是它就去找 parent (找到 <app-root>) 然后 append,所以最终 Hello World 就到了 footer 的下面。
如果我们把 <ng-container /> 改成 div 它就对了
<p>header</p> <div cdkPortalOutlet></div> <p>footer</p>
效果
Angular Material 官方的教程非常聪明,它做了一个 wrapper
如果你把 wrapper 去掉,那 DomPortal 会被插入到 button 下面😂。
目录
上一篇 Angular Material 18+ 高级教程 – Custom Themes for Material Design 3 (自定义主题 Material 3)
下一篇 Angular Material 18+ 高级教程 – CDK Layout の Breakpoints
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻