メインコンテンツまでスキップ

[Lv3] 仮想スクロールの実装:大量データレンダリングの処理

ページに 1000+ 件のデータをレンダリングする必要がある場合、仮想スクロールで DOM ノードを 1000+ から 20-30 に削減し、メモリ使用量を 80% 低減できます。


面接シナリオ問題

Q: ページに複数の table があり、それぞれ 100 件以上のデータがあり、さらに DOM を頻繁に更新するイベントがある場合、どんな方法でこのページのパフォーマンスを最適化しますか?


問題分析(Situation)

実際のプロジェクトシーン

プラットフォームプロジェクトで、大量データを処理する必要があるページ:

📊 あるページの履歴ページ
├─ 入金記録テーブル:1000+ 件
├─ 出金記録テーブル:800+ 件
├─ ベット記録テーブル:5000+ 件
└─ 各レコード 8-10 フィールド(時間、金額、ステータスなど)

❌ 最適化前の問題
├─ DOM ノード数:1000 件 × 10 フィールド = 10,000+ ノード
├─ メモリ消費:約 150-200 MB
├─ 初回レンダリング時間:3-5 秒(白画面)
├─ スクロールのカクつき:FPS < 20
└─ WebSocket 更新時:テーブル全体が再レンダリング(非常にカクつく)

問題の深刻さ

// ❌ 従来の方法
<tr v-for="record in allRecords"> // 1000+ 件すべてをレンダリング
<td>{{ record.time }}</td>
<td>{{ record.amount }}</td>
// ... 8-10 フィールド
</tr>

// 結果:
// - 初回レンダリング:10,000+ DOM ノード
// - ユーザーが実際に見えるのは:20-30 件
// - 無駄:99% のノードはユーザーに見えない

解決策(Action)

Virtual Scrolling(仮想スクロール)

まず仮想スクロールの最適化を考えます。大きく2つの方向があり、1つは公式推奨のサードパーティパッケージ vue-virtual-scroller を使用し、パラメータと要件に基づいて可視範囲の row を決定する方法です。

// 可視領域の row のみレンダリング。例:
// - 100 件のデータで、可視の 20 件のみレンダリング
// - DOM ノード数を大幅に削減

もう1つは自前で実装する方法ですが、実際の開発コストやカバーすべきシナリオを考慮すると、公式推奨のサードパーティパッケージを採用する方が良いでしょう。

データ更新頻度の制御

解法1:requestAnimationFrame(RAF) コンセプト:ブラウザは毎秒最大 60 回再描画(60 FPS)。それ以上速く更新しても人間の目には見えないので、画面のリフレッシュレートに合わせて更新

// ❌ 元の方法:データを受け取ったら即座に更新(毎秒 100 回になる可能性)
socket.on('price', (newPrice) => {
btcPrice.value = newPrice;
});

// ✅ 改良:データを収集し、画面リフレッシュレートに合わせて一括更新(毎秒最大 60 回)
let latestPrice = null;
let isScheduled = false;

socket.on('price', (newPrice) => {
latestPrice = newPrice; // 最新価格を一時保存

if (!isScheduled) {
isScheduled = true;
requestAnimationFrame(() => {
btcPrice.value = latestPrice; // ブラウザが再描画の準備ができた時に更新
isScheduled = false;
});
}
});

解法2:throttle(スロットル) コンセプト:更新頻度を強制的に制限。例えば「100ms に最大 1 回の更新」

// lodash の throttle(プロジェクトで使用している場合)
import { throttle } from 'lodash-es';

const updatePrice = throttle((newPrice) => {
btcPrice.value = newPrice;
}, 100); // 100ms あたり最大 1 回実行

socket.on('price', updatePrice);

Vue3 固有の最適化

Vue3 のシンタックスシュガーにはパフォーマンス最適化を提供するものがあります。例えば v-memo ですが、個人的にはこのシナリオではあまり使用しません。

// 1. v-memo - 変更が少ない列をメモ化
<tr v-for="row in data"
:key="row.id"
v-memo="[row.price, row.volume]"> // これらのフィールドが変化した時のみ再レンダリング
</tr>

// 2. 静的データをフリーズし、リアクティブのオーバーヘッドを回避
const staticData = Object.freeze(largeDataArray)

// 3. shallowRef で大きな配列を処理
const tableData = shallowRef([...]) // 配列自体のみ追跡、内部オブジェクトは追跡しない

// 4. key で diff アルゴリズムを最適化(ユニークな id で各 item を追跡し、DOM の更新を変更があったノードに限定してパフォーマンスを節約)
<tr v-for="row in data" :key="row.id"> // 安定した key

RAF:画面リフレッシュに同期(約 16ms)、アニメーション・スクロールに適合 throttle:カスタム間隔(例:100ms)、検索・resize に適合

DOM レンダリングの最適化

// top/left の代わりに CSS transform を使用
.row-update {
transform: translateY(0); /* GPU アクセラレーションをトリガー */
will-change: transform; /* ブラウザに最適化をヒント */
}

// CSS containment でレンダリング範囲を分離
.table-container {
contain: layout style paint;
}

最適化成果(Result)

パフォーマンス比較

指標最適化前最適化後改善幅
DOM ノード数10,000+20-30↓ 99.7%
メモリ使用量150-200 MB30-40 MB↓ 80%
初回レンダリング3-5 秒0.3-0.5 秒↑ 90%
スクロール FPS< 2055-60↑ 200%
更新レスポンス500-800 ms16-33 ms↑ 95%

実際の効果

✅ 仮想スクロール
├─ 可視の 20-30 件のみレンダリング
├─ スクロール時に可視範囲を動的に更新
├─ ユーザーが気づかない(スムーズな体験)
└─ メモリが安定(データ量の増加に伴わない)

✅ RAF データ更新
├─ WebSocket 毎秒 100 回更新 → 最大 60 回レンダリング
├─ 画面リフレッシュレートに同期(60 FPS)
└─ CPU 使用率 60% 低減

✅ Vue3 最適化
├─ v-memo:不要な再レンダリングを回避
├─ shallowRef:リアクティブのオーバーヘッドを削減
└─ 安定した :key:diff アルゴリズムを最適化

面接のポイント

よくある追加質問

Q: サードパーティ library を使えない場合は? A: 仮想スクロールのコアロジックを自前で実装:

// コアコンセプト
const itemHeight = 50; // 各行の高さ
const containerHeight = 600; // コンテナの高さ
const visibleCount = Math.ceil(containerHeight / itemHeight); // 可視数

// 現在表示すべきアイテムを計算
const scrollTop = container.scrollTop;
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;

// 可視範囲のみレンダリング
const visibleItems = allItems.slice(startIndex, endIndex);

// padding で高さを補完(スクロールバーを正しく表示)
const paddingTop = startIndex * itemHeight;
const paddingBottom = (allItems.length - endIndex) * itemHeight;

ポイント:

  • 可視範囲の計算(startIndex → endIndex)
  • 動的データ読み込み(slice)
  • 高さの補完(padding top/bottom)
  • スクロールイベントの監視(throttle で最適化)

Q: WebSocket の切断再接続はどう処理しますか? A: 指数バックオフ再接続戦略を実装:

let retryCount = 0;
const maxRetries = 5;
const baseDelay = 1000; // 1 秒

function reconnect() {
if (retryCount >= maxRetries) {
showError('接続できません。ページをリロードしてください');
return;
}

// 指数バックオフ:1s → 2s → 4s → 8s → 16s
const delay = baseDelay * Math.pow(2, retryCount);

setTimeout(() => {
retryCount++;
connectWebSocket();
}, delay);
}

// 再接続成功後
socket.on('connect', () => {
retryCount = 0; // カウントリセット
syncData(); // データ同期
showSuccess('接続が復旧しました');
});

Q: Virtual Scroll の欠点は? A: 注意すべき Trade-offs:

❌ 欠点
├─ ブラウザのネイティブ検索(Ctrl+F)が使えない
├─ 「全選択」機能が使えない(特別な処理が必要)
├─ 実装の複雑度が高い
├─ 固定高さまたは事前の高さ計算が必要
└─ アクセシビリティ機能の追加対応が必要

✅ 適したシーン
├─ データ量 > 100 件
├─ 各データの構造が類似(高さ固定)
├─ 高パフォーマンスのスクロールが必要
└─ 閲覧メイン(編集ではない)

❌ 適さないシーン
├─ データ量 < 50 件(オーバーエンジニアリング)
├─ 高さが不定(実装が困難)
├─ 大量のインタラクションが必要(複数選択、ドラッグ&ドロップなど)
└─ テーブル全体の印刷が必要

技術比較

Virtual Scroll vs ページネーション

比較項目Virtual Scroll従来のページネーション
ユーザー体験連続スクロール(良い)ページ切り替えが必要(中断)
パフォーマンス常に可視範囲のみレンダリング各ページを全てレンダリング
実装難度比較的複雑シンプル
SEO 対応劣る優れている
アクセシビリティ特別な処理が必要ネイティブサポート

推奨:

  • 管理画面、Dashboard → Virtual Scroll
  • 公開サイト、ブログ → 従来のページネーション
  • ハイブリッド方式:Virtual Scroll + 「もっと見る」ボタン