框架
版本

服务器渲染与水合

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

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

有关高级服务器渲染模式,例如流式传输、服务器组件和新的 Next.js app router,请参阅高级服务器渲染指南

如果您只想查看代码,可以跳到下面的Next.js pages router 完整示例Remix 完整示例

服务器渲染与 React Query

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

如果您阅读了请求瀑布流指南,您可能会记得这个

1. |-> Markup (without content)
2.   |-> JS
3.     |-> Query
1. |-> Markup (without content)
2.   |-> JS
3.     |-> Query

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

1. |-> Markup (with content AND initial data)
2.   |-> JS
1. |-> Markup (with content AND initial data)
2.   |-> JS

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

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

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

关于 Suspense 的简要说明

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

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

初始设置

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

Next.js pages router

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

// NEVER DO THIS:
// const queryClient = new QueryClient()
//
// Creating the queryClient at the file root level makes the cache shared
// between all requests and means _all_ data gets passed to _all_ users.
// Besides being bad for performance, this also leaks any sensitive data.

export default function MyApp({ Component, pageProps }) {
  // Instead do this, which ensures each request has its own cache:
  const [queryClient] = React.useState(
    () =>
      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,
          },
        },
      }),
  )

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

// NEVER DO THIS:
// const queryClient = new QueryClient()
//
// Creating the queryClient at the file root level makes the cache shared
// between all requests and means _all_ data gets passed to _all_ users.
// Besides being bad for performance, this also leaks any sensitive data.

export default function MyApp({ Component, pageProps }) {
  // Instead do this, which ensures each request has its own cache:
  const [queryClient] = React.useState(
    () =>
      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,
          },
        },
      }),
  )

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

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

使用 initialData 快速开始

最快的入门方法是完全不让 React Query 参与预取,也不使用 dehydrate/hydrate API。取而代之的是,将原始数据作为 initialData 选项传递给 useQuery。我们来看一个使用 Next.js pages router 和 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 选项,客户端缓存和数据将永远不会更新。

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

使用 Hydration API

只需稍加设置,您就可以使用 queryClient 在预加载阶段预取查询,将该 queryClient 的序列化版本传递给应用程序的渲染部分并在那里重用。这避免了上述缺点。您可以直接跳到完整的 Next.js pages router 和 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 pages router 完整示例

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

初始设置

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

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(
    () =>
      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,
          },
        },
      }),
  )

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

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

在每个路由中

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

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: {
            // With SSR, we usually want to set some default staleTime
            // above 0 to avoid refetching immediately on the client
            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: {
            // With SSR, we usually want to set some default staleTime
            // above 0 to avoid refetching immediately on the client
            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() {
  // This useQuery could just as well happen in some deeper child to
  // the <PostsRoute>, 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,
  })

  // ...
}

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() {
  // This useQuery could just as well happen in some deeper child to
  // the <PostsRoute>, 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,
  })

  // ...
}

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
// Remove PostsRoute with the HydrationBoundary and instead export Posts directly:
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
// Remove PostsRoute with the HydrationBoundary and instead export Posts directly:
export default function Posts() { ... }

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

预获取依赖查询

在预取指南中,我们学习了如何预取依赖查询,但我们如何在框架加载器中实现这一点呢?考虑以下代码,取自依赖查询指南

tsx
// Get the user
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// Then get the user's projects
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // The query will not execute until the userId exists
  enabled: !!userId,
})
// Get the user
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// Then get the user's projects
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // The query will not execute until the userId exists
  enabled: !!userId,
})

我们如何预取它以便在服务器端渲染?这是一个例子

tsx
// For Remix, rename this to loader instead
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,
    })
  }

  // For Remix:
  // return json({ dehydratedState: dehydrate(queryClient) })
  return { props: { dehydratedState: dehydrate(queryClient) } }
}
// For Remix, rename this to loader instead
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,
    })
  }

  // For 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) {
  // Handle the error, refer to your framework documentation
}

// You might also want to check and handle any invalid `result` here
let result

try {
  result = await queryClient.fetchQuery(...)
} catch (error) {
  // Handle the error, refer to your framework documentation
}

// You might also want to check and handle any invalid `result` here

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

tsx
dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    // This will include all queries, including failed ones,
    // but you can also implement your own logic by inspecting `query`
    return true
  },
})
dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    // This will include all queries, including failed ones,
    // but you can also implement your own logic by inspecting `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>组件。只有当 feed 包含图表项时,它才会被渲染,并且这两个组件都会获取自己的数据。在客户端渲染中,这会导致以下请求瀑布流

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

服务器渲染的好处在于我们可以将上述过程变成

1. |> Markup (with content AND initial data)
2.   |> JS for <Feed>
2.   |> JS for <GraphFeedItem>
1. |> Markup (with content AND initial data)
2.   |> JS for <Feed>
2.   |> JS for <GraphFeedItem>

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

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

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

太棒了,我们大部分都将瀑布流扁平化了!不过,这里有一个陷阱。我们称此页面为 /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,它会禁用手动垃圾回收,并在请求完成后自动清除内存。如果您明确设置了非 InfinitygcTime,则您将负责提前清除缓存。

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

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

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

Next.js 重写规则的注意事项

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

结果是所有水合数据都缺少引用相等性,这例如会在您的数据用作组件的 props 或 useEffect/useMemo 的依赖数组时触发。