跳到主要内容

Infinite Query 与 Cursor Pagination

分页场景一旦从“点下一页”走到“下拉加载更多”,查询模型就会明显变复杂。TanStack Query 在这类场景里给出的主线是 useInfiniteQuery

它适合的通常是这些页面:

  • 信息流
  • 评论列表
  • 聊天记录
  • 商品瀑布流
  • 管理后台的加载更多列表

先分清两种分页思路

页码分页

后端返回:

{
"list": [],
"page": 2,
"pageSize": 20,
"total": 180
}

这种结构更接近传统后台列表。前端会关心当前页码、总页数、上一页和下一页。

Cursor 分页

后端返回:

{
"list": [],
"nextCursor": "eyJpZCI6MTAwMX0=",
"hasMore": true
}

这里不再强调“第几页”,而是强调“从哪一条之后继续拿”。信息流、聊天、动态列表更常用这套。

Cursor 分页的好处通常有两点:

  • 新数据插入后,分页稳定性更好
  • 不容易因为页码漂移而重复或漏数据

最基础的 useInfiniteQuery

import { useInfiniteQuery } from '@tanstack/react-query'

function usePostFeed() {
return useInfiniteQuery({
queryKey: ['posts'],
initialPageParam: null as string | null,
queryFn: ({ pageParam }) => fetchPostFeed({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
}

这里最重要的是 3 个点:

  • initialPageParam:第一页从哪里开始
  • queryFn:每次根据 pageParam 去拿下一段数据
  • getNextPageParam:告诉 TanStack Query 下次该从哪里继续

一个更完整的 Cursor 例子

interface FeedItem {
id: string
title: string
}

interface FeedPage {
list: FeedItem[]
nextCursor: string | null
hasMore: boolean
}

async function fetchFeed(params: { cursor: string | null }): Promise<FeedPage> {
const search = new URLSearchParams()

if (params.cursor) {
search.set('cursor', params.cursor)
}

const res = await fetch(`/api/feed?${search.toString()}`)

if (!res.ok) {
throw new Error('request failed')
}

return res.json()
}

export function useFeed() {
return useInfiniteQuery({
queryKey: ['feed'],
initialPageParam: null as string | null,
queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam }),
getNextPageParam: (lastPage) => {
if (!lastPage.hasMore) return undefined
return lastPage.nextCursor ?? undefined
},
})
}

页面消费时,一般会把所有页拍平:

function FeedList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useFeed()

const items = data?.pages.flatMap((page) => page.list) ?? []

return (
<>
<ul>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>

{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
)}
</>
)
}

页码分页怎么写

如果后端接口仍然是 page / pageSize 结构,也能继续用 useInfiniteQuery

useInfiniteQuery({
queryKey: ['orders', filters],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
fetchOrders({
page: pageParam,
pageSize: 20,
...filters,
}),
getNextPageParam: (lastPage) => {
const { page, total, pageSize } = lastPage
const totalPage = Math.ceil(total / pageSize)

return page < totalPage ? page + 1 : undefined
},
})

这种写法能用,但如果列表数据实时变化明显,Cursor 分页通常更稳。

和普通 useQuery 的区别

useQuery 更像“一次取一页,然后替换旧页”。

useInfiniteQuery 更像“保留前面已经拿到的页,再继续往后接”。

所以这两类场景不要混:

  • 传统表格分页:useQuery 通常就够了
  • 加载更多、下拉滚动:useInfiniteQuery 更顺

getNextPageParam 怎么写才稳

这一段最容易出问题。建议先看后端到底返回哪种信号:

  • nextCursor
  • nextPage
  • hasMore
  • total

前端最好只认一个明确规则,不要自己猜。

例如:

getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined

或者:

getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.page + 1 : undefined

无限滚动怎么接

加载更多按钮是第一步。继续往下走到无限滚动时,通常会配 IntersectionObserver

useEffect(() => {
if (!targetRef.current || !hasNextPage) return

const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
fetchNextPage()
}
})

observer.observe(targetRef.current)

return () => observer.disconnect()
}, [fetchNextPage, hasNextPage, isFetchingNextPage])

这种写法最该防的一件事,是重复触发。

常见保护条件:

  • hasNextPage
  • isFetchingNextPage
  • 接口层本身幂等

查询键怎么设计

Infinite Query 的 queryKey 仍然要把筛选条件带进去。

queryKey: ['feed', filters]

否则条件变了,但缓存还在沿用旧分页,列表很容易错位。

列表更新后怎么处理缓存

Infinite Query 的缓存结构比普通查询多一层:

{
pages: [],
pageParams: []
}

如果要手动更新缓存,通常会这么写:

queryClient.setQueryData(['feed'], (old: any) => {
if (!old) return old

return {
...old,
pages: old.pages.map((page: any, index: number) => {
if (index !== 0) return page

return {
...page,
list: page.list.map((item: any) =>
item.id === nextItem.id ? nextItem : item
),
}
}),
}
})

这里最容易忽略的,是不能只改 pages[0].list 的心智模型。要先确认:

  • 这条数据到底可能出现在第几页
  • 只改当前页够不够
  • 是否应该直接 invalidateQueries

常见踩坑点

1. 忘了写 initialPageParam

v5 里这是必填项,漏掉后查询模型会不完整。

2. queryKey 没带筛选条件

这是最常见的缓存串页来源。

3. getNextPageParam 返回错了

一旦这里判断不准,就会出现:

  • 永远还有下一页
  • 明明还有数据却提前结束
  • 重复请求同一页

4. 无限滚动里没有挡重复触发

滚到底部时,多次交叉触发会把请求打爆。

5. 没看清接口结构,就把页码分页当成 Cursor 分页

如果后端本质是页码接口,前端也没必要硬凑 Cursor 心智。先把真实数据结构认清,再决定用哪种写法。

什么时候更适合先别做 infinite query

  • 列表总量不大
  • 传统表格分页更清楚
  • 后端接口只支持普通页码
  • 列表交互复杂,且每页需要独立状态

这类场景里,用 useQuery 做普通分页,往往比上来就做 infinite query 更省心。

相关文章