Vanilla JavaScript で無限スクロールを実装する方法
Vanilla JavaScriptでIntersection Observerを使い、センチネル要素、ページネーション、重複防止、アクセシビリティ対応付きの無限スクロールを実装します。
Vanilla JavaScript で無限スクロールを実装するには Intersection Observer API を使います。リストの末尾にセンチネル要素を配置して監視し、それがビューポートに入るたびに次のページのデータを取得します。
一度でも実装した経験があれば、その失敗パターンはご存知でしょう。スクロールバーを少し強く弾いただけで、同じ 10 件のアイテムがリストに 3 回も並んでしまうというものです。基本的な仕組みを動かすのに 10 分ほど、それを実ユーザーの操作に耐えるものにするのに午後いっぱいかかります。この手法は、スクロールのたびに位置計算を走らせる従来の scroll イベント + getBoundingClientRect のアプローチを置き換えるものです。本ガイドでは、実際の fetch、ページネーション、DOM への追加まで含めた完全に動作するフィードを 1 つ構築し、さらに本番環境で問題になる 4 つの落とし穴(二重フェッチ、停止しない挙動、エラーハンドリング、プリフェッチのタイミング)と、デモとリリース可能なコードを分けるアクセシビリティのフォールバックについて解説します。
要点
- スクロールイベントではなく
IntersectionObserverを使う。スクロールリスナーはメインスレッドで継続的に発火し、位置計算を手動で行う必要があるのに対し、observer はターゲットが実際にビューポートを横切ったときにのみコールバックを実行する。 IntersectionObserverは 2019 年 3 月以降、すべてのモダンブラウザで Baseline となっているため、現在の無限スクロールに polyfill は不要。- すべての fetch を boolean フラグでガードし、最初のリクエストが解決する前に高速スクロールで複数のリクエストが重複して発火しないようにする。
- API が短いページまたは空のページを返した時点で停止する。
observer.disconnect()を呼んでセンチネルを非表示にしないと、observer はもう存在しないページを要求し続ける。 - 無限スクロールには可視の「Load more」ボタンを併設する。これはキーボード、スクリーンリーダー、JavaScript 無効環境すべてに対するフォールバックを兼ねる。
なぜ IntersectionObserver はスクロールイベントより優れているのか
scroll リスナーの代わりに IntersectionObserver を使うべき理由は、スクロールフレームごとに実行されるのではなく、ターゲットがビューポートを横切ったときにのみ発火するコールバックを通じて、可視性を非同期に報告してくれるからです。従来のパターンでは scroll ハンドラーを取り付け、リスト末尾が近づいているかを計算するために毎回 getBoundingClientRect() を呼び出します。これはメインスレッド上でレイアウトを読み取る計算であり、必要以上にはるかに高い頻度で実行されるため、スクロールジャンクの原因としてよく知られています。
scroll + getBoundingClientRect() | IntersectionObserver | |
|---|---|---|
| 発火タイミング | すべてのスクロールフレーム | ターゲットがビューポートを横切ったときのみ |
| 位置計算 | 自前のコードで手動実行 | ブラウザが処理 |
| スレッド | メインスレッド上で同期実行 | 非同期に配信 |
| polyfill の要否 | 該当なし | 不要(Baseline) |
polyfill は必要ありません。MDN はこの API を Baseline Widely available と分類しており、2019 年 3 月まで遡ってすべての主要ブラウザでサポートされています。したがって polyfill を推奨する(Chrome 51 時代のサポート状況を引き合いに出す)古い情報はすでに時代遅れです。ただし 1 つ例外があります。trackVisibility はデフォルトでは使わないでください。MDN はこのオクルージョン検出プロパティを、利用可能性が限定的な実験的機能として今も掲載しています。
センチネルパターンとは何か
Discover how at OpenReplay.com.
センチネルパターンとは、リストの末尾にマーカー要素を 1 つ配置し、そのセンチネルがビューポートに入ったと observer が報告した時点で次のページを取得して追加する手法です。センチネルは最後のアイテムの後ろに置かれた空要素にすぎず、新しいアイテムを追加するたびにセンチネルはさらに下へ押し下げられていくため、選び直す必要は一切ありません。
構成要素は 3 つです。
- observer を生成する:
new IntersectionObserver(callback, options)。 - 監視を開始する:
observer.observe(sentinel)。 - コールバック内で
entry.isIntersectingを確認し、trueなら次のページを読み込む。
entries[0] を読むのではなく、entries 配列をイテレートしてください。IntersectionObserver() コンストラクターのリファレンスでは、コールバックの 1 回の実行で複数の交差が同時に運ばれてくる可能性があるため、エントリー数を決め打ちしないよう警告しています。
Vanilla JavaScript による無限スクロールの完全な実装例
以下は、JSONPlaceholder を対象とした完全に動作する実装です。JSONPlaceholder は JSON Server と LowDB をバックエンドに持つ無料のモック REST API です。/posts エンドポイントには 100 件のレコードがあり、_page と _limit のクエリパラメーターを受け付けて、要求されたスライスをプレーンな配列として返します。有限のデータセットなので、データが尽きたときの挙動を示すのに便利です。
マークアップは、リスト、フォールバック用ボタン、センチネル、そしてライブリージョンのステータス行で構成されます。
<main>
<ul id="list" aria-label="Posts"></ul>
<button id="load-more" type="button">Load more</button>
<div id="sentinel" aria-hidden="true"></div>
<p id="status" role="status" aria-live="polite"></p>
</main>
スクリプトは observer をセンチネルに結び付け、交差ごとに 1 ページを取得します。
const LIMIT = 10;
let page = 1;
let loading = false; // guard against overlapping requests
let done = false; // stop at end of data
const list = document.getElementById("list");
const sentinel = document.getElementById("sentinel");
const loadMoreBtn = document.getElementById("load-more");
const status = document.getElementById("status");
async function fetchPosts(page) {
const url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${LIMIT}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function render(posts) {
const frag = document.createDocumentFragment();
for (const post of posts) {
const li = document.createElement("li");
li.innerHTML = `<h2>${post.title}</h2><p>${post.body}</p>`;
frag.appendChild(li);
}
list.appendChild(frag);
}
async function loadNextPage() {
if (loading || done) return;
loading = true;
status.textContent = "Loading…";
try {
const posts = await fetchPosts(page);
render(posts);
page += 1;
if (posts.length < LIMIT) { // short/empty page = no more data
done = true;
observer.disconnect();
loadMoreBtn.hidden = true;
status.textContent = "You've reached the end.";
} else {
status.textContent = "";
}
} catch (err) {
status.textContent = "Could not load posts. Tap Load more to retry.";
console.error(err);
} finally {
loading = false;
}
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) loadNextPage();
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
observer.observe(sentinel);
loadMoreBtn.addEventListener("click", loadNextPage);
document.addEventListener("DOMContentLoaded", loadNextPage);
すべてのリクエストは loadNextPage を経由するため、observer のコールバック、ボタンのクリック、初回の DOMContentLoaded による読み込みのいずれもが、同じガードと停止ロジックを共有します。
デモとリリース可能なコードを分ける 4 つの落とし穴
多くのチュートリアルは「データが追加される」ところで終わっています。実ユーザーの操作に耐えるようにするのは、以下の 4 つの対策です。
| 症状 | 原因 | 対策 |
|---|---|---|
| 高速スクロール時にリクエストが重複する | リクエストガードがない | if (loading) return; の boolean フラグ |
| リストが停止せず、空のページを要求し続ける | 終端検出がない | if (posts.length < LIMIT) observer.disconnect() |
| エラーが黙って消える | fetch のエラー処理がない | res.ok を確認し、try/catch でリトライ手段を提示 |
| 末尾で目に見える停止が発生する | rootMargin: "0px" | rootMargin: "200px" で早めにプリフェッチ |
二重フェッチを防ぐ。 素早いフリック操作では、最初の await が解決する前にコールバックが複数回発火することがあります。loading フラグを使えば、実行中のリクエストが finally ブロックで完了するまで、余分な呼び出しはすべて早期リターンします。リクエスト中はセンチネルを unobserve し、完了後に再度 observe する方法もあります。ただし unobserve(対象 1 つ)と disconnect(すべての対象)を混同しないよう注意してください。
データの終端で停止する。 有限のデータソースに対してリクエストを続けると、存在しないページへのリクエストを連発することになります。LIMIT より短いページを検出しましょう。これは Prismatic のページネーション API ループのチュートリアルで使われているのと同じ終端検出シグナルで、リクエストが空配列を返した時点でループを抜けます。検出したら disconnect() を呼び、センチネルとボタンを非表示にします。
threshold: 0 と rootMargin の組み合わせを選ぶ。 rootMargin: "200px" を設定すると、ユーザーが末尾に到達するおよそ 200 ピクセル手前で次のフェッチが始まり、目に見える停止がなくなります。これを 1.0 ではなく threshold: 0 と組み合わせてください。ビューポートより背の高いセンチネルは 100% 可視にならない可能性があるため、完全可視のしきい値では発火しないまま黙って失敗することがあります。
無限スクロールのバグはタイミングとスクロール速度に依存するため、ローカルで慎重にスクロールしても再現することはめったにありません。セッションリプレイのようなツールで実際のセッションを観察することは、素早いテストでは見えないままになる種類の障害、たとえば素早いフリック時のリクエスト重複や、停止しないリストを表面化させる 1 つの手段です。
アクセシビリティと「Load more」フォールバック
無限スクロールには必ず可視の「Load more」ボタンを併設してください。これはキーボードとスクリーンリーダーのフォールバックであり、JavaScript 無効環境のフォールバックであり、そして多くの場合、ユーザーがストリームを止めてフッターにたどり着く唯一の手段でもあります。際限なく自動読み込みされるコンテンツは支援技術のユーザーを閉じ込め、増え続けるコンテンツの下にフッターリンクを埋もれさせ、DOM 上にもう存在しない位置へユーザーが戻ってきたときに戻るボタンのスクロール位置復元を壊します。
具体的な 3 つのステップは、いずれも上記のコードに含まれています。
- ライブリージョンで読み込み状態を通知する:
<p role="status" aria-live="polite">により、スクリーンリーダーが「Loading…」や「You’ve reached the end.」を読み上げられるようになります。 - ボタンを実際にフォーカス可能なコントロールとして残し、observer が発火しない場合や JavaScript が無効な場合でも動作するようにします。
- センチネルには
aria-hidden="true"を付けます。これはコンテンツではなく仕組みであり、アクセシビリティツリーに載せるべきではありません。
フィードのフッターが本当に重要な場合(問い合わせリンク、法的表記、ディープリンク用のページネーションなど)は、「Load more」ボタン単体のほうが適したパターンではないかを検討し、自動読み込みは際限のないストリームであること自体が価値になるコンテンツに限定しましょう。
Vanilla JavaScript における無限スクロールは、1 つの普遍的な考え方に集約されます。センチネルを監視し、交差時にフェッチし、エッジケースを処理する、それだけです。上記の完全なファイルをそのまま使い、fetchPosts を自分のページネーション対応エンドポイントに向け、リリース前にガードと終端停止の両方が正しく発火することを確認してください。この 2 行こそが、動くデモを本番で信頼できるコードへと変えるものです。
FAQ
IntersectionObserver の unobserve と disconnect の違いは何ですか?
observer に特定の 1 要素だけを対象から外させ、残りの監視は続けたいときは unobserve を呼び、現在監視しているすべての対象を手放させたいときは disconnect を呼びます。無限スクロールに当てはめると、リクエスト実行中に単一のセンチネルの監視を一時停止するのが unobserve、データが尽きて observer にもう役割がなくなった時点で呼ぶのが disconnect です。
無限スクロールの代わりにページネーションや Load more ボタンを使うべきなのはどんなときですか?
問い合わせリンク、法的文書、ディープリンク用ページネーションなど、フッターが重要な場合はページネーションや Load more ボタンを選びましょう。際限のない自動読み込みはフッターのコンテンツを永久に手の届かない場所へ押しやり、キーボードやスクリーンリーダーのユーザーを閉じ込めてしまうからです。無限スクロールは、ソーシャルフィードのように際限のないストリームであること自体が目的となるオープンエンドなコンテンツに向いています。ユーザーが区切りを必要とする場合や、末尾に到達する必要がある場合は、明示的なコントロールのほうが適したパターンです。
threshold を 1.0 にすると無限スクロールが発火しないことがあるのはなぜですか?
threshold が 1.0 の場合、コールバックが発火するには監視対象の要素が 100 パーセント可視である必要があります。そのため、ビューポートより背の高いセンチネルは完全に画面内に入ることができず、コールバックが黙って一度も実行されないという事態が起こります。代わりに threshold 0 と rootMargin のバッファを組み合わせてください。そうすればセンチネルの一部が拡張されたルート境界を横切った時点でコールバックが発火し、無限スクロールにとってより信頼できるデフォルトになります。
IntersectionObserver のコールバックで複数のエントリーを扱う必要がありますか?
必要です。MDN のコンストラクターリファレンスでは、コールバックの 1 回の実行が複数の交差を運ぶ可能性があるため、entries 配列の長さを決め打ちしないよう指示しています。センチネルが 1 つだけなら実際には entries[0] でも動くことが多いですが、すべてのエントリーをループしてそれぞれの isIntersecting を確認するのが正しいアプローチであり、複数のターゲットが同時に報告された場合に交差を取りこぼしたり誤って解釈したりするのを防げます。
無限スクロールはブラウザの戻るボタンを壊しますか?
はい、無限スクロールは戻るボタンによるスクロール位置の復元を壊すことがあります。ナビゲーション時に動的読み込みされたコンテンツが破棄された後、ブラウザは DOM 上にもう存在しないスクロール位置へユーザーを戻そうとするためです。結果としてユーザーは誤った場所やリストの先頭に着地します。緩和策としては、読み込み済みの状態を永続化する、スクロール位置を手動で復元する、あるいはナビゲーションが安定した再現可能な状態に対応するよう Load more ボタンを提供する、といった方法があります。