从本篇开始进入 CDK(Component Dev Kit)部分。CDK 是 Angular Material 团队从组件库中沉淀下来的”无样式基础设施”:它不提供任何视觉,只提供行为。Material 的 MatDialog、MatSelect、MatTooltip 全部建立在 Overlay 之上——可以说 Overlay 是整个浮层世界的地基。本文从”为什么需要 Overlay”讲起,完整覆盖定位策略、滚动策略与声明式用法。
为什么需要 Overlay
假设你要在某个深层嵌套的组件里弹出一个下拉菜单,直接在组件模板里 *ngIf 渲染会遇到两个经典问题:
- 裁剪:任何一层祖先设置了
overflow: hidden(滚动容器、圆角卡片),浮层就会被截断 - 层叠上下文:祖先的
transform、z-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/overlayX 取 start | center | end,originY/overlayY 取 top | center | bottom——用”点对齐点”的模型描述九宫格相对位置。策略会在 attach 时测量实际尺寸,依次尝试候选位置,都不合适再根据 withPush 决定行为。典型 dropdown 的两方向候选就是上面代码的形态。
两种策略的分工:
| 维度 | GlobalPositionStrategy | FlexibleConnectedPositionStrategy |
|---|---|---|
| 参照物 | 视口 | 任意 origin 元素 |
| 典型组件 | dialog、toast | dropdown、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) 事件和 cdkConnectedOverlayScrollStrategy、cdkConnectedOverlayPush 等配置项,能力与命令式 API 一一对应。v22 站点默认 standalone + Zoneless,指令内部全部走信号与 effect 更新,无需变更检测干预。
常见坑
- 忘记 dispose:OverlayRef 挂在全局容器上,不随宿主组件销毁,必须在
DestroyRef.onDestroy中清理 - 在构造函数外创建后跨请求复用:
detach()后 pane 仍占着定位计算结果,复用前先确认hasAttached()状态 - backdrop 拦截了浮层自身点击:检查
backdropClass是否被写到了panelClass上,两者作用于不同元素 - flexibleConnectedTo 传了组件实例:它要的是
HTMLElement或ElementRef,传错只能拿到默认位置 - SSR 环境:Overlay 依赖
document,服务端渲染时创建浮层应延后到交互(增量 hydration 之后)再执行
系列导航
- 上一篇:Angular Material 22+ 教程 39:Icon 与 Tooltip
- 下一篇:CDK 22+ 教程 41:Portal——内容投射
- 相关阅读:Portal——内容投射(Overlay 的 attach 依赖 Portal,建议连读) · Accessibility——FocusTrap 与焦点管理