译零 TransZero · TanStack Query 中文文档
主站

预取与路由器集成

更新于 2026-11-02 Preact 查看英文原文 ↗

当你知道或怀疑某段数据即将被需要时,可以使用预取(prefetching)提前将该数据填充到缓存中,从而获得更快的用户体验。

预取有几种不同的模式:

  1. 在事件处理器中
  2. 在组件中
  3. 通过路由集成
  4. 在服务端渲染期间(另一种形式的路由集成)

本指南将重点介绍前三种方式;第四种将在 服务端渲染与注水指南 和 高级服务端渲染指南 中深入讲解。

预取的一个具体用途是避免请求瀑布(request waterfalls)。关于请求瀑布的详细背景和解释,请参阅 性能与请求瀑布指南。

使用 query 进行预取

[!NOTE] 这些提示替代了现已弃用的 prefetchQuery 和 ensureQueryData 方法。如果你曾参考过本指南的早期版本,请注意这些方法将在 TanStack Query 的下一个主版本中被移除。

预取一个查询需调用 query 方法。该方法默认会:

  • 执行查询函数
  • 将结果缓存
  • 返回该查询的结果
  • 若遇到任何错误则抛出异常

在预取场景下,你通常希望修改这些默认行为:

  • 默认情况下,query 使用 queryClient 上配置的默认 staleTime 来判断缓存中已有的数据是否新鲜(fresh),或是否需要再次拉取
  • 你也可以显式传入特定的 staleTime,例如:query({ queryKey: ['todos'], queryFn: fn, staleTime: 5 * 1000 })
    • 此 staleTime 仅对本次查询拉取生效;若后续还有 useQuery 调用,仍需为其单独设置 staleTime
    • 如果你希望无论默认 staleTime 如何,只要缓存中有可用数据就始终返回它,可传入 "static" 作为 staleTime
    • 提示:若你在服务端执行预取,可为该 queryClient 设置一个高于 0 的默认 staleTime,以避免每次预取调用都需手动传入 staleTime
  • 若某个已预取的查询未被任何 useQuery 实例引用,它将在 gcTime 指定的时间后被删除并进行垃圾回收
  • 若你的预取针对的是非关键数据,可通过 void 丢弃 Promise,并使用 .catch(noop) 吞掉错误。该查询通常会在后续的 useQuery 中再次尝试拉取,这是一种优雅的降级策略

以下是使用 query 进行预取的方式:

TypeScript
1import { noop } from '@tanstack/preact-query'2 3const prefetchTodos = async () => {4  await queryClient5    .query({6      queryKey: ['todos'],7      queryFn: fetchTodos,8      // Swallow errors here, because usually they will fetch again in `useQuery`9    })10    .catch(noop)11}

无限查询(infinite queries)也可像普通查询一样被预取。默认情况下,仅预取查询的第一页,并将其存储在给定的查询键(QueryKey)下。若需预取多页,可使用 pages 选项;此时你还必须提供一个 getNextPageParam 函数:

TypeScript
1import { noop } from '@tanstack/preact-query'2 3const prefetchProjects = () => {4  await queryClient5    .infiniteQuery({6      queryKey: ['projects'],7      queryFn: fetchProjects,8      initialPageParam: 0,9      getNextPageParam: (lastPage, pages) => lastPage.nextCursor,10      pages: 3, // prefetch the first 3 pages11    })12    .catch(noop)13}

接下来,我们来看如何在不同场景下使用这些及其他预取方式。

在事件处理器中预取

一种直接的预取方式是在用户与界面交互时触发。本例中,我们将使用 queryClient.query 在 onMouseEnter 或 onFocus 事件上启动预取。

TypeScript
1function ShowDetailsButton() {2  const queryClient = useQueryClient()3 4  const prefetch = () => {5    void queryClient.query({6      queryKey: ['details'],7      queryFn: getDetailsData,8      // Prefetch only fires when data is older than the staleTime,9      // so in a case like this you definitely want to set one10      staleTime: 60 * 1000,11    }).catch(noop)12  }13 14  return (15    <button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>16      Show Details17    </button>18  )19}

在组件中预取

在组件生命周期内进行预取,适用于我们已知某些子组件或后代组件将需要特定数据,但又无法等到另一查询加载完成后再渲染它们的情形。我们借用 请求瀑布指南 中的一个例子来说明:

TypeScript
1function Article({ id }) {2  const { data: articleData, isPending } = useQuery({3    queryKey: ['article', id],4    queryFn: getArticleById,5  })6 7  if (isPending) {8    return 'Loading article...'9  }10 11  return (12    <>13      <ArticleHeader articleData={articleData} />14      <ArticleBody articleData={articleData} />15      <Comments id={id} />16    </>17  )18}19 20function Comments({ id }) {21  const { data, isPending } = useQuery({22    queryKey: ['article-comments', id],23    queryFn: getArticleCommentsById,24  })25 26  ...27}

这将导致如下所示的请求瀑布:

Code
11. |> getArticleById()22.   |> getArticleCommentsById()

正如该指南所述,一种扁平化该瀑布并提升性能的方法是将 getArticleCommentsById 查询上提到父组件,并将结果作为 props 传递下去。但如果组件之间无关联、且层级较深,这种重构可能不可行或不理想。

此时,我们可在父组件中预取该查询。最简单的方式是发起一个查询但忽略其结果:

TypeScript
1function Article({ id }) {2  const { data: articleData, isPending } = useQuery({3    queryKey: ['article', id],4    queryFn: getArticleById,5  })6 7  // Prefetch8  useQuery({9    queryKey: ['article-comments', id],10    queryFn: getArticleCommentsById,11    // Optional optimization to avoid rerenders when this query changes:12    notifyOnChangeProps: [],13  })14 15  if (isPending) {16    return 'Loading article...'17  }18 19  return (20    <>21      <ArticleHeader articleData={articleData} />22      <ArticleBody articleData={articleData} />23      <Comments id={id} />24    </>25  )26}27 28function Comments({ id }) {29  const { data, isPending } = useQuery({30    queryKey: ['article-comments', id],31    queryFn: getArticleCommentsById,32  })33 34  ...35}

这将立即开始拉取 'article-comments',从而扁平化瀑布:

Code
11. |> getArticleById()21. |> getArticleCommentsById()

若你希望配合 Suspense 进行预取,则需采用略有不同的做法。你不能使用 useSuspenseQueries 来预取,因为预取会阻塞组件渲染;也不能使用 useQuery 进行预取,因为那要等到 suspenseful 查询解析完成后才开始预取。对于该场景,你可以使用库中提供的 usePrefetchQuery 或 usePrefetchInfiniteQuery Hook。

你现在可以在真正需要数据的组件中使用 useSuspenseQuery。你 可能 希望将这个后续组件包裹在它自己的 <Suspense> 边界内,以确保我们正在预取的“次要”查询不会阻塞“主要”数据的渲染。

TypeScript
1function ArticleLayout({ id }) {2  usePrefetchQuery({3    queryKey: ['article-comments', id],4    queryFn: getArticleCommentsById,5  })6 7  return (8    <Suspense fallback="Loading article">9      <Article id={id} />10    </Suspense>11  )12}13 14function Article({ id }) {15  const { data: articleData, isPending } = useSuspenseQuery({16    queryKey: ['article', id],17    queryFn: getArticleById,18  })19 20  ...21}

另一种方式是在查询函数内部进行预取。若你确定每次拉取文章时,评论数据也极有可能被需要,这种方式就很合理。此处我们使用 queryClient.query:

TypeScript
1const queryClient = useQueryClient()2const { data: articleData, isPending } = useQuery({3  queryKey: ['article', id],4  queryFn: (...args) => {5    void queryClient6      .query({7        queryKey: ['article-comments', id],8        queryFn: getArticleCommentsById,9      })10      .catch(noop)11 12    return getArticleById(...args)13  },14})

在 effect 中预取同样可行,但请注意:若你在同一组件中使用 useSuspenseQuery,该 effect 将在查询完成后才运行,这可能并非你所期望的行为。

TypeScript
1const queryClient = useQueryClient()2 3useEffect(() => {4  void queryClient5    .query({6      queryKey: ['article-comments', id],7      queryFn: getArticleCommentsById,8    })9    .catch(noop)10}, [queryClient, id])

总结一下,若你想在组件生命周期内预取一个查询,有多种方式可供选择,请根据实际情况选用最合适的一种:

  • 使用 usePrefetchQuery 或 usePrefetchInfiniteQuery Hook,在 Suspense 边界之前预取
  • 使用 useQuery 或 useSuspenseQueries 并忽略其结果
  • 在查询函数内部预取
  • 在 effect 中预取

接下来我们看一个稍复杂的案例。

依赖查询与代码分割

有时我们需要基于另一个拉取操作的结果,有条件地进行预取。考虑以下来自 性能与请求瀑布指南 的示例:

TypeScript
1// This lazy loads the GraphFeedItem component, meaning2// it wont start loading until something renders it3import { lazy } from 'preact/iso'4 5const GraphFeedItem = lazy(() => import('./GraphFeedItem'))6 7function Feed() {8  const { data, isPending } = useQuery({9    queryKey: ['feed'],10    queryFn: getFeed,11  })12 13  if (isPending) {14    return 'Loading feed...'15  }16 17  return (18    <>19      {data.map((feedItem) => {20        if (feedItem.type === 'GRAPH') {21          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />22        }2324        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />25      })}26    </>27  )28}29 30// GraphFeedItem.tsx31function GraphFeedItem({ feedItem }) {32  const { data, isPending } = useQuery({33    queryKey: ['graph', feedItem.id],34    queryFn: getGraphDataById,35  })36 37  ...38}

如该指南所述,此示例会导致如下双重请求瀑布:

Code
11. |> getFeed()22.   |> JS for <GraphFeedItem>33.     |> getGraphDataById()

如果我们无法重构 API,使其在必要时让 getFeed() 同时返回 getGraphDataById() 数据,那么 getFeed->getGraphDataById 瀑布就无法消除。但借助条件预取,我们至少可以让代码加载与数据拉取并行进行。正如上文所述,实现方式有多种;本例中,我们选择在查询函数内完成:

TypeScript
1function Feed() {2  const queryClient = useQueryClient()3  const { data, isPending } = useQuery({4    queryKey: ['feed'],5    queryFn: async (...args) => {6      const feed = await getFeed(...args)7 8      for (const feedItem of feed) {9        if (feedItem.type === 'GRAPH') {10          void queryClient.query({11            queryKey: ['graph', feedItem.id],12            queryFn: getGraphDataById,13          }).catch(noop)14        }15      }16 17      return feed18    }19  })20 21  ...22}

这将使代码加载与数据拉取并行执行:

Code
11. |> getFeed()22.   |> JS for <GraphFeedItem>32.   |> getGraphDataById()

但这里存在一个权衡:getGraphDataById 的代码现在被包含在父 bundle 中,而非 JS for <GraphFeedItem> 中。你需要根据具体情况判断哪种性能权衡更优。如果 GraphFeedItem 很常见,将代码包含在父 bundle 中可能是值得的;如果它们极为罕见,则很可能不值得。

路由集成

由于在组件树中直接进行数据拉取很容易引发请求瀑布,而各种修复方案又会随着应用规模扩大而变得繁琐,因此一种颇具吸引力的预取方式是将其集成到路由层。

在此方法中,你为每个 路由 显式声明其对应组件树所需的数据,并提前加载。由于服务端渲染(SSR)传统上要求所有数据在渲染开始前就已加载完毕,这种方法长期以来一直是 SSR 应用的主流方案。它至今仍很常见,更多细节请参阅 服务端渲染与注水指南。

目前,我们先聚焦于客户端场景,并以 TanStack Router 为例说明如何实现。为保持简洁,这些示例省略了许多初始化和样板代码;你可在 TanStack Router 文档 中查看完整的 Preact Query 示例。

在路由层集成时,你可以选择两种策略:一是 阻塞 该路由的渲染,直到所有数据就绪;二是启动预取但不等待其完成,从而尽快开始渲染路由。你还可以混合使用这两种策略:对部分关键数据等待其加载完成,同时在次要数据尚未加载完毕时就开始渲染路由。本例中,我们将配置一个 /article 路由,使其在文章数据加载完成前不渲染,同时尽可能早地预取评论数据,但即使评论尚未加载完成也不阻塞路由渲染。

请注意,许多路由加载器会利用错误边界(error boundary)来触发错误回退。此前我们一直使用 .catch(noop) 来忽略那些将由 useQuery 重试的数据错误;但对于路由正常运行所必需的关键数据,你应该使用 await 并不带 noop 地处理 Promise,并在 try 块或路由自身的错误处理机制(例如 TanStack Router 的 errorComponent)中处理错误。

TypeScript
1const queryClient = new QueryClient()2const routerContext = new RouterContext()3const rootRoute = routerContext.createRootRoute({4  component: () => { ... }5})6 7const articleRoute = new Route({8  getParentRoute: () => rootRoute,9  path: 'article',10  beforeLoad: () => {11    return {12      articleQueryOptions: { queryKey: ['article'], queryFn: fetchArticle },13      commentsQueryOptions: { queryKey: ['comments'], queryFn: fetchComments },14    }15  },16  loader: async ({17    context: { queryClient },18    routeContext: { articleQueryOptions, commentsQueryOptions },19  }) => {20    // Fetch comments asap, but don't block or throw errors21    void queryClient.query(commentsQueryOptions).catch(noop)22 23    // Don't render the route at all until article has been fetched24    // As this is critical data we want the error component to trigger25    // as soon as possible if something goes wrong26    await queryClient.query({27      ...articleQueryOptions,28      // If we have the article loaded already, we don't want to block on29      // an extra prefetch; fallback on the default useQuery behavior to30      // keep the data fresh31      staleTime: 'static'32    })33  },34  component: ({ useRouteContext }) => {35    const { articleQueryOptions, commentsQueryOptions } = useRouteContext()36    const articleQuery = useQuery(articleQueryOptions)37    const commentsQuery = useQuery(commentsQueryOptions)38 39    return (40      ...41    )42  },43  errorComponent: () => 'Oh crap!',44})

与其他路由库的集成也同样可行,详见 contacts-react-router 的演示。

手动预热一个查询

如果你已经同步获得了查询所需的数据,则无需预取。你可以直接使用 Query Client 的 setQueryData 方法,按查询键(key)向缓存中添加或更新查询的缓存结果。

TypeScript
1queryClient.setQueryData(['todos'], todos)

进一步阅读

若想深入了解如何在拉取数据前将数据注入查询缓存(Query Cache),请参阅 TkDodo 撰写的 《为查询缓存注入初始数据》一文。

与服务端路由和框架(Server Side routers and frameworks)集成的方式与我们刚刚看到的非常相似,区别在于:数据需从服务端传递至客户端,并在客户端完成注水(hydration)以填充缓存。要了解具体实现方法,请继续阅读 《服务端渲染与注水指南》。

本页为 TanStack Query 文档(Preact 版)的非官方中文翻译,原文由上游团队维护,遵循 MIT 许可。 译文如有偏差,以英文原文为准。

译自上游 59d11de