首页
/ TanStack Query Preact 无限查询(useInfiniteQuery)实战指南:分页加载、无限滚动与 maxPages 内存控制

TanStack Query Preact 无限查询(useInfiniteQuery)实战指南:分页加载、无限滚动与 maxPages 内存控制

2026-09-08 13:07:20作者:柯茵沙

无限列表是 Web 前端最常见的交互形态之一:用户点击 "Load More" 按钮在已有数据上增量追加新数据,或者在滚动接近底部时自动加载下一页(无限滚动)。TanStack Query 为这类场景提供了 useQuery 的增强版本 useInfiniteQuery,在 Preact 生态中通过 @tanstack/preact-query 包暴露。本文围绕 Preact 框架下的 Infinite Queries 指南 展开(该指南是 React 版本指南 的框架衍生文档,仅替换了包名与框架字眼),结合 preact-query 源码query-core 底层实现,完整讲解无限查询的数据结构、分页参数推导、双向翻页、手动更新、页数上限控制等能力,帮你写出可复用、无竞态、内存可控的无限列表组件。

一、为什么需要 useInfiniteQuery:与普通查询的本质差异

普通 useQuery 每次只维护一份数据;而无限查询需要"增量加载"多组数据并追加到已有列表中。useInfiniteQuerypreact-query 的实现 中本质上是把选项交给 query-core 的 InfiniteQueryObserver 处理:

export function useInfiniteQuery(options, queryClient?) {
  return useBaseQuery(options, InfiniteQueryObserver as typeof QueryObserver, queryClient)
}

也就是说,它复用了 useBaseQuery 中关于订阅、乐观更新、Suspense、错误边界的一切机制,只是在数据形态与分页能力上做了扩展。当你使用 useInfiniteQuery 时,与普通查询相比会观察到以下不同:

  • data 不再是你 queryFn 返回的单页数据,而是一个包含无限查询数据的对象:
    • data.pages:已抓取的各页数据组成的数组;
    • data.pageParams:抓取每一页时实际使用的 page param 组成的数组(与 pages 一一对应);
  • 返回值中新增 fetchNextPagefetchPreviousPage 两个函数(其中 fetchNextPage 为必用);
  • 配置项中新增必填的 initialPageParam,用来指定第一页的初始 page param;
  • 配置项 getNextPageParamgetPreviousPageParam 负责两件事:判断是否还有更多数据可加载,并给出抓取下一页/上一页所需的信息;该信息会以额外的 pageParam 参数传给 queryFn
  • 返回 hasNextPage 布尔值:当 getNextPageParam 返回非 null/undefined 时为 true
  • 返回 hasPreviousPage 布尔值:当 getPreviousPageParam 返回非 null/undefined 时为 true
  • 返回 isFetchingNextPageisFetchingPreviousPage 布尔值,用于区分"后台刷新状态"与"正在加载更多状态"。

注意:如果使用了 initialDataplaceholderData,其结构必须与上述无限查询数据一致,即包含 data.pagesdata.pageParams 两个属性的对象。在 query-core 的类型定义 中,这一结构被抽象为 InfiniteData<TData, TPageParam>{ pages: Array<TData>, pageParams: Array<TPageParam> },这正是整个无限查询缓存中的单一数据形态。

二、第一个例子:基于 cursor 的 "Load More" 列表

假设有一个按 cursor 索引每次返回 3 条 projects 的 API,并同时返回可抓取下一组的 cursor:

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: [...] }   // 没有 nextCursor,说明已到末尾

基于这一信息构造 "Load More" UI 只需三步:

  1. 默认等待 useInfiniteQuery 抓取第一组数据;
  2. getNextPageParam 中返回下一次请求所需的信息(下一个 cursor);
  3. 需要加载更多时调用 fetchNextPage

下面是在 Preact 中的完整组件实现(与 Preact Infinite Queries 指南 中的示例一致,包名为 @tanstack/preact-query):

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

function Projects() {
  const fetchProjects = async ({ pageParam }) => {
    const res = await fetch('/api/projects?cursor=' + pageParam)
    return res.json()
  }

  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  })

  return status === 'pending' ? (
    <p>Loading...</p>
  ) : status === 'error' ? (
    <p>Error: {error.message}</p>
  ) : (
    <>
      {data.pages.map((group, i) => (
        <div key={i}>
          {group.data.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </div>
      ))}
      <div>
        <button
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetching}
        >
          {isFetchingNextPage
            ? 'Loading more...'
            : hasNextPage
              ? 'Load More'
              : 'Nothing more to load'}
        </button>
      </div>
      <div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
    </>
  )
}

关键点说明:

  • queryFn 的入参对象带有 pageParam,就是由 initialPageParam(首页)或 getNextPageParam 推导出的 cursor;
  • data.pages 需要外层循环"每一页"、内层循环"页内条目"进行渲染,渲染层的 key 建议取在页内条目的唯一 id 上(如上例 project.id);
  • disabled={!hasNextPage || isFetching} 与按钮文案的 isFetchingNextPage 分支共同保证了在无更多数据或正在请求时不触发重复加载。

三、必须避开的竞态:无限查询同一时刻只能有一次在途请求

必须深刻理解:当有请求正在进行时再调用 fetchNextPage,存在覆盖后台数据刷新结果的隐患。这在"一边渲染列表、一边触发 fetchNextPage"的场景下尤其关键。

原因为:一个 Infinite Query 同一时刻只能存在一次在途请求。整份分页数据(所有页)共享同一个缓存条目,如果同时发起两次抓取,就可能导致数据互相覆盖、丢失某次抓取结果。若你确实需要允许多个请求并发,可以在调用 fetchNextPage 时传入 { cancelRefetch: false } 选项(默认值为 true,即默认会取消上一次未完成的抓取)。

为了让查询过程顺畅无冲突,强烈建议在调用加载函数前确认查询不处于 isFetching 状态——尤其是当调用不是由用户直接触发时(例如滚动事件):

<List onEndReached={() => hasNextPage && !isFetching && fetchNextPage()} />

infiniteQueryBehavior 的实现 中可以看到,fetchNextPage/fetchPreviousPage 最终都会在 fetch options 的 meta.fetchMore.direction 上标记方向('forward'/'backward'),InfiniteQueryObserver 再据此决定抓取下一页还是上一页。而 createResult 正是通过判断"当前状态是否在抓取 + 抓取方向"来推导 isFetchingNextPageisFetchingPreviousPage 这两个布尔值的,这从源码层面印证了区分"加载更多"与"后台刷新"的实现方式。

四、无限查询被自动 refetch 时会发生什么

当无限查询变为 stale 并需要重新抓取时,每一组页面会被"顺序地"逐一重新抓取,从第一页开始。这么做是有意为之:即使底层数据已被修改,也不会继续使用陈旧的 cursor 去抓取后续页面,从而避免出现重复数据或跳漏记录。从 fetchFn 的实现 可以看到,重新抓取时并不直接沿用缓存里旧 nextCursor,而是每抓完一页都基于"最新已抓到的结果"通过 getNextPageParam(options, result) 实时推导下一页的 param,直到页数抓完为止。

另外,如果无限查询的结果被移出了 queryCache(例如组件卸载超过 gcTime、缓存被清理或手动移除),那么分页会从头重新开始:只请求初始的那一组数据。对应到代码,当 oldPages 为空时循环次数按旧页数计为 0,会退回用 oldPageParams[0] ?? options.initialPageParam 抓取初始页。

五、双向无限列表:向前与向后翻页

需要"上滑翻更早的数据、下滑翻更新的数据"的双向列表,可借助 getPreviousPageParamfetchPreviousPagehasPreviousPageisFetchingPreviousPage 这组属性与函数实现:

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})

其中 getNextPageParam 接收 (lastPage, pages),作用于"最后一页"来推导向后的 cursor;getPreviousPageParam 接收 (firstPage, pages),作用于"第一页"来推导向前的 cursor。判断是否有上一页的逻辑在 hasPreviousPage 中:只有存在 getPreviousPageParam 且其推导结果不为空时才为 true

六、想倒序展示页面?用 select 派生数据

如果希望页面以倒序显示(例如时间线类型的最新在前),可以使用 select 选项对 data 做一次派生转换。注意 select 只是替换了订阅到组件的结果,并不会改动缓存:

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  select: (data) => ({
    pages: [...data.pages].reverse(),
    pageParams: [...data.pageParams].reverse(),
  }),
})

七、如何手动更新无限查询

无限查询在缓存中是一个"整体"(包含全部 pagespageParams 的单一数据对象),因此使用 queryClient.setQueryData 手动更新时,必须始终保持 pagespageParams 的结构一致,且两者要同步裁剪,否则后续推导 cursor 时会错位。下面给出几种常见操作。

手动移除第一页

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),
}))

需要强调的是,data.pages 中各页的条目一般是一组对象(如上面的 val.id),而上文 cursor 示例中 group.data 是"页"内的数据数组;具体 filter 的层级取决于你 API 返回的单页结构。

八、限制页数:maxPages 与 "Limited Infinite Query"

某些场景下你可能希望限制缓存中保存的页数,以改善性能与体验:

  • 用户可能加载大量页面时(内存占用);
  • 当无限查询包含几十页而需要重新抓取时(网络开销:前面提到 refetch 会把所有页顺序重抓一遍)。

解法是使用 Limited Infinite Query:通过 maxPages 选项配合 getNextPageParamgetPreviousPageParam,在需要时向两个方向抓取页面。下面的示例中,查询数据里最多保留 3 页;如果发生 refetch,也只会顺序重抓这 3 页:

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
  maxPages: 3,
})

maxPagestypes.ts 中被注释为"无限查询数据中最多存储的页面数"。它的实际修剪逻辑在 utils.ts 的 addToEnd / addToStart 中:向后加载时新页追加到数组末尾,一旦超过 max 就从头部裁掉最旧的一页(newItems.slice(1));向前加载时新页插入到头部,超过上限则从尾部裁掉最新的一页(newItems.slice(0, -1))。也就是说,maxPages 在双向无限列表中会淘汰"最远离当前视野"的页面。

九、我的 API 不返回 cursor 怎么办

如果后端不返回 cursor,可以直接把 pageParam 本身当作游标使用:由于 getNextPageParamgetPreviousPageParam 的回调同时也能拿到当前页的 pageParam(以及全部页参数数组),你可以基于它做数值运算来推导相邻页。例如按序号翻页的 API,可这样实现(以"当前页为空数组即停止"作为终止条件):

return 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
  },
})

这里 getNextPageParam 返回 undefined 表示"没有下一页";getPreviousPageParamfirstPageParam <= 1 时返回 undefined 表示"没有上一页",这与前文 hasNextPage / hasPreviousPage 用返回值是否为空判断的逻辑完全对应。

十、延伸:把无限查询选项抽成共享的 infiniteQueryOptions

如果你希望同一份无限查询配置既能在组件里用、又能被 queryClient.infiniteQueryqueryClient.prefetchInfiniteQuery 等命令式 API 复用,preact-query 包 提供了与普通 queryOptions 对应的 infiniteQueryOptions 辅助函数:

import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/preact-query'

export const projectsOptions = infiniteQueryOptions({
  queryKey: ['projects'],
  queryFn: ({ pageParam }) => fetchProjects(pageParam),
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextId,
})

function Projects() {
  const { data, isPending, isError, error } = useInfiniteQuery(projectsOptions)
  // ...
}

从源码看,infiniteQueryOptions 本身只是原样返回选项对象(infiniteQueryOptions 实现),它的价值在于类型系统:让 queryKey 携带推导出的数据类型,从而在组件内与命令式调用之间建立强类型的安全通道。

十一、阅读源码的最佳路径

想深入理解本文涉及的底层机制,建议按以下仓库路径阅读:

了解 Infinite Query 在 query-core 内部的完整运作方式(例如 fetchMore 方向如何通过 meta 传递、重抓为何从第一页开始按序执行),还能帮助你更准确地预判其在异常与并发场景下的行为。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391