服务端渲染与注水
在本指南中,你将学习如何在服务端渲染(SSR)环境中使用 React Query。
对于 TanStack Start,请使用 Start + TanStack Query 指南,其中涵盖其 SSR 集成、按请求作用域配置 QueryClient 以及修改失效处理示例。
请先参阅 预取与路由集成指南 了解背景知识。你可能还想在阅读本指南前查看 性能与请求瀑布指南。
如需更深入的注水与预取示例(包括代码分割),请参阅 依赖查询与代码分割 章节。
如需了解高级服务端渲染模式(例如流式渲染、服务端组件及 Next.js 新的 App Router),请参阅 高级服务端渲染指南。
如果你只想快速浏览代码,可直接跳转至下方的 完整的 Next.js Pages Router 示例 或 完整的 Remix 示例。
服务端渲染与 React Query
那么,服务端渲染究竟是什么?本指南其余部分默认你已熟悉该概念,但让我们花些时间探讨它与 React Query 的关系。服务端渲染是指在服务端生成初始 HTML,使用户在页面加载完成时即可看到内容。这可在页面被请求时按需发生(SSR),也可提前发生——要么因先前请求已被缓存,要么在构建时完成(SSG)。
如果你已阅读过 性能与请求瀑布指南,你可能还记得以下内容:
11. |-> Markup (without content)22. |-> JS33. |-> Query在客户端渲染应用中,这是你在屏幕上向用户呈现任何内容前所需的最少三次服务端往返请求。从某种角度看,服务端渲染的作用就是将上述流程转变为:
11. |-> Markup (with content AND initial data)22. |-> JS一旦 1. 完成,用户即可看到内容;当 2. 完成后,页面即具备交互性并可点击。由于 HTML 标记中已包含我们所需的初始数据,步骤 3. 在客户端完全无需执行——至少在你因某些原因需要重新验证数据之前是如此。
以上是从客户端视角出发的描述。而在服务端,我们需要在生成/渲染 HTML 标记前 预取 数据,再将该数据 脱水 为一种可序列化的格式以便嵌入 HTML 标记中;在客户端,则需将该数据 注水 至 React Query 缓存中,从而避免在客户端发起新的拉取请求。
继续阅读以了解如何使用 React Query 实现这三个步骤。
关于 Suspense 的简要说明
本指南使用常规的 useQuery API。虽然我们并不一定推荐,但只要确保始终预取全部查询,你也可以用 useSuspenseQuery 替代 useQuery。其优势在于你可在客户端使用 <Suspense> 处理加载状态。
若你在使用 useSuspenseQuery 时遗漏了某个查询的预取,后果将取决于你所使用的框架。在某些情况下,数据会触发 Suspense 并在服务端拉取,但永远不会注水到客户端,导致客户端再次拉取。此时会出现标记注水不匹配(markup hydration mismatch),因为服务端与客户端尝试渲染的内容不同。
初始设置
使用 React Query 的第一步始终是创建一个 queryClient 并用 <QueryClientProvider> 包裹整个应用。进行服务端渲染时,重要的是将 queryClient 实例 创建于你的应用内部(通过 React 状态实现,使用 ref 实例亦可)。此举可确保不同用户与请求之间不会共享数据,同时仍保证每个组件生命周期内仅创建一次 queryClient。
Next.js Pages Router:
1// _app.tsx2import { QueryClient, QueryClientProvider } from '@tanstack/react-query'3 4// NEVER DO THIS:5// const queryClient = new QueryClient()6//7// Creating the queryClient at the file root level makes the cache shared8// between all requests and means _all_ data gets passed to _all_ users.9// Besides being bad for performance, this also leaks any sensitive data.10 11export default function MyApp({ Component, pageProps }) {12 // Instead do this, which ensures each request has its own cache:13 const [queryClient] = React.useState(14 () =>15 new QueryClient({16 defaultOptions: {17 queries: {18 // With SSR, we usually want to set some default staleTime19 // above 0 to avoid refetching immediately on the client20 staleTime: 60 * 1000,21 },22 },23 }),24 )25 26 return (27 <QueryClientProvider client={queryClient}>28 <Component {...pageProps} />29 </QueryClientProvider>30 )31}Remix:
1// app/root.tsx2import { Outlet } from '@remix-run/react'3import { QueryClient, QueryClientProvider } from '@tanstack/react-query'4 5export default function MyApp() {6 const [queryClient] = React.useState(7 () =>8 new QueryClient({9 defaultOptions: {10 queries: {11 // With SSR, we usually want to set some default staleTime12 // above 0 to avoid refetching immediately on the client13 staleTime: 60 * 1000,14 },15 },16 }),17 )18 19 return (20 <QueryClientProvider client={queryClient}>21 <Outlet />22 </QueryClientProvider>23 )24}使用 initialData 快速上手
最快上手的方式是:在预取阶段完全不涉及 React Query,也不使用 dehydrate/hydrate API。取而代之的是,将原始数据作为 initialData 选项传入 useQuery。下面以 Next.js Pages Router 和 getServerSideProps 为例:
1export async function getServerSideProps() {2 const posts = await getPosts()3 return { props: { posts } }4}5 6function Posts(props) {7 const { data } = useQuery({8 queryKey: ['posts'],9 queryFn: getPosts,10 initialData: props.posts,11 })12 13 // ...14}此方式同样适用于 getStaticProps,甚至更早的 getInitialProps;其他具有类似功能的框架也可采用相同模式。以下是同一示例在 Remix 中的写法:
1export async function loader() {2 const posts = await getPosts()3 return json({ posts })4}5 6function Posts() {7 const { posts } = useLoaderData<typeof loader>()8 9 const { data } = useQuery({10 queryKey: ['posts'],11 queryFn: getPosts,12 initialData: posts,13 })14 15 // ...16}该方案设置极简,对某些场景而言是快速解决方案,但相较于完整方案,存在 若干权衡点需考虑:
- 若你在组件树更深层调用
useQuery,则需将initialData向下逐层传递; - 若你在多个位置以相同查询键调用
useQuery,仅向其中一个传入initialData可能导致脆弱性,并在应用变更时失效。例如,若你移除或移动了携带useQuery与initialData的组件,则嵌套更深的useQuery可能不再拥有任何数据。而向 所有 需要它的查询传入initialData同样繁琐; - 你无法得知查询在服务端被拉取的具体时间,因此
dataUpdatedAt及判断查询是否需要重新拉取,均基于页面加载时间而非实际拉取时间; - 若查询在缓存中已存在数据,
initialData将永远不会覆盖该数据,即使新数据比旧数据更新。- 要理解为何这点尤其糟糕,请回顾上方的
getServerSideProps示例:若你多次来回导航至某页面,getServerSideProps将每次都被调用并拉取新数据,但由于我们使用initialData选项,客户端缓存与数据将永远不会更新。
- 要理解为何这点尤其糟糕,请回顾上方的
设置完整的注水方案十分直接,且无上述缺陷,本文档后续内容将聚焦于此。
使用注水 API
只需稍作额外配置,你即可在预加载阶段使用 queryClient 预取查询,将该 queryClient 的序列化版本传入应用的渲染部分并复用。此举可规避上述缺陷。你可直接跳转至完整的 Next.js Pages Router 与 Remix 示例,但总体而言,这些是额外步骤:
- 在框架的 loader 函数中,创建一个
const queryClient = new QueryClient(options); - 在 loader 函数中,对每个需预取的查询执行
await queryClient.query(...);- 应尽可能使用
await Promise.all(...)并行拉取查询; - 允许存在未预取的查询。这些查询不会被服务端渲染,而是在应用具备交互性后由客户端拉取。这对仅在用户交互后才显示的内容,或位于页面底部以避免阻塞关键内容的区域尤为适用;
- 应尽可能使用
- 从 loader 返回
dehydrate(queryClient),注意不同框架返回该值的具体语法各不相同; - 使用
<HydrationBoundary state={dehydratedState}>包裹你的组件树,其中dehydratedState来自框架的 loader。获取dehydratedState的方式也因框架而异。- 此操作可针对每个路由单独执行,也可置于应用顶层以避免样板代码,详见示例。
一个有趣的细节是,实际上涉及 三个
queryClient。框架的 loader 是一种发生在渲染前的“预加载”阶段,该阶段拥有自己的queryClient用于执行预取。该阶段的脱水结果会同时传递给 服务端渲染过程 与 客户端渲染过程,二者各自拥有独立的queryClient。这确保它们均以相同数据启动,从而返回一致的 HTML 标记。
服务端组件是另一种形式的“预加载”阶段,亦可对 React 组件树的部分内容进行“预加载”(预渲染)。更多详情请参阅 高级服务端渲染指南。
完整的 Next.js Pages Router 示例
如需 App Router 文档,请参阅 高级服务端渲染指南。
初始设置:
1// _app.tsx2import { QueryClient, QueryClientProvider } from '@tanstack/react-query'3 4export default function MyApp({ Component, pageProps }) {5 const [queryClient] = React.useState(6 () =>7 new QueryClient({8 defaultOptions: {9 queries: {10 // With SSR, we usually want to set some default staleTime11 // above 0 to avoid refetching immediately on the client12 staleTime: 60 * 1000,13 },14 },15 }),16 )17 18 return (19 <QueryClientProvider client={queryClient}>20 <Component {...pageProps} />21 </QueryClientProvider>22 )23}每个路由中:
1// pages/posts.tsx2import {3 dehydrate,4 HydrationBoundary,5 noop,6 QueryClient,7 useQuery,8} from '@tanstack/react-query'9 10// This could also be getServerSideProps11export async function getStaticProps() {12 const queryClient = new QueryClient()13 14 await queryClient15 .query({16 queryKey: ['posts'],17 queryFn: getPosts,18 })19 .catch(noop)20 21 return {22 props: {23 dehydratedState: dehydrate(queryClient),24 },25 }26}27 28function Posts() {29 // This useQuery could just as well happen in some deeper child to30 // the <PostsRoute>, data will be available immediately either way31 const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })32 33 // This query was not prefetched on the server and will not start34 // fetching until on the client, both patterns are fine to mix35 const { data: commentsData } = useQuery({36 queryKey: ['posts-comments'],37 queryFn: getComments,38 })39 40 // ...41}42 43export default function PostsRoute({ dehydratedState }) {44 return (45 <HydrationBoundary state={dehydratedState}>46 <Posts />47 </HydrationBoundary>48 )49}完整的 Remix 示例
初始设置:
1// app/root.tsx2import { Outlet } from '@remix-run/react'3import { QueryClient, QueryClientProvider } from '@tanstack/react-query'4 5export default function MyApp() {6 const [queryClient] = React.useState(7 () =>8 new QueryClient({9 defaultOptions: {10 queries: {11 // With SSR, we usually want to set some default staleTime12 // above 0 to avoid refetching immediately on the client13 staleTime: 60 * 1000,14 },15 },16 }),17 )18 19 return (20 <QueryClientProvider client={queryClient}>21 <Outlet />22 </QueryClientProvider>23 )24}每个路由中(注意:嵌套路由中执行此操作亦可):
1// app/routes/posts.tsx2import { json } from '@remix-run/node'3import {4 dehydrate,5 HydrationBoundary,6 noop,7 QueryClient,8 useQuery,9} from '@tanstack/react-query'10 11export async function loader() {12 const queryClient = new QueryClient()13 14 await queryClient15 .query({16 queryKey: ['posts'],17 queryFn: getPosts,18 })19 .catch(noop)20 21 return json({ dehydratedState: dehydrate(queryClient) })22}23 24function Posts() {25 // This useQuery could just as well happen in some deeper child to26 // the <PostsRoute>, data will be available immediately either way27 const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })28 29 // This query was not prefetched on the server and will not start30 // fetching until on the client, both patterns are fine to mix31 const { data: commentsData } = useQuery({32 queryKey: ['posts-comments'],33 queryFn: getComments,34 })35 36 // ...37}38 39export default function PostsRoute() {40 const { dehydratedState } = useLoaderData<typeof loader>()41 return (42 <HydrationBoundary state={dehydratedState}>43 <Posts />44 </HydrationBoundary>45 )46}可选 —— 移除样板代码
每个路由中重复这部分代码可能显得冗余:
1export default function PostsRoute({ dehydratedState }) {2 return (3 <HydrationBoundary state={dehydratedState}>4 <Posts />5 </HydrationBoundary>6 )7}尽管此方法并无问题,但若你想消除此类样板代码,以下是在 Next.js 中调整配置的方法:
1// _app.tsx2import {3 HydrationBoundary,4 QueryClient,5 QueryClientProvider,6} from '@tanstack/react-query'7 8export default function MyApp({ Component, pageProps }) {9 const [queryClient] = React.useState(10 () =>11 new QueryClient({12 defaultOptions: {13 queries: {14 // With SSR, we usually want to set some default staleTime15 // above 0 to avoid refetching immediately on the client16 staleTime: 60 * 1000,17 },18 },19 }),20 )21 22 return (23 <QueryClientProvider client={queryClient}>24 <HydrationBoundary state={pageProps.dehydratedState}>25 <Component {...pageProps} />26 </HydrationBoundary>27 </QueryClientProvider>28 )29}30 31// pages/posts.tsx32// Remove PostsRoute with the HydrationBoundary and instead export Posts directly:33export default function Posts() { ... }在 Remix 中,此操作略复杂,我们建议查阅 use-dehydrated-state 包。
预取依赖查询
在预取指南中,我们学习了如何 预取依赖查询,但在框架 loader 中应如何实现?请参考 依赖查询指南 中的如下代码:
1// Get the user2const { data: user } = useQuery({3 queryKey: ['user', email],4 queryFn: getUserByEmail,5})6 7const userId = user?.id8 9// Then get the user's projects10const {11 status,12 fetchStatus,13 data: projects,14} = useQuery({15 queryKey: ['projects', userId],16 queryFn: getProjectsByUser,17 // The query will not execute until the userId exists18 enabled: !!userId,19})我们该如何预取该查询以支持服务端渲染?以下是一个示例:
1// For Remix, rename this to loader instead2export async function getServerSideProps() {3 const queryClient = new QueryClient()4 5 const user = await queryClient.query({6 queryKey: ['user', email],7 queryFn: getUserByEmail,8 })9 10 if (user?.id) {11 await queryClient.query({12 queryKey: ['projects', user.id],13 queryFn: getProjectsByUser,14 })15 }16 17 // For Remix:18 // return json({ dehydratedState: dehydrate(queryClient) })19 return { props: { dehydratedState: dehydrate(queryClient) } }20}当然,情况可能更为复杂,但由于这些 loader 函数本质上是 JavaScript,你可以充分利用语言能力构建逻辑。请确保预取所有你希望服务端渲染的查询。
错误处理
React Query 默认采用优雅降级策略。这意味着:
dehydrate(...)仅包含成功查询,不包含失败查询;- 我们可有意忽略
void queryClient.query(...)返回的 Promise,并添加.catch(noop)以吞掉任何错误,从而使外围 loader 代码不会感知查询错误。
这将导致失败查询在客户端重试,且服务端渲染输出将包含加载状态而非完整内容。
虽然这是良好的默认行为,但有时并非你所需。当关键内容缺失时,你可能希望根据具体情况返回 404 或 500 状态码。对此类场景,请使用不带空捕获(noop catch)的 await queryClient.query(...),使其失败时抛出错误,以便你以合适方式处理。
1let result2 3try {4 result = await queryClient.query(...)5} catch (error) {6 // Handle the error, refer to your framework documentation7}8 9// You might also want to check and handle any invalid `result` here若因某些原因你希望在脱水状态中包含失败查询以避免重试,可使用 shouldDehydrateQuery 选项覆写默认函数并实现自定义逻辑:
1dehydrate(queryClient, {2 shouldDehydrateQuery: (query) => {3 // This will include all queries, including failed ones,4 // but you can also implement your own logic by inspecting `query`5 return true6 },7})序列化
在 Next.js 中执行 return { props: { dehydratedState: dehydrate(queryClient) } },或在 Remix 中执行 return json({ dehydratedState: dehydrate(queryClient) }) 时,框架会将 queryClient 的 dehydratedState 表示形式序列化,以便嵌入 HTML 标记并传输至客户端。
默认情况下,这些框架仅支持返回安全可序列化/可解析的内容,因此不支持 undefined、Error、Date、Map、Set、BigInt、Infinity、NaN、-0、正则表达式等。这也意味着你不能从查询中返回这些值。若你需要返回此类值,请查阅 superjson 或类似包。
若你使用自定义 SSR 设置,则需自行处理此步骤。你第一反应可能是使用 JSON.stringify(dehydratedState),但由于其默认不转义 <script>alert('Oh no..')</script> 等内容,极易导致应用出现 XSS 漏洞。superjson 同样 不转义 值,在自定义 SSR 设置中单独使用是不安全的(除非额外增加输出转义步骤)。我们推荐使用 Serialize JavaScript 或 devalue 等库,二者均开箱即用、具备 XSS 防护能力。
关于请求瀑布的说明
在 性能与请求瀑布指南 中,我们提到将重新审视服务端渲染如何改变一种更复杂的嵌套瀑布。请回看 具体代码示例,作为复习,我们有一个代码分割的 <GraphFeedItem> 组件嵌套在 <Feed> 组件内。该组件仅在 feed 包含图谱项时渲染,且这两个组件各自拉取自身数据。在客户端渲染下,这将导致如下请求瀑布:
11. |> Markup (without content)22. |> JS for <Feed>33. |> getFeed()44. |> JS for <GraphFeedItem>55. |> getGraphDataById()服务端渲染的妙处在于,我们可以将上述流程转变为:
11. |> Markup (with content AND initial data)22. |> JS for <Feed>32. |> JS for <GraphFeedItem>请注意,查询不再在客户端拉取,其数据已包含在 HTML 标记中。我们现在能并行加载 JS 的原因是:由于 <GraphFeedItem> 已在服务端渲染,我们已知客户端同样需要该 JS,因此可在 HTML 标记中插入对应代码块的 script 标签。但在服务端,我们仍会遇到如下请求瀑布:
11. |> getFeed()22. |> getGraphDataById()我们无法在拉取 feed 前预知是否还需拉取图谱数据,它们属于依赖查询。由于此过程发生在服务端,而服务端延迟通常更低且更稳定,因此往往影响不大。
太棒了,我们的瀑布已基本压平!但有个陷阱。我们称此页面为 /feed 页面,并假设有另一个类似 /posts 的页面。若你在地址栏中直接输入 www.example.com/feed 并回车,即可获得所有这些出色的服务端渲染优势;但若你先输入 www.example.com/posts,再 点击链接 导航至 /feed,则又回到如下状态:
11. |> JS for <Feed>22. |> getFeed()33. |> JS for <GraphFeedItem>44. |> getGraphDataById()这是因为单页应用(SPA)中,服务端渲染仅对首次页面加载生效,对后续任意导航均无效。
现代框架常试图通过并行拉取初始代码与数据来解决此问题。因此,若你使用 Next.js 或 Remix 并遵循本指南所述的预取模式(包括如何预取依赖查询),实际效果将如下所示:
11. |> JS for <Feed>21. |> getFeed() + getGraphDataById()32. |> JS for <GraphFeedItem>这已大为改善,但若想进一步优化,可通过服务端组件将其压平为单次往返请求。详情请参阅 高级服务端渲染指南。
提示、技巧与注意事项
过期性基于查询在服务端拉取的时间计算
查询是否过期取决于其 dataUpdatedAt 时间。此处需注意:服务端需具备正确时间才能正常工作,但 React Query 使用 UTC 时间,因此时区不影响该计算。
由于 staleTime 默认为 0,查询将在页面加载时默认于后台重新拉取。你可能希望增大 staleTime 以避免重复拉取,尤其是在你不缓存 HTML 标记的情况下。
对 CDN 中缓存的 HTML 标记而言,这种对过期查询的重新拉取恰到好处!你可以将页面自身的缓存时间设得较高,以避免频繁在服务端重新渲染页面,但将查询的 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 中提供。
其结果是所有注水数据丢失引用相等性,例如,当你的数据作为组件 props 或 useEffects/useMemos 的依赖数组使用时,将触发相应行为。