框架
版本

服务器端渲染和水合

在本指南中,您将学习如何将 React Query 与服务器端渲染结合使用。

有关一些背景知识,请参阅预取和路由器集成指南。在此之前,您可能还想查看性能和请求瀑布流指南

有关高级服务器端渲染模式(例如流式传输、服务器组件和新的 Next.js 应用路由器),请参阅高级服务器端渲染指南

如果您只想看一些代码,可以跳到下面的完整的 Next.js 页面路由器示例完整的 Remix 示例

服务器端渲染和 React Query

那么什么是服务器端渲染呢?本指南的其余部分将假设您熟悉这个概念,但让我们花一些时间来看看它与 React Query 的关系。服务器端渲染是指在服务器上生成初始 HTML,以便用户在页面加载后立即可以看到一些内容。这可以在请求页面时按需发生 (SSR)。它也可以提前发生,要么是因为先前的请求被缓存了,要么是在构建时 (SSG)。

如果您阅读过请求瀑布流指南,您可能还记得这个:

1. |-> 标记(无内容)
2.   |-> JS
3.     |-> 查询
1. |-> 标记(无内容)
2.   |-> JS
3.     |-> 查询

对于客户端渲染的应用程序,这些是您在用户屏幕上显示任何内容之前至少需要进行的 3 次服务器往返。看待服务器端渲染的一种方式是它将上述内容转换为:

1. |-> 标记(包含内容和初始数据)
2.   |-> JS
1. |-> 标记(包含内容和初始数据)
2.   |-> JS

一旦 1. 完成,用户就可以看到内容,当 2. 完成时,页面就具有交互性并且可以点击。因为标记还包含我们需要的初始数据,所以步骤 3. 完全不需要在客户端运行,至少在您出于某种原因想要重新验证数据之前是这样。

这完全是从客户端的角度来看的。在服务器上,我们需要在生成/渲染标记之前预取该数据,我们需要将该数据脱水为可序列化的格式,以便我们可以将其嵌入到标记中,并且在客户端,我们需要将该数据水合到 React Query 缓存中,这样我们就可以避免在客户端进行新的获取。

继续阅读以了解如何使用 React Query 实现这三个步骤。

关于 Suspense 的简要说明

本指南使用常规的 useQuery API。虽然我们不一定推荐这样做,但可以用 useSuspenseQuery 代替它,只要您始终预取所有查询。好处是您可以在客户端使用 <Suspense> 来处理加载状态。

如果您在使用 useSuspenseQuery 时忘记预取查询,后果将取决于您使用的框架。在某些情况下,数据将暂停并在服务器上获取,但永远不会水合到客户端,然后在客户端再次获取。在这些情况下,您会遇到标记水合不匹配的问题,因为服务器和客户端尝试渲染不同的内容。

初始设置

使用 React Query 的第一步始终是创建一个 queryClient 并用 <QueryClientProvider> 包装应用程序。进行服务器端渲染时,重要的是在 React 状态(实例引用也可以)中在您的应用程序内部创建 queryClient 实例。这可以确保数据不会在不同的用户和请求之间共享,同时仍然只在每个组件生命周期中创建一次 queryClient

Next.js 页面路由器:

tsx
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

// 永远不要这样做:
// const queryClient = new QueryClient()
//
// 在文件根级别创建 queryClient 会使缓存在所有请求之间共享
// 这意味着_所有_数据都会传递给_所有_用户。
// 除了对性能不利之外,这还会泄漏任何敏感数据。

export default function MyApp({ Component, pageProps }) {
  // 相反,这样做可以确保每个请求都有自己的缓存:
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // 对于 SSR,我们通常希望设置一些默认的 staleTime
            // 大于 0 以避免在客户端立即重新获取
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  )
}
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

// 永远不要这样做:
// const queryClient = new QueryClient()
//
// 在文件根级别创建 queryClient 会使缓存在所有请求之间共享
// 这意味着_所有_数据都会传递给_所有_用户。
// 除了对性能不利之外,这还会泄漏任何敏感数据。

export default function MyApp({ Component, pageProps }) {
  // 相反,这样做可以确保每个请求都有自己的缓存:
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // 对于 SSR,我们通常希望设置一些默认的 staleTime
            // 大于 0 以避免在客户端立即重新获取
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  )
}

Remix:

tsx
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export default function MyApp() {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // 对于 SSR,我们通常希望设置一些默认的 staleTime
            // 大于 0 以避免在客户端立即重新获取
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  )
}
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export default function MyApp() {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // 对于 SSR,我们通常希望设置一些默认的 staleTime
            // 大于 0 以避免在客户端立即重新获取
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  )
}

使用 initialData 快速入门

最快的入门方法是在预取方面完全不涉及 React Query,也不使用 dehydrate/hydrate API。相反,您将原始数据作为 initialData 选项传递给 useQuery。让我们看一个使用 Next.js 页面路由器和 getServerSideProps 的示例。

tsx
export async function getServerSideProps() {
  const posts = await getPosts()
  return { props: { posts } }
}

function Posts(props) {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: props.posts,
  })

  // ...
}
export async function getServerSideProps() {
  const posts = await getPosts()
  return { props: { posts } }
}

function Posts(props) {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: props.posts,
  })

  // ...
}

这也适用于 getStaticProps 甚至更早的 getInitialProps,并且相同的模式可以应用于任何其他具有等效函数的框架。以下是使用 Remix 的相同示例:

tsx
export async function loader() {
  const posts = await getPosts()
  return json({ posts })
}

function Posts() {
  const { posts } = useLoaderData<typeof loader>()

  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: posts,
  })

  // ...
}
export async function loader() {
  const posts = await getPosts()
  return json({ posts })
}

function Posts() {
  const { posts } = useLoaderData<typeof loader>()

  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: posts,
  })

  // ...
}

设置很简单,对于某些情况来说这可能是一个快速的解决方案,但是与完整方法相比,需要考虑一些权衡

  • 如果您在树中更深层的组件中调用 useQuery,则需要将 initialData 向下传递到该点
  • 如果您在多个位置使用相同的查询调用 useQuery,则仅向其中一个传递 initialData 可能会很脆弱,并且在您的应用程序更改时会中断。如果您删除或移动具有 initialDatauseQuery 的组件,则更深层嵌套的 useQuery 可能不再有任何数据。向所有需要它的查询传递 initialData 也可能很麻烦。
  • 无法知道查询在服务器上获取的时间,因此 dataUpdatedAt 和确定查询是否需要重新获取是基于页面加载时间而不是
  • 如果查询的缓存中已经存在数据,initialData 将永远不会覆盖此数据,即使新数据比旧数据更新鲜
    • 要理解为什么这尤其糟糕,请考虑上面的 getServerSideProps 示例。如果您多次来回导航到一个页面,getServerSideProps 每次都会被调用并获取新数据,但是因为我们使用的是 initialData 选项,所以客户端缓存和数据永远不会更新。

设置完整的水合解决方案很简单,并且没有这些缺点,这将是本文档其余部分的重点。

使用水合 API

只需进行少量额外设置,您就可以使用 queryClient 在预加载阶段预取查询,将该 queryClient 的序列化版本传递给应用程序的渲染部分,并在那里重用它。这样可以避免上述缺点。您可以随意跳到完整的 Next.js 页面路由器和 Remix 示例,但总的来说,这些是额外的步骤:

  • 在框架加载器函数中,创建一个 const queryClient = new QueryClient(options)
  • 在加载器函数中,为您要预取的每个查询执行 await queryClient.prefetchQuery(...)
    • 如果可能,您希望使用 await Promise.all(...) 来并行获取查询
    • 拥有未预取的查询是可以的。这些查询不会进行服务器端渲染,而是在应用程序具有交互性后在客户端获取。这对于仅在用户交互后显示的内容或页面底部的内容非常有用,以避免阻塞更关键的内容。
  • 从加载器返回 dehydrate(queryClient),请注意,返回此内容的具体语法因框架而异
  • <HydrationBoundary state={dehydratedState}> 包装您的树,其中 dehydratedState 来自框架加载器。如何获取 dehydratedState 也因框架而异。
    • 这可以针对每个路由执行,也可以在应用程序的顶层执行以避免样板代码,请参阅示例

一个有趣的细节是,实际上涉及_三个_ queryClient。框架加载器是一种在渲染之前发生的“预加载”阶段,此阶段有其自己的 queryClient 来执行预取。此阶段的脱水结果将传递给服务器端渲染过程客户端渲染过程,每个过程都有其自己的 queryClient。这可以确保它们都以相同的数据开始,以便它们可以返回相同的标记。

服务器组件是另一种形式的“预加载”阶段,它也可以“预加载”(预渲染)React 组件树的某些部分。有关更多信息,请阅读高级服务器端渲染指南

完整的 Next.js 页面路由器示例

有关应用路由器文档,请参阅高级服务器端渲染指南

初始设置:

tsx
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // 对于 SSR,我们通常希望设置一些默认的 staleTime
            // 大于 0 以避免在客户端立即重新获取
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  )
}
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // 对于 SSR,我们通常希望设置一些默认的 staleTime
            // 大于 0 以避免在客户端立即重新获取
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  )
}

在每个路由中:

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> 的某个更深层子组件中,
  // 无论哪种方式,数据都将立即可用
  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> 的某个更深层子组件中,
  // 无论哪种方式,数据都将立即可用
  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>
  )
}

完整的 Remix 示例

初始设置:

tsx
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export default function MyApp() {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // 对于 SSR,我们通常希望设置一些默认的 staleTime
            // 大于 0 以避免在客户端立即重新获取
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  )
}
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export default function MyApp() {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // 对于 SSR,我们通常希望设置一些默认的 staleTime
            // 大于 0 以避免在客户端立即重新获取
            staleTime: 60 * 1000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  )
}

在每个路由中,请注意,在嵌套路由中执行此操作也是可以的:

tsx
// app/routes/posts.tsx
import { json } from '@remix-run/node'
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
  useQuery,
} from '@tanstack/react-query'

export async function loader() {
  const queryClient = new QueryClient()

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

  return json({ dehydratedState: dehydrate(queryClient) })
}

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

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

  // ...
}

export default function PostsRoute() {
  const { dehydratedState } = useLoaderData<typeof loader>()
  return (
    <HydrationBoundary state={dehydratedState}>
      <Posts />
    </HydrationBoundary>
  )
}
// app/routes/posts.tsx
import { json } from '@remix-run/node'
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
  useQuery,
} from '@tanstack/react-query'

export async function loader() {
  const queryClient = new QueryClient()

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

  return json({ dehydratedState: dehydrate(queryClient) })
}

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

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

  // ...
}

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

可选 - 删除样板代码

在每个路由中都包含这部分代码可能看起来像很多样板代码:

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

虽然这种方法没有任何问题,但如果您想摆脱这种样板代码,可以按如下方式修改 Next.js 中的设置:

tsx
// _app.tsx
import {
  HydrationBoundary,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(() => new QueryClient())

  return (
    <QueryClientProvider client={queryClient}>
      <HydrationBoundary state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </HydrationBoundary>
    </QueryClientProvider>
  )
}

// pages/posts.tsx
// 删除带有 HydrationBoundary 的 PostsRoute,而是直接导出 Posts:
export default function Posts() { ... }
// _app.tsx
import {
  HydrationBoundary,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(() => new QueryClient())

  return (
    <QueryClientProvider client={queryClient}>
      <HydrationBoundary state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </HydrationBoundary>
    </QueryClientProvider>
  )
}

// pages/posts.tsx
// 删除带有 HydrationBoundary 的 PostsRoute,而是直接导出 Posts:
export default function Posts() { ... }

对于 Remix,这稍微复杂一些,我们建议查看 use-dehydrated-state 包。

预取依赖查询

在预取指南中,我们学习了如何预取依赖查询,但是如何在框架加载器中执行此操作呢?考虑以下代码,取自依赖查询指南

tsx
// 获取用户
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// 然后获取用户的项目
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // 直到 userId 存在,查询才会执行
  enabled: !!userId,
})
// 获取用户
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// 然后获取用户的项目
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // 直到 userId 存在,查询才会执行
  enabled: !!userId,
})

我们如何预取它以便进行服务器端渲染?这是一个示例:

tsx
// 对于 Remix,请改名为 loader
export async function getServerSideProps() {
  const queryClient = new QueryClient()

  const user = await queryClient.fetchQuery({
    queryKey: ['user', email],
    queryFn: getUserByEmail,
  })

  if (user?.userId) {
    await queryClient.prefetchQuery({
      queryKey: ['projects', userId],
      queryFn: getProjectsByUser,
    })
  }

  // 对于 Remix:
  // return json({ dehydratedState: dehydrate(queryClient) })
  return { props: { dehydratedState: dehydrate(queryClient) } }
}
// 对于 Remix,请改名为 loader
export async function getServerSideProps() {
  const queryClient = new QueryClient()

  const user = await queryClient.fetchQuery({
    queryKey: ['user', email],
    queryFn: getUserByEmail,
  })

  if (user?.userId) {
    await queryClient.prefetchQuery({
      queryKey: ['projects', userId],
      queryFn: getProjectsByUser,
    })
  }

  // 对于 Remix:
  // return json({ dehydratedState: dehydrate(queryClient) })
  return { props: { dehydratedState: dehydrate(queryClient) } }
}

当然,这可能会变得更加复杂,但是由于这些加载器函数只是 JavaScript,因此您可以使用该语言的全部功能来构建您的逻辑。确保预取所有要进行服务器端渲染的查询。

错误处理

React Query 默认采用优雅降级策略。这意味着:

  • queryClient.prefetchQuery(...) 从不抛出错误
  • dehydrate(...) 仅包含成功的查询,不包含失败的查询

这将导致任何失败的查询在客户端重试,并且服务器端渲染的输出将包含加载状态而不是完整内容。

虽然这是一个很好的默认设置,但有时这并不是您想要的。当关键内容丢失时,您可能希望根据情况使用 404 或 500 状态代码进行响应。对于这些情况,请改用 queryClient.fetchQuery(...),它在失败时会抛出错误,让您以适当的方式处理事情。

tsx
let result

try {
  result = await queryClient.fetchQuery(...)
} catch (error) {
  // 处理错误,请参阅您的框架文档
}

// 您可能还想在此处检查并处理任何无效的 `result`
let result

try {
  result = await queryClient.fetchQuery(...)
} catch (error) {
  // 处理错误,请参阅您的框架文档
}

// 您可能还想在此处检查并处理任何无效的 `result`

如果出于某种原因,您希望在脱���状态中包含失败的查询以避免重试,则可以使用 shouldDehydrateQuery 选项来覆盖默认函数并实现您自己的逻辑:

tsx
dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    // 这将包括所有查询,包括失败的查询,
    // 但您也可以通过检查 `query` 来实现自己的逻辑
    return true
  },
})
dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    // 这将包括所有查询,包括失败的查询,
    // 但您也可以通过检查 `query` 来实现自己的逻辑
    return true
  },
})

序列化

在 Next.js 中执行 return { props: { dehydratedState: dehydrate(queryClient) } } 或在 Remix 中执行 return json({ dehydratedState: dehydrate(queryClient) }) 时,发生的情况是 queryClientdehydratedState 表示由框架序列化,以便可以将其嵌入到标记中并传输到客户端。

默认情况下,这些框架仅支持返回可安全序列化/解析的内容,因此不支持 undefinedErrorDateMapSetBigIntInfinityNaN-0、正则表达式等。这也意味着您不能从查询中���回任何这些内容。如果您希望返回这些值,请查看 superjson 或类似包。

如果您使用的是自定义 SSR 设置,则需要自己处理此步骤。您的第一直觉可能是使用 JSON.stringify(dehydratedState),但是由于默认情况下这不会转义诸如 <script>alert('Oh no..')</script> 之类的内容,因此很容易导致应用程序中出现 XSS 漏洞superjson不会转义值,并且在自定义 SSR 设置中单独使用它是不安全的(除非您添加额外的步骤来转义输出)。相反,我们建议使用诸如 Serialize JavaScriptdevalue 之类的库,它们都开箱即用地可以防止 XSS 注入。

关于请求瀑布流的说明

性能和请求瀑布流指南中,我们提到我们将重新审视服务器端渲染如何更改一个更复杂的嵌套瀑布流。请查看特定代码示例,但作为回顾,我们在 <Feed> 组件内部有一个代码拆分的 <GraphFeedItem> 组件。仅当提要包含图形项并且这两个组件都获取自己的数据时,才会渲染此组件。对于客户端渲染,这会导致以下请求瀑布流:

1. |-> 标记(无内容)
2.   |-> <Feed> 的 JS
3.     |-> getFeed()
4.       |-> <GraphFeedItem> 的 JS
5.         |-> getGraphDataById()
1. |-> 标记(无内容)
2.   |-> <Feed> 的 JS
3.     |-> getFeed()
4.       |-> <GraphFeedItem> 的 JS
5.         |-> getGraphDataById()

服务器端渲染的好处在于我们可以将上述内容转换为:

1. |-> 标记(包含内容和初始数据)
2.   |-> <Feed> 的 JS
2.   |-> <GraphFeedItem> 的 JS
1. |-> 标记(包含内容和初始数据)
2.   |-> <Feed> 的 JS
2.   |-> <GraphFeedItem> 的 JS

请注意,查询不再在客户端获取,而是其数据包含在标记中。我们现在可以并行加载 JS 的原因在于,由于 <GraphFeedItem> 是在服务器上渲染的,我们知道我们也将在客户端需要此 JS,并且可以在标记中为此块插入一个 script 标签。在服务器上,我们仍然会有这个请求瀑布流:

1. |-> getFeed()
2.   |-> getGraphDataById()
1. |-> getFeed()
2.   |-> getGraphDataById()

在获取提要之前,我们根本无法知道是否还需要获取图形数据,它们是依赖查询。由于这发生在服务器上,延迟通常较低且更稳定,因此这通常不是什么大问题。

太棒了,我们基本上展平了我们的瀑布流!不过有一个问题。让我们将此页面称为 /feed 页面,并假设我们还有一个像 /posts 这样的页面。如果我们在 URL 栏中直接输入 www.example.com/feed 并按回车键,我们将获得所有这些出色的服务器端渲染好处,但是,如果我们改为输入 www.example.com/posts 然后单击链接/feed,我们将回到:

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

这是因为对于 SPA,服务器端渲染仅适用于初始页面加载,而不适用于任何后续导航。

现代框架通常尝试通过并行获取初始代码和数据来解决此问题,因此如果您使用 Next.js 或 Remix 以及本指南中概述的预取模式(包括如何预取依赖查询),它实际上看起来会是这样:

1. |-> JS for <Feed>
1. |> getFeed() + getGraphDataById()
2.   |> JS for <GraphFeedItem>
1. |-> JS for <Feed>
1. |> getFeed() + getGraphDataById()
2.   |> JS for <GraphFeedItem>

这要好得多,但是如果我们想进一步改进,我们可以使用服务器组件将其展平为单个往返。在高级服务器端渲染指南中了解如何操作。

提示、技巧和注意事项

过期时间是从查询在服务器上获取时开始计算的

查询是否过时取决于其 dataUpdatedAt 的时间。需要注意的是,服务器需要有正确的时间才能正常工作,但使用的是 UTC 时间,因此时区不会影响这一点。

由于 staleTime 默认为 0,因此默认情况下,查询将在页面加载时在后台重新获取。您可能希望使用更高的 staleTime 来避免这种双重获取,尤其是在您不缓存标记的情况下。

这种过时查询的重新获取与在 CDN 中缓存标记完美匹配!您可以将页面本身的缓存时间设置得相当高,以避免在服务器上重新渲染页面,但将查询的 staleTime 配置得较低,以确保在用户访问页面后立即在后台重新获取数据。也许您想将页面缓存一周,但如果数据超过一天,则在页面加载时自动重新获取数据?

服务器上的高内存消耗

如果您为每个请求创建 QueryClient,React Query 会为此客户端创建隔离的缓存,该缓存会在 gcTime 期间保留在内存中。如果在该期间内请求数量较多,这可能会导致服务器上的高内存消耗。

在服务器上,gcTime 默认为 Infinity,这会禁用手动垃圾回收,并在请求完成后自动清除内存。如果您显式设置了非 Infinity 的 gcTime,则您将负责尽早清除缓存。

避免将 gcTime 设置为 0,因为这可能会导致水合错误。发生这种情况是因为水合边界将必要的数据放入缓存中进行渲染,但是如果垃圾回收器在渲染完成之前删除了数据,则可能会出现问题。如果您需要较短的 gcTime,我们建议将其设置为 2 * 1000 以允许应用程序有足够的时间引用数据。

要在不再需要缓存后清除缓存并降低内存消耗,您可以在处理请求并将脱水状态发送到客户端后添加对 queryClient.clear() 的调用。

或者,您可以设置一个较小的 gcTime

Next.js 重写的注意事项

如果您将 Next.js 的重写功能自动静态优化getStaticProps 一起使用,则存在一个问题:它将导致 React Query 进行第二次水合。这是因为 Next.js 需要确保它们在客户端解析重写并在水合后收集任何参数,以便可以在 router.query 中提供它们。

结果是所有水合数据的引用相等性丢失,例如,这会在您的数据用作组件的属性或 useEffects/useMemos 的依赖项数组的任何地方触发。