ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

TanStack Query 并行查询(Parallel Queries)完全指南:从手动并行到 useQueries 动态调度

2026/9/9 21:11:51 拓冰建站 浏览量
TanStack Query 并行查询(Parallel Queries)完全指南:从手动并行到 useQueries 动态调度 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/queryTanStack Query即 React Query中的「并行查询」指多条查询在同一时刻开始执行从而最大化数据拉取的并发度。本文围绕 React 框架下的并行查询玩法展开覆盖手动并行的基本姿势、Suspense 模式下的特殊约束、用于「动态数量」查询的useQueries与useSuspenseQueries并结合本仓库的 React Query 源码与测试讲清并行调度在底层是如何实现的。读完你即可判断场景应该并排写useQuery还是改用useQueries/useSuspenseQueries并规避类型推断与渲染上的典型坑。什么是并行查询「并行查询」是指那些在同一时刻执行或以最大并发度执行的查询。在 TanStack Query 的世界里只要多个查询互不依赖、彼此之间没有「先拿到 A 的结果才去请求 B」的先后关系它们就可以并行发起而不是一个接一个地串行等待。与依赖型查询参见 依赖查询指南或串行瀑布参见 请求瀑布流指南相对并行查询的目标是把网络往返「摊平」多个请求并发地在途而不是让后续请求排队等待前一个完成。做到这一点的前提是每条查询的queryKey彼此不同如果两条查询使用完全相同的 key则共享同一份缓存只会触发一次网络请求。手动并行查询数量固定时的零成本方案当需要并行的查询数量固定不变时使用并行查询不需要任何额外工作——只要把任意数量的useQuery以及useInfiniteQueryHook并排放在同一个组件里即可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天然互不阻塞。同理固定数量的无限滚动查询也可以并排使用useInfiniteQueryfunction 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: Arraynumber }) { 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 层的QueriesObserverReact 层的useQueries只负责把它接到 React 渲染生命周期上。这与useQuery单个 QueryObserver 的模型对应是理解整套并行架构的关键。combine把多条结果合并成一个返回值useQueries支持一个顶层combine函数用于把整组查询结果合并成单一值再返回。典型场景是「整组是否仍在加载 / 是否出错 / 是否在后台刷新」这类聚合判断function Posts({ ids }: { ids: Arraynumber }) { 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。重复的 queryKeyqueries数组中若多次出现相同 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不存在isPlaceholderDatastatus只会是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: Arraynumber }) { // 每个结果都保证 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{h1Loading 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。不支持取消cancellationsuspense 模式下的取消语义不适用于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零额外成本每条查询独立可读固定数量 SuspenseuseSuspenseQueries或拆分为独立子组件避免首条查询挂起导致后续查询串行查询数量动态变化useQueries遵守 Rules of Hooks按数组驱动动态数量 SuspenseuseSuspenseQueries全部并行发起、一次性完成挂起需要聚合整组状态给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),仅供参考