無限スクロールを実装する

## この記事は？

最近、Web ページを実装する際に、無限スクロールで画像が次々に出現するページを作成したのだが、この実装の際に幾つかのナレッジベースを参考にして作成したが、結果的にピッタリの記事がなくて微妙に辛かったので、ここに自分のユースケースとして無限スクロールの実装について書き残しておきます。

## 参考ページ

先に、今回作成した無限スクロールのページを参考に記載しておきます。宣伝になってしまいますが、このページは、Twitter でバズっているイラストを集めてギャラリー形式にして表示するページで、定期的に CI によって内容が更新されます。また、この Web ページは `Next.js` で作成しており、`Vercel` にデプロイされています。

https://tw-gallery.vercel.app/

GitHub にてプロジェクトも公開しているので、さくっと実装を確認したい場合はこちらを参考にしてください。参考ページでは、下記の無限スクロールの選択肢のうち、後者の交差オブザーバーを使用した実装を行っています。

https://github.com/uakihir0/gallery-web


## 無限スクロールの選択肢

無限スクロールの実装には、ざっくり２つの選択肢があります。

* スクロール位置を判断して、特定の位置を超えた場合にハンドラーを実行
* 交差オブザーバーを用い、特定の DOM 要素が表示された場合にハンドラーを実行

結論を先に言うと、**交差オブザーバーを用いた方法** をオススメします。

### スクロールにおける実装

スクロールにおける実装は、基本的に以下のような手順で実装が可能です。

1. スクロールハンドラを登録
1. 特定のスクロール位置までスクロールした場合
    1. スクロールハンドラを登録削除
    1. イベントを実行 (ロードなど)
1. イベントによって DOM 要素が増加
1. 最初に戻る

これをざっくり実装すると以下のような感じになります。（useEffect の依存にちゃんと無限スクロールに関連する表示要素を入れる必要があります）

```typescript
  const isScrolledToButtom = () => {
    const bottomPosition =
      document.body.offsetHeight - (window.scrollY + window.innerHeight);
    return (bottomPosition < window.innerHeight);
  };

  const handleScroll = () => {
    if (isScrolledToButtom()) {
      window.removeEventListener(`scroll`, handleScroll);
      // 最後までスクロールした場合この部分が実行
    }
  }
  useEffect(() => {
    window.addEventListener(`scroll`, handleScroll);
  }, [xxx]);
```


### 交差オブザーバーを用いた実装

交差オブザーバー API は以下のページに詳細が記載されています。

https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

交差オブザーバーとは、簡単に表現すると、特定の DOM 要素がどれだけ画面上に表示されているかで、イベントを発行することができる仕組みで、上記のページにも記載されているように、まさに無限スクロールを作るためにピッタリの API です。交差オブザーバーにおける実装も、基本的にはスクロールでやる場合と同じで、以下のような手順で実装が可能です。

1. 特定の DOM 要素にオブザーバーを登録
1. オブザーバーがイベンド発火した場合
    1. オブザーバーを登録削除
    1. イベントを実行 (ロードなど)
1. イベントによって DOM 要素が増加
1. 最初に戻る

これをざっくり実装すると以下のような感じになります。（useCallback の依存にちゃんと無限スクロールに関連する表示要素を入れる必要があります）

```typescript
  const lastRef = useCallback(
    (element: HTMLDivElement | null) => {
      if (element === null) return;
      const options = { threshold: 0.01 };
      const observer = new IntersectionObserver((entries, observer) => {
        const ratio = entries[0].intersectionRatio;
        if (ratio > 0 && ratio <= 1) {
          observer.disconnect();
          // 特定の DOM 要素が少しでも見えたらこの部分が実行
        }
      }, options);
      observer.observe(element);
    },
    [...]
  );

  // JSX
  <div ref={lastRef}>
```

ここで、`IntersectionObserver` が交差オブザーバーです。この交差オブザーバーには、オプションを指定することができて、上の例においては、 `{ threshold: 0.01 }` と指定しています。これは、その DOM 要素がどれぐらい表示されたらイベンドを発火するかを指定する閾値になっており、デフォルトだと `{ threshold: 0 }` になっており、全く表示されていない場合でも、イベントが発火され、少し表示されはじめた場合にも、イベントが発火されます。この二回目の発火の際にも、コード上におけるどれぐらい表示されているかを示す値 `(0~1)` の `intersectionRatio` が `0` の場合もあるので、閾値には `0` 以上の値を設定しておくことが、ケアする内容が減って楽です。

## まとめ

基本的には **交差オブザーバーを用いた実装** をおすすめします。

理由としては、スクロール位置で無限スクロールを実装する場合は、クライアントがどのような表示をしているかを想像しないと、どのスクロール位置で追加ロードをするか判別しにくいという点があります。PC、タブレット、スマホ、各々でロード位置を変えるのは面倒でありバグの原因になります。交差オブザーバーを用いた場合は、リストの最後の DOM 要素が表示されていたらロードする、といった直感的な仕組みで実装できるため、クライアントに依存しにくいといった特徴があります。[参考ページ](https://tw-gallery.vercel.app/) もまさにその問題があり、最後の画像が見える前に、次の画像を読み込みはじめたいのですが、その場所がどこか？ がクライアントに依存してしまい、実装をすすめる上で厄介でしたが、交差オブザーバーを用いたことで、特にクライアントを意識せずとも、最後の画像が見えたら追加ロードをするという方法で決着ができました。

自分は今回、特定のライブラリと組み合わせる関係上、自前で無限スクロールを実装しましたが、当然のように先人が作成したライブラリもあるので、それで問題ない場合は**ライブラリ使って素直に実装しましょう**...

https://github.com/danbovey/react-infinite-scroller