画像ギャラリーのパフォーマンス最適化 - 大量画像を高速表示するテクニック
大量画像表示の課題 - なぜギャラリーは遅くなるのか
画像ギャラリーは Web サイトで最もパフォーマンス問題が顕在化しやすいコンポーネントの一つです。数百枚の画像を含むページでは、ネットワーク帯域、メモリ使用量、レンダリング性能のすべてがボトルネックになり得ます。Google Photos、Pinterest、Unsplash などの大規模ギャラリーサービスが採用している最適化手法を理解し、実装に活かしましょう。
パフォーマンス劣化の主な原因:
- DOM ノード数の爆発: 1000 枚の画像を表示するために 1000 個の
<img>要素を DOM に配置すると、レイアウト計算とペイント処理が重くなります。Chrome DevTools の Performance パネルでは、DOM ノード数が 1500 を超えると警告が表示されます - 同時ネットワークリクエスト: ブラウザの同一ドメインへの同時接続数は HTTP/1.1 で 6 本、HTTP/2 で理論上無制限ですが、実際には 100 本以上の同時リクエストはネットワークスタックに負荷をかけます
- メモリ消費: デコード済み画像はピクセルあたり 4 バイト (RGBA) を消費します。1,000 × 1,000 px の画像 100 枚で約 400MB のメモリを使用し、モバイルデバイスではメモリ不足でタブがクラッシュする原因になります
- レイアウト計算: Masonry (Pinterest 風) レイアウトでは、各画像の位置を JavaScript で計算する必要があり、画像数に比例して計算コストが増大します
最適化の基本戦略は「表示に必要な最小限のリソースだけを読み込み、不要になったリソースを解放する」ことです。以下のセクションで具体的な実装手法を解説します。
仮想スクロール (Virtual Scrolling) - DOM ノード数を制限する
仮想スクロールは、実際にビューポートに表示されている (または表示される直前の) 要素のみを DOM に配置し、スクロールに応じて動的に要素を追加・削除する手法です。1000 枚の画像があっても、DOM 上には常に 20-30 個の要素しか存在しないため、レンダリング性能が一定に保たれます。
仮想スクロールの実装原理:
- コンテナの高さ計算: 全画像の合計高さを計算し、スクロールコンテナに設定。これによりスクロールバーが正しい長さで表示されます
- 表示範囲の計算:
scrollTopとclientHeightから、現在表示すべき画像のインデックス範囲を算出 - 要素の配置: 表示範囲の画像のみ DOM に追加し、
transform: translateY()で正しい位置に配置 - バッファ領域: 表示範囲の上下に 5-10 個分のバッファを設け、スクロール時のちらつきを防止
グリッドレイアウトでの仮想スクロール:
- 固定サイズのグリッド (例: 3 列 x 200px) では、行単位で仮想化が可能。1 行に 3 枚なら、表示行数 + バッファ行数の要素のみ DOM に配置
- 可変高さの Masonry レイアウトでは、各画像の高さを事前に知る必要があるため、画像メタデータ (width, height) を API レスポンスに含めることが重要
既存ライブラリの活用:
- react-virtuoso: React 向け。可変高さ対応、グリッドモード、無限スクロール統合
- vue-virtual-scroller: Vue 向け。RecycleScroller コンポーネントで要素を再利用
- @tanstack/virtual: フレームワーク非依存。React、Vue、Solid、Svelte で利用可能
注意点: 仮想スクロールは SEO に影響します。DOM に存在しない画像はクローラーに認識されないため、SSR で全画像の URL を含む HTML を生成するか、サイトマップに画像 URL を含める対策が必要です。
プログレッシブ読み込みと LQIP - 体感速度を向上させる
プログレッシブ読み込みは、低品質のプレースホルダーを即座に表示し、高品質画像の読み込みが完了したら差し替える手法です。ユーザーの体感待ち時間を大幅に短縮し、ギャラリーの「空白状態」を排除します。
LQIP (Low Quality Image Placeholder) の実装パターン:
- ぼかし画像: 元画像を 20x20px 程度に縮小し、Base64 エンコードして HTML に埋め込み。CSS の
filter: blur(20px)で表示し、本画像読み込み後にフェードアウト。データサイズは 200-500 バイト程度 - ドミナントカラー: 画像の主要色 1 色を背景色として設定。データサイズは 7 バイト (HEX 値) のみで最軽量
- BlurHash: Wolt 社が開発したアルゴリズム。20-30 文字の文字列で画像のぼかしプレビューを表現。Base83 エンコードで効率的にデータを圧縮
- ThumbHash: BlurHash の改良版。アスペクト比の保持と、より正確な色再現を実現。約 28 バイトのバイナリデータ
実装のベストプラクティス:
- LQIP データはギャラリーの API レスポンスに含め、画像 URL と同時に取得する
- プレースホルダーから本画像への切り替えは CSS transition (opacity 0.3s) でスムーズに行う
- 本画像の
onloadイベントで切り替えをトリガーし、デコード完了をimg.decode()で待つとさらに滑らか - エラー時 (画像読み込み失敗) はプレースホルダーを維持し、リトライボタンを表示
Progressive JPEG の活用:
- Progressive JPEG は低解像度から段階的に表示されるため、LQIP なしでもプログレッシブな体験を提供
- ただし、ギャラリーの全画像を Progressive JPEG にすると、デコード負荷が増加する場合があります
- サムネイル (300px 以下) は Baseline JPEG、フルサイズは Progressive JPEG という使い分けが効果的
メモリ管理 - 画像のデコード・破棄を制御する
大量の画像を扱うギャラリーでは、メモリ管理が安定性の鍵を握ります。ブラウザは表示中の画像をデコード済みビットマップとしてメモリに保持するため、数百枚の高解像度画像を同時にデコード状態にすると、数 GB のメモリを消費してタブがクラッシュします。
メモリ消費の計算:
- デコード済み画像のメモリ = width x height x 4 バイト (RGBA)
- 1,200 × 800 px の画像: 1200 x 800 x 4 = 3.84MB
- 100 枚同時デコード: 384MB
- 500 枚同時デコード: 1.92GB (モバイルではクラッシュ確実)
メモリ管理の戦略:
- ビューポート外画像の解放: Intersection Observer でビューポートから離れた画像を検出し、
img.src = ''またはimg.removeAttribute('src')でデコード済みビットマップを解放。再表示時に再読み込み (キャッシュから高速) - 同時デコード数の制限: 一度にデコードする画像数を制限 (例: 最大 30 枚)。新しい画像をデコードする前に、最も古い画像を解放
- サムネイルの活用: ギャラリー表示では 300-400px 幅のサムネイルを使用し、フルサイズ画像はライトボックス表示時のみ読み込む
- srcset による適切なサイズ選択: デバイスの画面密度とコンテナサイズに応じた最適なサイズの画像を選択し、不要に大きな画像のデコードを避ける
Chrome の画像デコードポリシー:
- Chrome はビューポート外の画像を自動的にデコード解除する場合がありますが、このタイミングは予測不能
content-visibility: autoを使用すると、ブラウザに明示的にレンダリング遅延を指示でき、メモリ管理が改善されます- Performance Monitor (Chrome DevTools) の「JS heap size」と「Documents」で実際のメモリ使用量を監視
Masonry レイアウトの効率的な実装
Masonry (石積み) レイアウトは Pinterest が普及させたギャラリーレイアウトで、異なるアスペクト比の画像を隙間なく配置します。しかし、CSS だけでは完全な Masonry レイアウトを実現できないため、JavaScript による位置計算が必要であり、これがパフォーマンスのボトルネックになります。
Masonry レイアウトの実装アプローチ:
- CSS Grid + masonry (実験的):
grid-template-rows: masonryが Firefox 77+ で実験的にサポート。Chrome は未対応 (2024 年時点)。将来的には CSS だけで実現可能になる見込み - CSS columns:
column-count: 3で擬似的な Masonry を実現。ただし要素の並び順が上→下→右になるため、時系列順の表示には不向き - JavaScript 計算: 各列の現在の高さを追跡し、最も短い列に次の画像を配置。
position: absolute+transform: translate(x, y)で配置
JavaScript Masonry の最適化:
- バッチ DOM 更新: 全画像の位置を計算してから、一度に DOM を更新。
requestAnimationFrame内で実行し、レイアウトスラッシングを防止 - ResizeObserver: コンテナのリサイズを検出し、列数と画像位置を再計算。
debounce(100ms) を適用して過剰な再計算を防止 - 事前の高さ計算: 画像のアスペクト比 (API レスポンスに含める) から、列幅に基づいた表示高さを事前計算。画像読み込み前にレイアウトを確定させ、CLS を防止
- CSS containment:
contain: layout styleを各画像コンテナに適用し、個別の画像読み込みが他の要素のレイアウトに影響しないようにする
ライブラリの選択:
- Masonry.js: 最も歴史のあるライブラリ。jQuery 依存あり (非依存版も存在)
- Isotope: Masonry + フィルタリング + ソート機能。商用利用は有料ライセンス
- react-masonry-css: React 向け。CSS columns ベースで軽量 (JS 計算なし)
無限スクロールとページネーション - UX とパフォーマンスの両立
大量の画像を段階的に読み込む方法として、無限スクロール (Infinite Scroll) とページネーション (Pagination) があります。それぞれの特性を理解し、ギャラリーの用途に応じて適切な方式を選択しましょう。
無限スクロールの実装:
- Intersection Observer によるトリガー: ギャラリー末尾にセンチネル要素を配置し、ビューポートに入ったら次のバッチを読み込み。
rootMargin: '500px'で事前読み込みを開始 - バッチサイズ: 1 回の読み込みで 20-30 枚が適切。少なすぎると頻繁な API コールが発生し、多すぎると初回表示が遅延
- ローディング状態: スケルトンスクリーン (画像サイズのプレースホルダー) を表示し、読み込み中であることを視覚的に伝える
- エラーハンドリング: ネットワークエラー時は「再読み込み」ボタンを表示。自動リトライは 3 回まで、指数バックオフで実行
無限スクロールの課題と対策:
- メモリ累積: スクロールし続けると DOM ノードとデコード済み画像が蓄積。仮想スクロールとの組み合わせで解決
- スクロール位置の復元: ブラウザバック時にスクロール位置を復元するため、
sessionStorageに位置とデータを保存 - URL の更新:
history.replaceStateでページ番号を URL に反映し、共有やブックマークに対応 - フッターへのアクセス: 無限スクロールではフッターに到達できない問題。「もっと見る」ボタン方式 (Load More) で解決
ページネーション vs 無限スクロール vs Load More:
- ページネーション: SEO に有利 (各ページが独立した URL)。ユーザーが全体量を把握しやすい。EC サイトの商品一覧に適切
- 無限スクロール: 没入感が高く、SNS フィードや写真ギャラリーに適切。SEO には不利
- Load More ボタン: 無限スクロールとページネーションの中間。ユーザーが明示的に読み込みを制御でき、フッターにもアクセス可能