框架
版本

服务端渲染 & 水合

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

请参阅 预取 & 路由集成 指南以了解一些背景知识。您可能还想在之前查看 性能 & 请求瀑布流指南

对于高级服务端渲染模式,例如流式传输、Server Components 和新的 Next.js app 路由器,请参阅 高级服务端渲染指南

如果您只想查看一些代码,您可以跳到下面的 完整的 Next.js pages 路由器示例完整的 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 替换它,只要您始终预取所有 query。这样做的好处是您可以使用 <Suspense> 来处理客户端上的加载状态。

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

初始设置

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

Next.js pages 路由器

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

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

使用水合 API

只需稍作设置,您就可以使用 queryClient 在预加载阶段预取 query,将 queryClient 的序列化版本传递给应用程序的渲染部分并在那里重用它。这避免了上述缺点。请随意跳到完整的 Next.js pages 路由器和 Remix 示例,但在一般层面上,这些是额外的步骤

  • 在框架加载器函数中,创建一个 const queryClient = new QueryClient(options)
  • 在加载器函数中,为每个要预取的 query 执行 await queryClient.prefetchQuery(...)
    • 您想要使用 await Promise.all(...) 尽可能并行地获取 query
    • 拥有未预取的 query 是可以的。这些 query 不会被服务端渲染,而是在应用程序可交互后在客户端上获取。这对于仅在用户交互后显示的内容,或者在页面下方很远的内容以避免阻塞更重要的内容来说非常有用。
  • 从加载器返回 dehydrate(queryClient),请注意,返回此语法的确切语法因框架而异
  • 使用 <HydrationBoundary state={dehydratedState}> 包装您的树,其中 dehydratedState 来自框架加载器。您如何获取 dehydratedState 也因框架而异。
    • 这可以为每个路由完成,也可以在应用程序的顶部完成以避免样板代码,请参阅示例

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

Server Components 是另一种“预加载”阶段的形式,它也可以“预加载”(预渲染)React 组件树的各个部分。在 高级服务端渲染指南 中阅读更多内容。

完整的 Next.js pages 路由器示例

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

初始设置

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 包。

预取依赖 query

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

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,因此您可以充分利用该语言的功能来构建您的逻辑。确保预取所有您想要服务端渲染的 query。

错误处理

React Query 默认为优雅降级策略。这意味着

  • queryClient.prefetchQuery(...) 永远不会抛出错误
  • dehydrate(...) 仅包含成功的 query,不包含失败的 query

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

虽然这是一个很好的默认值,但有时这不是您想要的。当关键内容丢失时,您可能希望根据情况使用 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

如果您出于某种原因想要在脱水状态中包含失败的 query 以避免重试,您可以使用 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、正则表达式等。这也意味着您不能从您的 query 中返回任何这些内容。如果返回这些值是您想要的,请查看 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>

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

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

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

太棒了,我们基本上已经扁平化了我们的瀑布流!但是有一个问题。让我们将此页面称为 /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 以及我们在本指南中概述的预取模式(包括如何预取依赖 query),它实际上看起来像这样

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

这好得多,但如果我们想进一步改进它,我们可以使用 Server Components 将其扁平化为单次往返。在 高级服务端渲染指南 中了解如何操作。

提示、技巧和注意事项

陈旧性是从 query 在服务器上获取时开始衡量的

一个 query 是否被认为是陈旧的取决于它的 dataUpdatedAt 时间。这里的注意事项是服务器需要具有正确的时间才能正常工作,但使用的是 UTC 时间,因此时区不会影响这一点。

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

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

服务器上高内存消耗

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

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

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

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

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

Next.js rewrites 的注意事项

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

结果是所有水合数据都缺少引用相等性,例如,无论您的数据用作组件的 props 还是 useEffects/useMemos 的依赖项数组中,都会触发这种情况。