本节目标:把查询层从「单条数据」推进到「列表数据」。读完本节,你应该能判断一个列表该用页码分页还是游标分页,能写出类型正确的
useInfiniteQuery,能用select把分页结构摊平成数组,并且知道预取应该挂在什么时机上、为什么预取不改变缓存的有效性语义。
14.3 分页、无限滚动与预取
列表页的数据获取比详情页复杂一个量级,因为多了三个变量:翻页状态、累积数据、以及「下一页什么时候来」。先看一个最朴素的页码分页:
function PostTable() {
const [page, setPage] = useState(1);
const { data, isPending } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts({ page }),
});
if (isPending) return <Skeleton />;
return (
<>
<Table rows={data.items} />
<Pager page={page} total={data.total} onChange={setPage} />
</>
);
}
它能工作,但翻页时 isPending 会让整个表格闪成骨架屏——因为新 key 没有任何缓存,data 立刻变回 undefined。本节从这个问题开始。
14.3.1 两种分页模型
在写代码之前先选模型,因为它决定了整个类型形状:
| 维度 | 页码分页 | 游标分页 |
|---|---|---|
| 请求参数 | page / pageSize | cursor / limit |
| 服务端成本 | 深页 OFFSET 越来越慢 | 恒定,走索引定位 |
| 数据变动时 | 会重复或漏项 | 相对稳定 |
| 可跳页 | 支持 | 不支持,只能顺序前进 |
| 适合场景 | 后台表格、可跳转的报表 | 信息流、无限滚动、大表 |
判断标准是用户是否需要跳到第 N 页。后台管理需要,所以用页码;时间线不需要,所以用游标。两者的类型设计完全不同:页码分页的响应里通常带 total,游标分页只带 nextCursor。
14.3.2 页码分页:keepPreviousData 消除闪烁
v5 里这个需求的答案是 placeholderData: keepPreviousData:
import { keepPreviousData, useQuery } from '@tanstack/react-query';
const { data, isPlaceholderData } = useQuery({
queryKey: postKeys.list({ page }),
queryFn: () => fetchPosts({ page }),
placeholderData: keepPreviousData,
});
// 翻页时:data 仍是上一页的数据,isPlaceholderData === true
placeholderData 的语义是「这个值不是真数据,只是临时占位」。所以它有三个可观测的后果,全部是类型层面能看到的:data 不再是 undefined(isPending 不会翻成 true),但 isPlaceholderData 会变 true,isFetching 也会是 true。表格因此可以这样做:
<div className={isPlaceholderData ? 'opacity-60' : ''}>
<Table rows={data.items} />
</div>
比骨架屏体验好得多。注意 placeholderData 不写入缓存——它只是渲染层的占位,缓存里仍然只有真正取回过的页。
14.3.3 用 queryOptions 收拢分页参数
分页查询的参数是对象,直接写进 queryKey 会踩到 《TypeScript编程实战》14.1 TanStack Query 类型推导
里提过的对象哈希问题。key factory 负责把它拆成稳定数组:
export interface PostFilter {
page: number;
pageSize: number;
keyword?: string;
}
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (f: PostFilter) => [...postKeys.lists(), f.page, f.pageSize, f.keyword ?? ''] as const,
detail: (id: string) => [...postKeys.all, 'detail', id] as const,
};
export const postListQuery = (f: PostFilter) =>
queryOptions({
queryKey: postKeys.list(f),
queryFn: () => fetchPosts(f),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
这里把 f.keyword ?? '' 显式写进 key 很关键:省略 undefined 字段会让 { page: 1 } 与 { page: 1, keyword: undefined } 产生不同的 key 长度,缓存会莫名其妙地分成两份。key 里每个位置的含义必须固定。
14.3.4 useInfiniteQuery 的类型形状
无限滚动的核心 API 是 useInfiniteQuery,它的类型与 useQuery 最大的差别在于 data 不是「一页」,而是「一页的数组」:
const q = useInfiniteQuery({
queryKey: postKeys.infinite(),
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, limit: 20 }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// q.data: InfiniteData<PostPage, string | null> | undefined
// 展开即 { pages: PostPage[]; pageParams: (string | null)[] }
三个类型参数值得记清楚:
| 位置 | 类型 | 来源 |
|---|---|---|
TQueryFnData | PostPage | queryFn 返回值 |
TPageParam | string | null | initialPageParam |
data.pages | PostPage[] | 累积的所有页 |
data.pageParams | (string | null)[] | 每页对应的游标 |
initialPageParam 是 v5 强制要求的字段。漏写会得到一条很直白的错误:
error TS2345: Property 'initialPageParam' is missing in type
'{ queryKey: ...; queryFn: ...; getNextPageParam: ... }'
but required in type 'UseInfiniteQueryOptions<...>'.
这不是框架啰嗦,而是为了让 TPageParam 能被推导出来——它是整条链路的锚点。
14.3.5 getNextPageParam 的类型对齐
getNextPageParam 的返回值必须与 TPageParam 兼容,否则 fetchNextPage() 会拿到错误类型的游标。它的签名是:
type GetNextPageParam = (
lastPage: PostPage,
allPages: PostPage[],
lastPageParam: string | null,
allPageParams: (string | null)[],
) => string | null | undefined;
返回 undefined 是终止信号,hasNextPage 会变成 false。所以服务端返回 nextCursor: string | null 时,最常见的写法是原样透传:
const configExcerpt = {
getNextPageParam: (lastPage: PostPage) => lastPage.nextCursor ?? undefined,
};
TanStack Query v5 文档
明确:返回 null 或 undefined 都表示没有下一页。这里归一成 undefined 是项目约定;不能把两者都终止的行为写成只有 undefined 才有效。初始请求可以使用 null,但下一页回调返回 null 的含义是结束。
14.3.6 用 select 把 pages 摊平
组件通常只想要一个数组,而不是嵌套结构。select 是摊平的正确位置:
const { data: posts } = useInfiniteQuery({
...postInfiniteQuery(),
select: (data) => data.pages.flatMap((page) => page.items),
});
// posts: Post[] | undefined
摊平之后有一个必须接受的事实:缓存的失效与写入仍然以 InfiniteData 为粒度。所以 《TypeScript编程实战》14.2 乐观更新与缓存失效
里那套 setQueryData 写法在无限查询上要按页改:
queryClient.setQueryData<InfiniteData<PostPage, string | null>>(postKeys.infinite(), (old) =>
old
? {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((p) => (p.id === id ? { ...p, liked: true } : p)),
})),
}
: old,
);
这种嵌套 map 很容易写错一层。一个更省事的替代方案是:无限列表里不做精确写入,只做失效重取,把乐观更新留给详情页。
14.3.7 无限滚动的触底检测
滚动监听不要写在业务组件里,抽成通用 Hook:
export function useIntersection(onReach: () => void, enabled: boolean) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onReach();
},
{ rootMargin: '200px' },
);
io.observe(el);
return () => io.disconnect();
}, [onReach, enabled]);
return ref;
}
接到查询上时,enabled 必须同时考虑「还有下一页」和「当前没有在取」:
const sentinelRef = useIntersection(
() => void fetchNextPage(),
hasNextPage && !isFetchingNextPage,
);
void 不是装饰:fetchNextPage() 返回 Promise,直接作为 onReach 的回调返回值会触发 ESLint 的 no-misused-promises 规则。rootMargin: '200px' 让哨兵元素提前 200px 触发,用户滚到底时数据往往已经在路上——这就是最朴素的预取。
14.3.8 主动预取:prefetchQuery
真正意义上的预取是「在用户表达意图之前先把数据取回缓存」:
function PostRow({ post }: { post: Post }) {
const qc = useQueryClient();
return (
<Link
to={`/posts/${post.id}`}
onMouseEnter={() => void qc.prefetchQuery(postDetailQuery(post.id))}
onFocus={() => void qc.prefetchQuery(postDetailQuery(post.id))}
>
{post.title}
</Link>
);
}
prefetchQuery 与 useQuery 共享同一套 key,所以预取的结果会被详情页直接命中,用户看到的是「秒开」。三个要点:
- 必须用同一个
queryOptions。手写 key 很容易少一层前缀,预取就白做了。这是queryOptions存在的第二个理由。 staleTime决定预取的价值。若为 0,详情页挂载时仍会立刻重取,预取只省下了首屏骨架的时间;把staleTime设成 30 秒以上,预取才是完整的。onFocus不能省。键盘用户用 Tab 导航时没有 hover 事件,只写onMouseEnter等于把无障碍体验排除在外。
14.3.9 预取的时机清单
| 时机 | API | 说明 |
|---|---|---|
| 悬停 / 聚焦链接 | prefetchQuery | 最常用,收益最高 |
| 路由切换前 | 路由 loader + ensureQueryData | 见第 12 章的 SSR 数据流 |
| 列表渲染后 | useQueries + prefetchQuery | 只对可视区前几项做 |
| 定时刷新 | queryClient.prefetchQuery + setInterval | 配合 staleTime 使用 |
| 当前页取回后 | getNextPageParam + 自动触发 | 无限滚动的「提前一页」 |
其中最后一条最容易做过头。不要在 onSuccess 里无条件递归预取——那会变成「用户滚了 3 屏,请求发了 10 次」。正确的约束是「最多提前一页」,靠 hasNextPage && !isFetchingNextPage 就能自然限制住。
预取与失效是正交的两件事:预取只是「提前把数据放进缓存」,它不改变数据的有效性。staleTime 到了,预取过的数据照样要重取。把这两件事混在一起思考,是无限列表里状态错乱的常见根因。
14.3.10 五个常见坑
一、initialPageParam 漏写。 前面给过报错原文,v5 里它是必填项,类型报错会直接指向缺失的属性名。
二、getNextPageParam 返回 null 导致死循环。 null 不是终止信号,undefined 才是。服务端返回 null 时一定要转成 undefined。
三、placeholderData: keepPreviousData 用在无限查询上。 这个组合语义混乱:无限查询本身就是累积的,再叠加占位数据会让 pages 出现重复项。它只适合单页查询。
四、select 里返回新对象导致重渲染。 select: (d) => d.pages.flatMap((p) => p.items) 每次执行都产出新数组。TanStack Query 内部会做结构共享比较,但把 select 抽成模块级函数仍是更稳的写法。
五、翻页时把页码放进组件 state 又放进 key。 若两者不同步(比如 URL 是真相源而 state 是副本),会出现「URL 显示第 2 页、列表显示第 1 页」。规范做法是让 URL 成为唯一真相源,页码从路由参数读。
14.3.11 与全书其它章节的衔接
分页查询的 key 设计依赖 《TypeScript编程实战》14.1 TanStack Query 类型推导 里的 key factory,失效范围见 《TypeScript编程实战》14.2 乐观更新与缓存失效 。当列表与路由懒加载结合时,预取还能顺带把路由分包拉下来,见 《TypeScript编程实战》15.2 代码分割与 tree-shaking 。若列表数据来自 OpenAPI 生成的客户端,类型由 codegen 保证,见 《TypeScript编程实战》16.2 OpenAPI / GraphQL Codegen 。
站内专题对分页本身有更细的展开,可作为延伸阅读:游标分页设计 、分页 API 实现 、React 性能优化 。列表渲染的性能诊断可读 前端性能调试 。
小结
本节把列表场景拆成三块。模型层:页码分页适合需要跳页的后台,游标分页适合信息流;前者响应带 total,后者带 nextCursor,类型设计因此不同。类型层:useInfiniteQuery 的 data 是 InfiniteData<PostPage, string | null>,pages 是数组、pageParams 与 TPageParam 同构;initialPageParam 是必填的推导锚点,getNextPageParam 返回 undefined(而不是 null)才是终止信号。交互层:keepPreviousData 消除翻页闪烁,IntersectionObserver 做触底检测,prefetchQuery 配合 staleTime 做真正的预取。
一条经验值得记牢:预取只负责提前放数据,不负责让数据变新鲜。把预取和失效分开思考,无限列表里的状态错乱基本可以避免。
到这里,第 14 章的查询层就完整了——从单条查询的类型推导,到写操作的乐观更新,再到列表的分页与预取。下一章转向构建侧,从 《TypeScript编程实战》15.1 Vite 与 TS 集成 开始,看看这些代码是怎么被打包成产物的。
阅读导航:上一节:14.2 乐观更新与缓存失效 · 下一节:15.1 Vite 与 TS 集成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。