CHARLIE SAYS

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

CDK 22+ 教程 44:Table 与 DataSource

表格是中后台的主角。CDK Table(cdk-table)是一个”无样式、强结构”的数据表格引擎:它不画一条边框,却把列定义、行渲染、数据流抽象做成了一套可组合的协议。Angular Material 的 MatTable 就是在它之上加了一层 M3 样式的直接封装。理解 cdk-table,等于同时理解了 MatTable 的骨架。

模板结构:以”列”为中心

cdk-table 用 ng-template 风格的微语法声明每一列的 header 与 cell,再把列拼装成行:

<cdk-table [dataSource]="data" [trackBy]="trackById" cdkSort (cdkSortChange)="onSort($event)">
  <!-- 每列一个 ng-container,cdkColumnDef 声明列名 -->
  <ng-container cdkColumnDef="name">
    <th cdk-header-cell *cdkHeaderCellDef>名称</th>
    <td cdk-cell *cdkCellDef="let row">{{ row.name }}</td>
  </ng-container>

  <ng-container cdkColumnDef="role">
    <th cdk-header-cell *cdkHeaderCellDef>角色</th>
    <td cdk-cell *cdkCellDef="let row">{{ row.role | json }}</td>
  </ng-container>

  <!-- 行的组装:columns 是列名数组,顺序即列顺序 -->
  <tr cdk-header-row *cdkHeaderRowDef="columns"></tr>
  <tr cdk-row *cdkRowDef="let row; columns: columns"></tr>
</cdk-table>
columns = ['name', 'role'];
trackById(_index: number, row: UserRow): number { return row.id; }

这套模板(源码中即 CDK_TABLE_TEMPLATE)的分工很清晰:

指令职责
cdkColumnDef声明一列的名字与元信息
*cdkHeaderCellDef / *cdkCellDef列头/单元格的模板,let row 拿到行数据
*cdkHeaderRowDef / *cdkRowDef把列模板拼成表头行/数据行
[dataSource]数据源(数组或 DataSource 实例)
[trackBy]行复用策略,语义同 @for 的 track

列结构带来的核心收益是动态列columns 换成信号,勾选配置即可增删列、调顺序,模板一行不用改。

dataSource:数组只是最简形态

dataSource 接受两种输入。最简单的是普通数组(或只读数组信号解包后的值),cdk-table 会同步渲染。但当数据来自异步流、需要排序/分页联动、或要做服务端分页时,就该实现 DataSource 抽象类:

import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, Subject, switchMap, takeUntil } from 'rxjs';

export class ServerSource extends DataSource<UserRow> {
  private readonly http = inject(HttpClient);
  private readonly query$ = new Subject<{ sort: string; direction: string; page: number }>();
  private readonly destroy$ = new Subject<void>();

  connect(viewer: CollectionViewer): Observable<readonly UserRow[]> {
    return this.query$.pipe(
      switchMap((q) => this.http.get<UserRow[]>(`/api/users`, { params: { ...q } })),
      takeUntil(this.destroy$),
    );
  }

  disconnect(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

协议只有两个方法:

  • connect(collectionViewer):返回 Observable<readonly T[]>。cdk-table 订阅它,每次发出新数组就重渲染行。参数 CollectionViewer 暴露 viewChange: Observable<ListRange>——渲染行范围流,虚拟滚动组合时用它做按需取数
  • disconnect():表格销毁时调用,负责清理订阅与请求

connect 里没有 Angular 魔法,就是一个 RxJS 流:把排序、分页、筛选信号 toObservablecombineLatest 进来,switchMap 到请求即可。MatTable 的 MatTableDataSource(内置排序/过滤/分页的本地数据源)正是这个抽象的官方参考实现。

排序集成:cdkSort 与 cdk-sort-header

CDK 的排序是一对搭档:cdkSort 指令挂在表级管理状态,cdk-sort-header 挂在列头提供交互与箭头:

<cdk-table [dataSource]="data" cdkSort (cdkSortChange)="onSortChange($event)">
  <ng-container cdkColumnDef="name">
    <th cdk-header-cell *cdkHeaderCellDef
        cdk-sort-header="name" arrowPosition="after">名称</th>
    <td cdk-cell *cdkCellDef="let row">{{ row.name }}</td>
  </ng-container>

  <ng-container cdkColumnDef="age">
    <th cdk-header-cell *cdkHeaderCellDef
        cdk-sort-header="age" [disabled]="!ageSortable()">年龄</th>
    <td cdk-cell *cdkCellDef="let row">{{ row.age }}</td>
  </ng-container>
  ...
</cdk-table>
import { Sort } from '@angular/cdk/sort';

onSortChange(sort: Sort): void {
  // sort.active 是列 id,direction 为 'asc' | 'desc' | ''
  const sorted = [...this.data()].sort((a, b) =>
    sort.direction === 'asc' ? a[sort.active] - b[sort.active] : b[sort.active] - a[sort.active],
  );
  this.data.set(sorted);
}

cdk-sort-header 的行为细节:首次点击升序、再次降序、第三次复原(cdkSortDisableClear 可关闭三态);箭头默认随状态显示,arrowPosition 控制在文本前还是后;disabled 输入禁用单列排序;整个排序状态也可用 [(cdkSortActive)] 等输入做双向绑定。服务端排序场景下,把 cdkSortChange 事件接到 DataSource 的查询流即可,本地排序交给 MatTableDataSource 时则一行代码都不用写。

trackBy 与排序的关系值得强调:排序是典型的”整表数据重排”,没有 trackById 时每行 DOM 都销毁重建;有了它,cdk-table 按行数据身份复用 DOM,只移动位置。这直接影响大数据量下的排序流畅度。

与 MatTable 的关系

MatTable = cdk-table + Material 样式 + 一组现成的便捷封装:

能力cdk-tableMatTable
样式无,全自定义M3 主题样式开箱即用
列协议cdkColumnDef同一套(matColumnDef 是别名)
数据源数组 / DataSource额外提供 MatTableDataSource(本地排序/过滤/分页)
排序cdkSortmatSort(协议相同)
适用设计系统/深定制Material 风格后台

升级路径平滑:先用 cdk-table 搭好数据层,需要 Material 视觉时把 cdk- 前缀换成 mat- 前缀、指令换 import,模板结构原样保留。

大数据量:Table × 虚拟滚动

万行数据放进表格,思路与第 43 篇一致——用 cdk-virtual-scroll-viewport 包住表格,让行按需渲染。因为 *cdkRowDef*cdkVirtualFor 不能叠在同一 tr 上,惯用做法是把”行渲染”交给 cdk-table、“滚动窗口”交给 viewport,数据源用 viewChange 按需供数:

<cdk-virtual-scroll-viewport itemSize="48" class="table-viewport">
  <cdk-table [dataSource]="windowedSource" [trackBy]="trackById">
    <ng-container cdkColumnDef="name">
      <th cdk-header-cell *cdkHeaderCellDef>名称</th>
      <td cdk-cell *cdkCellDef="let row">{{ row.name }}</td>
    </ng-container>
    <tr cdk-header-row *cdkHeaderRowDef="columns"></tr>
    <tr cdk-row *cdkRowDef="let row; columns: columns"></tr>
  </cdk-table>
</cdk-virtual-scroll-viewport>
// DataSource.connect 中利用 viewChange 只发送窗口内数据
connect(viewer: CollectionViewer): Observable<readonly UserRow[]> {
  return viewer.viewChange.pipe(
    // start/end 为渲染行区间,前后各多取 20 行缓冲
    map(({ start, end }) => this.allRows.slice(Math.max(0, start - 20), end + 20)),
  );
}

配合固定行高(itemSizetd 高度严格一致)、列宽用 table-layout: fixed 锁定,即可得到一个流畅滚动的万行表格。若行高不定,退回”服务端分页 + 本地虚拟滚动缓存”的组合更稳妥。

常见坑

  • *cdkRowDef*cdkCellDef 写错位置:cell 模板属于 ng-container cdkColumnDef,行模板属于表格直接子级,混用会静默渲染不出内容
  • trackBy 返回 index:排序后行身份全变,复用失效;务必返回业务 id
  • dataSource 传了信号本身:应传数组值(模板中信号调用后自动解包),传信号函数会导致表格拿到非数组
  • connect 里忘了清理disconnect 未取消订阅会内存泄漏(参见第 27 篇)
  • 列宽抖动:无样式表格默认 auto 布局,长内容挤压其他列,用固定 table-layout + 列宽 class

系列导航

← 算法 044:一些领域算法知识体系 目录 算法 045:头脑风暴题目 →
← 返回文章列表