响应式布局的工具箱里,CSS media query 负责”长什么样”,但有些行为必须由 TypeScript 决定:移动端换手势交互、宽屏预加载更多列、断点变化时重算虚拟滚动参数。CDK Layout 模块的 BreakpointObserver 把 media query 变成 Observable;Observers 模块则把 MutationObserver 包装成 Angular 友好的指令与服务。这是 CDK 部分的收官篇。
BreakpointObserver:断点即流
import { BreakpointObserver, Breakpoints, BreakpointState } from '@angular/cdk/layout';
@Service()
export class UiStateService {
private readonly bp = inject(BreakpointObserver);
readonly isHandset$ = this.bp.observe([Breakpoints.Handset]);
readonly isWide$ = this.bp.observe([Breakpoints.WebLandscape]);
}
observe() 返回 Observable<BreakpointState>:
interface BreakpointState {
matches: boolean;
breakpoints: { [key: string]: boolean }; // 每个查询各自的匹配详情
}
它是多播的(底层一个 MediaMatcher 驱动),初始立即发出当前状态,之后每次断点变化发出新值——matches 从 true 变 false 也会通知,不需要写两次 observe。
内置断点与自定义查询
Breakpoints 常量覆盖 Material 断点体系:
| 常量 | 含义 |
|---|---|
Handset | 手机竖屏(小屏) |
Tablet / TabletLandscape | 平板 |
Web / WebLandscape | 桌面 |
Small / Medium / Large / XLarge | 纯宽度档位 |
多查询取并集:observe([Breakpoints.Handset, Breakpoints.Tablet]) 任一匹配即 matches: true。自定义断点直接写媒体查询字符串,或组合:
readonly isNarrow$ = this.bp.observe(['(max-width: 640px)']);
readonly isPortraitTablet$ = this.bp.observe(['(orientation: portrait) and (min-width: 600px)']);
// isMatched:一次性判断,不订阅
if (this.bp.isMatched(Breakpoints.Handset)) {
// 首屏按移动端初始化
}
isMatched() 返回同步快照,适合初始化分支;持续响应用 observe()。两者背后都是 MediaMatcher 对 matchMedia 的封装(含 SSR 降级)。
与信号配合:toSignal 包装
v22 的组件状态几乎都是信号,把断点流接进信号世界是标准操作:
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-layout',
template: `
@if (isHandset()) {
<app-mobile-nav />
} @else {
<app-side-nav [expanded]="isWide()" />
}
<main [class.compact]="isHandset()">...</main>
`,
})
export class LayoutComponent {
private readonly bp = inject(BreakpointObserver);
readonly isHandset = toSignal(
this.bp.observe(Breakpoints.Handset).pipe(map((state) => state.matches)),
{ initialValue: false },
);
readonly isWide = toSignal(
this.bp.observe(Breakpoints.WebLandscape).pipe(map((state) => state.matches)),
{ initialValue: false },
);
}
要点:
toSignal必须给initialValue(或requireSync),否则模板首次渲染读到undefined- 需要的是布尔而不是状态对象时,先
pipe(map((s) => s.matches))再 toSignal - 组件销毁时 toSignal 自动退订,无需手动清理(与第 27 篇的泄漏主题呼应)
- 初始化时的一次性分支用
isMatched()快照,别为一次判断订阅整条流
与 CSS media query 的分工
| 职责 | 手段 |
|---|---|
| 显隐、间距、栅格、字体等纯视觉 | CSS @media / container query |
| 换组件、换数据策略、换交互模式 | BreakpointObserver + 信号 |
| 图片 srcset | 原生 srcset/sizes |
| 仅切换 class | CSS 优先,别为一个 class 订阅断点 |
经验法则:能用 CSS 表达的绝不进 TS。一旦进 TS,就用一个集中的 UiStateService 暴露信号,避免十个组件各自 observe 同一断点。
Observers:观察 DOM 变化
CDK Observers 模块包装了 MutationObserver,观察元素的内容与特性变化。
ObserveContent 指令
cdkObserveContent 监听子树的内容变动并发出事件:
<div class="badge" (cdkObserveContent)="onContentChange()">
{{ count() }}
</div>
<!-- 只观察特定特性变化,用 cdkObserveContentDisabled 暂停 -->
<div cdkObserveContent [debounce]="300" (cdkObserveContent)="recalculate()">
<ng-content />
</div>
import { ObserversModule } from '@angular/cdk/observers';
典型场景:容器里有 <ng-content> 投影进来的第三方内容(MathJax 渲染、代码高亮、用户编辑的富文本),内容变了要重新计算高度或触发重排——Angular 的绑定体系感知不到这类”框架外”的 DOM 变化,MutationObserver 是唯一手段。debounce 输入把高频 DOM 变化合并成一次回调,长内容必开。
observe 服务
命令式场景用 observe 工厂函数,直接拿到 Observable:
import { observe } from '@angular/cdk/observers';
const injector = inject(Injector);
// 观察特性变化:如第三方脚本改写 disabled/aria-* 属性
const sub = observe(element, { attributes: true, attributeFilter: ['disabled'] })
.pipe(debounceTime(200))
.subscribe(() => this.syncState());
sub.unsubscribe(); // 手动管理生命周期
ResizeObserver 场景
Observers 模块专注内容/特性(MutationObserver 家族)。元素尺寸变化(ResizeObserver)CDK 未做专门封装,惯例是原生 API + 手动桥接:
constructor() {
const host = inject(ElementRef<HTMLElement>).nativeElement;
const ro = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
this.hostSize.set({ width, height });
});
ro.observe(host);
inject(DestroyRef).onDestroy(() => ro.disconnect());
}
典型用途:卡片尺寸变化时重算 ECharts 图表、瀑布流重排、@defer 块的占位测量。注意 ResizeObserver 回调高频触发,进信号前节流;SSR 下该 API 不可用,判断 typeof ResizeObserver !== 'undefined' 再注册。
组合实战:自适应虚拟滚动参数
第 43 篇的虚拟滚动 itemSize 固定,但移动端行高更大、可见行数更少——用断点流动态决定参数:
readonly isHandset = toSignal(
this.bp.observe(Breakpoints.Handset).pipe(map((s) => s.matches)),
{ initialValue: false },
);
readonly itemSize = computed(() => (this.isHandset() ? 64 : 40));
readonly bufferPx = computed(() => (this.isHandset() ? 600 : 400));
<cdk-virtual-scroll-viewport [itemSize]="itemSize()"
[minBufferPx]="bufferPx()" [maxBufferPx]="bufferPx() * 2">
<div class="row" *cdkVirtualFor="let item of items(); trackBy: trackById"
[class.mobile]="isHandset()">{{ item.label }}</div>
</cdk-virtual-scroll-viewport>
断点信号驱动参数、CSS 处理行内视觉——这就是 Layout 模块与样式体系的理想分工。
常见坑
- 在模块/服务构造期 observe 却不退订:root 服务无妨,组件级服务记得随 DestroyRef 清理
- toSignal 放在字段初始化器之外:脱离注入上下文会抛错,需要显式传
injector - observe 了高频断点做重活:每次变化都重发请求/重建列表,先
distinctUntilChanged映射成布尔 - cdkObserveContent 观察 ng-content 但内容来自绑定:Angular 绑定变化你本来就知道,别多此一举;它只对”框架外”变化有意义
- SSR 首帧不一致:服务端没有 matchMedia,
initialValue决定首屏渲染,选 false 可避免水合闪烁