🛠️

IntersectionObserver API で簡単な無限スクロールコンポネートを実装してみた(React版)

2022/08/09に公開

無限スクロールとは?

無限スクロールとは、同一ページ内でスクロールダウンするたびにページを読み込み表示すること。
SNSのタイムラインやニュースアプリでよく見られますよね。ページネーションの代案としても様々なアプリやWebサイトに使われていると思います。
(個人的な暴論)ユーザーから時間をどんどん奪うためのちょっと邪悪なUXです😈。


スクロールダウンするたびにページを読み込み

でもライブラリはもういっぱいあるでしょう?!

実装ニーズが高いので既に多数なライブラリがあります。Reactだと react-virtualize なども有名だとお思います。
しかしながら、「ライブラリが重い!」や「自分のニーズに合わない!」などの状況も時々ありますよね。
それで今日はIntersectionObserver APIを利用して簡単な無限スクロールコンポネートを自ら実装してみましょう。

IntersectionObserver API

そもそも IntersectionObserver API って何?

ご存知の方も多いだと思いますが、簡単に説明しましょう。
IntersectionObserver API は特定の領域を監視して指定した要素がその領域に入ったかどうかを検知してくれる JavaScript の API です。
比較的新しく出たAPI(2016年頃)ので、今までのスクロールイベントを検出する手法より簡単に利用できますし、パフォーマンス的にも優れています。
スクロールスパイ・遅延読み込み・スクロールによるのアニメーションなど様々な場面で利用できる便利な API です。

ちなみにMDNの説明は:

IntersectionObserver インターフェイスは、対象の要素と祖先要素または文書の最上位のビューポートとの交差状態の変化非同期に監視する方法を提供します。
IntersectionObserver が生成されると、ルート内での可視部分の比率を監視するように構築されます。構成はいったん IntersectionObserver が生成されると変更できませんので、与えられた監視オブジェクトは可視性の観点による特定の変化を監視する場合にのみ有用です。しかしながら、同じオブザーバーで複数の対象要素を監視することができます。

ブラウザ対応状況

ブラウザ対応状況はこんな感じです:

ブラウザ対応状況

モダンブラウザはほぼ対応していますが、IE など古いブラウザには polyfill で対応する必要があります。

使ってみる

APIの使い方が簡単で、例としてはこんな感じ:

const intObserver = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      console.log(entry.isIntersecting) // ターゲットエレメントと祖先要素または最上位のビューポートと交差すると true になる
    })
  },
  {
    // オプションがデフォルトで
  }
);

const target = document.querySelector('#targetId');
intObserver.observe(target); // 監視開始

entriesIntersectionObserverEntry オブジェクトの配列で、それぞれの要素が閾値となります。これは、見えている割合が下から上に、または上から下にその値を越える時に callback が呼ばれるよう指定するものです。

実際に無限スクロールを実装してみましょう

目標は初期に要素30個を表示して、1番下にスクロールたびにもう30個を読み込みます。

下準備

まずは簡単なリストを作りましょう

App.tsx
import * as React from 'react';

const countPerFetch = 30;

const App: React.FC = () => {
  // 初期化に30個を入れます。動的に変わるの state で管理
  const [listItems, setListItems] = React.useState([
    ...Array(countPerFetch).keys(),
  ]);
  
  // listItems を更新するフェッチ関数を定義(demoのため適当で)
  const fetchMore = () => {
    setListItems([...Array(listItems.length + countPerFetch).keys()]);
  };
  
  return (
    <div>
      <h1>Infinite Scroll Demo</h1>
      <ul>
        {listItems.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
};

export default App;

要素が30個ある簡単なリストを準備できました。

ボトム要素を決める

次はどこまでスクロールしたら次の読み込むを発火するを決めます。
だいたいの場合は1番下にスクロールしたら次の読み込むを発火します。
また、読み込みにも時間かかるので、下から何番目(下から5番目とか)が表示されたら次の読み込むを発火することも割とあります。
今回は簡単に1番下にスクロールしたら次の読み込むを発火するようにします。

App.tsx
...
let bottomBoundaryRef = React.useRef(null);
...
return (
    <div>
      <h1>Infinite Scroll Demo</h1>
      <ul>
        {listItems.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
      <div ref={bottomBoundaryRef}>
    </div>
  );

bottomBoundaryRefを定義しまして、リスト下の空白の div と紐付けます。

useRefの簡単な説明

useRef とは、参照を保持するためのhookです。Refオブジェクトを生成して、その値をメモ化する。
値の取り出し、こんな感じで行います:

// ref を初期化
const ref = useRef(null)
// 設定した値を取り出す
const value = ref.current;
// 値を変更する
const ref.current = <div />;

ref.currentの値を変更させているだけなので再レンダリングが起こらないです。

この例だと、bottomBoundaryRef.currentの初期値が null になって、そのあとページのレンダリングによって設定した <div> になります。

オブザーバーを入れる

App.tsx
...
// オブザーバーを定義する
const scrollObserver = React.useCallback(
  (node) => {
    new IntersectionObserver((entries) => {
      entries.forEach((en) => {
        // ビューポートに入ったら true になります
        if (en.isIntersecting) {
	   // needFetchMore を true 更新
	   // 時間がかかる重い処理はここに置かないように注意
           setNeedFetchMore(true);
        }
      });
    }).observe(node);
  },[fetchMore]
);

React.useEffect(() => {
  // ref に値が与えられたら監視開始
  if (bottomBoundaryRef.current) {
    scrollObserver(bottomBoundaryRef.current);
  }
}, [scrollObserver, bottomBoundaryRef]);

React.useEffect(() => {
  // needFetchMore が true になったら fetchMore を発火する
  if (needFetchMore) {
    fetchMore();
    setNeedFetchMore(false);
  }
}, [needFetchMore, fetchMore, setNeedFetchMore]);
...

次に、オブザーバーを設定するための関数 scrollObserver を定義します。この関数は、観測する DOM ノードを受け取ります。ここで注目すべき点は、監視対象がビューポートに入るたびに、needFetchMoretrue に更新されます。これが起こると、それを依存関係として持つuseEffectフックが再実行されます。この再実行により、fetchMoreが呼び出されます。

イベントプロセシングは次のようになります。

監視対象がビューポートに入る → setNeedFetchMoreをコール → フェッチコール用hook useEffect 実行 → フェッチコール(fetchMore)実行 → 配列を更新。

これで簡単な無限スクロールの実装ができました。ロジックが簡単なので、50行以内に収めました。
実際のユーズケースによって柔軟に調整すると良いでしょう。

Demo

最終的なデモはこんな感じ:

ボトム要素/refオブザーバーを抽出して、別のコンポーネントにしました。
これで汎用的なコンポーネントになって、無限スクロールしたいコンポーネントの外を囲むだけでOK。

データやフェッチはダミーになる簡単なデモなので、実際に使うときはローディング状態やエラー、遅延読み込みを考慮して実装する必要があります。

まとめ

IntersectionObserver API を簡単に紹介しました。簡単に利用できるし、パフォーマンスにも優れていて便利な API です。
それを利用して簡単な無限スクロールコンポネートを実装してみました。
「重い!」・「自分のニーズに合わない!」など良さそうなライブラリーを見つけられない時は自らで実装を試してもいかがでしょう?

おまけ

無限スクロールを利用する時に SEO も顧慮しないといけない場合が多いと思います。
Googleさんもベストプラクティスを出してるので、興味がある方はこちらどうぞ:
https://developers.google.com/search/blog/2014/02/infinite-scroll-search-friendly

参考

https://www.smashingmagazine.com/2020/03/infinite-scroll-lazy-image-loading-react/
https://developer.chrome.com/blog/intersectionobserver/
https://www.webdesignleaves.com/pr/jquery/intersectionObserverAPI-basic.html

Discussion