上一篇的 overlayRef.attach(...) 接收的参数就是 Portal。Portal 模块解决的问题是”把一段内容动态地放进一个插槽”——内容可以是组件实例,也可以是模板渲染结果;插槽可以是 Overlay 浮层,也可以是页面上的任意区域。它是一套极小但极精的抽象,Material 的 Dialog、Tab、BottomSheet 都靠它驱动。
抽象模型:Portal 与 PortalOutlet
整个模块只有两个核心角色:
graph LR
P1[ComponentPortal<br/>组件 + Injector] --> O[PortalOutlet<br/>插槽]
P2[TemplatePortal<br/>模板 + 上下文] --> O
O -- attach 返回 --> R[PortalOutletAttachedResult<br/>ComponentRef / EmbeddedViewRef]
O -- detach --> D[卸下内容,插槽可复用]
- Portal:待投放的内容。
ComponentPortal包装一个组件类,TemplatePortal包装一个ng-template - PortalOutlet:接收内容的插槽,核心接口是
attach(portal): PortalOutletAttachedResult、detach(): any、hasAttached()、dispose()
PortalOutletAttachedResult 是 ComponentPortal attach 后返回的 ComponentRef、TemplatePortal attach 后返回的 EmbeddedViewRef 的联合类型——拿到它就能进一步操作实例、读取输出、销毁视图。
CDK 自带两个 Outlet 实现:DomPortalOutlet(把内容挂到任意原生 DOM 节点)和 Overlay 内建的 outlet(上一章的 OverlayRef 同时实现了该接口)。
ComponentPortal:组件即内容
ComponentPortal 是”动态组件”的标准答案。构造参数依次是组件类型、可选的 ViewContainerRef、可选的 Injector:
import { ComponentPortal } from '@angular/cdk/portal';
const portal = new ComponentPortal(OrderDetailComponent);
const componentRef = overlayRef.attach(portal); // 返回 ComponentRef<OrderDetailComponent>
用 Injector 携带数据
动态创建的组件不在模板里,没法用 @Input() 传值。惯用做法是造一个”数据注入令牌 + 私有 Injector”:
import { InjectionToken, Injector, inject } from '@angular/core';
import { Overlay } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
export const ORDER_DATA = new InjectionToken<Order>('ORDER_DATA');
@Service()
export class OrderDialogService {
private readonly overlay = inject(Overlay);
private readonly injector = inject(Injector);
open(order: Order): void {
const ref = this.overlay.create({
hasBackdrop: true,
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
scrollStrategy: this.overlay.scrollStrategies.block(),
});
// 私有 Injector 链到父 Injector:既能拿到 ORDER_DATA,也能拿到全局服务
const dataInjector = Injector.create({
parent: this.injector,
providers: [{ provide: ORDER_DATA, useValue: order }],
});
const portal = new ComponentPortal(OrderDetailDialog, undefined, dataInjector);
const componentRef = ref.attach(portal); // ComponentRef<OrderDetailDialog>
ref.backdropClick().subscribe(() => ref.dispose());
}
}
@Component({ selector: 'app-order-detail-dialog', template: `...` })
export class OrderDetailDialog {
readonly order = inject(ORDER_DATA);
}
组件内部用 inject(ORDER_DATA) 取数据,还能通过 inject(OverlayRef) 直接拿到包裹自己的浮层引用来关闭——这条链路让动态组件保持完全的 @Service()/inject() 风格,与 v22 的 DI 心智一致。
ComponentPortal 的第二个参数 viewContainerRef 指定视图插入位置:不传时内容直接挂到目标 outlet;传了则作为该容器的子视图创建,生命周期跟随容器。
TemplatePortal:模板即内容
TemplatePortal 包装 ng-template,构造时必须提供所属的 ViewContainerRef(用于解析模板上下文与依赖注入层级),第三个参数可作为模板上下文:
const portal = new TemplatePortal(this.menuTpl, this.vcr, { $implicit: items });
overlayRef.attach(portal); // 渲染结果返回 EmbeddedViewRef
<ng-template #menuTpl let-items>
<ul>
@for (item of items; track item.id) {
<li>{{ item.label }}</li>
}
</ul>
</ng-template>
与 ComponentPortal 的选型对比:
| 维度 | ComponentPortal | TemplatePortal |
|---|---|---|
| 内容形态 | 独立组件类 | 当前组件里的模板 |
| 传数据 | 自定义 Injector | 模板上下文 let-xxx |
| 依赖注入 | 独立注入层级(可用私有 Injector 定制) | 跟随声明它的组件 |
| 适用场景 | 通用对话框、可复用弹层 | 轻量下拉菜单、宿主强相关的浮层 |
| 返回值 | ComponentRef | EmbeddedViewRef |
attach/detach 生命周期
Outlet 的状态机很简单但值得严格遵守:
stateDiagram-v2
[*] --> Empty
Empty --> Attached: attach(portal)
Attached --> Empty: detach()
Attached --> Attached: attach 另一个 portal(先自动 detach)
Empty --> Destroyed: dispose()
Attached --> Destroyed: dispose()
Destroyed --> [*]
三条纪律:
- detach 复用插槽:
detach()只卸内容不销毁 outlet,Tab 切换这类高频场景应该复用同一个 outlet 反复 attach - dispose 一次性:销毁后 outlet 不可再用;OverlayRef 的
dispose()会连带销毁其内建 outlet - 内容只能同时属于一个 outlet:把同一个 Portal attach 到第二处会抛错,复用内容请创建新实例
实战:单插槽多面板的动态布局
Portal 不只服务浮层。一个常见场景是”仪表盘卡片槽位”——同一块区域按用户选择切换不同面板组件,面板类注册在配置里:
import { ComponentPortal, Portal, PortalModule, PortalOutletAttachedResult } from '@angular/cdk/portal';
const PANELS = {
sales: SalesPanel,
stock: StockPanel,
log: ActivityPanel,
} as const;
type PanelKey = keyof typeof PANELS;
@Component({
selector: 'app-dashboard',
imports: [PortalModule],
template: `
<nav>
@for (key of panelKeys; track key) {
<button type="button" [class.active]="active() === key" (click)="switch(key)">
{{ key }}
</button>
}
</nav>
<!-- 声明式插槽:cdkPortalOutlet 自动 attach/detach -->
<ng-template [cdkPortalOutlet]="panel()" (attached)="onAttached($event)" />
`,
})
export class DashboardComponent {
readonly panelKeys = Object.keys(PANELS) as PanelKey[];
readonly active = signal<PanelKey>('sales');
readonly panel = computed<Portal<unknown> | null>(
() => new ComponentPortal(PANELS[this.active()]),
);
onAttached(result: PortalOutletAttachedResult): void {
// result 为 ComponentRef,可在此调用面板组件的初始化方法
}
switch(key: PanelKey): void {
this.active.set(key); // 信号变化 -> panel() 重新计算 -> 插槽自动切换内容
}
}
cdkPortalOutlet 指令接收一个 Portal,输入变化时自动 detach 旧的、attach 新的——与信号搭配后,“动态内容”被表达成纯派生状态,完全声明式。如果需要命令式控制,也可以注入 CdkPortalOutlet 或直接操作 outlet 实例。
与 ng-template 结构指令的对比
Angular 原生也有”动态内容”手段,Portal 的差异化价值在于:
| 需求 | 原生方案 | Portal 方案 |
|---|---|---|
| 宿主内条件渲染 | @if / ng-template + 结构指令 | 无必要,原生足够 |
| 渲染到 DOM 其他位置 | 需手写 ViewContainerRef.createComponent | DomPortalOutlet.attach 一步到位 |
| 挂到 Overlay 浮层 | 手动拼装 | ComponentPortal/TemplatePortal 直接 attach |
| 跨组件/跨微前端边界传”内容对象” | 做不到(模板离不开声明组件) | Portal 是可序列化传递的普通对象 |
| 单插槽多内容切换 | 自己管理 create/destroy | cdkPortalOutlet 输入即切换 |
一句话总结:模板内的内容投射用 ng-content/ng-template,脱离声明位置的内容投放用 Portal。下一篇的 DragDrop、后文的 Table 都不依赖 Portal,但 Dialog/BottomSheet 类组件会同时用到 Overlay + Portal + FocusTrap,届时三块拼图将合体成完整的无障碍对话框(见第 45 篇)。
常见坑
- TemplatePortal 忘传 ViewContainerRef:attach 时直接抛错,第二个参数不是可选项
- detach 后模板上下文失效:
EmbeddedViewRef被 detach 即与 outlet 解绑,再次使用要重新 attach 新 Portal - Injector 没挂 parent:
Injector.create不传parent时动态组件拿不到全局服务,几乎总是应该链上宿主注入器 - 在 effect 中 attach:attach 是 DOM 副作用,放在事件处理器或
effect的清理重建模式中皆可,但不要在computed里做 - SSR 下使用 DomPortalOutlet:同 Overlay,推迟到客户端交互阶段执行
系列导航
- 上一篇:CDK 22+ 教程 40:Overlay——浮层的基石
- 下一篇:CDK 22+ 教程 42:Drag and Drop 拖拽
- 相关阅读:Overlay——浮层的基石(attach 的另一端就是本文的 Portal) · Accessibility——FocusTrap 与焦点管理