React 基础体系 · 第 62/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 水合诊断:不一致、事件绑定、客户端边界和调试

服务端渲染(Server-Side Rendering,SSR)解决的是“首屏 HTML 从哪里来”的问题;水合(hydration)解决的是“这段已经存在的 HTML 如何变成可交互的 React 应用”。

二者不是同一个阶段:

  1. 服务端执行 React 组件,生成 HTML 字符串或流。
  2. 浏览器解析 HTML,并先展示静态内容。
  3. 客户端加载 JavaScript。
  4. hydrateRoot 根据同一棵 React 树接管已有 DOM。
  5. React 恢复组件状态、建立事件处理逻辑,并继续进行客户端更新。

因此,水合不是重新生成一份 HTML 再简单替换,而是一次“用客户端 React 树验证并接管服务端 DOM”的过程。服务端输出和客户端首次渲染之间如果不一致,React 就无法可靠地判断哪些 DOM 可以复用、哪些 DOM 需要修正,以及事件应该绑定到哪里。


一、水合的基本模型:同一棵树必须产生兼容的初始结果

设:

  • SS 表示服务端渲染函数;
  • CC 表示客户端首次渲染函数;
  • DD 表示请求对应的数据和环境输入;
  • HH 表示服务端生成并交给浏览器的 HTML;
  • TT 表示客户端 React 首次渲染期望的树。

理想条件可以写成:

H=S(D)H = S(D)

T=C(D)T = C(D)

水合要求的不是 JavaScript 函数引用相同,而是:

DOMShape(H)DOMShape(T)\operatorname{DOMShape}(H) \approx \operatorname{DOMShape}(T)

其中“兼容”至少包括:

  • 元素层级相同;
  • 元素类型相同,例如 button 不能变成 a
  • 文本内容相同;
  • 关键属性和属性值一致;
  • 列表项顺序一致;
  • React 用于定位节点的结构信息一致。

事件处理器本身不会出现在服务端 HTML 中,因此不能要求 HTML 中出现某个函数。但客户端首次渲染必须能够为服务端已有节点建立相同的交互语义。

一个最小的 SSR 与水合结构如下:

// App.tsx
export function App() {
  return (
    <main>
      <h1>Hello</h1>
      <button type="button" onClick={() => alert("clicked")}>
        Click
      </button>
    </main>
  );
}
// server.tsx
import { renderToString } from "react-dom/server";
import { App } from "./App";

const html = renderToString(<App />);

// html 可被嵌入完整 HTML 文档的 #root 中
console.log(html);
// client.tsx
import { hydrateRoot } from "react-dom/client";
import { App } from "./App";

const rootElement = document.getElementById("root");

if (!rootElement) {
  throw new Error("Missing #root");
}

hydrateRoot(rootElement, <App />);

服务端可能输出近似如下的 HTML:

<main>
  <h1>Hello</h1>
  <button type="button">Click</button>
</main>

onClick 不会被序列化到 HTML 中。水合时,客户端 React 重新执行 <App />,看到这个 button 应该具有 onClick,于是建立事件处理逻辑。

这里的“相同”是初始渲染结果的相同,而不是后续每次渲染都必须相同。点击按钮后显示计数器、请求数据或改变主题,都是水合完成后的正常客户端更新。


二、什么是不一致:不是所有差异都具有相同后果

水合不一致(hydration mismatch)是指服务端产生的初始 DOM 与客户端首次渲染期望的结构或内容不一致。

例如:

export function Clock() {
  return <p>{new Date().toLocaleTimeString()}</p>;
}

假设服务端在 10:00:00 渲染:

<p>10:00:00</p>

浏览器稍后执行客户端代码时,可能得到:

<p>10:00:01</p>

服务端和客户端调用的是同一段源码,但输入环境已经不同,所以:

S(Dserver,t1)C(Dbrowser,t2)S(D_{\text{server}}, t_1) \ne C(D_{\text{browser}}, t_2)

这就是不一致。问题不在 Date API 本身,而在于把一个随时间变化的值放进了必须一致的首次渲染结果。

2.1 常见不一致来源

时间、随机数和环境状态

以下代码都可能在两次渲染之间产生不同结果:

function RandomLabel() {
  return <span>{Math.random()}</span>;
}
function TimeLabel() {
  return <span>{Date.now()}</span>;
}
function ThemeLabel() {
  return <span>{window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"}</span>;
}

前两个值在服务端和浏览器中天然不同;第三个值依赖浏览器 API,服务端通常没有 window,即使通过条件判断避免了异常,也可能因为服务端和客户端初始结果不同而触发不一致。

本地化格式

function Price({ amount }: { amount: number }) {
  return <span>{amount.toLocaleString()}</span>;
}

数字格式可能受语言、时区、运行时 ICU 数据影响。服务端运行在 en-US 环境,浏览器运行在 zh-CN 环境时,可能分别输出:

1,234.56
1,234.56

也可能在日期、货币、小数位上产生明显差异。需要显式指定格式化环境:

function Price({ amount }: { amount: number }) {
  const text = new Intl.NumberFormat("zh-CN", {
    style: "currency",
    currency: "CNY",
  }).format(amount);

  return <span>{text}</span>;
}

如果服务端和客户端都使用同样的 locale、currency 和输入数据,结果才具有可预测性。生产系统还应确认 Node 运行时的 ICU 能力与目标浏览器行为足够一致。

数据读取时机不同

下面的代码假设服务端和客户端都能同步得到同一份数据:

function UserName() {
  const user = readCurrentUserSynchronously();
  return <p>{user.name}</p>;
}

但实际应用经常出现:

  1. 服务端请求期间有用户信息;
  2. HTML 已经包含用户名称;
  3. 客户端初始化时缓存还未恢复;
  4. 客户端首次渲染成“登录”或“Loading”;
  5. 水合发现文本不同。

更可靠的方式是把决定首屏的数据作为服务端结果传给客户端,并确保客户端第一次渲染使用同一份快照:

type User = {
  id: string;
  name: string;
};

export function UserName({ initialUser }: { initialUser: User | null }) {
  if (!initialUser) {
    return <p>未登录</p>;
  }

  return <p>{initialUser.name}</p>;
}

关键不只是“传 props”,而是客户端水合前不能用另一份尚未同步的数据覆盖 initialUser


三、React 对不一致的处理:警告、恢复和代价

在开发环境中,React 通常会报告类似“服务端 HTML 与客户端属性或文本不匹配”的信息。React 可能尝试恢复,但不能把它理解为“React 会自动修好所有差异”。

水合有几个重要事实:

  1. 小范围文本差异有时可以被修正。
  2. 属性差异不应依赖 React 自动修正。
  3. 结构差异可能导致局部放弃水合并改为客户端重新渲染。
  4. 复杂差异会增加主线程工作,并可能丢失服务端已经创建的 DOM。
  5. 错误可能在开发环境中明确显示,生产环境则通过错误回调或监控发现。

React 19 的 hydrateRoot 可以接收错误回调:

import { hydrateRoot } from "react-dom/client";
import { App } from "./App";

const container = document.getElementById("root");

if (!container) {
  throw new Error("Missing #root");
}

hydrateRoot(container, <App />, {
  onRecoverableError(error, errorInfo) {
    console.error("Recoverable hydration error:", error);
    console.error("Component stack:", errorInfo.componentStack);
  },
  onUncaughtError(error, errorInfo) {
    console.error("Uncaught root error:", error);
    console.error("Component stack:", errorInfo.componentStack);
  },
  onCaughtError(error, errorInfo) {
    console.error("Caught by Error Boundary:", error);
    console.error("Component stack:", errorInfo.componentStack);
  },
});

这些回调的含义不同:

  • onRecoverableError:React 认为可以继续运行的错误,例如部分水合问题。
  • onUncaughtError:错误没有被错误边界捕获。
  • onCaughtError:错误被错误边界捕获。

具体错误对象和附加信息应以当前 React 版本为准;生产环境上报时通常应带上 URL、构建版本、用户代理和组件栈,但要避免把用户隐私或完整页面数据发送到日志系统。


四、完整算例:从错误的时间渲染到可水合实现

4.1 错误实现

export function LastUpdated() {
  return (
    <time dateTime={new Date().toISOString()}>
      {new Date().toLocaleString()}
    </time>
  );
}

这里至少有两个问题:

  1. new Date() 被调用了两次,甚至同一次渲染内部也可能跨过秒或毫秒边界;
  2. toLocaleString() 的时区和语言可能不同。

假设服务端输出:

<time datetime="2025-01-01T00:00:00.100Z">
  2025/1/1 08:00:00
</time>

客户端首次渲染输出:

<time datetime="2025-01-01T00:00:01.100Z">
  2025/1/1 08:00:01
</time>

两个文本值和 dateTime 属性都不同。

4.2 方案一:固定输入,首屏之后再更新

import { useEffect, useState } from "react";

type LastUpdatedProps = {
  timestamp: string;
};

export function LastUpdated({ timestamp }: LastUpdatedProps) {
  const [display, setDisplay] = useState(timestamp);

  useEffect(() => {
    setDisplay(new Date(timestamp).toLocaleString("zh-CN"));
  }, [timestamp]);

  return (
    <time dateTime={timestamp}>
      {display}
    </time>
  );
}

服务端和客户端首次都使用传入的 timestampuseEffect 不会在服务端执行,因此浏览器完成水合后才把文本转换为本地显示格式。

这个方案的因果关系是:

  1. timestamp 是稳定输入;
  2. 首次渲染只使用稳定输入;
  3. 浏览器专属的显示转换放入 effect;
  4. 水合完成后才产生环境相关的差异。

代价是用户可能先看到一个机器可读或统一格式的值,然后看到本地化格式;如果这个变化明显,应该设计成可接受的渐进显示,而不是把本地化结果直接塞进首次渲染。

4.3 方案二:显式固定格式,服务端和客户端共同使用

const formatter = new Intl.DateTimeFormat("zh-CN", {
  timeZone: "Asia/Shanghai",
  dateStyle: "medium",
  timeStyle: "short",
});

type LastUpdatedProps = {
  timestamp: string;
};

export function LastUpdated({ timestamp }: LastUpdatedProps) {
  const date = new Date(timestamp);

  return (
    <time dateTime={timestamp}>
      {formatter.format(date)}
    </time>
  );
}

这个方案要求服务端和客户端对时区、语言及格式选项使用相同配置。它适合首屏必须直接显示最终格式的场景,但不能假设所有运行时的国际化实现细节都完全一致。需要在目标 Node 运行时和浏览器中测试。

4.4 方案三:只对已知且可接受的单个差异使用抑制

export function ApproximateTime() {
  return (
    <time suppressHydrationWarning>
      {new Date().toLocaleString()}
    </time>
  );
}

suppressHydrationWarning 是一个有限的逃生口,不是让组件“跳过水合”的开关。它主要用于已知的、局部的文本或属性差异;它只抑制有限层级的警告,也不会替你解决事件、结构和数据一致性问题。

如果一个组件大量依赖浏览器状态,不应给最外层容器加上这个属性来掩盖问题,而应重新设计首次渲染输入或将浏览器相关逻辑延后。


五、useEffect 为什么能避免一部分问题,但不是通用修复

以下写法通常是安全的:

import { useEffect, useState } from "react";

export function ClientOnlyValue() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    setReady(true);
  }, []);

  return <p>{ready ? window.innerWidth : "正在读取窗口宽度…"}</p>;
}

服务端首次渲染:

<p>正在读取窗口宽度…</p>

客户端水合前的首次渲染也必须是:

<p>正在读取窗口宽度…</p>

水合完成后,effect 执行,组件再读取 window.innerWidth

但这个方法有明确代价:

  • 真实内容要等到水合和 effect 完成后才出现;
  • 页面会经历一次占位内容到真实内容的切换;
  • 如果很多组件都采用这种方式,SSR 的首屏价值会被削弱;
  • 它不能解决服务端和客户端使用不同初始数据的问题,除非初始状态确实相同。

因此,useEffect 适用于“必须等待浏览器 API”的逻辑,不适用于掩盖本应在服务端和客户端共享的数据模型错误。


六、浏览器 HTML 解析也会制造不一致

不一致不一定来自 React 代码中的条件判断。浏览器会先把服务端字符串解析成 DOM,而 HTML 解析器会修正非法嵌套。

例如:

export function InvalidMarkup() {
  return (
    <p>
      文本
      <div>块级元素</div>
    </p>
  );
}

开发者可能以为结构是:

p
└── div

但浏览器对 <p><div> 的处理会自动关闭段落,实际 DOM 可能与 React 生成树不同。React 水合时看到的不是服务端字符串本身,而是浏览器已经修正后的 DOM。

这类问题的诊断方法是:

  1. 查看服务端返回的原始响应;
  2. 查看 Elements 面板中的实际 DOM;
  3. 对照 React 组件树;
  4. 检查 <p>tabletbody、交互元素嵌套等 HTML 规则;
  5. 使用 HTML 校验工具或框架提供的开发警告。

“服务端字符串看起来正确”不能证明“浏览器解析出的 DOM 正确”。


七、事件绑定:HTML 没有事件,水合负责恢复交互

7.1 服务端不会序列化函数

下面的组件:

function SubmitButton() {
  function handleSubmit() {
    console.log("submitted");
  }

  return (
    <button type="button" onClick={handleSubmit}>
      提交
    </button>
  );
}

服务端只能输出类似:

<button type="button">提交</button>

函数 handleSubmit 不可能直接放进 HTML。浏览器接收到 HTML 后,按钮只是一个普通的原生按钮;只有客户端 JavaScript 加载并完成水合,React 才能使其进入 React 的事件处理流程。

所以,下面两种状态必须区分:

  • HTML 已展示,但 JavaScript 尚未加载:按钮可能存在,但 React 交互尚未建立;
  • 水合完成:按钮事件处理器已可用,React 状态更新可以工作。

SSR 提供的是可见性和部分原生能力,不等于应用已经交互就绪。

7.2 事件处理器的“相同”指语义,不是函数引用

服务端和客户端不可能共享同一个函数对象。水合关注的是客户端树中该节点是否具有相应事件处理逻辑,例如:

<button onClick={enabled ? handleClick : undefined}>
  操作
</button>

如果 enabled 在服务端为 true、客户端首次渲染为 false,那么 React 面对的是同一个按钮但不同的交互语义。即便视觉上的 HTML 没有明显变化,也可能产生事件绑定不一致。

应确保影响事件处理器存在与否的条件,在服务端和客户端首次渲染时一致:

type Props = {
  enabled: boolean;
};

export function ActionButton({ enabled }: Props) {
  return (
    <button type="button" disabled={!enabled} onClick={enabled ? doAction : undefined}>
      操作
    </button>
  );
}

function doAction() {
  console.log("action");
}

这里 enabled 来自稳定的服务端数据,而不是在组件初始化时读取 localStoragewindow

7.3 React 事件代理不应当被当作业务契约

React 通常采用事件委托和统一事件系统来处理常见事件,但应用不应该依赖某个具体 DOM 节点上是否能看到原生 onclick 属性,也不应通过检查 element.onclick 判断 React 事件是否已绑定。

正确的验证方式是测试行为:

import { fireEvent, render, screen } from "@testing-library/react";
import { vi } from "vitest";

it("click invokes action", () => {
  const action = vi.fn();

  render(
    <button type="button" onClick={action}>
      保存
    </button>,
  );

  fireEvent.click(screen.getByRole("button", { name: "保存" }));
  expect(action).toHaveBeenCalledTimes(1);
});

这段测试是客户端渲染测试,不等于水合测试。水合问题还需要把服务端生成的 HTML 放进 DOM,再调用 hydrateRoot,并检查控制台、错误回调和交互行为。

7.4 水合期间的事件与延迟交互

在大型应用中,HTML 可能先到达,而对应的 JavaScript chunk 尚未加载。React 和框架可以围绕 Suspense、流式渲染以及选择性水合安排优先级;某些事件可能被延迟或重放,直到相关边界可以水合。

这不是“所有点击都会永久排队”的保证。实际行为受以下因素影响:

  • 事件类型;
  • 目标节点是否处于尚未水合的 Suspense 边界;
  • 相关代码是否已经加载;
  • 使用的框架和 bundler;
  • 错误是否阻止了边界恢复。

因此,关键提交动作不能只依赖客户端事件。表单应尽量保留原生语义,服务端也应验证请求;客户端增强失败时,至少要有明确的不可用状态或可恢复路径。


八、客户端边界:它不是“这里绝不会在服务端运行”

“客户端边界”通常指一个模块或组件从服务端执行范围进入客户端 JavaScript 执行范围的边界。需要区分三种概念。

8.1 React 核心中的客户端入口

React DOM 提供:

import { createRoot, hydrateRoot } from "react-dom/client";
  • createRoot:容器通常是空的,由客户端创建 DOM;
  • hydrateRoot:容器已经有服务端 HTML,客户端尝试接管它。

这两个 API 是客户端入口,但 React 核心并没有规定一个通用的 "use client" 文件指令。

8.2 RSC 框架中的 "use client"

在支持 React Server Components(RSC)的框架中,文件顶部的:

"use client";

import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button type="button" onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

通常表示:从该模块开始,相关模块进入客户端组件图,客户端需要加载它们以提供状态和交互。

但它不应被理解为:

  • 该组件一定不会在服务端参与 HTML 生成;
  • 该组件可以无条件访问 window
  • 所有导入它的代码都自动变成可序列化;
  • "use client" 是 React DOM 的通用 SSR API。

在许多 RSC 框架中,客户端组件仍可能被预渲染为 HTML,然后在浏览器中水合。因此下面的代码仍然危险:

"use client";

export function Width() {
  return <span>{window.innerWidth}</span>;
}

如果该组件参与预渲染,服务端仍没有 window,或者服务端占位结果与客户端首次结果不一致。客户端边界解决的是“代码和交互归属”,不是自动解决“初始输出一致性”。

8.3 客户端边界的可传递性和 props 限制

服务端向客户端边界传递数据时,框架通常需要把 props 序列化或编码传输。可传输的数据一般包括字符串、数字、布尔值、数组、对象等受支持值;函数、类实例、连接对象、请求对象等通常不能直接作为客户端组件 props 传递。

错误示例:

<ClientButton onSave={() => saveToDatabase()} />

如果 ClientButton 是跨 RSC 边界的客户端组件,这个函数不能像普通同进程 React 树那样直接跨网络发送。常见替代方案是:

  • 客户端组件内部调用 API;
  • 使用框架支持的 server action 能力,但遵循该框架和 React 版本的明确约束;
  • 传递标识符或普通数据,而不是函数闭包。

边界的本质是执行环境和数据表示发生变化:服务端拥有数据库、文件系统和请求上下文;客户端拥有 DOM、用户事件和浏览器 API。跨边界的数据必须满足目标环境的表示规则。


九、服务端组件、客户端组件和水合边界的关系

可以用下面的流程表示常见的 RSC/SSR 应用:

flowchart TD
    A[请求进入服务端] --> B[服务端组件执行]
    B --> C[读取数据并生成 RSC 结果]
    B --> D[生成 HTML 或流式 HTML]
    C --> E[发送 RSC 数据与客户端组件引用]
    D --> F[浏览器展示静态 HTML]
    E --> G[浏览器加载客户端组件代码]
    G --> H[hydrateRoot 或框架内部水合]
    H --> I[恢复状态与事件]
    I --> J[后续客户端更新]

关键路径是:

  1. 服务端组件可以访问服务端资源,但不能直接使用浏览器交互 API。
  2. 客户端组件负责 useStateuseEffect 和事件处理。
  3. 客户端组件可能仍参与服务端预渲染,所以首次输出仍要考虑一致性。
  4. 水合完成后,客户端组件才真正具备持续交互能力。
  5. 服务端组件的后续刷新通常不是普通客户端 state 更新,而是由框架重新请求或刷新服务端结果。

“服务端组件”与“SSR 组件”不是同义词:SSR 描述渲染结果在哪里生成;RSC 描述组件代码和数据依赖如何划分。


十、Suspense、流式 HTML 与选择性水合

传统 SSR 可以等待整个组件树准备好后一次性输出 HTML;现代 React 服务端渲染也可以通过流式 API 分段发送结果,例如:

import { renderToPipeableStream } from "react-dom/server";
import { App } from "./App";

const { pipe, abort } = renderToPipeableStream(<App />, {
  onShellReady() {
    // HTTP 响应头设置完成后开始发送 shell
    pipe(response);
  },
  onError(error) {
    console.error(error);
  },
});

request.on("close", () => {
  abort();
});

这个 API 面向 Node 流式响应。Web Streams 环境使用相应的 Web Stream API;具体接入方式由运行时和框架决定。

流式渲染通常把页面分成:

  • 已经可以发送的 shell;
  • 被 Suspense 阻塞、稍后补上的内容;
  • 客户端 JavaScript 加载后需要水合的交互区域。

选择性水合意味着不是所有页面节点都必须同时完成客户端接管。用户先交互的区域可能获得更高优先级,其他区域稍后处理。它改善的是大型页面的启动组织方式,但不能消除不一致:每个水合边界仍必须有可匹配的服务端结果和客户端初始树。

错误路径也必须设计:

  • 服务端流式渲染失败时,需要决定是发送错误 shell、关闭响应,还是让客户端接管;
  • 某个 Suspense 边界加载失败时,需要显示 fallback 或错误边界;
  • 客户端 chunk 加载失败时,静态 HTML 可能仍可见,但交互无法恢复;
  • abort() 只是在请求关闭或超时等情况下停止服务端工作,不是业务错误处理的替代品。

十一、数据一致性的更严格处理:服务端快照和外部存储

对于窗口尺寸、在线状态、媒体查询等外部状态,直接在渲染函数中读取会导致服务端和客户端初始结果不同。React 提供的 useSyncExternalStore 可以表达“服务端快照”和“客户端快照”:

import { useSyncExternalStore } from "react";

function subscribe(onStoreChange: () => void) {
  window.addEventListener("online", onStoreChange);
  window.addEventListener("offline", onStoreChange);

  return () => {
    window.removeEventListener("online", onStoreChange);
    window.removeEventListener("offline", onStoreChange);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

function getServerSnapshot() {
  return true;
}

export function OnlineStatus() {
  const online = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );

  return <span>{online ? "在线" : "离线"}</span>;
}

这里:

  • getSnapshot 是浏览器中的当前值;
  • getServerSnapshot 是服务端渲染以及客户端水合初始阶段使用的值;
  • subscribe 在外部状态改变时通知 React。

如果实际请求上下文知道用户初始网络状态,getServerSnapshot 不应随意写成固定值,而应使用与服务端输出一致的初始快照。固定 true 只是示例,不能代表真实网络状态。


十二、useId 和多根应用的特殊一致性问题

React 的 useId 用于生成可关联的稳定 ID,例如表单标签和输入框:

import { useId } from "react";

export function EmailField() {
  const id = useId();

  return (
    <div>
      <label htmlFor={id}>邮箱</label>
      <input id={id} name="email" type="email" />
    </div>
  );
}

不要用 Math.random() 生成这种关联 ID:

function BadField() {
  const id = Math.random().toString(36);
  return <input id={id} />;
}

useId 生成机制会配合服务端和客户端树保持关联,适合 SSR。若一个页面存在多个独立 React 根,并且这些根可能生成 ID,需要使用一致且不同的 identifierPrefix

hydrateRoot(containerA, <AppA />, {
  identifierPrefix: "app-a-",
});

hydrateRoot(containerB, <AppB />, {
  identifierPrefix: "app-b-",
});

服务端生成对应 HTML 时也需要使用相同前缀。否则,多个根之间的 ID 可能冲突,导致 label、ARIA 属性或组件内部引用指向错误节点。


十三、错误实现与修复对照

13.1 在渲染阶段读取浏览器存储

错误:

function Greeting() {
  const name = localStorage.getItem("name") ?? "访客";
  return <p>你好,{name}</p>;
}

问题有两层:

  1. 服务端没有 localStorage
  2. 即便通过 typeof window !== "undefined" 避免异常,服务端可能输出“访客”,客户端首次输出真实姓名,仍然不一致。

修复方式之一:

import { useEffect, useState } from "react";

export function Greeting() {
  const [name, setName] = useState("访客");

  useEffect(() => {
    const storedName = window.localStorage.getItem("name");
    if (storedName) {
      setName(storedName);
    }
  }, []);

  return <p>你好,{name}</p>;
}

修复后的首屏统一为“访客”,水合完成后才读取浏览器存储。

更好的产品方案可能是把用户名称作为服务端请求上下文中的数据传入,这样首屏直接显示真实值,并避免一次额外的客户端切换。

13.2 条件渲染改变元素结构

错误:

function Navigation({ authenticated }: { authenticated: boolean }) {
  if (authenticated) {
    return <nav><a href="/account">账户</a></nav>;
  }

  return <nav><button type="button">登录</button></nav>;
}

如果服务端根据请求 cookie 判定为已登录,而客户端初始化状态仍是未登录,水合时结构从 a 变成 button。这不只是文字差异,还改变了元素类型和交互模型。

修复重点不是给 <nav>suppressHydrationWarning,而是让 authenticated 在客户端首次渲染时使用相同的服务端快照:

type Props = {
  initialAuthenticated: boolean;
};

function Navigation({ initialAuthenticated }: Props) {
  const authenticated = initialAuthenticated;

  return authenticated ? (
    <nav><a href="/account">账户</a></nav>
  ) : (
    <nav><button type="button">登录</button></nav>
  );
}

后续客户端可以刷新会话状态,但首次水合必须先使用稳定快照。

13.3 列表顺序来自不稳定来源

错误:

function Items({ items }: { items: string[] }) {
  return (
    <ul>
      {items.sort().map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}

sort() 会原地修改传入数组。如果服务端和客户端拿到的数组引用、排序规则或 locale 不同,列表顺序可能不同。应明确排序规则,并避免修改输入:

function Items({ items }: { items: string[] }) {
  const sortedItems = [...items].sort((a, b) => a.localeCompare(b, "zh-CN"));

  return (
    <ul>
      {sortedItems.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}

key 还必须代表稳定的业务身份,不能用依赖渲染顺序的数组索引来掩盖数据顺序问题。


十四、调试水合问题的正确路径

水合错误往往出现在客户端,但根因可能在服务端数据、HTML 解析或构建边界。应按层定位,而不是直接修改客户端组件。

14.1 第一步:确认是否真的发生了水合

检查客户端入口:

hydrateRoot(container, <App />);

如果服务端 HTML 已经存在,却错误地使用:

createRoot(container).render(<App />);

这不是水合,而是客户端重新创建应用。它可能清除服务端内容,失去 SSR 的复用价值,并制造闪烁。

反过来,如果容器本来是空的,使用 hydrateRoot 也不正确,因为它要求容器中已有与 React 树对应的服务端内容。

14.2 第二步:保存原始服务端 HTML

浏览器 Elements 面板显示的是解析和脚本修改后的 DOM,不一定等于网络响应。使用浏览器 Network 面板查看 Response,或在服务端记录经过脱敏的 HTML 片段。

同时比较:

const serverHtml = renderToString(<App />);

与浏览器中客户端首次渲染的预期结构。直接比较完整 HTML 可能受到属性顺序、React 内部标记和序列化差异影响,应该优先比较:

  • 文本;
  • 标签层级;
  • 条件分支;
  • 列表顺序;
  • 关键属性;
  • 数据快照。

14.3 第三步:定位第一个分叉点

把大页面拆成边界,寻找最早不一致的组件。常见方法包括:

function DebugValue({ name, value }: { name: string; value: unknown }) {
  if (typeof window !== "undefined") {
    console.debug(`[client] ${name}`, value);
  } else {
    console.debug(`[server] ${name}`, value);
  }

  return null;
}

在服务端日志和浏览器日志中记录:

  • 请求 ID;
  • 构建版本;
  • 组件名称;
  • 初始 props;
  • locale;
  • time zone;
  • 用户认证状态;
  • 数据版本或缓存键。

不要在生产日志中直接记录密码、token、完整 cookie 或敏感用户资料。

14.4 第四步:检查不稳定表达式

优先搜索这些调用是否出现在渲染路径:

Date.now
new Date()
Math.random
crypto.randomUUID
window
document
localStorage
sessionStorage
navigator
matchMedia
toLocaleString
Intl.DateTimeFormat

不是说这些 API 永远不能使用,而是它们不能在没有稳定服务端快照的情况下决定客户端首次渲染结果。

14.5 第五步:检查 HTML 解析和边界

如果代码看起来一致,但仍有错误,检查:

  • 非法嵌套;
  • 服务端模板是否插入了额外节点;
  • CDN、代理或扩展是否修改 HTML;
  • 多个 React 根是否使用了相同的 ID;
  • 客户端 bundle 是否与服务端构建版本不匹配;
  • 客户端边界传入的数据是否可序列化;
  • Suspense fallback 是否与实际边界输出匹配。

14.6 第六步:把错误回调接入监控

不要只依赖控制台。使用 onRecoverableError 收集水合恢复错误,并按版本、路由和组件栈聚合。验证时应进行:

  1. 生产构建;
  2. 真实网络延迟;
  3. 禁用缓存或使用旧 HTML、新 JavaScript 的混合场景;
  4. 不同语言和时区;
  5. 已登录与未登录状态;
  6. 慢速 CPU 和 JavaScript 延迟;
  7. Suspense 数据成功和失败路径。

开发环境无警告不代表生产部署不会出现“旧 HTML 配新 bundle”的问题。


十五、版本和部署不一致会伪装成水合错误

考虑一个部署过程:

  1. CDN 缓存了旧页面 HTML;
  2. 新版本 JavaScript 已经上线;
  3. 新旧版本的组件结构不同;
  4. 浏览器拿到旧 HTML 和新 React 树;
  5. 水合报告标签、属性或文本不一致。

这时修改组件内部的 useEffect 可能没有帮助,因为根因是资源版本不一致。

生产部署需要保证 HTML 与 JavaScript 构建版本具有可识别的关联。例如:

  • 静态资源使用内容哈希文件名;
  • HTML 和资源清单按版本发布;
  • CDN 缓存失效策略避免长期旧 HTML;
  • 回滚时同时回滚 HTML、RSC 数据和客户端资源;
  • 服务端错误日志记录构建版本;
  • 客户端监控上报构建版本。

如果确认是混合版本造成的故障,恢复动作应是清理或绕过错误缓存并重新发布匹配资源,而不是仅仅压制警告。


十六、客户端边界的取舍:边界越大,水合越重

把整个页面声明为客户端代码,通常可以快速获得交互,但会带来几个结果:

  • 更多 JavaScript 发送到浏览器;
  • 更多组件需要水合;
  • 更多状态初始化在客户端发生;
  • 服务端组件无法直接封装在该客户端模块内部;
  • 浏览器启动成本增加。

把边界缩小到真正需要交互的组件,例如:

export function ProductPage() {
  return (
    <main>
      <ProductDescription />
      <AddToCartButton />
    </main>
  );
}

其中 ProductDescription 保持服务端渲染,AddToCartButton 作为客户端边界。这样静态内容不必承担额外事件和状态代码,交互区域仍可在水合后工作。

但边界过小也有代价:

  • 服务端和客户端之间传递的数据接口变多;
  • 组件拆分复杂;
  • 多个小客户端边界可能产生更多 chunk 请求;
  • 边界之间的加载顺序和错误处理更复杂。

因此,边界划分应由交互需求、数据访问权限和加载路径决定,而不是简单地把所有组件都标记为客户端组件。


十七、一个可执行的最小水合测试思路

以下示例展示测试的核心过程。实际项目需要根据测试环境配置 JSX、DOM 和 React 19 依赖。

import { act } from "react";
import { renderToString } from "react-dom/server";
import { hydrateRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { App } from "./App";

describe("hydration", () => {
  it("hydrates server HTML and restores interaction", async () => {
    const html = renderToString(<App />);

    document.body.innerHTML = `<div id="root">${html}</div>`;

    const errors: unknown[] = [];
    const container = document.getElementById("root");

    if (!container) {
      throw new Error("Missing root");
    }

    await act(async () => {
      hydrateRoot(container, <App />, {
        onRecoverableError(error) {
          errors.push(error);
        },
      });
    });

    expect(errors).toHaveLength(0);

    const button = container.querySelector("button");
    expect(button).not.toBeNull();

    await act(async () => {
      button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
    });

    expect(container.textContent).toContain("clicked");
  });
});

这个测试验证了三件事:

  1. 服务端能生成 HTML;
  2. 客户端能在已有 HTML 上调用 hydrateRoot
  3. 水合后事件能够驱动状态变化。

它还没有覆盖真实浏览器中的资源加载、流式响应、时区差异和 CDN 缓存,因此不能取代端到端测试。若要测试水合警告,必须让服务端和客户端首次输出故意不同,并断言 onRecoverableError 或开发环境控制台是否收到错误。


十八、几个容易混淆的判断

“有 SSR,就一定有水合”

不一定。服务端可以只返回静态 HTML,不加载 React 客户端代码;也可以使用客户端重新渲染而不是水合。只有客户端用已有服务端 DOM 作为初始容器接管时,才是水合。

“水合错误只是开发环境噪声”

不正确。即便页面最后看起来正常,React 也可能放弃部分 DOM 复用、延迟交互或产生不可预期的属性状态。错误本身说明初始执行环境不满足一致性条件。

“加 suppressHydrationWarning 就解决了”

它只能压制有限范围的已知差异,不能解决:

  • 元素结构不同;
  • 事件逻辑不同;
  • 数据快照不同;
  • 旧 HTML 和新 bundle 不匹配;
  • 非法 HTML 导致的 DOM 变形。

“加了 use client,就可以访问 window

在支持 RSC 的框架中,客户端组件仍可能被预渲染。因此访问浏览器 API 的代码通常应在事件处理器、effect 或能提供稳定 server snapshot 的外部存储逻辑中执行。

“服务端和客户端使用同一份组件源码,就不会不一致”

源码相同不等于输入相同。时间、随机数、locale、时区、认证状态、缓存数据、浏览器 API 和 HTML 解析都会改变结果。


十九、诊断原则归纳

水合问题可以归结为三个连续条件:

可水合=HTML 结构可匹配初始数据快照一致客户端代码能够及时接管\text{可水合} = \text{HTML 结构可匹配} \land \text{初始数据快照一致} \land \text{客户端代码能够及时接管}

第一项失败,会出现节点、文本或属性不匹配;第二项失败,会出现条件分支、列表、时间和用户状态差异;第三项失败,页面可能可见但不可交互,或者事件在代码加载前处于延迟状态。

实际排查时,应先回答:

  1. 容器中的 HTML 是否确实由同一版本的服务端 React 生成?
  2. 客户端首次渲染是否使用了相同数据和环境假设?
  3. 浏览器解析后的 DOM 是否仍符合 React 组件树?
  4. 事件处理器存在条件是否一致?
  5. 客户端边界是否只传递了可表示的数据?
  6. React 是否通过错误回调报告了可恢复或不可恢复错误?
  7. 部署缓存是否可能造成旧 HTML 与新 JavaScript 混用?

水合成功不是“控制台没有警告”这么简单,而是服务端输出能够被客户端 React 正确复用,客户端事件能够在预期时间恢复,后续状态更新仍沿着正确的组件边界运行。理解这三个层次,才能把水合从首屏技巧还原为一个具有明确输入、状态和故障路径的系统过程。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。