分页

分页

本指南涵盖 Lucid 的基于偏移的分页。你将学会如何:

  • 对数据库查询、模型查询和关联记录进行分页
  • 在应用代码中使用分页器对象
  • 使用基础 URL 和查询字符串值自定义分页 URL
  • 为 API 端点、Inertia 应用和 Edge 模板渲染分页数据
  • 理解基于偏移的分页的性能权衡

概述

Lucid 支持基于偏移的分页,其中页码和每页大小决定返回哪些行。任何查询构建器上的 paginate 方法使用 LIMITOFFSET 运行查询,执行单独的 COUNT 查询来计算匹配行的总数,并返回一个包含该页行和构建分页 UI 所需元数据的 SimplePaginator 实例。

基于偏移的分页是大多数管理界面和面向用户界面的正确选择。它在非常大的表上会失效,因为 COUNT 查询变得昂贵。参见性能考虑了解权衡。

基本用法

在任何查询构建器上调用 .paginate(page, perPage)。该方法返回一个包含该页行和元数据的分页器。

app/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
export default class PostsController {
async index({ request }: HttpContext) {
const page = request.input('page', 1)
const perPage = 20
return Post.query()
.orderBy('created_at', 'desc')
.paginate(page, perPage)
}
}

你也可以通过 db 服务对原始表查询进行分页。

app/services/posts_service.ts
import db from '@adonisjs/lucid/services/db'
export async function listPosts(page: number) {
return db
.from('posts')
.orderBy('created_at', 'desc')
.paginate(page, 20)
}

省略时 perPage 默认为 20。始终包含 orderBy 子句,以便分页在跨页边界时返回稳定结果。没有显式排序,数据库可以自由地以任何顺序返回行,这可能导致同一行出现在多个页面上或被完全跳过。

分页器对象

paginate 返回的对象是 SimplePaginator 的实例。它继承自 Array,因此你可以直接迭代它或在其上使用任何数组方法,而无需先调用 .all()

const posts = await Post.query().paginate(page, 20)
for (const post of posts) {
console.log(post.title)
}
const titles = posts.map((post) => post.title)

分页器暴露了以下属性和方法。

perPage

每页请求的行数。

currentPage

传递给 paginate 的页码。

firstPage

始终为 1

lastPage

计算为 Math.max(Math.ceil(total / perPage), 1)

total

COUNT 查询报告的匹配行总数。

hasTotal

当表至少有一行匹配时为 true。这与 isEmpty 不同:hasTotal 报告整个结果集,而 isEmpty 报告当前页。

isEmpty

当前页没有返回行时为 true

hasPages

结果跨越多个页面时为 true

hasMorePages

当前页之后存在页面时为 true

all()

返回原始行数组。等同于直接迭代分页器,因为它继承自 Array,所以也可以这样做。

getMeta()

将分页元数据作为普通对象返回,包含 totalperPagecurrentPagelastPagefirstPagefirstPageUrllastPageUrlnextPageUrlpreviousPageUrl 字段。

getUrl(page)

返回给定页码的 URL,根据配置的基础 URL 和查询字符串构建。

getNextPageUrl() / getPreviousPageUrl()

返回下一页或上一页的 URL,当当前页在边界时返回 null

getUrlsForRange(start, end)

返回 { url, page, isActive } 对象的数组,对应 startend 之间(包含)的页面。用于在模板中构建编号链接列表。

自定义分页 URL

分页器从基础 URL 和可选的查询字符串对象构建 URL。默认情况下,基础 URL 是 /。调用 baseUrl 来更改它,调用 queryString 来在生成的页面链接中保留额外的查询参数。

const posts = await Post.query()
.where('status', 'published')
.paginate(page, 20)
posts.baseUrl('/posts')
posts.queryString({ status: 'published', sort: 'newest' })

使用上述设置,posts.getUrl(2) 返回 /posts?status=published&sort=newest&page=2getUrlsForRangegetNextPageUrlgetPreviousPageUrl 都遵循相同的基础 URL 和查询字符串值。

转换和序列化响应

分页响应在 AdonisJS 应用中通常以三种形式出现:返回 JSON 的 API 端点、将数据作为 props 传递给前端的 Inertia 端点,以及渲染分页链接的 Edge 模板。前两种最好由 AdonisJS transformers 处理,它们保持响应形状显式并保持端到端的类型安全。

给定一个分页器,将其行和元数据传递给 transformer 的 paginate 方法。

app/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ request, inertia }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post.query()
.orderBy('created_at', 'desc')
.paginate(page, 20)
return inertia.render('posts/index', {
posts: PostTransformer.paginate(posts.all(), posts.getMeta()),
})
}
}

对于纯 JSON API,使用 HttpContext 上可用的 serialize 助手包装 transformer 结果。序列化器控制响应的包装方式(例如,在 data 键下)以及元数据的形状。

app/controllers/api/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ request, serialize }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post.query()
.orderBy('created_at', 'desc')
.paginate(page, 20)
return serialize(
PostTransformer.paginate(posts.all(), posts.getMeta())
)
}
}

参见 AdonisJS transformers 文档了解定义 transformers 和配置序列化器的完整工作流。

在 Edge 模板中渲染分页

对于渲染 HTML 的超媒体应用,分页器的 getUrlsForRange 方法返回构建编号链接列表所需的数据。

app/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
export default class PostsController {
async index({ request, view }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post.query()
.orderBy('created_at', 'desc')
.paginate(page, 20)
posts.baseUrl('/posts')
return view.render('posts/index', { posts })
}
}
{{-- title: resources/views/posts/index.edge --}}
<div>
@each(post in posts)
<h2>{{ post.title }}</h2>
<p>{{ excerpt(post.body, 200) }}</p>
@endeach
</div>
@if(posts.hasPages)
<nav>
@if(posts.getPreviousPageUrl())
<a href="{{ posts.getPreviousPageUrl() }}">上一页</a>
@endif
@each(link in posts.getUrlsForRange(1, posts.lastPage))
<a href="{{ link.url }}" class="{{ link.isActive ? 'active' : '' }}">
{{ link.page }}
</a>
@endeach
@if(posts.getNextPageUrl())
<a href="{{ posts.getNextPageUrl() }}">下一页</a>
@endif
</nav>
@endif

getUrlsForRange 返回的每个对象都有 urlpageisActive 字段。使用 isActive 来为当前页链接设置与其他链接不同的样式。对于具有许多页面的结果集,通过向 getUrlsForRange 传递显式边界来自己组合一个更窄的范围,而不是将每个页面都渲染为链接。

对关联记录进行分页

paginate 也适用于模型关联关系。典型的用例是显示属于单个父记录的记录的分页切片,例如帖子上的评论。

app/controllers/comments_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
export default class CommentsController {
async index({ params, request }: HttpContext) {
const post = await Post.findOrFail(params.postId)
const page = request.input('page', 1)
return post
.related('comments')
.query()
.orderBy('created_at', 'desc')
.paginate(page, 20)
}
}

hasManymanyToManyhasManyThrough 关联关系支持分页。在 belongsTohasOne 关联关系上调用 paginate 会抛出异常,因为这些关联关系按定义只返回单个记录。

性能考虑

paginate 运行一个单独的 COUNT 查询,将主查询包装为子查询,并剥离了排序、限制和偏移。在中小型表上,这种开销可以忽略不计。在非常大的表上,COUNT 查询可能成为请求中最慢的部分,因为数据库扫描整个结果集来计算总数。

COUNT 过于昂贵时,有几种模式效果良好:

  • 缓存总数并按计划刷新,这样每次页面浏览就不需要支付全部成本。
  • 当不需要精确总数时,使用数据库系统表的近似计数(例如,PostgreSQL 上的 pg_class.reltuples)。
  • 切换到键集(游标)分页,它按索引列排序,并使用 where id > lastId 子句向前移动。键集分页不需要 COUNT 查询,并且可以扩展到任意大的表,但它不支持跳转到特定页码。Lucid 不包含键集分页的内置助手;直接使用查询构建器组合它。

后续步骤