框架
版本

预取与路由集成

当您知道或怀疑某些数据即将被需要时,可以使用预取功能提前将数据填充到缓存中,从而获得更快的体验。

有几种不同的预取模式:

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

在本指南中,我们将探讨前三种模式,而第四种模式将在服务器渲染与水合指南高级服务器渲染指南中深入介绍。

预取的一个具体用途是避免请求瀑布。有关请求瀑布的深入背景和解释,请参阅性能与请求瀑布指南

prefetchQuery & prefetchInfiniteQuery

在深入了解不同的特定预取模式之前,我们先来看看prefetchQueryprefetchInfiniteQuery函数。首先是几个基本点:

  • 默认情况下,这些函数使用为queryClient配置的默认staleTime来确定缓存中的现有数据是否新鲜或需要重新获取
  • 您也可以像这样传递一个特定的staleTimeprefetchQuery({ 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 进行预取,因为那样直到 Suspense 查询解决后才会开始预取。对于这种情况,您可以使用库中提供的 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])

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

  • 在 Suspense 边界之前使用 usePrefetchQueryusePrefetchInfiniteQuery 钩子进行预取
  • 使用 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 的代码现在包含在父捆绑包中,而不是在 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)

延伸阅读

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

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