CHARLIE SAYS

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

CDK 22+ 教程 40:Overlay——浮层的基石

从本篇开始进入 CDK(Component Dev Kit)部分。CDK 是 Angular Material 团队从组件库中沉淀下来的”无样式基础设施”:它不提供任何视觉,只提供行为。Material 的 MatDialog、MatSelect、MatTooltip 全部建立在 Overlay 之上——可以说 Overlay 是整个浮层世界的地基。本文从”为什么需要 Overlay”讲起,完整覆盖定位策略、滚动策略与声明式用法。

为什么需要 Overlay

假设你要在某个深层嵌套的组件里弹出一个下拉菜单,直接在组件模板里 *ngIf 渲染会遇到两个经典问题:

  • 裁剪:任何一层祖先设置了 overflow: hidden(滚动容器、圆角卡片),浮层就会被截断
  • 层叠上下文:祖先的 transformz-index 会创建新的 stacking context,z-index: 9999 也压不过隔壁兄弟节点

CSS 的层叠规则决定了”渲染在哪,就被哪的祖先约束”。解法只有一个:把浮层挂到 document.body 下的专用容器(Overlay Container)里,与业务 DOM 层级彻底解耦,再用 JS 计算把它”摆”回触发元素旁边。Overlay 做的就是这件事:

graph LR
    A[触发元素 深层嵌套] -- "flexibleConnectedTo(origin)" --> B[Overlay Pane<br/>挂在 body 下的容器中]
    B -- PositionStrategy --> C[定位计算:preferred/fallback/flip]
    B -- ScrollStrategy --> D[滚动时 reposition 或关闭]
    B -- backdrop --> E[遮罩拦截点击]
    A -. 不受祖先 overflow / z-index 影响 .-> B

Overlay 服务与 OverlayRef

Overlay 是一个可注入的服务,create() 接收 OverlayConfig 并返回 OverlayRef

import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';

@Service()
export class DialogService {
  private readonly overlay = inject(Overlay);

  open(): OverlayRef {
    const config = new OverlayConfig({
      hasBackdrop: true,
      backdropClass: 'cdk-overlay-dark-backdrop',
      positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
      scrollStrategy: this.overlay.scrollStrategies.close(),
    });
    const ref = this.overlay.create(config);
    return ref;
  }
}

OverlayRef 的生命周期方法只有三个,务必分清:

方法作用备注
attach(portal)把内容挂到浮层上接受 ComponentPortal 或 TemplatePortal(下篇详解)
detach()卸下内容,浮层保留可再次 attach 复用
dispose()彻底销毁浮层从 DOM 移除 pane,之后不可复用

配套的查询与事件:hasAttached() 判断当前状态;backdropClick() 返回点击遮罩的 Observable<void>keydownEvents() 返回浮层内键盘事件流(常用来监听 Escape);detachments() 在浮层卸下时发出通知,适合做 takeUntil

PositionStrategy 全解

定位策略决定浮层”摆在哪里”。Overlay.position() 返回 OverlayPositionBuilder,提供两种策略。

GlobalPositionStrategy:与视口对齐

适合 dialog、全局 toast 这类”相对屏幕”的浮层:

const global = this.overlay.position()
  .global()
  .centerHorizontally()
  .centerVertically()
  .width('480px');

支持 top/bottom/left/right 固定值与 centerHorizontally()/centerVertically() 居中,链式调用后取其一。

FlexibleConnectedPositionStrategy:锚定触发元素

这是 dropdown、popover 的核心。它把浮层”连接”到 origin 元素,并支持多候选位置自动翻转:

const flexible = this.overlay.position()
  .flexibleConnectedTo(originEl)
  .withPositions([
    // 首选:浮层在 origin 下方,左对齐
    { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 },
    // 备选:下方放不下时翻到上方
    { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 },
  ])
  .withPush(false)          // 视口放不下时是否强行推入
  .withViewportMargin(8);   // 与视口边缘保留的间距

originX/overlayXstart | center | endoriginY/overlayYtop | center | bottom——用”点对齐点”的模型描述九宫格相对位置。策略会在 attach 时测量实际尺寸,依次尝试候选位置,都不合适再根据 withPush 决定行为。典型 dropdown 的两方向候选就是上面代码的形态。

两种策略的分工:

维度GlobalPositionStrategyFlexibleConnectedPositionStrategy
参照物视口任意 origin 元素
典型组件dialog、toastdropdown、select、tooltip
滚动联动通常直接关闭或不动可跟随 origin 重新定位
位置翻转不需要核心能力

ScrollStrategy:滚动时怎么办

浮层出现后页面滚动,行为由 scrollStrategies 决定:

策略行为典型场景
reposition()浮层跟随 origin 重新定位dropdown、select
close()直接关闭浮层小 popover、菜单
block()阻挡页面滚动(body 加锁)dialog
noop()什么都不做全局 toast

block() 的原理是给页面加 cdk-global-scrollblock class(position: fixed 冻结滚动),dialog 场景必备,否则背景滚动会”穿透”遮罩。

backdrop 配置

OverlayConfig 中与遮罩相关的三件套:

const config = new OverlayConfig({
  hasBackdrop: true,                          // 是否渲染遮罩
  backdropClass: 'demo-backdrop',             // 自定义遮罩样式
  // panelClass: 'demo-panel',                // 浮层本体的样式
});

自定义样式时注意:CDK 只提供结构与行为,视觉全部自己写。遮罩默认透明,常见做法是一条半透明黑 + pointer-events: auto。点击遮罩关闭的惯用写法:

const ref = this.overlay.create(config);
ref.backdropClick().subscribe(() => ref.dispose());

完整示例:信号驱动的 Dropdown

下面用 Overlay + TemplatePortal 实现一个完整下拉菜单。状态用信号管理,打开时创建 OverlayRef,关闭时 dispose() 并清空信号:

import { DestroyRef, ElementRef, TemplateRef, viewChild, inject, signal, Component, ViewContainerRef } from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';

@Component({
  selector: 'app-user-menu',
  template: `
    <button type="button" (click)="toggle()" #trigger>账户</button>
    <ng-template #menu>
      <ul class="menu" role="menu">
        @for (item of items(); track item) {
          <li role="menuitem" (click)="choose(item)">{{ item }}</li>
        }
      </ul>
    </ng-template>
  `,
  styles: `
    .menu { background: #fff; border: 1px solid #ddd; border-radius: 6px;
            padding: 4px 0; list-style: none; margin: 0; box-shadow: 0 4px 16px rgba(0,0,0,.12); }
    .menu li { padding: 8px 16px; cursor: pointer; }
    .menu li:hover { background: #f5f5f5; }
  `,
})
export class UserMenuComponent {
  private readonly overlay = inject(Overlay);
  private readonly vcr = inject(ViewContainerRef);
  private readonly host = inject(ElementRef<HTMLElement>);
  private readonly menuTpl = viewChild.required<TemplateRef<never>>('menu');

  readonly items = signal<string[]>(['个人资料', '设置', '退出登录']);
  readonly open = signal(false);
  private ref: OverlayRef | null = null;

  constructor() {
    // 组件销毁时兜底清理,防止浮层泄漏
    inject(DestroyRef).onDestroy(() => this.ref?.dispose());
  }

  toggle(): void {
    this.open() ? this.close() : this.show();
  }

  private show(): void {
    const origin = this.host.nativeElement; // 演示用,实际取 trigger 按钮
    const position = this.overlay.position()
      .flexibleConnectedTo(origin)
      .withPositions([
        { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 },
        { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 },
      ]);
    this.ref = this.overlay.create({
      positionStrategy: position,
      scrollStrategy: this.overlay.scrollStrategies.reposition(),
      hasBackdrop: true,
      backdropClass: 'cdk-overlay-transparent-backdrop',
    });
    this.ref.attach(new TemplatePortal(this.menuTpl(), this.vcr));
    this.ref.backdropClick().subscribe(() => this.close());
    this.open.set(true);
  }

  private close(): void {
    this.ref?.dispose();
    this.ref = null;
    this.open.set(false);
  }

  choose(item: string): void {
    console.log('选中', item);
    this.close();
  }
}

(完整示例中 menuTpl 通过 viewChild 信号查询获取,触发元素也可用同样的方式精确到按钮节点。)几个要点:TemplatePortal 构造时必须传 ViewContainerRef;关闭用 dispose() 而非 detach(),因为每次打开都重建浮层更简单;backdrop 使用透明遮罩拦截点击但不遮挡视觉。

CDKConnectedOverlay:声明式用法

命令式 API 适合封装服务,简单场景用指令更省事。CDKConnectedOverlay 把”创建浮层 + 挂模板”封装成一对指令:

<button cdkOverlayOrigin #origin="cdkOverlayOrigin" type="button">更多操作</button>

<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="origin"
             [cdkConnectedOverlayOpen]="menuOpen()"
             [cdkConnectedOverlayHasBackdrop]="true"
             cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
             (backdropClick)="menuOpen.set(false)"
             [cdkConnectedOverlayPositions]="positions">
  <ul class="menu">
    <li>复制</li>
    <li>删除</li>
  </ul>
</ng-template>
readonly menuOpen = signal(false);
readonly positions = [
  { originX: 'start' as const, originY: 'bottom' as const, overlayX: 'start' as const, overlayY: 'top' as const },
];

cdkOverlayOrigin 标记触发元素,cdkConnectedOverlayOpen 为 true 时模板被投射到 Overlay 容器中,false 时自动卸下并销毁浮层。指令同样暴露 (attach)/(detach) 事件和 cdkConnectedOverlayScrollStrategycdkConnectedOverlayPush 等配置项,能力与命令式 API 一一对应。v22 站点默认 standalone + Zoneless,指令内部全部走信号与 effect 更新,无需变更检测干预。

常见坑

  • 忘记 dispose:OverlayRef 挂在全局容器上,不随宿主组件销毁,必须在 DestroyRef.onDestroy 中清理
  • 在构造函数外创建后跨请求复用detach() 后 pane 仍占着定位计算结果,复用前先确认 hasAttached() 状态
  • backdrop 拦截了浮层自身点击:检查 backdropClass 是否被写到了 panelClass 上,两者作用于不同元素
  • flexibleConnectedTo 传了组件实例:它要的是 HTMLElementElementRef,传错只能拿到默认位置
  • SSR 环境:Overlay 依赖 document,服务端渲染时创建浮层应延后到交互(增量 hydration 之后)再执行

系列导航

← 算法 040:大数据处理:Overview 目录 算法 041:大数据处理:分治、Hash、排序 →
← 返回文章列表