服务端渲染与水合
本指南将介绍如何在服务端渲染(server rendering)中使用 Preact Query。
有关背景知识,请参阅 预取与路由集成指南。你可能还想先阅读 性能与请求瀑布指南。
服务端渲染与 Preact Query
那么,服务端渲染究竟是什么?本指南其余部分默认你已了解该概念,但让我们花点时间看看它与 Preact 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 标记前预取(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。
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:
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 的完整示例
服务端入口文件:
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}客户端入口文件:
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)组件树中任意位置:
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}预取依赖查询
在预取指南中,我们学习了如何 预取依赖查询,但如何在服务端实现这一点?请参考 依赖查询指南 中的如下代码:
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})我们该如何预取这段逻辑以支持服务端渲染?示例如下:
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(...),使其在失败时抛出错误,以便你以合适方式处理。
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 选项覆写默认函数,并自行实现逻辑:
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:
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> 边界仅需等待自身对应的查询,而不会在上一个边界解析完成后才启动下一个查询:
1void queryClient.query(postsQueries.list()).catch(noop)2void queryClient.query(commentsQueries.list()).catch(noop)由于 HTML 是分块发送的,因此不存在某个单一时间点可对整个缓存执行 dehydrate 并嵌入其中。取而代之的是,每个查询一旦成功即刻执行 dehydrate,并随下一个数据块一同发送其状态;如此一来,在客户端执行注水(hydrate)时,已拥有所有边界的对应数据:
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:
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),因此仅在整个流接收完毕后才会运行:
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。