CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-204 · Angular 高级教程

CDK 22+ 教程 41:Portal——内容投射

上一篇的 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): PortalOutletAttachedResultdetach(): anyhasAttached()dispose()

PortalOutletAttachedResultComponentPortal attach 后返回的 ComponentRefTemplatePortal 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 的选型对比:

维度ComponentPortalTemplatePortal
内容形态独立组件类当前组件里的模板
传数据自定义 Injector模板上下文 let-xxx
依赖注入独立注入层级(可用私有 Injector 定制)跟随声明它的组件
适用场景通用对话框、可复用弹层轻量下拉菜单、宿主强相关的浮层
返回值ComponentRefEmbeddedViewRef

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.createComponentDomPortalOutlet.attach 一步到位
挂到 Overlay 浮层手动拼装ComponentPortal/TemplatePortal 直接 attach
跨组件/跨微前端边界传”内容对象”做不到(模板离不开声明组件)Portal 是可序列化传递的普通对象
单插槽多内容切换自己管理 create/destroycdkPortalOutlet 输入即切换

一句话总结:模板内的内容投射用 ng-content/ng-template,脱离声明位置的内容投放用 Portal。下一篇的 DragDrop、后文的 Table 都不依赖 Portal,但 Dialog/BottomSheet 类组件会同时用到 Overlay + Portal + FocusTrap,届时三块拼图将合体成完整的无障碍对话框(见第 45 篇)。

常见坑

  • TemplatePortal 忘传 ViewContainerRef:attach 时直接抛错,第二个参数不是可选项
  • detach 后模板上下文失效EmbeddedViewRef 被 detach 即与 outlet 解绑,再次使用要重新 attach 新 Portal
  • Injector 没挂 parentInjector.create 不传 parent 时动态组件拿不到全局服务,几乎总是应该链上宿主注入器
  • 在 effect 中 attach:attach 是 DOM 副作用,放在事件处理器或 effect 的清理重建模式中皆可,但不要在 computed 里做
  • SSR 下使用 DomPortalOutlet:同 Overlay,推迟到客户端交互阶段执行

系列导航

← 算法 041:大数据处理:分治、Hash、排序 目录 算法 042:大数据处理:双层桶划分 →
← 返回文章列表