框架
版本

高级服务器端渲染

欢迎来到高级服务器端渲染指南,在这里您将学习有关将 React Query 与流式传输、服务器组件和 Next.js 应用路由器结合使用的所有知识。

您可能想在此之前阅读服务器端渲染和水合指南,因为它教授了将 React Query 与 SSR 结合使用的基础知识,以及性能和请求瀑布流以及预取和路由器集成也包含了有价值的背景知识。

在开始之前,请注意,虽然 SSR 指南中概述的 initialData 方法也适用于服务器组件,但本指南将重点介绍水合 API。

服务器组件和 Next.js 应用路由器

我们不会在这里深入介绍服务器组件,但简而言之,它们是保证_仅_在服务器上运行的组件,既适用于初始页面视图,也适用于页面转换。这类似于 Next.js getServerSideProps/getStaticProps 和 Remix loader 的工作方式,因为这些也总是在服务器上运行,但虽然那些只能返回数据,但服务器组件可以做更多的事情。然而,数据部分是 React Query 的核心,所以让我们关注这一点。

我们如何将在服务器端渲染指南中学到的关于将框架加载器中预取的数据传递给应用程序的知识应用于服务器组件和 Next.js 应用路由器?开始思考这个问题的最佳方式是将服务器组件视为“仅仅是”另一个框架加载器。

关于术语的简要说明

到目前为止,在这些指南中,我们一直在讨论_服务器_和_客户端_。需要注意的是,令人困惑的是,这与_服务器组件_和_客户端组件_并非一一对应。服务器组件保证仅在服务器上运行,但客户端组件实际上可以在两个地方运行。原因是它们也可以在初始_服务器端渲染_期间渲染。

一种思考方式是,即使服务器组件也_渲染_,它们发生在“加载器阶段”(始终在服务器上发生),而客户端组件在“应用程序阶段”运行。该应用程序可以在 SSR 期间在服务器上运行,也可以在例如浏览器中运行。该应用程序究竟在哪里运行以及是否在 SSR 期间运行可能因框架而异。

初始设置

任何 React Query 设置的第一步始终是创建一个 queryClient 并用 QueryClientProvider 包装您的应用程序。对于服务器组件,这在不同框架中看起来基本相同,一个区别是文件名约定:

tsx
// 在 Next.js 中,此文件将被称为:app/providers.tsx
'use client'

// 由于 QueryClientProvider 在底层依赖于 useContext,我们必须在顶部放置 'use client'
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // 对于 SSR,我们通常希望设置一些默认的 staleTime
        // 大于 0 以避免在客户端立即重新获取
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // 服务器:始终创建一个新的查询客户端
    return makeQueryClient()
  } else {
    // 浏览器:如果我们还没有查询客户端,则创建一个新的
    // 这非常重要,因此如果 React 在初始渲染期间暂停,
    // 我们不会重新创建一个新的客户端。如果我们有一个 suspense 边界
    // 低于查询客户端的创建,则可能不需要这样做
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export default function Providers({ children }: { children: React.ReactNode }) {
  // 注意:如果在初始化查询客户端时没有 suspense 边界,
  // 并且代码可能会暂停,请避免使用 useState,
  // 因为如果 React 在初始渲染时暂停并且没有边界,它将丢弃客户端
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}
// 在 Next.js 中,此文件将被称为:app/providers.tsx
'use client'

// 由于 QueryClientProvider 在底层依赖于 useContext,我们必须在顶部放置 'use client'
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // 对于 SSR,我们通常希望设置一些默认的 staleTime
        // 大于 0 以避免在客户端立即重新获取
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // 服务器:始终创建一个新的查询客户端
    return makeQueryClient()
  } else {
    // 浏览器:如果我们还没有查询客户端,则创建一个新的
    // 这非常重要,因此如果 React 在初始渲染期间暂停,
    // 我们不会重新创建一个新的客户端。如果我们有一个 suspense 边界
    // 低于查询客户端的创建,则可能不需要这样做
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export default function Providers({ children }: { children: React.ReactNode }) {
  // 注意:如果在初始化查询客户端时没有 suspense 边界,
  // 并且代码可能会暂停,请避免使用 useState,
  // 因为如果 React 在初始渲染时暂停并且没有边界,它将丢弃客户端
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}
tsx
// 在 Next.js 中,此文件将被称为:app/layout.tsx
import Providers from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head />
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}
// 在 Next.js 中,此文件将被称为:app/layout.tsx
import Providers from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head />
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

这部分与我们在 SSR 指南中所做的非常相似,我们只需要将内容分成两个不同的文件。

预取和脱水/补水数据

接下来,让我们看看如何实际预取数据,然后对其进行脱水和补水。这是使用 Next.js Pages Router 时的样子:

tsx
// pages/posts.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
  useQuery,
} from '@tanstack/react-query'

// 这也可以是 getServerSideProps
export async function getStaticProps() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  }
}

function Posts() {
  // 此 useQuery 也可能发生在 <PostsRoute> 的某个更深层子组件中,
  // 无论哪种方式,数据都将立即可用
  //
  // 请注意,我们在这里使用的是 useQuery 而不是 useSuspenseQuery。
  // 由于此数据已被预取,因此无需在组件本身中暂停。
  // 如果我们忘记或删除了预取,这将改为在客户端获取数据,
  // 而使用 useSuspenseQuery 会产生更糟糕的副作用。
  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })

  // 此查询未在服务器上预取,并且在客户端之前不会开始获取,
  // 两种模式都可以混合使用
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  // ...
}

export default function PostsRoute({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <Posts />
    </HydrationBoundary>
  )
}
// pages/posts.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
  useQuery,
} from '@tanstack/react-query'

// 这也可以是 getServerSideProps
export async function getStaticProps() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  }
}

function Posts() {
  // 此 useQuery 也可能发生在 <PostsRoute> 的某个更深层子组件中,
  // 无论哪种方式,数据都将立即可用
  //
  // 请注意,我们在这里使用的是 useQuery 而不是 useSuspenseQuery。
  // 由于此数据已被预取,因此无需在组件本身中暂停。
  // 如果我们忘记或删除了预取,这将改为在客户端获取数据,
  // 而使用 useSuspenseQuery 会产生更糟糕的副作用。
  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })

  // 此查询未在服务器上预取,并且在客户端之前不会开始获取,
  // 两种模式都可以混合使用
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  // ...
}

export default function PostsRoute({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <Posts />
    </HydrationBoundary>
  )
}

将其转换为应用路由器实际上看起来非常相似,我们只需要稍微移动一下内容。首先,我们将创建一个服务器组件来执行预取部分:

tsx
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    // 太棒了!序列化现在就像传递 props 一样简单。
    // HydrationBoundary 是一个客户端组件,因此水合将在此处发生。
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    // 太棒了!序列化现在就像传递 props 一样简单。
    // HydrationBoundary 是一个客户端组件,因此水合将在此处发生。
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}

接下来,我们来看看客户端组件部分的样子:

tsx
// app/posts/posts.tsx
'use client'

export default function Posts() {
  // 此 useQuery 也可能发生在 <Posts> 的某个更深层子组件中,
  // 无论哪种方式,数据都将立即可用
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts(),
  })

  // 此查询未在服务器上预取,并且在客户端之前不会开始获取,
  // 两种模式都可以混合使用。
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  // ...
}
// app/posts/posts.tsx
'use client'

export default function Posts() {
  // 此 useQuery 也可能发生在 <Posts> 的某个更深层子组件中,
  // 无论哪种方式,数据都将立即可用
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts(),
  })

  // 此查询未在服务器上预取,并且在客户端之前不会开始获取,
  // 两种模式都可以混合使用。
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  // ...
}

关于上述示例的一个巧妙之处在于,这里唯一特定于 Next.js 的是文件名,其他所有内容在任何其他支持服务器组件的框架中看起来都一样。

在 SSR 指南中,我们注意到您可以摆脱在每个路由中都包含 <HydrationBoundary> 的样板代码。这对于服务器组件是不可能的。

注意:如果您在使用低于 5.1.3 版本的 TypeScript 和低于 18.2.8 版本的 @types/react 时遇到类型错误,建议更新到这两个库的最新版本。或者,您可以使用临时解决方法,在另一个组件内部调用此组件时添加 {/* @ts-expect-error Server Component */}。有关更多信息,请参阅 Next.js 13 文档中的异步服务器组件 TypeScript 错误

注意:如果您遇到错误 Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported.,请确保您没有将函数引用传递给 queryFn,而是调用该函数,因为 queryFn 参数包含许多属性,并非所有属性都是可序列化的。请参阅 Server Action only works when queryFn isn't a reference

嵌套服务器组件

服务器组件的一个优点是它们可以嵌套并存在于 React 树的多个级别,从而可以在更接近实际使用数据的位置预取数据,而不仅仅是在应用程序的顶层(就像 Remix 加载器一样)。这可以像服务器组件渲染另一个服务器组件一样简单(为简洁起见,我们��在此示例中省略客户端组件):

tsx
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'
import CommentsServerComponent from './comments-server'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
      <CommentsServerComponent />
    </HydrationBoundary>
  )
}

// app/posts/comments-server.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Comments from './comments'

export default async function CommentsServerComponent() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Comments />
    </HydrationBoundary>
  )
}
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'
import CommentsServerComponent from './comments-server'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
      <CommentsServerComponent />
    </HydrationBoundary>
  )
}

// app/posts/comments-server.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Comments from './comments'

export default async function CommentsServerComponent() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Comments />
    </HydrationBoundary>
  )
}

如您所见,在多个位置使用 <HydrationBoundary> 并为预取创建和脱水多个 queryClient 是完全可以的。

请注意,因为我们在渲染 CommentsServerComponent 之前等待 getPosts,这将导致服务器端瀑布流:

1. |> getPosts()
2.   |> getComments()
1. |> getPosts()
2.   |> getComments()

如果到数据的服务器延迟较低,这可能不是一个大问题,但仍然值得指出。

在 Next.js 中,除了在 page.tsx 中预取数据外,您还可以在 layout.tsx并行路由中执行此操作。因为这些都是路由的一部分,所以 Next.js 知道如何并行获取它们。因此,如果上面的 CommentsServerComponent 改为表示为并行路由,则瀑布流将自动展平。

随着更多框架开始支持服务器组件,它们可能具有其他路由约定。有关详细信息,请阅读您的框架文档。

替代方案:使用单个 queryClient 进行预取

在上面的示例中,我们为每个获取数据的服务器组件创建一个新的 queryClient。这是推荐的方法,但如果您愿意,也可以创建一个在所有服务器组件之间重用的单个 queryClient

tsx
// app/getQueryClient.tsx
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'

// cache() 的作用域是每个请求,因此我们不会在请求之间泄漏数据
const getQueryClient = cache(() => new QueryClient())
export default getQueryClient
// app/getQueryClient.tsx
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'

// cache() 的作用域是每个请求,因此我们不会在请求之间泄漏数据
const getQueryClient = cache(() => new QueryClient())
export default getQueryClient

这样做的好处是,您可以在从服务器组件调用的任何位置(包括实用程序函数)调用 getQueryClient() 来获取此客户端。缺点是,每次调用 dehydrate(getQueryClient()) 时,都会序列化_整个_ queryClient,包括之前已经序列化过且与当前服务器组件无关的查询,这是不必要的开销。

Next.js 已经对使用 fetch() 的请求进行了重复数据删除,但如果您在 queryFn 中使用其他内容,或者如果您使用的框架_不会_自动对这些请求进行重复数据删除,那么使用如上所述的单个 queryClient 可能是有意义的,尽管存在重复序列化的问题。

作为未来的改进,我们可能会考虑创建一个 dehydrateNew() 函数(名称待定),该函数仅脱水自上次调用 dehydrateNew() 以来_新增_的查询。如果您对此感兴趣并希望提供帮助,请随时与我们联系!

数据所有权和重新验证

对于服务器组件,考虑数据所有权和重新验证非常重要。为了解释原因,让我们看一个上面修改过的示例:

tsx
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  // 注意我们现在使用的是 fetchQuery()
  const posts = await queryClient.fetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {/* 这是新的部分 */}
      <div>帖子数量:{posts.length}</div>
      <Posts />
    </HydrationBoundary>
  )
}
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  // 注意我们现在使用的是 fetchQuery()
  const posts = await queryClient.fetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {/* 这是新的部分 */}
      <div>帖子数量:{posts.length}</div>
      <Posts />
    </HydrationBoundary>
  )
}

我们现在正在服务器组件和客户端组件中渲染来自 getPosts 查询的数据。这对于初始页面渲染来说没有问题,但是当 staleTime 过后,查询由于某种原因在客户端重新验证时会发生什么?

React Query 不知道如何_重新验证服务器组件_,因此如果它在客户端重新获取数据,导致 React 重新渲染帖子列表,则 帖子数量:{posts.length} 将最终不同步。

如果您设置 staleTime: Infinity,以便 React Query 永不重新验证,则这没有问题,但如果您首先使用 React Query,这可能不是您想要的。

在以下情况下,将 React Query 与服务器组件结合使用最有意义:

  • 您有一个使用 React Query 的应用程序,并且希望在不重写所有数据获取的情况下迁移到服务器组件
  • 您想要一种熟悉的编程范例,但仍希望在最有意义的地方加入服务器组件的优点
  • 您有一些 React Query 涵盖但您选择的框架未涵盖的用例

很难就何时将 React Query 与服务器组件结合使用以及何时不这样做给出一般性建议。**如果您刚开始使用新的服务器组件应用程序,我们建议您从框架提供的任何数据获取工具开始,并避免引入 React Query,除非您确实需要它。**这可能永远不会发生,这没关系,请为工作选择合适的工具!

如果您确实使用它,一个好的经验法则是避免使用 queryClient.fetchQuery,除非您需要捕获错误。如果您确实使用它,请不要在服务器上渲染其结果,也不要将结果传递给另一个组件,即使是客户端组件。

从 React Query 的角度来看,将服务器组件视为预取数据的地���,仅此而已。

当然,让服务器组件拥有一些数据,让客户端组件拥有其他数据是可以的,只要确保这两个现实不会不同步即可。

使用服务器组件进行流式传输

Next.js 应用路由器会自动将准备好显示的应用程序的任何部分尽快流式传输到浏览器,因此可以立即显示已完成的内容,而无需等待仍在等待的内容。它沿着 <Suspense> 边界线执行此操作。请注意,如果创建文件 loading.tsx,这会在后台自动创建一个 <Suspense> 边界。

通过上述预取模式,React Query 与这种流式传输形式完美兼容。当每个 Suspense 边界的数据解析完成时,Next.js 可以渲染并将完成的内容流式传输到浏览器。即使您如上所述使用 useQuery,这也能正常工作,因为暂停实际上发生在您 await 预取时。

从 React Query v5.40.0 开始,您不必 await 所有预取才能使其正常工作,因为 pending 查询也可以脱水并发送到客户端。这使您可以尽早启动预取,而不会让它们阻塞整个 Suspense 边界,并在查询完成时将_数据_流式传输到客户端。例如,如果您想预取一些仅在某些用户交互后才可见的内容,或者如果您想 await 并渲染无限查询的第一页,但开始预取第 2 页而不阻塞渲染,则这可能很有用。

为此,我们必须指示 queryClientdehydrate 挂起的查询。我们可以全局执行此操作,也可以通过将该选项直接传递给 dehydrate 来执行此操作。

我们还需要将 getQueryClient() 函数移出 app/providers.tsx 文件,因为我们希望在服务器组件和客户端提供程序中使用它。

tsx
// app/get-query-client.ts
import {
  isServer,
  QueryClient,
  defaultShouldDehydrateQuery,
} from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
      },
      dehydrate: {
        // 在脱水中包含挂起的查询
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
        shouldRedactErrors: (error) => {
          // 我们不应该捕获 Next.js 服务器错误
          // 因为这是 Next.js 检测动态页面的方式
          // 所以我们不能编辑它们。
          // Next.js 也会自动为我们编辑错误
          // 并提供更好的摘要。
          return false
        },
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

export function getQueryClient() {
  if (isServer) {
    // 服务器:始终创建一个新的查询客户端
    return makeQueryClient()
  } else {
    // 浏览器:如果我们还没有查询客户端,则创建一个新的
    // 这非常重要,因此如果 React 在初始渲染期间暂停,
    // 我们不会重新创建一个新的客户端。如果我们有一个 suspense 边界
    // 低于查询客户端的创建,则可能不需要这样做
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}
// app/get-query-client.ts
import {
  isServer,
  QueryClient,
  defaultShouldDehydrateQuery,
} from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
      },
      dehydrate: {
        // 在脱水中包含挂起的查询
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
        shouldRedactErrors: (error) => {
          // 我们不应该捕获 Next.js 服务器错误
          // 因为这是 Next.js 检测动态页面的方式
          // 所以我们不能编辑它们。
          // Next.js 也会自动为我们编辑错误
          // 并提供更好的摘要。
          return false
        },
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

export function getQueryClient() {
  if (isServer) {
    // 服务器:始终创建一个新的查询客户端
    return makeQueryClient()
  } else {
    // 浏览器:如果我们还没有查询客户端,则创建一个新的
    // 这非常重要,因此如果 React 在初始渲染期间暂停,
    // 我们不会重新创建一个新的客户端。如果我们有一个 suspense 边界
    // 低于查询客户端的创建,则可能不需要这样做
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

注意:这在 NextJs 和服务器组件中有效,因为当您将 Promise 传递给客户端组件时,React 可以通过网络序列化它们。

然后,我们只需要提供一个 HydrationBoundary,但我们不再需要 await 预取了:

tsx
// app/posts/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import Posts from './posts'

// 该函数不需要是 `async`,因为我们不 `await` 任何内容
export default function PostsPage() {
  const queryClient = getQueryClient()

  // 看,没有 await
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}
// app/posts/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import Posts from './posts'

// 该函数不需要是 `async`,因为我们不 `await` 任何内容
export default function PostsPage() {
  const queryClient = getQueryClient()

  // 看,没有 await
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}

在客户端,Promise 将为我们放入 QueryCache 中。这意味着我们现在可以在 Posts 组件内部调用 useSuspenseQuery 来“使用”该 Promise(该 Promise 是在服务器上创建的):

tsx
// app/posts/posts.tsx
'use client'

export default function Posts() {
  const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })

  // ...
}
// app/posts/posts.tsx
'use client'

export default function Posts() {
  const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })

  // ...
}

请注意,您也可以使用 useQuery 代替 useSuspenseQuery,Promise 仍然会被正确获取。但是,在这种情况下,NextJs 不会暂停,组件将以 pending 状态渲染,这也意味着服务器不会渲染内容。

如果您使用非 JSON 数据类型并在服务器上序列化查询结果,则可以指定 dehydrate.serializeDatahydrate.deserializeData 选项,以便在边界的每一侧序列化和反序列化数据,以确保服务器和客户端缓存中的数据格式相同:

tsx
// app/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
import { deserialize, serialize } from './transformer'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      // ...
      hydrate: {
        deserializeData: deserialize,
      },
      dehydrate: {
        serializeData: serialize,
      },
    },
  })
}

// ...
// app/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
import { deserialize, serialize } from './transformer'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      // ...
      hydrate: {
        deserializeData: deserialize,
      },
      dehydrate: {
        serializeData: serialize,
      },
    },
  })
}

// ...
tsx
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import { serialize } from './transformer'
import Posts from './posts'

export default function PostsPage() {
  const queryClient = getQueryClient()

  // 看,没有 await
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts().then(serialize), // <-- 在服务器上序列化数据
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import { serialize } from './transformer'
import Posts from './posts'

export default function PostsPage() {
  const queryClient = getQueryClient()

  // 看,没有 await
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts().then(serialize), // <-- 在服务器上序列化数据
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}
tsx
// app/posts/posts.tsx
'use client'

export default function Posts() {
  const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })

  // ...
}
// app/posts/posts.tsx
'use client'

export default function Posts() {
  const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })

  // ...
}

现在,您的 getPosts 函数可以返回例如 Temporal 日期时间对象,并且数据将在客户端��列化和反序列化,前提是您的转换器可以序列化和反序列化这些数据类型。

有关更多信息,请查看 Next.js App with Prefetching Example

Next.js 中的实验性流式传输(无需预取)

虽然我们推荐上面详细介绍的预取解决方案,因为它可以在初始页面加载任何后续页面导航中展平请求瀑布流,但有一种实验性的方法可以完全跳过预取,并且仍然可以使流式 SSR 正常工作:@tanstack/react-query-next-experimental

此包将允许您通过在组件中调用 useSuspenseQuery 来在服务器上(在客户端组件中)获取数据。然后,当 SuspenseBoundaries 解析时,结果将从服务器流式传输到客户端。如果您在没有将其包装在 <Suspense> 边界中的情况下调用 useSuspenseQuery,则 HTML 响应将在获取解析之前不会开始。根据情况,这可能是您想要的,但请记住,这会损害您的 TTFB。

为此,请将您的应用程序包装在 ReactQueryStreamedHydration 组件中:

tsx
// app/providers.tsx
'use client'

import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
import * as React from 'react'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // 对于 SSR,我们通常希望设置一些默认的 staleTime
        // 大于 0 以避免在客户端立即重新获取
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // 服务器:始终创建一个新的查询客户端
    return makeQueryClient()
  } else {
    // 浏览器:如果我们还没有查询客户端,则创建一个新的
    // 这非常重要,因此如果 React 在初始渲染期间暂停,
    // 我们不���重新创建一个新的客户端。如果我们有一个 suspense 边界
    // 低于查询客户端的创建,则可能不需要这样做
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export function Providers(props: { children: React.ReactNode }) {
  // 注意:如果在初始化查询客户端时没有 suspense 边界,
  // 并且代码可能会暂停,请避免使用 useState,
  // 因为如果 React 在初始渲染时暂停并且没有边界,它将丢弃客户端
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>
        {props.children}
      </ReactQueryStreamedHydration>
    </QueryClientProvider>
  )
}
// app/providers.tsx
'use client'

import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
import * as React from 'react'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // 对于 SSR,我们通常希望设置一些默认的 staleTime
        // 大于 0 以避免在客户端立即重新获取
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // 服务器:始终创建一个新的查询客户端
    return makeQueryClient()
  } else {
    // 浏览器:如果我们还没有查询客户端,则创建一个新的
    // 这非常重要,因此如果 React 在初始渲染期间暂停,
    // 我们不会重新创建一个新的客户端。如果我们有一个 suspense 边界
    // 低于查询客户端的创建,则可能不需要这样做
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export function Providers(props: { children: React.ReactNode }) {
  // 注意:如果在初始化查询客户端时没有 suspense 边界,
  // 并且代码可能会暂停,请避免使用 useState,
  // 因为如果 React 在初始渲染时暂停并且没有边界,它将丢弃客户端
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>
        {props.children}
      </ReactQueryStreamedHydration>
    </QueryClientProvider>
  )
}

有关更多信息,请查看 NextJs Suspense Streaming Example

最大的好处是,您不再需要手动预取查询才能使 SSR 正常工作,它甚至仍然可以流式传输结果!这为您提供了卓越的 DX 和更低的代码复杂性。

如果我们回顾一下性能和请求瀑布流指南中的复杂请求瀑布流示例,则最容易解释缺点。具有预取的服务器组件有效地消除了初始页面加载任何后续导航的请求瀑布流。然而,这种无预取的方法只会在初始页面加载时展平瀑布流,但在页面导航时最终会与原始示例一样出现深度瀑布流:

1. |> JS for <Feed>
2.   |> getFeed()
3.     |> JS for <GraphFeedItem>
4.       |> getGraphDataById()
1. |> JS for <Feed>
2.   |> getFeed()
3.     |> JS for <GraphFeedItem>
4.       |> getGraphDataById()

这甚至比 getServerSideProps/getStaticProps 更糟糕,因为对于那些,我们至少可以并行化数据和代码获取。

如果您重视 DX/迭代/交付速度,代码复杂度低,而不是性能,没有深度嵌套的查询,或者您正在使用诸如 useSuspenseQueries 之类的工具通过并行获取来处理请求瀑布流,那么这可能是一个不错的权衡。

也许可以将这两种方法结合起来,但即使我们还没有尝试过。如果您尝试这样做,请报告您的发现,或者甚至用一些技巧更新这些文档!

最后的话

服务器组件和流式传输仍然是相当新的概念,我们仍在研究 React Query 如何适应以及我们可以对 API 进行哪些改进。我们欢迎建议、反馈和错误报告!

同样,不可能在第一次尝试时就在一个指南中教授这个新范例的所有复杂性。如果您在此处缺少某些信息或对如何改进此内容有建议,也请与我们联系,或者更好的是,单击下面的“在 GitHub 上编辑”按钮并帮助我们。