通过首次的中继连接实现的页面分页功能
我之前在初次实现页面分页时使用了Relay Connection。由于实施要求的限制,虽然没有将其优势发挥到最大,但我个人学会了基于游标的分页思想以及Connection的规范等方面的内容。我希望能将这些学习总结和实际实现的思路一起分享出来。
前面所述的条件
页面导航的实现方式
在实施分页时,有几种方法可以选择,其中最常见的是基于偏移量和基于游标的方法。
由于它们各自擅长不同的事情,因此需要根据不同的情况选择合适的方法。
偏移基础分页 (offset-based pagination)
这是使用SQL的OFFSET/LIMIT子句进行分页的方法。
从数据开始计数,跳过由偏移量指定的数目,然后从下一个元素开始,按照由限制数量指定的数目获取数据。
作为一个例子,我们可以在 Relay 的 GitHub 上查看一个 pull request 的两页 URL。
在一个以页面编号排列的用户界面上,根据页面编号,page 查询所使用的数字会发生变化。
在第2页中,可以看到指定了一个名为?page=2的查询。
https://github.com/facebook/relay/pulls?page=2&q=is%3Apr+is%3Aopen
一般来说,当需要指定页面号以便跳转到任意页面时,通常会使用基于偏移量的分页方法。然而,与基于游标的方法相比,这种实现相对简单,但同时也需要计算到达指定页面之前的所有行数,因此偏移量越大,可能会对数据通信的性能造成担忧。
另外,由于在数据更新时可能会出现重复或缺失的情况,如果频繁进行数据更新,需要稍微注意一下。
我们来考虑一下在显示第一页时,数据的开头被添加了进去的情况。
当我们转到第二页时,之前在第一页最后显示的数据将会出现在第二页的开头位置。
如果在浏览第一页时,同时删除了开头的数据,那么当浏览第二页时,数据就会出现一段跳空的现象,看起来好像数据向前移动了一格。
游标式分页
游标是用于唯一标识元素的ID,类似于指针或地址的概念,用于表示元素的位置。
在GraphQL中,推荐将所需的值编码为base64字符串,并将其用作游标。
请注意,游标基于的思想并不局限于GraphQL,而是一种普遍的概念。
提醒您,光标是不透明的,不能依赖其格式,我们建议对其进行Base64编码。
在指定的光标位置上,根据获取下一个元素(前一个元素)的数量来实现。Relay 支持向前和向后分页。
作为一个採用游标基础的例子,我们再次举GitHub上Relay页面作为示例。
如果看一下提交列表页面的URL,与偏移基础的例子不同,这里采用的是一个可以指定页面的前后的分页UI,而不是页面编号。
从第一页开始,当点击”Older”按钮一次时,可以看到URL附带了类似?after=ab92df525948d0d445101c97c489ce8c3087a990+34的查询参数。
https://github.com/facebook/relay/commits/main?after=ab92df525948d0d445101c97c489ce8c3087a990+34&branch=main&qualified_name=refs%2Fheads%2Fmain
这个 ab92df525948d0d445101c97c489ce8c3087a990 是最新的提交编号,末尾的 +34 表示从该提交向后 +34 的提交,即显示在第一页上的最后一个提交的位置。
虽然没有实际查看代码,但我认为这可能是用来实现基于光标的分页的。
上述的例子中,游标基于页面前后移动、查看更多、无限滚动等模式进行使用。
此外,由于可以通过搜索确定游标的位置,因此不需要计算总行数,因此当数据量增加时,性能下降较小。
通过唯一的游标来确定要显示的数据位置,可以说它是一种对数据的添加和删除具有很强韧性的方法。
关于Connection的规格说明
在GraphQL中,通常认为游标分页是最强大的分页方法,并作为最佳实践介绍了如何实现。
Relay已经将此作为连接模式进行了标准化并提供。
比如说,如果考虑以分页的方式展示用户列表,则可以通过以下方式来定义GraphQL schema。
extend type Query {
users(
first: Int
after: String
last: Int
before: String
): UserConnection!
}
interface Node {
id: ID!
}
type UsersConnection {
edges: [UserEdge]
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
cursor: String!
node: User
}
type User implements Node {
id: ID!
name: String!
age: Int!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
如果想获取前10个项目的列表,调用查询的方面如下所示。
import { graphql } from "react-relay"
const query = graphql`
query usersQuery {
users(first: 10) {
... on UsersConnection {
edges {
node {
name
age
}
}
pageInfo {
hasNextPage
hasPreviousPage
}
totalCount
}
}
}
`
如果 `after` 未定义,将从第一个人开始获取。
如果要获取第二页(第11人至第20人),请在变量中添加 `after`,并指定第10个用户的光标。
实施
接力连接和BE API的实现分离
在使用我所创建的分页的界面上,需要满足以下的显示要求。
-
- 「前の10件」「1」「2」「3」「次の10件」といった、ページ番号指定可能なページネーションであること
- 画面を更新した際に、現在開いているページを保持すること
在FE与BE之间建立了BFF,在FE-BFF之间使用GraphQL进行通信,在BFF-BE之间使用REST API进行通信。但是,这次BE API将基于偏移量来实现。
如果要模拟页面编号方式的UI,只返回API当前显示页面末尾的光标(endCursor),则无法跳转到下一页以外的页面。
因此,如果要返回适用于所有页面的endCursor,则必须从数据库中选择所有元素。
此外,每个API都需要考虑光标,这也会带来一些麻烦,所以采用了方便实现的基于偏移量的方式。
GET /users?offset=20&limit=10
然而,作为一个FE,使用Relay,同时不使用Connection根据BE的规范是一种浪费,所以我们选择了基于游标的实现。
正如前面提到的,基于游标的实现在数据增减方面更强。
此外,当后来页面样式变更为“加载更多”等时,基于游标的实现更容易进行适配,而且我们认为采用的库提供的官方功能有很大的优势。
FE在实际实现中,创建了一些能够在GraphQL的解析器中相互转换cursor和offset&limit的util函数,并将其引入。
graphql-relay提供了一些函数,比如cursorToOffset()和offsetToCursor(),Relay Connection也可以支持基于offset的API。
我觉得提供这种方法可以将FE和BE的实施分离,让每个部分都可以选择最优的方法,这是一个优点。
另外,为了补充 PageInfo 类型无法提供的其他信息,我们还定义了一个可在整个模式中共享的自定义模式。
type UserConnection {
edges: [UserEdge]
pageInfo: PageInfo!
+ paginationInfo: [PaginationInfo!]!
totalCount: Int!
}
# 各ページへのリンク生成等、ページャー実装に必要な項目を返す
+ type PaginationInfo {
+ after: String # 各ページ末尾の User のカーソル
+ currentPage: Boolean! # 現在のページかどうか
+ pageIndex: Int! # ページ番号
+ }
根据上述,实际的查询变成了以下形式。
import { graphql } from "react-relay"
const query = graphql`
query usersQuery(
$after: String
$first: Int
) {
users(
after: $after
first: $first
) {
... on UsersConnection {
edges {
node {
name
age
}
}
paginationInfo {
after
isCurrent
pageIndex
}
totalCount
}
}
}
`
将页面编号转换为光标位置。
如前所述,我们决定以基于光标的方式实现页面编号格式的用户界面。
在 Relay 的教程中介绍了使用 usePaginationFragment() 进行重新获取数据的方法。
但是由于使用这种方法很难实现所需的用户界面,所以我采取了将页面编号保存在URL查询中,并将该页面编号转换为游标的方法。
/users?page=2
首先,定义一个函数,该函数能够接收页面编号和每页显示的项目数量,并返回对应页面编号的偏移值。
/**
* @example
* convertPageToOffset({ page: 2, itemsPerPage: 10 })
* → 10
*/
export const convertPageToOffset = ({
page,
itemsPerPage,
}: {
page: number;
itemsPerPage: number;
}) => {
if (page <= 0 || itemsPerPage <= 0) {
throw Error("page と itemsPerPage には 0 より大きい数を設定してください");
}
return (page - 1) * itemsPerPage;
};
根据这个,我在页面的getInitialProps中,将页面查询一次转换为偏移量,然后再使用offsetToCursor()将其转换为游标。
※由于各种原因,在本项目中我们使用了Next.js中被弃用的getInitialProps()。
import { z } from "zod";
import { offsetToCursor } from "graphql-relay";
const ITEMS_PER_PAGE = 10;
Page.getInitialProps = async (ctx) => {
// normalizeQueryString() は URL からクエリを取得し、{ [key: string]: string | undefined } 型にして返す自前関数
const { page } = normalizeQueryString(ctx.query);
// page に 0 やマイナスの数値、その他不正な値を指定した場合は 404
// 2ページしかないのに3ページ目を指定した場合は 0 件表示
if (page) {
const pageSchema = z.number().positive();
const { success: isValidPage } = pageSchema.safeParse(Number(page));
if (!isValidPage) {
return {
errorCode: 404,
};
}
}
// page を offset に変換する
const offset = convertPageToOffset({
page: page ? Number(page) : 1, // 1 ページ目はクエリがつかない
itemsPerPage: ITEMS_PER_PAGE,
});
// offset を cursor に変換する
// offset が 0 の場合は1ページ目のため、cursor は undefined となる
const cursor = offset ? offsetToCursor(offset - 1) : undefined
// 以下省略
};
如果在页面查询中故意输入了无效的值,例如0、负数或其他无法转换为数字的任意字符串,将显示404页面。
由于使用了验证库zod,我们使用zod的safeParse()函数来判断无效的页面。
另外,原本的服務要求並未考慮到 URL 的查詢參數或其他錯誤需求,因此我們向前端部分提出了需要進行這樣的實現,並請求進行修正。
users/cursor=ABC…然而,我们收到了一些建议,无论是否使用GraphQL,URL都应该保持一致(应避免在URL中包含GraphQL特定的值),于是我们对文章中的方法进行了修正。
对于这个问题,我的见解是…
起初我只了解基于偏移量的分页,但后来了解到了基于游标的分页和各种分页方式的特点。然后,我学习了使用Relay Connection进行实现的方法。这次特别之处在于后端采用了基于偏移量的实现,而前端则采用了基于游标的方式,因此即使是分页这一点,在考虑不同的方法上也需要一些思考的。
而且,正如我在文中所提到的,通过利用我们所采用的Relay库提供的功能,我们可以增加实现选择的可能性。
这次我们没有使用,但是举个例子,Relay提供了一些指令,如@appendEdge和@prependEdge ,以便在Mutation(数据更新)时能更新Connection的缓存。
由于项目需要开发实时性服务,目前没有使用缓存或需要对列表进行更新的场景。但是,如果将来需求发生变化,我们也能够通过添加和利用这些功能来获得一些优势。