【JavaScript】Lazyload(遅延読み込み)完全ガイド|loading属性・IntersectionObserver・iframe対応・LCPへの影響まで解説

Lazyload(遅延読み込み)は、ページを開いた瞬間には表示されていない画像や動画・iframeの読み込みを後回しにすることで、初期表示速度を大幅に改善できる手法です。Largest Contentful Paint(LCP)の改善にも直結し、Core Web Vitals の観点でもSEOに影響します。

かつては外部ライブラリが必要でしたが、現在はHTMLの loading 属性だけでブラウザネイティブの遅延読み込みが実現できます。この記事ではネイティブ実装からJavaScript(IntersectionObserver)を使ったカスタム実装、LCPへの悪影響を避ける設計まで体系的に解説します。

スポンサーリンク

ネイティブ遅延読み込み:loading=”lazy” 属性(JSゼロ)

最もシンプルな方法はHTML属性の loading="lazy" を付けるだけです。JavaScriptは一切不要で、ブラウザが自動的にビューポート外の画像・iframeの読み込みを遅延させます。

loading=”lazy” の基本
<!-- 画像 -->
<img
  src="large-photo.jpg"
  alt="説明テキスト"
  width="800"
  height="600"
  loading="lazy"
>

<!-- iframe(YouTube埋め込みなど) -->
<iframe
  src="https://www.youtube.com/embed/XXXXX"
  width="560"
  height="315"
  loading="lazy"
  allowfullscreen
></iframe>
属性値 動作 用途
loading="lazy" ビューポートに近づいたら読み込む ファーストビュー外の画像・iframe
loading="eager" 即座に読み込む(デフォルト) ファーストビューの重要な画像
loading="auto" ブラウザに委ねる 通常は eager と同じ
width / height を必ず指定する:
loading="lazy" を使うとき、widthheight 属性がないとブラウザが画像の領域を事前に確保できません。画像が読み込まれるたびにレイアウトがずれる CLS(Cumulative Layout Shift) が発生し、Core Web Vitals のスコアを下げます。必ず widthheight(または CSS の aspect-ratio)を指定してください。
ブラウザサポート:
loading="lazy" は Chrome 77+、Firefox 75+、Safari 15.4+、Edge 79+ で対応しており、2024年時点でほぼすべての主要ブラウザで利用できます。未対応ブラウザでは属性が無視されて通常の読み込みになるだけなので、フォールバックを気にせず安全に使えます。

LCP を悪化させないための注意点

最大コンテンツの表示タイミングを測る LCP(Largest Contentful Paint) の対象がヒーロー画像など「ファーストビューに表示される大きな画像」の場合、loading="lazy" を付けると読み込みが遅れて LCP スコアが悪化します。

NG と OK の使い分け
<!-- NG: ファーストビューのメイン画像に lazy を付けると LCP が悪化 -->
<img
  src="hero.jpg"
  alt="ヒーロー画像"
  width="1920"
  height="1080"
  loading="lazy"
>

<!-- OK: ファーストビューのメイン画像は eager(またはデフォルト)+ fetchpriority="high" -->
<img
  src="hero.jpg"
  alt="ヒーロー画像"
  width="1920"
  height="1080"
  loading="eager"
  fetchpriority="high"
>

<!-- OK: スクロールしないと見えない画像には lazy -->
<img
  src="gallery-1.jpg"
  alt="ギャラリー画像"
  width="600"
  height="400"
  loading="lazy"
>
Lazy load の適用判断チェックリスト:

  • ページを開いてすぐ見える(ファーストビュー)画像 → loading="eager" または省略
  • ページ最上部のメインビジュアル(LCP候補) → fetchpriority="high" も追加
  • スクロールしないと見えない画像・iframe → loading="lazy"
  • ギャラリー・カード一覧・フッター画像 → loading="lazy"

IntersectionObserver によるカスタム遅延読み込み

画像だけでなく「コンテンツブロック全体」を遅延レンダリングしたいとき、loading="lazy" では対応できません。JavaScript の IntersectionObserver を使ってカスタムの遅延読み込みを実装します。

data-src でソースを遅延指定する
<!-- src の代わりに data-src に実際の画像URLを書く -->
<!-- src には小さなプレースホルダーか省略 -->
<img
  data-src="large-photo.jpg"
  alt="遅延読み込み画像"
  width="800"
  height="600"
  class="lazy-img"
  src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
>
IntersectionObserver で画像を遅延読み込み
/**
 * IntersectionObserver で data-src を src に差し替えて遅延読み込みする
 */
function initLazyImages() {
  const images = document.querySelectorAll('img[data-src]');

  if (!images.length) return;

  const observer = new IntersectionObserver((entries, obs) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;

      const img = entry.target;
      img.src = img.dataset.src;            // data-src を src に移す
      img.removeAttribute('data-src');      // 処理済みマーク
      img.classList.add('lazy-loaded');     // ロード後のスタイル用

      obs.unobserve(img);                   // 読み込んだら監視解除
    });
  }, {
    rootMargin: '200px 0px',  // ビューポートより200px手前で発火
    threshold:  0,
  });

  images.forEach(img => observer.observe(img));
}

document.addEventListener('DOMContentLoaded', initLazyImages);
フェードインアニメーション
/* 読み込み前はぼかして半透明 */
img[data-src] {
  filter: blur(4px);
  opacity: 0;
  transition: opacity 0.4s ease, filter 0.4s ease;
}

/* 読み込み後にフェードイン */
img.lazy-loaded {
  filter: none;
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  img[data-src],
  img.lazy-loaded {
    transition: none;
  }
}

srcset・picture 要素への対応

レスポンシブ画像(srcset)でも同様に data- 属性を使って遅延読み込みできます。

srcset の遅延読み込み
<!-- data-srcset に実際の値を書く -->
<img
  data-src="photo-800.jpg"
  data-srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
  sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
  alt="レスポンシブ画像"
  width="800"
  height="600"
  class="lazy-img"
  src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
>
srcset も一緒に差し替える
const observer = new IntersectionObserver((entries, obs) => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return;

    const img = entry.target;

    // srcset も差し替える
    if (img.dataset.srcset) {
      img.srcset = img.dataset.srcset;
      img.removeAttribute('data-srcset');
    }
    img.src = img.dataset.src;
    img.removeAttribute('data-src');
    img.classList.add('lazy-loaded');

    obs.unobserve(img);
  });
}, { rootMargin: '200px 0px', threshold: 0 });

document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

コンテンツブロックの遅延レンダリング

画像だけでなく、SNSの埋め込みウィジェットや重いUIブロック全体を「画面に近づいたときに初めて挿入する」方法です。初期HTMLは軽くなり、DOMの構築が速くなります。

HTML(template 要素でコンテンツを保持)
<!-- template 要素の中身はDOMにレンダリングされない -->
<div class="lazy-block" id="heavy-widget">
  <template>
    <!-- ここに重い埋め込みウィジェットや複雑なHTMLを書く -->
    <div class="widget">
      <iframe src="https://example.com/widget" width="300" height="250"></iframe>
    </div>
  </template>
</div>
template 要素を遅延挿入する
function initLazyBlocks() {
  const blocks = document.querySelectorAll('.lazy-block');

  const observer = new IntersectionObserver((entries, obs) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;

      const container = entry.target;
      const template  = container.querySelector('template');

      if (template) {
        // template の中身を複製してDOMに挿入
        container.appendChild(template.content.cloneNode(true));
        template.remove();
      }

      obs.unobserve(container);
    });
  }, { rootMargin: '300px 0px', threshold: 0 });

  blocks.forEach(block => observer.observe(block));
}

document.addEventListener('DOMContentLoaded', initLazyBlocks);
template 要素を使う理由:
<template> 要素の内容はDOMとして解析されますが、レンダリングされず、スクリプトも実行されません。ページ読み込み時のHTMLパースに大きな影響を与えずに「後で挿入するHTML」を準備しておける仕組みです。YouTubeやGoogleマップの埋め込みなど、読み込みコストが高いiframeを遅延挿入するのに最適です。

rootMargin と threshold のチューニング

rootMarginthreshold を調整することで、遅延読み込みのトリガーするタイミングを細かく制御できます。

rootMargin / threshold の意味と設定例
const observer = new IntersectionObserver(callback, {
  // rootMargin: 監視領域をビューポートから拡張する余白(CSSのmarginと同じ形式)
  // "200px 0px" → ビューポートの上下200px外から監視開始
  // 正の値で「早めに発火」、負の値で「中心に入ったら発火」
  rootMargin: '200px 0px',

  // threshold: 対象要素のどれだけが見えたら発火するか(0〜1)
  // 0  → 1pxでも見えたら発火(最も早い)
  // 0.5 → 要素の50%が見えたら発火
  // 1  → 要素全体が見えたら発火
  threshold: 0,
});

/*
 * 用途別チューニング例:
 *
 * 画像の先読み(スクロール前に準備)
 *   rootMargin: '400px 0px',  threshold: 0
 *
 * アニメーション(要素が半分入ったら開始)
 *   rootMargin: '0px',  threshold: 0.5
 *
 * 無限スクロールの次ページ読み込み
 *   rootMargin: '100px 0px',  threshold: 0
 *
 * フッターの到達検知
 *   rootMargin: '0px',  threshold: 1
 */

完成形:LazyLoader クラス

画像・srcset・コンテンツブロックをまとめて処理する汎用クラスです。

LazyLoader クラス
class LazyLoader {
  #observer;

  /**
   * @param {object} opts
   * @param {string} opts.imageSelector  - 遅延画像のセレクタ(デフォルト: 'img[data-src]')
   * @param {string} opts.blockSelector  - 遅延ブロックのセレクタ(デフォルト: '.lazy-block')
   * @param {string} opts.rootMargin     - IntersectionObserver の rootMargin
   */
  constructor({
    imageSelector = 'img[data-src]',
    blockSelector = '.lazy-block',
    rootMargin    = '200px 0px',
  } = {}) {
    this.#observer = new IntersectionObserver(
      (entries, obs) => this.#handle(entries, obs),
      { rootMargin, threshold: 0 }
    );

    document.querySelectorAll(imageSelector)
      .forEach(el => this.#observer.observe(el));
    document.querySelectorAll(blockSelector)
      .forEach(el => this.#observer.observe(el));
  }

  #handle(entries, obs) {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;
      const el = entry.target;

      if (el.tagName === 'IMG') {
        this.#loadImage(el);
      } else {
        this.#loadBlock(el);
      }

      obs.unobserve(el);
    });
  }

  #loadImage(img) {
    if (img.dataset.srcset) {
      img.srcset = img.dataset.srcset;
      img.removeAttribute('data-srcset');
    }
    img.src = img.dataset.src;
    img.removeAttribute('data-src');
    img.classList.add('lazy-loaded');
  }

  #loadBlock(container) {
    const tpl = container.querySelector('template');
    if (tpl) {
      container.appendChild(tpl.content.cloneNode(true));
      tpl.remove();
    }
  }

  /** 動的に追加した要素を追加監視する */
  observe(el) {
    this.#observer.observe(el);
  }

  /** 監視を全て停止する */
  disconnect() {
    this.#observer.disconnect();
  }
}

// 使用例
document.addEventListener('DOMContentLoaded', () => {
  new LazyLoader({ rootMargin: '300px 0px' });
});

よくある質問(FAQ)

Qloading="lazy" と IntersectionObserver のどちらを使えばいいですか?
A画像・iframeの遅延読み込みなら loading="lazy" 属性が最もシンプルで推奨です。ブラウザネイティブの実装なのでパフォーマンスも優れています。フェードインアニメーションの付与、コンテンツブロックの遅延レンダリング、より細かいトリガーのコントロールが必要な場合に IntersectionObserver を使います。
Qヒーロー画像に loading="lazy" を付けたら LCP が悪化しました。
Aファーストビューに表示される画像(特にページ最上部のメインビジュアル)に loading="lazy" を付けると読み込みが遅れて LCP スコアが下がります。ヒーロー画像には loading="eager"(またはデフォルト)に加えて fetchpriority="high" を付けて最優先で読み込むようにしてください。
QJavaScript が無効の環境でも動作しますか?
Aloading="lazy" はHTMLの属性なのでJavaScript不要で動作します。IntersectionObserver を使うカスタム実装では、JavaScript が無効の場合は data-src のまま画像が読み込まれません。<noscript> タグで通常の <img src="..."> を用意しておくフォールバックが安全です。
QCLS(Cumulative Layout Shift)が発生するのを防ぐには?
A<img>widthheight 属性を必ず指定してください。CSSの aspect-ratio プロパティで代替することもできます(例: aspect-ratio: 16 / 9)。画像の領域を事前に確保することで、読み込み完了後のレイアウトずれを防げます。
Qスクロールせずに全画像を読み込ませるには(印刷・スクレイピング対応など)?
Aloading="lazy" を使っている場合、印刷時には window.onbeforeprint イベントで data-src を一括 src に差し替える処理を追加します。IntersectionObserver 版では、フォールバック関数を用意してすべての要素を即座に読み込む loadAll() メソッドを実装しておくのが実用的です。

まとめ

手法 対象 JS不要 アニメーション コンテンツブロック
loading="lazy" img・iframe × ×
IntersectionObserver img・あらゆる要素 ×
実装のポイント 内容
ファーストビュー画像 loading="eager" + fetchpriority="high"
スクロール外の画像・iframe loading="lazy"
CLS対策 widthheight 属性を必ず指定
先読みタイミング rootMargin: "200〜400px 0px"
重いウィジェットの遅延 <template> + IntersectionObserver で遅延挿入

IntersectionObserver の使い方全般はIntersectionObserverでスクロール時に要素が見えると表示が変わる方法で、複数要素への適用についてはIntersectionObserverを使って複数の要素にスクロールイベントを設定する方法もあわせて参照してください。