Hydration 不是把 Server HTML 再渲染一遍,也不是给现有 DOM 批量绑定几个事件。它要求客户端用同一棵 React Tree 接管服务端已生成的 DOM,在保持首屏结构一致的前提下恢复组件状态、事件系统和后续更新能力。
一、为什么“页面已经显示”仍可能点不动
服务端渲染、静态生成或 Streaming SSR 可以让浏览器较早收到有意义的 HTML。用户可能已经看到商品标题、按钮和表单,但此时浏览器仍需:
- 下载对应 Client JavaScript;
- Parse、Compile 并执行模块;
- 调用
hydrateRoot; - 在客户端重新计算初始 React Tree;
- 将 React 组件身份与现有 DOM 对齐;
- 激活事件、Ref、Effect 和后续更新;
- Hydrate 尚未完成的 Suspense Boundary。
这解释了一个常见现象:LCP 已经很好,用户却在首屏点击时遇到高 Input Delay。Server HTML 改善了内容可见时间,但 Hydration JavaScript 仍可能形成主线程 Long Task。
本文以 React 19.2 稳定公开 API 为基准,重点讨论 React DOM Hydration。Framework 可能在其上实现 Streaming、RSC、Route Data、Partial Prerendering 或自定义恢复协议;具体 Payload、Marker、缓存和调度细节属于版本相关实现,应以项目锁定版本文档和源码验证。
核心结论
- Server HTML 是已存在的 DOM 输入,
hydrateRoot让客户端 React 接管它;createRoot则用于没有服务端 React Markup 的容器。 - 服务端输出与客户端第一次 Render 必须一致。Hydration Mismatch 是正确性问题,不只是开发环境的一条无害警告。
- React 不保证修补所有不一致属性或文本;Mismatch 严重时可能放弃局部或整个 Root 的 Hydration,转为 Client Render。
- 时间、随机数、Locale、Browser API、外部 Store、数据缓存和无效 HTML 都可能制造不确定首屏。
- 正确做法是传输同一 Snapshot 或设计稳定的首屏 Fallback,再在 Hydration 后更新,而不是普遍使用
suppressHydrationWarning。 - Selective Hydration 允许 Suspense Boundary 按代码、数据和交互优先级逐步激活,不要求整页同时完成。
- Selective Hydration 不等于“只 Hydrate 一部分且其他部分永不 Hydrate”;后者通常需要 RSC、Islands 或 Framework 级 Client Boundary。
- React 可以对部分 Hydration 前事件进行阻塞、优先 Hydration 或 Replay,但它不是所有事件和默认浏览器行为的可靠持久队列。
hydrateRoot没有返回“整页 Hydration 完成”的 Promise;Shell、Boundary 和业务 Ready 必须用不同观测信号描述。- Hydration 优化应同时减少 Client JavaScript、保持 Snapshot 一致、合理放置 Suspense Boundary,并在生产构建和低端设备上测试首次交互。
二、Server HTML 到 Client Hydration 的执行模型
2.1 服务端只生成静态表示
服务端 Render 会执行组件以生成 HTML,但浏览器收到的 HTML 本身不携带 JavaScript Closure、Hook Queue 或事件处理函数:
function AddToCartButton({ productId }: { productId: string }) {
return (
<button type="button" onClick={() => addToCart(productId)}>
加入购物车
</button>
);
}Server HTML 中会出现 <button> 和文本,不会把 onClick 函数序列化进 HTML Attribute。Client Bundle 执行后,React 才恢复这棵组件树与事件语义。
2.2 浏览器可能先 Paint,再 Hydrate
Hydration 仍包含 Render 与 Commit 心智模型:客户端需要执行组件来计算预期树,再将结果与现有 DOM 对应。它与普通 Mount 的差异在于,Commit 尽量复用服务端节点,而不是从空容器创建整棵 DOM。
“复用”不代表零工作。组件函数、Hook、Context、外部 Store Snapshot、事件系统和必要的 DOM 检查仍有成本。
2.3 Hydration 的正确性契约
React 官方要求服务端生成内容与客户端初始输出相同。这里的“相同”应理解为客户端 React 期望看到同样的元素类型、层级、顺序、文本和关键属性。
React 不会为了性能对每个 Attribute 做完整、昂贵的通用校验。开发环境会报告许多常见 Mismatch,但生产环境不能依赖 React 自动发现并修复所有差异。
三、hydrateRoot:入口、选项与生命周期
3.1 基本入口
import { StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
const container = document.getElementById('root');
if (container === null) {
throw new Error('Hydration root container not found');
}
const root = hydrateRoot(
container,
<StrictMode>
<App />
</StrictMode>,
);
window.addEventListener('app:dispose', () => {
root.unmount();
}, { once: true });服务端 React Markup 应使用 hydrateRoot。如果对同一容器调用 createRoot(...).render(...),React 会把它当作普通 Client Mount,而不是接管 Server HTML。
hydrateRoot 会立即返回 Root Handle,不会返回等待 Hydration 完成的 Promise。调用方不能用 await hydrateRoot(...) 判断整页已经可交互。
3.2 生产错误观测
React 19 的 Hydration Root 支持错误回调:
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root')!, <App />, {
onCaughtError(error, errorInfo) {
reportClientError(error, {
kind: 'caught',
componentStack: errorInfo.componentStack,
});
},
onUncaughtError(error, errorInfo) {
reportClientError(error, {
kind: 'uncaught',
componentStack: errorInfo.componentStack,
});
},
onRecoverableError(error, errorInfo) {
reportClientError(error, {
kind: 'recoverable',
componentStack: errorInfo.componentStack,
});
},
});onCaughtError观察被 Error Boundary 捕获的错误;onUncaughtError观察逃出所有 Error Boundary 的错误;onRecoverableError观察 React 能自动恢复的错误,其中可能包含部分 Hydration 问题。
这些回调不是 Hydration 成功通知,也不能保证每条开发警告都会成为同一种生产回调。日志需包含 App Version、Route、Release、Browser 和匿名化错误指纹,避免把 Server HTML、用户输入、Token 或个人数据直接上传。
3.3 不要在初始 Hydration 完成前抢先 root.render
hydrateRoot 返回后可以通过 root.render 更新应用,但 React 官方说明:如果在 Root 完成初始 Hydration 前调用它,React 会清除现有 Server HTML,并切换为完整 Client Render。
初始数据应作为 hydrateRoot 的同一棵 <App initialState={...} /> 输入,而不是先 Hydrate 空状态,再立刻 root.render 注入数据。
四、同一 Snapshot:Hydration 最重要的数据约束
客户端第一次 Render 需要消费服务端生成 HTML 时使用的同一份逻辑输入:
4.1 安全传输初始数据
常见 Framework 会提供经过转义的 Data Payload 和 Hydration Bridge。自建协议时必须同时考虑:
- 运行时 Schema Validation;
</script>、<等字符的安全转义;- CSP Nonce/Hash;
- 不序列化 Secret、内部权限对象或无关 PII;
- Date、BigInt、Map、Set、自定义 Class 等非 JSON 类型;
- App Version 和 Schema Version;
- 每个 SSR Request 使用独立数据与 Store 实例。
不要直接将未转义的 JSON.stringify(userData) 拼进 Inline Script。即使数据来自数据库,也可能包含攻击者控制字符串并提前闭合 <script>。
4.2 数据新鲜不等于 Hydration 时立即重取
SSR 已经使用 Snapshot A 生成 HTML。若 Client 首次 Render 直接读取刚请求到的 Snapshot B,就可能在 Hydration 尚未完成时产生不同内容。
Server State Library 通常通过 Dehydrate/Hydrate 协议传输 Query Data、Key、Timestamp 和 Error/Status。客户端应先恢复同一 Snapshot,再依据 Stale Time、Focus 或明确策略重新验证。
还要避免在服务端模块级创建共享 Query Client 或 Store,否则并发请求可能互相泄漏用户数据。
五、Hydration Mismatch:分类、后果与定位
Mismatch 的表象可能是文本警告,也可能是属性错误、节点替换、局部 Boundary Client Render,甚至整个 Root 放弃 Hydration。
5.1 常见根因表
| 根因 | Server 输出 | Client 首次输出 | 风险 |
|---|---|---|---|
| 当前时间 | 10:00:00 | 10:00:01 | 文本不一致 |
Math.random() |
0.42 |
0.87 |
ID、顺序或内容不一致 |
| Browser Locale | Server 默认语言 | 用户浏览器语言 | 文本、数字、日期不一致 |
| Browser API | 不存在 window |
读取宽度/存储 | 分支结构不一致或服务端崩溃 |
| 外部 Store | 默认 Snapshot | 真实客户端 Snapshot | UI 状态不一致 |
| 数据重取 | Cache A | 请求结果 B | 列表、价格、状态不一致 |
| 无效 HTML | 原字符串结构 | 浏览器修复后的 DOM | Element 层级不一致 |
| CSS-in-JS 配置 | Server Class Hash | Client Class Hash | 样式与属性不一致 |
| CDN/Extension 改写 | 原始 HTML | 已插入/删除节点 | DOM 不再由 React 单独拥有 |
5.2 无效 HTML 会在 React 运行前被浏览器修复
// 错误:p 中不能嵌套 div
function InvalidMarkup() {
return (
<p>
商品说明
<div>促销信息</div>
</p>
);
}浏览器 HTML Parser 可能自动关闭 <p> 并重排节点。客户端 React 看到的真实 DOM 已不同于服务端字符串,因此必须使用 HTML Validator、浏览器 Integration Test 和 React Warning 检查结构。
5.3 Mismatch 不是“React 最后会修好”
可能出现的恢复路径包括:
- 报告可恢复错误并继续;
- 保留某些不一致 Attribute,直到后续更新;
- 放弃某个 Suspense Boundary 的 Hydration,并在客户端重新 Render;
- Root 外发生严重错误时,切换整个 Root 为 Client Render;
- 用户在恢复期间看到内容跳变、输入丢失或事件目标变化。
具体恢复粒度属于 React 版本和错误位置相关行为。工程上应修复根因,而不是依赖某种内部恢复路径恒定不变。
六、时间、随机数与 Locale:让首次输出可重复
6.1 时间:传输事实,不在 Render 中读取“现在”
// 错误:服务端和客户端调用时间不同
function LastUpdated() {
return <time>{new Date().toLocaleTimeString()}</time>;
}更稳定的方案是传输时间事实和显式格式化上下文:
type PublishedAtProps = {
iso: string;
locale: string;
timeZone: string;
};
function PublishedAt({ iso, locale, timeZone }: PublishedAtProps) {
const label = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
}).format(new Date(iso));
return <time dateTime={iso}>{label}</time>;
}只要 iso、locale、timeZone 在服务端与客户端首次 Render 相同,输出就可重复。服务端还应固定 ICU/Locale 支持范围,并在目标 Node/Edge Runtime 与浏览器中测试格式结果。
6.2 浏览器时区只能在客户端确定时
先输出服务端已知的稳定 Label,Hydration 后再更新:
import { useEffect, useState } from 'react';
function LocalTime({
iso,
serverLabel,
}: {
iso: string;
serverLabel: string;
}) {
const [label, setLabel] = useState(serverLabel);
useEffect(() => {
const localLabel = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso));
setLabel(localLabel);
}, [iso]);
return <time dateTime={iso}>{label}</time>;
}客户端第一次 Render 仍使用 serverLabel,所以不会 Mismatch。Effect 更新可能改变文本宽度或换行,应预留空间,或由产品选择统一时区。
6.3 随机数:不要在 Render 中决定结构和身份
// 错误:每端得到不同促销项和 Key
function Promotion({ offers }: { offers: Offer[] }) {
const index = Math.floor(Math.random() * offers.length);
return <OfferCard key={Math.random()} offer={offers[index]} />;
}随机选择应在服务端/业务层完成,并将选中 ID 作为 Snapshot 传到客户端。需要可重复实验时,应使用稳定 Assignment Key 和经审计的确定性算法。
useId 适合生成服务端与客户端一致的 Accessibility/Form DOM ID,不适合列表 Key、数据库 ID、随机 Token 或安全 Nonce。
6.4 Locale:把选择结果当作请求状态
Locale 可能来自 URL、Cookie、Accept-Language 或用户设置。服务端应确定本次响应使用的 Locale,并把同一值传给客户端 Provider。
不要让服务端使用默认 en-US,客户端第一次 Render 又直接读取 navigator.language。如果产品需要切换到浏览器偏好,应 Redirect 到明确 Locale URL,或在 Hydration 后执行可见、可追踪的语言切换。
七、Browser-only API:稳定 Fallback 与 Client Boundary
7.1 typeof window 不会自动保证一致
// 错误:服务端和客户端首次 Render 走不同分支
function ViewportMessage() {
const isBrowser = typeof window !== 'undefined';
return <p>{isBrowser ? window.innerWidth : 'unknown'}</p>;
}服务端显示 unknown,客户端第一次 Render 显示宽度,仍然 Mismatch。更好的优先级是:
- 布局差异使用 CSS Media Query;
- 请求可知的状态通过 Props/Context 传输;
- 外部浏览器状态用
useSyncExternalStore和稳定getServerSnapshot; - 只有无法服务端表达的局部能力才使用 Client-only Fallback。
7.2 一个最小 Client-only Boundary
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
function ClientOnly({
fallback,
children,
}: {
fallback: ReactNode;
children: ReactNode;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted ? children : fallback;
}Server Render 与客户端第一次 Render 都返回 fallback,Effect 后才显示 Browser-only 内容。代价是额外同步 Render 和可能的 Layout Shift,因此应限制在地图、媒体设备、浏览器扩展状态等小区域。
这个 Wrapper 只延后子组件 Render,不能阻止已静态导入模块的顶层代码执行。如果第三方模块在 Import 时就读取 window,仍需使用 Framework 支持的 Client-only Dynamic Import,并处理加载与错误状态。
Framework 的“关闭 SSR”或 Client-only Dynamic Import 属于 Framework 能力,不是 React 核心 API。它通常会牺牲这部分 Server HTML,并需要明确 Loading、SEO、布局和错误体验。
7.3 localStorage 初值
不要在 State Initializer 中无条件读取 localStorage:
// 错误:服务端不可用,客户端值也可能与 Server HTML 不同
const [theme] = useState(() => localStorage.getItem('theme') ?? 'light');可选策略包括:
- 将主题写入 Cookie,让服务端与客户端读取同一值;
- 使用 CSS
prefers-color-scheme; - 在 React 启动前运行经过 CSP 管理的小型 Theme Script,并确保 DOM/Class 与 Hydration 输入一致;
- 先 Hydrate 稳定主题,再在 Effect 中切换,同时接受并治理 Flash。
八、useId 与多 Root:稳定身份不是随机身份
useId 会根据组件树位置生成可用于 Server/Client 的稳定 ID:
import { useId } from 'react';
function EmailField() {
const id = useId();
return (
<div>
<label htmlFor={id}>邮箱</label>
<input id={id} name="email" type="email" />
</div>
);
}前提是服务端与客户端树结构和 useId 调用顺序一致。
页面包含多个 React Root 时,应在服务端 Render 与客户端 Hydration 使用相同 identifierPrefix,避免不同 Root 生成冲突 ID:
// Server
renderToPipeableStream(<CheckoutApp />, {
identifierPrefix: 'checkout-',
onShellReady() {
// pipe response
},
});
// Client
hydrateRoot(document.getElementById('checkout-root')!, <CheckoutApp />, {
identifierPrefix: 'checkout-',
});Prefix 不一致本身就会造成 Hydration ID Mismatch。实际 Framework 通常代为管理,不应在业务组件中拼接不稳定前缀。
九、Selective Hydration:按 Boundary 和优先级逐步激活
没有 Suspense Boundary 的巨大 Root 容易让 Hydration 成为单个沉重任务。React 并发 Hydration 与 Suspense 集成后,可以让不同 Boundary 在资源就绪时逐步 Hydrate,并优先处理用户正在交互的区域。
9.1 与 Streaming SSR 的协作
Server Streaming 决定 HTML 何时到达,Selective Hydration 决定客户端区域何时激活。两者互补但不是同一阶段。
9.2 Boundary 粒度
适合独立 Boundary 的区域通常具备:
- 独立的 Loading/Error 语义;
- 独立 Code Chunk 或数据依赖;
- 用户可能先与它交互;
- Fallback 能保持稳定布局;
- Hydration 失败时可以局部恢复。
Boundary 太大时慢资源阻塞大片交互;太碎时增加 Fallback 噪声、协议标记、调度和测试组合。Boundary 应对应产品 Reveal 与故障隔离边界,而不是机械包裹每个组件。
9.3 Selective Hydration 不等于 Partial Hydration
- Selective Hydration:同一 Client React Tree 的区域以不同顺序和时间 Hydrate,通常最终都会激活;
- RSC Client Boundary:只有需要客户端状态/事件的模块进入 Client Graph,可减少需要 Hydrate 的代码;
- Islands Architecture:多个独立交互岛按 Framework 协议 Hydrate;
- Resumability:恢复已序列化执行状态,属于另一类框架模型。
不能因为使用 Suspense 就宣称“未交互组件完全没有发送 JavaScript”。应检查真实 Client Manifest、Chunk 和执行 Trace。
十、Event Replay:不是无限可靠的事件队列
当用户在某个尚未 Hydrate 的 React 区域上交互时,React 可能拦截受支持事件、提高目标 Boundary 的 Hydration 优先级,并在能够安全处理后 Replay。
10.1 Replay 的必要条件
- Client Bootstrap 已下载并执行,
hydrateRoot已建立 React Root Event System; - 事件类型受当前 React DOM 事件系统支持;
- 目标 DOM 与 Server/Client Tree 仍能匹配;
- 目标 Boundary 的代码和数据最终能够准备好;
- Hydration 没有因错误或 Mismatch 替换目标节点。
如果用户在 JavaScript 尚未加载时点击,React 根本没有运行,不能捕获该事件。浏览器默认行为、Native Listener、第三方脚本和不同事件类型也不受同一 Replay 保证。
10.2 不要把业务正确性押在 Replay 上
关键交互应尽可能具备 Progressive Enhancement:
- 导航使用真实
<a href>,没有 JavaScript 时仍可跳转; - 表单提供有效
action或 Framework Server Action/Fallback; - 不把
<div onClick>伪装成按钮或链接; - 防止 Hydration 前后重复提交,服务端 Mutation 使用幂等键;
- 购买、支付、权限操作最终以服务端验证为准;
- 测试 JavaScript 慢加载、加载失败和用户快速重复点击。
Event Replay 改善了过渡窗口体验,不是 Durable Queue、Exactly-once Delivery 或事务协议。
10.3 输入控件的特殊风险
用户可能在 Hydration 前向 Input 输入内容。若发生 Mismatch、Root Client Render 或代码在 Effect 中重置 State,真实 DOM Value 可能被覆盖。
表单应避免 Hydration 后无条件从旧 Server Snapshot 重置用户输入,并测试 Autofill、Password Manager、IME、快速提交和 Back/Forward Cache。
十一、外部 Store 与请求缓存的 Hydration 协议
11.1 useSyncExternalStore 的 Server Snapshot
const online = useSyncExternalStore(
subscribeToOnlineStatus,
getOnlineSnapshot,
getServerOnlineSnapshot,
);getServerSnapshot 不只在 Server Render 时调用,也用于客户端 Hydration 的初始 Snapshot。它必须与生成 Server HTML 时使用的值一致;Hydration 后 React 再读取 getSnapshot 并更新真实客户端状态。
例如服务端以“Online”输出,客户端第一次 Hydration 也应使用同一 Server Snapshot,而不是立刻读取 navigator.onLine 生成不同文本。
11.2 Query Cache
SSR Query Hydration 应保证:
- Server 与 Client Query Key 完全一致;
- Dehydrated Data 经过安全序列化和 Schema Validation;
- 每个请求创建隔离 Query Client;
- 时间戳与 Stale Time 语义明确;
- Hydration 后不会无条件重复 Fetch 并覆盖更新数据;
- Error、Infinite Query、Pagination 和 Mutation State 有明确边界;
- 用户私有 Cache 不进入共享 HTML 或跨请求内存。
React Hydration、TanStack Query 的 HydrationBoundary 和状态库 Persistence 都使用“Hydration”一词,但它们分别接管 DOM、Query Cache 和 Store Snapshot,不能混为同一过程。
十二、suppressHydrationWarning:有限逃生口,不是修复方案
React 允许对确实不可避免的单层文本或属性差异抑制警告:
<time suppressHydrationWarning>{currentTimeLabel}</time>它的边界是:
- 只影响当前 Element 的一层,不是整棵子树;
- 只抑制 Warning,不证明 Server/Client 语义一致;
- React 文档明确不会尝试修补这类不匹配文本;
- 不能修复无效 HTML、节点数量、组件树或事件目标差异;
- 可能掩盖真正的数据泄漏、时区错误或缓存版本问题。
更优先的方案是:传同一 Snapshot、传明确 Locale/TimeZone、Hydration 后更新,或把无法服务端表达的功能隔离为稳定 Client Boundary。
适合使用的少数场景包括受外部系统控制、确实无法同步且允许短暂不同的时间戳。即使使用,也应添加注释、测试和监控,避免范围扩散。
十三、Hydration 错误与恢复边界
13.1 Suspense Boundary 可以限制部分恢复范围
Streaming/Selective Hydration 中,某个 Boundary 的代码或数据失败时,Framework 和 React 可以在边界内展示 Fallback/Error UI,其他已完成区域继续工作。
但 Suspense 只处理等待,不捕获最终 Reject Error;仍需要 Error Boundary 或 Framework Route Error Boundary。
13.2 Root 外错误代价更大
若错误发生在可隔离 Boundary 之外,React 可能需要放弃整个 Root 的 Server Markup 并 Client Render。结果包括:
- 已显示内容闪烁或重建;
- 用户输入和 Selection 丢失;
- DOM/Scroll/Focus 变化;
- Client CPU 突增;
- LCP/CLS/INP 与错误率恶化。
Shell 中的 Provider、Router、Locale 和全局 Store 初始化必须尽量纯净、可重复、可观测。
13.3 第三方 DOM 修改
翻译插件、密码管理器、A/B SDK、浏览器扩展和广告脚本可能在 Hydration 前修改 DOM。应:
- 延后非关键第三方脚本到 Hydration 或 Consent 后;
- 避免第三方修改 React Root 内受控节点;
- 为必须托管外部 DOM 的区域使用明确 Portal/Container Ownership;
- 在错误日志中记录 DOM Mutation 线索,但不要收集敏感内容;
- 用干净浏览器 Profile 与真实用户环境分别复现。
十四、Hydration 性能:测量“看到”到“能用”的间隙
14.1 Hydration 成本来源
- Client JavaScript Transfer、Parse、Compile、Execute;
- 组件函数和 Hook 的初始 Render;
- Context、外部 Store 和 Query Cache 恢复;
- Event System、Ref 与 Effect Setup;
- DOM Match、必要 Mutation 与 Layout;
- 第三方脚本竞争主线程;
- 立即在 Effect 中触发的第二次同步/异步更新;
- Suspense Boundary Code/Data Waterfall。
14.2 没有单一通用的“Hydration 完成时间”
hydrateRoot 返回不代表完成;Root useEffect 运行只说明该已提交子树进入 Passive Effect,并不证明所有延迟 Suspense Boundary 都已 Hydrate。
应根据产品定义多个里程碑:
| Marker | 含义 |
|---|---|
| Shell Hydrated | 导航和基础布局事件可用 |
| Critical Form Ready | 首屏核心表单可输入和提交 |
| Boundary Ready | 某个延迟区域完成 Hydration |
| Route Interactive | 目标 Journey 可及时完成 |
这些 Marker 应通过 Framework Instrumentation、业务 Effect、User Timing 和真实交互验证组合实现,并清楚说明覆盖范围。
14.3 优化顺序
- 删除不需要到客户端的代码,或在后续 RSC/Framework 边界中留在服务端;
- 按 Route/Feature 拆分 Client JavaScript;
- 让关键 Client Chunk 和数据更早发现,避免 Waterfall;
- 将独立慢区域放入合理 Suspense Boundary;
- 避免 Root 级 Provider 变化让大树重复 Render;
- 避免 Hydration Effect 立即重建整页 State;
- 延迟非关键第三方脚本;
- 在目标低端设备和生产构建上验证 LCP、Long Task 与 INP。
不能通过隐藏 Server HTML 直到 Hydration 完成来“解决闪烁”。这通常会牺牲 SSR 的内容可见收益,并掩盖真正的 Mismatch。
十五、工程案例:商品页首屏可见但交互不稳定
假设商品页出现以下问题:
- Server 使用 UTC 输出促销结束时间,Client 使用浏览器时区;
- 推荐卡片在 Render 中
Math.random()排序; - 购物车角标从
localStorage初始化; - 评论区代码很大,与购买按钮共用一个 Suspense Boundary;
- A/B SDK 在 Hydration 前修改 CTA 文本;
- 用户快速点击购买时偶发无响应或重复提交。
15.1 根因图
15.2 修复顺序
- 服务端传
iso + locale + timeZone,Client 首次使用同一值; - 推荐排序由服务端生成稳定 ID 顺序并随 Snapshot 传输;
- 购物车状态优先由 Cookie/Server Session 提供,或先 Hydrate 稳定角标再同步 Storage;
- 购买区域进入关键 Shell,评论使用独立 Boundary 与 Chunk;
- A/B Assignment 在服务端完成,或让 SDK 在 Hydration 后通过 React State 更新;
- 购买表单提供 Server Mutation/Progressive Fallback 和 Idempotency Key;
- 接入
onRecoverableError并按 Route、Release、Browser 聚合; - 在慢 JavaScript、6x CPU Throttling 和快速重复点击下回归。
15.3 验证护栏
- Server 与 Client 商品 ID、价格、实验版本一致;
- Hydration Error 率下降;
- 用户输入、Focus、Scroll 不被恢复流程重置;
- LCP、CLS、INP 和购买成功率不恶化;
- 评论延迟不会阻塞购买区域;
- JavaScript 失败时仍可导航、查看商品和提交可降级表单。
十六、常见误区与错误案例
16.1 Hydration 只是添加事件监听器
错误。客户端仍需执行组件和 Hook、重建 React Tree、匹配现有 DOM,并恢复 Context、State、Ref、Effect 和后续更新语义。
16.2 Server HTML 与 Client 不同也没关系,React 会修复
错误。React 不保证修复所有 Mismatch,严重时会转为局部或整 Root Client Render,并可能产生输入丢失和性能回退。
16.3 typeof window !== 'undefined' 就能解决 SSR 问题
错误。它能避免服务端访问 window,但若两端第一次 Render 走不同分支,仍会 Mismatch。
16.4 suppressHydrationWarning 可以包住整个动态组件
错误。它是单层、有限逃生口,只抑制部分警告,不修复树结构、状态和事件语义。
16.5 Selective Hydration 表示未点击区域不会下载 JavaScript
错误。Selective Hydration 主要调度激活顺序。是否发送 Client JavaScript 由 Client Boundary、Code Splitting、RSC 或 Framework 架构决定。
16.6 Event Replay 保证点击绝不丢失
错误。JavaScript 未启动、事件不受支持、DOM 被替换、默认行为或 Hydration 失败都可能超出 Replay 边界。关键 Mutation 仍需幂等和 Progressive Enhancement。
16.7 useEffect 执行就代表整页 Hydration 完成
错误。某个 Effect 只说明对应已提交子树进入 Effect 生命周期;其他 Suspense Boundary 可能仍未加载或 Hydrate。
16.8 Mismatch 只在开发环境有影响
错误。开发环境通常提供更多 Warning,但生产环境仍需恢复不一致 DOM,并可能产生 Client Render、视觉跳变和事件问题。
16.9 SSR Store 可以使用模块级用户单例
危险。并发 Request 会共享数据并可能泄漏用户状态。Server Store、Query Client 和缓存上下文应按请求隔离。
16.10 Hydration 越快,LCP 就一定越好
不一定。LCP 可能在 Hydration 前完成;减少 Hydration 更常改善主线程和交互,但具体影响需看 LCP Element、资源、Render Delay 与竞争关系。
十七、测试与验证方法
17.1 开发环境根因检查
- 保留 React Hydration Warning,不使用全局 Console Filter 隐藏;
- 检查 Warning 中的 Component Stack 和 Diff;
- 固定 Time、Locale、TimeZone、Random Seed 和测试数据复现;
- 使用 HTML Validator 检查嵌套;
- 对比干净浏览器与安装扩展环境;
- 检查 Server/Client Build 是否来自同一 Release。
Strict Mode 的开发检查可能让 Render/Effect 更容易暴露不纯逻辑,但生产执行次数不能按开发现象推断。
17.2 浏览器 Integration Test
- 拦截 Client JavaScript,先断言 Server HTML 关键内容;
- 延迟 JavaScript,观察未 Hydrate 状态和 Progressive Enhancement;
- 在脚本到达前点击链接、输入表单、提交关键动作;
- 恢复脚本,确认输入、Focus、Selection 和 Scroll;
- 监听
onRecoverableError测试出口; - 模拟 Chunk 404、数据 Reject 和 Boundary Error;
- 验证 Hydration 后 Client Navigation 与 Back/Forward Cache。
JSDOM 可以覆盖部分结构测试,但不能替代真实 HTML Parser、浏览器事件、Streaming、Layout 和性能行为。
17.3 性能测试
至少记录:
- HTML TTFB、FCP、LCP;
- Client JS Transfer、Parse/Execute;
- Long Task 与主线程占用;
- Shell/Critical Boundary Ready Marker;
- 用户首次交互的 Event Timing 与 INP;
- Hydration 前后的 CLS;
- Recoverable/Uncaught Error Rate;
- 低端设备、弱网和 Cold Cache 分位数。
React Profiler 可帮助观察组件 Render/Commit,但不能单独证明浏览器何时 Paint、事件等待多久或所有 Boundary 何时 Hydrate。应与 Browser Performance Trace、Network、User Timing 和 RUM 联合分析。
17.4 不要机械比较整页 innerHTML
浏览器会规范化 Attribute、解析 HTML,Streaming 会逐步插入内容,Hydration 后合法 Effect 也可能更新 DOM。测试应断言稳定业务结构、文本、属性、Error Callback 和用户行为,而不是把整页字符串差异当作唯一证据。
十八、发布前检查清单
Server 与 Client 输入
- Server HTML 与 Client 首次 Props/Context 是否来自同一 Snapshot;
- Locale、TimeZone、Feature Flag 和 Experiment 是否一致;
- Query Cache/Store 是否按 Request 隔离;
- Payload 是否安全转义、校验并移除敏感字段;
- 多 Root
identifierPrefix是否一致。
组件输出
- Render 中是否读取当前时间、随机数或 Browser-only API;
- HTML 嵌套是否有效;
useId是否只用于正确 DOM Identity 场景;- Client-only 区域是否提供稳定 Fallback;
suppressHydrationWarning是否最小范围且有原因说明。
Suspense 与交互
- Boundary 是否对应独立 Reveal、代码和错误边界;
- 关键导航和表单是否支持 Progressive Enhancement;
- Hydration 前快速交互是否测试;
- Chunk/Data 失败是否局部恢复;
- 用户输入、Focus 和 Selection 是否不会被重置。
可观测性与性能
onRecoverableError、Caught 和 Uncaught Error 是否接入;- 日志是否包含 Release/Route 且不泄漏 PII;
- 是否区分 Shell Ready、Boundary Ready 和业务 Interactive;
- 是否在生产构建、目标设备、Cold Cache 下测试;
- LCP、CLS、INP、错误率与业务转化是否共同验证。
十九、总结
Hydration 的核心不是“让静态 HTML 动起来”,而是让两次独立执行在同一 Snapshot 上达成一致:
- 服务端先根据组件树和数据生成 HTML,浏览器可在 JavaScript 完成前显示内容。
- 客户端通过
hydrateRoot重新计算同一初始树,并复用已存在 DOM。 - 时间、随机数、Locale、Browser API、外部 Store 和数据重取都必须服从确定性首次输出。
- Mismatch 是数据与渲染契约被破坏的信号,不能靠 Warning Suppression 普遍掩盖。
- Selective Hydration 通过 Suspense Boundary 调整激活顺序,但不会自动减少 Client JavaScript。
- Event Replay 只改善部分 Hydration 过渡事件,关键业务仍需真实链接、可降级表单、服务端授权和幂等协议。
useId、getServerSnapshot、Query Dehydration 和identifierPrefix都是在维持 Server/Client 身份与 Snapshot 一致。- Hydration 性能需要同时控制 JavaScript、组件 Render、Effect、第三方脚本和 Boundary Waterfall。
- “Hydration 完成”应按 Shell、关键区域和业务 Journey 分层定义,而不是寻找不存在的全局 Promise。
- 最终验证必须覆盖内容可见、首次交互、错误恢复、用户输入保留和真实 Field 指标。
真正稳定的 SSR 页面不是从不变化,而是让 Server HTML 与 Client 首次 Render 完全可解释地一致,再把浏览器差异和实时更新放到明确的 Hydration 后阶段。
问答复盘
Q1:hydrateRoot 与 createRoot 的核心区别是什么?
答: hydrateRoot 接管服务端已生成的 React DOM,并要求客户端初始树匹配;createRoot 从普通或空容器进行 Client Mount,不承担 Server Markup Hydration 契约。
Q2:为什么 Server HTML 已经显示,按钮仍可能暂时不能可靠响应?
答: HTML 可先被浏览器 Paint,但按钮对应的 Client JavaScript、Boundary 和事件处理可能尚未 Hydrate。可见与可交互是不同里程碑。
Q3:typeof window !== 'undefined' 为什么仍会导致 Mismatch?
答: 它只阻止服务端访问 window。若服务端输出 Fallback,而客户端第一次 Render 立刻输出 Browser Value,两端结构或文本仍不同。
Q4:suppressHydrationWarning 是否会让 React 自动修复动态时间文本?
答: 不会。它只抑制有限单层警告;React 文档说明不会尝试修补该不匹配文本。应优先传输同一时间 Snapshot,或在 Hydration 后更新。
Q5:Selective Hydration 与 React Server Components 的核心差异是什么?
答: Selective Hydration 调整 Client Tree 各 Boundary 的激活时间和优先级;RSC Boundary 决定哪些组件代码留在服务端、哪些进入 Client Graph。前者不自动减少需要发送的客户端代码。
Q6:React Event Replay 能否替代提交接口的幂等设计?
答: 不能。Replay 不是 Exactly-once Queue,JavaScript 未启动、Hydration 失败或默认浏览器行为都可能超出其边界。Mutation 必须在服务端使用幂等键和权限校验。
Q7:使用 useSyncExternalStore 时,getServerSnapshot 为什么也影响客户端?
答: 客户端 Hydration 的首次读取会使用 Server Snapshot,以匹配 Server HTML;Hydration 后才通过 getSnapshot 读取真实客户端状态。
Q8:Root 组件的 useEffect 已执行,是否可以上报整页 Hydration 完成?
答: 不可以直接这样定义。它只说明 Root 已提交部分进入 Passive Effect;延迟 Suspense Boundary 可能仍未 Hydrate。应分别定义 Shell、关键 Boundary 和业务 Interactive Marker。
Q9:Hydration Mismatch 最危险的工程后果是什么?
答: 不只是 Console Warning。React 可能放弃局部或整个 Root 的 Hydration,并改用 Client Render,导致内容闪烁、输入丢失、事件异常和主线程成本增加。
Q10:如何验证 Hydration 优化真正改善了用户体验?
答: 在生产构建和目标设备上同时比较 Client JS、Long Task、Shell/Boundary Ready、首次交互 Event Timing、INP、Mismatch Error、输入保留和业务结果,并通过 Field 分群确认改善。
延伸知识
- React Server Components:Server/Client Boundary、Flight Payload 与 Client Graph;
- Streaming SSR:Shell、Suspense Segment、状态码与断连;
- Next.js App Router:Hydration、RSC Cache、Dynamic Rendering 与 Client Component;
- External Store:
useSyncExternalStore、getServerSnapshot与 Tearing; - Server State:Query Dehydrate/Hydrate、Stale Time 与请求隔离;
- 浏览器表单:Autofill、Password Manager、IME 与 Progressive Enhancement;
- 安全:Safe Serialization、CSP、Trusted Types 与日志脱敏;
- 性能:Long Task、INP Attribution、Server-Timing 与 Hydration RUM。