《TypeScript编程实战》14.3 分页、无限滚动与预取

本节把查询层推进到列表场景:先区分页码分页与游标分页的适用场景,再讲清 useInfiniteQuery 的 InfiniteData 类型形状、pageParam 与 getNextPageParam 的类型对齐,以及用 select 扁平化 pages。接着给出基于 IntersectionObserver 的无限滚动封装与 prefetchQuery 预取时机,并说明预取与缓存失效的配合。

本节目标:把查询层从「单条数据」推进到「列表数据」。读完本节,你应该能判断一个列表该用页码分页还是游标分页,能写出类型正确的 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 / pageSizecursor / 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)[] }

三个类型参数值得记清楚:

位置类型来源
TQueryFnDataPostPagequeryFn 返回值
TPageParamstring | nullinitialPageParam
data.pagesPostPage[]累积的所有页
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 集成 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes