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

[Lv1] 画像読み込み最適化:4層 Lazy Load

4層の画像遅延読み込み戦略により、ファーストビューの画像転送量を 60MB から 2MB に削減し、読み込み時間を 85% 改善。


問題背景 (Situation)

スマートフォンでウェブページを閲覧するとき、画面に表示できるのは 10 枚の画像だけなのに、ウェブページが一度に 500 枚の画像データを読み込むとしたら、スマートフォンはフリーズし、通信量も一瞬で 50MB 消費してしまいます。

プロジェクトの実際の状況:

📊 あるページのトップページ統計
├─ 300+ 枚のサムネイル(各 150-300KB)
├─ 50+ 枚のプロモーション Banner
└─ 全て読み込んだ場合:300 × 200KB = 60MB+ の画像データ

❌ 実際の問題
├─ ファーストビューで見えるのは 8-12 枚のみ
├─ ユーザーは 30 枚目までスクロールして離脱する可能性
└─ 残り 270 枚は完全に無駄な読み込み(通信量の浪費 + 速度低下)

📉 影響
├─ 初回読み込み時間:15-20 秒
├─ 通信量消費:60MB+(ユーザーから不満の声)
├─ ページのカクつき:スクロールが不滑らか
└─ 直帰率:42%(非常に高い)

最適化目標 (Task)

  1. 可視範囲内の画像のみ読み込む
  2. ビューポートに入りそうな画像を先読み(50px 手前から読み込み開始)
  3. 同時読み込み数を制御(一度に大量の画像を読み込むのを防止)
  4. 高速切り替えによるリソースの無駄を防止
  5. ファーストビューの画像転送量 < 3MB

解決策(Action)

v-lazy-load.ts の実装

4層の image lazy load

第1層:ビューポート可視性検出(IntersectionObserver)

// オブザーバーを作成し、画像がビューポートに入ったかを監視
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 画像が可視領域に入った
// 画像の読み込みを開始
}
});
},
{
rootMargin: '50px 0px', // 50px 手前から読み込み開始(先読み)
threshold: 0.1, // 10% 見えたらトリガー
}
);
  • ブラウザネイティブの IntersectionObserver API を使用(scroll イベントよりはるかに高性能)
  • rootMargin: "50px" → 画像がまだ下方 50px にある時点で読み込み開始、ユーザーがスクロールした時には準備完了(よりスムーズに感じる)
  • ビューポート外の画像は一切読み込まない

第2層:同時実行制御メカニズム(Queue 管理)

class LazyLoadQueue {
private loadingCount = 0
private maxConcurrent = 6 // 同時読み込み最大 6 枚
private queue: (() => void)[] = []

enqueue(loadFn: () => void) {
if (this.loadingCount < this.maxConcurrent) {
this.executeLoad(loadFn) // 空きあり、即座に読み込み
} else {
this.queue.push(loadFn) // 空きなし、キューで待機
}
}
}
  • 20 枚の画像が同時にビューポートに入っても、同時読み込みは 6 枚まで
  • 「ウォーターフォール読み込み」によるブラウザのブロッキングを防止(Chrome のデフォルト同時接続数は最大 6)
  • 読み込み完了後、キュー内の次の画像を自動処理
ユーザーが高速で底部までスクロール → 30 枚の画像が同時にトリガー
キュー管理なし:30 リクエストが同時発行 → ブラウザがカクつく
キュー管理あり:最初の 6 枚を読み込み → 完了後に次の 6 枚 → スムーズ

第3層:リソース競合問題の解決(バージョン管理)

// 読み込み時のバージョン番号を設定
el.setAttribute('data-version', Date.now().toString());

// 読み込み完了後にバージョンを検証
img.onload = () => {
const currentVersion = img.getAttribute('data-version');
if (loadVersion === currentVersion) {
// バージョン一致、画像を表示
} else {
// バージョン不一致、ユーザーは既に別のゲームに切り替え済み、表示しない
}
};

実際のケース:

ユーザーの操作:

1. 「ニュース」カテゴリをクリック → 100 枚の画像読み込みをトリガー(バージョン 1001)
2. 0.5 秒後に「キャンペーン」をクリック → 80 枚の画像読み込みをトリガー(バージョン 1002)
3. ニュースの画像が 1 秒後に読み込み完了

バージョン管理なし:ニュースの画像を表示(間違い!)
バージョン管理あり:バージョンの不一致を検出、ニュースの画像を破棄(正しい!)

第4層:プレースホルダー戦略(Base64 透明画像)

// デフォルトで 1×1 透明 SVG を表示し、レイアウトシフトを防止
el.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIi...';

// 実際の画像 URL は data-src に格納
el.setAttribute('data-src', realImageUrl);
  • Base64 エンコードの透明 SVG を使用(わずか 100 bytes)
  • CLS(Cumulative Layout Shift、累積レイアウトシフト)を防止
  • ユーザーが「画像が突然飛び出してくる」現象を回避

最適化成果(Result)

最適化前:

ファーストビュー画像:一度に 300 枚読み込み(60MB)
読み込み時間:15-20 秒
スクロールの滑らかさ:深刻なカクつき
直帰率:42%

最適化後:

ファーストビュー画像:8-12 枚のみ読み込み(2MB) ↓ 97%
読み込み時間:2-3 秒 ↑ 85%
スクロールの滑らかさ:スムーズ(60fps)
直帰率:28% ↓ 33%

具体的なデータ:

  • ファーストビュー画像転送量:60 MB → 2 MB(97% 削減)
  • 画像読み込み時間:15 秒 → 2 秒(85% 改善)
  • ページスクロール FPS:20-30 から 55-60 に向上
  • メモリ使用量:65% 削減(未読み込みの画像はメモリを消費しないため)

技術指標:

  • IntersectionObserver の性能:従来の scroll イベントを大幅に上回る(CPU 使用率 80% 削減)
  • 同時実行制御効果:ブラウザのリクエストブロッキングを防止
  • バージョン管理の的中率:99.5%(誤った画像表示はほぼなし)

面接のポイント

よくある追加質問:

  1. Q: なぜ loading="lazy" 属性を直接使わないのですか? A: ネイティブの loading="lazy" にはいくつかの制限があります:

    • 先読み距離を制御できない(ブラウザが決定)
    • 同時読み込み数を制御できない
    • バージョン管理に対応できない(高速切り替え問題)
    • 古いブラウザは非対応

    カスタムディレクティブはより精細な制御を提供し、複雑なシナリオに適しています。

  2. Q: IntersectionObserver は scroll イベントと比べて何が優れていますか? A:

    // ❌ 従来の scroll イベント
    window.addEventListener('scroll', () => {
    // スクロールのたびにトリガー(60回/秒)
    // 要素位置の計算が必要(getBoundingClientRect)
    // 強制 reflow を引き起こす可能性(パフォーマンスキラー)
    });

    // ✅ IntersectionObserver
    const observer = new IntersectionObserver(callback);
    // 要素がビューポートに出入りする時のみトリガー
    // ブラウザネイティブ最適化、メインスレッドをブロックしない
    // パフォーマンス 80% 向上
  3. Q: 同時実行制御の 6 枚の制限はどこから来ていますか? A: ブラウザの HTTP/1.1 同一オリジン同時接続制限 に基づいています:

    • Chrome/Firefox:ドメインあたり最大 6 つの同時接続
    • 制限を超えるリクエストはキューで待機
    • HTTP/2 ではより多く可能だが、互換性を考慮して 6 に制御
    • 実際のテスト:6 枚同時がパフォーマンスと体験の最適なバランスポイント
  4. Q: バージョン管理にタイムスタンプを使い UUID を使わないのはなぜですか? A:

    • タイムスタンプ:Date.now()(シンプル、十分、ソート可能)
    • UUID:crypto.randomUUID()(より厳密だがオーバーエンジニアリング)
    • このシナリオ:タイムスタンプで十分にユニーク(ミリ秒レベル)
    • パフォーマンス面:タイムスタンプの生成がより高速
  5. Q: 画像読み込み失敗はどう処理しますか? A: 多層フォールバックを実装しました:

    img.onerror = () => {
    if (retryCount < 3) {
    // 1. 3回リトライ
    setTimeout(() => reload(), 1000 * retryCount);
    } else {
    // 2. デフォルト画像を表示
    img.src = '/images/game-placeholder.png';
    }
    };
  6. Q: CLS(累積レイアウトシフト)の問題は発生しませんか? A: 3つの戦略で回避しています:

    <!-- 1. デフォルトプレースホルダー SVG -->
    <img src="data:image/svg+xml..." />

    <!-- 2. CSS aspect-ratio で比率を固定 -->
    <img style="aspect-ratio: 16/9;" />

    <!-- 3. Skeleton Screen -->
    <div class="skeleton-box"></div>

    最終的な CLS スコア:< 0.1(優秀)