CHARLIE SAYS

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

CDK 22+ 教程 47:Layout 断点与 Observers 观察者

响应式布局的工具箱里,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()。两者背后都是 MediaMatchermatchMedia 的封装(含 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
仅切换 classCSS 优先,别为一个 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 可避免水合闪烁

系列导航

← CDK 22+ 教程 46:ListKeyManager 键盘导航 目录 Angular 全家桶 48:大杂烩——杂项技巧与经验合集 →
← 返回文章列表