Preact Query 的 infiniteQueryOptions:以强类型配置工厂串联 useInfiniteQuery 与命令式 API
导读
在 TanStack Query(本仓库中的 Preact Query 实现,包名 @tanstack/preact-query)中,无限滚动/分页加载通常由 useInfiniteQuery 完成;而 infiniteQueryOptions 则是一个类型安全的配置工厂函数,把「一份可用于无限查询的 options 对象」从组件内部抽离出来,使其能在 useInfiniteQuery、useSuspenseInfiniteQuery、预取辅助以及 queryClient.infiniteQuery 等命令式 API 之间自由复用。读完本文,你将掌握 infiniteQueryOptions 的三个函数重载各自适用于什么场景、initialData 与 skipToken 如何影响返回的数据类型,以及如何用参数化工厂模式为不同业务键复用同一份查询配置,并理解其背后的类型标记(DataTag)与分页参数流转原理。
infiniteQueryOptions 是什么,为什么要使用它
infiniteQueryOptions 的定位非常清晰:凡是能传给 useInfiniteQuery 的选项,都可以传给 infiniteQueryOptions。它的输入输出在运行时几乎不做任何加工,源码中最终实现只是把 options 原样返回:
// packages/preact-query/src/infiniteQueryOptions.ts
export function infiniteQueryOptions(options: unknown) {
return options
}
真正的价值全部集中在类型层面:调用后返回的对象既保留了全部可用的配置字段,又通过 queryKey 携带上已推断的数据类型标签,让同一份 options 对象在不同 API 之间传递时不会丢失类型信息。正因如此,它非常适合在以下场景使用:
- 把无限查询的配置从组件中提取出来,与
useInfiniteQuery解耦,便于集中维护; - 同一配置同时供 Hook 与
queryClient.infiniteQuery(预取、prefetchInfiniteQuery等命令式入口)复用; - 配合
useSuspenseInfiniteQuery做数据预取,或将配置直接交给预取辅助函数。
其定义位于 packages/preact-query/src/infiniteQueryOptions.ts,相关的 API 文档见 docs/framework/preact/reference/functions/infiniteQueryOptions.md。
与 queryOptions 的对应关系
如果你熟悉 queryOptions(普通查询的同类工具,见 packages/preact-query/src/queryOptions.ts),可以把 infiniteQueryOptions 理解为它在无限查询领域的孪生版本。区别在于:
- 普通查询的数据形态是单页数据;无限查询的数据被包成
InfiniteData<TData, TPageParam>(见下方说明); - 无限查询额外要求
initialPageParam与getNextPageParam等分页相关配置; - 无限查询的配置类型参数多出一个
TPageParam,用于描述每次翻页传给queryFn的参数。
三个重载与类型参数全解
infiniteQueryOptions 在类型层面定义了三个重载,分别应对「是否设置了 initialData」「queryFn 是否允许为 skipToken」等不同形态,从而在编译期为每种用法推导出最精确的返回类型。文档中给出的签名如下:
function infiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>(options): UseInfiniteQueryOptions<...> & object & QueryKeyWithDataTag<TQueryKey, InfiniteData<TQueryFnData, unknown>, TError>
| 重载位置 | 触发条件 | options 参数类型 | 关键约束 |
|---|---|---|---|
| 第一个 | 设置了 initialData |
DefinedInitialDataInfiniteOptions |
initialData 必填,data 永不为 undefined |
| 第二个 | 未设置 initialData 且 queryFn 不能是 skipToken |
UnusedSkipTokenInfiniteOptions |
queryFn 排除了 SkipToken |
| 第三个 | 未设置 initialData(最通用) |
UndefinedInitialDataInfiniteOptions |
queryFn 可为普通函数,数据可能处于 pending |
三个重载的实现位置分别在 infiniteQueryOptions.ts 的第 171、233、295 行附近,对应的 options 类型别名文档可分别参阅 DefinedInitialDataInfiniteOptions、UnusedSkipTokenInfiniteOptions 与 UndefinedInitialDataInfiniteOptions。
五个泛型参数逐一说明
| 类型参数 | 默认值 | 含义 |
|---|---|---|
TQueryFnData |
无(必推) | 单页数据的类型,即你的 queryFn 解析出的结果类型 |
TError |
DefaultError(默认即 Error) |
queryFn 可能抛出的错误类型 |
TData |
InfiniteData<TQueryFnData, unknown> |
经 select 处理后 data 的最终类型;默认是「所有已抓取页面 + 页码参数」的聚合形态 |
TQueryKey |
readonly unknown[](即 QueryKey) |
查询键的类型 |
TPageParam |
unknown |
传给 queryFn 用于抓取某一页的页码参数类型 |
其中 TData 的默认形态 InfiniteData 在 packages/query-core/src/types.ts 中定义得非常直观:
export interface InfiniteData<TData, TPageParam = unknown> {
pages: Array<TData>
pageParams: Array<TPageParam>
}
也就是说,无限查询的 data 永远由「页面内容数组 pages」和「每次翻页用到的参数数组 pageParams」两部分组成,pageParams 中记录了每一页分别是用哪个参数抓回来的,二者按序一一对应。
返回值的类型标记
无论命中哪个重载,函数都返回 同一个 options 对象,只是返回类型中叠加了 QueryKeyWithDataTag<TQueryKey, InfiniteData<TQueryFnData, unknown>, TError>,让 queryKey 携带数据与错误类型标记。QueryKeyWithDataTag 在 query-core/src/types.ts 中的定义是:
export type QueryKeyWithDataTag<
TQueryKey extends QueryKey = QueryKey,
TQueryFnData = unknown,
TError = DefaultError,
> = {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>
}
DataTag 通过特殊符号把「该查询键对应什么数据、什么错误」烙在 queryKey 的类型上。这样一来,当同一份 options 被传入 queryClient 的缓存读取类方法时,编译器也能从 query key 直接反推出 data 与 error 的类型,而不需要再手动补泛型——这正是「配置定义一次、处处类型安全」的核心机制。
快速上手:把配置抽到组件外
官方文档提供的第一个示例,展示了最基本的用法:把无限查询配置定义成模块级的 projectsOptions,再原样交给 useInfiniteQuery:
import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/preact-query'
export const projectsOptions = infiniteQueryOptions({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextId,
initialData: { pages: [], pageParams: [] },
})
function Projects() {
// `data` is never `undefined`, thanks to `initialData` — even if a refetch fails, so the
// list stays visible alongside the error.
const { data, isError, error } = useInfiniteQuery(projectsOptions)
return (
<div>
{isError ? <span>Error: {error.message}</span> : null}
<ul>
{data.pages.map((page) => page.projects.map((p) => <li key={p.id}>{p.name}</li>))}
</ul>
</div>
)
}
要点解读:
queryKey是必填项,它是生成配置的依据;initialPageParam: 0定义了第一页使用的页码参数;getNextPageParam: (lastPage) => lastPage.nextId从上一页结果中取出下一页参数,供下一次抓取使用;- 该示例命中了「设置
initialData」的重载,所以data类型为DefinedInitialDataInfiniteOptions对应的结果类型——即便重抓失败,列表仍会随错误一起保持可见。
useInfiniteQuery 的完整文档见 docs/framework/preact/reference/functions/useInfiniteQuery.md,其实现位于 packages/preact-query/src/useInfiniteQuery.ts。从实现注释可以确认,它能接受 infiniteQueryOptions 生成的配置对象,并返回比普通 useQuery 更丰富的字段:除 data.pages、data.pageParams 外,还包含 fetchNextPage、fetchPreviousPage、hasNextPage、hasPreviousPage、isFetchingNextPage、isFetchingPreviousPage 等。
设置 initialData 的重载:让 data 永不为空
第一个重载要求 initialData 存在。它对应的 options 类型是 DefinedInitialDataInfiniteOptions,其中的 initialData 有三种合法写法(见 infiniteQueryOptions.ts):
initialData:
| NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>
| (() => NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>)
| undefined
源码注释(同样保留在 DefinedInitialDataInfiniteOptions 文档中)对其语义作了精确说明:
initialData会被用作查询缓存的初始数据——前提是该查询尚未被创建或尚未有缓存;- 如果传入的是函数,函数只会在共享/根查询初始化期间被调用一次,且必须同步返回初始数据;
initialData默认被视为过期数据(stale),除非设置了staleTime;initialData会持久化写入缓存。
也就是说,它不是组件挂载后为了“好看”临时塞进渲染层的占位符,而是会真实参与缓存与过期判断的真实数据。设置 initialData 后,类型层面即可保证 data 不会是 undefined,这也是它与「无 initialData 重载」最本质的类型差异。
未设置 initialData 的重载与 skipToken 的边界
如果你没有设置 initialData,编译器会在剩余两个重载中继续区分 queryFn 是否允许是 skipToken。最通用的第三个重载(UndefinedInitialDataInfiniteOptions)对应标准形态:查询处于 pending 时 data 可能是 undefined,因此组件里通常要先用 isPending 做加载判断。
值得注意的是第二个重载 UnusedSkipTokenInfiniteOptions:它排除了 skipToken 作为 queryFn 的可能,并借助 OmitKeyof<..., 'queryFn'> 对 queryFn 字段做了收紧。为什么这样设计?类型定义中给出的原话值得逐句细读(见 infiniteQueryOptions.ts):
skipTokenis not allowed as a value here — this overload is selected when noinitialDatais set. If you don't intend to run the query yet, setenabled: false— omittingqueryFnalone still triggers a fetch that fails with "Missing queryFn" unlessenabledisfalseor a default query function has been defined.
翻译成实践结论:
- 在无限查询语境下,
skipToken与initialData不应同时使用,二者走的是不同的重载分支; - 如果暂时不想发起请求,正确做法是设置
enabled: false,而不是省略queryFn——省略queryFn仍会触发抓取并因 “Missing queryFn” 失败(除非enabled为false); - 即便定义了默认查询函数(default query function),它也只会补上
queryFn,本身不会推迟请求的发起。
参数化工厂模式:一份工厂,多个业务键复用
对于评论列表这类「每个业务实体(如 postId)都有一套独立无限查询」的场景,文档给出了参数化工厂的推荐写法。它实际上返回一个函数,每次传入不同的 postId 就生成一份带独立 queryKey 的 options:
import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/preact-query'
export const commentsOptions = (postId: string) =>
infiniteQueryOptions({
queryKey: ['post', postId, 'comments'],
queryFn: ({ pageParam }) => fetchComments(postId, pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextId,
})
function Comments({ postId }: { postId: string }) {
const { data, isPending, isError, error } = useInfiniteQuery(commentsOptions(postId))
if (isPending) return 'Loading...'
if (isError) return <span>Error: {error.message}</span>
return (
<ul>
{data.pages.map((page) => page.comments.map((c) => <li key={c.id}>{c.text}</li>))}
</ul>
)
}
该模式的核心收益:
- 每个
postId拥有以['post', postId, 'comments']为键的独立缓存,互不串扰; - 配置集中在一处定义,若后续要为评论增加预取,只需把
commentsOptions(postId)同样传给预取 API 即可; - 由于 query key 携带了
DataTag,跨 API 复用时数据/错误类型可自动推断。
useInfiniteQuery 还支持用 skipToken 暂时禁用查询,直到 postId 就绪,详细可参见 useInfiniteQuery 文档中的相关示例。
跨 Hook 与命令式 API 复用:预取与 Suspense
infiniteQueryOptions 文档反复强调:同一份 options 可以被 Hook 与命令式 API 共享。仓库中 preact-query 提供了现成的消费方,全部接受该工厂生成的配置对象:
1. usePrefetchInfiniteQuery——见 packages/preact-query/src/usePrefetchInfiniteQuery.tsx 与 usePrefetchInfiniteQuery 文档。它专门用来在 Suspense 边界之前、渲染期间发起预取,本身不返回任何值。实现上有一个值得注意的细节:它会先检查 client.getQueryState(options.queryKey),只有查询没有任何缓存状态(包括上次遗留的 pending/error 状态)时才执行 client.infiniteQuery(options),所以即便每次渲染都调用它,也不会重复抓取已有或进行中的数据:
const client = useQueryClient(queryClient)
if (!client.getQueryState(options.queryKey)) {
void client.infiniteQuery(options).catch(noop)
}
其文档示例正是直接接收 infiniteQueryOptions 生成的 projectsOptions:
import { Suspense } from 'preact/compat'
import { infiniteQueryOptions, usePrefetchInfiniteQuery } from '@tanstack/preact-query'
const projectsOptions = infiniteQueryOptions({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextId,
})
function App() {
// Fire the prefetch during render, before the suspense boundary below.
usePrefetchInfiniteQuery(projectsOptions)
return (
<Suspense fallback={<h1>Loading projects...</h1>}>
<Projects />
</Suspense>
)
}
注意预取场景的约束:queryKey、initialPageParam、getNextPageParam 始终必填,queryFn 在未定义默认查询函数时也必填,且 queryFn 不允许为 skipToken(见 types.ts 中 UsePrefetchInfiniteQueryOptions 的定义)。
2. useSuspenseInfiniteQuery——Suspense 形态的无限查询 Hook,文档见 docs/framework/preact/reference/functions/useSuspenseInfiniteQuery.md。它的 options 类型 UseSuspenseInfiniteQueryOptions 与 UseInfiniteQueryOptions 几乎一致,只是剔除了 enabled、throwOnError、placeholderData(Suspense Hook 无法渲染“禁用”或“占位”状态),且同样不允许 queryFn 为 skipToken(见 types.ts)。
3. queryClient.infiniteQuery 等命令式入口——由于类型层面共享,任何拿到 infiniteQueryOptions(...) 返回值的地方都可以把它作为参数传给命令式 API(如 queryClient.prefetchInfiniteQuery / infiniteQuery)。需要留意官方在 useInfiniteQuery.ts 中给出的提醒:命令式发起的 fetchNextPage 等调用可能与默认的自动重抓行为相互干扰,导致数据过期,因此应当只在用户交互回调中调用,或配合 hasNextPage && !isFetching 这样的条件。
底层原理:页码参数在无限查询中如何流转
infiniteQueryOptions 本身不执行抓取,它只是把分页策略声明成数据。真正的执行逻辑在 query-core 中,理解它有助于你正确书写 getNextPageParam。
从 packages/query-core/src/types.ts 可以看到 GetNextPageParamFunction 的完整签名:
export type GetNextPageParamFunction<TPageParam, TQueryFnData = unknown> = (
lastPage: TQueryFnData,
allPages: Array<TQueryFnData>,
lastPageParam: TPageParam,
allPageParams: Array<TPageParam>,
) => TPageParam | undefined | null
它最多接收四个实参:最后一页数据、全部页面数据、最后一个页码参数、全部页码参数;返回值若为 undefined 或 null,则代表没有下一页。InfiniteData.pages 与 .pageParams 正是由这类函数逐页驱动填充的。
packages/query-core/src/infiniteQueryBehavior.ts 负责把这些声明转换为实际抓取行为,其中:
- 首屏会使用
initialPageParam(在无已有页码参数时兜底取oldPageParams[0] ?? options.initialPageParam); - 抓取单页时会把
pageParam写入queryFn的执行上下文(context.fetchFn); getNextPageParam与hasNextPage协作决定是否还有下一页。
这些行为被大量测试覆盖,例如 packages/query-core/src/tests/infiniteQueryObserver.test.tsx 中验证了:getNextPageParam 返回 undefined 或 null 时停止继续抓取下一页、initialPageParam 为 null 时也能正常抓取首页、以及页面为空时不调用 getNextPageParam 等边界情况。类型层面的约束同样有类型测试支撑(见 packages/query-core/src/tests/infiniteQueryObserver.test-d.tsx),例如「不传 getNextPageParam 时不允许再传 pages」。
总结:何时使用 infiniteQueryOptions
- 多个消费方需要共享同一份无限查询配置(组件内 Hook + 渲染期预取 + 命令式预取)时,务必使用
infiniteQueryOptions封装一次,而不是在 Hook 里内联重复书写; - 依赖查询键推断数据类型的场景:返回对象带
DataTag标记,能让queryClient的缓存读写免去手写泛型; - 需要
data永不 undefined 时,选择带initialData的重载,但要清楚它会以 stale 状态写入缓存; - 暂时不发起请求时不要用省略
queryFn的方式,应显式设置enabled: false; - 若仅在某一个组件内部使用且无需预取/命令式共享,直接内联写
useInfiniteQuery({...})也完全合法——infiniteQueryOptions的价值在于复用,而非强制。
实现层面,它的运行时仅是恒等返回、全部智能由类型系统承载,因此把它当作纯编译期工具来理解即可:一份配置、处处复用、处处类型安全。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00