こんにちは、かつコーチです。
前回は、ドロップダウンメニューの実装を解説しました。
「実践UI実装」シリーズの締めくくりとして、今回は画像の遅延読み込み(Lazy Load)を扱います。
「画像がたくさんあるページを開くと、表示が遅い」という悩みを抱えているサイトは多いです。
今日は、なぜ画像がページ表示を遅くするのか、そしてJavaScriptとブラウザ標準機能、それぞれでの解決方法を見ていきます。
Lazy Loadとは?
なぜ画像がページを重くするのか
ブラウザは、HTMLを読み込むと同時に<img>タグに書かれた画像もすぐにダウンロードしようとします。
ページの最初に表示される部分だけでなく、スクロールしないと見えない、はるか下の画像まで全部最初にまとめて読み込んでしまうのが、標準の挙動です。
商品一覧ページのように画像を50枚、100枚と並べているサイトでは、最初のページ表示だけで大量の通信が発生し、表示速度が大きく落ちてしまいます。
Lazy Loadの考え方
Lazy Load(遅延読み込み)とは、画像が実際に画面に表示される直前になってから読み込みを開始する手法です。
「今すぐ見えていない画像は、まだ読み込まなくていい」という考え方に基づいており、初期表示に必要な通信量を大幅に減らせます。
前回までの記事で扱ったIntersection Observerが、まさにこの「画面に入ったかどうか」を判定するのに使われます。
実現方法は2つある
ブラウザ標準のloading=”lazy”
実は、最も手軽な方法はHTMLの属性を1つ追加するだけです。
<img src="photo.jpg" alt="商品写真" loading="lazy">
loading="lazy"を指定するだけで、モダンブラウザは自動的に「画面に近づいたら読み込む」という制御をしてくれます。
対応しているブラウザであれば、JavaScriptを1行も書かずにLazy Loadが実現できます。
JavaScript(Intersection Observer)で実装する理由
「それならloading="lazy"だけで十分では?」と思うかもしれませんが、JavaScriptで自前実装するのには次のような理由があります。
| 観点 | loading="lazy" | JavaScript実装 |
|---|---|---|
| 実装の手軽さ | 属性1つで完結 | 一定のコード量が必要 |
| 読み込み開始タイミングの調整 | ブラウザ任せで細かく調整できない | rootMarginで自由に調整できる |
| 読み込み完了時の演出(フェードインなど) | 難しい | 自由に実装できる |
背景画像(CSSのbackground-image)への対応 | 非対応 | 対応できる |
「画像が読み込まれたらふわっとフェードインさせたい」「背景画像もLazy Loadしたい」といった要件がある場合は、JavaScriptでの実装が必要になります。
この記事では、実務でも需要の多いJavaScript実装をメインに解説します。
JavaScriptでの実装
手順1:HTMLでは本物のsrcをdata属性に退避させる
Lazy Loadの基本テクニックは、本来の画像URLをsrcではなくdata-srcのようなカスタム属性に入れておくことです。
<div class="gallery">
<img
class="lazy-image"
src="placeholder.jpg"
data-src="photo1.jpg"
alt="商品写真1"
>
<img
class="lazy-image"
src="placeholder.jpg"
data-src="photo2.jpg"
alt="商品写真2"
>
<img
class="lazy-image"
src="placeholder.jpg"
data-src="photo3.jpg"
alt="商品写真3"
>
</div>
srcには軽量な仮画像(プレースホルダー)を指定しておくことで、ブラウザが本物の画像を勝手に読み込んでしまうのを防いでいます。
手順2:CSSでフェードイン用の見た目を用意する
.gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.lazy-image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
background: #eee;
opacity: 0;
transition: opacity 0.4s ease;
}
/* 読み込み完了後に付与するクラス */
.lazy-image.is-loaded {
opacity: 1;
}
手順3:JavaScriptでIntersection Observerを使って読み込む
const lazyImages = document.querySelectorAll('.lazy-image');
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
// data-srcの値を本物のsrcに差し替える=ここで初めて画像が読み込まれる
img.src = img.dataset.src;
// 画像の読み込みが完了したらフェードインさせる
img.addEventListener('load', () => {
img.classList.add('is-loaded');
});
imageObserver.unobserve(img); // 一度読み込んだら監視終了
}
});
}, {
rootMargin: '200px 0px', // 画面に入る200px手前から読み込みを開始する
});
lazyImages.forEach((img) => {
imageObserver.observe(img);
});
img.dataset.srcは、data-src="photo1.jpg"のように書かれた属性の値を取得するプロパティです。
これをimg.srcに代入した瞬間、ブラウザは初めて本物の画像のダウンロードを開始します。
rootMargin: '200px 0px'を指定しているのは、「画像がちょうど画面に入った瞬間に読み込み始める」と、通信に少し時間がかかる分、ユーザーには一瞬プレースホルダーのままの画像が見えてしまうためです。
画面に入る少し手前(ここでは200px手前)から読み込みを開始しておくことで、実際に画像が視界に入るころには読み込みが完了している、という体験に近づけられます。
つまずきポイント:画像の高さが決まっていないとガクガクする
レイアウトシフトで実際に困った話
私が最初にLazy Loadを実装したとき、img要素に高さの指定を一切していませんでした。
❌ Before:画像の縦横比・サイズを指定しない
.lazy-image {
width: 100%;
opacity: 0;
transition: opacity 0.4s ease;
}
読み込み前のimg要素には実質的な高さがなく、画像が読み込まれた瞬間に画像の高さ分だけレイアウトが一気に下にずれる、という現象が起きました。
スクロールしながらページを見ていると、画像が読み込まれるたびにコンテンツがガクッと動いてしまい、ユーザーからすると「今読んでいた場所を見失う」というかなりのストレスになります。
これはCLS(Cumulative Layout Shift)と呼ばれる、Webページの品質を測る指標の1つを悪化させる典型的な原因でもあります。
✅ After:aspect-ratioで事前に領域を確保する
.lazy-image {
width: 100%;
aspect-ratio: 4 / 3; /* 画像が読み込まれる前から領域を確保しておく */
object-fit: cover;
background: #eee; /* 読み込み前は背景色で埋めておく */
opacity: 0;
transition: opacity 0.4s ease;
}
aspect-ratioプロパティで、画像本来の縦横比をあらかじめCSSに指定しておくことで、画像が読み込まれる前から、その画像が入るべき領域が確保されます。
これにより、画像が読み込まれてもレイアウトが動かなくなり、スクロール中の「ガクッ」が解消されました。
Lazy Loadを実装するときは、「読み込み後の見た目」だけでなく「読み込み前の領域確保」まで含めて設計することが重要だと、この経験から学びました。
応用:背景画像(background-image)のLazy Load
CSSの背景画像にも同じ考え方を応用する
<img>タグだけでなく、CSSのbackground-imageで表示している画像にも同じ仕組みを応用できます。
<div class="hero-banner" data-bg="banner-large.jpg"></div>
.hero-banner {
width: 100%;
height: 400px;
background-color: #eee;
background-size: cover;
background-position: center;
transition: opacity 0.4s ease;
opacity: 0;
}
.hero-banner.is-loaded {
opacity: 1;
}
const bgElements = document.querySelectorAll('[data-bg]');
const bgObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target;
const imageUrl = el.dataset.bg;
// 画像を裏側で先読みしてから背景に適用する
const preloadImg = new Image();
preloadImg.src = imageUrl;
preloadImg.addEventListener('load', () => {
el.style.backgroundImage = `url(${imageUrl})`;
el.classList.add('is-loaded');
});
bgObserver.unobserve(el);
}
});
}, { rootMargin: '200px 0px' });
bgElements.forEach((el) => bgObserver.observe(el));
背景画像は<img>タグと違ってloadイベントを直接持てないため、new Image()で画像を裏側で先読みし、その読み込みが完了してからbackground-imageに反映する、という一手間を加えています。
この一手間によって、「背景として画像がいきなり中途半端な状態で表示される」ことを防いでいます。
まとめ
この記事のポイント
- Lazy Loadは、画面に入る直前まで画像の読み込みを遅らせ、初期表示の通信量を減らす手法である
- 手軽に実装するなら
loading="lazy"属性、細かい制御をしたいならIntersection Observerでの自前実装が向いている data-srcに本物のURLを退避させ、画面に入ったタイミングでsrcに差し替えるのが基本パターン- 画像の高さを
aspect-ratioで事前に確保しないと、読み込み時にレイアウトがガクガク動く原因になる background-imageのLazy Loadは、new Image()での先読みと組み合わせて実装する
次に読むべき記事
次回は、カウントアップアニメーションの実装を解説します。
→ 次の記事:カウントアップアニメーションを実装する