通过首次的中继连接实现的页面分页功能

我之前在初次实现页面分页时使用了Relay Connection。由于实施要求的限制,虽然没有将其优势发挥到最大,但我个人学会了基于游标的分页思想以及Connection的规范等方面的内容。我希望能将这些学习总结和实际实现的思路一起分享出来。

前面所述的条件

页面导航的实现方式

在实施分页时,有几种方法可以选择,其中最常见的是基于偏移量和基于游标的方法。

由于它们各自擅长不同的事情,因此需要根据不同的情况选择合适的方法。

偏移基础分页 (offset-based pagination)

这是使用SQL的OFFSET/LIMIT子句进行分页的方法。
从数据开始计数,跳过由偏移量指定的数目,然后从下一个元素开始,按照由限制数量指定的数目获取数据。

スクリーンショット 2023-11-24 8.48.51.png

作为一个例子,我们可以在 Relay 的 GitHub 上查看一个 pull request 的两页 URL。
在一个以页面编号排列的用户界面上,根据页面编号,page 查询所使用的数字会发生变化。

スクリーンショット 2023-11-21 9.19.29.png

在第2页中,可以看到指定了一个名为?page=2的查询。

https://github.com/facebook/relay/pulls?page=2&q=is%3Apr+is%3Aopen

一般来说,当需要指定页面号以便跳转到任意页面时,通常会使用基于偏移量的分页方法。然而,与基于游标的方法相比,这种实现相对简单,但同时也需要计算到达指定页面之前的所有行数,因此偏移量越大,可能会对数据通信的性能造成担忧。

另外,由于在数据更新时可能会出现重复或缺失的情况,如果频繁进行数据更新,需要稍微注意一下。

我们来考虑一下在显示第一页时,数据的开头被添加了进去的情况。

当我们转到第二页时,之前在第一页最后显示的数据将会出现在第二页的开头位置。

スクリーンショット 2023-11-24 8.51.02.png

如果在浏览第一页时,同时删除了开头的数据,那么当浏览第二页时,数据就会出现一段跳空的现象,看起来好像数据向前移动了一格。

游标式分页

游标是用于唯一标识元素的ID,类似于指针或地址的概念,用于表示元素的位置。
在GraphQL中,推荐将所需的值编码为base64字符串,并将其用作游标。
请注意,游标基于的思想并不局限于GraphQL,而是一种普遍的概念。

提醒您,光标是不透明的,不能依赖其格式,我们建议对其进行Base64编码。

 

在指定的光标位置上,根据获取下一个元素(前一个元素)的数量来实现。Relay 支持向前和向后分页。

スクリーンショット 2023-11-24 8.52.39.png

作为一个採用游标基础的例子,我们再次举GitHub上Relay页面作为示例。
如果看一下提交列表页面的URL,与偏移基础的例子不同,这里采用的是一个可以指定页面的前后的分页UI,而不是页面编号。

スクリーンショット 2023-11-21 9.37.45.png

从第一页开始,当点击”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 的查詢參數或其他錯誤需求,因此我們向前端部分提出了需要進行這樣的實現,並請求進行修正。

最初的实现是想将光标直接包含在URL中。
users/cursor=ABC…然而,我们收到了一些建议,无论是否使用GraphQL,URL都应该保持一致(应避免在URL中包含GraphQL特定的值),于是我们对文章中的方法进行了修正。

对于这个问题,我的见解是…

起初我只了解基于偏移量的分页,但后来了解到了基于游标的分页和各种分页方式的特点。然后,我学习了使用Relay Connection进行实现的方法。这次特别之处在于后端采用了基于偏移量的实现,而前端则采用了基于游标的方式,因此即使是分页这一点,在考虑不同的方法上也需要一些思考的。

而且,正如我在文中所提到的,通过利用我们所采用的Relay库提供的功能,我们可以增加实现选择的可能性。
这次我们没有使用,但是举个例子,Relay提供了一些指令,如@appendEdge和@prependEdge ,以便在Mutation(数据更新)时能更新Connection的缓存。

 

由于项目需要开发实时性服务,目前没有使用缓存或需要对列表进行更新的场景。但是,如果将来需求发生变化,我们也能够通过添加和利用这些功能来获得一些优势。

广告
将在 10 秒后关闭
bannerAds