【Vue.js】無限スクロールを実装する方法|Intersection Observerの活用

【Vue.js】無限スクロールを実装する方法|Intersection Observerの活用 Vue.js

一覧をスクロールしていくと、末尾で次のデータが自動で読み込まれる「無限スクロール」は、Vue 3ではIntersection Observer APIで実装するのが定番です。スクロールイベントを自分で監視して位置を計算する必要がなく、コードが短くなります。ただし、コピーして動かすと止まってしまう落とし穴がいくつかあります。この記事では、その対策を入れた実装を、動くコードで解説します。

実装の流れは4つです。①リスト末尾に「監視用の要素」を置く、②Intersection Observerでその要素が画面に入ったかを検知する、③入ったら次のページを取得する、④取得し終わったら監視を止める(または何もしない)。

スポンサーリンク

基本の実装:終了判定とエラー処理つき

無料で試せる jsonplaceholder のAPI(全100件、_pageと_limitでページ分割できます)を使った例です。

Vue:InfiniteList.vue
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'

const LIMIT = 10
const posts = ref([])
const page = ref(1)
const isLoading = ref(false)
const hasMore = ref(true)
const error = ref('')
const trigger = ref(null)
let observer = null

async function loadMore() {
  if (isLoading.value || !hasMore.value) return
  isLoading.value = true
  error.value = ''
  try {
    const res = await fetch(
      `https://jsonplaceholder.typicode.com/posts?_limit=${LIMIT}&_page=${page.value}`
    )
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    const data = await res.json()

    posts.value.push(...data)
    page.value++
    // 1ページ分に満たなければ、これが最後
    if (data.length < LIMIT) hasMore.value = false
  } catch (e) {
    error.value = '読み込みに失敗しました。'
  } finally {
    isLoading.value = false
  }

  // 読み込み後も監視要素が画面内に残っている場合に備えて、再判定させる
  if (hasMore.value && !error.value && observer && trigger.value) {
    await nextTick()
    observer.unobserve(trigger.value)
    observer.observe(trigger.value)
  }
}

onMounted(() => {
  observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting) loadMore()
    },
    { rootMargin: '200px' } // 末尾の200px手前で先読みする
  )
  observer.observe(trigger.value)
})

onBeforeUnmount(() => {
  observer?.disconnect()
})
</script>

<template>
  <div class="post-list">
    <div v-for="post in posts" :key="post.id" class="post-item">
      {{ post.id }}. {{ post.title }}
    </div>

    <!-- 監視用の要素。常に末尾に置く -->
    <div ref="trigger" class="load-trigger">
      <p v-if="isLoading">読み込み中...</p>
      <p v-else-if="error">
        {{ error }}
        <button type="button" @click="loadMore">再試行</button>
      </p>
      <p v-else-if="!hasMore">すべて読み込みました</p>
    </div>
  </div>
</template>

<style scoped>
.post-list { max-width: 600px; margin: 0 auto; }
.post-item { padding: 16px; border-bottom: 1px solid #ccc; }
.load-trigger { min-height: 40px; padding: 16px; text-align: center; color: #888; }
</style>

よくある落とし穴と、コードでの対策

読み込みのあと、次の取得が止まる

Intersection Observerのコールバックは、要素の交差状態が変わったときにだけ呼ばれます。取得したデータが少なくて、監視用の要素が画面内に残ったままだと、状態が変わらず、次の読み込みが始まりません。上のコードでは、読み込みのあとにunobserveとobserveをやり直して、再判定させています。

全件取得したあとも、リクエストが飛び続ける

終わりの判定がないと、データが空になっても、監視要素が見えるたびにリクエストを送ってしまいます。この例では、取得件数がLIMIT未満ならhasMoreをfalseにして、以後の読み込みを止めています。APIが総件数を返すなら、それと比べる方法でも構いません。

通信に失敗すると、読み込み中のまま固まる

旧来のコードでは、fetchが失敗するとisLoadingがtrueのまま戻らず、二度と読み込めなくなります。try/catch/finallyで必ず戻し、再試行ボタンを出しましょう。エラーの状態管理の考え方は非同期APIの状態管理パターンにまとめています。

thresholdを1.0にすると反応しないことがある

threshold: 1.0は、要素の全体が見えたときだけ反応します。ブラウザのズームや、要素の高さが小数になる環境では、発火しないことがあります。監視用の要素は小さくて構わないので、既定値のままにして、rootMarginで先読みさせるのが安定します。

後片付けはdisconnectで行う

コンポーネントが消えるタイミングでは、テンプレート参照がすでにnullになっていることがあり、unobserve(loadTrigger.value)が何もしないまま、監視が残る場合があります。disconnect()で、すべての監視をまとめて解除します。

rootMarginで先読みする

rootMargin: '200px'を指定すると、監視用の要素が画面の下端から200px手前に来た時点で、次の読み込みが始まります。ユーザーが末尾に着く前にデータが届くので、待たされる感じが減ります。値を大きくしすぎると、不要な先読みが増えます。

composableにして使い回す

複数の画面で使うなら、監視の処理をcomposableに切り出します。コンポーネントは「何を読み込むか」だけに集中できます。composableの考え方はComposable関数でロジックを共通化する方法で詳しく解説しています。

JavaScript:useInfiniteScroll.js
// useInfiniteScroll.js
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'

export function useInfiniteScroll(callback, options = {}) {
  const target = ref(null)
  let observer = null

  onMounted(() => {
    observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) callback()
    }, { rootMargin: '200px', ...options })
    observer.observe(target.value)
  })

  onBeforeUnmount(() => observer?.disconnect())

  // 読み込み後に呼ぶと、画面内に残っていても再判定される
  async function recheck() {
    if (!observer || !target.value) return
    await nextTick()
    observer.unobserve(target.value)
    observer.observe(target.value)
  }

  return { target, recheck }
}

特定の要素の中でスクロールする場合

画面全体ではなく、高さを固定したボックスの中でスクロールさせる場合は、rootにそのスクロール要素を渡します。省略すると、ブラウザのビューポートが基準になります。

Vue:rootを指定する
<!-- スクロール領域が window ではなく、特定の要素のとき -->
<div ref="scroller" class="scroller">
  ...
  <div ref="trigger"></div>
</div>

<script setup>
// root に、スクロールする側の要素を渡す
observer = new IntersectionObserver(callback, {
  root: scroller.value,
  rootMargin: '200px'
})
</script>

無限スクロールを使うべきでない場面

  • フッターに到達したい画面:読み込みのたびにフッターが押し下げられ、たどり着けません。
  • 特定の項目に戻りたい一覧:ページ番号がないと、位置を共有したり、再訪問で同じ場所に戻ったりしにくくなります。その場合はページネーションが向いています。
  • 検索エンジンに載せたい一覧:クライアントで追加読み込みする内容は、クローラーに届かないことがあります。

キーボード操作や支援技術を使う人のために、自動読み込みと並べて「もっと見る」ボタンを置いておくと、使いやすくなります。

関連記事

よくある質問(FAQ)

Q最初の1ページ分しか読み込まれず、スクロールしても続きが出ません。
A読み込んだあとも、監視用の要素が画面内に残っている可能性があります。コールバックは交差状態が変わったときだけ呼ばれるため、読み込み後にunobserveとobserveをやり直してください。
Qスクロールイベントで実装するのと、何が違いますか?
Aスクロールのたびに位置を計算する必要がなく、ブラウザが交差を検知して知らせてくれます。コードが短く、処理も軽くなります。スクロール位置そのものが必要な場合(たとえば現在位置の表示)は、スクロールイベントのほうが向いています。
Q読み込んだ件数が増えすぎて、画面が重くなります。
ADOMの数が増え続けるためです。上限を決めて古い項目を外す、画面内の行だけ描画する「仮想スクロール」を使う、といった方法があります。再描画の抑制にはv-memoも有効です。
QIntersection Observerは古いブラウザでも動きますか?
A現在の主要なブラウザは対応しています。ただし、古い環境を対象にする場合は、サポート状況をMDNで確認し、必要ならポリフィルを検討してください。

まとめ

無限スクロールは、末尾に置いた監視用の要素をIntersection Observerで見張り、画面に入ったら次を読み込む、という仕組みです。実用にするには、読み込み後の再判定、終端の判定、失敗時の再試行、disconnect()による後片付けが欠かせません。あわせて、フッターに届かない、位置を共有しにくいといった弱点を踏まえ、ページネーションと使い分けてください。