Skip to content
SSR 与持久化

SSR 与持久化

本章涵盖两个高级场景:服务端渲染(SSR)下的预取与注水(Hydration),以及客户端的离线持久化。


SSR 核心流程

服务端:
  1. 创建 QueryClient
  2. prefetchQuery() — 预取数据
  3. dehydrate(queryClient) — 序列化缓存为 JSON
  4. 将 dehydratedState 注入 HTML

客户端:
  5. 从 HTML 读取 dehydratedState
  6. queryClient.hydrate(dehydratedState) — 恢复缓存
  7. useQuery 直接从缓存读取 → 无 loading 状态

基础 SSR:dehydrate + HydrationBoundary

服务端(以 Next.js Pages Router 为例)

// pages/todos.tsx
import { dehydrate, QueryClient } from '@tanstack/react-query'

export async function getServerSideProps() {
  const queryClient = new QueryClient()

  // 预取数据(必须 await,确保数据就绪后再返回 HTML)
  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  }
}

function TodosPage({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <TodoList />  {/* useQuery 命中服务端预取的缓存 */}
    </HydrationBoundary>
  )
}

客户端

function TodoList() {
  const { data, isPending } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
  // 首次渲染时 data 来自服务端缓存的注水,isPending 为 false
}

Next.js App Router(React Server Components)

v5 与 Next.js 13+ App Router 集成时,需要注意 RSC 和 Client Components 的分界:

方案一:在 Server Component 中预取,Client Component 中消费

// app/todos/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { todoListOptions } from '@/query/options/todoOptions'
import TodoList from './TodoList'  // Client Component

export default async function TodosPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery(todoListOptions())

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <TodoList />
    </HydrationBoundary>
  )
}
// app/todos/TodoList.tsx (Client Component)
'use client'

import { useQuery } from '@tanstack/react-query'
import { todoListOptions } from '@/query/options/todoOptions'

export default function TodoList() {
  const { data, isPending } = useQuery(todoListOptions())
  // ...
}

方案二:使用 useSuspenseQuery 避免注水不匹配

当 SSR 预取的数据与客户端首次渲染不一致时,会出现注水错误(Hydration Mismatch)。useSuspenseQuery 可以避免此问题:

'use client'
import { useSuspenseQuery } from '@tanstack/react-query'

function TodoList() {
  const { data } = useSuspenseQuery(todoListOptions())
  // data 永远不会是 undefined(Suspense 保证)
}

// 父组件用 Suspense 包裹
<Suspense fallback={<TodoListSkeleton />}>
  <TodoList />
</Suspense>

⚠️ 使用 useSuspenseQuery 时,服务端 retry 默认为 0(不重试),避免服务端渲染被阻塞。


服务端与客户端的 staleTime / gcTime 差异

配置 服务端 客户端
retry 默认 0 默认 3
staleTime 默认 0(或自定义) 默认 0
gcTime 默认 5min(无意义——服务端 QueryClient 在一次请求后即被销毁) 默认 5min

💡 最佳实践:服务端创建 QueryClient 时设置 staleTime 大于客户端,这样预取的数据在客户端被视为 fresh,不会立即重取。例如:服务端 staleTime: 60_000,客户端 staleTime: 0


Streaming SSR

React 18+ 支持 Streaming SSR(renderToPipeableStream)。TanStack Query v5 通过 useSuspenseQuery 原生支持:

// 使用 useSuspenseQuery 而非 useQuery
// Suspense 边界会触发流式渲染 —— 数据就绪的部分先发送给客户端

function App() {
  return (
    <Suspense fallback={<TodosSkeleton />}>
      <Todos />
    </Suspense>
  )
}

function Todos() {
  const { data } = useSuspenseQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
  return <TodoList data={data} />
}

prefetchQuery vs ensureQueryData vs fetchQuery

方法 行为 返回值 何时用
prefetchQuery 如果缓存不存在或 stale,发起请求 Promise<void> SSR、路由预取
ensureQueryData 如果缓存不存在,发起请求;如果存在且 fresh,不请求 Promise<TData> 需要确认数据存在且获取它
fetchQuery 无论如何都发起请求 Promise<TData> 需要强制获取最新数据

持久化(Offline Persistence)

@tanstack/react-query-persist-client 支持将缓存持久化到 localStorage / IndexedDB / AsyncStorage,实现离线体验。

安装

npm install @tanstack/react-query-persist-client

配置(localStorage)

import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/react-query-persist-client'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // 24 小时(离线场景需要更长的 gcTime)
      staleTime: 5 * 60 * 1000,
    },
  },
})

const persister = createSyncStoragePersister({
  storage: window.localStorage,
  // 可选:只持久化部分查询
  key: 'REACT_QUERY_OFFLINE_CACHE',
  throttleTime: 1000,  // 1 秒内最多写入一次
})

function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister }}
    >
      <YourApp />
    </PersistQueryClientProvider>
  )
}

持久化配置选项

<PersistQueryClientProvider
  client={queryClient}
  persistOptions={{
    persister,
    maxAge: 1000 * 60 * 60 * 24,   // 超过 24h 的缓存不恢复
    buster: 'v2',                   // 版本升级时清除旧缓存
    dehydrateOptions: {
      shouldDehydrateQuery: (query) => {
        // 只持久化特定查询
        return query.queryKey[0] !== 'transient'
      },
    },
  }}
>

注意事项

问题 解决方案
localStorage 容量限制(5MB) 只持久化关键查询,设置 maxAge 清除旧数据
版本升级导致缓存结构不兼容 修改 buster 字符串强制清除
敏感数据不应持久化 shouldDehydrateQuery 过滤
服务端无 window.localStorage 检查 typeof window !== 'undefined',服务端用 noop persister

多标签页同步

@tanstack/react-query-broadcast-client-experimental 通过 Broadcast Channel API 同步多个标签页的缓存:

import { broadcastQueryClient } from '@tanstack/react-query-broadcast-client-experimental'

broadcastQueryClient({
  queryClient,
  broadcastChannel: 'my-app',
})

当一个标签页执行 mutation 成功并 invalidate 查询后,其他标签页的相同查询也会自动重取。


离线 Mutation 队列

结合 networkMode: 'offlineFirst' 和持久化,可以实现离线 mutation 队列:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      networkMode: 'offlineFirst',  // 离线时返回缓存
    },
    mutations: {
      networkMode: 'offlineFirst',  // 离线时 mutation 暂停,联网后自动重试
    },
  },
})

🔬 深入原理:TanStack Query 内部有一个 onlineManager,监听 window.addEventListener('online'/'offline')。当网络恢复在线时,暂停的 queries 和 mutations 自动恢复。这是通过 @tanstack/query-core 中的 OnlineManager 类实现的。


完整的 SSR + 持久化架构

用户首次访问
  │
  ▼
服务端:prefetchQuery → dehydrate → 序列化到 HTML
  │
  ▼
客户端:HydrationBoundary → hydrate → useQuery 从缓存读
  │
  ▼
后续交互:正常使用 useQuery / useMutation
  │
  ▼
缓存变更 → persistClient → 写入 localStorage
  │
  ▼
用户关闭页面后重新打开
  │
  ▼
persistClient → 从 localStorage 恢复缓存 → 即时显示旧数据 → 后台重取