预取与路由器集成
当你知道或怀疑某段数据即将被需要时,可以使用预取(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/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 函数:
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 事件上启动预取。
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 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}如该指南所述,此示例会导致如下双重请求瀑布:
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)传统上要求所有数据在渲染开始前就已加载完毕,这种方法长期以来一直是 SSR 应用的主流方案。它至今仍很常见,更多细节请参阅 服务端渲染与注水指南。
目前,我们先聚焦于客户端场景,并以 TanStack Router 为例说明如何实现。为保持简洁,这些示例省略了许多初始化和样板代码;你可在 TanStack Router 文档 中查看完整的 Preact Query 示例。
在路由层集成时,你可以选择两种策略:一是 阻塞 该路由的渲染,直到所有数据就绪;二是启动预取但不等待其完成,从而尽快开始渲染路由。你还可以混合使用这两种策略:对部分关键数据等待其加载完成,同时在次要数据尚未加载完毕时就开始渲染路由。本例中,我们将配置一个 /article 路由,使其在文章数据加载完成前不渲染,同时尽可能早地预取评论数据,但即使评论尚未加载完成也不阻塞路由渲染。
请注意,许多路由加载器会利用错误边界(error boundary)来触发错误回退。此前我们一直使用 .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 方法,按查询键(key)向缓存中添加或更新查询的缓存结果。
1queryClient.setQueryData(['todos'], todos)进一步阅读
若想深入了解如何在拉取数据前将数据注入查询缓存(Query Cache),请参阅 TkDodo 撰写的 《为查询缓存注入初始数据》一文。
与服务端路由和框架(Server Side routers and frameworks)集成的方式与我们刚刚看到的非常相似,区别在于:数据需从服务端传递至客户端,并在客户端完成注水(hydration)以填充缓存。要了解具体实现方法,请继续阅读 《服务端渲染与注水指南》。