预取与路由器集成
当你知道或怀疑某段数据即将被需要时,可以使用预取(prefetching)提前将该数据填充到缓存中,从而获得更快的用户体验。
预取有几种不同的模式:
- 在事件处理器中
- 在组件中
- 通过路由集成
- 在服务端渲染期间(另一种形式的路由集成)
本指南将重点介绍前三种模式;第四种将在服务端渲染与注水指南和高级服务端渲染指南中深入讲解。
预取的一个具体用途是避免请求瀑布(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 进行预取的方式:
1import { noop } from '@tanstack/react-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 函数:
1import { noop } from '@tanstack/react-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 上启动预取。
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}在组件中预取
在组件生命周期内进行预取,适用于你已知某些子组件或后代组件将需要特定数据,但又必须等待其他查询完成加载后才能渲染它们的情况。我们借用请求瀑布指南中的一个例子来说明:
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}这将导致如下所示的请求瀑布:
11. |> getArticleById()22. |> getArticleCommentsById()正如该指南中所述,一种展平该瀑布并提升性能的方法是将 getArticleCommentsById 查询上提到父组件,并将结果作为 props 传递下去。但如果组件之间无关联、且层级较深,这种重构可能不可行或不理想。
此时,我们可在父组件中对查询进行预取。最简单的方式是发起一次查询,但忽略其结果:
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',从而展平瀑布:
11. |> getArticleById()21. |> getArticleCommentsById()若你想结合 Suspense 进行预取,则需采用略有不同的做法。你不能使用 useSuspenseQueries 进行预取,因为预取会阻塞组件渲染;也不能使用 useQuery 进行预取,因为那要等到 suspenseful 查询解析完成后才开始预取。对于该场景,你可以使用库中提供的usePrefetchQuery 或 usePrefetchInfiniteQuery Hook。
你现在可以在真正需要数据的组件中使用 useSuspenseQuery。你_可能_希望将该后续组件包裹在它自己的 <Suspense> 错误边界中,以确保我们正在预取的“次要”查询不会阻塞“主要”数据的渲染。
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:
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 将在查询完成_之后_才运行,而这未必符合你的预期。
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或usePrefetchInfiniteQueryHook,在 Suspense 边界之前预取 - 使用
useQuery或useSuspenseQueries并忽略其结果 - 在查询函数内部预取
- 在 effect 中预取
接下来我们看一个稍复杂的案例。
依赖查询与代码分割
有时我们需要基于另一个拉取操作的结果,有条件地进行预取。以下示例引自性能与请求瀑布指南:
1// This lazy loads the GraphFeedItem component, meaning2// it won't start loading until something renders it3const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))4 5function Feed() {6 const { data, isPending } = useQuery({7 queryKey: ['feed'],8 queryFn: getFeed,9 })10 11 if (isPending) {12 return 'Loading feed...'13 }14 15 return (16 <>17 {data.map((feedItem) => {18 if (feedItem.type === 'GRAPH') {19 return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />20 }2122 return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />23 })}24 </>25 )26}27 28// GraphFeedItem.tsx29function GraphFeedItem({ feedItem }) {30 const { data, isPending } = useQuery({31 queryKey: ['graph', feedItem.id],32 queryFn: getGraphDataById,33 })34 35 ...36}如该指南所述,此例会导致如下双重请求瀑布:
11. |> getFeed()22. |> JS for <GraphFeedItem>33. |> getGraphDataById()如果我们无法重构 API,使其在必要时让 getFeed() 同时返回 getGraphDataById() 数据,则无法彻底消除 getFeed->getGraphDataById 瀑布;但借助条件预取,至少可并行加载代码与数据。正如上文所述,实现方式有多种,而本例中我们选择在查询函数内完成:
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}这将并行加载代码与数据:
11. |> getFeed()22. |> JS for <GraphFeedItem>32. |> getGraphDataById()但存在一个权衡:getGraphDataById 的代码现在被包含在父 bundle 中,而非 JS for <GraphFeedItem> 中,因此你需要逐案评估哪种性能权衡更优。如果 GraphFeedItem 很常见,那么将代码包含在父 bundle 中可能是值得的;如果它们极为罕见,则很可能并不值得。
路由集成
由于在组件树内部进行数据拉取很容易引发请求瀑布,而针对该问题的各种修复方案又会随着应用规模扩大而变得繁琐,因此一种颇具吸引力的预取方式是将其集成至路由层。
在此方法中,你为每个_路由_显式声明其对应组件树所需的数据,并提前加载。由于服务端渲染传统上要求所有数据在渲染开始前就已加载完毕,这种方法长期以来一直是 SSR 应用的主流方案。它至今仍很常见,更多细节请参阅服务端渲染与注水指南。
目前,我们先聚焦于客户端场景,并以 TanStack Router 为例说明如何实现该集成。为保持简洁,这些示例省略了大量初始化和样板代码;你可在 TanStack Router 文档 中查阅完整的 React Query 示例链接。
在路由层集成时,你可以选择两种策略:一是_阻塞_该路由的渲染,直至所有数据就绪;二是启动预取但不等待其完成,以便尽快开始渲染路由。你还可以混合使用这两种策略:对部分关键数据进行 await,而对次要数据则在未完成时即开始渲染。本例中,我们将配置一个 /article 路由,使其在文章数据加载完成前不渲染,同时尽可能早地预取评论数据,但即使评论尚未加载完成也不阻塞路由渲染。
请注意,许多路由加载器会利用错误边界触发错误回退。此前我们一直使用 .catch(noop) 忽略那些将由 useQuery 重试的数据错误;但对于路由所依赖的关键数据,你应使用 await 并不带 noop 地拒绝 Promise,并在 try 块或路由自身的错误处理机制(例如 TanStack Router 的 errorComponent)中处理错误。
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 方法,按查询键(query key)直接添加或更新查询的缓存结果。
1queryClient.setQueryData(['todos'], todos)进一步阅读
若想深入了解如何在拉取数据前将数据注入查询缓存(Query Cache),请参阅 TkDodo 撰写的 《为查询缓存注入初始数据》一文。
与服务端路由和框架(Server Side routers and frameworks)集成的方式与我们刚才所见非常相似,区别在于:数据需从服务端传递至客户端,并在客户端完成注水(hydration)以填充缓存。要了解具体实现方式,请继续阅读 《服务端渲染与注水指南》。