Angular Material 18+ 高级教程 – CDK Table

前言

CDK Table 是 Angular Material 对 <table> 的抽象 (无 styles) 封装。

无 styles 的 table 有什么好封装的呢?

CDK Table 最重要的 3 个功能:

  1. 以 column 概念来做管理

  2. 动态选择性输出 column

  3. sticky column

都不算什么大功能,但如果我们要自己实现这些功能,确实也挺费劲的,所以我还是推荐大家使用。

提醒:CDK Table 使用了 Angular 的 ng-template 和指令微语法,对这块不熟悉的朋友最好先看这两篇:ng-template 和 指令微语法

 

Overview

我们先看看如何使用它,之后再分析它的实现手法和逛一逛源码。

App 组件

首先在 App 组件 import CdkTableModule,因为我们需要用到它的指令

imports: [CdkTableModule]

接着定义一个数据类型作为 table 展示的数据

interface Person {
  name: string;
  age: number;
}

然后定义数据和指定要显示的 columns (CDK 可以动态选择要输出哪些 column,不一定要出到完)

export class AppComponent {
  readonly people = signal<Person[]>([
    { name: 'Derrick', age: 11 },
    { name: 'Alex', age: 12 },
  ]);

  readonly displayedColumns = signal<(keyof Person)[]>(['name', 'age']);
}

App Template

首先是 <table> element

<table cdk-table [dataSource]="people()">

加上一个 cdk-table (a.k.a CdkTable) 组件 (它是组件来的哦,不是指令),和把数据传入 @Input dataSource。

这个 dataSource 支持很多种类型

Array,RxJS Observable Array,还有 DataSoruce (这个我们在 CDK Scrolling 文章中讲解过)

接着是 define column

<table cdk-table [dataSource]="people()">

  <ng-container cdkColumnDef="name">
     <th cdk-header-cell *cdkHeaderCellDef >Name</th>
     <td cdk-cell *cdkCellDef="let person">{{ person.name }}</td>
  </ng-container>
  
</table>

每一个 column 都需要一个 definition,这个 definition 主要是定义 header cell ng-template 和 body cell ng-template。

上面是指令微语法的写法,换成 ng-template 的写法是这样

<ng-container cdkColumnDef="name">

  <ng-template cdkHeaderCellDef>
    <th cdk-header-cell>Name</th>
  </ng-template>

  <ng-template cdkCellDef let-person>
    <td cdk-cell>{{ person.name }}</td>
  </ng-template>
  
</ng-container>

cdkColumnDef, cdkHeaderCellDef, cdk-header-cell, cdkCellDef, cdk-cell 这 5 个都是指令。

下面我们统一用 class name 来称呼它们吧,不然 case style 不一致,看了很乱 -- CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCellDef, CdkCell。

我们有 name 和 age 两个 column,所以需要 2 个 definition。

再加上 age column 的 definition

<ng-container cdkColumnDef="age">
  <th cdk-header-cell *cdkHeaderCellDef >Age</th>
  <td cdk-cell *cdkCellDef="let person">{{ person.age }}</td>
</ng-container>

column definition 搞定后,下一个是搞 header row 和 body row 的 definition,这个 row definition 主要也是定义 ng-template。

<tr cdk-header-row *cdkHeaderRowDef="displayedColumns()"></tr>
<tr cdk-row *cdkRowDef="let row; columns: displayedColumns()"></tr> 

上面是指令微语法的写法,换成 ng-template 的写法是这样

<ng-template [cdkHeaderRowDef]="displayedColumns()">
  <tr cdk-header-row></tr>
</ng-template>

<ng-template cdkRowDef [cdkRowDefColumns]="displayedColumns()">
  <tr cdk-row></tr> 
</ng-template>

cdkHeaderRowDef, cdkRowDef 是指令,cdk-header-row, cdk-row 是组件 (和 CdkTable 类似,虽然是用 attribute selector 但其实是组件来的)

一样,下面统一用 class name 称呼它们,CdkHeaderRowDef, CdkRowDef, CdkHeaderRow, CdkRow。

完整的 table

<table cdk-table [dataSource]="people()">
  <ng-container cdkColumnDef="name">
     <th cdk-header-cell *cdkHeaderCellDef >Name</th>
     <td cdk-cell *cdkCellDef="let person">{{ person.name }}</td>
  </ng-container>

  <ng-container cdkColumnDef="age">
    <th cdk-header-cell *cdkHeaderCellDef >Age</th>
    <td cdk-cell *cdkCellDef="let person">{{ person.age }}</td>
  </ng-container>

  <tr cdk-header-row *cdkHeaderRowDef="displayedColumns()"></tr>
  <tr cdk-row *cdkRowDef="let row; columns: displayedColumns()"></tr> 
</table>

效果

角色说明

CdkTable 组件是老大。

CdkHeaderCellDef, CdkCellDef, CdkHeaderRowDef, CdkRowDef,这四个结尾带有 Def (definition 的缩写) 都是指令,负责各自的 ng-template。

CdkColumnDef 指令虽然也是带有 Def 结尾,但它没有自身的 ng-template,它只是 CdkHeaderCellDef 和 CdkCellDef 的 wrapper 而已。

CdkHeaderCell, CdkCell 这两个是指令,没什么职责,负责添加 class 而已。

CdkHeaderRow, CdkRow 这两个是组件。

推测底层实现

从上面的代码结构,我们可以大致推测出它底层的实现方式。

CdkTable 组件是老大,通过 @Input 拿到数据,然后 query content 拿到 CdkHeaderRowDef (header row ng-template) 和 CdkRowDef (body row ng-template)。

接着 for loop 数据 createEmbededView,把 row ng-template 和数据 (作为 ng-template context) 结合,然后插入到 CdkTable Template 里。

与此同时,也 query content 拿到 CdkColumnDef 指令,再往内 query content 拿到 CdkHeaderCellDef (header cell ng-template) 和 CdkCellDef (body cell ng-template),

然后 for loop 数据 createEmbededView,最后插入到对应的 CdkHeaderRow 和 CdkRow 的 Template 里。

 

Features and Options

Overview 了解后,我们来看几个细节功能。

Update data & displayedColumns

数据和 displayedColumns 都是 binding data,直接修改就可以了

window.setTimeout(() => {
  this.people.set([this.people()[0], { name: 'David', age: 13 }]);
  this.displayedColumns.set(['name']);
}, 1000);

效果

trackBy

和 *ngFor 的 TrackByFunction 同样概念

组件

readonly trackByName: TrackByFunction<Person> = (_index, person) => person.name;

Template

<table cdk-table [dataSource]="people()" [trackBy]="trackByName">

by default,如果数据对象的引用不同,那会被认为是不同的数据,row 会被 remove,然后 create 新的。

加上 trackBy 后,对比的方式不再是看对象的引用,而是 person.name,如果 name 相同将被视为是相同数据,哪怕对象引用是不同的。这时 row 不会被 remove,只会 update binding data 而已。

recycleRows

recycleRows 比 trackBy 极端,它不需要比对数据,因为它会尽可能的保留 row 重复使用,它只 update binding data 而已。

row index

和 *ngFor 一样,可以拿到 index, odd, even, first, last 等等 

<td cdk-cell 
  *cdkCellDef="let person; index as index; odd as odd"
>
  {{ person.name }} - {{ index }} - {{ odd }}
</td>

效果

Styling

CDK Table 会为各个 element 加上对应的 class,比如

<table class="cdk-table">
  <thead>
    <tr class="cdk-header-row">
      <th class="cdk-header-cell cdk-column-name"></th>
      <th class="cdk-header-cell cdk-column-age"></th>
    </tr>
  </thead>
  <tbody>
    <tr class="cdk-row">
      <td class="cdk-cell cdk-column-name"></td>
      <td class="cdk-cell cdk-column-age"></td>
    </tr>
    <tr class="cdk-row">
      <td class="cdk-cell cdk-column-name"></td>
      <td class="cdk-cell cdk-column-age"></td>
    </tr>
  </tbody>
</table>

我们可以用 CSS select 这些 class 做 styling。

fixedLayout

by default,table 和 td width 都是 max-content,也就是依据内容的 width。

我们可以通过 @Input fixedLayout 让它变成依据 table width。

<table cdk-table [dataSource]="people()" fixedLayout [style.width.px]="512">

效果

td width 就是平均值,table width 除于 column 数量,512 / 2 = 253px for each td。

display: flex

CDK Table 不一定要搭配原生 <table> element,我们也可以改成 div 用 display: flex 来实现。

把 cdk-table, cdk-header-row, cdk-row, cdk-header-cell, cdk-cell 从 attribute 换成 tag

<cdk-table [dataSource]="people()" fixedLayout [style.width.px]="512">
  <ng-container cdkColumnDef="name">
    <cdk-header-cell *cdkHeaderCellDef>Name</cdk-header-cell>
    <cdk-cell *cdkCellDef="let person">{{ person.name }} </cdk-cell>
  </ng-container>

  <ng-container cdkColumnDef="age">
    <cdk-header-cell *cdkHeaderCellDef>Age</cdk-header-cell>
    <cdk-cell *cdkCellDef="let person">{{ person.age }}</cdk-cell>
  </ng-container>

  <cdk-header-row *cdkHeaderRowDef="displayedColumns()"></cdk-header-row>
  <cdk-row *cdkRowDef="let row; columns: displayedColumns()"></cdk-row>
</cdk-table>

然后添加一些 flex styling

cdk-table {
  display: block;
}

cdk-row,
cdk-header-row,
cdk-footer-row {
  display: flex;
}

cdk-cell,
cdk-header-cell,
cdk-footer-cell {
  flex: 1;
}

效果

<cdk-table class="cdk-table cdk-table-fixed-layout">
  <cdk-header-row class="cdk-header-row">
    <cdk-header-cell class="cdk-header-cell cdk-column-name">Name</cdk-header-cell>
    <cdk-header-cell class="cdk-header-cell cdk-column-age">Age</cdk-header-cell>
  </cdk-header-row>
  <cdk-row class="cdk-row">
    <cdk-cell class="cdk-cell cdk-column-name">Derrick</cdk-cell>
    <cdk-cell class="cdk-cell cdk-column-age">11</cdk-cell>
  </cdk-row>
  <cdk-row class="cdk-row">
    <cdk-cell class="cdk-cell cdk-column-name">Alex</cdk-cell>
    <cdk-cell class="cdk-cell cdk-column-age">12</cdk-cell>
  </cdk-row>
</cdk-table>

不再生成 thead, tbody 这些了。

提醒:flex 版会失去所有原生 table 的功能,比如 printing styles, colspan, rowspan, sticky behaviour 等等。

Footer

footer 和 header 的做法大同小异

Template

<ng-container cdkColumnDef="age">
  <th cdk-header-cell *cdkHeaderCellDef align="right">Age</th>
  <td cdk-cell *cdkCellDef="let person" align="right">{{ person.age }}</td>
  <td cdk-footer-cell *cdkFooterCellDef colspan="2" align="right">{{ totalAge() }}</td>
</ng-container>

<tr cdk-footer-row *cdkFooterRowDef="['age']"></tr>

组件

readonly totalAge = computed(() => this.people().reduce((prev, curr) => prev + curr.age, 0));

效果

No data row

当没有数据时,我们可以让它输出一条 no data row

<tr *cdkNoDataRow>
  <td colspan="2">No data</td>
</tr>

注:它不需要 column definition 哦。

效果

Row when and multiple row template

row when 的玩法是这样的

<tr cdk-row *cdkRowDef="let row; columns: displayedColumns(); when: aCondition"></tr>
<tr cdk-row *cdkRowDef="let row; columns: displayedColumns(); when: bCondition"></tr>

我们可以声明多个 body row ng-template,并且给予一个 when 函数作为判断输出的条件。

注:header 和 footer row 是没有 when 概念的,只有 body row 有。

readonly aCondition: CdkRowDef<Person>['when'] = (_index, person) => true;
readonly bCondition: CdkRowDef<Person>['when'] = (_index, person) => true;

有点类似 switch 的概念,依据数据决定要采用哪一个 row ng-template。

by default,第一个匹配成功的 row ng-template 会用来 createEmbededView,其它的会忽视,但如果我们想 multiple 输出也可以 (意思是 1 条数据,如果匹配到 2 个 row ng-template 那就输出 2 条 row)。

在 CdkTable 添加 @Input multiTemplateDataRows

<table cdk-table [dataSource]="people()" [trackBy]="trackByName" multiTemplateDataRows>

效果

2 条数据输出了 4 条 row。 

另外,multiTemplateDataRows 的情况下,index 会被分成 2 种,一种是 dataIndex,另一种是 renderIndex

<td 
  cdk-cell 
  *cdkCellDef="let person; dataIndex as dataIndex; renderIndex as renderIndex"
>
  {{ person.name }} - {{ dataIndex }} - {{ renderIndex }}
</td>

原本的 index 就没了。

效果

dataIndex 依据的是数据 length,renderIndex 就是依据 row length。

 

Sticky

CDK Table 用的是原生 CSS position sticky。

原生 sticky 有一个缺陷,那就是当 sticky multiple row / column 时,我们需要计算 top left 的值。

CDK Table 弥补了这个缺陷,我们不需要自己做计算,它会替我们算好好。

我们把数据加大,看看效果

Template

<div class="table-container">
  <table cdk-table [dataSource]="people()" [trackBy]="trackByIdFn" >
    <ng-container cdkColumnDef="id" sticky>
      <th cdk-header-cell *cdkHeaderCellDef >ID</th>
      <td cdk-cell *cdkCellDef="let rowData">{{ rowData.id }}</td>
      <td cdk-footer-cell *cdkFooterCellDef></td>
    </ng-container>

    <ng-container cdkColumnDef="firstName" sticky>
      <th cdk-header-cell *cdkHeaderCellDef >First Name</th>
      <td cdk-cell *cdkCellDef="let rowData">{{ rowData.firstName }}</td>
      <td cdk-footer-cell *cdkFooterCellDef>-</td>
    </ng-container>

    <ng-container cdkColumnDef="lastName">
      <th cdk-header-cell *cdkHeaderCellDef >Last Name</th>
      <td cdk-cell *cdkCellDef="let rowData">{{ rowData.lastName }}</td>
      <td cdk-footer-cell *cdkFooterCellDef>-</td>
    </ng-container>

    <ng-container cdkColumnDef="fullName">
      <th cdk-header-cell *cdkHeaderCellDef >Full Name</th>
      <td cdk-cell *cdkCellDef="let rowData">{{ rowData.fullName }}</td>
      <td cdk-footer-cell *cdkFooterCellDef>-</td>
    </ng-container>

    <ng-container cdkColumnDef="age">
      <th cdk-header-cell *cdkHeaderCellDef >Age</th>
      <td cdk-cell *cdkCellDef="let rowData">{{ rowData.age }}</td>
      <td cdk-footer-cell *cdkFooterCellDef>{{ totalAge() }}</td>
    </ng-container>

    <ng-container cdkColumnDef="salary">
      <th cdk-header-cell *cdkHeaderCellDef >Salary</th>
      <td cdk-cell *cdkCellDef="let rowData">{{ rowData.salary }}</td>
      <td cdk-footer-cell *cdkFooterCellDef>{{ totalSalary() }}</td>
    </ng-container>
   
    <tr cdk-header-row *cdkHeaderRowDef="displayedColumns(); sticky: true"></tr>
    <tr cdk-row *cdkRowDef="let row; columns: displayedColumns()"></tr>
    <tr cdk-footer-row *cdkFooterRowDef="displayedColumns()"></tr>
  </table>
</div>
View Code

在 CdkColumnDef 和 CdkHeaderRowDef 指令添加 @Input sticky

注:只有 header 和 footer row 可以 sticky, body row 是不可以 sticky 的。

Styles

.table-container {
  margin-left: 512px;
  margin-top: 64px;

  max-width: 360px;
  max-height: 360px;

  overflow: auto;
  border: 1px solid var(--mat-table-row-item-outline-color, rgb(0 0 0 / 12%));

  .cdk-table {
    border-spacing: 0;

    .cdk-table-sticky {
      background-color: pink;
      color: black;
    }

    .cdk-cell,
    .cdk-header-cell,
    .cdk-footer-cell {
      padding: 16px;
    }
  }
}
View Code

组件

import { CdkTableModule } from '@angular/cdk/table';
import { ChangeDetectionStrategy, Component, computed, signal, type TrackByFunction } from '@angular/core';

interface Person {
  id: number;
  firstName: string;
  lastName: string;
  fullName: string;
  age: number;
  salary: number;
}

@Component({
  selector: 'app-test-cdk-table',
  standalone: true,
  imports: [CdkTableModule],
  templateUrl: './test-cdk-table.component.html',
  styleUrl: './test-cdk-table.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TestCdkTableComponent {
  readonly trackByIdFn: TrackByFunction<Person> = (_index, person) => person.id;

  readonly displayedColumns = signal(['id', 'firstName', 'lastName', 'fullName', 'age', 'salary']);

  readonly people = signal<Person[]>([
    { id: 1, firstName: 'Derrick', lastName: 'Yam', fullName: 'Derrick Yam', age: 12, salary: 100 },
    { id: 2, firstName: 'Anna', lastName: 'Smith', fullName: 'Anna Smith', age: 25, salary: 500 },
    { id: 3, firstName: 'John', lastName: 'Doe', fullName: 'John Doe', age: 30, salary: 700 },
    { id: 4, firstName: 'Jane', lastName: 'Brown', fullName: 'Jane Brown', age: 22, salary: 650 },
    { id: 5, firstName: 'Emily', lastName: 'Clark', fullName: 'Emily Clark', age: 27, salary: 800 },
    { id: 6, firstName: 'Michael', lastName: 'Johnson', fullName: 'Michael Johnson', age: 35, salary: 900 },
    { id: 7, firstName: 'Sarah', lastName: 'Williams', fullName: 'Sarah Williams', age: 28, salary: 750 },
    { id: 8, firstName: 'David', lastName: 'Miller', fullName: 'David Miller', age: 32, salary: 720 },
    { id: 9, firstName: 'Chris', lastName: 'Davis', fullName: 'Chris Davis', age: 24, salary: 680 },
    { id: 10, firstName: 'Laura', lastName: 'Martinez', fullName: 'Laura Martinez', age: 26, salary: 650 },
    { id: 11, firstName: 'Robert', lastName: 'Lee', fullName: 'Robert Lee', age: 29, salary: 780 },
  ]);

  readonly totalAge = computed(() => this.people().reduce((prev, curr) => prev + curr.age, 0));
  readonly totalSalary = computed(() => this.people().reduce((prev, curr) => prev + curr.salary, 0));
}
View Code

效果

firstName column 的 left 值是 CDK Table 负责计算出来的

算法是依据 id column 的 width,如果前面还有其它 column 就通通累加起来。

column 要 sticky right 也可以,用 @Input stickyEnd (end 是 logical property 的叫法,left to right 的情况下,end 就是指 right)

另外,header 和 footer row 我们只能决定要不要 sticky,方向是定了的。

header row 一定是 sticky top,footer row 一定是 sticky bottom

最终效果

 

封装 Column & Row Definition の CdkTextColumn

下面是一个很简单 standard 的 column definition

<ng-container cdkColumnDef="firstName">
  <th cdk-header-cell *cdkHeaderCellDef >First Name</th>
  <td cdk-cell *cdkCellDef="let rowData">{{ rowData.firstName }}</td>
</ng-container>

因为它太简单 standard 了,所以 CDK Table 对它进行了封装,我们可以用 CdkTextColumn 组件替换掉它。

<cdk-text-column name="firstName" headerText="First Name" ></cdk-text-column>

CdkTextColumn 组件其实也没做什么事,只是 wrap 了一层而已,源码在 text-column.ts

可以看到,里面就是 ng-container th td 还有 CdkColumnDef,CdkHeaderCell, CdkHeaderCellDef, CdkCell, CdkCellDef 这些指令。

dataAccessor 默认是 data[name] 取值。

当然,真实项目中,我们不可能拿这么简单的封装来用。但它确实是一个很好的封装 example,我们可以依据它的方式,封装出自己想要的 column definition。

CdkTable.addColumnDef

整个封装最重要的知识点是 CdkTable.addColumnDef 方法。

上面我们推测了 CDK Table 的底层实现,CdkTable 组件会 content query CdkColumnDef 指令,假如 CdkColumnDef 指令被 wrap 了一层 CdkTextColumn 组件,

那 CdkTable 组件还能 content query 到 CdkColumnDef 指令吗?

依据 Angular Query Content Projection 机制,肯定是 query 不到了,所以 CdkTable 组件提供了一个接口,让外部能用另外一种方式传入 CdkColumnDef 指令,这个接口就是 CdkTable.addColumnDef 方法。

我们来看看 CdkTextColumn 组件如何利用 CdkTable.addColumnDef 把定义在它 Template 上的 CdkColumnDef 传给 CdkTable。

首先 CdkTextColumn 需要 view query CdkColumnDef, CdkCellDef, CdkHeaderCellDef 指令,源码在 text-column.ts

奇怪,为什么需要 query 到 3 个?只 query CdkColumnDef 不够吗?它内部不是会 content query CdkCellDef 和 CdkHeaderCellDef 吗?

这题下面再解答,我们先继续往下看其它的。

inject CdkTable 组件

在 OnInit 阶段把 CdkColumnDef 指令传给 CdkTable

在调用 CdkTable.addColumnDef 之前,需要确保 CdkColumnDef 指令有 name, cell, headerCell。

name 本来是通过 @Input 传入的,header 和 cell 本来是通过 content query 获取的。

但是 wrap 一层 CdkTextColumn 之后,它的 lifecycle 就变了。

在 CdkTextColumn OnInit 阶段,CdkTextColumn 还没有执行 refreshView,意思是它的 Template

还没有执行 binding data,CdkColumnDef.name 来自 @Input 还没有发生,所以 name 是 undefined。

另外一点,CdkColumnDef content query 用的是 decorator 版本,而且没有声明 static: true

所以,虽然 CdkColumnDef 已经实例化了,但是 cell 和 headerCell 依然还是 undefined。

结论,在 CdkTextColumn OnInit 阶段,CdkColumnDef 的 name, cell, headerCell 都是 undefined。

undefined 当然不可以传入 CdkTable.addColumnDef,所以要先赋值。

这也是为什么要 view query CdkHeaderCellDef 和 CdkCellDef

然后再传进去 

总结,假如我们自己做 column definition 封装,记得要在调用 CdkTable.AddColumnDef 之前确保 CdkColumnDef name, cell, headercell 有值,

它的 @Input 和 content query 很可能会因为 lifecycle 而不起作用,所以最好是由我们来赋值。

最后,记得在 OnDestory 阶段调用 CdkTable.removeColumnDef 移除 CdkColumnDef。

封装 row definition

封装 row definition 比较罕见,Angular 也没有给出任何示范,不像上面封装 column definition 那样有 cdk-text-column 可以参考。

我们按照封装的 column definition 的方式试试看。

HeaderRow Template

<ng-template cdkHeaderRowDef>
  <tr cdk-header-row></tr>
</ng-template>

HeaderRow 组件

@Component({
  selector: 'app-header-row',
  standalone: true,
  imports: [CdkTableModule],
  templateUrl: './header-row.component.html',
  styleUrl: './header-row.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeaderRowComponent implements OnInit, OnDestroy {
  readonly displayedColumns = input.required<string[]>();

  private readonly headerRowDef = viewChild.required(CdkHeaderRowDef);
  private readonly table = inject(CdkTable);

  ngOnInit() {
    this.headerRowDef().columns = this.displayedColumns();
    this.table.addHeaderRowDef(this.headerRowDef());
  }

  ngOnDestroy() {
    this.table.removeHeaderRowDef(this.headerRowDef());
  }
}

调用 CdkTable.addHeaderRowDef 把 CdkHeaderRowDef 加进去。在加之前确保属性 columns 有值。

结果报错了🤔

原因是

由于我们改了结构,导致 CdkTable AfterContentInit 时,CdkHeaderRowDef 指令还没有 ngOnChanges,所以就报错了。

解决方法是手动调用 ngOnChanges

ngOnInit() {
  this.headerRowDef().columns = this.displayedColumns();
  const simpleChange = new SimpleChange([], this.headerRowDef().columns, true);
  this.headerRowDef().ngOnChanges({ columns: simpleChange });
  this.table.addHeaderRowDef(this.headerRowDef());
}

这样就不会报错了。

另外一点,displayedColumns 是 @Input,它是会改变的,在改变的时候我们还需要同步更新 CdkHeaderRowDef.columns。

private injector = inject(Injector);
ngOnInit() {
  const { displayedColumns, injector, headerRowDef, table } = this;
  const cdr = injector.get(ChangeDetectorRef);

  toObservable(displayedColumns, { injector })
    .pipe(skip(1), startWith(displayedColumns()))
    .subscribe(() => {
      headerRowDef().columns = displayedColumns();
      cdr.markForCheck(); // 必须 markForCheck,因为 Angular Material 还没有开始使用新潮流 Signal...😒
    });

  const simpleChange = new SimpleChange([], headerRowDef().columns, true);
  headerRowDef().ngOnChanges({ columns: simpleChange });
  table.addHeaderRowDef(headerRowDef());
}

大功告成 😎

Only addHeaderRowDef on first load

下图是一条 progress bar 

它是用 header row 做的。

假如我们像这样去封装和使用它

@if (fetchingData()) {
  <stg-header-progress-bar [displayedColumnCount]="displayedColumns().length" />
}

@if 会导致 HeaderProgressBar 不断的被 destroy 和 re-create,组件内就会一直调用 CdkTable.addHeaderRowDef 和 CdkTable.removeHeaderRowDef。

这样不好,因为每一次调用 CdkTable.addHeaderRowDef,CdkTable 都会先 remove (destroy) 所有的 header row。

相关源码在 table.ts

每当 addHeaderRowDef,它会记入 headerRowDefChanged。

接着在 render 的时候,如果发现 headerRowDefChanged 就会执行 forceRenderHeaderRows。

forceRenderHeaderRows 会清空 thead 里所有 header row

这通常不是我们期望的。

至于为什么 CdkTable 要 clear all 呢?不能单纯创建插入一条新 row?

我估计这是因为 CdkTable 的某些功能 (maybe sticky) 依赖 row cell 的创建顺序,所以在变化以后,它必须清空从头再按顺序创建过一遍。

总结:

addHeaderRowDef 会导致 CdkTable 清空所有的 header row 再 re-create。

@if (fetchingData()) {
  <stg-header-progress-bar [displayedColumnCount]="displayedColumns().length" />
}

所以像上面这样不太好,我们可以改成 display: none 去实现,总之不要反复调用 addHeaderRowDef 就是了,onInit 时调用一次就好。

addFooterRowDef & setNoDataRow

上面的例子是 CdkHeaderRowDef,其它像 CdkFooterRowDef,CdkNoDataRow 做法大同小异,下图是相关的接口,我就不一一给例子了。

封装 table

直接看官网的例子吧 -- Angular Material Table 官方教程,写的还可以。

 

Query and inject between row and cell

假设,有一个 table,user hover header row 时,每一个 header cell 要显示它们的 border right。

正常人的直觉是,监听 header row mouse enter 事件,然后 query 出 header row 里所有 header cell,然后 add class 显示 border right。

简单,直接。

但是!Angular 可不会顺着你,这么简单就不 Angular 了丫🙄

首先 header row 是无法 query 到 header cell 的,原因可以看这篇:Angular 的局限 の Query Elements

简单说就是,

header cell 不在 header row 的 Template 里,所以 view query 不到,

同时 header cell 也不是 header row 的 project content,所以 content query 不到。

view 和 content query 都不行,那就没有其它 query 的方法了。

anyway,上到下 query 不了,我们可以反过来,下到上,让 header cell inject header row,这样勉强也能让 header row "collect" all header cell。

但是!inject 也不行。

WHY 😡😡😡

我们看一个例子

MyCell 指令可以 inject 到 MyRow 指令吗?

答案是不可以。

header cell 是 ng-template,而 ng-template 的内容,默认 inject 查找是依据 ng-templat declare 的位置,而不是 insert 的位置 (不熟悉这块的朋友可以看这几篇:NodeInjector, Dymanic Component, ng-template)

也就是说,假如 CDK Table 在创建 header cell embededView 时没有特别给予 embededViewInjector,那 header cell 只能 inject 到 CdkTable 组件。

因为按照 declare 的位置,header row 和 cell 并不是 parent child 关系,CdkTable 才是 header cell 的 parent。

当然,CDK Table 在创建 cell embededView 确实是没有特别给予 embededViewInjector,所以 inject 查找路线就是按默认的找 ng-template declare 的位置。

那 header cell 自然就找不到 header row 了。

针对这个问题有人在 2019 年提过 Github Issue –  Inject the RowContext from the CDK Table to a component inside a CdkCell

无奈被当时傲慢的 Angular Team (旧的那一批) 无情的拒接了。

其实只要在 create cell embededView 时传入 _viewContainer.parentInjector 就可以了,不清楚他们为什么当时会拒绝🤔。

How to solve?

query 不行,inject 也不行,难道只能用杀手锏 -- Direct DOM query 了吗?

那倒未必。其实 CDK Table 本身就用着一个奇葩招数,我展示给大家看看。

Template

<table cdk-table [dataSource]="people()">
  <ng-container cdkColumnDef="name">
     <th cdk-header-cell *cdkHeaderCellDef >Name</th>
     <td cdk-cell *cdkCellDef="let person" appMyCell>{{ person.name }}</td>
  </ng-container>

  <ng-container cdkColumnDef="age">
    <th cdk-header-cell *cdkHeaderCellDef >Age</th>
    <td cdk-cell *cdkCellDef="let person" appMyCell>{{ person.age }}</td>
  </ng-container>

  <tr cdk-header-row *cdkHeaderRowDef="displayedColumns()"></tr>
  <tr cdk-row *cdkRowDef="let row; columns: displayedColumns()" appMyRow></tr> 
</table>

需求是 MyCell 指令要 inject MyRow 指令

MyRow 指令

export class MyRowDirective {
  static mostRecentRow: MyRowDirective | null = null;
  constructor() { 
    MyRowDirective.mostRecentRow = this;
  }
}

它有一个静态属性 mostRecentRow,当 MyRow 指令被实例化以后,赋值 MyRow 实例到这个 mostRecentRow。

顾名思义,most recent row 意思是最 "近期" 的 row。也就是最近一次被实例化的 MyRow 指令实例。

接着添加 cell register 相关代码

private cells: MyCellDirective[] = [];
registerCell (cell: MyCellDirective) {
  this.cells.push(cell);
}

然后是 MyCell 指令

export class MyCellDirective {
  constructor() { 
    MyRowDirective.mostRecentRow!.registerCell(this);
  }
}

MyCell 虽然无法 inject 到 MyRow,但是它可以直接访问 MyRow 静态属性 mostRecentRow 拿到最近一次的 MyRow 实例。

这样就成功把 MyCell 和 MyRow 关联起来了。

你可以会想,这个做法靠谱吗?谁能保证实例化的顺序是 row > all cell > row > cell all?

不用担心,因为 CDK Table 里面就用着这个招数。

相关源码在 row.ts

看到了吗?静态属性 mostRecentCellOutlet。

下面我以 header 为例,相关源码在 table.ts

在渲染的时候,如果 header row definition (CdkHeaderRowDef) 改变了就会调用 _forceRenderHeaderRows 方法。

如果 header columns (CdkHeaderRowDef.columns) 改变了也会调用 _forceRenderHeaderRows 方法。

_forceRenderHeaderRows 会删除所以 header row 和 cell,然后重新创建它们。

所以创建的顺序是固定的,一定是创建一条 row,然后创建这条 row 的所有 cell,然后再去下一条 row。

也因为这样,most recent 概念才可以 work。

注:

这招也不是万能的哦,它只能保证 cell inject row 正确,row.cells 的顺序正确,但是 table.rows 的顺序就无法保证了。

因为当我们 swap row 时,row 不会被 destroy,它在 DOM 的位置换了,但是在 table.rows 的位置却没有换。

如果我们想确保 table.rows 的顺序,那只能 DOM Manipulation 了。

 

CdkCellDef  ng-template Context Type Guard

对类型敏感的朋友应该已经注意到了

上面例子中的 person 类型是 any。

我们在 ng-template 文章中学过 Template Context Type Guard,勉强可以用在这里。

使用指令 template-context-type-guard.directive.ts

import { Directive, input } from '@angular/core';

@Directive({
  selector: 'ng-template[templateContextType]',
  standalone: true,
})
export class TemplateContextTypeGuardDirective<T> {
  readonly type = input.required<T>({ alias: 'templateContextType' });

  static ngTemplateContextGuard<T>(_dir: TemplateContextTypeGuardDirective<T>, ctx: unknown): ctx is T {
    return true;
  }
}

App 组件添加属性

declare templateContextType: { $implicit: Person };

App Template

<!-- before -->
<!-- <td cdk-cell *cdkCellDef="let person">{{ person.id }}</td> -->

<!-- after -->
<ng-template cdkCellDef let-person [templateContextType]="templateContextType">
  <td cdk-cell>{{ person.id }}</td>
</ng-template>

不可以使用微语法了,需要改成 ng-template 的写法。

效果

显然通用的 TemplateContextTypeGuardDirective 不太适合这里,我们能否特地做一个 Guard 指令专门 for 支持微语法的 CdkCellDef ?

可以,参考:Type-safe MatCellDef

创建 cdk-cell-def-type-guard.directive.ts

import { Directive, input } from '@angular/core';
import { CdkCellDef } from '@angular/cdk/table';

@Directive({
  // 1. 使用 CdkCellDef 的 selector
  selector: '[cdkCellDef]',
  standalone: true,
  // 2. 这里需要偷龙转风一下
  providers: [{ provide: CdkCellDef, useExisting: CdkCellDefTypeGuardDirective }],
})
// 3. 继承 CdkCellDef
export class CdkCellDefTypeGuardDirective<T> extends CdkCellDef {
  readonly type = input.required<T>({ alias: 'cdkCellDefType' });

  static ngTemplateContextGuard<T>(_dir: CdkCellDefTypeGuardDirective<T>, ctx: unknown): ctx is T {
    return true;
  }
}

效果

特地传入一个 templateContextType 有点麻烦,其实只要能给到它类型就可以了,让我们再修一修

cdk-cell-def-type-guard.directive.ts

export class CdkCellDefTypeGuardDirective<T> extends CdkCellDef {
  // 不直接传入类型
  // readonly type = input.required<T>({ alias: 'cdkCellDefType' });

  // 改成传入 CdkTable 让它 infer 出类型也可以
  readonly table = input.required<CdkTable<T>>({ alias: 'cdkCellDefTable' });

  static ngTemplateContextGuard<T>(_dir: CdkCellDefTypeGuardDirective<T>, ctx: unknown): ctx is { $implicit: T } {
    return true;
  }
}

效果

虽然每一个 CdkCellDef 都需要写上 table: table 有点可爱😅,但勉强还算能用。希望 Angular Team 以后会重视这个 DX 吧。

相关 Github Issue:Define generic of ng-templatetypescript/strong typing of cell templates

 

Multiple Header and Colspan

需求

2 个 header row 并且第一个 header row 里的 cell 还带有 colspan。

组件

import { CdkTableModule } from '@angular/cdk/table';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { PricePipe } from './price.pipe';

interface Price {
  unit: number;
  one: number | null;
  three: number | null;
  four: number | null;
  six: number | null;
}

@Component({
  selector: 'app-test-price-table',
  standalone: true,
  imports: [CdkTableModule, PricePipe],
  templateUrl: './test-price-table.component.html',
  styleUrl: './test-price-table.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TestPriceTableComponent {
  readonly prices = signal<Price[]>([
    { unit: 1, one: 40, three: null, four: null, six: null },
    { unit: 2, one: 55, three: 160, four: 200, six: 270 },
    { unit: 3, one: 70, three: 180, four: 220, six: 300 },
    { unit: 4, one: 85, three: 225, four: 280, six: 390 },
    { unit: 5, one: 100, three: 270, four: 360, six: 480 },
    { unit: 6, one: 115, three: 315, four: 400, six: 570 },
  ]);

  readonly displayedColumns = signal<Array<keyof Price>>(['unit', 'one', 'three', 'four', 'six']);
}
View Code

Template

<table cdk-table [dataSource]="prices()">
  <ng-container cdkColumnDef="unit">
    <th *cdkHeaderCellDef>Nos Units</th>
    <td *cdkCellDef="let price">{{ price.unit }}</td>
  </ng-container>

  <ng-container cdkColumnDef="one">
    <th *cdkHeaderCellDef>AD HOC</th>
    <td *cdkCellDef="let price">{{ price.one | price }}</td>
  </ng-container>

  <ng-container cdkColumnDef="three">
    <th *cdkHeaderCellDef>3Xs Yearly</th>
    <td *cdkCellDef="let price">{{ price.three | price }}</td>
  </ng-container>

  <ng-container cdkColumnDef="four">
    <th *cdkHeaderCellDef>4Xs Yearly</th>
    <td *cdkCellDef="let price">{{ price.four | price }}</td>
  </ng-container>

  <ng-container cdkColumnDef="six">
    <th ckk-header-cell *cdkHeaderCellDef>6Xs Yearly</th>
    <td cdk-cell *cdkCellDef="let price">{{ price.six | price }}</td>
  </ng-container>

  <tr cdk-header-row *cdkHeaderRowDef="displayedColumns()"></tr>
  <tr cdk-row *cdkRowDef="let price; columns: displayedColumns()"></tr>
</table>
View Code

目前的效果

我们再加上多一条 header row 和它的 header cell,还要 colspan (提醒:colspan 是原生 table 的功能,如果我们用 flex 版本的 table 就不支持 colspan 了,要自己另外实现)

<ng-container cdkColumnDef="nonContract">
  <th ckk-header-cell *cdkHeaderCellDef colspan="2">Non-Contract</th>
</ng-container>

<ng-container cdkColumnDef="contractBasis">
  <th ckk-header-cell *cdkHeaderCellDef colspan="3">Contract Basis</th>
</ng-container>

<tr cdk-header-row *cdkHeaderRowDef="['nonContract', 'contractBasis']"></tr>

column definition 的顺序无所谓,row definition 的顺序是有讲究的

效果

CDK Table 的玩法

从这里也可以看出 CDK Table 的玩法。

首先是一堆的 column definition (ng-template),至于这些 column ng-template 会不会被 create 则是看 row definition 的 columns。

接着是一堆 row definition (ng-template),body row 会不会被 create 取决于 dataSoruce 和 when 条件,

header 和 footer row 则只要有 definition 就会被 create,如果我们要 dynamic 那就 wrap 一层 @if 或者 @for,像这样

@if(false) {
  <tr cdk-header-row *cdkHeaderRowDef="['nonContract', 'contractBasis']"></tr>
}

 

CDK Table with Virtual Scrolling?

Virtual Scrolling 之前我们介绍过,如果想让 CDK Table 结合 Virtual Scrolling 可以吗?

直觉当然是可以的,毕竟都是 Angular Material 维护的 features,应该很自然就可以结合使用嘛。

但是!你这样想就太低估 Angular Material 团队了,他们的表现有时候会差劲到让人匪夷所思。

打从一开始,Virtual Scrolling 就不是为了辅助其它组件而设计的,所以它完全不和其它组件兼容。

CDK Table 虽然支持 DataSoruce,但它只是表面支持而已。

在 connect 的时候,它会把 CdkTable 实例传进去

CdkTable 实例满足 CollectionViewer 接口 (有 viewChange 属性)

但这个 viewChange 根本还没有实现

这个 TODO 从 v5.0 到目前 v18.0 一直都是 TODO

相信还会继续 TODO 好多年呢🙄

所以,如果你真的有需要结合 CDK Table 和 Virtual Scrolling,只有三条路:

  1. 傻傻的等这个 feature request

  2. 尝试 ng-table-virtual-scroll,虽然肯定很多坑

  3. 自己实现,原理我都教你了嘛,不试试身手吗😜

 

总结

本篇简单的介绍了 Angular Material CDK Table 的基本用法。

本来还想逛一逛源码的,但感觉 Angular Material 的源码普遍质量都没有那么好,所以还是不拿出讲解了。

 

 

目录

上一篇 Angular Material 18+ 高级教程 – Material Tooltip

下一篇 Angular Material 18+ 高级教程 – CDK Drag and Drop

想查看目录,请移步 Angular 18+ 高级教程 – 目录

喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻

 

posted @ 2024-05-22 17:55  兴杰  阅读(113)  评论(0编辑  收藏  举报