🐙

Vue.js + Laravelでページネーションを実装するメモ

2022/04/24に公開

環境

  • Nuxt 3.0.0-rc.1
  • Laravel 9.9.0

https://github.com/tekihei2317/vue-laravel-pagination

ページネーションとは

ページネーションとは、何かの一覧を表示するときに、ある個数ごとに区切って複数のページに表示することをいいます。

SPAでのページネーションの例

少し複雑なため図で整理してみます。

まず、ユーザーはページネーションのページ番号をクリックします(①)。

フロントエンドでは、例えばページ番号2がクリックされた場合は、?page=2のようにクエリパラメータを付けてリクエストを送ります(②)。

APIはクエリパラメータをもとに、該当するデータをOFFSET句やLIMIT句を使用してDBから取得します(③、④)。

最後に、取得したデータをもとに画面を更新します(⑤)。

APIを実装する

LaravelのAPIリソースを使って実装してみます。APIリソースにページングしたデータを渡すと、ページネーションに必要な情報(合計ページ数や現在のページ番号など)を付与してくれます。

まず、コントローラーでモデルを使ってデータを取得し、pagenateメソッドでページングします。そして、ページングしたデータをAPIリソースに渡して整形します。

backend/app/Http/Controllers/TaskController.php
class TaskController extends Controller
{
    public function __construct(
        private Task $taskModel
    ) {
    }

    public function index()
    {
        return TaskResource::collection($this->taskModel->with('status')->paginate(10));
    }
}

APIリソースでは、タスクの情報に加えて、タスクのステータス名をリレーションを使って取得しています。

backend/app/Http/Resources/TaskResource.php
class TaskResource extends JsonResource
{
    public function toArray($request)
    {
        /** @var \App\Models\Task */
        $task = $this->resource;
        assert($task->relationLoaded('status')); // N+1が起きないようにロードされていることを確認

        return [
            'id' => $task->id,
            'name' => $task->name,
            'description' => $task->description,
            'status' => $task->status->name,
        ];
    }
}

APIリソースにページングしたデータ(ページネーター)を渡すと、APIリソースは以下の情報を付与します。

キー 詳細
links 最初のページ、最後のページ、取得したページの前後のページのURL。
meta 現在のページ番号、最後のページ番号、ページの枚の件数、合計件数など。
meta.links ページネーションを表示するためのリンクの集まり。戻る・進むリンク、現在のページの前後3ページのリンク、最初と最後から2ページずつのリンクが入っている。

例えば、合計件数100件・各ページ3件・ページ番号10でAPIを実行した場合は以下のようになります。

レスポンスの例
$ curl 'localhost:8000/api/tasks?page=10' | jq .
{
  "data": [
    {
      "id": 28,
      "name": "todo28",
      "description": "28個目",
      "status": "OPEN"
    },
    {
      "id": 29,
      "name": "todo29",
      "description": "29個目",
      "status": "OPEN"
    },
    {
      "id": 30,
      "name": "todo30",
      "description": "30個目",
      "status": "CLOSED"
    }
  ],
  "links": {
    "first": "http://localhost:8000/api/tasks?page=1",
    "last": "http://localhost:8000/api/tasks?page=34",
    "prev": "http://localhost:8000/api/tasks?page=9",
    "next": "http://localhost:8000/api/tasks?page=11"
  },
  "meta": {
    "current_page": 10,
    "from": 28,
    "last_page": 34,
    "links": [
      {
        "url": "http://localhost:8000/api/tasks?page=9",
        "label": "« Previous",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=1",
        "label": "1",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=2",
        "label": "2",
        "active": false
      },
      {
        "url": null,
        "label": "...",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=7",
        "label": "7",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=8",
        "label": "8",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=9",
        "label": "9",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=10",
        "label": "10",
        "active": true
      },
      {
        "url": "http://localhost:8000/api/tasks?page=11",
        "label": "11",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=12",
        "label": "12",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=13",
        "label": "13",
        "active": false
      },
      {
        "url": null,
        "label": "...",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=33",
        "label": "33",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=34",
        "label": "34",
        "active": false
      },
      {
        "url": "http://localhost:8000/api/tasks?page=11",
        "label": "Next »",
        "active": false
      }
    ],
    "path": "http://localhost:8000/api/tasks",
    "per_page": 3,
    "to": 30,
    "total": 100
  }
}

Laravelのルーターは、コントローラーのアクションを実行した後、アクションの戻り値を整形してからカーネルに返します。アクションの戻り値を整形するときは、戻り値に対してtoResponseメソッドを呼びます(戻り値がIlluminate\Contracts\Support\Responsableインターフェイスを実装している場合です。APIリソースの継承元のJsonResourceクラスはこれに当てはまります)。

リソースコレクションのtoResponseメソッドに、ページネータークラスを渡した場合の処理が書かれています。こちらを追っていくと、上記の情報を付与する処理を確認できると思います。

ResourceCollection.php
public function toResponse($request)
{
    if ($this->resource instanceof AbstractPaginator || $this->resource instanceof AbstractCursorPaginator) {
        return $this->preparePaginatedResponse($request);
    }

    return parent::toResponse($request);
}

参考: Illuminate\Http\Resources\Json\ResourceCollection | Laravel API

フロントエンドを実装する

ページネーションのページ番号は、APIリソースが付与してくれるmeta.linksを用いて作成してみました。

ページ番号をクリックしたときは、APIエンドポイントのクエリ文字列からページ番号を取得し、データの再取得を行っています。

frontend/pages/index.vue
<script setup>
const page = ref(1)
const { data: taskPaginator, refresh } = await useFetch(() => `/api/tasks?page=${page.value}`, {
  baseURL: 'http://localhost:8000',
})

const onClickLink = (url) => {
  page.value = new URL(url).searchParams.get('page')
  refresh()
}
</script>

<template>
  <div>
    <!-- 一覧の表示 -->
    <h2>タスク一覧</h2>
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>タスク名</th>
          <th>説明名</th>
          <th>ステータス</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="task in taskPaginator.data">
          <td>{{ task.id }}</td>
          <td>{{ task.name }}</td>
          <td>{{ task.description }}</td>
          <td>{{ task.status }}</td>
        </tr>
      </tbody>
    </table>

    <!-- ページネーションの表示 -->
    <div class="pagination">
      <template v-for="link in taskPaginator.meta.links">
        <button
          :disabled="link.url === null"
          class="pagination-link"
          :class="{
            'pagination-link-enabled': link.url !== null,
            'pagination-link-active': link.active,
          }"
          @click="onClickLink(link.url)"
        >
          {{ link.label }}
        </button>
      </template>
    </div>
  </div>
</template>

ページネーションをコンポーネント化する

他のページでもページネーションを使えるように、コンポーネント化してみます。

コンポーネント側:

frontend/components/custom/Pagination.vue
<script lang="ts" setup>
type PaginationLink = {
  url: string | null
  label: string
  active: boolean
}

type Props = {
  links: PaginationLink[]
  onClickLink: (url: string) => void
}

const { links, onClickLink } = defineProps<Props>()
</script>

<template>
  <div class="pagination">
    <template v-for="link in links">
      <button
        :disabled="link.url === null"
        class="pagination-link"
        :class="{
          'pagination-link-enabled': link.url !== null,
          'pagination-link-active': link.active,
        }"
        @click="onClickLink(link.url)"
      >
        {{ link.label }}
      </button>
    </template>
  </div>
</template>

呼び出し側:

<custom-pagination
  :links="taskPaginator.meta.links"
  :on-click-link="onClickLink"
  class="task-pagination"
/>

スッキリしました。

まとめ

  • SPAでのページネーションは、ページ番号をクリックしたときにリクエストを送ってデータを取得し、画面を更新する
  • LaravelのAPIリソースにページネーターを渡すと、ページネーションに必要な情報を付与してくれる
GitHubで編集を提案

Discussion