预取与路由器集成
当你已知或怀疑某段数据将被需要时,可以使用预取(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/solid-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/solid-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}在组件中预取
在组件生命周期内进行预取,适用于你已知某些子组件或后代组件将需要特定数据,但又无法等到另一查询完成加载后再渲染它们的情况。我们借用请求瀑布指南中的一个例子来说明:
1import { Switch, Match } from 'solid-js'2 3function Article(props) {4 const articleQuery = useQuery(() => ({5 queryKey: ['article', props.id],6 queryFn: getArticleById,7 }))8 9 return (10 <Switch>11 <Match when={articleQuery.isPending}>12 Loading article...13 </Match>14 <Match when={articleQuery.isSuccess}>15 <ArticleHeader articleData={articleQuery.data} />16 <ArticleBody articleData={articleQuery.data} />17 <Comments id={props.id} />18 </Match>19 </Switch>20 )21}22 23function Comments(props) {24 const commentsQuery = useQuery(() => ({25 queryKey: ['article-comments', props.id],26 queryFn: getArticleCommentsById,27 }))28 29 ...30}这将导致如下请求瀑布:
11. |> getArticleById()22. |> getArticleCommentsById()正如该指南所述,一种展平此瀑布并提升性能的方法是将 getArticleCommentsById 查询上提到父组件,并将结果作为 prop 传递下去;但如果组件彼此无关且中间相隔多个层级,这种重构可能不可行或不理想。
此时,我们可在父组件中对查询进行预取。最简单的方式是发起一个查询但忽略其结果:
1import { Switch, Match } from 'solid-js'2 3function Article(props) {4 const articleQuery = useQuery(() => ({5 queryKey: ['article', props.id],6 queryFn: getArticleById,7 }))8 9 // Prefetch10 useQuery(() => ({11 queryKey: ['article-comments', props.id],12 queryFn: getArticleCommentsById,13 // Optional optimization to avoid rerenders when this query changes:14 notifyOnChangeProps: [],15 }))16 17 return (18 <Switch>19 <Match when={articleQuery.isPending}>20 Loading article...21 </Match>22 <Match when={articleQuery.isSuccess}>23 <ArticleHeader articleData={articleQuery.data} />24 <ArticleBody articleData={articleQuery.data} />25 <Comments id={props.id} />26 </Match>27 </Switch>28 )29}30 31function Comments(props) {32 const commentsQuery = useQuery(() => ({33 queryKey: ['article-comments', props.id],34 queryFn: getArticleCommentsById,35 }))36 37 ...38}这将立即开始拉取 'article-comments' 并展平瀑布:
11. |> getArticleById()21. |> getArticleCommentsById()另一种方式是在查询函数内部进行预取。若你确定每次拉取文章时极有可能也需要评论数据,这种方式就很合理。此处我们将使用 queryClient.query:
1const queryClient = useQueryClient()2const articleQuery = 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)中预取同样可行:
1import { createEffect } from 'solid-js'2 3const queryClient = useQueryClient()4 5createEffect(() => {6 void queryClient7 .query({8 queryKey: ['article-comments', id],9 queryFn: getArticleCommentsById,10 })11 .catch(noop)12})总结一下,若你想在组件生命周期内预取一个查询,有多种方式可供选择,请根据具体场景选用最合适的一种:
- 使用
useQuery并忽略结果 - 在查询函数内部预取
- 在副作用(effect)中预取
接下来我们看一个稍复杂的案例。
依赖查询与代码分割
有时我们需要基于另一个拉取的结果有条件地进行预取。考虑以下来自性能与请求瀑布指南的例子:
1import { lazy, Switch, Match, For } from 'solid-js'2 3// This lazy loads the GraphFeedItem component, meaning4// it wont start loading until something renders it5const GraphFeedItem = lazy(() => import('./GraphFeedItem'))6 7function Feed() {8 const feedQuery = useQuery(() => ({9 queryKey: ['feed'],10 queryFn: getFeed,11 }))12 13 return (14 <Switch>15 <Match when={feedQuery.isPending}>16 Loading feed...17 </Match>18 <Match when={feedQuery.isSuccess}>19 <For each={feedQuery.data}>20 {(feedItem) => {21 if (feedItem.type === 'GRAPH') {22 return <GraphFeedItem feedItem={feedItem} />23 }24 return <StandardFeedItem feedItem={feedItem} />25 }}26 </For>27 </Match>28 </Switch>29 )30}31 32// GraphFeedItem.tsx33function GraphFeedItem(props) {34 const graphQuery = useQuery(() => ({35 queryKey: ['graph', props.feedItem.id],36 queryFn: getGraphDataById,37 }))38 39 ...40}如该指南所述,此例会导致如下双重请求瀑布:
11. |> getFeed()22. |> JS for <GraphFeedItem>33. |> getGraphDataById()如果我们无法重构 API,使其在必要时让 getFeed() 同时返回 getGraphDataById() 数据,则无法彻底消除 getFeed->getGraphDataById 瀑布;但借助条件预取,至少可以让代码加载与数据拉取并行执行。如前所述,实现方式有多种,本例中我们选择在查询函数内完成:
1function Feed() {2 const queryClient = useQueryClient()3 const feedQuery = 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 文档中查看完整的 Solid Query 示例链接。
在路由器层面集成时,你可以选择两种策略:一是_阻塞_该路由的渲染,直到所有数据就绪;二是启动预取但不等待其结果,从而尽可能早地开始渲染路由。你还可以混合使用这两种策略:对部分关键数据等待其加载完成,同时在次要数据尚未加载完毕时就开始渲染。本例中,我们将配置一个 /article 路由,使其在文章数据加载完成前不渲染,同时尽快启动评论数据的预取,但即使评论尚未加载完成也不阻塞路由渲染。
请注意,许多路由加载器(route loaders)使用错误边界(error boundary)来触发错误回退。此前我们一直使用 .catch(noop) 忽略那些将由 useQuery 重试的数据错误;但对于路由运行所必需的关键数据,你应该使用 await 处理 Promise(不加 noop),并在 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})与其他路由器的集成也同样可行。
手动预热一个查询
如果你已经同步获得了查询所需的数据,则无需预取。你可以直接使用查询客户端(Query Client)的 setQueryData 方法,按查询键(key)直接添加或更新查询的缓存结果。
1queryClient.setQueryData(['todos'], todos)延伸阅读
若想深入了解如何在拉取之前将数据注入查询缓存(Query Cache),请参阅 TkDodo 撰写的《为查询缓存注入初始数据》一文。
与服务端路由器及框架的集成方式与我们刚刚看到的非常相似,区别在于数据需从服务端传递至客户端,并在客户端完成注水(hydration)以填入缓存。要了解具体实现方式,请继续阅读服务端渲染与注水指南。