🏞️

画像、動画を任意のタイミングでロードする【<source>対応】

に公開

画像の遅延読み込みといえば loading="lazy" ですが、これは画像はビューポートに近づいた時にトリガーされるので、スクロールが発生しない領域やfixed領域に大量に画像があって、それをjsでスライドショー的に見せるケースなどでは意味を成しません。

ここでは、リソースの待機でロード完了が遅れるのを避け、それらをページ表示後に非同期で取得する方法を取り上げます。

なお、非同期処理のノウハウは先人の記事を参照させていただきました。
それに加え今回は、<source>対応などもう少し深めた内容になっています。

<picture>
  <source type="image/avif" data-srcset="/path-to.avif" />
  <source type="image/webp" data-srcset="/path-to.webp" />
  <img data-src="/path-to.png" />
</picture>

<video loop muted autoplay playsinline class="lazy-load-video">
  <source data-src="/path-to.webm" type="video/webm" />
  <source data-src="/path-to.mp4" type="video/mp4" />
</video>
function loadAsyncMedia() {
  let supportResults;
  checkMediaSupport()
    .then((result) => (supportResults = result))
    .catch((error) => console.error("Error checking media support:", error));

  document.addEventListener("DOMContentLoaded", function () {
    const lazyLoadImages = document.querySelectorAll(
      ".lazy-load img[data-src]"
    );
    const lazyLoadSources = document.querySelectorAll(
      ".lazy-load source[data-srcset], .lazy-load-video source[data-src]"
    );
    const lazyLoadVideos = document.querySelectorAll(".lazy-load-video");

    const loadImage = (element) => {
      return new Promise((resolve, reject) => {
        if (element.tagName === "IMG") {
          const img = new Image();
          img.onload = () => {
            resolve();
          };
          img.onerror = reject;
          img.src = element.dataset.src;
          if (element.dataset.srcset) {
            element.srcset = element.dataset.srcset;
          }
        } else if (element.tagName === "SOURCE") {
          if (element.dataset.srcset) {
            element.srcset = element.dataset.srcset;
          }
          if (element.dataset.src) {
            element.src = element.dataset.src;
          }
          resolve();
        }
      });
    };

    const loadVideo = (videoElement) => {
      return new Promise((resolve, reject) => {
        const sources = videoElement.querySelectorAll("source[data-src]");
        sources.forEach((source) => {
          if (source.dataset.src) {
            source.src = source.dataset.src;
          }
        });

        videoElement.load();
        if (videoElement.hasAttribute("autoplay")) {
          videoElement
            .play()
            .catch((e) =>
              console.warn("Autoplay prevented for video:", videoElement, e)
            );
        }
        resolve();
      });
    };

    let loadQueue = [];

    if (supportResults) {
      if (!supportResults.avif && !supportResults.webp) {
        lazyLoadImages.forEach((img) => {
          //NOTE: webpとavifに対応していない時のみpngを読み込み
          loadQueue.push(() => loadImage(img));
        });
      }
    }

    lazyLoadSources.forEach((source) => {
      if (!source.closest(".lazy-load-video")) {
        loadQueue.push(() => loadImage(source));
      }
    });

    lazyLoadVideos.forEach((video) => {
      loadQueue.push(() => loadVideo(video));
    });

    async function processLoadQueue() {
      for (const loadFunction of loadQueue) {
        try {
          await loadFunction();
        } catch (error) {
          console.error("Error loading media:", error);
        }
      }
    }

    if (document.readyState === "complete") {
      processLoadQueue();
    } else {
      window.addEventListener("load", processLoadQueue);
    }
  });
}
async function checkMediaSupport() {
  const mediaTypes = {
    webp: "/path-to-test.webp",
    avif: "/path-to-test.avif",
    webm: "/path-to-test.webm",
  };

  const supportResults = {};

  await Promise.all(
    Object.keys(mediaTypes).map(async (type) => {
      try {
        const response = await fetch(mediaTypes[type], {
          method: "HEAD",
        });
        supportResults[type] = response.ok;
      } catch (error) {
        supportResults[type] = false;
      }
    })
  );

  return supportResults;
}

基本的な流れとしては、

<img>は初期状態でsrc=""とすることでDOMだけ確保

ページ読み込み完了

data-srcを元にImageコンストラクターでリソースを非同期取得

DOMに適用

という順序で、画像取得がページの表示をブロックしないというのが肝です。

逆にいえば画像の読み込みを待たずにロードが完了するので、最初の方に表示させたい要素など、非同期処理では不都合が生じる画像については素直にHTMLで読み込む方がよいと思います。
今回筆者が作成していた画像ギャラリーでは終盤の画像を非同期で取得するという使い方をしています。


なお今回は<source>対応を擬似的に実現しています。

<source>はブラウザが対応しているmineTypeを判定して、適切なリソースにフォールバックする機能ですが、これはロード時に作用するものなので、今回の非同期処理では通常考慮されず、ブラウザが<source>のmineTypeに対応してる場合でも、<img>のフォールバックリソースまで取得してしまうという問題がありました。

非同期処理でロードを早めているとはいえ、非同期で不要なリソースをリクエストし、全体の完了が遅くなるのは不完全な設計DEATH。
ので擬似フォールバックを実装しました。

まずはcheckMediaSupport()という関数であらかじめブラウザのmineType対応状況を判定しておきます。今回は、webp、avif、webmあたりの対応をしたかったのでそのように用意。
それぞれに拡張子について、テスト用の軽量ファイルを用意しておいて、それを読めたかどうかでブラウザの対応状況をチェックするという方式です。

これを踏まえ、画像の非同期取得処理を行う際、リソースを<source>を元に取得するのか<img>を元に取得するのかを、先のmineTypeの対応状況で分岐処理します。

//例
lazyLoadImages.forEach((img) => {
      //NOTE: webpとavifに対応していない時のみpngを読み込み
      if (supportResults) {
        if (!supportResults.avif && !supportResults.webp) {
          loadQueue.push(() => loadImage(img));
        }
      }
    });

<source>を取得する時、<img>のsrcは空のままですが、これでも問題なく画像を表示させることができます。


なお最後の方でprocessLoadQueue()を用いて、ロード関数を順番に処理しています。
ネットワークとブラウザへの負荷を軽減することを意図していますが、これはオプションのようなものですね。


ここまで画像を中心に説明してきましたが、<video>は<picture>に対して行ったリソースの分岐処理などやらなくても、対応している<source>のみを取得してくれたので、より簡易な処理になっています。(説明省きます。)

今回はDOMContentLoadedで非同期処理を発火していますが、何か他のイベントで発火するなど応用も効くと思います。


先にも少し触れましたが、この非同期処理にはデメリットがあります。
この処理の目的は、ページの表示を早めることなので、そのあと非同期で取得されるリソースのサイズが膨大だと、画像を表示させるタイミングになってもまだ画像が来ていないという問題が起こりえます。

これはしょうがないですね。来てないもんは来てねえですから。
早く来い!って言って通ればいいですけど。こればっかりは、ネットワーク次第ですね。

なので多少ロードをブロックしても、先に表示されうる画像、動画はHTMLで素直に読み込ませるなどしてバランスが取れるといいですね。

Discussion