Notes

Hydration mismatch(React / Next.js):成因、检测、排查清单与工程化规避

一句话结论:Hydration mismatch 的本质是「首屏 HTML(server render)与客户端第一次 render 产物不一致」——React 在 hydration 时无法把事件/状态“贴回”到同一棵 DOM 上,就会告警、丢弃部分 server DOM、甚至整棵树改为客户端重建;修复要点是:保证“首屏可见结构 + 关键文本/属性”在 server 与 client 的 第一次 render 完全确定且一致,把所有“只在浏览器才知道的差异”延后到 useEffect(或明确的 client-only 边界)之后。

0. 这类问题的典型信号(Symptoms)

你通常会在控制台/Next overlay 看到类似信息:

  • Warning: Text content did not match. Server: "..." Client: "..."
  • Hydration failed because the initial UI does not match what was rendered on the server.
  • There was an error while hydrating... the entire root will switch to client rendering.

用户侧表现可能是:

  • 首屏闪一下(server 内容被替换)
  • 点击/输入短暂失灵(事件没正确挂上)
  • 某些组件丢状态(因为 hydration 被迫重建)

1. Hydration 到底在做什么(机制对齐)

1.1 SSR + Hydration 的两段式

  1. Server Render(服务端渲染):服务端把 React 组件树渲染成 HTML 字符串(或 streaming chunks),浏览器先拿到可见内容。
  2. Hydration(客户端水合):客户端运行同一棵组件树,React 尝试把“客户端 Fiber 树”与“现有 DOM”对齐,并补上:
    • 事件监听
    • 组件状态(初始 state)
    • refs 等

关键点:Hydration 不是“把 HTML 变成 React”,而是“用 客户端第一次 render 的结果 去匹配现有 DOM”。只要第一次 render 不一致,就会 mismatch

1.2 Next.js 里的现实情况

  • Client Components 也会在服务端输出 HTML(用于首屏可见内容),随后在浏览器 hydration。
  • App Router + RSC/Streaming 下:server 输出可能是分片��,但“每个 chunk 对应的客户端首次 render 结果”仍必须匹配。

2. 成因(Causes)——从“确定性”角度分类

下面按“为什么 server 与 client 会算出不同 UI”来分类(你排查时也按这个顺序扫一遍)。

2.1 非确定性渲染(同一份代码,两边算出的值不同)

常见触发器:

  • Date.now() / new Date()(时刻不同、时区/locale 也可能不同)
  • Math.random() / crypto.randomUUID()(随机数)
  • Intl.* 格式化(locale/timezone、实现差异)
  • 依赖 process.env / feature flag 在两端取值不同

症状:最常见是“文本不一致”。

2.2 读取“仅客户端可得”的信息,并影响首次 render

常见触发器:

  • window / document / navigator / location
  • localStorage / sessionStorage
  • matchMedia('(prefers-color-scheme: dark)')(主题)
  • window.innerWidth / devicePixelRatio(响应式)

典型写法:

  • 在 render 里直接读:const theme = localStorage.getItem('theme')
  • 通过 typeof window !== 'undefined' 做条件渲染,并改变结构/文本

注意:typeof window !== 'undefined' 本身不是问题;问题是它导致“首屏结构分叉”。

2.3 数据不一致(server 用了 A,client 首次 render 用了 B)

  • 服务端请求与客户端请求命中不同缓存/不同身份(cookie/header)
  • 首屏 HTML 用的是 SSR 数据,但客户端首次 render 直接走“空数据 + loading”,或反之
  • 并发/streaming 下 fallback 边界切分不当:server 先吐了 fallback,但 client 首次 render 没有 fallback(或相反)

2.4 DOM 在 hydration 前被外部因素改写

  • 第三方脚本在 React hydration 前插入/改写节点(广告、埋点、A/B SDK)
  • 浏览器插件改写 DOM
  • 服务器输出了 无效 HTML 结构(例如 div 嵌在 p 里),浏览器会“修复 DOM”,导致与 React 预期不同

2.5 样式/类名注入顺序问题(CSS-in-JS / style registry)

  • 典型在 Emotion/Styled-components 等:服务端生成的类名与客户端生成的类名不一致,或注入顺序不同。

Next App Router 下通常需要对应的“样式 registry”方案,否则很容易遇到 className 不一致。

3. 如何检测(Detection)——把问题“变成可复现”

  • 不要只看 dev:dev 下包含额外的调试/StrictMode 行为;请尽量用 production build 复现。
    • Next:next build && next start
  • 用“View Source”看服务端 HTML,用 DevTools Elements 看 hydration 后 DOM。
    • mismatch 往往体现在:文本、属性(className/aria-*)、节点数量/层级。
  • 快速二分定位
    • 先把可疑组件替换成静态占位,看告警是否消失
    • 再逐段恢复,直到复现

4. Debugging Checklist(排查清单)

目标:快速判断是「非确定性渲染」还是「数据/外部修改」还是「样式注入」。

4.1 复现与环境一致性

  • 是否在 next build && next start 下仍能复现?
  • 是否在无痕窗口/禁用插件后复现?(排除 DOM 被插件改写)
  • 是否只在某些地区/时区/语言环境出现?(Intl/时区问题)
  • 是否只在登录/特定 cookie 下出现?(身份导致数据分叉)

4.2 定位 mismatch 的“最小子树”

  • 控制台/overlay 是否提示了具体节点(文本 diff)?
  • 在可疑区域临时加 data-debug="...",从 DOM 反查是哪块组件输出的
  • 临时把该区域替换为纯静态 HTML,确认告警是否消失(验证定位正确)

4.3 扫描高频触发器(从代码层面)

  • 是否在 render/初始化 state 里使用了 Date.now() / new Date() / Math.random()
  • 是否在 render 里读了 window/document/localStorage/matchMedia
  • 是否根据 typeof window 做了结构性分支(多/少节点)?
  • 是否有 dangerouslySetInnerHTML 且两端内容来源不同?
  • 是否有第三方脚本在 hydration 前执行并改写 DOM?

4.4 数据一致性(Next 特有高发区)

  • SSR/RSC 用到的 cookies()/headers() 是否会让同一路由对不同用户返回不同 HTML?
  • 客户端首次 render 是否拿到与服务端相同的“初始数据快照”?
    • 例如:SSR 数据注入、query cache hydrate、RSC payload
  • Suspense fallback 是否出现 server/client 不一致?(边界切分问题)

4.5 样式注入与 className

  • 是否使用了 CSS-in-JS,但没有做正确的 SSR 提取/registry?
  • mismatch 是否主要体现在 class/style 属性,而非文本/结构?

5. 预防与修复模式(Prevention Patterns)

5.1 规则 1:首屏 render 必须“纯函数 + 同输入同输出”

  • 把随机/时间/浏览器信息都视为“副作用输入”,不要在首屏 render 里读取。
  • 如果确实要展示,保证 server 与 client 用同一份值(由 server 计算并下发)。

5.2 模式 A:延后到 useEffect(mounted gate)

适用于:主题、窗口尺寸、仅客户端能力检测等。

function ClientOnly({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  if (!mounted) return null; // 或返回与 server 相同的占位骨架
  return children;
}

代价:首屏可能少一块内容;如果是关键内容,更推荐“模式 B:同值下发”。

5.3 模式 B:把值变成“可序列化的初始 props”(server 决定、client 复用)

适用于:时间戳、地区、A/B 实验桶、用户信息(可公开部分)等。

// server 组件里(或 getServerSideProps 等)
const now = Date.now();
return <Clock initialNow={now} />;

// client 组件
function Clock({ initialNow }: { initialNow: number }) {
  const [now, setNow] = React.useState(initialNow);
  React.useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);
  return <span>{new Date(now).toLocaleTimeString()}</span>;
}

5.4 模式 C:client-only 边界(Next dynamic / Client Component 拆分)

适用于:第三方库依赖 DOM、广告/地图/富文本编辑器等“必然非同构”的组件。

  • Pages Router/通用:dynamic(() => import('./X'), { ssr: false })
  • App Router:把该组件放到 "use client" 文件中,再用 next/dynamic 控制是否 SSR。

代价:失去 SSR 首屏 HTML;但比“hydration 报错 + 重建”更可控。

5.5 模式 D:外部 store 用 useSyncExternalStore 提供 server snapshot

适用于:从全局 store / media query / storage 同步 UI(并且要 SSR)。

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

核心是:server 也要能返回一个与首屏一致的 snapshot

5.6 模式 E:suppressHydrationWarning(只用于“可接受不一致”的文本)

适用:时间、随机推荐等“首屏不一致但无交互含义”的小块文本。

<span suppressHydrationWarning>{clientOnlyText}</span>

注意:

  • 不要把它当成“修复”——它只是“消音”。
  • 不要对大块结构使用,否则隐藏真正问题。

6. 典型场景(Example Scenarios)

6.1 根据时间展示问候语(server/client 时区不同)

坏例子(首屏 render 读时间):

function Greeting() {
  const hour = new Date().getHours();
  return <h1>{hour < 12 ? 'Good morning' : 'Good afternoon'}</h1>;
}

好例子:server 计算 hour 并下发,或首屏固定文案、客户端再更新。

6.2 深色模式(localStorage + className)

坏例子

function ThemeIcon() {
  const theme = localStorage.getItem('theme');
  return <Icon name={theme === 'dark' ? 'moon' : 'sun'} />;
}

好例子

  • 首屏由 server 通过 cookie 决定主题 class(或注入 beforeInteractive 脚本先设置 class)
  • 客户端再接管状态

6.3 响应式:按窗口宽度渲染不同结构

坏例子

function Nav() {
  const isMobile = window.innerWidth < 768;
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

好例子

  • 结构尽量一致,靠 CSS media query 控制展示
  • 或 server 用 UA 推断并把 isMobile 作为初始 props(注意准确率/边界)

6.4 Suspense fallback 不一致(Streaming/边界切分)

现象:server 吐出了 fallback,但客户端首次 render 没有 fallback(或相反)。

修复方向:

  • 调整边界层级(更稳定的 shell + 更少的结构性分叉)
  • 确保“是否 suspend”在首屏两端一致(数据依赖要统一、避免 client 首次 render 直接走另一条数据路径)

6.5 第三方脚本改写 DOM

现象:只有线上有、只有某些浏览器有;React 提示 hydration 失败并重建。

修复方向:

  • 把第三方脚本延后到 hydration 后执行(Next Script 的 strategy)
  • 给第三方“可控挂载点”,不要让它插入到 React 管理的 DOM 子树内部

7. 经验法则(快速决策)

  • 如果 mismatch 来自“不可避免的客户端差异”(地图、编辑器、广告):用 client-only,别硬 SSR。
  • 如果 mismatch 来自“随机/时间/locale”:用 server 下发初值,不要在首屏 render 读。
  • 如果 mismatch 来自“主题/响应式”:优先 首屏一致(cookie/脚本先设置),其次才是 mounted gate。
  • 如果 mismatch 来自“数据缓存真相不一致”:先把 数据边界理清(RSC/SSR vs client cache),再谈 UI。

关联阅读(repo 内)

cd ..