- Angular
Angular CDK仮想スクロールの使い方|1万件の一覧を軽く表示
仮想スクロールは、画面に見えている行だけを DOM に置き、スクロールに合わせて中身を入れ替えるしくみです。Angular CDK では <cdk-virtual-scroll-viewport itemSize="行の高さ"> で囲み、@for の代わりに *cdkVirtualFor を書きます。
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
selector: 'app-virtual-list',
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="48" class="box">
<div *cdkVirtualFor="let u of users(); trackBy: trackById" class="row">
{{ u.id }} {{ u.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: `.box { height: 480px; } .row { height: 48px; }`,
})
export class VirtualList {
readonly users = signal(makeUsers(10_000));
trackById = (_: number, u: User) => u.id;
}
1万件の一覧を、通常の @for と仮想スクロールで比べた結果です(5回測った中央値)。
| 測ったもの | 通常の @for |
仮想スクロール |
|---|---|---|
| DOM に置かれた行 | 10,000 行 | 15 行 |
| ページ全体の要素数 | 50,022 | 100 |
| 表示されるまでの時間 | 1,359 ms | 585 ms |
| 1万件を逆順に並べ替えて描き直す時間 | 1,136 ms | 17.9 ms |
| JS ヒープの使用量(GC 後) | 9.4 MB | 5.1 MB |
確認環境:Angular CLI 22.2.2・@angular/core 22.2.2・@angular/cdk 22.2.2・@angular/material 22.2.2・@angular/cdk-experimental 22.2.2、Node.js 24.21.0、Linux(2コアの仮想マシン)。ng build(本番設定)したアプリを、Chromium 141 を playwright-core 1.56.1 で操作して測りました。時間は performance.now()、要素数は document.getElementsByTagName('*')、ヒープは Chrome DevTools Protocol の Performance.getMetrics で読んだ値です。仕様は angular/components の 22.2.x ブランチの「Scrolling」ドキュメント・表のドキュメントと、@angular/cdk 22.2.2 のソースで確かめました(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular CDK仮想スクロールの使い方|1万件の一覧を軽く表示」
しくみ:見えている分だけ描く
CDK の「Scrolling」ドキュメントでは、仮想スクロールを次のように説明しています。
- 外側の要素の高さを「全件を並べたときの高さ」にして、全件があるように見せる
- 実際には、画面に入る行だけを描く
- 「最後まで来たら残りを描き足す」無限スクロールとは別のしくみ
itemSize="48" で1万件なら、中の高さは 48 × 10,000 = 480,000px になります。実際に測ると、scrollHeight は 480000 でした。スクロールすると、描いている行の塊を transform: translateY(…) で下へずらし、中身を差し替えます。
完成コード:1万件の一覧
データ
// data.ts
export interface User {
id: number;
name: string;
email: string;
dept: string;
note: string;
}
const depts = ['営業', '開発', '総務', '人事', '経理'];
export function makeUsers(n: number, start = 0): User[] {
return Array.from({ length: n }, (_, i) => {
const id = start + i + 1;
return {
id,
name: `ユーザー ${id}`,
email: `user${id}@example.com`,
dept: depts[id % depts.length],
note: 'メモ。'.repeat((id * 7) % 40 + 1), // 高さが違う行の確認用
};
});
}
仮想スクロールの一覧
// virtual-list.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { makeUsers, User } from './data';
@Component({
selector: 'app-virtual-list',
imports: [ScrollingModule],
template: `
<button type="button" id="reverse" (click)="reverse()">逆順</button>
<cdk-virtual-scroll-viewport itemSize="48" class="box">
<div *cdkVirtualFor="let u of users(); trackBy: trackById" class="row">
<span class="id">{{ u.id }}</span>
<span class="name">{{ u.name }}</span>
<span class="mail">{{ u.email }}</span>
<span class="dept">{{ u.dept }}</span>
</div>
</cdk-virtual-scroll-viewport>
`,
styleUrl: './vs.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VirtualList {
readonly users = signal<User[]>(makeUsers(10_000));
trackById = (_: number, u: User) => u.id;
reverse(): void {
this.users.update((list) => [...list].reverse());
}
}
// vs.scss
.box { height: 480px; display: block; border: 1px solid #ccc; }
.row {
height: 48px; // itemSize と同じにする
box-sizing: border-box; // 枠線・余白を高さに含める
display: flex; gap: 12px; align-items: center;
padding: 0 12px; border-bottom: 1px solid #eee;
}
.id { width: 56px; color: #666; }
.name { width: 120px; }
.mail { flex: 1; }
比べた通常の @for
<div class="box" style="overflow: auto">
@for (u of users(); track u.id) {
<div class="row">
<span class="id">{{ u.id }}</span>
<span class="name">{{ u.name }}</span>
<span class="mail">{{ u.email }}</span>
<span class="dept">{{ u.dept }}</span>
</div>
}
</div>
@for の書き方は、関連記事「Angular @if・@for・@switchの使い方|ngIfとの対応表」で扱っています。
押さえる点
| 点 | 内容 |
|---|---|
| ビューポートに高さを付ける | 高さが無いと clientHeight が 0 になり、何も見えなかった |
行の高さを itemSize と合わせる |
box-sizing: border-box で、枠線を含めた高さを 48px に |
@for ではなく *cdkVirtualFor |
仮想スクロールの中の繰り返しは、このディレクティブで書く |
trackBy を付ける |
並べ替え・再取得のとき、行の作り直しを減らす |
cdk-virtual-scroll-viewport には contain: strict(中身の大きさで外側の大きさが決まらない指定)が付いています。CSS で高さを auto にして確かめると、clientHeight は 0 で、画面には何も出ませんでした。height: 480px や height: 100%(親に高さがある場合)、flex: 1 などで高さを決めます。
実測:通常の @for と比べる
測り方
- ページを開いて、最初の行が描かれた次のフレームの
performance.now()を「表示までの時間」とした(ページの読み込み開始から) - 「逆順」ボタンを押してから2フレーム後までの時間を「並べ替えの時間」とした(1行目が
10000に変わったことも確かめた) - 上から下まで100回に分けてスクロールし、その間のメインスレッドの仕事量を
Performance.getMetricsの差で測った - 毎回新しいページで5回ずつ測り、中央値を取った
結果
| 測ったもの | 通常の @for |
仮想スクロール |
|---|---|---|
.row の数 |
10,000 | 15 |
ページの要素数(getElementsByTagName('*')) |
50,022 | 100 |
| DevTools の Nodes(テキストなども含む) | 90,057 | 192 |
| 表示までの時間(5回の値) | 1,359 ms(1,159〜2,225) | 585 ms(483〜620) |
| 逆順の並べ替え(5回の値) | 1,136 ms(776〜2,257) | 17.9 ms(5〜30) |
| JS ヒープ(GC 後) | 9.4 MB | 5.1 MB |
| 100回のスクロール中のメインスレッドの仕事 | 676 ms | 377 ms |
差が一番大きかったのは、データが変わったときの描き直しでした。 通常の @for は1万行すべての並びを入れ替えるので1秒を超え、その間は画面が固まります。仮想スクロールは見えている15行を差し替えるだけで、17.9 ms でした。検索や並べ替えで一覧が何度も変わる画面ほど、効果が出ます。
スクロールそのものの滑らかさは、この行の作りでは大きな差になりませんでした。 100回のスクロールにかかった時間はどちらも約1.66秒(1フレーム約16.6ms × 100)で、ブラウザーが追いついていました。仮想スクロールはスクロールのたびに行を差し替えるので、レイアウトとスタイルの計算(114 ms)やスクリプト(105 ms)が出ますが、メインスレッドの仕事の合計は通常の方が多い結果でした。行の中身が重い(画像・入れ子のコンポーネントが多い)ほど、通常の @for の負担は増えます。
数値は2コアの仮想マシンでの値です。端末の性能で変わるので、目安として読みます。
*cdkVirtualFor と viewport の API
*cdkVirtualFor で使える変数
ドキュメントによると、*ngFor と同じ使い方ができ、次の変数が使えます。値は「描いている範囲の中」ではなく、「データ全体の中」の位置です。
| 変数 | 内容 |
|---|---|
index |
データ全体での位置 |
count |
データの件数 |
first / last |
最初・最後の要素か |
even / odd |
偶数・奇数番目か |
<div *cdkVirtualFor="let u of users(); let i = index; let odd = odd; trackBy: trackById"
[class.odd]="odd">{{ i + 1 }}. {{ u.name }}</div>
渡せるデータは、配列・Observable<配列>・DataSource(表やツリーと同じ形)です。
viewport の入力と主なメソッド
| 名前 | 既定(22.2.2 のソース) | 内容 |
|---|---|---|
itemSize |
20 | 1行の高さ(px) |
minBufferPx |
100 | 見えている範囲の外に、最低これだけ描いておく(px) |
maxBufferPx |
200 | 足りなくなったとき、ここまで描き足す(px) |
orientation |
vertical |
horizontal で横スクロール |
appendOnly |
false | 一度描いた行を消さずに残す |
cdkVirtualForTemplateCacheSize |
20 | 使い回すために取っておく行の数 |
(scrolledIndexChange) |
— | 見えている先頭の行の番号が変わると出る |
scrollToIndex(i) |
— | i 番目の行までスクロール |
getRenderedRange() |
— | いま描いている範囲 { start, end } |
getDataLength() |
— | データの件数 |
measureScrollOffset() |
— | いまのスクロール位置(px) |
checkViewportSize() |
— | 外側の大きさが変わったときに測り直す |
minBufferPx と maxBufferPx の関係は、ドキュメントの例が分かりやすいです。itemSize = 50・minBufferPx = 100・maxBufferPx = 250 で、残りの余白が 90px になると、100 を下回ったので描き足しが始まり、250 を超えるまで4行(200px)を足して 290px にします。スクロールの速い画面で端が一瞬白く見えるときは、この2つを大きくします。
ビューポートの大きさを CSS で後から変える(タブの切り替えで表示されるなど)と、行の数が合わなくなることがあります。そのときは checkViewportSize() を呼びます。
高さが違う行の扱い
itemSize は「全部の行が同じ高さ」という前提の設定です(FixedSizeVirtualScrollStrategy)。メモ欄の長さで高さが 61px〜161px と変わる行を作り、2つの方法で比べました。
itemSize のまま(誤った使い方)
<cdk-virtual-scroll-viewport itemSize="48" class="box">
<div *cdkVirtualFor="let u of users(); trackBy: trackById" class="auto-row">
<strong>{{ u.id }} {{ u.name }}</strong>
<p>{{ u.note }}</p>
</div>
</cdk-virtual-scroll-viewport>
autosize(@angular/cdk-experimental)
npm i @angular/cdk-experimental
import { ScrollingModule } from '@angular/cdk/scrolling';
import { ScrollingModule as ExperimentalScrollingModule } from '@angular/cdk-experimental/scrolling';
@Component({
imports: [ScrollingModule, ExperimentalScrollingModule],
template: `
<cdk-virtual-scroll-viewport autosize class="box">
<div *cdkVirtualFor="let u of users(); trackBy: trackById" class="auto-row">
<strong>{{ u.id }} {{ u.name }}</strong>
<p>{{ u.note }}</p>
</div>
</cdk-virtual-scroll-viewport>
`,
})
結果
120px ずつ200回スクロールし、毎回「前後どちらにも描かれている行」が画面上で何 px 動いたかを測りました。正しくは毎回ちょうど 120px 動きます。
| 測ったもの | itemSize="48" のまま |
autosize |
|---|---|---|
| 行の高さ | 61〜161px | 61〜161px |
| 見た目がずれた回数(200回中) | 100回 | 0回 |
| ずれの最大 | 365px | 0px |
| スクロールできる全体の高さ | 480,000px(48 × 1万) | 1,024,286px → 1,063,715px |
| 一番下まで行けたか | 行けた(9996〜10000 が見えた) | 行けた(9996〜10000 が見えた) |
itemSize のままだと、200回中100回、行が飛ぶように動きました(最大 365px)。 描く範囲が切り替わるたびに、行の塊を「番号 × 48px」の位置に置き直すのに、実際の行はもっと高いためです。読んでいた行が急に消えたり戻ったりして見えます。
autosize では、ずれは0回でした。 描いた行の高さの平均から、まだ描いていない行の高さを見積もるしくみです(ソースの ItemSizeAverager)。そのため、スクロールしていくと全体の高さの見積もりが変わり(1,024,286px → 1,063,715px)、スクロールバーのつまみの大きさが少し変わります。
autosize の注意点です。
- CDK のドキュメントでは「
@angular/cdk-experimentalで開発中で、本番で使える段階ではない」とされています - @angular/cdk-experimental 22.2.2 のソースでは、autosize の
scrollToIndex()は開発モードでcdk-virtual-scroll: scrollToIndex is currently not supported for the autosize scroll strategyというエラーを投げます。「○番目へ移動」は使えません
本番の画面では、次のどれかを選ぶのが現実的です。
| 方法 | 内容 |
|---|---|
| 高さをそろえる | 長い文は -webkit-line-clamp などで2行に切り、行の高さを固定して itemSize を使う |
| 高さの種類が少ない | 自分で VirtualScrollStrategy を作り、VIRTUAL_SCROLL_STRATEGY で渡す(ドキュメントに方法あり) |
| 実験的と承知で使う | autosize。scrollToIndex を使わない画面に限る |
テーブルと組み合わせる(mat-table)
Angular Material の表のドキュメント(22.2.x)には「Virtual scrolling」の節があり、<table mat-table> を cdk-virtual-scroll-viewport で囲む方法が載っています。1万行で試したコードです。
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatTableModule } from '@angular/material/table';
import { makeUsers, User } from './data';
@Component({
selector: 'app-table-list',
imports: [ScrollingModule, MatTableModule],
template: `
<cdk-virtual-scroll-viewport class="table-box" [itemSize]="52" [minBufferPx]="400" [maxBufferPx]="1200">
<table mat-table [dataSource]="users" [trackBy]="trackById">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>ID</th>
<td mat-cell *matCellDef="let u">{{ u.id }}</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>名前</th>
<td mat-cell *matCellDef="let u">{{ u.name }}</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef>メール</th>
<td mat-cell *matCellDef="let u">{{ u.email }}</td>
</ng-container>
<ng-container matColumnDef="dept">
<th mat-header-cell *matHeaderCellDef>部署</th>
<td mat-cell *matCellDef="let u">{{ u.dept }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
</cdk-virtual-scroll-viewport>
`,
styles: `
.table-box { height: 480px; overflow: auto; display: block; }
.table-box table { width: 100%; }
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableList {
readonly columns = ['id', 'name', 'email', 'dept'];
readonly users: User[] = makeUsers(10_000);
trackById = (_: number, u: User) => u.id;
}
スクロール位置を変えて、描かれた行の数と見出しの位置を測りました。
| スクロール位置 | 描かれた行 | 先頭の行の ID | 見出しの位置(枠の上端から) | 要素数 |
|---|---|---|---|---|
| 0 | 33 | 1 | 0px | 199 |
| 20,000px | 41 | 377 | 0px | 239 |
| 260,000px | 41 | 4993 | 0px | 239 |
| 520,000px(末尾) | 17 | 9984 | 0px | 119 |
*cdkVirtualFor を書かなくても、mat-table の行が見える分だけになり、sticky: true の見出しも上に残りました。 行の高さは Material の既定の 52px に合わせて itemSize を 52 にしています。ドキュメントには、仮想スクロールの表の制限として、次の2つが書かれています。
fixedLayout(列の幅を固定する表の配置)が常に有効になる。行の入れ替えで列の幅が揺れないようにするためwhenで行のテンプレートを出し分ける機能は、今はまだ使えない
Material の表の基本は、関連記事「Angular Materialの使い方|導入からテーブル・ダイアログまで」で扱っています。
無限スクロールと組み合わせる
仮想スクロールは「全件が手元にある」前提のしくみです。サーバーから少しずつ読む無限スクロールと組み合わせるときは、(scrolledIndexChange) で末尾に近づいたことを知り、次のページを読み足します。
import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core';
import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling';
import { makeUsers, User } from './data';
const PAGE = 50;
const TOTAL = 1_000;
@Component({
selector: 'app-infinite-list',
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="48" class="box" (scrolledIndexChange)="onScroll()">
<div *cdkVirtualFor="let u of users(); trackBy: trackById" class="row">
<span class="id">{{ u.id }}</span>
<span class="name">{{ u.name }}</span>
</div>
</cdk-virtual-scroll-viewport>
<p id="status">{{ users().length }} 件 / 読み込み {{ pages() }} 回 @if (loading()) { ・読み込み中 }</p>
`,
styleUrl: './vs.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InfiniteList {
readonly viewport = viewChild.required(CdkVirtualScrollViewport);
readonly users = signal<User[]>([]);
readonly loading = signal(false);
readonly pages = signal(0);
trackById = (_: number, u: User) => u.id;
constructor() {
this.loadMore();
}
onScroll(): void {
const end = this.viewport().getRenderedRange().end;
const total = this.viewport().getDataLength();
// 描いている範囲の終わりが、末尾から10件以内に来たら次を読む
if (end >= total - 10) this.loadMore();
}
private async loadMore(): Promise<void> {
if (this.loading() || this.users().length >= TOTAL) return;
this.loading.set(true);
await new Promise((r) => setTimeout(r, 300)); // API の代わり(300ms 待つ)
this.users.update((list) => [...list, ...makeUsers(PAGE, list.length)]);
this.pages.update((n) => n + 1);
this.loading.set(false);
}
}
一番下までのスクロールを30回くり返し、表示の変化を記録しました。
50 件 / 読み込み 1 回
100 件 / 読み込み 2 回
150 件 / 読み込み 3 回
…
950 件 / 読み込み 19 回
1000 件 / 読み込み 20 回
rendered rows: 13
1,000件まで、50件ずつ20回読み足され、最後まで DOM の行は13行でした。 読み込み中は loading で二重の読み込みを止め、全件読んだら止めています。実際の API では、HttpClient の結果を users.update で後ろに足します。
ポイントは2つです。
(scrolledIndexChange)は「先頭の行の番号」が変わったときに出ます。末尾の判定にはgetRenderedRange().endとgetDataLength()を使います- 仮想スクロールを使わない無限スクロール(
IntersectionObserverで末尾の要素を見張る方法)では、読み足すほど DOM の行が増えます。件数が多くなる画面では、この記事の形にすると行の数が増えません
IntersectionObserver で作る方法は、関連記事「IntersectionObserver使い方|無限スクロール・遅延読込レシピ集」で扱っています。
よくある質問
Q. *cdkVirtualFor の代わりに @for を使えますか。
A. 仮想スクロールの行の差し替えは *cdkVirtualFor が行っています。cdk-virtual-scroll-viewport の中に @for で1万件を書いて試すと、.row は 10,000 行すべて描かれ、仮想スクロールになりませんでした(エラーも出ません)。ScrollingModule(または CdkVirtualForOf)を imports に入れて、*cdkVirtualFor で書きます。
Q. ページ全体(ウィンドウ)のスクロールで仮想スクロールしたいです。
A. ドキュメントによると、<cdk-virtual-scroll-viewport scrollWindow> でウィンドウのスクロールを使えます。親の要素でスクロールさせるときは、親に cdkVirtualScrollingElement を付けます。
Q. ブラウザーの「ページ内検索」で、見えていない行が見つかりません。 A. 仮想スクロールでは、見えていない行は DOM にありません。ページ内検索やスクリーンリーダーで全件を読む使い方には向きません。アプリの中に検索欄を用意して、データを絞り込む形にします。
Q. 何件くらいから仮想スクロールにするのがよいですか。
A. この記事の1万件では、表示が 1,359 ms → 585 ms、並べ替えが 1,136 ms → 17.9 ms でした。件数だけでなく、1行の重さと、データが変わる回数で決まります。まず通常の @for で作り、Chrome DevTools の Performance パネルで描き直しが長いと分かった画面に使うと、手間に見合います。
まとめ
<cdk-virtual-scroll-viewport itemSize="…">で囲み、*cdkVirtualForで回す。ビューポートに高さが無いと何も見えない- 1万件で、DOM の行は 10,000 → 15、要素数は 50,022 → 100
- 一番の差はデータが変わったときの描き直し(並べ替え 1,136 ms → 17.9 ms)
- 高さが違う行に
itemSizeを使うと、200回中100回、最大 365px 行が飛んだ。autosizeはずれ0回だが実験的で、scrollToIndexは使えない mat-tableは viewport で囲むだけで仮想スクロールになり、stickyの見出しも残った- 無限スクロールは
(scrolledIndexChange)+getRenderedRange().endで末尾を判定し、読み足しても行は13行のままだった
参考・出典
確認日はいずれも 2026年10月13日です。
- Angular CDK「Scrolling」:https://material.angular.dev/cdk/scrolling/overview
- Angular CDK「Scrolling」API:https://material.angular.dev/cdk/scrolling/api
- angular/components の Scrolling ドキュメントのソース(22.2.x):https://github.com/angular/components/blob/22.2.x/src/cdk/scrolling/scrolling.md
- Angular Material「Table」(Virtual scrolling の節):https://material.angular.dev/components/table/overview
- mat-table の仮想スクロールの例(22.2.x):https://github.com/angular/components/tree/22.2.x/src/components-examples/material/table/table-virtual-scroll
- @angular/cdk-experimental の autosize(ソース):https://github.com/angular/components/tree/22.2.x/src/cdk-experimental/scrolling
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular CDK仮想スクロールの使い方|1万件の一覧を軽く表示」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。