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

javascript-icon JavaScript

こんにちは、かつコーチです。

前回はドラッグ&ドロップを扱いました。

今回はSNSのタイムラインやECサイトの商品一覧でおなじみの無限スクロールを実装していきます。

上級者向けのテーマとして、パフォーマンスや多重発火といった実務で必ずぶつかる問題まで踏み込んで解説します。

無限スクロールとは?

ページネーションとの違い

「もっと見る」ボタンや、1〜2〜3…とページ番号を切り替える「ページネーション」と違い、無限スクロールはスクロールするだけで自動的にコンテンツが追加されていく仕組みです。

  • ユーザーはクリック操作なしでコンテンツを読み進められる
  • 一覧の下部に近づいたタイミングで、自動的に次のデータを読み込む
  • サーバーからは一度に全件ではなく、少しずつデータを取得する

体験としては快適ですが、実装を誤ると「同じデータが重複して読み込まれる」「スクロールするたびに何度もAPIが呼ばれる」といった不具合を起こしやすい機能でもあります。

実装に必要な要素

無限スクロールは、大きく3つの仕組みの組み合わせで成り立っています。

  • 画面の下端に近づいたことを検知する仕組み(Intersection Observer)
  • 検知したタイミングでデータを取得する処理
  • 取得中は重複リクエストを防ぐためのフラグ管理

今回はサーバー通信の代わりに、擬似的なデータ生成関数を使って挙動を確認していきます。

基本の実装手順

手順1:HTMLとCSSでリストと監視用の要素を用意する

まず、記事一覧と、その末尾に「監視対象」となる目印の要素を置きます。

<ul id="item-list" class="item-list"></ul>
<div id="sentinel" class="sentinel"></div>
<p id="loading" class="loading" hidden>読み込み中...</p>
.item-list {
  list-style: none;
  padding: 0;
  max-width: 400px;
}
.item-list li {
  padding: 12px;
  border-bottom: 1px solid #e5e7eb;
}
.sentinel {
  height: 1px;
}
.loading {
  text-align: center;
  color: #6b7280;
  padding: 12px;
}

#sentinelは見た目としては何もない1pxの要素ですが、「この要素が画面に入ってきたら、次のデータを読み込む」という合図の役割を担います。

手順2:データ取得部分を用意する

実際のAPI通信の代わりに、疑似的にデータを返す関数を用意します(fetch APIを使った本物の通信は次回の記事で扱います)。

let currentPage = 1;
const MAX_PAGE = 5;

function fetchItems(page) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const items = Array.from({ length: 10 }, (_, i) => {
        return `記事 ${(page - 1) * 10 + i + 1}`;
      });
      resolve(items);
    }, 600); // 通信の遅延を疑似的に再現
  });
}

setTimeoutで0.6秒の遅延を挟んでいるのは、実際の通信でありがちな「読み込み中に次のスクロールが来てしまう」状況をあえて再現するためです。

手順3:Intersection Observerで読み込みをトリガーする

いよいよ本体部分です。

const list = document.getElementById("item-list");
const loading = document.getElementById("loading");
const sentinel = document.getElementById("sentinel");
let isLoading = false;

async function loadNextPage() {
  if (isLoading || currentPage > MAX_PAGE) return;
  isLoading = true;
  loading.hidden = false;

  const items = await fetchItems(currentPage);
  items.forEach((text) => {
    const li = document.createElement("li");
    li.textContent = text;
    list.appendChild(li);
  });

  currentPage += 1;
  isLoading = false;
  loading.hidden = true;
}

const observer = new IntersectionObserver(
  (entries) => {
    if (entries[0].isIntersecting) {
      loadNextPage();
    }
  },
  { rootMargin: "200px" }
);

observer.observe(sentinel);
loadNextPage(); // 初期表示分を読み込む

rootMargin: "200px"を指定しているのがポイントで、これにより監視対象の要素が画面の実際の下端に到達する200px手前で早めに検知できます。

ユーザーが「そろそろ下まで来たな」と感じる前に次のデータが読み込まれているので、読み込み待ちのストレスが軽減されます。

よくあるつまずきポイント・エラー対処

つまずき:isLoadingフラグを忘れて同じデータが重複して読み込まれた

私が最初にこの機能を作ったとき、isLoadingのようなフラグ管理をせず、Intersection ObserverのコールバックからそのままfetchItemsを呼んでいました。

❌ Before:多重リクエスト防止のフラグがない

const observerBad = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    fetchItems(currentPage).then((items) => {
      items.forEach((text) => {
        const li = document.createElement("li");
        li.textContent = text;
        list.appendChild(li);
      });
      currentPage += 1;
    });
  }
});

通信に時間がかかる想定でsetTimeoutを仕込んだ状態でテストしたところ、監視対象の要素が画面内に留まっている間、isIntersectingが何度もtrueのまま呼び出され続け、同じページのデータが2重・3重に読み込まれてしまいました。

Wi-Fiが遅い環境や、スクロール後に監視対象の要素がしばらく画面内に留まるケースで特に顕著でした。

✅ After:isLoadingフラグでリクエスト中は次を呼ばせない

let isLoadingGood = false;

const observerGood = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting && !isLoadingGood) {
    isLoadingGood = true;
    fetchItems(currentPage).then((items) => {
      items.forEach((text) => {
        const li = document.createElement("li");
        li.textContent = text;
        list.appendChild(li);
      });
      currentPage += 1;
      isLoadingGood = false;
    });
  }
});

「今まさにデータを読み込み中かどうか」をisLoadingGoodという変数で管理し、読み込み中は新しいリクエストを一切受け付けないようにしたことで、重複読み込みが完全になくなりました。

非同期処理が絡む機能では、「今処理中かどうか」を必ずどこかの変数で管理する、という考え方はこの機能に限らず応用が効くので覚えておいて損はありません。

応用・一歩先の使い方

最終ページに到達したら監視を止める

全データを読み込み終えたら、それ以上の監視は不要になるので後片付けをしておきます。

async function loadNextPage() {
  if (isLoading || currentPage > MAX_PAGE) return;
  isLoading = true;
  loading.hidden = false;

  const items = await fetchItems(currentPage);
  items.forEach((text) => {
    const li = document.createElement("li");
    li.textContent = text;
    list.appendChild(li);
  });

  currentPage += 1;
  isLoading = false;
  loading.hidden = true;

  if (currentPage > MAX_PAGE) {
    observer.unobserve(sentinel); // 監視を停止してリソースを解放する
    loading.textContent = "すべて読み込みました";
    loading.hidden = false;
  }
}

observer.unobserve(sentinel)を呼ぶことで、不要になったIntersection Observerの監視を明示的に止められます。

大量の要素をスクロールし続けるページでは、こうした後片付けの積み重ねがパフォーマンスに効いてきます。

ページ全体のパフォーマンスへの配慮

無限スクロールでは、読み込んだ要素をDOMに追加し続けるとページが徐々に重くなっていきます。

数百件を超えるような一覧を扱う場合は、画面外に出た古い要素をDOMから間引く「仮想スクロール」という手法もあわせて検討する価値があります。

まずは今回のような基本形をしっかり理解した上で、必要に応じて発展的な最適化に進むのがおすすめです。

まとめ

この記事のポイント

  • 無限スクロールはIntersection Observerで画面下端への到達を検知し、データを追加取得する仕組み
  • rootMarginを使うと、実際の下端より少し手前で早めに読み込みを開始できる
  • isLoadingのようなフラグで多重リクエストを防がないと、同じデータが重複表示される
  • 全件読み込み後はunobserve()で監視を止め、不要なリソース消費を防ぐ

次に読むべき記事

次回は、初心者向けにテキストをワンクリックでコピーする「Clipboard API」の実装を扱います。

→ 次の記事:コピーボタン(Clipboard API)を実装する

タイトルとURLをコピーしました