Vue.js + Laravelでページネーションを実装するメモ
環境
- Nuxt 3.0.0-rc.1
- Laravel 9.9.0
ページネーションとは
ページネーションとは、何かの一覧を表示するときに、ある個数ごとに区切って複数のページに表示することをいいます。
SPAでのページネーションの例
少し複雑なため図で整理してみます。
まず、ユーザーはページネーションのページ番号をクリックします(①)。
フロントエンドでは、例えばページ番号2がクリックされた場合は、?page=2
のようにクエリパラメータを付けてリクエストを送ります(②)。
APIはクエリパラメータをもとに、該当するデータをOFFSET句やLIMIT句を使用してDBから取得します(③、④)。
最後に、取得したデータをもとに画面を更新します(⑤)。
APIを実装する
LaravelのAPIリソースを使って実装してみます。APIリソースにページングしたデータを渡すと、ページネーションに必要な情報(合計ページ数や現在のページ番号など)を付与してくれます。
まず、コントローラーでモデルを使ってデータを取得し、pagenate
メソッドでページングします。そして、ページングしたデータをAPIリソースに渡して整形します。
class TaskController extends Controller
{
public function __construct(
private Task $taskModel
) {
}
public function index()
{
return TaskResource::collection($this->taskModel->with('status')->paginate(10));
}
}
APIリソースでは、タスクの情報に加えて、タスクのステータス名をリレーションを使って取得しています。
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
メソッドに、ページネータークラスを渡した場合の処理が書かれています。こちらを追っていくと、上記の情報を付与する処理を確認できると思います。
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エンドポイントのクエリ文字列からページ番号を取得し、データの再取得を行っています。
<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>
ページネーションをコンポーネント化する
他のページでもページネーションを使えるように、コンポーネント化してみます。
コンポーネント側:
<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リソースにページネーターを渡すと、ページネーションに必要な情報を付与してくれる
Discussion