1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

1人フロントエンドAdvent Calendar 2023

Day 9

loading="lazy"のような挙動をIntersection Observer APIで実装する

Posted at

はじめに

この記事ではIntersection Observer APIを用いてloading="lazy"のようにビューポートに近づいた時に読み込みを開始するようなコンポーネントをReactで作成する方法を紹介します。

loading="lazy"

imgiframeloading属性にlazyを加えることで、視覚ビューポートの範囲内に入るまでコンテンツの読み込みを延期するようになります(ブラウザによっては視覚ビューポートに近づいたタイミングで読み込みが完了するように最適化されています)。

<img src="sample.webp" alt="代替テキスト" width="25" height="25" loading="lazy" />
<iframe src="sample.html" title="タイトル" loading="lazy" />

このようにすることで、ページの読み込み時に読み込むコンテンツの総量が必要最低限まで減るのでパフォーマンスに大きく寄与します(画像はほとんどのウェブサイトで最も多いアセットで多くの帯域を占有すると言われています)。

この設定はどのような場合でもパフォーマンス上に利点があるわけではありません。
初期描画の段階で表示されるコンテンツで扱う場合は遅延読み込みさせることでレイアウトが完了されるまで読み込みが遅延するため、通常のPreload Scannerで読み込む場合に比べて幾分か損をします。
また、画像に対してwidthheightを指定しなかった場合はレイアウト時にコンテンツの計算を0×0として行うので、読み込み後にレイアウトシフトが発生する可能性があります。

Intersection Observer APIで実装する

loading="lazy"は画像やiframeの読み込み最適化にあたって便利な機能であることがわかりました。loading="lazy"が実装される前はIntersection Observer APIやscrollなどのイベントハンドラを用いて機能を組み込んで実装されていました。

これから、Intersection Observer APIを用いて画像の遅延読み込みを行うReactコンポーネントをいくつかのパートに分けて実装します。

Intersection Observer API

まずはIntersection Observer APIについておさらいをしていきます。
Intersection Observer APIは、特定の要素やレイアウトビューポートが他の特定の要素と交差したことを監視するためのAPIです。今回実装する遅延読み込みの他に、無限ローディングなど様々な場面で活用されます。

利用を開始するにはまずIntersectionObserverインスタンスを作成します。この時、コールバック関数とオプションを引数として渡します。コールバック関数は監視対象が交差した時に発火する処理を、オプションは監視に対する条件を渡します。

コールバック関数は引数にentriesobserverが渡されます。entriesIntersectionObserverEntry(監視対象の交差状態)の配列です。observerはコールバックが呼び出されるIntersectionObserver自身です。

オプションはオブジェクトを渡します。オブジェクトはrootrootMarginthresholdをキーとして持ちます。
rootは監視対象となるベースのElementDocumentオブジェクトです。
rootMargin監視対象のベースとなる要素のマージンを設定します。要素がビューポートに完全に入る前に交差させるように条件付けを行えます。
thresholdは0.0~1.0の値を取り監視対象の要素が交差したみなすための比率を表します。0.5の場合は監視対象の要素のうち半分の面積がベースとなる要素となっていた場合に交差したとみなされます。
オプションを渡さなかった場合は、デフォルト値が適用されます。デフォルトはレイアウトビューポートを基準に余白なしに1pxでも交差したらコールバック関数発火するように設定されます。

このようにして作成されたインスタンスはobserveメソッドで監視対象の要素を追加、disconnectで監視の停止を行います(メソッドは他にもありますが、ここでは紹介を省略します)。

// インスタンスの作成
const observer = new IntersectionObserve(callback, options);

// 監視要素の追加
observer.observe(element1);
observer.observe(element2);

// 全ての監視を停止する
observer.disconnect();

画像のコンポーネント準備

次に、遅延読み込みを行わない画像のコンポーネントを作成します。

type ImageProps = {
  src: string;
  alt: string;
  width: number;
  height: number;
};

const Image: FC<ImageProps> = (props) => {
  return <img {...props} />;
};

なんの変哲もないただのimg要素のpropsを絞ったコンポーネントです。

遅延読み込みを組み込む

先ほどのコンポーネントをベースに遅延読み込みを組み込んでいきます。
まずはloadingをpropsに渡せるようにします。デフォルトは'eager'で、'eager''lazy'を渡せるようにします。

type ImageProps = {
  src: string;
  alt: string;
  width: number;
  height: number;
  loading?: 'eager' | 'lazy';
};

const Image: FC<ImageProps> = ({ loading, ...props }) => {
  return <img {...props} />;
};

次に、imgを監視対象としたいのでuseRefを用いてコンポーネントで要素を管理できるようにします。

type ImageProps = {
  src: string;
  alt: string;
  width: number;
  height: number;
  loading?: 'eager' | 'lazy';
};

const Image: FC<ImageProps> = ({ loading, ...props }) => {
const imgRef = useRef<HTMLImageElement>(null);

  return <img ref={imgRef} {...props} />;
};

最後に、Intersection Observer APIを使ってレイアウトビューポートとimg要素が交差した時に読み込みを開始するようにします。

type ImageProps = {
  src: string;
  alt: string;
  width: number;
  height: number;
  loading?: 'eager' | 'lazy';
};

const Image: FC<ImageProps> = ({ src, loading, ...props }) => {
  const imgRef = useRef<HTMLImageElement>(null);

  useEffect(() => {
    if (imgRef.current === null || loading === 'eager') return;

    const observer = new IntersectionObserver((entries) => {
      if (entries[0] && entries[0].isIntersecting && imgRef.current) {
        imgRef.current.src = src;
        observer.disconnect();
      }
    });

    observer.observe(imgRef.current);

    return () => observer.disconnect();
  }, [src, loading]);
  
  return <img ref={imgRef} {...props} />;
};

ReactではReactと外部システムで接続するためにuseEffectを用います。今回の場合はReactで制御されていないブラウザAPIを扱うのでuseEffectを用いました。

エフェクト関数ではimgRef.currentHTMLImageElementが存在していて、loading要素がlazyの場合のみ処理を行います。

そして、IntersectionObserverインスタンスを作成します。

const observer = new IntersectionObserver((entries) => {
  if (entries[0] && entries[0].isIntersecting && imgRef.current) {
    imgRef.current.src = src;
    observer.disconnect();
  }
});

今回はimgに対する監視だけを行うので、entriesの最初の要素があってisIntersectingが真だったら、imgsrcを登録して監視を解除してます。この処理が実施されるタイミングで画像の読み込みが開始されるというわけです。
imgRef.currentの存在チェックはスコープが異なるので再度行なっています。

インスタンスが作成されたら、observeメソッドを呼んでimgを監視対象にしてます。

クリーンアップ関数では前回のエフェクト関数で作成した監視を引き摺らないように初期化しています。

以上でレイアウトビューポートにimgが現れたら画像の読み込みを開始するようなコンポーネントが作成されました。IntersectionObserverインスタンスを生成する際にoptionsrootMarginを適切に設定することで現れる直前から読み込みを開始することもできるのでぜひやってみてください。

このコンポーネントは読み込んでいない時のサイズが指定したwidthheightになりません。そのため読み込み前後でレイアウトシフトが起こります。レイアウトシフトを考慮してimgの大きさを明示的に指定したり、箱の中にimgを置くようにすることをお勧めします(記事ではCSSを触りたくなかったので何もしてません)。

おわりに

loading="lazy"のような挙動をIntersection Observer APIで実装しました。通常の利用はloading="lazy"で十分ですが、それぞれ視覚ビューポートを元にするか、レイアウトビューポートを元にするかで挙動が変わってくるので、視覚ビューポートでは画像の読み込みがうまくいかない場合はこの方法を使ってみても良いかもしれません。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?