当您预知或推测某块数据即将被需要时,可以通过预取 (prefetching) 提前将该数据填充到缓存中,从而获得更快的用户体验。
预取存在几种不同的实现模式:
本指南将探讨前三种模式,而第四种模式将在《服务端渲染与注水指南》和《高级服务端渲染指南》中深入讲解。
预取的一个典型应用场景是避免请求瀑布流 (Request Waterfalls),相关背景和原理详见《性能与请求瀑布流指南》。
在深入具体预取模式前,我们先了解 prefetchQuery 和 prefetchInfiniteQuery 函数的基础特性:
prefetchQuery 的使用示例如下:
const prefetchTodos = async () => {
// 该查询结果会像普通查询一样被缓存
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
const prefetchTodos = async () => {
// 该查询结果会像普通查询一样被缓存
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
无限查询 (Infinite Queries) 的预取方式与常规查询相同。默认仅预取第一页数据并存储于指定 QueryKey 下。如需预取多页数据,可使用 pages 选项并配合 getNextPageParam 函数:
const prefetchProjects = async () => {
// 该查询结果会像普通查询一样被缓存
await queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
pages: 3, // 预取前 3 页数据
})
}
const prefetchProjects = async () => {
// 该查询结果会像普通查询一样被缓存
await queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
pages: 3, // 预取前 3 页数据
})
}
接下来我们将探讨如何在不同场景下应用这些预取方法。
最直接的预取方式是在用户交互时触发。以下示例通过 onMouseEnter 或 onFocus 事件调用 queryClient.prefetchQuery:
function ShowDetailsButton() {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['details'],
queryFn: getDetailsData,
// 预取仅在数据早于 staleTime 时触发
// 此类场景务必设置该参数
staleTime: 60000,
})
}
return (
<button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
查看详情
</button>
)
}
function ShowDetailsButton() {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['details'],
queryFn: getDetailsData,
// 预取仅在数据早于 staleTime 时触发
// 此类场景务必设置该参数
staleTime: 60000,
})
}
return (
<button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
查看详情
</button>
)
}
当预知子组件需要某块数据但需等待其他查询完成时,组件生命周期内的预取非常有用。以下示例来自请求瀑布流指南:
function Article({ id }) {
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})
if (isPending) {
return '文章加载中...'
}
return (
<>
<ArticleHeader articleData={articleData} />
<ArticleBody articleData={articleData} />
<Comments id={id} />
</>
)
}
function Comments({ id }) {
const { data, isPending } = useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
...
}
function Article({ id }) {
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})
if (isPending) {
return '文章加载中...'
}
return (
<>
<ArticleHeader articleData={articleData} />
<ArticleBody articleData={articleData} />
<Comments id={id} />
</>
)
}
function Comments({ id }) {
const { data, isPending } = useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
...
}
此时会产生如下请求瀑布流:
1. |> getArticleById()
2. |> getArticleCommentsById()
1. |> getArticleById()
2. |> getArticleCommentsById()
如指南所述,优化方案之一是将 getArticleCommentsById 查询提升到父组件并通过 props 传递。但当组件层级复杂或关联性较弱时,可以采用预取方案:
function Article({ id }) {
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})
// 预取操作
useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
// 可选优化:避免查询变化导致的重复渲染
notifyOnChangeProps: [],
})
if (isPending) {
return '文章加载中...'
}
return (
<>
<ArticleHeader articleData={articleData} />
<ArticleBody articleData={articleData} />
<Comments id={id} />
</>
)
}
function Comments({ id }) {
const { data, isPending } = useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
...
}
function Article({ id }) {
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})
// 预取操作
useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
// 可选优化:避免查询变化导致的重复渲染
notifyOnChangeProps: [],
})
if (isPending) {
return '文章加载中...'
}
return (
<>
<ArticleHeader articleData={articleData} />
<ArticleBody articleData={articleData} />
<Comments id={id} />
</>
)
}
function Comments({ id }) {
const { data, isPending } = useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
...
}
此时请求变为并行:
1. |> getArticleById()
1. |> getArticleCommentsById()
1. |> getArticleById()
1. |> getArticleCommentsById()
若需结合 Suspense 使用预取,需采用特殊处理。由于 useSuspenseQueries 会阻塞渲染,而 useQuery 又会在 suspenseful query 解析后才启动预取,此时应使用 usePrefetchQuery 或 usePrefetchInfiniteQuery 钩子。
实际需要数据的组件可使用 useSuspenseQuery。建议为次级查询包裹单独的 <Suspense> 边界,避免阻塞主要数据渲染:
function ArticleLayout({ id }) {
usePrefetchQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
return (
<Suspense fallback="加载文章中">
<Article id={id} />
</Suspense>
)
}
function Article({ id }) {
const { data: articleData, isPending } = useSuspenseQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})
...
}
function ArticleLayout({ id }) {
usePrefetchQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
return (
<Suspense fallback="加载文章中">
<Article id={id} />
</Suspense>
)
}
function Article({ id }) {
const { data: articleData, isPending } = useSuspenseQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})
...
}
另一种方案是在查询函数内预取,适用于文章与评论数据强关联的场景:
const queryClient = useQueryClient()
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: (...args) => {
queryClient.prefetchQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
return getArticleById(...args)
},
})
const queryClient = useQueryClient()
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: (...args) => {
queryClient.prefetchQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
return getArticleById(...args)
},
})
Effect 中的预取也可行,但注意若同一组件使用 useSuspenseQuery,effect 会在查询完成后才执行:
const queryClient = useQueryClient()
useEffect(() => {
queryClient.prefetchQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
}, [queryClient, id])
const queryClient = useQueryClient()
useEffect(() => {
queryClient.prefetchQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
}, [queryClient, id])
总结组件内预取的四种方案(根据场景选择):
接下来我们看更复杂的案例。
有时需要基于其他查询结果条件式预取。以下示例来自《性能与请求瀑布流指南》:
// 动态加载 GraphFeedItem 组件
// 只有在渲染时才会开始加载
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))
function Feed() {
const { data, isPending } = useQuery({
queryKey: ['feed'],
queryFn: getFeed,
})
if (isPending) {
return '加载动态中...'
}
return (
<>
{data.map((feedItem) => {
if (feedItem.type === 'GRAPH') {
return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
}
return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
})}
</>
)
}
// GraphFeedItem.tsx
function GraphFeedItem({ feedItem }) {
const { data, isPending } = useQuery({
queryKey: ['graph', feedItem.id],
queryFn: getGraphDataById,
})
...
}
// 动态加载 GraphFeedItem 组件
// 只有在渲染时才会开始加载
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))
function Feed() {
const { data, isPending } = useQuery({
queryKey: ['feed'],
queryFn: getFeed,
})
if (isPending) {
return '加载动态中...'
}
return (
<>
{data.map((feedItem) => {
if (feedItem.type === 'GRAPH') {
return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
}
return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
})}
</>
)
}
// GraphFeedItem.tsx
function GraphFeedItem({ feedItem }) {
const { data, isPending } = useQuery({
queryKey: ['graph', feedItem.id],
queryFn: getGraphDataById,
})
...
}
此时会产生双重请求瀑布:
1. |> getFeed()
2. |> JS for <GraphFeedItem>
3. |> getGraphDataById()
1. |> getFeed()
2. |> JS for <GraphFeedItem>
3. |> getGraphDataById()
若无法通过 API 重构让 getFeed() 返回 getGraphDataById() 数据,虽然无法消除 getFeed->getGraphDataById 瀑布流,但通过条件预取可实现代码与数据并行加载(以下示例在查询函数中实现):
function Feed() {
const queryClient = useQueryClient()
const { data, isPending } = useQuery({
queryKey: ['feed'],
queryFn: async (...args) => {
const feed = await getFeed(...args)
for (const feedItem of feed) {
if (feedItem.type === 'GRAPH') {
queryClient.prefetchQuery({
queryKey: ['graph', feedItem.id],
queryFn: getGraphDataById,
})
}
}
return feed
}
})
...
}
function Feed() {
const queryClient = useQueryClient()
const { data, isPending } = useQuery({
queryKey: ['feed'],
queryFn: async (...args) => {
const feed = await getFeed(...args)
for (const feedItem of feed) {
if (feedItem.type === 'GRAPH') {
queryClient.prefetchQuery({
queryKey: ['graph', feedItem.id],
queryFn: getGraphDataById,
})
}
}
return feed
}
})
...
}
此时加载流程变为:
1. |> getFeed()
2. |> JS for <GraphFeedItem>
2. |> getGraphDataById()
1. |> getFeed()
2. |> JS for <GraphFeedItem>
2. |> getGraphDataById()
但需权衡的是:getGraphDataById 的代码现在会打包到父组件中。若 GraphFeedItem 出现频率高,这种优化值得;若非常罕见,则可能不划算。
由于组件树内的数据获取容易引发请求瀑布流,而各种修复方案又会在应用中不断累积,将预取集成到路由层成为颇具吸引力的解决方案。
这种方式需要为每个路由预先声明其组件树所需的数据。传统服务端渲染 (SSR) 应用由于需要在渲染前加载所有数据,长期采用此方案(详见《服务端渲染与注水指南》)。
以下以 Tanstack Router 为例展示客户端方案(省略了大量配置代码,完整示例参见 Tanstack Router 文档):
路由集成时,可选择阻塞渲染直到数据加载完成,或不等待结果立即开始渲染。也可混合使用——等待关键数据同时预取次要数据。本例中 /article 路由会等待文章数据加载,同时预取但不阻塞评论数据:
const queryClient = new QueryClient()
const routerContext = new RouterContext()
const rootRoute = routerContext.createRootRoute({
component: () => { ... }
})
const articleRoute = new Route({
getParentRoute: () => rootRoute,
path: 'article',
beforeLoad: () => {
return {
articleQueryOptions: { queryKey: ['article'], queryFn: fetchArticle },
commentsQueryOptions: { queryKey: ['comments'], queryFn: fetchComments },
}
},
loader: async ({
context: { queryClient },
routeContext: { articleQueryOptions, commentsQueryOptions },
}) => {
// 立即预取评论但不阻塞
queryClient.prefetchQuery(commentsQueryOptions)
// 阻塞直到文章数据加载完成
await queryClient.prefetchQuery(articleQueryOptions)
},
component: ({ useRouteContext }) => {
const { articleQueryOptions, commentsQueryOptions } = useRouteContext()
const articleQuery = useQuery(articleQueryOptions)
const commentsQuery = useQuery(commentsQueryOptions)
return (
...
)
},
errorComponent: () => '出错了!',
})
const queryClient = new QueryClient()
const routerContext = new RouterContext()
const rootRoute = routerContext.createRootRoute({
component: () => { ... }
})
const articleRoute = new Route({
getParentRoute: () => rootRoute,
path: 'article',
beforeLoad: () => {
return {
articleQueryOptions: { queryKey: ['article'], queryFn: fetchArticle },
commentsQueryOptions: { queryKey: ['comments'], queryFn: fetchComments },
}
},
loader: async ({
context: { queryClient },
routeContext: { articleQueryOptions, commentsQueryOptions },
}) => {
// 立即预取评论但不阻塞
queryClient.prefetchQuery(commentsQueryOptions)
// 阻塞直到文章数据加载完成
await queryClient.prefetchQuery(articleQueryOptions)
},
component: ({ useRouteContext }) => {
const { articleQueryOptions, commentsQueryOptions } = useRouteContext()
const articleQuery = useQuery(articleQueryOptions)
const commentsQuery = useQuery(commentsQueryOptions)
return (
...
)
},
errorComponent: () => '出错了!',
})
其他路由器的集成方案也可行,参见 React Router 示例。
如果已同步获取到查询数据,可直接使用 Query Client 的 setQueryData 方法 通过键值对添加或更新缓存结果:
queryClient.setQueryData(['todos'], todos)
queryClient.setQueryData(['todos'], todos)