陌上人如玉
公子世无双

9.4 水合与不匹配

9.4 水合与不匹配

一句话:水合(Hydration) 是「客户端复用服务端生成的 HTML、绑定事件、变成可交互」的过程;水合不匹配(hydration mismatch) 发生在「客户端首次渲染的结果与服务端 HTML 不一致」时——常见原因是用了随机值/当前时间、依赖了浏览器信息、渲染了非法 HTML 结构。

一、水合是什么

SSR 的完整流程:

服务端:
  ① 渲染 React 树 → HTML 字符串(内容 + 结构)
  ② 把「渲染时用到的数据」脱水进 HTML(如 <script>window.__DATA__={...}</script>)
  ③ 返回 HTML

浏览器:
  ④ 立刻显示 HTML(用户看到内容 —— FCP 好,但【点不动】)
  ⑤ 下载并执行客户端 JS
  ⑥ 【水合】:
     · 遍历 DOM,把 React 的「虚拟树」和「真实 DOM」对应起来
     · 绑定事件监听器
     · 恢复状态(从脱水的数据)
  ⑦ 页面变为可交互(TTI)

⚠️ 关键:水合的前提是「客户端的首次渲染结果与服务端 HTML 一致」——
   否则 React 无法把「虚拟节点」和「DOM 节点」正确对应。
为什么要「复用 HTML」而不是「客户端重新渲染一遍」?
① 性能——重新渲染会产生大量 DOM 操作(浪费)
② 体验——复用不会「闪烁」(页面上已经有内容了)
③ 状态——表单输入、滚动位置、媒体播放不会被打断

所以水合的语义是「【采纳】服务端的 DOM,然后【接上】事件」。

二、为什么会不匹配

水合的匹配过程(简化):
  ① 客户端渲染「同一个组件」→ 得到一棵虚拟树 A
  ② 服务端返回的 DOM → 对应一棵「预期的树」B
  ③ React 遍历比较 A 与 B:
     · 结构一致 → 采纳 B 并绑定事件(正常)
     · 结构不一致 → 【mismatch】
       - React 18+ 会「客户端重新渲染这一部分」(放弃复用)
       - 开发环境会打印详细警告
       - 用户可能看到「内容闪一下变了」
⭐ 根因只有一句话:
   「服务端渲染时的输出」与「客户端首次渲染时的输出」不同。

   常见的「不同」来源见下一节。

三、六个常见原因与修复

1) 随机值 / 当前时间

// ❌ 服务端与客户端生成不同的值
function Bad() {
  return (
    <div>
      <span>{Math.random()}</span>
      <span>{Date.now()}</span>
      <span>{new Date().toLocaleString()}</span>       {/* ⚠️ 还有时区问题 */}
    </div>
  );
}
// ✅ 修复:把「不确定的值」放到客户端生成(useEffect / useState + effect)
function Good() {
  const [now, setNow] = useState<number | null>(null);

  useEffect(() => {
    setNow(Date.now());                                // ⭐ 只在客户端执行
  }, []);

  if (now === null) return <span>-</span>;             // ⭐ 服务端与客户端首屏一致
  return <span>{new Date(now).toLocaleString()}</span>;
}
// ✅ 另一个修复:用「服务端生成的值」通过 props 传下来(保证两端一致)
// 服务端(Server Component)
export default function Page() {
  const serverTime = Date.now();                       // 服务端生成
  return <Clock serverTime={serverTime} />;            // ⭐ 传给客户端组件
}
// ✅ 相对时间(避免时区与格式化差异)
function RelativeTime({ iso }: { iso: string }) {
  return <time dateTime={iso}>{iso}</time>;            // ⭐ 输出原始 ISO 字符串(两端一致)
}

2) 依赖浏览器信息

// ❌ 用「是否存在于浏览器」来分支
function Bad() {
  const isBrowser = typeof window !== 'undefined';     // ⚠️ 服务端 false、客户端 true
  return <div>{isBrowser ? '浏览器' : '服务端'}</div>;
}

// ✅ 修复:把「浏览器判断」延迟到客户端
function Good() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  // ⭐ 首屏(服务端与客户端首次渲染)都渲染「一致的内容」
  return <div>{mounted ? <BrowserOnly /> : <Skeleton />}</div>;
}
// ❌ 直接读 window / localStorage / matchMedia
const width = window.innerWidth;                       // ❌ 服务端没有 window
const theme = localStorage.getItem('theme');           // ❌

// ✅ 修正:用 useEffect 读取(或用「同构 Hook」)
function useTheme() {
  const [theme, setTheme] = useState('light');          // ⭐ 首屏用默认值(两端一致)

  useEffect(() => {
    const saved = localStorage.getItem('theme');
    if (saved) setTheme(saved);
  }, []);

  return theme;
}
// ⚠️ 但「首屏用默认值」会造成「主题闪烁」(FOUC)
// ✅ 常见解法:把「初始主题」写进 HTML(服务端读 cookie → 注入 class/style)
// Next.js 里可以在根布局读 cookie 并设置 <html className={theme}>
export default async function RootLayout({ children }) {
  const theme = (await cookies()).get('theme')?.value ?? 'light';
  return (
    <html lang="zh-CN" className={theme}>              {/* ⭐ 服务端就确定主题 */}
      <body>{children}</body>
    </html>
  );
}

3) 非法 HTML 嵌套

// ❌ 浏览器会「修正」非法结构 → 服务端 HTML 与客户端预期不一致
function Bad() {
  return (
    <p>
      <div>块级元素不能放在 p 里</div>                   {/* ⚠️ 浏览器会自动闭合 <p> */}
    </p>
  );
}

function Bad2() {
  return (
    <table>
      <tr>                                             {/* ⚠️ 缺少 <tbody>,浏览器会补 */}
        <td>1</td>
      </tr>
    </table>
  );
}

function Bad3() {
  return (
    <a href="/a">
      <a href="/b">嵌套的 a</a>                        {/* ⚠️ 非法 */}
    </a>
  );
}
// ✅ 修复:遵守 HTML 内容模型
// · <p> 里只放「行内内容」(放块级用 <div>)
// · <table> 里显式写 <thead>/<tbody>/<tr>/<td>
// · 不嵌套 <a>、不嵌套 <button>
⭐ 为什么浏览器会「修正」?
   HTML 解析器对「非法嵌套」有容错规则(如 <p><div> 会在 <div> 前自动闭合 <p>)。
   于是「服务端输出的 HTML 字符串」经过浏览器解析后,DOM 结构与「React 期望的树」不同
   → 水合时 React 发现「DOM 里有它没预料到的节点」→ mismatch。

4) 时区与语言差异

// ❌ 服务端与客户端的时区/语言可能不同
new Date().toLocaleString();                            // ⚠️ 服务端 UTC、客户端本地
new Intl.NumberFormat().format(1234.5);                 // ⚠️ 本地化差异
[1, 2, 3].sort((a, b) => a - b).toLocaleString();       // ⚠️ 同上
// ✅ 修复:显式指定时区与 locale
new Date(iso).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
new Intl.NumberFormat('zh-CN').format(1234.5);

5) 第三方浏览器扩展改写 DOM

症状:本地开发完全正常,但「部分用户」频繁见到 mismatch 警告(尤其在 <body> 上)

原因:浏览器扩展(翻译插件、广告拦截、暗色模式插件)会在「HTML 到达浏览器后、
      React 水合前」修改 DOM(如给 <html> 加 class、替换文本)
     → React 水合时发现「DOM 与预期不一致」

✅ 处理方式:
① 无法完全避免(属于外部干扰)
② 可以用 `suppressHydrationWarning` 抑制「特定元素」的警告
③ 上报到监控时过滤掉这类噪音(见 8.6)
// ✅ suppressHydrationWarning 的合理使用(只用于「内容会变」的叶子元素)
<html lang="zh-CN" suppressHydrationWarning={true}>
  <body suppressHydrationWarning={true}>
    {/* 有些扩展/主题脚本会改 html/body 的属性 → 抑制这一层的警告 */}
  </body>
</html>

6) 组件内部状态「初始值依赖客户端」

// ❌ 组件初始化时读「客户端专属数据」
function Bad() {
  const [flag] = useState(() => localStorage.getItem('flag') === '1');   // ❌ 服务端报错/不一致
  return <div>{flag ? 'A' : 'B'}</div>;
}

// ✅ 初始值必须「两端一致」,客户端专属数据在 Effect 里读
function Good() {
  const [flag, setFlag] = useState(false);              // ⭐ 默认值(两端一致)

  useEffect(() => {
    setFlag(localStorage.getItem('flag') === '1');
  }, []);

  return <div>{flag ? 'A' : 'B'}</div>;
}

四、怎么排查

四步排查法:

① 看控制台的警告信息
   React 会给出「具体的节点路径」与「期望/实际」的差异:
   "Hydration failed because the server rendered HTML didn't match the client.
    ... <span>  Server: "1" Client: "2""

② 定位「不确定的来源」
   在可疑组件里「渲染时打印」——
   · 服务端日志与客户端控制台的输出对比(值不同 → 就是它)

③ 二分法缩小范围
   · 注释掉一半子树 → 看警告是否消失
   · 或者在「外层组件」用 useEffect 单独渲染可疑部分(先排除)

④ 常见「三个必查点」
   · 有没有用 Math.random() / Date.now() / new Date()?
   · 有没有直接读 window / localStorage / matchMedia?
   · 有没有可疑的 HTML 嵌套(<p><div>、<table> 缺 <tbody>、嵌套 <a>)?
// ✅ 一个实用的排查辅助:把「关键值」在两端都打印出来
function DebugComponent({ value }: { value: unknown }) {
  console.log('[render]', { value, isServer: typeof window === 'undefined' });
  return <div>{String(value)}</div>;
}
// 服务端会打印到终端,客户端会打印到浏览器控制台 → 对比两边的 value

五、React 18 的改进

React 18 对 hydration mismatch 的处理更「健壮」:

① 【不再「整棵树重新渲染」】
   React 17 遇到不匹配会「丢弃整棵树的 DOM、客户端全量重渲染」→ 严重闪屏
   React 18 会「尽量保留」并「只重新渲染出错的那一小块」(局部回退)

② 【更好的错误提示】
   会明确指出「哪个节点」不匹配、期望什么、实际是什么

③ 【与 Suspense 结合的选择性水合】
   边界内的内容可以「晚一点水合」;用户点击某块时会【优先水合那块】

④ 【流式 SSR 下的渐进式水合】
   shell 先水合,慢的部分到货后再水合

六、常见坑

// ① 用 Math.random() / Date.now() 渲染内容
// ② 用 new Date().toLocaleString()(时区差异)
// ③ 用 typeof window 分支渲染
// ④ 直接读 localStorage / matchMedia / navigator(渲染期)
// ⑤ 非法 HTML 嵌套(<p><div>、缺 <tbody>、嵌套 <a>)
// ⑥ useState 的初始值读「客户端专属数据」
// ⑦ 用「时间相关的相对文案」(如「3 分钟前」)
function Bad({ createdAt }: { createdAt: string }) {
  return <span>{minutesAgo(createdAt)} 前</span>;       // ⚠️ 服务端与客户端计算的「现在」不同
}
// ✅ 传「绝对时间」或者「服务端算好的字符串」

// ⑧ 用了「只在客户端有效」的第三方库(在渲染期读 window)
// ⑨ 用 suppressHydrationWarning 覆盖过大的范围(掩盖真问题)
//    —— 它只抑制「该元素的属性/文本」的警告,不会修复逻辑
// ⑩ 认为「mismatch 只是警告,可以忽略」
//    → 它会导致「界面闪变」「事件绑定错位」,必须修
// ⑪ 在开发环境「因为 mismatch 频繁」而关掉 SSR
// ⑫ 用「随机 key」渲染列表(导致 DOM 复用错乱 + mismatch)
// ⑬ 把「用户信息」渲染进「被 ISR 静态化的页面」(缓存串用户 + mismatch)
// ⑭ 用 `crypto.randomUUID()` 在渲染期生成 ID(两端不同)
//    ✅ 用 React 19 的 useId(两端一致,见 2.11)
// ⑮ 忽略了「样式注入顺序」导致的 mismatch(CSS-in-JS 场景,需要有确定顺序)
// ⑭ 的正确做法
// ❌
const id = `field-${crypto.randomUUID()}`;               // 服务端与客户端不同

// ✅ React 提供 SSR 安全的 ID
import { useId } from 'react';

function Field() {
  const id = useId();                                    // ⭐ 两端一致
  return (
    <>
      <label htmlFor={id}>标签</label>
      <input id={id} />
    </>
  );
}

七、面试延伸

  1. 「什么是水合(Hydration)?」
    水合是「客户端复用服务端生成的 HTML、把事件绑定上去、恢复状态,使页面变成可交互」的过程。完整流程是:服务端渲染 React 树为 HTML 并把数据「脱水」进页面 → 浏览器立刻显示 HTML(内容可见,但点不动)→ 下载执行客户端 JS → 水合(遍历 DOM,把虚拟树与真实 DOM 对应、绑定事件、恢复状态)→ 页面可交互。水合的前提是「客户端的首次渲染结果与服务端 HTML 一致」。

  2. 「什么是 hydration mismatch?为什么会发生?」
    hydration mismatch 指「客户端首次渲染的结果与服务端 HTML 不一致」——此时 React 无法把「虚拟节点」与「DOM 节点」正确对应。根因只有一句话:「两端渲染输出不同」,常见来源是:① 随机值 / 当前时间(Math.random()、Date.now());② 依赖浏览器信息(typeof window、localStorage、matchMedia);③ 非法 HTML 嵌套(浏览器会自动修正结构);④ 时区/语言差异(toLocaleString());⑤ 浏览器扩展改写 DOM;⑥ 组件初始 state 依赖客户端数据。

  3. 「水合不匹配的后果是什么?」
    三点:① 界面闪变——React 发现不匹配后会「重新渲染那一部分」,用户会看到内容闪一下(甚至从 A 变成 B);② 事件绑定错位——如果结构不一致,事件可能被绑到错误的节点上;③ 性能浪费——丢失了「复用服务端 DOM」的收益(React 18 会尽量局部回退,但仍有成本)。在开发环境会打印警告;生产环境虽然不打印,但问题依然存在。

  4. 「怎么修复水合不匹配?」
    按原因修复:① 随机值/时间——把「不确定的值」放到 useEffect 里生成(先用占位保证两端一致),或由服务端生成后通过 props 传下来;② 浏览器信息——不要在渲染期读,改用 useEffect + state(首屏用「两端一致的默认值」);③ 非法嵌套——遵守 HTML 内容模型(<p> 里不放块级、<table> 显式写 <tbody>、不嵌套 <a>);④ 时区/语言——显式指定 timeZone 与 locale;⑤ 第三方扩展——无法避免,可在 html/body 上加 suppressHydrationWarning 并过滤噪音上报;⑥ 初始 state——首屏用「两端一致的默认值」。

  5. 「suppressHydrationWarning 应该怎么用?」
    它只抑制「该元素自身的「文本/属性」不一致的警告」,不会修复逻辑、也不会影响子树(只作用于直接子级文本与属性)。合理用法:① 「内容本就会变」的叶子元素(如「刚刚更新」的时间戳);② 会被「浏览器扩展/主题脚本」改写的 html/body 属性。不应该用它来「掩盖真正的逻辑 bug」——那只会让问题隐藏到生产环境(表现为「闪变 + 事件错位」)。

  6. 「React 18 对水合有什么改进?」
    四点:① 不再「整棵树重渲染」——React 17 遇到不匹配会丢弃整棵树的 DOM 并客户端全量重渲染(严重闪屏),React 18 会尽量保留已匹配的部分、只局部回退(体验好得多);② 更清晰的错误提示(指出哪个节点、期望什么、实际什么);③ 与 Suspense 结合的「选择性水合」——边界内内容可以晚一点水合,用户点击某块时优先水合那一块;④ 流式 SSR 下的渐进式水合(shell 先水合、慢的部分到货后补充水合)。

  7. 「为什么水合期间页面『点不动』?」
    因为水合的语义是「复用 HTML 并绑定事件」——在事件绑定完成之前,DOM 上没有任何 React 的事件监听器(React 的事件是「委托到根容器」的,见 4.9)。此时用户看到内容但点击无效。这是 SSR 的固有特性(FCP 好但 TTI 晚)。缓解手段:① 减少客户端 JS(RSC、代码分割、减少 Client Component);② 选择性水合(用户交互的部分优先水合);③ 岛屿架构(Astro)——只对「需要交互的岛屿」水合。

  8. 「怎么从『架构上』减少 hydration mismatch?」
    四条经验:① 把「不确定的值」和「浏览器专属逻辑」都放进 useEffect(首屏渲染保持「纯 + 确定」);② 用「服务端确定的初始值」——如主题从 cookie 读(服务端注入 class),而不是客户端 localStorage;③ 用 useId(React 提供的 SSR 安全 ID,替代 Math.random() 生成 id);④ 避免「渲染期读外部可变状态」(全局变量、document、navigator)。此外「组件渲染保持纯函数」也是必要条件(与 4.3、8.5 的结论一致)。

一句话速记

水合 = 「客户端复用服务端 HTML + 绑定事件 + 恢复状态」→ 页面可交互(水合前看得见但点不动);前提是「两端首次渲染结果一致」,不一致就是 hydration mismatch;六个常见原因是「随机值/当前时间、依赖浏览器信息(window/localStorage)、非法 HTML 嵌套(<p><div>、缺 <tbody>、嵌套 <a>)、时区/语言差异、浏览器扩展改写 DOM、初始 state 读客户端数据」;修法是「把不确定值与浏览器逻辑放进 useEffect(首屏用两端一致的默认值)、显式指定时区与 locale、遵守 HTML 内容模型、用 useId 替代 Math.random()」;别用 suppressHydrationWarning 掩盖 bug(它只抑制警告,不修逻辑);React 18 起会「局部回退」而不是「整棵树重渲染」,并支持选择性水合。

赞(0) 打赏
未经允许不得转载:陌上寒 » 9.4 水合与不匹配

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫