Vue Apollo 分页实战指南:offset 与 cursor 策略、fetchMore 与缓存合并
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 依次做了三件事:
- 用新的变量发送一次查询;
- 用字段策略中的
merge函数把结果与缓存中的已有值合并; - 通知所有读取该字段的活动查询(包括当前这一个)。
current.resultref 随之更新为合并后的列表。
一个重要的前置条件:该字段必须配置了 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 的类型注释中,官方明确给出了两种合并数据的方式:
updateQuery回调——手动把fetchMoreResult与previousQueryResult合并(适合fetchPolicy: 'no-cache'的场景,此时必须提供updateQuery);- 字段策略(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 在分页列表中新增或删除一条记录后,缓存无法推断这条记录属于哪一页,因此新条目不会自动出现在分页列表中。通常有两种处理方式:
- 自定义
update回调:在 mutation 的update回调中用cache.modify(或cache.writeQuery)把实体插入/移除合并后的列表。完整的插入、移除、驱逐模式见 Cache Updates。 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。
下一步阅读
- Offset-based 分页详解:offset/limit 模式的完整配置与
keyArgs用法。 - Cursor-based 分页详解:独立游标与 Relay 风格连接两种形态的
merge/read实现。 - Cache Updates:mutation 后同步缓存的三种模式与组合策略。
- Refetching:
refetch、轮询、refetchQueries等刷新手段的对比。 useQueryAPI 参考 及其源码 useQuery.ts。