TanStack Query React 的 useQueries:批量并发查询、结果合并与类型推导实战指南
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
导读
useQueries是 TanStack Query 中用于一次性获取数量可变的一组查询的核心 Hook,典型的应用场景是"根据一组动态 id 批量拉取详情数据"。本文基于 react-query 源码 与 query-core 的 QueriesObserver 实现,完整讲解它的签名、参数语义、combine结果合并与引用稳定性、subscribed订阅控制、placeholderData行为差异,以及 inlineselect类型推导失效这一知名 TypeScript 限制及其解决方案。读完本文,你将能写出类型安全、性能可控的批量查询代码,并理解其底层运行机制。
1. 函数签名与核心定位
useQueries的定义位于 react-query/src/useQueries.ts:354,签名如下:
function useQueries< T extends Array<any>, TCombinedResult = QueriesResults<T>, >( { queries, combine?, subscribed?, }: { queries: | readonly [...QueriesOptions<T>] | readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries<T[K]> }] combine?: (result: QueriesResults<T>) => TCombinedResult subscribed?: boolean }, queryClient?: QueryClient, ): TCombinedResult核心定位与useQuery的区别在于:
- 数量可变:
queries接收一个"查询选项对象数组",每个对象的形态与useQuery的选项几乎完全一致,唯一例外是每个查询内不接受queryClient选项——因为QueryClient是在顶层作为第二个参数传入的(或从最近的上下文 Provider 中获取)。 - 批量执行:底层不是简单地多次调用
useQuery,而是实例化一个 QueriesObserver,由它统一管理一组QueryObserver。 - 返回结果数组:未传
combine时,返回值是查询结果数组,顺序与输入queries数组一一对应;传入combine后,返回combine的返回值。
在 useQueries.test.tsx:25-64 中可以看到最基础的行为验证:两个不同 queryKey 的查询分别以不同延迟返回数据,useQueries在三次渲染中依次产出[{data:undefined},{data:undefined}]→[{data:1},{data:undefined}]→[{data:1},{data:2}],证明结果数组始终与输入顺序严格对应,且每个查询独立演进自己的状态机。
2. 基础用法:动态批量查询
文档给出的第一个示例是最典型的场景——根据一组动态id拉取文章列表。源码中queries: ids.map(...)每次渲染都会生成一个新数组,这正好展示了useQueries处理"数量可变"查询的灵活性:
import { useQueries } from '@tanstack/react-query' function Posts({ ids }: { ids: Array<number> }) { const postQueries = useQueries({ queries: ids.map((id) => ({ queryKey: ['post', id], queryFn: () => fetchPost(id), staleTime: Infinity, })), }) return ( <ul> {postQueries.map((query, index) => { if (query.isPending) return <li key={ids[index]}>Loading...</li> if (query.isError) return <li key={ids[index]}>Error: {query.error.message}</li> return <li key={ids[index]}>{query.data.title}</li> })} </ul> ) }2.1 去重与数据共享的注意事项
文档明确警告:如果queries数组中出现相同的 query key 不止一次,可能会导致数据在查询之间被共享(同一个QueryObserver被复用)。在开发环境下,QueriesObserver.setQueries 会检测到重复 queryHash 并输出控制台警告:
[QueriesObserver]: Duplicate Queries found. This might result in unexpected behavior.从源码 queriesObserver.ts:261-294 的#findMatchingObservers可以看到:新查询会按queryHash去匹配并复用已有的 observer,相同 hash 的查询共享同一个 observer 状态。因此文档建议:先去重查询,再把结果映射回你期望的结构。例如ids本身有重复值时,应先unique再去map。
2.2 内部运行机制(源码视角)
useQueries.ts:390-451 展示了 Hook 的主流程:
defaultedQueries通过client.defaultQueryOptions为每个查询选项补充默认值,并根据isRestoring/subscribed设置_optimisticResults,保证"结果在订阅前就已处于拉取状态";useState(() => new QueriesObserver(client, defaultedQueries, options))只创建一次 observer,之后通过useEffect中的observer.setQueries(...)同步最新的查询选项;- 借助
useSyncExternalStore订阅 observer(订阅逻辑经过notifyManager.batchCalls批量合并通知)。
这也解释了为什么useQueries支持数量动态变化的查询:每次渲染setQueries都会调用#findMatchingObservers做 observer 复用与回收(queriesObserver.ts:100-151),只有长度或索引发生变化(hasStructuralChange)时才销毁/新建 observer。
3. 使用 combine 合并结果为单一值
多个查询的独立结果数组在多数场景下并不好用,combine选项可以把它们合并成一个聚合值,并做结构共享(structural sharing),使其引用尽可能稳定:
import { useQueries } from '@tanstack/react-query' function Posts({ ids }: { ids: Array<number> }) { const { data, isPending, isError } = useQueries({ queries: ids.map((id) => ({ queryKey: ['post', id], queryFn: () => fetchPost(id), })), combine: (postQueries) => { return { data: postQueries.map((query) => query.data), isPending: postQueries.some((query) => query.isPending), isError: postQueries.some((query) => query.isError), } }, }) if (isPending) return 'Loading...' if (isError) return 'Error loading posts' return ( <ul> {data.map((post) => ( <li key={post?.id}>{post?.title}</li> ))} </ul> ) }3.1 combine 的底层实现:何时重新执行
#combineResult(queriesObserver.ts:215-248)是核心逻辑。只有当以下任一条件满足时,combine才会重新执行:
- 底层原始结果
#result的引用发生了改变(即某个查询的结果真的更新了); - 查询的
queryHash列表发生了变化(查询数量或 key 变了); combine函数本身的引用发生了变化。
而计算出的聚合结果会经过replaceEqualDeep(深度相等比较)处理,若新旧聚合值在结构上相等,则复用旧引用——这就是文档所说"结果会被结构共享,尽可能保持引用稳定"的含义。测试 useQueries.test.tsx:817-871 验证了"combine 返回稳定引用时组件不重新渲染、返回不同引用时才重新渲染"。
3.2 性能陷阱:内联 combine 每次渲染都会执行
文档特别强调:内联定义的combine每次渲染都会重新执行,因为每次渲染它的函数引用都不同(满足上述条件 3)。测试 useQueries.test.tsx:674-737 实测了"稳定引用 combine 的优化":用React.useCallback包裹后,无关重渲染不会触发combine重新执行(useQueries.test.tsx:993-1066 有专门用例);而引用变化时确实会重跑(useQueries.test.tsx:753-813)。
因此生产代码应遵循:
const combine = React.useCallback((postQueries: Array<QueryObserverResult>) => { return { data: postQueries.map((query) => query.data), isPending: postQueries.some((query) => query.isPending), isError: postQueries.some((query) => query.isError), } }, [])若combine没有依赖,也可以直接提取为模块级稳定函数。此外还有两个相关的工程细节:
- 属性跟踪:
#trackResult(queriesObserver.ts:193-213)会把组件实际访问过的属性在所有 observer 上同步跟踪,保证渲染最小化;测试 useQueries.test.tsx:490 和 useQueries.test.tsx:926 验证了"通过 combine 也能正确进行属性跟踪"。 - 过期闭包问题:测试 useQueries.test.tsx:630-673 覆盖了 #6648 的 stale closure 回归用例,确保 combine 不会读到旧状态。
4. subscribed:控制对查询缓存的订阅
subscribed是useQueries顶层的布尔选项,默认true:
| 取值 | 行为 |
|---|---|
true(默认) | observer 订阅查询缓存更新,结果会随缓存变化而更新 |
false | 该 observer取消订阅查询缓存的更新 |
在源码 useQueries.ts:388 中,const subscribed = options.subscribed !== false,随后在 useQueries.ts:398-402 影响_optimisticResults的取值,并在 useQueries.ts:433-444 决定是否真正执行observer.subscribe(...)(不订阅时退化为noop)。
注意:subscribed只在useQueries顶层接受,单个查询对象内不接受该选项——这是与useQuery选项集的一个显著差异。测试 useQueries.test.tsx:66-92 验证了subscribed: false时:queryFn不会被调用、isFetching为false、fetchStatus为idle,且查询缓存上不挂载任何 observer。
5. placeholderData:与 useQuery 的行为差异
useQueries同样支持placeholderData,但存在两个与useQuery不同的细节(见 useQueries.ts:42-52 的类型定义):
- 签名不同:这里接受的是
QueriesPlaceholderDataFunction,其回调接收的previousData和previousQuery永远是undefined,而useQuery的 placeholder 函数会收到前一次渲染的数据与查询。 - 原因:因为
queries数组的长度和内容在不同渲染之间可能完全不同,useQueries无法像useQuery那样为每个查询提供"上一次渲染的同 key 查询信息"。
换句话说,在useQueries中placeholderData只能提供静态占位值(或基于参数自身计算的函数形式),不能跨渲染复用先前数据。
6. 类型参数与返回值类型推导
6.1 泛型参数
| 参数 | 约束/默认 | 说明 |
|---|---|---|
T | 必须是数组(Array<any>) | queries数组的完整类型,驱动后续所有推导 |
TCombinedResult | 默认QueriesResults<T> | 无combine时即查询结果数组类型;有combine时为它的返回类型 |
6.2 QueriesOptions / QueriesResults 的递归推导
从 useQueries.ts:156-223 可以看到两个对外导出的工具类型:
QueriesOptions<T>:把每个元素逐个解包(unwrapping),让每个查询对象的queryFn/select/throwOnError各自独立推导,最多递归 20 个元素(MAXIMUM_DEPTH = 20,useQueries.ts:55)。QueriesResults<T>:与前者镜像,把每个元素映射为各自的GetUseQueryResult<...>。
推导规则分三部分(useQueries.ts:60-142):
- 若传入显式类型参数(对象形态
{ queryFnData, error, data }或元组形态[TQueryFnData, TError, TData]),按显式类型映射; - 否则从
queryFn、select、throwOnError中推断出TQueryFnData、TData、TError; - 兜底为默认类型。
数组元素的特性还体现在:
- 空数组
T extends []→ 返回[]; - 不定长数组(如
unknown[])→ 原样返回; - 超过 20 个元素的元组或同构数组→ 回退为单一的同构
UseQueryOptionsForUseQueries/UseQueryResult类型。
另外,若查询对象设置了非 undefined 的initialData,对应的结果类型会升级为DefinedUseQueryResult(见GetDefinedOrUndefinedQueryResult,useQueries.ts:97-111),从而保证data非空。
6.3 返回值
- 无
combine:返回QueriesResults<T>,即与输入顺序一致的结果数组; - 有
combine:返回TCombinedResult(combine的返回值)。
7. inline select 的类型推导限制与 queryOptions 解法
这是useQueries最知名的类型陷阱(对应 TanStack Query issue #6556)。文档明确指出:与useQuery不同,useQueries无法从内联查询对象自身的queryFn推导出同一对象内联select的参数类型。原因在于useQueries是一次性对整个queries数组做整体类型推导,内联对象的select参数无法从同对象queryFn获得上下文类型(contextual typing),于是退化为unknown。
同样的限制也适用于 useSuspenseQueries。
两种解决方式:
方式一:显式标注select参数类型(最直接,但类型需手写):
select: (data: Post) => data.title方式二:用queryOptions预先解析类型(推荐)。把查询选项封装进queryOptions(),其类型会在单个对象内部先完成解析,再整体交给useQueries,从而绕开数组级推导的限制(见 queryOptions 参考文档):
import { queryOptions, useQueries } from '@tanstack/react-query' const postOptions = (id: number) => queryOptions({ queryKey: ['post', id], queryFn: () => fetchPost(id), }) function PostTitle({ id }: { id: number }) { const [{ data: broken }] = useQueries({ queries: [ { ...postOptions(id), // ❌ `data` 在这里是 `unknown`:先展开 queryOptions 再覆盖 select, // 覆盖发生在 useQueries 整体推导之前,select 参数拿不到类型 select: (data) => data.title, }, ], }) const [{ data: fixed }] = useQueries({ queries: [ queryOptions({ ...postOptions(id), // ✅ `data` 在这里是 `Post`:整个对象在 queryOptions 内部完成类型解析 select: (data) => data.title, }), ], }) return <h1>{fixed}</h1> }注意文档与示例代码共同强调的一个细节:即使先用queryOptions生成选项,再对其展开(spread)并内联覆盖select,类型依然会退化为unknown——因为覆盖行为发生在对象到达useQueries之前、类型解析之外。正确做法是把整个对象(含覆盖后的select)再次包进queryOptions,让最终形态在单对象层面完成类型解析。
8. 常见问题速查
| 问题 | 结论 | 依据 |
|---|---|---|
| 数组中出现重复 queryKey | 数据可能被共享,先unique去重再映射结果 | queriesObserver.ts:89-98 开发环境会告警 |
内联combine每次渲染都执行 | 是,用useCallback或模块级稳定函数包裹 | queriesObserver.ts:228-232;测试 useQueries.test.tsx:993-1066 |
placeholderData能否拿到上次数据 | 不能,previousData/previousQuery恒为undefined | useQueries.ts:42-52 |
内联select参数类型为unknown | 用queryOptions包裹或显式标注参数类型 | useQueries.ts:243-249 |
| 超过 20 个查询的元组类型推导 | 回退为同构类型,仍可工作 | useQueries.ts:160-161 |
subscribed: false时查询是否发起 | 不发起,queryFn不会被调用 | 测试 useQueries.test.tsx:66-92 |
9. 延伸阅读
- Hook 完整实现:packages/react-query/src/useQueries.ts
- 底层多查询观察者:packages/query-core/src/queriesObserver.ts
- 行为测试用例:packages/react-query/src/tests/useQueries.test.tsx
- 类型测试(编译期类型行为验证):packages/react-query/src/tests/useQueries.test-d.tsx
- 相关 API:queryOptions | useSuspenseQueries
- 其他框架的同名实现(原理一致,API 略异):preact 版 useQueries、solid 版 useQueries、vue 版 useQueries
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考