写真がたくさん並んだ記事一覧ページを開いたとき、最初の表示がもたつくことがあります。原因の多くは、画面に映っていない下のほうの画像まで、ページを開いた瞬間にまとめて読み込んでいることです。これを防ぐのに JavaScript は必要ありません。<img> に loading と decoding という2つの属性を書き足すだけで、読み込みのタイミングを調整できます。この記事では、それぞれの属性の値と使い分け、そして「ファーストビューの画像には付けてはいけない」という実務上いちばん大事な注意点までを解説します。
目次
画像が多いページで何が起きているのか
ブラウザーは HTML を読みながら、<img> を見つけるたびに画像の取得を始めます。既定ではこの取得に「画面に映っているかどうか」は関係ありません。3画面ぶんスクロールしないと見えない写真も、ページを開いた時点で取りにいきます。
サムネイルが30枚並ぶ一覧ページなら、開いた瞬間に30本の通信が走ります。同時に取得できる本数には限りがあるので、まず見せたい上部の画像が、下のほうの画像に順番を譲らされるという状態も起きます。回線が細いスマートフォンでは、この差がそのまま体感速度に出ます。
ここで効いてくるのが loading 属性です。そして画像には、取得のあとに「デコード」という別の処理が控えています。そちらを扱うのが decoding 属性です。2つは名前が似ていますが、対象としている工程がまったく違います。
loading 属性で読み込みを後回しにする
loading は、その画像をいつ取得しにいくかをブラウザーに伝える属性です。書き方は、値を1つ指定するだけです。
<!-- 画面に近づくまで読み込まない -->
<img src="/images/photo.jpg" alt="紅葉した並木道" width="800" height="600" loading="lazy">
<!-- すぐ読み込む(既定値なので、通常は書かなくてもよい) -->
<img src="/images/hero.jpg" alt="店内の様子" width="1200" height="630" loading="eager">
指定できる値は2つだけです。
| 値 | 動作 |
|---|---|
eager | 既定値。画像がビューポート(表示領域)に入っているかどうかにかかわらず、直ちに読み込む |
lazy | ブラウザーが定めた「ビューポートからの距離」に達するまで、読み込みを先延ばしにする |
lazy を指定しておけば、その画像が必要になることがほぼ確実になるまで通信が発生しません。使われない可能性のある画像のためにネットワーク帯域を消費せずに済むので、画面外の画像が多いページほど効果が大きくなります。
ここで押さえておきたいのが、lazy は「画面に入った瞬間に読み込む」わけではないという点です。判定に使われるのはビューポートそのものではなく、ビューポートから一定の距離です。その距離の決め方はブラウザーに任されており、後述するように、かなり手前から先読みが始まります。スクロールしてから慌てて取得するのでは間に合わないからです。
iframe でも同じように使える
loading は <img> 専用ではありません。<iframe> にも同じ2つの値を指定できます。動作の考え方も同じで、eager が既定値、lazy にするとビューポートから計算された距離に達するまで読み込みが延期されます。
<iframe
src="https://www.youtube.com/embed/xxxxxxxxxxx"
title="紹介動画"
width="560"
height="315"
loading="lazy"
allowfullscreen></iframe>
埋め込み動画やマップの <iframe> は、中身が1ページぶんのドキュメントです。画像1枚とは比べものにならない量のスクリプトやスタイルを連れてくることも珍しくありません。記事の下のほうに置いた埋め込みほど、loading="lazy" の恩恵は大きくなります。
なお、decoding 属性のほうは <img> のための属性です。<iframe> に書いても意味がありません。
decoding 属性は「読み込み」ではなくデコードのタイミング
decoding は名前が loading と似ているため、同じく取得のタイミングを指定するものだと誤解されがちです。実際には、まったく別の工程を扱っています。
ブラウザーが画像を画面に出すまでには、大きく分けて2つの段階があります。1つは 取得、つまりサーバーから JPEG や PNG のファイルをダウンロードすること。もう1つが デコードです。ダウンロードしたファイルは圧縮された状態のバイト列にすぎず、そのままでは表示できません。これを画面に描けるピクセルの集まりに展開する処理がデコードで、大きな画像ほど CPU の時間を食います。
loading が前半(取得)のタイミングを指示する属性なのに対して、decoding は後半(デコード)を他の表示処理と一緒にやるか、切り離すかを指示する属性です。値は3つあります。
| 値 | 動作 |
|---|---|
sync | 他の DOM コンテンツの描画と同期してデコードし、すべてを一緒に表示する |
async | 他の DOM コンテンツを描画して表示したあと、非同期にデコードする |
auto | 既定値。指定なし。ブラウザーが最適と判断した方法を選ぶ |
async は、平たく言えば「次の描画は画像のデコード完了を待たなくてよい」という意味です。周りのテキストやレイアウトを先に見せ、画像は準備ができ次第あとから差し込まれます。逆に sync は、画像と周囲がバラバラに現れるのを避けたい場合の指定です。
<img
src="/images/thumb.jpg"
alt="レシピのサムネイル"
width="400"
height="300"
loading="lazy"
decoding="async">
静的な img では効果が見えにくい
正直なところ、最初から HTML に書かれている <img> に decoding を指定しても、目に見える違いを感じ取るのは困難です。画像はファイルを取得している間そもそも空の状態で描画され、そのあと独立して処理されるため、「周囲と同時に出る/出ない」という差が意識されにくいのです。デコード中に描画がブロックされる時間は計測はできるものの、多くの場合ごくわずかです。
違いがはっきり出るのは、JavaScript で <img> を動的に DOM へ挿入するときです。大きな画像を差し込む処理がデコードを待ってしまうと、その間ページ全体の操作が引っかかったように感じられます。こうしたケースでは decoding="async" が効いてきます。
また、挿入のタイミングまで細かく制御したいなら、属性ではなく HTMLImageElement の decode() メソッドという選択肢もあります。デコードが終わったことを Promise で待ってから DOM に追加できるので、「差し込んだ瞬間は空白で、一拍おいて画像が出る」という表示を避けられます。
const img = new Image();
img.src = '/images/large-photo.jpg';
img.alt = '会場の全景';
// デコードが終わってから DOM に追加する
img.decode()
.then(() => {
document.getElementById('gallery').appendChild(img);
})
.catch((error) => {
// 画像の取得やデコードに失敗した場合
console.error(error);
});
静的なページを書いているぶんには、decoding は「付けておいて損はないが、劇的な改善を期待するものではない」くらいの温度感で構いません。表示速度に本当に効くのは、次に説明する loading の使い分けのほうです。
ファーストビューの画像に lazy を付けてはいけない
この記事でいちばん覚えて帰ってほしいのがこれです。ページを開いた時点で画面に入っている画像に loading="lazy" を付けると、表示はむしろ遅くなります。
理由は単純で、lazy を付けた画像は HTML の解析中に即座に取得されず、レイアウトが確定してビューポートとの位置関係が判定されてから読み込みが始まるからです。本来なら真っ先に取りにいけたはずのヒーロー画像が、一手遅れてスタートすることになります。
これが直撃するのが LCP(Largest Contentful Paint)です。LCP は「ページで最も大きな要素が表示されるまでの時間」を測る指標で、ファーストビューの大きな画像はまさにその対象になりやすい要素です。そこに lazy を付けると、指標の数字がそのまま悪化します。
ついでに知っておくと役立つのが、遅延読み込みされた画像は load イベントの発生時点でまだ表示されていないことがあるという挙動です。load イベントは即時読み込みされた画像を基準に発生するため、ページを開いた時点でビューポート内にあった画像でも、lazy が付いていればカウントに入りません。「読み込み完了後に何か処理をする」コードが、画像の表示前に走ってしまう可能性があるということです。
<!-- ファーストビューのメイン画像:lazy は付けない -->
<img src="/images/hero.jpg" alt="トップページのメインビジュアル" width="1600" height="900" fetchpriority="high">
<!-- スクロールしないと見えない画像:lazy を付ける -->
<img src="/images/section-2.jpg" alt="サービスの流れ" width="800" height="600" loading="lazy" decoding="async">
上のコードで使っている fetchpriority="high" は、この画像を他より優先して取得してほしい、とブラウザーに伝えるヒントです。ファーストビューの主役となる画像には lazy を付けないだけでなく、こちらを検討する価値があります(詳しくは fetchpriority 属性の記事を参照してください)。
運用としては、「一覧やテンプレートの先頭の1〜2枚は lazy を外す」というルールを決めておくのが現実的です。CMS のループで全画像に一律 loading="lazy" を出力してしまう構成は、この落とし穴にはまりやすいので気をつけてください。
width と height をセットで指定する
遅延読み込みと必ずセットで考えたいのが、レイアウトシフト(CLS)の対策です。CLS は、読んでいる最中に文章やボタンが突然ずれる現象のことで、押そうとしたリンクが動いて誤タップする、といった不快な体験につながります。
画像の width と height を書いていないと、ブラウザーは読み込みが終わるまでその画像が何ピクセルの場所を占めるのか分かりません。結果、最初は高さ0の隙間として扱われ、画像が届いた瞬間に下のコンテンツが押し下げられます。
そして loading="lazy" を使うと、この問題はさらに厄介になります。読み込まれていない画像は width と height が0として扱われるため、寸法を書いていない遅延読み込み画像は、たとえ読み込まれれば表示領域に重なるはずだったとしても、重なっていないと判定されて読み込みが始まらない、という状況が起こり得ます。読んでいる途中でコンテンツが再配置されれば、使い勝手への影響は即時読み込みの場合よりも大きくなります。
対処は簡単で、元画像の実寸を width と height 属性に書くだけです。CSS で表示幅を変える場合も、属性は消さずに残します。
<!-- 元画像が 800 × 600 px なら、その数値をそのまま書く -->
<img
src="/images/photo.jpg"
alt="海沿いのカフェ"
width="800"
height="600"
loading="lazy"
decoding="async">
/* 幅は親に合わせ、高さは比率にまかせる */
img {
max-width: 100%;
height: auto;
}
height: auto を添えるのがポイントです。width と height 属性が書かれていれば、ブラウザーはその比率から表示前でも必要な高さを計算できます。CSS で幅を縮めても、比率を保ったぶんの高さがあらかじめ確保されるので、画像が届いてもレイアウトは動きません。height: auto を書かずに width: 100% だけを当てると、属性の高さがそのまま効いて画像が縦に潰れるので注意してください。
実際の場面での書き方
記事一覧のサムネイル
もっとも効果が分かりやすいのが、同じ形のカードが縦に積み上がる一覧ページです。先頭の数枚だけ即時読み込みにして、残りを lazy にします。
<ul class="post-list">
<li>
<a href="/posts/1">
<!-- 1件目は画面に入っているので lazy を付けない -->
<img src="/thumb/1.jpg" alt="" width="400" height="225" decoding="async">
<span>最初の記事のタイトル</span>
</a>
</li>
<li>
<a href="/posts/2">
<!-- 2件目以降はスクロールしてから見えるので lazy -->
<img src="/thumb/2.jpg" alt="" width="400" height="225" loading="lazy" decoding="async">
<span>2番目の記事のタイトル</span>
</a>
</li>
</ul>
サムネイルの alt が空になっているのは、すぐ隣の <span> に同じ内容のタイトルが書かれているためです。リンクのテキストで用が足りているときに画像の alt にも同じ文言を入れると、読み上げが二重になります。
何件目までを即時読み込みにするかは、カードの大きさと画面幅次第です。テンプレート側でループのインデックスを見て、最初の1行ぶんだけ loading を出力しない、といった分岐にしておくと管理しやすくなります。
picture や srcset と組み合わせる
レスポンシブ画像と併用するときに迷いやすいのが、属性をどこに書くかです。答えははっきりしていて、loading と decoding は <img> に書きます。<source> に書いても意味はありません。
<picture>
<source srcset="/images/photo.webp" type="image/webp">
<source srcset="/images/photo.jpg" type="image/jpeg">
<!-- 属性は img に書く -->
<img
src="/images/photo.jpg"
alt="工房で作業する職人"
width="800"
height="600"
loading="lazy"
decoding="async">
</picture>
<picture> はあくまで候補を並べる入れ物で、実際に表示を担当するのは中の <img> です。alt や width / height と同じく、読み込みに関する属性も <img> に集約すると覚えておけば迷いません。
srcset と sizes で解像度を出し分ける場合も同様です。width と height には、比率の基準として使われる代表的なサイズ(多くは src に指定した画像の実寸)を書いておきます。
<img
src="/images/photo-800.jpg"
srcset="/images/photo-400.jpg 400w,
/images/photo-800.jpg 800w,
/images/photo-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 800px"
alt="展示会の会場"
width="800"
height="600"
loading="lazy"
decoding="async">
埋め込み動画やマップ
記事の途中に置く YouTube や Google マップの <iframe> は、loading="lazy" の効果がとくに大きい部分です。画像1枚ぶんではなく、フレームの中のページが丸ごと後回しになるからです。
<div class="embed">
<iframe
src="https://www.youtube.com/embed/xxxxxxxxxxx"
title="作り方の解説動画"
width="560"
height="315"
loading="lazy"
allow="accelerometer; clipboard-write; encrypted-media; picture-in-picture"
allowfullscreen></iframe>
</div>
/* 読み込み前でも 16:9 の場所を確保しておく */
.embed {
aspect-ratio: 16 / 9;
}
.embed iframe {
width: 100%;
height: 100%;
border: 0;
}
画像と同じで、<iframe> も読み込み前の場所を確保しておかないとレイアウトがずれます。aspect-ratio で比率を固定しておけば、フレームが現れても周囲が動きません。
遅延読み込みが効いていないように見えるとき
loading="lazy" を書いたのに、開発者ツールの Network タブを見たら画像が全部読み込まれていた。よくある相談ですが、その多くは不具合ではありません。
ブラウザーは画面のかなり手前から読み始める
lazy の判定基準は「ビューポートに入ったかどうか」ではなく、ビューポートから計算された一定の距離に達したかどうかです。そして、この距離はかなり余裕をもって設定されています。スクロールして視界に入ってから取得を始めたのでは、空白を見せてしまうからです。
しきい値の具体的な値は仕様で決められておらず、ブラウザーの実装に委ねられています。回線の速さによって距離を変える実装もあります。つまり、速い回線のデスクトップで検証すると「ほぼ全部先読みされる」という結果になりやすいのです。効果を確かめたいときは、開発者ツールでネットワークを低速に絞り、ページも十分な長さがある状態で試してください。
そもそも画面内に収まっている
基本的な見落としですが、画像が数枚しかない短いページや、大きなモニターで開いたときにすべて画面に収まってしまうレイアウトでは、lazy を付けても全部読み込まれるのが正しい動作です。遅延読み込みは「スクロールしないと見えない要素がある」ことが前提の仕組みです。
display: none で隠している要素
タブやアコーディオンの中身を display: none で隠している場合、その要素には表示領域がありません。遅延読み込みの判定はビューポートとの位置関係で行われるため、隠れている間は読み込みが始まらないことがあります。タブを切り替えた瞬間に取得が走り、一瞬だけ画像が空白になる、という見え方になりがちです。
開いた直後から確実に見せたい画像であれば、そこだけ lazy を外す、あるいは切り替え処理の中で先に読み込んでおく、といった対応を検討してください。逆に、開かれないかもしれないタブの画像を読み込まずに済ませられるのは lazy の利点でもあるので、どちらを優先するかはコンテンツ次第です。
JavaScript が無効なときは遅延されない
意外に知られていない仕様として、読み込みが延期されるのは JavaScript が有効なときだけというものがあります。属性だけで完結する機能なのに、なぜ JavaScript の有無が関係するのか不思議に思うかもしれません。
これはトラッキング対策です。スクリプトを切っている利用者に対しても遅延読み込みが働くと、ページ内に画像を戦略的に配置しておくだけで、サーバー側が「いつ、何枚目の画像が要求されたか」を観測できてしまいます。スクリプトを無効にしている人ほどこうした追跡を避けたいはずなのに、それが可能になっては本末転倒です。そのため、スクリプトが無効な環境では画像がすべて即時読み込みされます。<iframe> の loading も同じ理由で同じ扱いです。
IntersectionObserver で自前実装すべきか
loading="lazy" が登場する前は、IntersectionObserver で画面への出入りを監視し、近づいたら data-src の値を src に移す、という実装が一般的でした。今もライブラリーの中にはこの方式のものがあります。
ただ、単に画像を遅延読み込みしたいだけなら属性で十分です。スクリプトを読み込む必要がなく、ブラウザー自身が回線状況を見ながらしきい値を調整してくれるぶん、素直に速く動きます。自前実装が必要になるのは、読み込み開始の距離を自分で決めたい、フェードインなどの演出を組み合わせたい、スクロール位置に応じて別の処理も走らせたい、といった読み込み以外の要求がある場合です。
なお、両方を同時に使うと、どちらのタイミングで読み込まれているのか分からなくなります。既存のライブラリーを外して属性に一本化するか、ライブラリーを使い続けるか、どちらかに寄せるのが無難です。
まとめ
loading は画像や <iframe> をいつ取得するかを指示する属性で、値は eager(既定値・すぐ読み込む)と lazy(ビューポートから一定の距離に達するまで延期する)の2つです。decoding は取得ではなくデコード、つまり圧縮された画像データを描画用に展開する処理のタイミングを指示する属性で、sync / async / auto(既定値)から選びます。名前は似ていますが、担当している工程が違います。
実務でいちばん重要なのは、ファーストビューの画像に loading="lazy" を付けないことです。開いた時点で見えている画像に付けると取得の開始が遅れ、LCP が悪化します。主役となる画像はむしろ fetchpriority="high" を検討する場面です。そして、すべての画像に width と height を書き、CSS で height: auto を添えてレイアウトシフトを防ぐところまでをセットで行ってください。
「効いていないように見える」ときは、まず落ち着いて条件を確認しましょう。ブラウザーは画面のかなり手前から先読みしますし、ページが短ければ全部読まれて当然です。JavaScript が無効な環境では、トラッキング対策として遅延そのものが行われません。仕組みを理解したうえで使えば、属性を2つ足すだけの手軽さで、画像の多いページの体感速度を確実に改善できます。