ジャンルから探す

Web Design English

Intersection Observer APIで無限スクロールを実装する方法

無限スクロールを実装するなら、Intersection Observer API で「番兵(sentinel)要素」を1つ監視するのが最短です。scrollイベントで位置計算をする方法はもう選ぶ理由がありません。

ただし、素直に書くと読み込み処理が何度も連続で走るという定番のバグを踏みます。この記事では、そのガードの入れ方、非同期APIとの組み合わせ方、そして「無限スクロールにするとフッターに永久にたどり着けない」というUX上の問題への対処まで含めて解説します。

スポンサーリンク

無限スクロールとは何か、どこで使われているか

無限スクロールは、ページ下部に到達したタイミングで次のコンテンツを自動で追加していく読み込み方式です。ページネーションのように「次へ」を押させず、スクロールだけで読み進められます。

代表的な採用例は次の通りです。

  • SNSのタイムライン(X、Instagram など)
  • ニュースサイトの記事一覧
  • ECサイトの「もっと見る」による商品追加

一方で、検索・フィルタ・比較が目的の画面には向きません。「3ページ目のあの商品」に戻れないためです。ECの検索結果でページネーションが根強く残っているのはこの理由です。

なぜ scroll イベントではなく Intersection Observer なのか

結論は負荷とコード量の両方で有利だからです。scrollイベントは指を動かしている間ずっと発火し、そのたびにgetBoundingClientRect()scrollTopを読むとレイアウトの再計算が起きます。

項目 scrollイベント Intersection Observer
発火頻度 スクロール中ずっと(要間引き) 交差状態が変わったときだけ
判定コード 座標計算を自前で書く entry.isIntersecting の1行
レイアウト再計算 読み取りのたびに発生しやすい ブラウザ内部で最適化される
入れ子スクロール 親要素ごとに実装が必要 root オプションで指定できる

なお、かつて必要だった intersection-observer ポリフィルはもう不要です。対応していなかったInternet Explorerはすでにサポート終了しており、現行のブラウザはすべてネイティブ対応しています。

スポンサーリンク

基本の書き方

監視対象は、リストの末尾に置いた高さのある空要素(番兵)にします。リストの最後の項目そのものを監視すると、項目が追加されるたびに監視対象を付け替える必要があり面倒です。

<div id="content">
  <div class="item">コンテンツ1</div>
  <div class="item">コンテンツ2</div>
  <div class="item">コンテンツ3</div>
</div>
<div id="sentinel" aria-hidden="true"></div>
<p id="status" role="status" aria-live="polite"></p>
#content {
  display: flex;
  flex-direction: column;
}
.item {
  padding: 20px;
  border-bottom: 1px solid #ddd;
}
#sentinel {
  height: 1px;   /* 高さ0だと交差判定されないブラウザがある */
}
#status {
  text-align: center;
  padding: 10px;
  font-size: 14px;
  color: gray;
}

#sentinel の高さを0にしないのがポイントです。高さ0の要素は交差矩形も0になり、環境によってisIntersectingが立ちません。1pxで十分です。

連続発火を止める:無限スクロール最大のバグ

ガードを入れないと、番兵が画面内にある間ずっと読み込みが走ります。 追加されたコンテンツの分だけ番兵が押し下げられるまでの一瞬に、コールバックが何度も呼ばれるためです。

対策は読み込み中フラグ終端フラグの2つです。

const content = document.getElementById('content');
const sentinel = document.getElementById('sentinel');
const status = document.getElementById('status');

let isLoading = false;   // 読み込み中の二重発火を防ぐ
let hasMore = true;      // これ以上データが無いことを表す
let page = 1;

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      if (isLoading || !hasMore) continue;
      loadMoreContent();
    }
  },
  { rootMargin: '200px' } // 200px手前で先読みを始める
);

observer.observe(sentinel);

rootMargin: '200px' を入れておくと、番兵が画面に入る200px手前から読み込みが始まるので、ユーザーがローディング表示を見る時間を減らせます。

スポンサーリンク

APIからデータを取得して追加する

実際のアプリでは配列を作るのではなく、APIを叩きます。成功・失敗・終端の3状態を必ず扱ってください。

async function loadMoreContent() {
  isLoading = true;
  status.textContent = '読み込み中...';

  try {
    const res = await fetch(`/api/items?page=${page}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const items = await res.json();

    if (items.length === 0) {
      hasMore = false;
      status.textContent = 'すべて読み込みました';
      observer.unobserve(sentinel);
      return;
    }

    const fragment = document.createDocumentFragment();
    for (const item of items) {
      const el = document.createElement('div');
      el.className = 'item';
      el.textContent = item.title;
      fragment.appendChild(el);
    }
    content.appendChild(fragment);

    page += 1;
    status.textContent = '';
  } catch (err) {
    status.textContent = '読み込みに失敗しました。再試行してください';
    console.error(err);
  } finally {
    isLoading = false;
  }
}

実装上のポイントは3つです。

  • finally で必ずフラグを戻す:失敗時にisLoadingtrueのままだと、以降二度と読み込めなくなります
  • DocumentFragment にまとめてから追加するappendChildを要素数ぶん繰り返すより再描画が減ります
  • 終端に達したらunobserve():監視を残す意味がありません

フッターに到達できない問題をどうするか

無限スクロールの最大の弊害は、ページ下部のフッター(規約・問い合わせ・サイトマップ)に永久にたどり着けなくなることです。これはアクセシビリティ上の実害があります。

私が採る対策は次のどれかです。

  • N回で自動読み込みを止め、「もっと見る」ボタンに切り替える(最も無難。3〜5回が目安)
  • フッターをサイドバーやヘッダーにも配置する
  • そもそもページネーションにする(検索結果や商品一覧はこちらが正解)
const AUTO_LOAD_LIMIT = 5;
let autoLoadCount = 0;

// コールバック内
if (autoLoadCount >= AUTO_LOAD_LIMIT) {
  observer.unobserve(sentinel);
  showLoadMoreButton(); // 以降は手動
  return;
}
autoLoadCount += 1;

あわせて、role="status"aria-live="polite" を付けた要素で「読み込み中」「すべて読み込みました」を通知しておくと、スクリーンリーダー利用者にも状態が伝わります。上のHTMLに入れているのはそのためです。

件数が増えたときの描画負荷を下げる

無限スクロールはDOM要素が減らないので、数千件になるとスクロール自体が重くなります。手軽に効くのが CSS の content-visibility です。

.item {
  content-visibility: auto;
  contain-intrinsic-size: auto 120px; /* おおよその高さを伝える */
}

画面外の要素のレンダリングをブラウザがスキップしてくれます。contain-intrinsic-size を指定しないとスクロールバーが跳ねるので、必ずセットで書いてください。

それでも足りない規模(数万件)になると、画面内の要素だけをDOMに置く仮想スクロールが必要です。ここまで来ると Intersection Observer だけでは足りず、専用ライブラリの領域になります。

よくあるトラブルと原因

症状 原因と対処
読み込みが何度も連続で走る isLoading ガードが無い。finally で戻すこと
一度エラーが出ると二度と読み込まない catch の中でフラグを戻していない
番兵が反応しない 番兵の高さが0、またはdisplay:noneになっている
入れ子スクロール内で反応しない root にスクロールする親要素を指定する
データが尽きても呼ばれ続ける 空配列を受け取ったらunobserve()する

同じAPIの他の使い方は、スクロールアニメーションの実装方法遅延読み込みを最適化する方法にまとめています。

まとめ

  • リスト末尾に高さ1pxの番兵要素を置き、それだけを監視する
  • isLoadinghasMore の2つのフラグが無いと連続発火する。finallyで必ずフラグを戻す
  • rootMargin: '200px' で先読みするとローディング表示が目立たない
  • 終端では unobserve() して監視を止める
  • フッターに到達できなくなるので、N回で「もっと見る」ボタンに切り替える
  • 件数が増えたら content-visibility: auto を併用する
  • ポリフィルはもう不要

「自動で読み込む」こと自体が目的ではなく、ユーザーが目的の情報にたどり着けるかどうかが本題です。検索・比較が絡む画面では、無限スクロールを選ばない判断も含めて検討してください。