【Vue.js】ページネーション付きリスト表示を実装する方法|1ページ○件表示に対応

【Vue.js】ページネーション付きリスト表示を実装する方法|1ページ○件表示に対応 Vue.js

リストの項目が増えてくると、1つの画面に全部を出すのは見づらく、表示も重くなります。そこで使うのがページネーションです。この記事では、Vue 3(<script setup>)で、computedを使った基本の作り方から、ページ番号の省略、データが変わったときの対処、アクセシビリティ、URLとの連動までを整理します。

スポンサーリンク

表示範囲を computed で計算する

基本の考え方は単純です。全件の配列から、現在のページ番号と1ページの件数をもとに、sliceで表示する範囲を切り出します。総ページ数はMath.ceil(件数 ÷ 1ページの件数)で求めます。切り上げるのは、端数のある最後のページを、1ページとして数えるためです。

  • 表示の始点は、(currentPage - 1) * pageSize
  • 表示する項目は、items.slice(start, start + pageSize)
  • 総ページ数は、Math.ceil(items.length / pageSize)(0件でも1ページとして扱う)

ページ番号が増えすぎないようにする

ページ番号のボタンを、全ページ分並べる書き方は、ページ数が少ないうちは動きます。しかし、100ページになれば、100個のボタンが並びます。実際のサイトは、現在のページの前後だけを表示し、間を「…」で省略します。ページ番号の並びを返す関数を作りましょう。この関数は、常に最大7個の要素(先頭、末尾、現在とその前後、省略記号)を返します。

JavaScript:utils/getPageItems.js
// utils/getPageItems.js
// 表示するページ番号の並びを返す。常に最大7個になり、ボタンの数が増えすぎない
//   getPageItems(1, 10)  → [1, 2, 3, 4, 5, 'ellipsis-right', 10]
//   getPageItems(5, 10)  → [1, 'ellipsis-left', 4, 5, 6, 'ellipsis-right', 10]
//   getPageItems(10, 10) → [1, 'ellipsis-left', 6, 7, 8, 9, 10]
export function getPageItems(current, total, siblings = 1) {
  const range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i)

  // 先頭・末尾・現在・左右の省略記号・現在の前後 を並べても収まるなら、全部出す
  const maxButtons = siblings * 2 + 5
  if (total <= maxButtons) return range(1, total)

  const left = Math.max(current - siblings, 1)
  const right = Math.min(current + siblings, total)
  const showLeftDots = left > 2
  const showRightDots = right < total - 1
  const edgeCount = 3 + siblings * 2

  if (!showLeftDots && showRightDots) {
    return [...range(1, edgeCount), 'ellipsis-right', total]
  }
  if (showLeftDots && !showRightDots) {
    return [1, 'ellipsis-left', ...range(total - edgeCount + 1, total)]
  }
  return [1, 'ellipsis-left', ...range(left, right), 'ellipsis-right', total]
}

完成コード

ページサイズの切り替え、件数とページの表示、ページ番号の省略を含んだ完成版です。

Vue:ページネーション付きリスト
<script setup>
import { ref, computed, watch } from 'vue'
import { getPageItems } from './utils/getPageItems'

// 各項目に、固有の id を持たせる
const items = ref(Array.from({ length: 95 }, (_, i) => ({ id: i + 1, title: `項目${i + 1}` })))

const pageSize = ref(10)
const currentPage = ref(1)

// 0件でも 1 ページとして扱う(ボタンが消えないように)
const totalPages = computed(() => Math.max(1, Math.ceil(items.value.length / pageSize.value)))

const pagedItems = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  return items.value.slice(start, start + pageSize.value)
})

const pageItems = computed(() => getPageItems(currentPage.value, totalPages.value))

const rangeText = computed(() => {
  if (items.value.length === 0) return '0件'
  const start = (currentPage.value - 1) * pageSize.value + 1
  const end = Math.min(currentPage.value * pageSize.value, items.value.length)
  return `全${items.value.length}件中 ${start}〜${end}件`
})

function goToPage(page) {
  if (page < 1 || page > totalPages.value || page === currentPage.value) return
  currentPage.value = page
  // 移動したら、リストの先頭へ戻す
  document.getElementById('list-top')?.scrollIntoView()
}

// 件数が減って、今のページが範囲外になったら、最後のページに戻す
watch(totalPages, (total) => {
  if (currentPage.value > total) currentPage.value = total
})

// 1ページの件数を変えたら、1ページ目に戻す
watch(pageSize, () => {
  currentPage.value = 1
})
</script>

<template>
  <div id="list-top">
    <label for="page-size">1ページの件数</label>
    <select id="page-size" v-model.number="pageSize">
      <option :value="10">10件</option>
      <option :value="20">20件</option>
      <option :value="50">50件</option>
    </select>

    <!-- 件数とページを、読み上げソフトにも伝える -->
    <p role="status">{{ rangeText }}({{ currentPage }} / {{ totalPages }}ページ)</p>

    <ul v-if="pagedItems.length">
      <li v-for="item in pagedItems" :key="item.id">{{ item.title }}</li>
    </ul>
    <p v-else>該当する項目がありません。</p>

    <nav aria-label="ページネーション" class="pagination">
      <button
        type="button"
        :aria-disabled="currentPage === 1"
        @click="goToPage(currentPage - 1)"
      >
        前へ
      </button>

      <template v-for="item in pageItems" :key="item">
        <span v-if="typeof item === 'string'" aria-hidden="true">…</span>
        <button
          v-else
          type="button"
          :aria-current="item === currentPage ? 'page' : undefined"
          :aria-label="`${item}ページ目`"
          :class="{ active: item === currentPage }"
          @click="goToPage(item)"
        >
          {{ item }}
        </button>
      </template>

      <button
        type="button"
        :aria-disabled="currentPage === totalPages"
        @click="goToPage(currentPage + 1)"
      >
        次へ
      </button>
    </nav>
  </div>
</template>

<style scoped>
.pagination { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin-top: 1rem; }
.pagination button { padding: 0.4rem 0.8rem; cursor: pointer; }
.pagination button.active { background: #007acc; color: #fff; }
/* 端のボタンは見た目を弱めるが、フォーカスはできる状態のままにする */
.pagination button[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
</style>

コードのポイント

データが変わったときの対処

項目数が減ると(検索で絞り込む、削除する、など)、currentPageがtotalPagesを超えたまま、空のリストが表示されることがあります。たとえば3ページ目にいる状態で絞り込むと、1ページに収まるのに、currentPageは3のままです。totalPagesをwatchして、範囲外になったら戻します。ページサイズを変えたときも、1ページ目に戻します。検索との組み合わせは検索フィルター付きリストの作成をご覧ください。

アクセシビリティ

  • ページ移動のまとまりを<nav aria-label="ページネーション">で囲みます。
  • 現在のページにaria-current="page"を付けます。MDNによると、セットの中の現在のページを示す正式な方法で、1つの要素にだけ付けます。色の違いだけでは、読み上げソフトに伝わりません。
  • 端の「前へ」「次へ」は、disabledではなくaria-disabledにしています。disabledのボタンはフォーカスできませんが、aria-disabledならフォーカスでき、押しても何も起きません(goToPageが範囲外を無視する)。
  • 件数とページ番号をrole="status"で伝えます。ページを移動すると、読み上げソフトが新しい内容を知らせます。

keyはindexではなくid

リストのkeyには、項目のidを使います。ページを切り替えるとき、インデックスのkeyでは、同じ位置の要素が再利用され、状態が残る原因になります。理由はv-forで配列やオブジェクトをループして表示する方法で解説しています。computedの基本はcomputedプロパティで動的なデータ処理を行う方法をご覧ください。

現在のページをURLに持たせる

ページ番号をURLのクエリ(?page=3)に持たせると、リロードしても同じページが開き、特定のページのURLを共有できます。currentPageを、route.queryと同期するcomputedにします。タブでも同じ書き方を使っています。詳しくはタブ切り替えUIの作り方をご覧ください。

Vue:URLクエリと同期する
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

// URL の ?page=3 と currentPage を同期する
const currentPage = computed({
  get: () => {
    const n = Number(route.query.page)
    return Number.isInteger(n) && n >= 1 ? n : 1   // 不正な値は 1 ページ目にする
  },
  // 履歴をページごとに残したいなら push、残したくなければ replace
  set: (page) => router.push({ query: { ...route.query, page } })
})
</script>

クライアント側とサーバー側の使い分け

ここまでの方法は、全件をブラウザに持った上で、sliceで分ける「クライアント側」のページネーションです。数百件程度なら問題ありませんが、数万件になると、最初の取得と描画が重くなります。その場合は、表示するページ分だけをサーバーから取得する、サーバー側のページネーションにします。

JavaScript:サーバー側のページネーション
// サーバー側でページを分ける場合:表示するページ分だけを取得する
const res = await fetch(`/api/items?page=${currentPage.value}&limit=${pageSize.value}`)
const { items, total } = await res.json()   // total は、全件数

// totalPages は、取得した全件数 total から計算する
// const totalPages = Math.max(1, Math.ceil(total / pageSize.value))

ページが変わるたびに通信する場合は、古い応答が新しい結果を上書きしないよう注意が必要です。非同期APIの状態管理パターンをご覧ください。ページを区切らず、スクロールで追加するスタイルなら、無限スクロールを実装する方法が参考になります。

よくある質問(FAQ)

Qページ数が多いとき、ボタンが並びすぎます。
A現在のページの前後だけを表示し、先頭と末尾を残して、間を省略記号でまとめます。この記事のgetPageItemsのような関数で、ページ番号の並びを作ります。
Q絞り込んだら、リストが空になりました。
AcurrentPageが、絞り込み後の総ページ数を超えている可能性があります。totalPagesをwatchして、範囲外になったら、最後のページに戻してください。
Q現在のページは、色で示すだけではだめですか?
A色だけでは、読み上げソフトに伝わりません。現在のページのボタンにaria-current="page"を付けてください。1つの要素にだけ付けます。
Qデータが多いときも、sliceで分ければ十分ですか?
A数百件程度なら十分です。数万件になると、全件の取得と保持が重くなるため、表示するページ分だけをサーバーから取得する方式にしてください。

まとめ

ページネーションは、computedで表示範囲を切り出すだけなら簡単です。実用にするには、ページ番号の省略、データが減ったときのcurrentPageの補正、aria-currentなどのアクセシビリティ、そしてデータ量に応じたクライアント側とサーバー側の使い分けまで、考えておきましょう。