Angular Material 18+ 高级教程 – CDK Scrolling

Angular CDK 的意义

经过之前两篇文章 CDK Portal 和 CDK Layout の Breakpoints,我相信大家已经悟到了 CDK 的意义。

CDK 有 3 个方向:

  1. 包装 BOM / DOM 上层接口 (e.g. CDK Layout)

    这个方向主要是让我们不直接操作/依赖 BOM 和 DOM。

    还有把接口包装成 RxJS / Promise 之类的,方便我们使用。

  2. 包装 Angular 上层接口 (e.g. CDK Portal)

    这个方向主要是优化 Angular 上层接口调用,或者是扩展弥补一些 Angular 缺失的功能。

  3. 辅助制作 UI Component

    Angular Material Team 是在用 Angular Way 开发 Material Design UI Component 过程中提炼出 CDK 的。

    UI Component 通常需要 HTML,CSS,JS 三个地方实现,而 CDK 主要是抽象封装了 JS 的部分,CSS 几乎没有,HTML 有一点点 (大概是这个比例)。

    这个方向主要是让我们可以做以下几件事:

    a. 制作 Angular Material Team 多年都没有完成的 Material Design UI Component (e.g. Time pickers)

    b. 制作变种的 Material Design UI Component,有些 UI Component 没有在 Material Design 规范中,这些 UI Component Angular Material Team 是坚决不肯做的,

        但其实 Google Products (Gmail, Google Ads 等) 都有这些 UI Component 的具体实现,这时我们就可以依照它们自己制作出来。

    c. 制作不同风格的 UI Component。如果 UI Component 只是视觉上不同,交互逻辑差不多,那 CDK 确实非常合适。

        但要记得,CDK 服务的对象始终只有 Angular Material,就好比 Angular 服务的对象始终只有 Google Products 一样,它们不会为了你或者社区去做任何对 Google 没有利益的事。

好,理解了 CDK 的方向,我们在学习和使用它时就能得心应手了。

 

CDK Scrolling 简单介绍

在做 UI Component 时,我们经常需要监听 scroll event,获取 element scrollTop,操作 element scrollTo 等等。

CDK Scrolling 主要就是对这些 DOM Manipulation 做了封装。

另外,CDK Scrolling 还有一个强大的功能 -- Virtual Scrolling,不熟悉 Virtual Scrolling 的可以看这篇:CSS & JS Effect – Virtual Scrolling

虽然目前 Virtual Scrolling 还不完善

但也勉强可以用于一些小场景。

 

CdkScrollable 指令

需求

App Template

<div class="scrollable">
  <div class="spacer"></div>
</div>

App Styles

.scrollable {
  margin-inline: auto;
  margin-top: 128px;

  overflow-y: auto;
  height: 200px;
  width: 100px;
  border: 1px solid red;

  .spacer {
    width: 100%;
    height: 2000px;
  }
}

效果

一个 div,里面有另一个 div 把它撑大,于是它可以 scroll。

需求是监听它的 scroll event。

Without CDK

不使用 CDK 的情况下,我们会这样实现

在 scrollable element 上添加 Template Binding Syntax

<div class="scrollable" (scroll)="handleScroll($event)">
  <div class="spacer"></div>
</div>

接着在 App 组件写上 handleScroll 方法

export class AppComponent {
  handleScroll(event: Event) {
    console.log('scrolled');
  }
}

效果

代码虽然简单,但这个方式往往不足够应付 UI Component。
UI Component 一般上交互会比较复杂,一个事件往往会牵连到很多事情,所以更好的方式是以 RxJS 的形式去监听 (RxJS Stream 比较容易拿来组合,或者变化等等,简单说就是比较灵活使用)。

with CDK

在 App 组件 import ScrollingModule,因为我们需要用到相关的指令。

把 CdkScrollable 指令 apply 到 scrollable element

<div class="scrollable" cdkScrollable>
  <div class="spacer"></div>
</div>

通过 viewChild 获取 CdkScrollable 指令,然后使用 elementScrolled 方法监听 scroll event。

export class AppComponent implements OnInit {
  // 1. 获取 CdkScrollable 指令
  scrollable = viewChild.required(CdkScrollable);

  ngOnInit() {
    // 2. 使用 elementScrolled 监听 scroll event
    //    它会返回 RxJS Observable
    this.scrollable()
      .elementScrolled()
      .subscribe(event => {
        console.log('scrolled', event);
      });
  }
}

它返回的是 RxJS Stream。

源码在 scrollable.ts

没什么特别的,只是用 RxJS 监听了 element scroll event 而已。

注:ngZone 是 Change Detection 的知识,我们可以忽视它,因为 Angular v17.1.0 后 ngZone 基本已经废弃了。

对比使用 CDK 和不使用 CDK 两个版本,我们可以悟出一个道理 -- 开发 UI Component 和开发业务项目是不同的。

开发业务我们会远离 DOM,开发 UI Component 我们会贴近 DOM。

get scrollTop

监听到了 scroll event 下一步通常是获取 scrollTop 值。

CdkScrollable 指令也有针对这个的接口。

const scrollTop = this.scrollable().measureScrollOffset('top');

没什么特别的,底层就是拿 element.scrollTop 属性而已

scrollTo

scrollTo 就是对 DOM element.scrollTo 的封装而已。

this.scrollable().scrollTo({ top: 100, behavior: 'smooth' });
this.scrollable().scrollTo({ bottom: 100, behavior: 'smooth' });

接口比原生 DOM 好多了,甚至支持 scroll to bottom

相关源码

 

ScrollDispatcher

CdkScrollable 指令可以用来监听 element scroll event,但它监听不到 document scroll event,因为指令无法 apply 超出 App 组件范围。

这时,我们需要使用 ScrollDispatcher。

监听 document scroll event

ScrollDispatcher 是一个 Root Level Provider。

export class AppComponent {
  constructor() {
    // 1. inject ScrollDispatcher
    const scrollDispatcher = inject(ScrollDispatcher);

    // 2. 监听 scroll event
    scrollDispatcher.scrolled().subscribe(() => {
      console.log('document scrolled');
    });
  }
}

效果

源码在 scroll-dispatcher.ts

not only document scroll event but all CdkScrollable

ScrollDispatcher 不仅仅监听 document scroll event,它还监听了所有 CdkScrollable 指令的 scroll event。

App Template 有一个 CdkScrollable 指令

<div class="scrollable" cdkScrollable>
  <div class="spacer"></div>
</div>

然后 App 组件监听 ScrollDispatcher 

export class AppComponent {
  constructor() {
    const scrollDispatcher = inject(ScrollDispatcher);
    scrollDispatcher.scrolled().subscribe(scrollable => {
      console.log('scroll dispatcher', scrollable);
    });
  }
}

如果是 document scrolled 那 callback 函数的参数 scrollable 会是 undefined。

如果是 CdkScrollable 指令 scrolled 那 callback 函数的参数 scrollable 会是 CdkScrollable 指令实例。

效果

提醒:ScrollDispatcher 会监听全世界所有的 CdkScrollable 指令哦

每一个 CdkScrollable 在 OnInit 时都会把自己注册到 ScrollDispatcher

所谓的注册就是监听 CdkScrollable 的 scroll event 然后 ScrollDispatcher 再转发。

only listen ancestor CdkScrollable

通过 ScrollDispatcher 监听 CdkScrollable scroll event 有时候是会比较混乱的,毕竟它是 Root Level Provider,意味着着全世界的 CdkScrollable 指令都会往它身上注册。

也因为这样,ScrollDispatcher 提供了一些过滤监听的方式,比如说 ancestorScrolled 方法。

getAncestorScrollContainers 方法

_scrollableContainsElement 方法

ancestorScrolled 的监听时机

ancestorScrolled 的监听时机是很讲究的,当我们调用 ancestorScrolled 时,请一定要确保 CdkScrollable 已经被注册到 ScrollDispatcher 里。

上面我们有提到,CdkScrollable 指令是在 OnInit 阶段被注册到 ScrollDispatcher 里的,不是 constructor 阶段哦。

总结

  1. 想监听指定某个 element 的 scroll event

    用 CdkScrollable 指令。

  2. 想监听 document scroll event

    用 ScrollDispatcher 监听,callback 函数的 scrollable 参数是 undefined 就表示是 document scroll。

  3. 想监听 parent / ancestor scroll event

    用 ScrollDispatcher.ancestorScrolled 方法。

题外话:Dependency injection based on ancestor element

我们知道 Angular 的 DI 查找的是 NodeInjector Tree 而不是 DOM Tree,不熟悉的朋友可以看这篇 Dependency Injection & NodeInjector

这是一个局限,有时候使用起来很不方便。

上面这个 ancestorScrolled 方法是一个很好的案例。

1 个 Root Service

1 个指令

1 个 element 

指令可以 inject 其它 Service,然后把 element 和相关的 Service 一起记入到 Root Service。

要使用的人就可以通过 Root Service 依据 element 做查找,最后获得相关 Service。

 

ViewportRuler

ViewportRuler 其实和 Scrolling 没有什么关系,不知为什么 Angular Material 会把它纳入到 Scrolling Module🤔

ViewportRuler 也是一个 Root Level Provider。它可以监听 viewport resize event 和获取 viewport dimension。

export class AppComponent {
  constructor() {
    // 1. inject ViewportRuler
    const viewportRuler = inject(ViewportRuler);

    // 2. 监听 resize 
    viewportRuler.change().subscribe(event => {

      // 3. 获取 viewport dimension
      const { width, height } = viewportRuler.getViewportSize();
      console.log('dimension', [width, height]);
    });
  }
}

viewport 指的是 window。

resize 指的是 window resize event 和 orientationchange event

dimension 指的是 window.innerWidth 和 innerHeight

另外,change 方法返回的 Observable 默认会有一个 auditTime 20ms

 

Virtual Scrolling

不熟悉 Virtual Scrolling 的可以先看这篇:CSS & JS Effect – Virtual Scrolling

Example

我用回文章里最后一个完整的例子

App 组件

@Component({
  selector: 'app-root',
  standalone: true,
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ScrollingModule],
})
export class AppComponent {
  itemHeight = signal(40);
  itemTexts = signal(new Array(500).fill(null).map((_, index) => `item${index + 1}`));
}

App Template

<div class="viewport" cdkVirtualScrollingElement>
  <div class="header">Header</div>

  <cdk-virtual-scroll-viewport [itemSize]="itemHeight()" class="item-list">
    <ng-template let-item cdkVirtualFor [cdkVirtualForOf]="itemTexts()">
       <div class="item">{{ item }}</div>
    </ng-template>
  </cdk-virtual-scroll-viewport>
  
  <div class="footer">Footer</div>
</div>

几个知识点:

  1. cdkVirtualScrollingElement 指令

    如果 scrollable 和 cdk-virtual-scroll-viewport 不是同一个 element,那就需要用到 CdkVirtualScrollingElement 指令。

  2. cdkVirtualForOf 指令

    它和 ngForOf 指令的接口完全一模一样,把它当成 ngForOf 指令使用就可以了。

  3. [itemSize] 就是 itemHeight,一定要提供。

App Styles

.viewport {
  margin-top: 128px;
  margin-inline: auto;
  width: 256px;
  height: 256px;
  border: 2px solid red;

  .header,
  .footer {
    height: 80px;
    background-color: #988beb;
    display: flex;
    justify-content: center;
    align-items: center;
  }

  .item-list .item {
    height: 40px;
    display: flex;
    justify-content: center;
    align-items: center;

    &:nth-child(odd) {
      background-color: pink;
    }

    &:nth-child(even) {
      background-color: lightblue;
    }
  }
}

效果

Cache Concept

默认情况下 CDK Virtual Scrolling 会缓存 20 个创建过的 Item ViewRef。

相关源码在 recycle-view-repeater-strategy.ts

要缓存多少,我们可以自己配置

<cdk-virtual-scroll-viewport class="viewport" [itemSize]="itemHeight()">
  <dl>

    <ng-template 
      let-keyValue 
      cdkVirtualFor 
      [cdkVirtualForOf]="keyValues()" 
    >
      <dt>{{ keyValue.key }}</dt>
      <dd>{{ keyValue.value }}</dd>
    </ng-template>
    
  </dl>
</cdk-virtual-scroll-viewport>

注意看它的结构,cdk-virtual-scroll-viewport 里面先是一个 dl,dl 里面才是 <ng-template>。

其实 cdk-virtual-scroll-viewport 并不负责创建 item

它只负责 item-wrapper 和 spacer 而已,item 是由 cdkVirtualForOf 指令创建好后 transclude 进来的。

DataSource and lazy loading

虽然 Virtual Scrolling 解决了渲染时的性能问题,但是 cdkVirtualForOf 要求我们预先传入一个完整的 item list,

假如我有 10 万条 items 而且资料是从后端取来的,那我就需要一次性下载 10 万条资料,这样也会导致 http 请求性能问题。

为此,Angular Material 设计了一个叫 DataSource 的概念,它允许我们分量提供 items。

注:DataSoruce 概念不仅仅用于 Virtual Scrolling,其它地方比如 Table 和 Tree 也可以使用 DataSoruce (以后会教)

DataSource 是一个抽象 class,它长这样

我们需要 extends 它,并且 override connect 和 disconnect 方法。

顾名思义,connect 会在 CdkVirtualForOf 指令 OnInit 时被调用,disconnect 则是在 OnDestroy 时被调用。

class ItemTextsDataSource

class ItemTextsDataSource extends DataSource<string | null> {
  private subscription = new Subscription();
  private itemsBS: BehaviorSubject<readonly (string | null)[]>;

  constructor(itemCount: number) {
    super();
    // 1. 初始化一个空 item list
    //    虽然我们不需要立刻 http request 10 万条 item 的具体信息,但我们依然需要知道 item 总数是多少。
    this.itemsBS = new BehaviorSubject<readonly (string | null)[]>(new Array(itemCount).fill(null));
  }

  override connect(collectionViewer: CollectionViewer): Observable<readonly (string | null)[]> {
    this.subscription.add(
      // 3. connect 时,我们会等到参数 collectionViewer
      //    我们需要监听它,每当 user scroll 它会触发,并且告诉我们当前 user 需要看到哪些 item
      collectionViewer.viewChange.subscribe(range => {
        // 4. 比如 user scroll 到中间,
        //    需要显示第 50001 到 50021 的 items
        //    start 和 end 指的是 index
        const { start, end } = range;

        // 5. 这里可以发 http request 去拿 item 的具体信息
        //    然后添加进 itemsBS
        window.setTimeout(() => {
          const newItems = new Array(end - start).fill(null).map((_, index) => `Item ${start + index + 1}`);
          const items = this.itemsBS.value;
          this.itemsBS.next([...items.slice(0, start), ...newItems, ...items.slice(end, items.length)]);
        }, 1000);
      }),
    );

    // 2. 返回 items$ Observable
    return this.itemsBS.asObservable();
  }

  override disconnect() {
    // 7. connect 的时候,我们监听了 CollectionViewer.viewChange,这里需要退订
    //    题外话,即使不退订也不会造成内存泄漏的,因为 CdkVirtualForOf 指令 OnDestroy 的时候会 complete 掉 CollectionViewer.viewChange。
    //    但是呢,我们最好还是养成退订的好习惯,毕竟 complete 和 unsubscribe 在一些特定情况下效果是不一样。
    this.subscription.unsubscribe();
  }
}

接着换上 ItemTextsDataSource

export class AppComponent {
  readonly itemHeight = signal(40);
  // 1. 把 itemTexts 换成 itemDataSource
  // readonly itemTexts = signal(new Array(100000).fill(null).map((_, index) => `item${index + 1}`));
  readonly itemTextsDataSource = new ItemTextsDataSource(100000);
}

App Template

item 是 null 就显示 loading...

效果

官网给了一个很不错的 Demo,大家也可以看一看。

Connect DataSource multiple times

DataSoruce 的接口设计是挺奇葩的

它让我们产生几个疑问:

  1. 每一次调用 connect 返回的 Observable 必须是同一个吗?

  2. unsubscribe connect 返回的 Observable 和 disconnect 是一样的效果吗?

我们看看 Material Table 如何实现 DataSource

  1. 假如有 multiple connect,它始终返回同一个 Observable (_renderData 是 BehaviorSubject),内部 subscription 也只维持一个。

  2. disconnect 会 unsubscribe 内部的 subscription,与此同时 _renderData 不会在 next 了,但它没用 complete,所以它是可以 re-connect 的。

  3. unsubscribe connect 返回的 Observable 和 disconnect 效果是不一样的。

结论:MatTableDataSource 大家可以 share 着用,只要其中一个人需要负责 disconnect 就可以了。

我没提到的功能:

有些功能很冷门,而且很简单,我就不一一讲解了:

  1. minBufferPx & maxBufferPx

    items 只需要显示足够填满 viewport 就可以了,但是如果我们希望它提早出现则可以 set buffer。

    这类似 IntersectionObserverrootMargin 设置。

  2. Viewport Orientation

    它还支持 horizontal👏 

  3. Append Only Mode

    创建 append 后的 item 不会再被清除。

    这意味着 Node 会越来越多,可能会引起性能问题,要留意哦。

  4. Scroll Window

    通常 scrollable 是 cdk-virtual-scroll-viewport,

    如果遇到有 header 的场景,那 scrollable 就不是 cdk-virtual-scroll-viewport 了

    <div class="viewport" cdkVirtualScrollingElement>

    这时需要需要在 scrollable element 加上 CdkVirtualScrollingElement 指令。

    如果 scrollable 是 window / document,那就需要用 CdkVirtualScrollableWindow 指令

    <cdk-virtual-scroll-viewport scrollWindow itemSize="50">

 

 

目录

上一篇 Angular Material 18+ 高级教程 – CDK Layout の Breakpoints

下一篇 Angular Material 18+ 高级教程 – Material Icon

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

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

 

posted @ 2024-03-02 10:13  兴杰  阅读(395)  评论(0编辑  收藏  举报