Vue Apollo 分页实战指南:offset 与 cursor 策略、fetchMore 与缓存合并

原创2026-10-09 16:46:111,426 阅读
文章标签:前端GraphQL

Vue Apollo 分页实战指南:offset 与 cursor 策略、fetchMore 与缓存合并

导读

GraphQL 查询会精确返回你请求的字段,但当字段包含成千上万条记录时,一次性拉取全部数据既不现实也不高效。本文基于 Vue Apollo 官方文档的 Pagination Overview,系统讲解分页问题的本质、offset 与 cursor 两大分页策略的取舍、如何通过 typePolicies 字段策略配置 Apollo 缓存的 merge 行为,以及如何使用 useQuery 返回的 fetchMore 和响应式变量两种方式加载后续页面。读完本文,你将能在 Vue 3 应用中实现无限滚动、"加载更多"按钮、页码导航等多种分页 UI,并正确处理分页列表与变更操作(mutation)之间的缓存同步问题。

分页问题:为什么 GraphQL 查询需要分页

GraphQL 的设计哲学是"按需取字段",但字段的值可能是大型列表。一个典型的查询:

query GetBookTitles {
  books {
    title
  }
}

如果 books 有数千条记录,这个查询会一次性返回所有书名的 title,网络传输和数据体积都难以接受。分页的核心思路是把这一大块结果切成小块:

query GetBookTitles($offset: Int!, $limit: Int!) {
  books(offset: $offset, limit: $limit) {
    title
  }
}

现在客户端通过提供分页参数决定拉取哪一页:第一页传 offset: 0, limit: 10,第二页传 offset: 10, limit: 10,以此类推。

但这里有一个关键问题:服务端只负责返回每一页,把多页合并成一条连贯数据流的工作落在客户端。Apollo Client 的任务是:把多次请求返回的页面合并进缓存,形成一个连贯的列表,让 UI 可以渲染一条不间断的 feed,而不必在每次加载新页时重新拉取之前的所有页面。

两种主流分页策略

Offset-based(偏移量分页)

使用绝对位置索引:offset 表示跳过多少条记录,limit 表示取多少条。

  • 优点:实现简单,服务端只需支持 LIMIT/OFFSET 语义即可。
  • 缺点:不稳定。如果在两次请求之间新增或删除了记录,所有记录的位置都会移动,导致页面之间出现重复或漏项。例如查看第一页时第 10 条记录被删除,下一页的首条记录就会与上一页重复。

Offset-based 的详细配置与代码见 Offset-based 分页。

Cursor-based(游标分页)

使用服务端为每条记录提供的**不透明游标(cursor)**标记列表中的位置。服务端返回给定游标之后的数据,同时附带下一页的新游标。

  • 优点:游标指向特定记录而非绝对位置,在插入和删除发生时依然稳定,不会错位。
  • 缺点:实现比 offset 复杂,客户端要维护游标状态。

Cursor-based 的详细配置与代码见 Cursor-based 分页。

其他变体——页码(page-number)、Relay 风格连接(Relay-style connections)、无限滚动与显式"下一页"按钮——本质上都可以归结为这两种底层模式之一。

Apollo 如何处理分页:字段策略与 merge

Apollo Client 不预设任何一种分页策略,而是把合并逻辑交给你通过 typePolicies 配置描述。merge 函数决定了新页面如何与已缓存内容合并:

import { InMemoryCache } from '@apollo/client'
import { offsetLimitPagination } from '@apollo/client/utilities'

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: offsetLimitPagination(), // 内置助手
      },
    },
  },
})

Apollo 为两种常见策略内置了现成的助手函数:

  • offsetLimitPagination():来自 @apollo/client/utilities,为 offset/limit 模式生成一个把新页面追加到已有列表的 merge 函数。
  • relayStylePagination():同样来自 @apollo/client/utilities,处理 Relay Cursor Connections 规范中的 edges、pageInfo 和标准游标命名。

如果你的数据形态超出了内置助手的能力,可以完全自定义 merge 函数(cursor 分页一节会给出完整示例)。关于 merge、keyArgs、read 函数的底层机制,可参考 Apollo 官方的 Pagination Core API 文档,本文不再展开外部链接。

用 fetchMore 加载下一页

Vue Apollo 的 useQuery 返回一个 fetchMore 函数,专门用于分页/无限滚动场景。官方文档给出的完整示例:

<script setup lang="ts">
import { TypedDocumentNode } from '@apollo/client'
import { useQuery } from '@vue/apollo-composable'

const FEED_QUERY: TypedDocumentNode<{ feed: Array<{ id: string, message: string }> }, { offset: number, limit: number }>
  = gql`
    query Feed($offset: Int!, $limit: Int!) {
      feed(offset: $offset, limit: $limit) {
        id
        message
      }
    }
  `

const { current, fetchMore } = useQuery(FEED_QUERY, {
  variables: { offset: 0, limit: 10 },
})

function loadMore() {
  if (current.value.resultState !== 'complete')
    return

  fetchMore({
    variables: {
      offset: current.value.result.feed.length, // 下一页从当前列表长度开始
      limit: 10,
    },
  })
}
</script>

<template>
  <ul v-if="current.resultState === 'complete'">
    <li v-for="item in current.result.feed" :key="item.id">
      {{ item.message }}
    </li>
  </ul>
  <button @click="loadMore">
    Load more
  </button>
</template>

调用 fetchMore 时 Apollo 依次做了三件事:

  1. 用新的变量发送一次查询;
  2. 用字段策略中的 merge 函数把结果与缓存中的已有值合并;
  3. 通知所有读取该字段的活动查询(包括当前这一个)。current.result ref 随之更新为合并后的列表。

一个重要的前置条件:该字段必须配置了 offsetLimitPagination(或对应的游标助手),否则 merge 的默认行为是"用新页面替换缓存值",列表会被覆盖而不是追加。

从源码看 fetchMore 的实现

fetchMore 是 useQuery 返回结果的一部分。在 useQuery.ts 中,它是这样实现的:

function fetchMore<
  TFetchData = TData,
  TFetchVars extends OperationVariables = TVariables,
>(options: ObservableQuery.FetchMoreOptions<TData, TVariables, TFetchData, TFetchVars>) {
  return observableQuery.value?.fetchMore(options).then(toFetchMoreResult)
}

它把调用委托给底层 Apollo ObservableQuery.fetchMore,随后用 toFetchMoreResult 把返回的 { data, ...rest } 形状转换为 Vue Apollo v5 的 { result, ...rest } 命名(data 改名为 result)。

在 useQuery.ts 的类型注释中,官方明确给出了两种合并数据的方式:

  1. updateQuery 回调——手动把 fetchMoreResult 与 previousQueryResult 合并(适合 fetchPolicy: 'no-cache' 的场景,此时必须提供 updateQuery);
  2. 字段策略(field policies)——在 Apollo 缓存配置中定义 merge 函数。

同时注意 fetchMore 的返回类型是 Promise<FetchMoreResult> | undefined:当查询被停止(observableQuery 不存在)时返回 undefined。

测试用例佐证

仓库的单元测试验证了 fetchMore 的行为。在 useQuery.test.ts 中,"fetchMore() with updateQuery should merge results" 测试用例:

  • 初始请求 variables: { limit: 2, offset: 0 },断言 result.paginatedTodos.items 长度为 2;
  • 调用 fetchMore({ variables: { limit: 2, offset: 2 }, updateQuery: ... }),把新页 items 追加到旧页之后;
  • 断言 fetchMore 返回的是未合并的原始新页结果(长度 2),而缓存中的列表在订阅传播后更新为 4 条。

另外在 compat.test.ts 中还有一组兼容性测试,验证 v4 风格的 fetchMore 返回 { data } 形状,即 more?.data?.paginatedTodos.items 可访问。

用响应式变量实现页码导航

如果把分页状态放在 Vue ref 中,useQuery 会在它变化时重新执行查询。这种方式适合"通过设置页码翻页"的 UI:

<script setup lang="ts">
import { TypedDocumentNode } from '@apollo/client'
import { useQuery } from '@vue/apollo-composable'
import { computed, ref } from 'vue'

declare const FEED_QUERY: TypedDocumentNode<{ feed: Array<{ id: string }> }, { offset: number, limit: number }>

const page = ref(0)
const pageSize = 20

const { current } = useQuery(FEED_QUERY, {
  variables: {
    offset: computed(() => page.value * pageSize), // 响应式计算偏移量
    limit: pageSize,
  },
  keepPreviousResult: true, // 新页到达前仍显示上一页
})
</script>

<template>
  <button @click="page--">
    Previous
  </button>
  <button @click="page++">
    Next
  </button>
</template>

对于分页列表,通常应设置 keepPreviousResult: true,避免翻页时列表闪空。useQuery 的选项是 MaybeRefOrGetter 类型的,即变量既可以是普通对象,也可以是单个 ref 或 getter 的映射——上面的 offset: computed(() => ...) 正是利用了这一点。

响应式变量与 keepPreviousResult 的底层行为

在 useQuery.ts 中,变量会被统一解包:

const variables = computed(() => {
  const vars = toValue(options.value?.variables)
  if (vars == null) return {} as TVariables
  const result = {} as Record<string, unknown>
  for (const [key, value] of Object.entries(vars)) {
    result[key] = toValue(value) // 逐个解包 ref/getter
  }
  return result as TVariables
})

keepPreviousResult 的效果在 useQuery.ts 的 applyState 中体现:当新状态是 empty 且设置了 keepPreviousResult 且上一状态非空时,保留上一结果,但把 loading、networkStatus 换成新请求的状态,并标记 isPreviousResult: true。这样 UI 上列表不会闪空,同时能通过 current.isPreviousResult 区分"当前显示的是旧页"。

fetchMore 与响应式变量的取舍

场景 推荐方案
原地增长列表(无限滚动、"加载更多"按钮) fetchMore
每页替换上一页(页码导航、"下一页"按钮) 响应式变量

fetchMore 走的是"发送新变量 → merge 追加 → 通知订阅者"的增量路径;响应式变量走的是"变量变化 → reobserve 重新请求 → 替换结果"的整页替换路径。

影响分页列表的变更操作(Mutations)

mutation 在分页列表中新增或删除一条记录后,缓存无法推断这条记录属于哪一页,因此新条目不会自动出现在分页列表中。通常有两种处理方式:

  1. 自定义 update 回调:在 mutation 的 update 回调中用 cache.modify(或 cache.writeQuery)把实体插入/移除合并后的列表。完整的插入、移除、驱逐模式见 Cache Updates。
  2. refetchQueries:让受影响的列表查询重新拉取。

对于顺序由服务端控制且顺序重要的列表(时间线 feed、服务端排序),refetchQueries 通常更可靠——因为客户端无法凭空推断正确的插入位置,重新拉取才能保证顺序正确。

组合模式:乐观 UI + 验证重取

在 Cache Updates 文档中,官方推荐组合多种模式:optimisticResponse 加 update 让 UI 立即更新,再用 onQueryUpdated 返回 observableQuery.refetch() 作为校验步骤,纠正 update 逻辑可能出现的偏差——"乐观缓存更新给出即时反馈,重取在一瞬间后修正任何不准确之处"。

分页策略对照与选择建议

维度 Offset-based Cursor-based
位置语义 绝对索引(offset/limit) 不透明游标
插入/删除稳定性 不稳定,可能重复/漏项 稳定
实现复杂度 低 中
典型场景 静态数据、后台管理系统 动态 feed、评论流
内置助手 offsetLimitPagination() relayStylePagination()(Relay 规范);自定义 merge

选择建议:列表数据经常增删(评论、消息流、社交 feed)时优先 cursor-based;数据相对静态或服务端仅支持 offset 时用 offset-based。

下一步阅读

登录后查看全文
apollo