并行、依赖查询与预取
本教程共 38 篇 · 第 14 篇 · 更新于 2026-07-27 · 约 13 分钟阅读
14. 并行、依赖查询与预取
本节目标:学会用
useQueries一次发起多个并行查询、用enabled串起依赖查询、用prefetchQuery提前把数据塞进缓存、用initialData给查询喂现成数据。学完你能让页面少转几个圈。
14.1 为什么要并行、要依赖、要预取
先把三个概念用生活化的例子讲清楚。
你去菜市场买菜,番茄、鸡蛋、葱三样都要。如果一样一样买(串行),跑三趟;同时去三个摊位买(并行),一趟搞定。并行查询就是这个意思,多个请求同时发出去,谁也不等谁。
但有些事得有先后。你得先拿到钥匙,才能开柜子拿米。依赖查询就是这种”先 A 后 B”的关系,B 查询要等 A 的结果出来才能发起。
预取更简单:你知道客人要点这道菜,提前把食材洗好切好放厨房。等客人真点了,下锅就快。预取就是用户还没点开详情页,你先把详情数据悄悄拉好放进缓存。
这三个套路组合起来,是 TanStack Query 性能优化的核心武器。
14.2 手动并行查询
当并行查询的数量是固定的(写代码时就确定几个),啥都不用特殊处理。直接把多个 useQuery 并排写就行,TanStack Query 会自动并行执行。
import { useQuery } from '@tanstack/react-query'
function Dashboard() {
// 三个查询会并行执行,互不等待
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
const teamsQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTeams })
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
if (usersQuery.isPending || teamsQuery.isPending || projectsQuery.isPending) {
return <div>加载中...</div>
}
return (
<div>
<h2>用户数:{usersQuery.data.length}</h2>
<h2>团队数:{teamsQuery.data.length}</h2>
<h2>项目数:{projectsQuery.data.length}</h2>
</div>
)
}
这种写法最省事,但有个前提:查询数量在渲染期间不能变。你要是写成 if (条件) useQuery(...) 这种,就违反了 React Hooks 规则,会报错。
Note如果你用的是 Suspense 模式(
useSuspenseQuery),手动并行的写法会失效。第一个查询会抛出 Promise 挂起组件,后面的查询根本没机会执行。这种情况要用useSuspenseQueries,后面会专门讲。
14.3 useQueries:动态并行查询
查询数量会变怎么办?比如一个用户列表,每个用户都要单独查一次详情,用户数量是后端返回的,写代码时不知道。这时候就要用 useQueries。
useQueries 接收一个对象,里面有个 queries 数组,每个元素就是一个 useQuery 的配置。返回值也是一个数组,每个元素对应一个查询结果。
import { useQueries } from '@tanstack/react-query'
function UserList({ users }) {
const userQueries = useQueries({
queries: users.map((user) => ({
queryKey: ['user', user.id],
queryFn: () => fetchUserById(user.id),
})),
})
// userQueries 是个数组,结构和 useQuery 返回值一样
const isLoading = userQueries.some((q) => q.isPending)
if (isLoading) {
return <div>加载中...</div>
}
return (
<ul>
{userQueries.map((query, i) => (
<li key={users[i].id}>{query.data.name}</li>
))}
</ul>
)
}
Tip
useQueries的数组长度每次渲染可以不一样,这正好补上了 Hooks 规则的缺口。但别滥用——如果后端能提供一个批量接口(比如fetchUsersByIds([1,2,3])),优先用批量接口,省 N 次请求。
有个 TypeScript 小坑值得提一嘴:如果你在 useQueries 的某个查询里用了 select,且是内联写法,TypeScript 推不出 data 的类型,会回退到 unknown。解决办法有两个:要么给 select 参数显式标注类型,要么用 queryOptions 帮手把查询定义抽出来。
import { queryOptions } from '@tanstack/react-query'
// 用 queryOptions 抽出来,类型能正确推导
const userOptions = (id: number) =>
queryOptions({
queryKey: ['user', id],
queryFn: () => fetchUserById(id),
select: (data) => data.name, // 这里 data 类型能正确推出来
})
const userQueries = useQueries({
queries: users.map((user) => userOptions(user.id)),
})
14.4 依赖查询:用 enabled 控制
依赖查询的核心是 enabled 选项。把 enabled 设为 false,查询就不执行;设为 true(或一个能转成 true 的表达式),查询才会发起。
经典场景:先查用户,拿到用户 ID 后再查这个用户的项目。
function UserProjects({ email }) {
// 第一步:根据邮箱查用户
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email),
})
const userId = user?.id
// 第二步:根据用户 ID 查项目,但 userId 还没有时不发起
const { data: projects, isPending } = useQuery({
queryKey: ['projects', userId],
queryFn: () => getProjectsByUser(userId),
enabled: !!userId, // userId 存在才执行
})
if (isPending) {
return <div>项目加载中...</div>
}
return <ProjectList projects={projects} />
}
这里有个状态转换的细节,我第一次踩坑看了半天没明白。第二个查询一开始的状态是:
status: 'pending' // 还没成功过
isPending: true
fetchStatus: 'idle' // 但没在请求(因为 enabled 是 false)
等第一个查询返回、userId 有了,第二个查询被激活,状态变成:
status: 'pending'
isPending: true
fetchStatus: 'fetching' // 开始请求了
请求成功后:
status: 'success'
isPending: false
fetchStatus: 'idle'
Note
status和fetchStatus是两回事。status描述”有没有拿到过数据”,fetchStatus描述”现在是不是在请求”。依赖查询里这俩组合起来才有意义,前面第 9 章专门讲过。
useQueries 也能搞依赖查询,套路一样:先查一个,拿到结果后再用 useQueries 批量查。
// 先查所有用户,只取 id
const { data: userIds } = useQuery({
queryKey: ['users'],
queryFn: getUsersData,
select: (users) => users.map((user) => user.id),
})
// 再根据 id 列表批量查每个用户的消息
const usersMessages = useQueries({
queries: userIds
? userIds.map((id) => ({
queryKey: ['messages', id],
queryFn: () => getMessagesByUsers(id),
}))
: [], // userIds 还没有时传空数组
})
14.5 依赖查询的代价:请求瀑布
依赖查询本质上是一种请求瀑布(Request Waterfall)——A 查完才能查 B,时间是叠加的。如果两个查询各要 500ms,串行就要 1000ms,并行只要 500ms,差一倍。在网络差的客户端上,这个差距会被放大。
能优化就优化。上面那个例子,理想做法是让后端提供一个 getProjectsByUserEmail(email) 接口,根据邮箱直接查项目,省掉”先查用户拿 ID”这一步。这样两个查询就能并行,瀑布被拍平。
Warning实在没法改后端接口时才用依赖查询。能用一个接口拿全的数据,别拆成两个串行查。这是性能优化的根本原则。
14.6 预取:提前把数据塞进缓存
预取的思路是:在用户真正需要数据之前,提前把数据拉好放进缓存。等用户真发起 useQuery 时,缓存里已经有了,直接命中,零等待。
TanStack Query 提供两个预取方法:
queryClient.prefetchQuery—— 预取普通查询queryClient.prefetchInfiniteQuery—— 预取无限查询
最基础的用法:
import { useQueryClient } from '@tanstack/react-query'
const queryClient = useQueryClient()
const prefetchTodos = async () => {
// 结果会像普通查询一样被缓存
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
几个关键行为你得知道:
- 预取默认用
queryClient配置的staleTime判断缓存里的数据新不新鲜。也可以单独传:prefetchQuery({ ..., staleTime: 5000 })。 - 这个
staleTime只对预取生效,真正用useQuery时还得再设一次。 - 如果预取后没有任何
useQuery用到这个 key,数据会在gcTime后被垃圾回收。 prefetchQuery返回Promise<void>,不会返回数据。你要拿数据用fetchQuery。prefetchQuery不会抛错——它假设后面useQuery会兜底重新请求。你要捕获错误就用fetchQuery。
Tip如果你想”缓存里有就用缓存里的,不管新不新鲜”,用
ensureQueryData。它和prefetchQuery的区别是:prefetchQuery会按staleTime判断要不要重新拉,ensureQueryData只要缓存有就直接返回。
无限查询也能预取,默认只预取第一页。想预取多页,用 pages 选项,同时必须传 getNextPageParam:
const prefetchProjects = async () => {
await queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
pages: 3, // 预取前 3 页
})
}
14.7 预取的三种场景
预取不是只能在一个地方用,常见有三处。
14.7.1 在事件处理里预取
用户鼠标移到按钮上、或者按钮获得焦点时,就提前拉数据。等用户真点了,数据早好了。
function ShowDetailsButton() {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['details'],
queryFn: getDetailsData,
// 鼠标悬停和点击之间可能隔几秒,设个 staleTime 避免重复拉
staleTime: 60000,
})
}
return (
<button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
查看详情
</button>
)
}
Note事件预取一定要设
staleTime。用户鼠标来回移动,不设staleTime会反复触发请求,得不偿失。
14.7.2 在组件里预取
父组件渲染时,子组件还没渲染但马上要渲染,可以在父组件里先把子组件要的数据预取了。这能拍平请求瀑布。
function Article({ id }) {
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})
// 预取评论,忽略结果(notifyOnChangeProps 避免无谓重渲染)
useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
notifyOnChangeProps: [],
})
if (isPending) {
return <div>文章加载中...</div>
}
return (
<>
<ArticleHeader articleData={articleData} />
<ArticleBody articleData={articleData} />
<Comments id={id} />
</>
)
}
function Comments({ id }) {
const { data, isPending } = useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
// ...
}
原来的瀑布是 getArticleById 查完 → 渲染 <Comments> → getArticleCommentsById。预取后两个查询同时发起,瀑布拍平了。
还有几招也行:
- 在 queryFn 里预取:查文章的同时顺手把评论预取了。
- 在 useEffect 里预取:但注意,如果同组件用了
useSuspenseQuery,effect 会等到查询完成后才执行,可能不是你要的时机。 - 用
usePrefetchQuery钩子:配合 Suspense 时用它,不会被 Suspense 挡住。
import { usePrefetchQuery, useSuspenseQuery } from '@tanstack/react-query'
function ArticleLayout({ id }) {
// 在 Suspense 边界外预取
usePrefetchQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
return (
<Suspense fallback="加载中">
<Article id={id} />
</Suspense>
)
}
14.7.3 在路由里预取
把预取放到路由层,是规模化应用最干净的做法。每个路由声明自己要什么数据,路由匹配时统一预取。TanStack Router 原生支持这种模式,第 21 章会专门讲 Router 和 Query 的集成,这里先看个大概:
const articleRoute = new Route({
getParentRoute: () => rootRoute,
path: 'article',
loader: async ({ context: { queryClient } }) => {
// 评论异步预取,不阻塞渲染
queryClient.prefetchQuery({
queryKey: ['comments'],
queryFn: fetchComments,
})
// 文章同步等待,保证渲染时一定有数据
await queryClient.prefetchQuery({
queryKey: ['article'],
queryFn: fetchArticle,
})
},
component: () => {
const article = useQuery({ queryKey: ['article'], queryFn: fetchArticle })
const comments = useQuery({ queryKey: ['comments'], queryFn: fetchComments })
// ...
},
})
这种”关键数据等、次要数据不等”的混合策略,是路由集成的精髓。
14.8 initialData:给查询喂现成数据
有时候你手里已经有数据了——比如服务端渲染传过来的、或者从别的查询里捞出来的。这时不用预取,直接用 initialData 把数据塞进缓存,跳过初始加载状态。
const result = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
initialData: initialTodos, // 一上来就有数据,不显示加载态
})
Warning
initialData会被写进缓存,所以别拿它当占位数据用。占位数据用placeholderData(第 13 章讲过)。initialData必须是真实可用的完整数据。
14.8.1 initialData 的”新鲜度”问题
默认情况下,initialData 被当成”刚刚拉取的”——完全新鲜。这会带来一个坑:如果你没设 staleTime(默认是 0),组件一挂载就会立即重新请求,因为数据”已经过期了”。
// 显示 initialTodos,但挂载后立即重新请求
const result = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
initialData: initialTodos,
})
设了 staleTime 就不会立即重请求:
// 1 秒内不会重新请求
const result = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
initialData: initialTodos,
staleTime: 1000,
})
但如果你的 initialData 不是刚拉的,是几分钟前的呢?这时要用 initialDataUpdatedAt 告诉 Query 这份数据是什么时候更新的,让它自己判断要不要重新拉。
const result = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
initialData: initialTodos,
staleTime: 60 * 1000, // 1 分钟内算新鲜
initialDataUpdatedAt: initialTodosUpdatedTimestamp, // 比如 1608412420052
})
Query 会根据 initialDataUpdatedAt 和 staleTime 算出数据是否过期,决定要不要在挂载时重新请求。这是最准确的用法。
14.8.2 initialData 用函数
如果获取初始数据的代价大(比如要遍历大数组),别直接传值,传个函数。函数只在查询初始化时执行一次:
const result = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
initialData: () => getExpensiveTodos(),
})
14.8.3 从别的查询缓存里借数据
这是 initialData 最实用的场景。比如你有个待办列表查询 ['todos'],点开某个待办详情时,详情查询 ['todo', todoId] 的初始数据可以从列表缓存里找:
const result = useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetch(`/todos/${todoId}`),
initialData: () =>
queryClient.getQueryData(['todos'])?.find((d) => d.id === todoId),
})
从缓存借数据有个问题:源查询可能已经过期了。这时要把源查询的 dataUpdatedAt 也借过来:
const result = useQuery({
queryKey: ['todos', todoId],
queryFn: () => fetch(`/todos/${todoId}`),
initialData: () =>
queryClient.getQueryData(['todos'])?.find((d) => d.id === todoId),
initialDataUpdatedAt: () =>
queryClient.getQueryState(['todos'])?.dataUpdatedAt,
})
更精细的做法:判断源查询新鲜度,太旧就别借了,直接硬加载。
const result = useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetch(`/todos/${todoId}`),
initialData: () => {
const state = queryClient.getQueryState(['todos'])
// 源查询存在且 10 秒内更新过才借
if (state && Date.now() - state.dataUpdatedAt <= 10 * 1000) {
return state.data.find((d) => d.id === todoId)
}
// 否则返回 undefined,走硬加载
},
})
14.9 setQueryData:手动塞数据
最后补一招。如果你同步就有数据(比如 mutation 返回的、或者本地算出来的),不用预取也不用 initialData,直接用 setQueryData 把数据塞进缓存:
queryClient.setQueryData(['todos'], todos)
这招在乐观更新(第 12 章)里用过,这里不展开。它和 initialData 的区别是:initialData 只在查询首次初始化时生效,setQueryData 随时都能改缓存。
14.10 选哪个?一张表理清
四种往缓存里塞数据的办法,容易混。我整理成一张表:
| 方法 | 何时用 | 是否异步 | 是否影响 staleTime |
|---|---|---|---|
prefetchQuery | 提前拉数据,用户还没用 | 异步,返回 Promise | 单独传 staleTime |
ensureQueryData | 缓存有就用,没有才拉 | 异步 | 忽略 staleTime |
initialData | 手头有现成数据 | 同步 | 当成刚拉的,可用 initialDataUpdatedAt 修正 |
setQueryData | 手动改缓存 | 同步 | 不影响 |
14.11 常见坑
坑一:依赖查询滥用导致瀑布成灾。 一个查完查下一个,最后串成四五层。每层 200ms,加起来 1 秒就没了。能并行的坚决并行,能改后端接口的改后端接口。
坑二:预取不设 staleTime。 鼠标移来移去,每次都触发请求。设个合理的 staleTime(比如 1 分钟),同一份数据短时间内不重复拉。
坑三:initialData 当 placeholderData 用。 initialData 会进缓存、影响 staleTime 判断;placeholderData 只是个占位显示,不进缓存。别搞混。
坑四:prefetchQuery 期望返回数据。 它返回 Promise<void>,拿不到数据。要拿数据用 fetchQuery,但 fetchQuery 会抛错,得自己 catch。
坑五:useQueries 里内联 select 类型丢失。 前面提过,用 queryOptions 抽出来,或者给 select 显式标类型。
14.12 小结
四个套路讲完了,回顾一下:
- 并行查询:固定数量用多个
useQuery并排,动态数量用useQueries。 - 依赖查询:用
enabled控制,注意请求瀑布的代价。 - 预取:
prefetchQuery提前拉数据,事件、组件、路由三处都能用。 - 初始数据:
initialData喂现成数据,setQueryData手动改缓存。
这些套路不是孤立的,实际项目里经常组合着用。比如路由 loader 里预取关键数据、组件里 initialData 借列表缓存的数据、事件里预取详情页——组合起来就是丝滑的用户体验。