Vue Query 无限查询(Infinite Queries)完整指南:useInfiniteQuery 用法、响应式返回值与底层原理
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
无限查询(Infinite Queries)是 TanStack Vue Query 中用于实现"加载更多"、"无限滚动"等增量列表场景的核心能力,它基于useQuery派生出useInfiniteQuery,在保留全部查询特性的同时,将数据组织为"分页组(pages)"并暴露向前/向后翻页的控制函数。本文以 Vue 框架的 infinite-queries 指南 为主体,结合 React 侧完整版 infinite-queries.md 与仓库源码,系统讲解useInfiniteQuery的完整配置项、实战写法、常见问题与底层实现,读完即可在 Vue 3 项目中落地一个健壮的"加载更多"列表。
使用 useInfiniteQuery 后有哪些变化
在 Vue 组件中使用useInfiniteQuery时(它被封装在 useInfiniteQuery.ts 中,本质上是通过useBaseQuery配合InfiniteQueryObserver实现的),与普通的useQuery相比,你会发现以下几点明显不同:
data不再是单纯的响应数据,而是一个包含无限查询数据的对象:data.pages:数组,存放所有已拉取的分页组(每个元素是一次 queryFn 的返回值);data.pageParams:数组,存放拉取每一页时所使用的页码参数(page param)。
- 新增
fetchNextPage与fetchPreviousPage两个函数(其中fetchNextPage为必用核心函数)。 - 新增
initialPageParam选项(必填),用于指定第一个页面的页码参数。 - 新增
getNextPageParam与getPreviousPageParam选项,既用于判断是否还有更多数据可加载,也用于计算加载下一页所需的信息;该信息会作为额外参数传给 queryFn。 - 新增
hasNextPage布尔值:当getNextPageParam返回除null或undefined之外的值时为true。 - 新增
hasPreviousPage布尔值:当getPreviousPageParam返回除null或undefined之外的值时为true。 - 新增
isFetchingNextPage与isFetchingPreviousPage布尔值,用于区分"后台刷新"与"加载更多"两种拉取状态。
注意:如果你使用
initialData或placeholderData为无限查询提供初始数据,它们必须符合与真实数据相同的结构——即一个包含data.pages与data.pageParams两个属性的对象,否则无限查询的状态计算会出错。
完整示例:基于 cursor 的 "Load More" 列表
假设我们的 API 每次基于cursor游标返回 3 条projects数据,同时返回一个可用于拉取下一组的游标:
fetch('/api/projects?cursor=0') // { data: [...], nextCursor: 3} fetch('/api/projects?cursor=3') // { data: [...], nextCursor: 6} fetch('/api/projects?cursor=6') // { data: [...], nextCursor: 9} fetch('/api/projects?cursor=9') // { data: [...] }基于这个信息,构建 "Load More" UI 只需三步:等待useInfiniteQuery默认发起首次请求拿到第一组数据;在getNextPageParam中返回下一页的查询信息;在按钮点击时调用fetchNextPage。
下面是原文档中的完整 Vue SFC 示例(见 infinite-queries.md):
<script setup> import { useInfiniteQuery } from '@tanstack/vue-query' const fetchProjects = async ({ pageParam }) => { const res = await fetch('/api/projects?cursor=' + pageParam) return res.json() } const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isPending, isError, } = useInfiniteQuery({ queryKey: ['projects'], queryFn: fetchProjects, initialPageParam: 0, getNextPageParam: (lastPage, pages) => lastPage.nextCursor, }) </script> <template> <span v-if="isPending">Loading...</span> <span v-else-if="isError">Error: {{ error.message }}</span> <div v-else-if="data"> <span v-if="isFetching && !isFetchingNextPage">Fetching...</span> <ul v-for="(group, index) in data.pages" :key="index"> <li v-for="project in group.projects" :key="project.id"> {{ project.name }} </li> </ul> <button @click="() => fetchNextPage()" :disabled="!hasNextPage || isFetchingNextPage" > <span v-if="isFetchingNextPage">Loading more...</span> <span v-else-if="hasNextPage">Load More</span> <span v-else>Nothing more to load</span> </button> </div> </template>模板中的渲染逻辑清晰地区分了五种状态:
isPending:首次请求尚未完成,显示 "Loading...";isError:请求失败,展示error.message;isFetching && !isFetchingNextPage:正在进行后台刷新(而非加载更多)时,显示 "Fetching...";isFetchingNextPage:正在加载下一页时,按钮显示 "Loading more..." 且处于禁用状态;hasNextPage为假时,按钮显示 "Nothing more to load",表示已没有更多数据。
每一组data.pages中的元素对应一次 queryFn 的返回结果(本例中为{ data: [...], nextCursor }),因此内层遍历使用的是group.projects。
Vue Query 的响应式返回值:ref 与函数的区别
在 Vue 中,useInfiniteQuery的返回值类型与 React 版本有明显差异,理解这一点对正确书写模板与逻辑至关重要。查看 useBaseQuery.ts 中定义的UseBaseQueryReturnType:
data、error、hasNextPage、isFetching、isPending等状态字段全部是Ref(在模板中会被自动解包,所以v-if="isPending"可以直接书写;在<script>中则需要data.value或保持解构后的 ref 使用);fetchNextPage、fetchPreviousPage、refetch则是普通函数,不是 ref,可以直接调用。
其实现原理位于 useBaseQuery.ts:Vue Query 内部创建InfiniteQueryObserver实例,用reactive()包装其当前结果作为state,通过observer.subscribe在结果变化时调用updateState更新响应式状态;最后用toRefs(readonlyState)将状态字段转为 ref,而对typeof state[key] === 'function'的字段(即函数)则直接原样挂载到返回对象上。
此外,Vue Query 还提供了一些平台相关的细节:
- 若在
setup()或 effect scope 之外调用useInfiniteQuery,开发环境下会打印内存泄漏警告(useBaseQuery.ts); - 可通过
shallow: true选项让内部使用shallowReactive/shallowReadonly,减少深层响应式代理开销; - 返回对象上还附带
suspense()方法,可在<Suspense>场景下手动等待查询结果。
分页参数如何计算:getNextPageParam 的完整签名与 hasNextPage 判定
getNextPageParam并不是只接收lastPage一个参数。查看核心实现 infiniteQueryBehavior.ts:
function getNextPageParam(options, { pages, pageParams }) { const lastIndex = pages.length - 1 return pages.length > 0 ? options.getNextPageParam( pages[lastIndex], // lastPage pages, // 全部页面 pageParams[lastIndex], // 最后一页的 pageParam pageParams, // 全部 pageParams ) : undefined }也就是说,getNextPageParam的实际签名是(lastPage, allPages, lastPageParam, allPageParams),getPreviousPageParam对应为(firstPage, allPages, firstPageParam, allPageParams)。多数场景只需用到第一个参数(从响应中取nextCursor),但当你需要基于"当前页码自增"或"根据已有页面数量做判断"时,后面的参数就派上用场了。
hasNextPage与hasPreviousPage的判定逻辑也非常直接(infiniteQueryBehavior.ts):调用对应的 pageParam 函数,只要返回值!= null(既不是null也不是undefined)即为true。因此,你的getNextPageParam应当在"没有更多数据"时明确返回undefined,而不是返回0之类的"假值"——否则hasNextPage仍会判定为true。
避免并发拉取冲突:fetchNextPage 与 isFetching 的配合
必须理解一个关键事实:同一个 InfiniteQuery 同时只能有一个进行中的 fetch。所有页面共享同一条缓存记录(cache entry),如果在已有 fetch 进行时再次触发fetchNextPage,可能会导致正在后台发生的数据刷新被覆盖。这在"渲染列表的同时触发fetchNextPage"的场景下尤其危险。
如果你确实需要允许同时拉取,可以在fetchNextPage中传入{ cancelRefetch: false }(该选项默认值为true)。但更稳妥的推荐做法是:在触发加载更多之前先确认查询不处于isFetching状态,尤其当该调用不是由用户直接控制时:
<List onEndReached={() => hasNextPage && !isFetching && fetchNextPage()} />这个模式同样适用于 Vue 项目中的无限滚动指令或 IntersectionObserver 回调。
无限查询重新获取(refetch)时会发生什么
当无限查询变stale需要重新获取时,每个分组会被按顺序(sequentially)从头开始依次拉取,而不是并行发起。这样做的原因在于:如果底层数据发生了变化,继续使用旧的游标可能造成重复数据或遗漏记录。这个行为可以在 infiniteQueryBehavior.ts 中看到:在没有direction(即非加载更多场景)时,代码从initialPageParam或已有第一页参数开始,循环调用fetchPage直到拉完remainingPages(默认等于已有页面数)。
此外,如果无限查询的结果被从 queryCache 中移除(例如gcTime过期被回收,或手动queryClient.removeQueries),分页状态会重置到初始状态,仅重新请求第一组数据。
双向无限列表:getPreviousPageParam / fetchPreviousPage
需要实现类似"聊天记录向上翻页"的双向列表时,可以使用getPreviousPageParam、fetchPreviousPage、hasPreviousPage与isFetchingPreviousPage:
useInfiniteQuery({ queryKey: ['projects'], queryFn: fetchProjects, initialPageParam: 0, getNextPageParam: (lastPage, pages) => lastPage.nextCursor, getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor, })从源码 infiniteQueryBehavior.ts 可以看到,fetchNextPage与fetchPreviousPage会通过meta: { fetchMore: { direction } }标记拉取方向('forward'/'backward')。InfiniteQueryObserver.createResult(infiniteQueryObserver.ts)根据这个方向计算isFetchingNextPage、isFetchNextPageError、isFetchingPreviousPage、isFetchPreviousPageError等状态;向前翻页时新页面会被追加到pages头部(addToStart),向后翻页时追加到尾部(addToEnd)。
倒序展示页面:select 选项
如果你希望页面以倒序展示(比如最新的内容在顶部),可以使用select选项对数据进行变换:
useInfiniteQuery({ queryKey: ['projects'], queryFn: fetchProjects, select: (data) => ({ pages: [...data.pages].reverse(), pageParams: [...data.pageParams].reverse(), }), })注意select的返回值必须同时包含pages与pageParams,且数组反转时应保持一致,否则后续的翻页计算会基于错误的pageParams顺序。
手动更新无限查询数据:setQueryData 的三种场景
当 API 发生变更、需要本地乐观更新无限查询数据时,使用queryClient.setQueryData并保持{ pages, pageParams }结构不变即可。
手动移除第一页(例如聊天中的"加载更早消息"合并):
queryClient.setQueryData(['projects'], (data) => ({ pages: data.pages.slice(1), pageParams: data.pageParams.slice(1), }))手动从某一页中移除单条记录(例如删除一条数据后本地过滤):
const newPagesArray = oldPagesArray?.pages.map((page) => page.filter((val) => val.id !== updatedId), ) ?? [] queryClient.setQueryData(['projects'], (data) => ({ pages: newPagesArray, pageParams: data.pageParams, }))只保留第一页(例如重置为初始状态):
queryClient.setQueryData(['projects'], (data) => ({ pages: data.pages.slice(0, 1), pageParams: data.pageParams.slice(0, 1), }))无论哪种场景,请务必始终维护pages与pageParams一一对应的数据结构,否则翻页与重取逻辑会失效。
限制页面数量:maxPages(Limited Infinite Query)
在某些场景下你可能希望限制查询数据中保存的页面数量,以兼顾性能与体验:
- 用户可能加载大量页面(内存占用);
- 需要重新获取包含几十页的无限查询时,所有页面会被顺序重新拉取(网络开销)。
解决方案是使用"受限无限查询":配合maxPages选项,在getNextPageParam与getPreviousPageParam同时存在的前提下,允许在需要时向前后两个方向拉取页面。下面的示例中,查询数据只保留 3 页,如果需要重取,也只会顺序重取这 3 页:
useInfiniteQuery({ queryKey: ['projects'], queryFn: fetchProjects, initialPageParam: 0, getNextPageParam: (lastPage, pages) => lastPage.nextCursor, getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor, maxPages: 3, })底层实现中,addToEnd/addToStart两个工具函数(见 infiniteQueryBehavior.ts)在追加新页时会把超过maxPages的旧页裁掉,从而让pages数组始终保持在限定长度内。
API 没有 cursor 怎么办:用 pageParam 作为游标
如果后端 API 不返回游标,可以直接把pageParam当作游标使用。因为getNextPageParam与getPreviousPageParam还能拿到当前页的pageParam,你完全可以用它计算下一页/上一页的参数:
useInfiniteQuery({ queryKey: ['projects'], queryFn: fetchProjects, initialPageParam: 0, getNextPageParam: (lastPage, allPages, lastPageParam) => { if (lastPage.length === 0) { return undefined } return lastPageParam + 1 }, getPreviousPageParam: (firstPage, allPages, firstPageParam) => { if (firstPageParam <= 1) { return undefined } return firstPageParam - 1 }, })这里lastPage.length === 0表示 API 返回了空数组,即没有更多数据,此时返回undefined让hasNextPage变为false;而firstPageParam <= 1表示已经回到第一页,返回undefined关闭向上翻页。
进阶资源:预取、类型化选项与测试用例
如果你需要在实际项目中进一步使用无限查询,仓库还提供了以下配套能力:
- usePrefetchInfiniteQuery.ts:在组件渲染前预取无限查询(配合 SSR 或路由守卫使用);
- infiniteQueryOptions.ts:提供类型安全的
infiniteQueryOptions()构造器,便于在组件外集中定义并复用无限查询选项; - useInfiniteQuery.test.ts 与 infiniteQueryBehavior.test.tsx:覆盖了分页参数计算、
maxPages裁剪、双向翻页、重取顺序等行为的测试用例,是理解各种边界行为的绝佳参考; - 核心类型定义集中在 infiniteQueryObserver.ts(观察者实现)与 types.ts(
InfiniteData、FetchNextPageOptions等类型)。
综上,useInfiniteQuery在 Vue Query 中提供了一套完整、声明式的无限列表解决方案:只要定义好initialPageParam与getNextPageParam,数据的分页获取、去重、重取与状态标记全部由框架接管;配合maxPages、select、setQueryData等能力,可以覆盖从简单的 "Load More" 到双向聊天记录、受限分页等绝大多数生产场景。
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考