一万条日志、五千个联系人,普通 @for 渲染会把所有 DOM 一次性铺满页面——浏览器内存暴涨、滚动掉帧、首屏被拖死。虚拟滚动的答案是:只渲染视口内(及附近)的那一小段。CDK 的 Scrolling 模块提供了生产级的实现,本文讲透它的原理与用法。
问题:DOM 数量是性能的头号敌人
每个 DOM 节点都有创建成本、内存占用与排版开销。粗略经验:
| 列表规模 | DOM 节点量级 | 典型表现 |
|---|---|---|
| < 200 项 | 数百 | @for 足够,无感 |
| 200 - 2000 项 | 数千 | 首屏变慢,需要 track 优化 |
| > 2000 项 | 万级 | 必须虚拟滚动或分页 |
@for 的 track 只解决”重排时复用 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"]
三个关键设计:
- 总高度占位:viewport 内部用一个撑高元素(spacer)撑起
itemSize × itemCount的总高度,让滚动条长度真实可信 - 渲染窗口:根据
scrollTop与itemSize算出可见区间[start, end],加上缓冲区向外扩展几屏 - 变换定位:渲染出的行不是普通流式布局,而是绝对定位/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.id | trackBy 函数(返回 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 元素应与滚动内容同层且高度计入计算