框架
版本

预取 & 路由器集成

当您知道或怀疑需要某个数据时,可以使用预取提前使用该数据填充缓存,从而加快体验。

有几种不同的预取模式

  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 () => {
  // The results of this query will be cached like a normal query
  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
}
const prefetchTodos = async () => {
  // The results of this query will be cached like a normal query
  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
}

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

tsx
const prefetchProjects = async () => {
  // The results of this query will be cached like a normal query
  await queryClient.prefetchInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
    pages: 3, // prefetch the first 3 pages
  })
}
const prefetchProjects = async () => {
  // The results of this query will be cached like a normal query
  await queryClient.prefetchInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
    pages: 3, // prefetch the first 3 pages
  })
}

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

事件处理程序中的预取

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

tsx
function ShowDetailsButton() {
  const queryClient = useQueryClient()

  const prefetch = () => {
    queryClient.prefetchQuery({
      queryKey: ['details'],
      queryFn: getDetailsData,
      // Prefetch only fires when data is older than the staleTime,
      // so in a case like this you definitely want to set one
      staleTime: 60000,
    })
  }

  return (
    <button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
      Show Details
    </button>
  )
}
function ShowDetailsButton() {
  const queryClient = useQueryClient()

  const prefetch = () => {
    queryClient.prefetchQuery({
      queryKey: ['details'],
      queryFn: getDetailsData,
      // Prefetch only fires when data is older than the staleTime,
      // so in a case like this you definitely want to set one
      staleTime: 60000,
    })
  }

  return (
    <button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
      Show Details
    </button>
  )
}

组件中的预取

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

tsx
function Article({ id }) {
  const { data: articleData, isPending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  if (isPending) {
    return 'Loading article...'
  }

  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 'Loading article...'
  }

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

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

tsx
function Article({ id }) {
  const { data: articleData, isPending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  // Prefetch
  useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
    // Optional optimization to avoid rerenders when this query changes:
    notifyOnChangeProps: [],
  })

  if (isPending) {
    return 'Loading article...'
  }

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

  // Prefetch
  useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
    // Optional optimization to avoid rerenders when this query changes:
    notifyOnChangeProps: [],
  })

  if (isPending) {
    return 'Loading article...'
  }

  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 进行预取,因为这不会在 suspenseful 查询解析后才开始预取。对于这种情况,您可以使用库中提供的 usePrefetchQueryusePrefetchInfiniteQuery 钩子。

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

tsx
function ArticleLayout({ id }) {
  usePrefetchQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  return (
    <Suspense fallback="Loading article">
      <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="Loading article">
      <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)
  },
})

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

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

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

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

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

依赖查询 & 代码拆分

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

tsx
// This lazy loads the GraphFeedItem component, meaning
// it wont start loading until something renders it
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))

function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

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

  ...
}
// This lazy loads the GraphFeedItem component, meaning
// it wont start loading until something renders it
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))

function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

  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 的代码现在包含在父 bundle 中,而不是在 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 },
  }) => {
    // Fetch comments asap, but don't block
    queryClient.prefetchQuery(commentsQueryOptions)

    // Don't render the route at all until article has been fetched
    await queryClient.prefetchQuery(articleQueryOptions)
  },
  component: ({ useRouteContext }) => {
    const { articleQueryOptions, commentsQueryOptions } = useRouteContext()
    const articleQuery = useQuery(articleQueryOptions)
    const commentsQuery = useQuery(commentsQueryOptions)

    return (
      ...
    )
  },
  errorComponent: () => 'Oh crap!',
})
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 },
  }) => {
    // Fetch comments asap, but don't block
    queryClient.prefetchQuery(commentsQueryOptions)

    // Don't render the route at all until article has been fetched
    await queryClient.prefetchQuery(articleQueryOptions)
  },
  component: ({ useRouteContext }) => {
    const { articleQueryOptions, commentsQueryOptions } = useRouteContext()
    const articleQuery = useQuery(articleQueryOptions)
    const commentsQuery = useQuery(commentsQueryOptions)

    return (
      ...
    )
  },
  errorComponent: () => 'Oh crap!',
})

与其他路由器的集成也是可能的,有关另一个演示,请参阅React Router 示例

手动启动查询

如果您已经同步提供了查询的数据,则无需预取它。您只需使用 Query Client 的 setQueryData 方法即可直接添加或更新查询的缓存结果(按键)。

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

延伸阅读

有关如何在获取之前将数据放入 Query Cache 的深入探讨,请查看社区资源中的#17:播种查询缓存

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