SSR
本指南介绍如何在服务端渲染(server rendering)环境中使用 Solid Query,特别是与 SolidStart 配合使用。
如果你尚未阅读,建议先了解 预取与路由集成 指南。
Solid Query 的注水模型
Solid Query 的 useQuery 建立在 Solid 自身的 createResource 机制之上,而非由你手动调用的 dehydrate/hydrate 组合。这对本指南意味着:你的应用代码中不存在 dehydrate、hydrate 或 HydrationBoundary。
相反,当 useQuery 在服务端运行时:
- 它通过与客户端渲染所用的同一
QueryClient缓存来解析查询,其中retry被强制禁用、throwOnError被强制启用——详见下方 错误处理。 - 解析所得值是一个纯文本、可序列化的查询结果快照(例如
refetch这类函数会被剥离,因其无法跨网络传输)。 - Solid 自身的资源流式传输机制将该快照传递至客户端——这与
createResource处理任意异步资源(无论是否与查询相关)所用的机制相同。 - 在客户端,一旦快照抵达,Solid Query 会在你的组件对该查询进入加载状态前,内部完成查询缓存的注水。
因此,你通常需手动实现的三步操作——预取(prefetch)、脱水(dehydrate)、注水(hydrate)——被简化为:“调用 useQuery(或使用 queryClient.query 预取),其余交由 Solid 的流式传输处理”。权衡在于:Solid Query 决定哪些内容被序列化以及何时序列化,而非由你调用 dehydrate 并自行编写过滤逻辑。
一个值得注意的后果是:若某查询已在服务端解析完成,并以 staleTime 方式读取(或未设置 initialData),则其在客户端注水时不会在挂载(mount)时重新拉取,因为 Solid Query 已知其拥有足够新鲜的服务端数据。此后其行为与其他任何成功查询一致:staleTime 控制它在下一次触发(窗口获得焦点、新挂载、invalidateQueries 等)时是否重新拉取。
初始配置
与任何服务端渲染应用一样,请按请求创建 QueryClient 实例,而非在模块作用域创建,从而确保一位访客请求的缓存数据绝不会与另一位访客共享:
1// src/app.tsx2import { MetaProvider, Title } from '@solidjs/meta'3import { Router } from '@solidjs/router'4import { FileRoutes } from '@solidjs/start/router'5import { Suspense } from 'solid-js'6import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'7 8export default function App() {9 // Constructing the QueryClient inside the component (rather than at module10 // scope) means SolidStart creates a fresh one for every request, so data11 // is never shared between users.12 const queryClient = new QueryClient({13 defaultOptions: {14 queries: {15 // staleTime governs refetches from later triggers (a new mount, window16 // focus, and so on) — see Solid Query's hydration model above for why17 // the very first mount after SSR doesn't need it to skip a refetch.18 staleTime: 5 * 1000,19 },20 },21 })22 23 return (24 <QueryClientProvider client={queryClient}>25 <Router26 root={(props) => (27 <MetaProvider>28 <Title>My App</Title>29 <Suspense>{props.children}</Suspense>30 </MetaProvider>31 )}32 >33 <FileRoutes />34 </Router>35 </QueryClientProvider>36 )37}<Suspense> 边界至关重要:useQuery 在查询处于等待中(pending)时会挂起(suspend),无论是在服务端(以便 SolidStart 等待其完成或跳过流式传输),还是在客户端(首次渲染期间、注水数据抵达之前)。
直接使用 useQuery
由于注水是自动进行的,获取服务端渲染数据最简单的方式就是在需要处直接调用 useQuery——无需单独的预取步骤:
1import { useQuery } from '@tanstack/solid-query'2import { For, Suspense } from 'solid-js'3 4function Posts() {5 const query = useQuery(() => ({6 queryKey: ['posts'],7 queryFn: fetchPosts,8 }))9 10 return (11 <Suspense fallback={<div>Loading...</div>}>12 <ul>13 <For each={query.data}>{(post) => <li>{post.title}</li>}</For>14 </ul>15 </Suspense>16 )17}在服务端,此操作会拉取 posts、等待其完成(因外层存在 <Suspense>),并将解析后的值流式传输至客户端。在客户端,该值会在 Posts 渲染加载状态前完成缓存注水——前提是客户端同样在 <Suspense> 边界内渲染,而 app.tsx 中的 <Suspense> 已提供该边界。
使用 Solid 的 ErrorBoundary 包裹同一查询以处理失败情形。在服务端,失败的查询总会抛出至最近的 ErrorBoundary——详见下方 错误处理 说明原因,以及如何在客户端实现相同行为。
1import { useQuery } from '@tanstack/solid-query'2import { ErrorBoundary, For, Suspense } from 'solid-js'3 4function Posts() {5 const query = useQuery(() => ({6 queryKey: ['posts'],7 queryFn: fetchPosts,8 }))9 10 return (11 <ErrorBoundary12 fallback={(err, reset) => (13 <div>14 <div>Error: {err.message}</div>15 <button16 onClick={async () => {17 await query.refetch()18 reset()19 }}20 >21 Retry22 </button>23 </div>24 )}25 >26 <Suspense fallback={<div>Loading...</div>}>27 <ul>28 <For each={query.data}>{(post) => <li>{post.title}</li>}</For>29 </ul>30 </Suspense>31 </ErrorBoundary>32 )33}使用 route.preload 预取
若要在 SolidStart 路由的 preload 函数中预取查询,请调用 queryClient.query(...):
1import { noop, useQueryClient } from '@tanstack/solid-query'2 3export const route = {4 preload: () => {5 const queryClient = useQueryClient()6 queryClient.query(postsQueryOptions()).catch(noop)7 },8}9 10export default function Posts() {11 const query = useQuery(() => postsQueryOptions())12 // ...13}.catch(noop) 可防止失败的预取中断路由流程——后续读取该查询的任何操作(如 useQuery)将自行暴露该失败。详见下方 错误处理,了解该失败在服务端与客户端的不同表现方式。
在 route.preload 与读取数据的组件之间共享查询选项对象(如上文的 postsQueryOptions()),而非在两处重复书写 queryKey/queryFn,可避免二者逐渐不一致——参见 queryOptions。
使用 deferStream 进行流式传输
默认情况下,查询一旦在服务端解析完成,即刻流式传输至客户端。设置 deferStream: true 可令服务端延迟响应,直至该查询解析完成——这对必须出现在首屏响应中的数据(如 SEO 元标签)尤为有用:
1const query = useQuery(() => ({2 queryKey: ['user'],3 queryFn: fetchUser,4 deferStream: true,5}))deferStream 仅影响服务端向客户端刷新该查询数据块的时机。若对页面上所有查询均设置 deferStream,服务端将在全部查询解析完成后再发送任何响应。
错误处理
在服务端,无论 throwOnError 选项如何设置,useQuery 在查询失败时总会抛出异常——因此它会被最近的 <ErrorBoundary> 捕获,而非表现为 query.isError。这与客户端行为不同:在客户端,失败的查询默认报告 isError/error,除非你显式启用 throwOnError: true 以使其抛出异常。此外,服务端也禁用重试(retry),因此查询会在首次失败时立即抛出,而非先尝试重试。
若某查询在服务端与客户端均通过 useQuery 读取,请显式为其设置 throwOnError: true,使客户端行为与服务端保持一致;否则,同一失败将在服务端渲染(SSR)期间抛出,却仅在客户端运行时设为 isError:
1const query = useQuery(() => ({2 queryKey: ['user'],3 queryFn: fetchUser,4 throwOnError: true,5}))route.preload 的预取不受此服务端专属覆盖的影响——queryClient.query(...) 根本不接受 throwOnError 选项,因此查询失败时,它在服务端与客户端均直接拒绝(reject)。你可以按路由需求捕获该拒绝(或任其传播);关于预取失败不应中断路由的 .catch(noop) 模式,请参见上方 使用 route.preload 预取。
提示、技巧与注意事项
过期性(staleness)从服务端拉取时刻起算
查询的过期性基于 dataUpdatedAt,该时间戳设为查询在服务端解析完成的时刻,而非客户端完成注水的时刻。由于 staleTime 默认为 0,未经显式设置 staleTime 的服务端渲染(SSR)查询,在其下一次触发(窗口获得焦点、首次挂载之后的新挂载、invalidateQueries 等)检查时即被视为过期,并可能随后在后台重新拉取——但客户端的首次挂载仍被豁免,参见上方 Solid Query 的注水模型。若你不希望出现此行为,可在初始配置示例中那样,将 staleTime 设为高于 0 的值。
服务端内存占用较高
由于 QueryClient 实例为每次请求新建,其缓存将在整个请求生命周期内驻留于内存中。高请求量下,内存占用可能显著上升。gcTime 在服务端默认为 Infinity,因此除非你将其配置为更低值,否则请求缓存的内存不会被自动回收——多数情况下这并无问题,因为一旦请求结束且再无引用指向该 QueryClient 实例,整个实例即符合垃圾回收(garbage collection)条件。