首页 / TanStack 生态入门教程 / 并行、依赖查询与预取

TanStack 生态入门教程

并行、依赖查询与预取

本教程共 38 篇 · 第 14 篇 · 更新于 2026-07-27 · 约 13 分钟阅读

TanStackTanStack 生态入门教程TanStack Query并行查询依赖查询预取initialDatauseQueries

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

statusfetchStatus 是两回事。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,
  })
}

几个关键行为你得知道:

  1. 预取默认用 queryClient 配置的 staleTime 判断缓存里的数据新不新鲜。也可以单独传:prefetchQuery({ ..., staleTime: 5000 })
  2. 这个 staleTime 只对预取生效,真正用 useQuery 时还得再设一次。
  3. 如果预取后没有任何 useQuery 用到这个 key,数据会在 gcTime 后被垃圾回收。
  4. prefetchQuery 返回 Promise<void>不会返回数据。你要拿数据用 fetchQuery
  5. 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 会根据 initialDataUpdatedAtstaleTime 算出数据是否过期,决定要不要在挂载时重新请求。这是最准确的用法。

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 分钟),同一份数据短时间内不重复拉。

坑三:initialDataplaceholderData 用。 initialData 会进缓存、影响 staleTime 判断;placeholderData 只是个占位显示,不进缓存。别搞混。

坑四:prefetchQuery 期望返回数据。 它返回 Promise<void>,拿不到数据。要拿数据用 fetchQuery,但 fetchQuery 会抛错,得自己 catch。

坑五:useQueries 里内联 select 类型丢失。 前面提过,用 queryOptions 抽出来,或者给 select 显式标类型。

14.12 小结

四个套路讲完了,回顾一下:

  • 并行查询:固定数量用多个 useQuery 并排,动态数量用 useQueries
  • 依赖查询:用 enabled 控制,注意请求瀑布的代价。
  • 预取prefetchQuery 提前拉数据,事件、组件、路由三处都能用。
  • 初始数据initialData 喂现成数据,setQueryData 手动改缓存。

这些套路不是孤立的,实际项目里经常组合着用。比如路由 loader 里预取关键数据、组件里 initialData 借列表缓存的数据、事件里预取详情页——组合起来就是丝滑的用户体验。