框架
版本

预取和路由器集成

当您知道或怀疑某个特定数据片段将被需要时,您可以使用预取来提前用该数据填充缓存,从而获得更快的体验。

有几种不同的预取模式:

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

在本指南中,我们将介绍前三种,而第��种将在服务器端渲染和水合指南高级服务器端渲染指南中深入介绍。

预取的一个特定用途是避免请求瀑布流,有关这些内容的深入背景和解释,请参阅性能和请求瀑布流指南

prefetchQuery 和 prefetchInfiniteQuery

在深入了解不同的特定预取模式之前,让我们先看看 prefetchQueryprefetchInfiniteQuery 函数。首先是一些基础知识:

  • 开箱即用,这些函数使用为 queryClient 配置的默认 staleTime 来确定缓存中的现有数据是否新鲜或需要再次获取
  • 您还可以传递一个特定的 staleTime,例如:prefetchQuery({ queryKey: ['todos'], queryFn: fn, staleTime: 5000 })
    • staleTime 仅用于预取,您仍然需要为任何 useQuery 调用设置它
    • 如果您想忽略 staleTime 并始终返回缓存中可用的数据,可以使用 ensureQueryData 函数。
    • 提示:如果您在服务器上进行预取,请为该 queryClient 设置一个高于 0 的默认 staleTime,以避免必须为每个预取调用传递特定的 staleTime
  • 如果预取查询没有出现 useQuery 实例,它将在 gcTime 中指定的时间后被删除并进行垃圾回收
  • 这些函数返回 Promise<void>,因此永远不会返回查询数据。如果您需要查询数据,请改用 fetchQuery/fetchInfiniteQuery
  • 预取函数永远不会抛出错误,因为它们通常会尝试在 useQuery 中再次获取,这是一个很好的优雅回退。如果您需要捕获错误,请改用 fetchQuery/fetchInfiniteQuery

以下是如何使用 prefetchQuery

tsx
const prefetchTodos = async () => {
  // 此查询的结果将像普通查询一样被缓存
  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
}
const prefetchTodos = async () => {
  // 此查询的结果将像普通查询一样被缓存
  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
}

无限查询可以像常规查询一样进行预取。默认情况下,仅预取查询的第一页,并将其存储在给定的 QueryKey 下。如果要预取多个页面,可以使用 pages 选项,在这种情况下,您还必须提供一个 getNextPageParam 函数:

tsx
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 页
  })
}

接下来,让我们看看如何在不同情况下使用这些以及其他预取方法。

在事件处理程序中预取

一种直接的预取形式是在用户与某些内容交互时执行。在此示例中,我们将使用 queryClient.prefetchQueryonMouseEnteronFocus 上启动预取。

tsx
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>
  )
}

在组件中预取

在组件生命周期中进行预取非常有用,因为我们知道某个子组件或后代组件将需要特定的数据片段,但在其他某个查询加载完成之前我们无法渲染该数据。让我们借用请求瀑布流指南中的一个示例来解释:

tsx
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 查询提升到父级并将结果作为属性向下传递,但是如果这不可行或不可取,例如当组件不相关并且它们之间有多个级别时,该怎么办?

在这种情况下,我们可以改为在父级中预取查询。最简单的方法是使用查询但忽略结果:

tsx
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,
  })

  ...
}

这将立即开始获取 'article-comments' 并展平瀑布流:

1. |> getArticleById()
1. |> getArticleCommentsById()
1. |> getArticleById()
1. |> getArticleCommentsById()

如果您想将预取与 Suspense 一起使用,则需要做一些不同的事情。您不能使用 useSuspenseQueries 进行预取,因为预取会阻止组件渲染。您也不能使用 useQuery 进行预取,因为在悬念查询完成之前,预取不会开始。对于这种情况,您可以使用库中可用的 usePrefetchQueryusePrefetchInfiniteQuery 钩子。

现在,您可以在实际需要数据的组件中使用 useSuspenseQuery。您_可能_希望将此后面的组件包装在其自己的 <Suspense> 边界中,以便我们正在预取的“辅助”查询不会阻止“主要”数据的渲染。

tsx
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,
  })

  ...
}

另一种方法是在查询函数内部进行预取。如果您知道每次获取文章时很可能也需要评论,那么这样做是有意义的。为此,我们将使用 queryClient.prefetchQuery

tsx
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)
  },
})

在效果中进行预取也可以,但请注意,如果您在同一个组件中使用 useSuspenseQuery,则此效果在查询完成_之后_才会运行,这可能不是您想要的。

tsx
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])

总而言之,如果您想在组件生命周期中预取查询,有几种不同的方法可以做到这一点,请选择最适合您情况的方法:

  • 在 suspense 边界之前使用 usePrefetchQueryusePrefetchInfiniteQuery 钩子进行预取
  • 使用 useQueryuseSuspenseQueries 并忽略结果
  • 在查询函数内部进行预取
  • 在效果中进行预取

接下来让我们看一个稍微高级一点的案例。

依赖查询和代码拆分

有时我们希望根据另一个获取的结果有条件地进行预取。考虑一下这个从性能和请求瀑布流指南中借用的示例:

tsx
// 这会延迟加载 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 瀑布流,但是通过利用条件预取,我们至少可以并行加载代码和数据。就像上面描述的那样,有多种方法可以做到这一点,但对于此示例,我们将在查询函数中执行此操作:

tsx
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 的代码现在包含在父捆绑包中,而不是在 JS for <GraphFeedItem> 中,因此您需要根据具体情况确定最佳的性能权衡。如果 GraphFeedItem 很可能出现,则可能值得将代码包含在父捆绑包中。如果它们非常罕见,则可能不值得。

路由器集成

由于组件树本身的数据获取很容易导致请求瀑布流,并且针对此问题的不同修复方法在整个应用程序中累积起来可能很麻烦,因此一种有吸引力的预取方法是在路由器级别进行集成。

在这种方法中,您明确声明每个_路由_的组件树将提前需要哪些数据。由于服务器端渲染传统上需要在渲染开始之前加载所有数据,因此这长期以来一直是 SSR 应用程序的主导方法。这仍然是一种常见的方法,您可以在服务器端渲染和水合指南中阅读更多相关信息。

现在,让我们专注于客户端情况,并看一个示例,说明如何使用 Tanstack Router 使其正常工作。这些示例省略了许多设置和样板代码以保持简洁,您可以在 Tanstack Router 文档中查看完整的 React Query 示例

在路由器级别集成时,您可以选择在所有数据都存在之前_阻止_该路由的渲染,也可以启动预取但不等待结果。这样,您可以尽快开始渲染路由。您还可以混合使用这两种方法,并等待一些关键数据,但在所有辅助数据加载完成之前开始渲染。在此示例中,我们将配置 /article 路由,使其在文章数据加载完成之前不渲染,并尽快开始预取评论,但如果评论尚未加载完成,则不阻止渲染路由。

tsx
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 以获取另一个演示。

手动填充查询

如果您已经同步拥有查询的数据,则无需预取它。您只需使用查询客户端的 setQueryData 方法即可按键直接添加或更新查询的缓存结果。

tsx
queryClient.setQueryData(['todos'], todos)
queryClient.setQueryData(['todos'], todos)

进一步阅读

有关如何在获取之前将数据放入查询缓存的深入探讨,请参阅社区资源中的 #17:填充查询缓存

与服务器端路由器和框架的集成与我们刚才看到的非常相似,不同之处在于数据必须从服务器传递到客户端才能在那里水合到缓存中。要了解如何操作,请继续阅读服务器端渲染和水合指南