React Query 服务端数据脱水与客户端水合:dehydrate 与 HydrationBoundary 实战
快速答案
- 核心结论:
dehydrate和HydrationBoundary用于将服务端预取的数据(如 Next.js Server Components 中的数据)传递给客户端,避免客户端重复请求,保持状态一致。 - 第一检查点:确保服务端数据是纯 JSON 可序列化对象(如
JSON.parse(JSON.stringify(data))),否则 hydrate 会失败。 - 最小配置:在服务端组件中调用
dehydrate(queryClient)获取状态,在客户端用<HydrationBoundary state={dehydratedState}>包裹组件树。 - 适用边界:仅适用于 React 18+ 的 SSR/SSG 框架(Next.js App Router、Remix 等),不适用于纯客户端应用。
它解决什么问题
在 SSR/SSG 框架中,服务端预取的数据(如用户信息、文章列表)需要传递给客户端。如果客户端重新发起请求,会浪费带宽和时间。dehydrate 将服务端 QueryClient 的状态序列化为 JSON,HydrationBoundary 在客户端将这些状态注入到客户端的 QueryClient 中,实现数据无缝传递。
典型场景:
- Next.js App Router 中 Server Components 预取数据后传递给 Client Components
- Remix 的 loader 中预取数据后传递给页面组件
- 静态生成(SSG)时预取数据,避免客户端首次加载时空白
核心 API 参数说明
dehydrate(queryClient, options?)
| 参数 | 必填 | 说明 |
|---|---|---|
client | 是 | 需要脱水的 QueryClient 实例 |
options.shouldDehydrateMutation | 否 | 自定义哪些 mutation 应被脱水(默认只脱水成功查询) |
options.shouldDehydrateQuery | 否 | 自定义哪些 query 应被脱水(默认只脱水成功查询) |
options.serializeData | 否 | 自定义数据序列化函数(如加密、脱敏) |
options.shouldRedactErrors | 否 | 是否脱敏错误信息(默认 true,生产环境建议保持) |
HydrationBoundary(props)
| 参数 | 必填 | 说明 |
|---|---|---|
state | 是 | 从 dehydrate 返回的状态对象 |
options.defaultOptions | 否 | 水合后的默认 query/mutation 选项 |
options.deserializeData | 否 | 自定义数据反序列化函数 |
options.queryClient | 否 | 使用自定义 QueryClient(默认从最近的 QueryClientProvider 获取) |
hydrate(queryClient, dehydratedState, options?)
| 参数 | 必填 | 说明 |
|---|---|---|
queryClient | 是 | 要注入状态的 QueryClient 实例 |
dehydratedState | 是 | 从 dehydrate 返回的状态对象 |
options.defaultOptions | 否 | 水合后的默认 query/mutation 选项 |
options.deserializeData | 否 | 自定义数据反序列化函数 |
实战配置:Next.js App Router 完整示例
服务端组件(Server Component)
TSX// app/page.tsx import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' import { getPosts } from './api' import PostsList from './PostsList' export default async function PostsPage() { const queryClient = new QueryClient() // 服务端预取数据 await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts, }) // 脱水:将 QueryClient 状态序列化为 JSON const dehydratedState = dehydrate(queryClient) return ( <HydrationBoundary state={dehydratedState}> <PostsList /> </HydrationBoundary> ) }
客户端组件(Client Component)
TSX// app/PostsList.tsx 'use client' import { useQuery } from '@tanstack/react-query' import { getPosts } from './api' export default function PostsList() { // 客户端不会重新请求,直接使用服务端预取的数据 const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts, staleTime: 5 * 60 * 1000, // 5分钟内不重新获取 }) return ( <ul> {data?.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> ) }
关键配置说明
- staleTime 必须大于 0:默认
staleTime为 0,客户端会立即重新请求。设置staleTime: 5 * 60 * 1000让客户端信任服务端数据。 - 查询键必须完全一致:服务端
prefetchQuery和客户端useQuery的queryKey必须相同。 - 数据必须是 JSON 可序列化:服务端返回的
Date对象、Map、Set等类型需要手动转换。
常见报错与排查
1. Text content did not match
报错信息:Text content did not match. Server: "..." Client: "..."
根因:客户端和服务端渲染的 HTML 不一致,通常由动态数据(如 Date.now()、Math.random())或浏览器特有 API 引起。
解决方案:
TSX// 错误示例:直接在组件中调用 Date.now() function TimeDisplay() { return <div>{Date.now()}</div> // 服务端和客户端渲染结果不同 } // 正确做法:使用 useEffect 同步 function TimeDisplay() { const [time, setTime] = useState(0) useEffect(() => { setTime(Date.now()) }, []) return <div>{time}</div> }
2. toLocaleDateString is not a function
报错信息:TypeError: toLocaleDateString is not a function
根因:服务端传递的 Date 对象在序列化时被转换为 ISO 字符串,客户端无法调用 Date 方法。
解决方案:
TSX// 服务端:将 Date 转换为 ISO 字符串 const data = { user: { name: 'Alice', registeredAt: new Date().toISOString(), // 转换为字符串 } } // 客户端:重新实例化为 Date const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser, select: (raw) => ({ ...raw, user: { ...raw.user, registeredAt: new Date(raw.user.registeredAt), // 重新转为 Date } }) })
3. Hydration failed because the initial UI does not match
报错信息:Hydration failed because the initial UI does not match what was rendered on the server
根因:数据在服务端和客户端不一致,或使用了客户端特有的 API。
解决方案:
- 检查是否在服务端组件中使用了
localStorage、window等浏览器 API - 使用
useSyncExternalStore或Suspense边界包裹动态内容 - 确保数据在两次渲染间不发生变化
4. Cannot read properties of undefined (reading 'map')
报错信息:TypeError: Cannot read properties of undefined (reading 'map')
根因:服务端返回的数据结构在客户端解析失败,通常是因为返回了 ORM 模型或类实例。
解决方案:
TSX// 服务端:确保返回纯 JSON const posts = await db.post.findMany() const serializedPosts = JSON.parse(JSON.stringify(posts)) // 去除 ORM 模型属性
生产环境实践与注意事项
数据序列化限制
| 数据类型 | 是否支持 | 处理方式 |
|---|---|---|
| 普通对象/数组 | 是 | 原生支持 |
| Date | 否 | 手动转换为 ISO 字符串 |
| Map/Set | 否 | 转换为数组或对象 |
| 类实例 | 否 | 转换为普通对象 |
| BigInt | 否 | 转换为字符串 |
| undefined | 否 | 转换为 null 或忽略 |
安全性建议
- 避免脱水敏感数据:密码、令牌、密钥等不应出现在脱水状态中。
- 使用 serializeData 脱敏:
TSXconst dehydratedState = dehydrate(queryClient, { serializeData: (data) => { // 脱敏敏感字段 if (data?.user?.password) { return { ...data, user: { ...data.user, password: undefined } } } return data } })
- 生产环境保持 shouldRedactErrors 为 true:避免泄露服务端错误详情。
缓存合并策略
HydrationBoundary 使用基于更新时间戳的智能合并策略:
- 如果客户端已有相同查询且数据更新,不会覆盖(除非服务端数据更新)
- 默认只脱水成功查询,减少不必要数据传输
- 可以通过
shouldDehydrateQuery: () => true强制脱水所有查询(包括失败查询)
常见问题 FAQ
Q: 如何确保服务端错误(如 500)也能被客户端捕获并显示?
A: 在 dehydrate 时设置 shouldDehydrateQuery: () => true 和 shouldRedactErrors: () => false,这样错误信息会包含在脱水状态中。但注意:生产环境建议脱敏错误详情,避免泄露敏感信息。
TSXconst dehydratedState = dehydrate(queryClient, { shouldDehydrateQuery: () => true, shouldRedactErrors: () => false, // 生产环境建议保持 true })
Q: HydrationBoundary 和手动调用 hydrate 有什么区别?
A: HydrationBoundary 是 React 组件,自动从最近的 QueryClientProvider 获取 queryClient,适合在组件树中使用。手动 hydrate 需要显式传入 queryClient,适合在非 React 环境(如 localStorage 持久化)或需要精细控制时使用。
TSX// 手动 hydrate 示例(适合非 React 环境) import { hydrate } from '@tanstack/react-query' const queryClient = new QueryClient() hydrate(queryClient, dehydratedState, { defaultOptions: { queries: { staleTime: 5000 }, }, })
Q: 为什么我的查询在客户端被重复请求,即使已经脱水?
A: 可能原因:
- 脱水时未包含该查询(默认只包含成功查询)
- 客户端查询的
staleTime为 0,导致 React Query 认为数据过期并重新获取 - 查询键不一致(服务端和客户端使用不同的键)
解决方法:
TSX// 确保查询键完全一致 const queryKey = ['posts', { page: 1 }] // 服务端 await queryClient.prefetchQuery({ queryKey, queryFn: fetchPosts }) // 客户端 const { data } = useQuery({ queryKey, queryFn: fetchPosts, staleTime: 5 * 60 * 1000 })
Q: 如何与 React Server Components 结合使用?
A: 在 Server Components 中预取数据并脱水,在 Client Components 中使用 HydrationBoundary 包裹。注意:Server Components 中不能使用 useQuery,只能使用 prefetchQuery 或 fetchQuery。
TSX// Server Component export default async function Page() { const queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: ['data'], queryFn: () => fetch('https://api.example.com/data').then(r => r.json()), }) const dehydratedState = dehydrate(queryClient) return ( <HydrationBoundary state={dehydratedState}> <ClientComponent /> </HydrationBoundary> ) }
相关深度解决方案
在配置当前服务时,如果您需要实现更复杂的架构或多源数据整合,建议配合参考我们整理的 Next.js App Router Metadata 不更新?排查与解决方案。
在配置当前服务时,如果您需要实现更复杂的架构或多源数据整合,建议配合参考我们整理的 React useEffect 无限循环修复:5分钟诊断与解决方案。