動画と音声の遅延読み込み

メディア要素(<video> と <audio>)とそれに関連するリソース(<video poster> 画像やプリロードされたメタデータなど)は、最初のページ読み込み時に大量のネットワーク帯域幅とストレージ帯域幅を消費します。<video> で preload="metadata" または preload="none" が設定されている場合でも、ブラウザは poster 画像をすぐに取得し、preload="metadata" はページ上のすべてのプレーヤーでメディア ヘッダーの範囲リクエストを開始します。

オフスクリーンの <video> 要素と <audio> 要素に loading="lazy" を追加すると、要素がビジュアル ビューポートからブラウザで計算された距離に到達するまで、poster 画像のダウンロード、preload ヒント、autoplay の実行など、すべてのメディア ネットワーク リクエストを遅らせるようブラウザに指示します。

導入方法

  1. 画面外のメディアに loading="lazy" を追加する: スクロールしなければ見えない範囲に配置されている <video> 要素と <audio> 要素に loading="lazy" を適用します。スクロールせずに見える範囲のメディアでは loading 属性を省略して、デフォルトの eager にします。
  2. <video> 要素に明示的な width と height を設定する: <video> には、常に整数の width 属性と height 属性(CSS ピクセル単位)を指定します。読み込まれていない動画のデフォルトのサイズは 300×150 です。サイズを明示的に指定しないと、遅延読み込みされた動画が表示可能なビューポートと交差せず、読み込まれないことがあります。また、読み込まれたときにレイアウトが大きくずれる(CLS)原因にもなります。
  3. <audio> 要素に controls 属性を含める: <audio loading="lazy"> には必ず controls を含めます。<audio> 要素には、ブラウザ コントロールのない固有のビジュアル ボックスがないため、controls のない <audio loading="lazy"> 要素はビューポートと視覚的に交差せず、読み込みや自動再生は行われません。
  4. preload 属性と poster 属性を調整する:
    • <video loading="lazy"> では、poster 画像の取得と preload の動作(none、metadata、auto)の両方が、動画がビューポートに近づくまで延期されます。
    • ユーザーがメディアまでスクロールした後に明示的に再生ボタンを押す必要がある場合は、loading="lazy" と preload="none" をペア設定します。プレーヤーがビューポートに入ると、再生時間と最初のフレームデータが自動的に入力されるようにするには、preload="metadata" をペア設定します。
  5. 遅延 autoplay を安全に処理する: loading="lazy" を、ミュートされた <video>(またはオプトイン <audio controls>)の autoplay と組み合わせると、要素がビューポートに近づくまで再生とダウンロードが待機されます。モーション メディアを自動再生する前に、必ず @media (prefers-reduced-motion: reduce) を尊重してください。

コード例

<!-- Above-the-fold hero video: DO NOT use loading="lazy".
     Omit loading (defaults to "eager") so the poster and initial media load immediately. -->
<video
  controls
  width="1280"
  height="720"
  poster="/images/hero-poster.jpg"
  preload="metadata"
>
  <source src="/media/keynote.webm" type="video/webm">
  <source src="/media/keynote.mp4" type="video/mp4">
  <track src="/media/keynote-en.vtt" kind="captions" srclang="en" label="English">
</video>

<!-- Below-the-fold video player:
     MANDATORY: Specify integer width and height attributes so the unloaded video
     has a non-zero layout box to intersect the viewport and avoids layout shift.
     Both the poster image and preload="metadata" are deferred until near the viewport. -->
<video
  controls
  loading="lazy"
  width="800"
  height="450"
  poster="/images/tutorial-poster.jpg"
  preload="metadata"
>
  <source src="/media/tutorial.webm" type="video/webm">
  <source src="/media/tutorial.mp4" type="video/mp4">
  <track src="/media/tutorial-en.vtt" kind="captions" srclang="en" label="English">
</video>

<!-- Below-the-fold inline product loop:
     With loading="lazy" and autoplay, downloading and playback wait until the
     video approaches the viewport. Include muted and playsinline for autoplay. -->
<video
  controls
  loading="lazy"
  autoplay
  loop
  muted
  playsinline
  width="640"
  height="360"
  poster="/images/feature-loop-poster.jpg"
>
  <source src="/media/feature-loop.webm" type="video/webm">
  <source src="/media/feature-loop.mp4" type="video/mp4">
  <track src="/media/feature-loop-en.vtt" kind="captions" srclang="en" label="English">
</video>

<!-- Below-the-fold audio player:
     MANDATORY: Include the controls attribute. Lazy-loaded <audio> without
     controls is invisible, cannot intersect the viewport, and will never load. -->
<figure>
  <figcaption id="episode-title">Episode 42: Browser Architecture Deep Dive</figcaption>
  <audio
    controls
    loading="lazy"
    preload="metadata"
    src="/media/episode-42.mp3"
    aria-labelledby="episode-title"
    aria-details="episode-transcript"
  ></audio>
  <details id="episode-transcript">
    <summary>View transcript</summary>
    <p>Transcript content for Episode 42...</p>
  </details>
</figure>
// Pause autoplaying videos when the user prefers reduced motion
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');

function syncReducedMotion(prefersReducedMotion) {
  for (const video of document.querySelectorAll('video[autoplay]')) {
    if (prefersReducedMotion) {
      video.removeAttribute('autoplay');
      video.pause();
    }
  }
}

syncReducedMotion(reducedMotionQuery.matches);
reducedMotionQuery.addEventListener('change', (event) => {
  syncReducedMotion(event.matches);
});

ベスト プラクティス

  • 必須: <video> 要素には、常に明示的な width 属性と height 属性を設定します。読み込まれていない動画の固有サイズは 300×150 で、ページの表示部分と交差しない場合は読み込まれません。
  • 必須: <audio loading="lazy"> 要素には必ず controls 属性を含めます。controls のない遅延読み込みの <audio> 要素は、表示されるようにレンダリングされず、読み込みや自動再生は行われません。
  • loading="lazy" は、スクロールせずに見える範囲の要素や Largest Contentful Paint(LCP)の <video> 要素には適用しないでください。poster の画像データと動画データの両方の取得が遅れるためです。
  • loading="eager" を明示的に記述するのではなく、デフォルトの積極的読み込みが必要な場合は、loading 属性を省略します。
  • 遅延読み込みされた <video> 要素または <audio> 要素が読み込まれたタイミングを検出するために、window load イベントに依存しないでください。load イベントは、loading="lazy" メディア要素が最初にビジュアル ビューポート内にある場合でも、eager-loaded メディアのみに基づいて発生します。
  • 推奨: ブラウザは、JavaScript が有効になっている場合にのみ loading="lazy" メディアを遅延読み込みします(スクリプトが無効になっている場合に、サーバーがメディア リクエストを介してスクロール位置を推測できないようにするためのトラッキング防止対策)。

フォールバック戦略

Baseline ターゲットが <video> と <audio> で loading="lazy" をサポートしていない場合は、帯域幅をどの程度厳密に節約する必要があるかに応じて、プログレッシブ エンハンスメントと IntersectionObserver 代替のいずれかを選択します。

<video> と <audio> で loading="lazy" を認識しないブラウザは、この属性を無視し、preload 属性に則って管理される標準の読み込みにフォールバックします。loading="lazy" を preload="none" または preload="metadata" と組み合わせると、サポートされていないブラウザでも、メディア ファイル全体を事前にダウンロードすることを回避できます。ただし、<video poster> 画像と preload="metadata" ヘッダーは積極的に取得されます。

カスタムコードのフォールバック(IntersectionObserver)

Baseline ターゲットで、<video poster> のダウンロード、preload="metadata" リクエスト、またはネイティブ サポートのないブラウザでのビューポート トリガー再生を厳密に遅延させる必要がある場合は、Object.hasOwn(HTMLMediaElement.prototype, 'loading') の機能を検出して、IntersectionObserver にフォールバックします。

// Feature-detect native media lazy loading on HTMLMediaElement.prototype
const supportsLazyMedia = Object.hasOwn(HTMLMediaElement.prototype, 'loading');

if (!supportsLazyMedia) {
  const lazyMediaElements = document.querySelectorAll(
    'video[loading="lazy"], audio[loading="lazy"][controls]',
  );

  // Example-only rootMargin value; adjust distance threshold for your layout
  const mediaObserver = new IntersectionObserver(
    (entries, observer) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;

        const media = entry.target;

        // Restore deferred poster and preload attributes when entering the viewport
        if (media.dataset.poster) {
          media.poster = media.dataset.poster;
          delete media.dataset.poster;
        }

        if (media.dataset.preload) {
          media.preload = media.dataset.preload;
          delete media.dataset.preload;
        }

        if (media.dataset.src) {
          media.src = media.dataset.src;
          delete media.dataset.src;
        }

        for (const source of media.querySelectorAll('source[data-src]')) {
          source.src = source.dataset.src;
          delete source.dataset.src;
        }

        media.load();
        observer.unobserve(media);
      }
    },
    { rootMargin: '200px 0px' },
  );

  for (const media of lazyMediaElements) {
    // Hold back network fetches in unsupported browsers until intersection
    if (media.hasAttribute('poster')) {
      media.dataset.poster = media.getAttribute('poster');
      media.removeAttribute('poster');
    }
    if (media.hasAttribute('preload') && media.getAttribute('preload') !== 'none') {
      media.dataset.preload = media.getAttribute('preload');
    }
    media.preload = 'none';
    mediaObserver.observe(media);
  }
}