TanStack Query 并行查询(Parallel Queries)完全指南:从手动并行到 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
TanStack Query(即 React Query)中的「并行查询」指多条查询在同一时刻开始执行,从而最大化数据拉取的并发度。本文围绕 React 框架下的并行查询玩法展开,覆盖手动并行的基本姿势、Suspense 模式下的特殊约束、用于「动态数量」查询的useQueries与useSuspenseQueries,并结合本仓库的 React Query 源码与测试,讲清并行调度在底层是如何实现的。读完你即可判断场景应该并排写useQuery还是改用useQueries/useSuspenseQueries,并规避类型推断与渲染上的典型坑。
什么是并行查询
「并行查询」是指那些在同一时刻执行(或以最大并发度执行)的查询。在 TanStack Query 的世界里,只要多个查询互不依赖、彼此之间没有「先拿到 A 的结果才去请求 B」的先后关系,它们就可以并行发起,而不是一个接一个地串行等待。
与依赖型查询(参见 依赖查询指南)或串行瀑布(参见 请求瀑布流指南)相对,并行查询的目标是把网络往返「摊平」:多个请求并发地在途,而不是让后续请求排队等待前一个完成。做到这一点的前提是每条查询的queryKey彼此不同;如果两条查询使用完全相同的 key,则共享同一份缓存,只会触发一次网络请求。
手动并行查询:数量固定时的零成本方案
当需要并行的查询数量固定不变时,使用并行查询不需要任何额外工作——只要把任意数量的useQuery(以及useInfiniteQuery)Hook并排放在同一个组件里即可:
function App () { // 下面的三个查询会并行执行 const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers }) const teamsQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTeams }) const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects }) ... }每条查询都是独立注册的 Query Observer,组件挂载后各自在自身生命周期内发起 fetch,天然互不阻塞。同理,固定数量的无限滚动查询也可以并排使用useInfiniteQuery:
function Feeds() { // 两个无限查询并行执行,各自维护独立的 pageParam 与分页状态 const latestPosts = useInfiniteQuery({ queryKey: ['posts', 'latest'], queryFn: ({ pageParam }) => fetchLatestPosts(pageParam), initialPageParam: 0, }) const trendingPosts = useInfiniteQuery({ queryKey: ['posts', 'trending'], queryFn: ({ pageParam }) => fetchTrendingPosts(pageParam), initialPageParam: 0, }) ... }需要注意的是,这种并行模式仅当查询数量在渲染之间保持稳定时才成立。一旦数量取决于 props、state 或路由参数而动态变化,就受 React「Hooks 必须在每次渲染以相同顺序、相同数量被调用」的规则约束,不能通过Array.map去循环调用useQuery——此时需要切换到下面介绍的useQueries。
Suspense 模式下的注意点:并排写法会失效
使用 React Query 的 suspense 模式时,上面的「并排 Hook」并行写法并不奏效。因为第一条
useSuspenseQuery会先在组件内部抛出 Promise 使组件挂起(suspend),后续查询还没机会执行。绕开的办法是改用useSuspenseQueries(推荐),或者把每个useSuspenseQuery拆到各自独立的子组件中,由你在组件层级上自己编排并行。
也就是说,在useQuery({ suspense: true })或全局suspense: true的配置下,若同一个组件连续写多个useSuspenseQuery,渲染到第一个挂起点就会中断,后面的查询要等组件重新渲染(数据就绪后)才开始,退化成串行。相关行为详解可参考 Suspense 指南。
动态并行查询:useQueries
如果每次渲染需要执行的查询数量是变化的(例如根据一组用户 ID、一组待拉取的文章 id 来生成查询),就不能用手动并行的方式写死 Hook,那样会违反 Rules of Hooks。此时应使用useQueries,它能动态地并行执行任意多条查询。
API 形态与基本用法
useQueries接收一个options 对象,内含一个querieskey,其值为查询对象数组;它返回一个查询结果数组:
function App({ users }) { const userQueries = useQueries({ queries: users.map((user) => { return { queryKey: ['user', user.id], queryFn: () => fetchUserById(user.id), } }), }) }queries数组里每一项 query 对象与useQuery接受的选项基本一致(queryKey、queryFn、staleTime、gcTime、refetchInterval、select、retry 等均可使用)。返回的结果数组与输入数组保持一一对应的顺序,其中每一项都是一个标准的查询结果对象,携带status、isPending、isError、error、data、isFetching、refetch等字段,可以直接参与渲染:
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> ) }每个 query 对象还可以带独立的queryClient之外的配置;而自定义QueryClient可以放在useQueries的第二个参数上(或依赖默认的 QueryClientProvider 上下文),而不是逐条传queryClient。
底层机制:一个 QueriesObserver 协调多条查询
useQueries并不是 N 个独立 Observer 的简单叠加。查看 React Query 的 useQueries 实现,可以看到它在内部维护了一个来自@tanstack/query-core的QueriesObserver(底层类定义在 queriesObserver.ts):
- 每次渲染,
queries.map会先把每个 query 对象交给client.defaultQueryOptions做一次默认值补齐(如默认staleTime、retry等),得到标准化后的选项数组(useQueries.ts); - 随后用
React.useState惰性创建唯一一个QueriesObserver,并通过useSyncExternalStore订阅它,从而在任意一条查询的状态变化时以单个批次触发一次组件渲染(得益于notifyManager.batchCalls); - 选项变化(比如
ids变化导致queries数组内容变化)时通过observer.setQueries热更新,而不会销毁重建整个 Observer。
由此可见,并行背后的调度中心是 query-core 层的QueriesObserver,React 层的useQueries只负责把它接到 React 渲染生命周期上。这与useQuery单个 QueryObserver 的模型对应,是理解整套并行架构的关键。
combine:把多条结果合并成一个返回值
useQueries支持一个顶层combine函数,用于把整组查询结果合并成单一值再返回。典型场景是「整组是否仍在加载 / 是否出错 / 是否在后台刷新」这类聚合判断:
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> ) }实现上,combine的结果会经过replaceEqualDeep结构化共享,尽可能保持引用稳定(见 queriesObserver.ts 中的 combine 处理),从而让下游组件尽量少做无谓重渲染。需要注意:combine只有在自身引用变化或任一查询结果变化时才会重新执行;把combine内联写在 JSX 每次渲染都会生成新引用,导致每次渲染都重算,因此建议用useCallback包裹或提取为无依赖的稳定函数引用(该建议直接来自 useQueries.ts 的实现注释)。
更多顶层选项与行为细节
结合源码注释与测试(useQueries.test.tsx),还有几个值得注意的细节:
subscribed(默认true):设为false时该 Observer 不再订阅查询缓存的更新,适用于只需一次性取值不关心后续变化的场景。测试「should not optimistically show fetching when unsubscribed」验证了subscribed: false时不会乐观地进入 fetching 状态,fetchStatus保持idle,且不会在 query cache 上注册 observer(见 useQueries.test.tsx)。- 重复的 queryKey:
queries数组中若多次出现相同 key,部分数据会在这些查询之间共享。官方建议先对查询去重,再把结果映射回需要的结构,避免意外共享(见 useQueries.ts 的 JSDoc)。 placeholderData:在useQueries中同样受支持,但它的函数版不会拿到之前渲染的查询信息(previousData/previousQuery恒为undefined),因为查询数量在不同渲染间可能不同。- 单条错误:某条查询出错时,默认只让对应项进入
error状态而不影响整组;仅当该 query 配置了throwOnError: true(或 suspense 语义下)才会抛出错误(相关行为在 useQueries.test.tsx 的「should throw error if in one of queries' queryFn throws」测试中有覆盖)。 - 类型层递归上限:为了让每个数组元素的
queryFn/select都能独立推断,类型QueriesOptions/QueriesResults会以递归元组方式逐项展开,并设置了 20 层的递归上限(MAXIMUM_DEPTH = 20,见 useQueries.ts)。超出上限或传入元素类型未知的普通数组时,会退化为统一的单一类型推断。
关于这些类型的细节,可查阅 QueriesOptions 类型参考 与 QueriesResults 类型参考。
TypeScript 陷阱:内联 select 无法推断 data
使用 TypeScript 时,如果直接写在传给
useQueries的 query 对象上的内联select,它无法从同一对象自身的queryFn推断出data参数类型——会退化回unknown。解决方式是显式标注select的参数类型,或借助queryOptions辅助函数先定义好该查询,从而恢复类型推断。这是 TanStack Query 官方文档标注的一个已知 TypeScript 限制。
原因在于:useQueries是对整个queries数组一次性做类型推断的,内联对象的select无法被同一对象的queryFn进行上下文类型化,于是回退到unknown。三种典型解法如下:
方案一:显式标注 select 参数
const [{ data }] = useQueries({ queries: [{ queryKey: ['post', id], queryFn: fetchPost, select: (data: Post) => data.title, // 显式标注 }], })方案二:用queryOptions工厂函数预先定型
queryOptions会在对象到达useQueries之前、于单个对象内完成类型解析:
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: title }] = useQueries({ queries: [postOptions(id)], }) return <h1>{title}</h1> }注意一个进阶细节:展开queryOptions的结果再内联覆盖select依然会退化回unknown——必须把覆盖后的对象再次包进queryOptions,让select在进入useQueries之前被解析(该示例与注释完整记录在 useQueries.ts 的实现 JSDoc 中):
// ❌ data 会是 unknown:展开后又内联 select const [{ data: broken }] = useQueries({ queries: [{ ...postOptions(id), select: (data) => data.title }], }) // ✅ data 推断为 Post 类型 const [{ data: fixed }] = useQueries({ queries: [ queryOptions({ ...postOptions(id), select: (data) => data.title, }), ], })queryOptions本身是一层极薄的运行时包装——其实现就是直接原样返回传入的 options(见 queryOptions.ts),它的价值完全在于编译期类型标注与跨 Hook/命令式 API 复用。其完整 API 可参考 queryOptions 参考文档。
Suspense 下的动态并行:useSuspenseQueries
对于使用了 Suspense 的动态并行查询,官方推荐直接使用useSuspenseQueries,而不是手动排布多个useSuspenseQuery。
从实现上看,useSuspenseQueries.ts 实际上是对useQueries的一层薄封装:它把每个 query 强制加上suspense: true、throwOnError: defaultThrowOnError、enabled: true,并清空placeholderData,然后原样转交useQueries(见 useSuspenseQueries.ts 的实现)。因此它天然继承了useQueries的动态并行能力,同时让每条结果满足 Suspense 语义:
- 每个结果对象的
data保证已定义(不再需要逐条判断isPending),不存在isPlaceholderData; status只会是success或error;- 多个查询并行发起,全部就绪后才让组件完成挂起,而非逐个挂起(这是它相对多个
useSuspenseQuery的核心优势); - 同样支持
combine,把整组结果合并为单一值(如results.some((r) => r.isFetching)用于渲染「刷新中」指示)。
一个基于用户 ID 列表的动态并行示例:
import { Suspense } from 'react' import { ErrorBoundary } from 'react-error-boundary' import { QueryErrorResetBoundary, useSuspenseQueries, } from '@tanstack/react-query' function Posts({ ids }: { ids: Array<number> }) { // 每个结果都保证 data 已定义,无需逐条 isPending 判断 const postQueries = useSuspenseQueries({ queries: ids.map((id) => ({ queryKey: ['post', id], queryFn: () => fetchPost(id), })), }) return ( <ul> {postQueries.map((query) => ( <li key={query.data.id}>{query.data.title}</li> ))} </ul> ) } function App() { return ( <QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => ( <div> There was an error! <button onClick={() => resetErrorBoundary()}>Try again</button> </div> )} > <Suspense fallback={<h1>Loading posts...</h1>}> <Posts ids={[1, 2, 3]} /> </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> ) }需要提醒的三条使用约束(来自 useSuspenseQueries.ts 的 JSDoc 与实现):
- 错误处理依赖 Error Boundary:首次 fetch 失败且无缓存数据时,查询错误会被抛出,因此
<Suspense>外围必须有错误边界;配合QueryErrorResetBoundary才能让用户点击「重试」后恢复。后台 refetch 失败则继续渲染已有缓存数据。 - 重新挂载与 staleTime:组件会在所有查询完成后重新挂载(re-mount)。若在等待期间某条查询已经过期(stale),重挂载时它会再次被 fetch。若不想重复请求,应设置足够高的
staleTime。 - 不支持取消(cancellation):suspense 模式下的取消语义不适用于
useSuspenseQueries。 skipToken被禁止:开发环境下向useSuspenseQueries传入queryFn: skipToken会直接打印错误日志(见 useSuspenseQueries.ts)。
这些行为均有测试佐证,可参考 useSuspenseQueries.test.tsx 中的用例。
并行执行的证据:来自测试与 Observer 层
并行不是「看起来并行」而是真实的并发调度,仓库中的证据链如下:
- useQueries.test.tsx 的用例「should return the correct states」构造了两条延迟分别为 10ms 与 200ms 的查询,最终断言
data1: 1, data2: 2同时就绪;渲染历史results依次为[undefined, undefined] → [1, undefined] → [1, 2],证明二者自渲染起即并发在途、互不等待。 - query-core 层的 queriesObserver.ts 负责统一管理这组查询,而 useQueries.ts 中每次渲染都会先对全部查询做
defaultQueryOptions默认化并调用observer.setQueries完成热同步,从机制上保证了「新增/移除某条查询」不会中断其他查询的状态。
何时选哪种方式
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 查询数量固定 | 并排写useQuery/useInfiniteQuery | 零额外成本,每条查询独立可读 |
| 固定数量 + Suspense | useSuspenseQueries或拆分为独立子组件 | 避免首条查询挂起导致后续查询串行 |
| 查询数量动态变化 | useQueries | 遵守 Rules of Hooks,按数组驱动 |
| 动态数量 + Suspense | useSuspenseQueries | 全部并行发起、一次性完成挂起 |
| 需要聚合整组状态 | 给useQueries/useSuspenseQueries配combine | 结构化共享、减少不必要重渲染 |
延伸阅读
- useQueries Hook 参考 与 useSuspenseQueries Hook 参考
- 查询(Queries)基础指南、查询键(Query Keys)指南
- Suspense 指南、请求瀑布流指南(理解并行 vs 串行)、依赖查询指南(理解并行 vs 依赖)
- queryOptions 参考、QueriesOptions 类型、QueriesResults 类型
【免费下载链接】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),仅供参考