[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)
- 可視範囲内の画像のみ読み込む
- ビューポートに入りそうな画像を先読み(50px 手前から読み込み開始)
- 同時読み込み数を制御(一度に大量の画像を読み込むのを防止)
- 高速切り替えによるリソースの無駄を防止
- ファーストビューの画像転送量 < 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%(誤った画像表示はほぼなし)
面接のポイント
よくある追加質問:
-
Q: なぜ
loading="lazy"属性を直接使わないのですか? A: ネイティブのloading="lazy"にはいくつかの制限があります:- 先読み距離を制御できない(ブラウザが決定)
- 同時読み込み数を制御できない
- バージョン管理に対応できない(高速切り替え問題)
- 古いブラウザは非対応
カスタムディレクティブはより精細な制御を提供し、複雑なシナリオに適しています。
-
Q: IntersectionObserver は scroll イベントと比べて何が優れていますか? A:
// ❌ 従来の scroll イベントwindow.addEventListener('scroll', () => {// スクロールのたびにトリガー(60回/秒)// 要素位置の計算が必要(getBoundingClientRect)// 強制 reflow を引き起こす可能性(パフォーマンスキラー)});// ✅ IntersectionObserverconst observer = new IntersectionObserver(callback);// 要素がビューポートに出入りする時のみトリガー// ブラウザネイティブ最適化、メインスレッドをブロックしない// パフォーマンス 80% 向上 -
Q: 同時実行制御の 6 枚の制限はどこから来ていますか? A: ブラウザの HTTP/1.1 同一オリジン同時接続制限 に基づいています:
- Chrome/Firefox:ドメインあたり最大 6 つの同時接続
- 制限を超えるリクエストはキューで待機
- HTTP/2 ではより多く可能だが、互換性を考慮して 6 に制御
- 実際のテスト:6 枚同時がパフォーマンスと体験の最適なバランスポイント
-
Q: バージョン管理にタイムスタンプを使い UUID を使わないのはなぜですか? A:
- タイムスタンプ:
Date.now()(シンプル、十分、ソート可能) - UUID:
crypto.randomUUID()(より厳密だがオーバーエンジニアリング) - このシナリオ:タイムスタンプで十分にユニーク(ミリ秒レベル)
- パフォーマンス面:タイムスタンプの生成がより高速
- タイムスタンプ:
-
Q: 画像読み込み失敗はどう処理しますか? A: 多層フォールバックを実装しました:
img.onerror = () => {if (retryCount < 3) {// 1. 3回リトライsetTimeout(() => reload(), 1000 * retryCount);} else {// 2. デフォルト画像を表示img.src = '/images/game-placeholder.png';}}; -
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(優秀)