框架
版本

高级服务端渲染

欢迎来到高级服务端渲染指南,在这里您将学习关于如何将 React Query 与流式处理、服务端组件和 Next.js app 路由一起使用的所有知识。

您可能需要先阅读 服务端渲染 & Hydration 指南,因为它教授了将 React Query 与 SSR 一起使用的基础知识,并且 性能 & 请求瀑布流 以及 预取 & 路由集成 也包含有价值的背景知识。

在我们开始之前,让我们注意到,虽然 SSR 指南中概述的 initialData 方法也适用于服务端组件,但本指南将重点放在 hydration API 上。

服务端组件 & Next.js app 路由

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

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

关于术语的简要说明

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

一种思考方式是,即使服务端组件也进行渲染,它们也发生在“加载器阶段”(始终发生在服务器上),而客户端组件在“应用程序阶段”运行。该应用程序可以在服务器上进行 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 路由 的样子

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 路由实际上看起来非常相似,我们只需要稍微移动一下位置。首先,我们将创建一个服务端组件来完成预取部分

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> 的样板代码。这对于服务端组件是不可能的。

注意:如果在 TypeScript 版本低于 5.1.3@types/react 版本低于 18.2.8 的情况下使用异步服务端组件时遇到类型错误,建议更新到两者的最新版本。 或者,您可以使用添加 {/* @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。 这是推荐的方法,但如果您愿意,您可以选择创建一个在所有服务端组件之间重用的单个客户端

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,包括已经序列化过且与当前服务端组件无关的 query,这是不必要的开销。

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

作为未来的改进,我们可能会研究创建一个 dehydrateNew() 函数(名称待定),该函数仅脱水自上次调用 dehydrateNew() 以来新的 query。 如果您对此听起来很感兴趣并且想为此提供帮助,请随时联系我们!

数据所有权和重新验证

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

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 query 的数据。 这对于初始页面渲染来说是没问题的,但是当 staleTime 过去后,query 在客户端上因某种原因重新验证时会发生什么?

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

使用上面描述的预取模式,React Query 完全兼容这种形式的流式处理。 随着每个 Suspense 边界的数据解析,Next.js 可以渲染并将完成的内容流式传输到浏览器。 即使您如上所述使用 useQuery,这也有效,因为当您 await 预取时,实际发生的是暂停。

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

为了使其工作,我们必须指示 queryClientdehydrate pending Queries。 我们可以全局执行此操作,也可以将该选项直接传递给 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 和服务端组件中有效,因为当您将 Promise 传递给客户端组件时,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(在服务器上创建)

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 数据类型并在服务器上序列化 query 结果,则可以指定 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 datetime 对象,并且数据将在客户端上进行序列化和反序列化,假设您的转换器可以序列化和反序列化这些数据类型。

有关更多信息,请查看 带有预取的 Next.js App 示例

Next.js 中不进行预取的实验性流式处理

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

此软件包将允许您通过仅在组件中调用 useSuspenseQuery 来在服务器(在客户端组件中)上获取数据。 结果将从服务器流式传输到客户端,因为 SuspenseBoundaries 会解析。 如果您调用 useSuspenseQuery 而不将其包裹在 <Suspense> 边界中,则 HTML 响应将不会开始,直到 fetch 解析为止。 这可能是您想要的情况,但这会损害您的 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 流式处理示例

最大的优点是您不再需要手动预取 query 即可使 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/迭代/交付速度而不是性能,没有深度嵌套的 query,或者通过使用 useSuspenseQueries 等工具进行并行获取来控制请求瀑布流,那么这可能是一个不错的权衡。

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

总结

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

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