框架
版本

高级服务器渲染

欢迎阅读高级服务器渲染指南,您将在此学习有关在流式传输、服务器组件和 Next.js app router 中使用 React Query 的所有知识。

您可能想先阅读 服务器渲染与数据水合指南,因为它讲解了在 SSR 中使用 React Query 的基础知识,以及 性能与请求瀑布预取与路由集成,它们也包含有价值的背景信息。

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

服务器组件 & Next.js app router

我们不会在此深入介绍服务器组件,但简而言之,它们是保证在服务器上运行的组件,包括初始页面视图和页面过渡期间。这类似于 Next.js 的 getServerSideProps/getStaticProps 和 Remix 的 loader 的工作方式,因为它们也始终在服务器上运行,但那些只能返回数据,而服务器组件可以做更多事情。数据部分是 React Query 的核心,所以让我们专注于这一点。

我们如何将服务器渲染指南中关于将框架加载器中预取的数据传递给应用程序的内容应用于服务器组件和 Next.js app router?开始思考这个问题最好的方法是,将服务器组件视为“只是”另一个框架加载器。

关于术语的快速说明

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

一种思考方式是,即使服务器组件也渲染,它们也发生在“加载器阶段”(始终在服务器上发生),而客户端组件则在“应用程序阶段”运行。该应用程序可以在服务器端进行 SSR,也可以在浏览器等地方运行。该应用程序确切的运行位置以及是否在 SSR 期间运行可能会因框架而异。

初始设置

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

tsx
// In Next.js, this file would be called: app/providers.tsx
'use client'

// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export default function Providers({ children }: { children: React.ReactNode }) {
  // NOTE: Avoid useState when initializing the query client if you don't
  //       have a suspense boundary between this and the code that may
  //       suspend because React will throw away the client on the initial
  //       render if it suspends and there is no boundary
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}
// In Next.js, this file would be called: app/providers.tsx
'use client'

// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export default function Providers({ children }: { children: React.ReactNode }) {
  // NOTE: Avoid useState when initializing the query client if you don't
  //       have a suspense boundary between this and the code that may
  //       suspend because React will throw away the client on the initial
  //       render if it suspends and there is no boundary
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}
tsx
// In Next.js, this file would be called: 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>
  )
}
// In Next.js, this file would be called: 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'

// This could also be getServerSideProps
export async function getStaticProps() {
  const queryClient = new QueryClient()

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

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

function Posts() {
  // This useQuery could just as well happen in some deeper child to
  // the <PostsRoute>, data will be available immediately either way
  //
  // Note that we are using useQuery here instead of useSuspenseQuery.
  // Because this data has already been prefetched, there is no need to
  // ever suspend in the component itself. If we forget or remove the
  // prefetch, this will instead fetch the data on the client, while
  // using useSuspenseQuery would have had worse side effects.
  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })

  // This query was not prefetched on the server and will not start
  // fetching until on the client, both patterns are fine to mix
  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'

// This could also be getServerSideProps
export async function getStaticProps() {
  const queryClient = new QueryClient()

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

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

function Posts() {
  // This useQuery could just as well happen in some deeper child to
  // the <PostsRoute>, data will be available immediately either way
  //
  // Note that we are using useQuery here instead of useSuspenseQuery.
  // Because this data has already been prefetched, there is no need to
  // ever suspend in the component itself. If we forget or remove the
  // prefetch, this will instead fetch the data on the client, while
  // using useSuspenseQuery would have had worse side effects.
  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })

  // This query was not prefetched on the server and will not start
  // fetching until on the client, both patterns are fine to mix
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  // ...
}

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

将其转换为 app router 实际上看起来非常相似,我们只需要稍微调整一下。首先,我们将创建一个服务器组件来执行预取部分:

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 (
    // Neat! Serialization is now as easy as passing props.
    // HydrationBoundary is a Client Component, so hydration will happen there.
    <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 (
    // Neat! Serialization is now as easy as passing props.
    // HydrationBoundary is a Client Component, so hydration will happen there.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}

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

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

export default function Posts() {
  // This useQuery could just as well happen in some deeper
  // child to <Posts>, data will be available immediately either way
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts(),
  })

  // This query was not prefetched on the server and will not start
  // fetching until on the client, both patterns are fine to mix.
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

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

export default function Posts() {
  // This useQuery could just as well happen in some deeper
  // child to <Posts>, data will be available immediately either way
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts(),
  })

  // This query was not prefetched on the server and will not start
  // fetching until on the client, both patterns are fine to mix.
  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 错误

注意:如果您遇到 只能将纯对象和少数内置对象传递给服务器操作。类或 null 原型不支持。 错误,请确保您没有将函数引用传递给 queryFn,而是调用该函数,因为 queryFn 参数包含许多属性,并非所有属性都可以序列化。请参阅 仅当 queryFn 不是引用时,服务器操作才能正常工作

嵌套服务器组件

服务器组件的一个优点是它们可以嵌套在 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() is scoped per request, so we don't leak data between requests
const getQueryClient = cache(() => new QueryClient())
export default getQueryClient
// app/getQueryClient.tsx
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'

// cache() is scoped per request, so we don't leak data between requests
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()

  // Note we are now using fetchQuery()
  const posts = await queryClient.fetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {/* This is the new part */}
      <div>Nr of posts: {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()

  // Note we are now using fetchQuery()
  const posts = await queryClient.fetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {/* This is the new part */}
      <div>Nr of posts: {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 app router 会自动尽快将应用程序中已准备好显示的部分流式传输到浏览器,因此已完成的内容可以立即显示,而无需等待仍处于挂起状态的内容。它会沿着 <Suspense> 边界线进行。请注意,如果您创建了一个 loading.tsx 文件,它会在后台自动创建一个 <Suspense> 边界。

通过上面描述的预取模式,React Query 与这种流式传输形式完美兼容。当每个 Suspense 边界的数据解析时,Next.js 可以渲染并流式传输已完成的内容到浏览器。即使您使用上面概述的 useQuery,这也会起作用,因为当您 await 预取时会发生暂停。

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

为了实现这一点,我们必须指示 queryClient脱水 挂起的查询。我们可以全局执行此操作,或将该选项直接传递给 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: {
        // include pending queries in dehydration
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
        shouldRedactErrors: (error) => {
          // We should not catch Next.js server errors
          // as that's how Next.js detects dynamic pages
          // so we cannot redact them.
          // Next.js also automatically redacts errors for us
          // with better digests.
          return false
        },
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

export function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    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: {
        // include pending queries in dehydration
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
        shouldRedactErrors: (error) => {
          // We should not catch Next.js server errors
          // as that's how Next.js detects dynamic pages
          // so we cannot redact them.
          // Next.js also automatically redacts errors for us
          // with better digests.
          return false
        },
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

export function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

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

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

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

// the function doesn't need to be `async` because we don't `await` anything
export default function PostsPage() {
  const queryClient = getQueryClient()

  // look ma, no 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'

// the function doesn't need to be `async` because we don't `await` anything
export default function PostsPage() {
  const queryClient = getQueryClient()

  // look ma, no 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 在这种情况下不会暂停,组件将以挂起状态渲染,这也将退出服务器渲染内容。

如果您使用的是非 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()

  // look ma, no await
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts().then(serialize), // <-- serialize the data on the server
  })

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

  // look ma, no await
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts().then(serialize), // <-- serialize the data on the server
  })

  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 示例

在 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: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export function Providers(props: { children: React.ReactNode }) {
  // NOTE: Avoid useState when initializing the query client if you don't
  //       have a suspense boundary between this and the code that may
  //       suspend because React will throw away the client on the initial
  //       render if it suspends and there is no boundary
  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: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export function Providers(props: { children: React.ReactNode }) {
  // NOTE: Avoid useState when initializing the query client if you don't
  //       have a suspense boundary between this and the code that may
  //       suspend because React will throw away the client on the initial
  //       render if it suspends and there is no boundary
  const queryClient = getQueryClient()

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

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

最大的好处是,您不再需要手动预取查询才能使 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 进行哪些改进。我们欢迎建议、反馈和 bug 报告!

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

延伸阅读

要了解您的应用程序在使用服务器组件时是否可以从 React Query 中受益,请查看社区资源中的 您可能不需要 React Query