Skip to content

分页

通常情况下,你需要在应用的某些视图中显示一个列表,该列表包含太多数据以至于无法一次性获取或显示。分页是解决此问题的最常见方法,并且 Apollo Client 的内置功能使其非常容易实现。

获取分页数据的方式基本上有两种:页码和游标。显示分页数据的方式也有两种:离散页面和无限滚动。要深入了解两者之间的区别以及何时使用其中的哪一个,我们建议你阅读 Apollo 关于该主题的博客文章:了解分页

在本文中,我们将介绍使用 Apollo 来实现这两种方法的技术细节。

使用 fetchMore

在 Apollo 中,最简单的分页方法是使用由 useQuery 组合函数返回的 fetchMore 函数。这基本上可以使你执行新的 GraphQL 查询并将结果合并到原始结果中。

js
const { fetchMore } = useQuery(...)

你可以指定要用于新查询的查询和变量,以及如何将新查询结果与客户端上的现有数据合并。具体的操作将决定你要实现什么样的分页。

基于偏移

基于偏移的分页(也称为页码分页)是一种很常见的模式,在许多网站上都可以找到,因为它通常是最容易在后端实现的。例如在 SQL 中,可以使用 OFFSET 和 LIMIT 轻松生成页码分页。

让我们用这个示例查询来加载一个可能包含无限数量帖子的订阅源:

vue
<script>
import { useQuery } from '@vue/apollo-composable'
import gql from 'graphql-tag'

const FEED_QUERY = gql`
  query getFeed ($type: FeedType!, $offset: Int, $limit: Int) {
    currentUser {
      login
    }
    feed (type: $type, offset: $offset, limit: $limit) {
      id
      # ...
    }
  }
`

export default {
  props: ['type'],

  setup (props) {
    const { result } = useQuery(FEED_QUERY, () => ({
      type: props.type,
    }))

    return {
      result,
    }
  },
}
</script>

我们可以使用 useQuery 返回的 fetchMore 函数从订阅源中加载更多帖子:

js
export default {
  props: ['type'],

  setup (props) {
    const { result, fetchMore } = useQuery(FEED_QUERY, () => ({
      type: props.type,
      offset: 0,
      limit: 10,
    }))

    function loadMore () {
      fetchMore({
        variables: {
          offset: result.feed.length,
        },
      })
    }

    return {
      result,
      loadMore,
    }
  },
}

默认情况下,fetchMore 将使用原始的 query,因此我们只传入新变量。

从服务器返回新数据后,使用 updateQuery 选项将其与现有数据合并,这将使用扩展后的列表重新渲染 UI 组件:

js
export default {
  props: ['type'],

  setup (props) {
    const { result, fetchMore } = useQuery(FEED_QUERY, () => ({
      type: props.type,
      offset: 0,
      limit: 10,
    }))

    function loadMore () {
      fetchMore({
        variables: {
          offset: result.feed.length,
        },
        updateQuery: (previousResult, { fetchMoreResult }) => {
          // No new feed posts
          if (!fetchMoreResult) return previousResult

          // Concat previous feed with new feed posts
          return {
            ...previousResult,
            feed: [
              ...previousResult.feed,
              ...fetchMoreResult.feed,
            ],
          }
        },
      })
    }

    return {
      result,
      loadMore,
    }
  },
}

上面的方法非常适合 limit/offset 分页。使用页码或偏移来进行分页的一个缺点是,在同一时间将项目插入了列表或从列表中删除了项目时,某些项目可能被跳过或被返回两次。使用基于游标的分页可以避免这种情况。

注意,为了使 UI 组件在调用 fetchMore 之后收到更新的 loading ref,你必须在 useQuery 的选项中将 notifyOnNetworkStatusChange 设置为 true

基于游标

在基于游标的分页中,“游标”被用来跟踪数据集中的下一个项目应该从哪里获取。有时候游标可能非常简单,仅引用获取到的最后一个对象的 ID,但是在某些情况下(例如,根据某些条件排序的列表),除了最后一个对象的 ID 外,游标还需要基于排序条件进行编码。

在客户端上实现基于游标的分页与基于偏移的分页并没有什么不同,区别只是我们没有使用绝对偏移,而是保留了对最后一个获取对象的引用以及使用的排序顺序的信息。

在下面的示例中,我们使用 fetchMore 查询来连续加载新帖子,这些帖子将被放在列表的前面。服务端的初始响应提供了将在 fetchMore 查询中使用的游标,并在获取更多数据时更新。

js
const FEED_QUERY = gql`
  query getFeed ($type: FeedType!) {
    currentUser {
      login
    }
    feed (type: $type) {
      cursor
      posts {
        id
        # ...
      }
    }
  }
`

export default {
  props: ['type'],

  setup (props) {
    const { result, fetchMore } = useQuery(FEED_QUERY, () => ({
      type: props.type,
      offset: 0,
      limit: 10,
    }))

    function loadMore () {
      fetchMore({
        // 注意这里使用的查询与 `useQuery` 中不同
        query: gql`
          query getMoreFeed ($cursor) {
            moreFeed (type: $type, cursor: $cursor) {
              cursor
              posts {
                id
                # ...
              }
            }
          }
        `,
        variables: {
          cursor: result.feed.cursor,
        },
        updateQuery: (previousResult, { fetchMoreResult }) => {
          return {
            ...previousResult,
            feed: {
              ...previousResult.feed,
              // 更新游标
              cursor: fetchMoreResult.moreFeed.cursor,
              // 将新的帖子与之前的结果合并
              posts: [
                ...previousResult.feed.posts,
                ...fetchMoreResult.moreFeed.posts,
              ],
            }
          }
        },
      })
    }

    return {
      result,
      loadMore,
    }
  },
}

Relay 风格的游标分页

另一个流行的 GraphQL 客户端 Relay 对分页查询的输入和输出有自己的想法,因此人们有时会根据 Relay 的需求来构建服务端的分页模型。如果你有一个被设计用于Relay 游标连接规范的服务器,则也可以从 Apollo Client 调用该服务器,而不会出现任何问题。

使用 Relay 风格的游标与基于游标的分页非常相似。主要区别在于查询响应的格式会影响你获得游标的位置。

Relay 在返回的游标连接上提供一个 pageInfo 对象,它包含了返回的第一项和最后一项的游标,分别作为属性 startCursorendCursor。该对象还包含布尔值属性 hasNextPage ,用于确定是否有更多的可用结果。

以下示例指定一次请求 10 个项目,并且结果应在提供的 cursor 之后开始。如果游标传递了 null,则 Relay 将忽略它并从数据集的开头提供结果,这允许对初始请求和后续请求使用相同的查询。

js
const FEED_QUERY = gql`
  query getFeed ($type: FeedType!, $cursor: String) {
    currentUser {
      login
    }
    feed (type: $type, first: 10, after: $cursor) {
      edges {
        node {
          id
          # ...
        }
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
`

export default {
  props: ['type'],

  setup (props) {
    const { result, fetchMore } = useQuery(FEED_QUERY, () => ({
      type: props.type,
    }))

    function loadMore () {
      fetchMore({
        variables: {
          cursor: result.feed.pageInfo.endCursor,
        },
        updateQuery: (previousResult, { fetchMoreResult }) => {
          const newEdges = fetchMoreResult.feed.edges
          const pageInfo = fetchMoreResult.feed.pageInfo

          return newEdges.length ? {
            ...previousResult,
            feed: {
              ...previousResult.feed,
              // 合并边
              edges: [
                ...previousResult.feed.edges,
                ...newEdges,
              ],
              // 覆盖新的 pageInfo
              pageInfo,
            }
          } : previousResult
        },
      })
    }

    return {
      result,
      loadMore,
    }
  },
}

@connection 指令

使用分页查询时,很难在存储中找到累积查询的结果,因为传递给查询的参数用于确定默认存储键,但通常在执行查询的代码之外是未知的。这对于必要的存储更新来说是有问题的,因为没有稳定的存储键来确定更新 目标。为了引导 Apollo Client 对分页查询使用稳定的存储键,你可以使用可选的 @connection 指令为部分查询指定存储键。例如,如果我们想为之前的订阅源查询提供一个稳定的存储键,我们可以使用 @connection 指令来调整查询:

graphql
query Feed($type: FeedType!, $offset: Int, $limit: Int) {
  currentUser {
    login
  }
  feed(type: $type, offset: $offset, limit: $limit) @connection(key: "feed", filter: ["type"]) {
    id
    # ...
  }
}

这样在每个查询或 fetchMore 中累积的订阅源被放置在存储中的 feed 键下,稍后我们可以使用它来进行必要的存储更新。在这个例子中,我们还使用了 @connection 指令的可选 filter 参数,该参数允许我们在存储键中包含查询的一些参数。在本例中,我们希望在存储键中包含 type 查询参数,这样就会产生多个存储值,分别累积每种类型的订阅源中的页面。

Released under the MIT License.