分页
本指南涵盖 Lucid 的基于偏移的分页。你将学会如何:
- 对数据库查询、模型查询和关联记录进行分页
- 在应用代码中使用分页器对象
- 使用基础 URL 和查询字符串值自定义分页 URL
- 为 API 端点、Inertia 应用和 Edge 模板渲染分页数据
- 理解基于偏移的分页的性能权衡
概述
Lucid 支持基于偏移的分页,其中页码和每页大小决定返回哪些行。任何查询构建器上的 paginate 方法使用 LIMIT 和 OFFSET 运行查询,执行单独的 COUNT 查询来计算匹配行的总数,并返回一个包含该页行和构建分页 UI 所需元数据的 SimplePaginator 实例。
基于偏移的分页是大多数管理界面和面向用户界面的正确选择。它在非常大的表上会失效,因为 COUNT 查询变得昂贵。参见性能考虑了解权衡。
基本用法
在任何查询构建器上调用 .paginate(page, perPage)。该方法返回一个包含该页行和元数据的分页器。
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 服务对原始表查询进行分页。
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()
-
将分页元数据作为普通对象返回,包含
total、perPage、currentPage、lastPage、firstPage、firstPageUrl、lastPageUrl、nextPageUrl和previousPageUrl字段。 -
getUrl(page)
-
返回给定页码的 URL,根据配置的基础 URL 和查询字符串构建。
-
getNextPageUrl() / getPreviousPageUrl()
-
返回下一页或上一页的 URL,当当前页在边界时返回
null。 -
getUrlsForRange(start, end)
-
返回
{ url, page, isActive }对象的数组,对应start和end之间(包含)的页面。用于在模板中构建编号链接列表。
自定义分页 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=2,getUrlsForRange、getNextPageUrl 和 getPreviousPageUrl 都遵循相同的基础 URL 和查询字符串值。
转换和序列化响应
分页响应在 AdonisJS 应用中通常以三种形式出现:返回 JSON 的 API 端点、将数据作为 props 传递给前端的 Inertia 端点,以及渲染分页链接的 Edge 模板。前两种最好由 AdonisJS transformers 处理,它们保持响应形状显式并保持端到端的类型安全。
给定一个分页器,将其行和元数据传递给 transformer 的 paginate 方法。
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 键下)以及元数据的形状。
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 方法返回构建编号链接列表所需的数据。
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 返回的每个对象都有 url、page 和 isActive 字段。使用 isActive 来为当前页链接设置与其他链接不同的样式。对于具有许多页面的结果集,通过向 getUrlsForRange 传递显式边界来自己组合一个更窄的范围,而不是将每个页面都渲染为链接。
对关联记录进行分页
paginate 也适用于模型关联关系。典型的用例是显示属于单个父记录的记录的分页切片,例如帖子上的评论。
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)
}
}
hasMany、manyToMany 和 hasManyThrough 关联关系支持分页。在 belongsTo 或 hasOne 关联关系上调用 paginate 会抛出异常,因为这些关联关系按定义只返回单个记录。
性能考虑
paginate 运行一个单独的 COUNT 查询,将主查询包装为子查询,并剥离了排序、限制和偏移。在中小型表上,这种开销可以忽略不计。在非常大的表上,COUNT 查询可能成为请求中最慢的部分,因为数据库扫描整个结果集来计算总数。
当 COUNT 过于昂贵时,有几种模式效果良好:
- 缓存总数并按计划刷新,这样每次页面浏览就不需要支付全部成本。
- 当不需要精确总数时,使用数据库系统表的近似计数(例如,PostgreSQL 上的
pg_class.reltuples)。 - 切换到键集(游标)分页,它按索引列排序,并使用
where id > lastId子句向前移动。键集分页不需要COUNT查询,并且可以扩展到任意大的表,但它不支持跳转到特定页码。Lucid 不包含键集分页的内置助手;直接使用查询构建器组合它。
后续步骤
- Select 查询构建器指南了解你将与分页结合使用的过滤和排序选项。
- 模型指南了解围绕
Model.query().paginate()的完整模型工作流。