译零 TransZero · TanStack Query 中文文档
主站

服务端渲染与水合

更新于 2026-11-02 Preact 查看英文原文 ↗

本指南将介绍如何在服务端渲染(server rendering)中使用 Preact Query。

有关背景知识,请参阅 预取与路由集成指南。你可能还想先阅读 性能与请求瀑布指南。

服务端渲染与 Preact Query

那么,服务端渲染究竟是什么?本指南其余部分默认你已了解该概念,但让我们花点时间看看它与 Preact Query 的关系。服务端渲染是指在服务器上生成初始 HTML,使用户在页面加载完成的第一时间就能看到内容。这可以在每次页面被请求时按需进行(SSR),也可以提前完成——要么因为之前的请求已被缓存,要么在构建时完成(SSG)。

如果你读过 性能与请求瀑布指南,可能还记得以下示意图:

Code
11. |-> Markup (without content)22.   |-> JS33.     |-> Query

在客户端渲染的应用中,你需要至少进行三次服务器往返通信,才能向用户展示任何内容。从某种角度看,服务端渲染的作用就是将上述流程变为如下形式:

Code
11. |-> Markup (with content AND initial data)22.   |-> JS

一旦 1. 完成,用户即可看到内容;当 2. 完成后,页面即具备交互能力并可点击。由于生成的 HTML 标记中已包含所需的初始数据,因此步骤 3. 在客户端完全无需执行——至少在你因某种原因需要重新验证数据之前是如此。

以上是从客户端视角出发的描述。而在服务器端,我们需要在生成/渲染 HTML 标记前预取(prefetch)这些数据;需要将这些数据脱水(dehydrate)为一种可序列化的格式,并嵌入到 HTML 标记中;在客户端,则需将这些数据注水(hydrate)回 Preact Query 缓存中,从而避免在客户端重复拉取。

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

关于 Suspense 的简要说明

本指南使用常规的 useQuery API。虽然我们并不特别推荐,但你也可以用 useSuspenseQuery 替代 useQuery,前提是始终对所有查询执行预取。其优势在于,你可在客户端使用 <Suspense> 处理加载状态。

如果你在使用 useSuspenseQuery 时遗漏了某个查询的预取,后果取决于你的具体配置。某些情况下,该查询会触发 Suspense,在服务端拉取数据,但无法将结果注水到客户端,导致客户端再次拉取。此时会出现标记注水不匹配(markup hydration mismatch),因为服务端与客户端尝试渲染的内容不同。

初始设置

使用 Preact Query 的第一步始终是创建一个 queryClient 实例,并用 <QueryClientProvider> 将应用包裹起来。在服务端渲染场景下,重要的是将 queryClient 实例创建于应用内部(例如使用 Preact 状态或 ref 实例均可)。此举可确保不同用户和请求之间不会共享数据,同时仍保证每个组件生命周期内仅创建一次 queryClient。

TypeScript
1import { QueryClient, QueryClientProvider } from '@tanstack/preact-query'2import { useState } from 'preact/hooks'3 4// NEVER DO THIS ON THE SERVER:5// const queryClient = new QueryClient()6//7// Creating the queryClient at the module 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// (On the client this is fine, since there's only ever one visitor per page.)11 12export function App({ children }) {13  // Instead do this, which ensures each request has its own cache:14  const [queryClient] = useState(15    () =>16      new QueryClient({17        defaultOptions: {18          queries: {19            // With SSR, we usually want to set some default staleTime20            // above 0 to avoid refetching immediately on the client21            staleTime: 60 * 1000,22          },23        },24      }),25  )26 27  return (28    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>29  )30}

使用 initialData 快速上手

最快捷的入门方式是:在预取环节完全不涉及 Preact Query,也不使用 dehydrate/hydrate API。取而代之的做法是,将原始数据作为 initialData 选项直接传给 useQuery:

TypeScript
1function Posts({ posts }) {2  const { data } = useQuery({3    queryKey: ['posts'],4    queryFn: getPosts,5    initialData: posts,6  })7 8  // ...9}

这种设置极为轻量,对某些场景而言是快速可行的方案;但与完整方案相比,存在若干权衡点需考虑:

  • 若你在组件树更深层调用 useQuery,则需将 initialData 逐层向下传递;
  • 若你在多个位置以相同查询键调用 useQuery,仅向其中一处传入 initialData 可能十分脆弱,且易随应用变更而失效。若移除或移动了携带 useQuery 与 initialData 的组件,嵌套更深的 useQuery 可能将彻底失去数据。而向所有需要它的查询都传入 initialData 同样繁琐;
  • 你无法得知查询在服务端的具体拉取时间,因此 dataUpdatedAt 及判断是否需重新拉取均基于页面加载时间而非真实拉取时间;
  • 若查询在缓存中已有数据,initialData 永远不会覆盖该数据,即使新数据比旧数据更新。

搭建完整的注水方案十分直接,且无上述缺陷,本文档后续内容将聚焦于此。

使用注水 API

只需稍作额外配置,你即可利用 queryClient 在服务端渲染过程中预取查询、序列化该 queryClient 并随初始 HTML 标记一同发送至客户端,再复用该序列化结果注水客户端渲染。此方式可规避前述所有缺点。总体而言,需增加以下步骤:

  • 在服务端创建一个 const queryClient = new QueryClient(options);
  • 对每个需预取的查询执行 await queryClient.query(...);
    • 应尽可能使用 await Promise.all(...) 并行拉取查询;
    • 允许存在未预取的查询。这些查询不会被服务端渲染,而是在应用具备交互能力后由客户端拉取。这对仅在用户交互后才显示的内容,或位于页面底部、为避免阻塞关键内容而延迟加载的内容尤为适用;
  • 调用 dehydrate(queryClient),并将结果嵌入发送给客户端的 HTML 标记中,与 renderToString 生成的 HTML 并列;
  • 在客户端,用 <HydrationBoundary state={dehydratedState}> 包裹整个组件树,其中 dehydratedState 从嵌入的 HTML 标记中解析还原。

值得注意的一个细节是:服务端实际上涉及两个 queryClient 实例——一个用于预取,一个用于渲染。预取客户端拉取查询并被 dehydrate;其脱水后的状态随后被 hydrate 进入一个全新的渲染客户端,后者才是你实际传给 <QueryClientProvider> 用于 renderToString 的实例。在客户端,第三个 queryClient 实例通过 <HydrationBoundary> 以相同的脱水状态完成注水,因此三者均始于同一份数据,并生成一致的 HTML 标记。

使用 preact-render-to-string 的完整示例

服务端入口文件:

TypeScript
1import { renderToString } from 'preact-render-to-string'2import {3  QueryCache,4  QueryClient,5  QueryClientProvider,6  dehydrate,7  hydrate,8  noop,9} from '@tanstack/preact-query'10 11function makeQueryClient() {12  return new QueryClient({13    queryCache: new QueryCache(),14    defaultOptions: {15      queries: {16        // With SSR, we usually want to set some default staleTime17        // above 0 to avoid refetching immediately on the client18        staleTime: 60 * 1000,19      },20    },21  })22}23 24export async function render(url) {25  // The prefetching client: only used to fetch and dehydrate26  const prefetchClient = makeQueryClient()27 28  await prefetchClient29    .query({30      queryKey: ['posts'],31      queryFn: getPosts,32    })33    .catch(noop)34 35  const dehydratedState = dehydrate(prefetchClient)36 37  // The render client: hydrated with the same state, used for renderToString38  const renderClient = makeQueryClient()39  hydrate(renderClient, dehydratedState)40 41  const appHtml = renderToString(42    <QueryClientProvider client={renderClient}>43      <App />44    </QueryClientProvider>,45  )46 47  // Embed both the markup and the dehydrated state in the html you send down.48  // Use a safe serializer here (see Serialization below), NOT JSON.stringify,49  // since raw JSON.stringify output can break out of the <script> tag:50  // e.g. `<div id="app">${appHtml}</div><script>window.__DEHYDRATED_STATE__ = ${serialize(dehydratedState)}</script>`51  return { appHtml, dehydratedState }52}

客户端入口文件:

TypeScript
1import { hydrate as preactHydrate } from 'preact'2import { useState } from 'preact/hooks'3import {4  HydrationBoundary,5  QueryClient,6  QueryClientProvider,7} from '@tanstack/preact-query'8 9function makeQueryClient() {10  return new QueryClient({11    defaultOptions: {12      queries: {13        // With SSR, we usually want to set some default staleTime14        // above 0 to avoid refetching immediately on the client15        staleTime: 60 * 1000,16      },17    },18  })19}20 21function App({ dehydratedState }) {22  const [queryClient] = useState(() => makeQueryClient())23 24  return (25    <QueryClientProvider client={queryClient}>26      <HydrationBoundary state={dehydratedState}>27        <Posts />28      </HydrationBoundary>29    </QueryClientProvider>30  )31}32 33// Read back and deserialize whatever you embedded on the server,34// e.g. `window.__DEHYDRATED_STATE__`35preactHydrate(36  <App dehydratedState={window.__DEHYDRATED_STATE__} />,37  document.getElementById('app'),38)

组件树中任意位置:

TypeScript
1function Posts() {2  // This useQuery could just as well happen in some deeper child to <App>,3  // data will be available immediately either way4  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })5 6  // This query was not prefetched on the server and will not start7  // fetching until on the client, both patterns are fine to mix8  const { data: commentsData } = useQuery({9    queryKey: ['posts-comments'],10    queryFn: getComments,11  })12 13  // ...14}

预取依赖查询

在预取指南中,我们学习了如何 预取依赖查询,但如何在服务端实现这一点?请参考 依赖查询指南 中的如下代码:

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

我们该如何预取这段逻辑以支持服务端渲染?示例如下:

TypeScript
1const queryClient = new QueryClient()2 3const user = await queryClient.query({4  queryKey: ['user', email],5  queryFn: getUserByEmail,6})7 8if (user?.id) {9  await queryClient.query({10    queryKey: ['projects', user.id],11    queryFn: getProjectsByUser,12  })13}14 15const dehydratedState = dehydrate(queryClient)

当然,实际场景可能更为复杂;但由于预取本身只是普通 JavaScript 执行过程,你可以充分利用语言能力构建任意逻辑。请确保对所有希望服务端渲染的查询执行预取。

错误处理

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

  • dehydrate(...) 仅包含成功查询,不包含失败查询;
  • 我们可有意忽略 void queryClient.query(...) 返回的 Promise,并添加 .catch(noop) 来捕获并吞掉所有错误,从而避免外围服务端代码感知到查询错误。

这将导致任何失败的查询在客户端重试,且服务端渲染输出中将显示加载状态,而非完整内容。

尽管这是个良好的默认行为,但有时并非你所需。当关键内容缺失时,你可能希望根据具体情况返回 404 或 500 状态码。对于此类场景,请使用不带空捕获(noop catch)的 await queryClient.query(...),使其在失败时抛出错误,以便你以合适方式处理。

TypeScript
1let result2 3try {4  result = await queryClient.query(...)5} catch (error) {6  // Handle the error in whatever way fits your server setup7}8 9// You might also want to check and handle any invalid `result` here

若因某种原因,你希望将失败查询也包含在脱水状态中以避免重试,可使用 shouldDehydrateQuery 选项覆写默认函数,并自行实现逻辑:

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

序列化

dehydrate(queryClient) 的结果需被序列化,方能嵌入 HTML 标记、传输至客户端,并在传给 <HydrationBoundary state={...}> 前解析还原。

你第一反应可能是使用 JSON.stringify(dehydratedState),但由于它默认不转义 <script>alert('Oh no..')</script> 等内容,极易在应用中引入XSS 漏洞。我们建议改用 Serialize JavaScript 或 devalue 等库,二者开箱即支持 XSS 防护。

默认情况下,原生 JSON.stringify/JSON.parse 同样不支持 undefined、Error、Date、Map、Set、BigInt、Infinity、NaN、-0、正则表达式等类型。这也意味着,除非你的序列化器支持它们,否则你无法从查询中返回这些值。若需返回此类值,请查阅 superjson 或类似包(请注意,superjson 本身不执行转义,因此你仍需额外步骤对输出进行转义)。

除了从外部序列化整个 dehydratedState,你也可让 Preact Query 按查询粒度完成序列化,方法是在 QueryClient 上配置 dehydrate.serializeData/hydrate.deserializeData:

TypeScript
1const queryClient = new QueryClient({2  defaultOptions: {3    dehydrate: {4      serializeData: serialize,5    },6    hydrate: {7      deserializeData: deserialize,8    },9  },10})

该配置会在 dehydrate 输出及 hydrate 输入时分别转换每个查询的 data,因此 serialize/deserialize 仅需处理查询数据本身的结构,而无需处理完整的脱水状态。与任何自定义序列化器一样,若你最终将序列化输出直接嵌入 HTML 标记中,请务必确保其具备 XSS 防护能力。

流式传输(Streaming)

preact-render-to-string 还可通过 renderToPipeableStream 流式传输 HTML。它会立即发送 <Suspense> 回退内容,并在查询解析完成时逐步将其替换为最终内容,从而让页面中就绪的部分先于较慢的部分显示出来。

若要将此功能与 useSuspenseQuery 结合使用,则仍需遵守 关于 Suspense 的说明 中的规则:对所有渲染出的查询进行预取。在渲染前启动全部查询,且不等待其完成,这样每个 <Suspense> 边界仅需等待自身对应的查询,而不会在上一个边界解析完成后才启动下一个查询:

TypeScript
1void queryClient.query(postsQueries.list()).catch(noop)2void queryClient.query(commentsQueries.list()).catch(noop)

由于 HTML 是分块发送的,因此不存在某个单一时间点可对整个缓存执行 dehydrate 并嵌入其中。取而代之的是,每个查询一旦成功即刻执行 dehydrate,并随下一个数据块一同发送其状态;如此一来,在客户端执行注水(hydrate)时,已拥有所有边界的对应数据:

TypeScript
1import { Transform } from 'node:stream'2import { renderToPipeableStream } from 'preact-render-to-string/stream-node'3import { uneval } from 'devalue'4import { QueryClientProvider, dehydrate, noop } from '@tanstack/preact-query'5 6export function render({ onShellReady, onError }) {7  const queryClient = makeQueryClient()8 9  void queryClient.query(postsQueries.list()).catch(noop)10  void queryClient.query(commentsQueries.list()).catch(noop)11 12  // Collect each query's state as soon as it succeeds13  const states = []14  const unsubscribe = queryClient.getQueryCache().subscribe((event) => {15    if (event.type === 'updated' && event.action.type === 'success') {16      states.push(17        dehydrate(queryClient, {18          shouldDehydrateQuery: (query) => query === event.query,19        }),20      )21    }22  })23 24  // Send the collected states right before the next chunk25  const flushStates = () =>26    states27      .splice(0)28      .map(29        (state) =>30          `<script>window.__QUERY_STATES__.push(${uneval(state)});document.currentScript.remove()</script>`,31      )32      .join('')33 34  const injectStates = new Transform({35    transform(chunk, _encoding, callback) {36      callback(null, flushStates() + chunk)37    },38    flush(callback) {39      callback(null, flushStates())40    },41  })42 43  const stream = renderToPipeableStream(44    <QueryClientProvider client={queryClient}>45      <App />46    </QueryClientProvider>,47    {48      onShellReady,49      onAllReady: unsubscribe,50      onError: (error) => {51        // The renderer destroys its own stream on errors and aborts,52        // so end this one as well to let the response finish53        unsubscribe()54        injectStates.end()55        onError(error)56      },57    },58  )59 60  return {61    pipe: (writable) => {62      injectStates.pipe(writable, { end: false })63      stream.pipe(injectStates)64      return injectStates65    },66    abort: stream.abort,67  }68}

在 HTML 的 <head> 中以内联 <script> 方式定义 window.__QUERY_STATES__ = [],在 onShellReady 中将 HTML 写在应用之前,将应用管道写入响应流,并在流结束时写入剩余 HTML:

TypeScript
1const { pipe, abort } = render({2  onShellReady() {3    res.statusCode = 2004    res.setHeader('Content-Type', 'text/html')5    res.write(htmlStart)6 7    const stream = pipe(res)8    stream.on('end', () => {9      clearTimeout(abortTimeout)10      res.end(htmlEnd)11    })12  },13  onError(error) {14    console.error(error)15  },16})17 18// Stop streaming and let the client render the rest if it takes too long19const abortTimeout = setTimeout(abort, 10000)

在客户端,于应用注水前,将所有流式传输的状态 hydrate 到 queryClient 中。客户端入口是一个模块脚本(module script),因此仅在整个流接收完毕后才会运行:

TypeScript
1import { hydrate as preactHydrate } from 'preact'2import { QueryClientProvider, hydrate } from '@tanstack/preact-query'3 4const queryClient = makeQueryClient()5 6for (const state of window.__QUERY_STATES__) {7  hydrate(queryClient, state)8}9 10preactHydrate(11  <QueryClientProvider client={queryClient}>12    <App />13  </QueryClientProvider>,14  document.getElementById('root'),15)

需注意以下几点:

  • 状态直接嵌入到 HTML 标记中,因此请使用 XSS 安全的序列化器(例如来自 devalue 的 uneval)对其进行序列化。参见 序列化(Serialization)。
  • 会缓冲响应的中间件(如 compression)会延迟发送各数据块,直至整个响应结束。请在每个数据块后调用 res.flush() 等方式刷新响应,以维持流式传输行为。
  • 失败的查询不会被脱水(dehydrate),因此客户端会重新拉取它们。请将边界组件包裹在基于 useErrorBoundary 构建的错误边界中,以便在该拉取也失败时显示错误提示。切勿在此场景下将类组件与 getDerivedStateFromError 或 componentDidCatch 和 options.errorBoundaries 一起使用:否则 preact-render-to-string 也会捕获服务端挂起(suspending)组件的 Promise,从而破坏流式传输。
  • 若查询在服务端失败,其对应边界所流式传输的内容将最终显示为文本 undefined,直到客户端完成注水并将其替换。

提示、技巧与注意事项

过期性(staleness)从服务端拉取查询的时间点开始计算

查询是否过期取决于其何时被 dataUpdatedAt。此处需注意:服务端必须具备准确的时间才能使该机制正常工作;不过实际采用的是 UTC 时间,因此时区差异不会影响判断。

由于 staleTime 默认为 0,查询将在页面加载时默认于后台重新拉取。你可能希望设置更高的 staleTime 来避免这种重复拉取,尤其是在未缓存 HTML 标记的情况下。

当在 CDN 中缓存 HTML 标记时,这种对过期查询的后台重新拉取机制尤为契合!你可以将页面自身的缓存时间设得较高,以避免频繁在服务端重新渲染页面;同时将查询的 staleTime 设得较低,确保用户访问页面时能立即在后台重新拉取数据。例如,你可能希望将页面缓存一周,但只要数据超过一天即在页面加载时自动重新拉取?

服务端内存占用过高

若你为每次请求都创建新的 QueryClient,Preact Query 将为此客户端创建一个独立缓存,并在 gcTime 时段内保留在内存中。若该时段内请求数量巨大,则可能导致服务端内存占用过高。

在服务端,gcTime 默认为 Infinity,这会禁用手动垃圾回收,并在单次请求结束后自动清理内存。若你显式设置了非 Infinity 的 gcTime 值,则需自行负责提前清理缓存。

请避免将 gcTime 设置为 0,否则可能导致注水错误(hydration error)。这是因为 注水边界(Hydration Boundary) 会将渲染所需的数据放入缓存,但如果垃圾回收器在渲染完成前就清除了这些数据,就会引发问题。若你确实需要更短的 gcTime,我们建议将其设为 2 * 1000,以留出充足时间供应用引用相关数据。

为在缓存不再需要时及时清理、降低内存占用,你可在请求处理完毕且脱水状态已发送至客户端后,调用 queryClient.clear()。

或者,你也可以设置一个更小的 gcTime。

本页为 TanStack Query 文档(Preact 版)的非官方中文翻译,原文由上游团队维护,遵循 MIT 许可。 译文如有偏差,以英文原文为准。

译自上游 59d11de