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

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

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

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

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

この設定はどのような場合でもパフォーマンス上に利点があるわけではありません。
初期描画の段階で表示されるコンテンツで扱う場合は遅延読み込みさせることでレイアウトが完了されるまで読み込みが遅延するため、通常のPreload Scannerで読み込む場合に比べて幾分か損をします。
また、画像に対して`width`と`height`を指定しなかった場合はレイアウト時にコンテンツの計算を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`インスタンスを作成します。この時、コールバック関数とオプションを引数として渡します。コールバック関数は監視対象が交差した時に発火する処理を、オプションは監視に対する条件を渡します。

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

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

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

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

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

### 画像のコンポーネント準備
次に、遅延読み込みを行わない画像のコンポーネントを作成します。
```tsx
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'`を渡せるようにします。
```tsx
type ImageProps = {
  src: string;
  alt: string;
  width: number;
  height: number;
  loading?: 'eager' | 'lazy';
};

const Image: FC<ImageProps> = ({ loading, ...props }) => {
  return <img {...props} />;
};
```
次に、`img`を監視対象としたいので`useRef`を用いてコンポーネントで要素を管理できるようにします。

```tsx
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`要素が交差した時に読み込みを開始するようにします。
```tsx
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.current`に`HTMLImageElement`が存在していて、`loading`要素が`lazy`の場合のみ処理を行います。

そして、`IntersectionObserver`インスタンスを作成します。
```ts
const observer = new IntersectionObserver((entries) => {
  if (entries[0] && entries[0].isIntersecting && imgRef.current) {
    imgRef.current.src = src;
    observer.disconnect();
  }
});
```
今回は`img`に対する監視だけを行うので、`entries`の最初の要素があって`isIntersecting`が真だったら、`img`に`src`を登録して監視を解除してます。この処理が実施されるタイミングで画像の読み込みが開始されるというわけです。
`imgRef.current`の存在チェックはスコープが異なるので再度行なっています。

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

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

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

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

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