CHARLIE SAYS

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

CDK 22+ 教程 43:Scrolling 虚拟滚动

一万条日志、五千个联系人,普通 @for 渲染会把所有 DOM 一次性铺满页面——浏览器内存暴涨、滚动掉帧、首屏被拖死。虚拟滚动的答案是:只渲染视口内(及附近)的那一小段。CDK 的 Scrolling 模块提供了生产级的实现,本文讲透它的原理与用法。

问题:DOM 数量是性能的头号敌人

每个 DOM 节点都有创建成本、内存占用与排版开销。粗略经验:

列表规模DOM 节点量级典型表现
< 200 项数百@for 足够,无感
200 - 2000 项数千首屏变慢,需要 track 优化
> 2000 项万级必须虚拟滚动或分页

@fortrack 只解决”重排时复用 DOM”,不解决”总量太多”。削减 DOM 总量才是虚拟滚动的本职。

原理:渲染窗口与缓冲区

cdk-virtual-scroll-viewport 的思路是把列表想象成一堵固定高度的”墙”,实际 DOM 只占墙上的一扇窗:

graph TB
    subgraph LIST["总高度 = itemSize × itemCount(由撑高元素占位)"]
        direction TB
        B1["缓冲区 minBufferPx(视口上方待命)"]
        V["视口(可见区域,如 600px 高)"]
        B2["缓冲区 maxBufferPx(视口下方待命)"]
    end
    S[滚动事件] --> C["计算新的渲染窗口范围"]
    C --> R1["进入窗口的项:实例化 DOM"]
    C --> R2["离开窗口的项:销毁/回收 DOM"]

三个关键设计:

  1. 总高度占位:viewport 内部用一个撑高元素(spacer)撑起 itemSize × itemCount 的总高度,让滚动条长度真实可信
  2. 渲染窗口:根据 scrollTopitemSize 算出可见区间 [start, end],加上缓冲区向外扩展几屏
  3. 变换定位:渲染出的行不是普通流式布局,而是绝对定位/transform 摆到 start × itemSize 的偏移上——滚动只是换一批”谁该被渲染”

滚动的每一步都在做”换窗”而非”移动内容”,DOM 总数始终约为 视口高度 + 缓冲区 除以行高,与总数据量无关。

基本用法

import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  selector: 'app-contact-list',
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="48" class="viewport">
      <div class="row" *cdkVirtualFor="let contact of filtered(); trackBy: trackById">
        {{ contact.name }} — {{ contact.phone }}
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: `
    .viewport { height: 600px; }
    .row { height: 48px; display: flex; align-items: center; padding: 0 16px; }
  `,
})
export class ContactListComponent {
  readonly contacts = signal<Contact[]>(/* 一万条 */);
  readonly keyword = signal('');

  readonly filtered = computed(() => {
    const kw = this.keyword().trim().toLowerCase();
    if (!kw) return this.contacts();
    return this.contacts().filter((c) => c.name.toLowerCase().includes(kw));
  });

  trackById(index: number, contact: Contact): number {
    return contact.id;
  }
}

注意两个硬性要求:

  • viewport 必须有确定高度(固定值或 flex 布局分配),否则渲染窗口无法计算
  • itemSize 必须与实际行高一致,不一致会导致滚动跳位与计算错乱——这就是”固定尺寸”策略的代价

*cdkVirtualFor

虚拟滚动有自己的结构指令,职责与 @for 类似但要向 viewport 汇报自己:

<cdk-virtual-scroll-viewport itemSize="48">
  <div class="row" *cdkVirtualFor="let item of items; trackBy: trackById; templateCacheSize: 0">
    {{ item.name }}
  </div>
</cdk-virtual-scroll-viewport>
特性@for*cdkVirtualFor
渲染范围全量仅渲染窗口内的项
track 机制track item.idtrackBy 函数(返回 id/索引)
视图回收销毁默认缓存复用(templateCacheSize 控制)
依赖上下文组件信号即可需在 viewport 内使用

trackBy 返回稳定 id 时,滚动来回切换窗口会优先复用缓存的视图、只更新绑定,性能最佳。templateCacheSize: 0 则完全禁用缓存(内存极度敏感时用)。另外 *cdkVirtualFor 支持 let i = index 与懒加载钩子 itemSize 变化后的重新测量。

v22 中 @for*cdkVirtualFor 各司其职:数据量小用 @for(信号驱动、默认 track),长列表用 *cdkVirtualFor。两者语法不能混在一个节点上。

itemSize 与缓冲区调优

minBufferPx / maxBufferPx 控制渲染窗口在视口外多渲染多少内容:

  • minBufferPx(默认 100):缓冲区低于该值时触发补渲染
  • maxBufferPx(默认 200):补渲染一次补到该值为止
<cdk-virtual-scroll-viewport
  itemSize="48"
  minBufferPx="400"
  maxBufferPx="800">

调优的两难:缓冲越大越不容易露出空白(快速滚动时白屏),但 DOM 越多内存越高。经验起点:普通文本列表用默认值;图片流或行高较大时调到 1-2 屏;配合 Chrome DevTools Performance 面板观察滚动帧率再收敛。横向列表加 orientation="horizontal",行高换成列宽,原理相同。

FixedSizeVirtualScrollStrategy

viewport 默认使用的就是 FixedSizeVirtualScrollStrategy——由 itemSize 输入装配。策略本身可以显式注入定制:

import { FixedSizeVirtualScrollStrategy, VIRTUAL_SCROLL_STRATEGY } from '@angular/cdk/scrolling';

providers: [
  {
    provide: VIRTUAL_SCROLL_STRATEGY,
    useFactory: () => new FixedSizeVirtualScrollStrategy(48, 400, 800),
  },
],

VIRTUAL_SCROLL_STRATEGY 令牌允许整体替换滚动策略——这正是”动态高度”的官方出路:实现自定义 VirtualScrollStrategy,在 onItemRendered/测量回调中按数据累积真实高度。成本不低,所以先考虑两条捷径:

方案适用
固定行高 + CSS 截断(-webkit-line-clamp内容可接受定高
分组定高(每组一个 itemSize)折叠分组列表
自定义 VirtualScrollStrategy / 瀑布流组件真正不定高,投入最大

viewport 实例还提供编程 API:scrollToIndex(index, behavior) 跳转、getRenderedRange() 读取当前窗口(可用于无限加载触发)、measureScrollOffset/elementScrolled 做自定义联动。用 viewChild(CdkVirtualScrollViewport) 拿到实例。

实战:无限加载 + 虚拟滚动

长列表常与”滚到底加载更多”组合。用渲染窗口判断接近尾部:

private readonly viewport = viewChild.required(CdkVirtualScrollViewport);

constructor() {
  effect(() => {
    const range = this.viewport().getRenderedRange();
    const total = this.page().items.length;
    // 渲染窗口距尾部不足 10 行时预取下一页
    if (total > 0 && range.end >= total - 10 && !this.page().loading) {
      this.loadNextPage();
    }
  });
}

数据源用 resource() 组织分页请求(第 23 篇详解),滚动逻辑只负责触发加载——两套机制解耦,各自可测。

常见坑

  • viewport 高度为 0:flex 布局里忘记 min-height: 0,表现为一片空白
  • itemSize 与 CSS 行高不符:偶数像素差累积后滚动条错位、行重叠;行内 padding 计入 itemSize
  • 窗口边缘闪烁:缓冲区太小,快速滚动时先见空白后见内容,增大 min/maxBufferPx
  • trackBy 缺失:数据替换后整窗视图重建,滚动中明显卡顿
  • 在视口内放 position: sticky 表头:需要 DOM 结构配合,sticky 元素应与滚动内容同层且高度计入计算

系列导航

← 算法 043:大数据处理:Bitmap & Bloom Filter 目录 算法 044:一些领域算法知识体系 →
← 返回文章列表