跳到主要内容

Next.js 水合(Hydration)

水合是 Next.js 和 React 里最容易“项目能跑,但脑子里还没完全接上”的一块。

页面第一次打开时,很多人看到的是:

  • 服务端已经吐出了 HTML
  • 浏览器也已经显示了内容
  • 后面按钮、输入框、路由跳转才逐步变得可交互

把这一步真正接起来的过程,就是水合。

先说一句最核心的定义

可以把水合理解成:

  • 服务端先生成一份 HTML
  • 浏览器拿到这份 HTML 先显示出来
  • React 在客户端接管这棵树,把事件、状态、组件逻辑重新挂上去
  • 页面从“能看”变成“能交互”

所以水合不是“重新渲染一个全新的页面”,而是让已有的 HTML 变成可交互应用。

为什么 Next.js 一定会谈到水合

因为 Next.js 天然会把“服务端输出”和“客户端接管”这两步组合在一起。

只要页面不是纯客户端单页,通常都会遇到:

  • SSR
  • SSG
  • ISR
  • App Router 下的预渲染
  • Pages Router 下的预渲染

这些能力最后几乎都会走到一个共同阶段:浏览器拿到服务端结果之后,再由 React 在客户端水合。

一个最常见的心智模型

可以把它拆成四步:

1. 服务端生成 HTML

这一步发生在:

  • 构建时
  • 请求时
  • 或静态结果重验证时

2. 浏览器先把 HTML 画出来

这时页面通常“已经能看”。

3. JS 下载并执行

浏览器开始加载页面对应的客户端 JS。

4. React 在客户端接管

这时按钮、输入框、事件处理、客户端状态、路由跳转等能力才真正连上。

为什么水合重要

因为它同时影响三件事:

  • 首屏体验
  • 可交互时间
  • 错误和性能问题

如果水合顺利:

  • 页面很快能看到
  • 很快能操作
  • 服务端和客户端结果一致

如果水合有问题:

  • 页面会报 hydration mismatch
  • 局部 UI 会闪一下
  • 某些区域会退化成纯客户端渲染
  • 首屏虽然出来了,但交互迟迟接不上

在 Pages Router 和 App Router 里,水合感受为什么不同

Pages Router

Pages Router 下,页面常见模式是:

  • 服务端先输出 HTML
  • 再把 __NEXT_DATA__ 这类序列化数据下发到客户端
  • React 再根据这些数据完成水合

这一代项目里,如果页面数据很大,水合成本会明显上升。官方错误说明里也专门提到,过大的 page data 会拖慢 hydration。

App Router

App Router 下,情况更复杂也更现代一些:

  • Server Components 默认留在服务端
  • Client Components 才需要在客户端接管
  • 路由可以按段流式返回
  • 不同区域的交互接管不一定同时发生

所以在 App Router 里,不应该再把整个页面想成“一个统一的大水合动作”。更准确的理解是:

  • 静态或服务端区域先出来
  • 需要客户端能力的局部区域再逐步接管

这也是为什么 App Router 的水合体验通常会比老式整页模式更细粒度。

服务端渲染和水合不是一回事

这两个概念经常被混用。

服务端渲染

回答的是:

  • HTML 在哪生成

水合

回答的是:

  • 浏览器怎么把这份 HTML 接成可交互应用

也就是说:

  • SSR 可以发生
  • 但真正能点、能输、能响应事件,还要等水合完成

一个简单例子

'use client';

import { useState } from 'react';

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

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

这个按钮所在的 HTML 可能已经由服务端先输出了,但真正让 onClick 生效的是客户端水合。

最常见的水合报错是什么

最典型的是:

  • Text content does not match server-rendered HTML

也就是服务端预渲染出来的内容,和浏览器第一次客户端渲染出来的内容对不上。

Next.js 官方把这类问题直接归到 hydration mismatch。

为什么会出现 hydration mismatch

官方文档列出的常见原因里,最值得记住的是这些:

1. 服务端和客户端第一次渲染结果不同

例如直接在渲染阶段写:

export default function Page() {
return <div>{Date.now()}</div>;
}

服务端和客户端执行时间不同,结果自然不同。

2. 在渲染逻辑里直接使用浏览器 API

例如:

  • window
  • localStorage
  • document

如果在服务端渲染阶段就依赖这些东西,结果通常会不一致,甚至直接报错。

3. 在 JSX 里直接做 typeof window !== 'undefined' 分支

这类写法很常见,但也很容易让服务端和客户端首帧输出不同。

4. HTML 结构不合法

例如:

  • <div> 放进 <p>
  • <a><a>
  • <button><button>

这种问题有时在普通 React 页面里就危险,在 SSR + hydration 环境里更容易直接炸出来。

5. 时间、随机数、地区化格式化

例如:

  • new Date()
  • Math.random()
  • toLocaleString()

只要两端环境不同,第一次输出就可能不同。

6. 浏览器扩展或 CDN 篡改 HTML

这类原因相对少见,但确实存在。官方文档也点名提到浏览器扩展和 CDN 对 HTML 的修改可能导致 mismatch。

一个典型错误例子

export default function Page() {
const theme = localStorage.getItem('theme');
return <div>{theme}</div>;
}

这段逻辑的问题是:

  • 服务端没有 localStorage
  • 客户端第一次渲染又拿到了真实值
  • 两边首帧结果不一致

更稳的写法

'use client';

import { useEffect, useState } from 'react';

export default function ThemeLabel() {
const [theme, setTheme] = useState('light');

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

return <div>{theme}</div>;
}

这个思路的关键是:

  • 首次渲染先输出稳定结果
  • 浏览器专属逻辑放进 useEffect
  • 水合完成后再做客户端差异更新

为什么 useEffect 常被当成修 hydration 的第一招

因为 useEffect 只会在客户端运行,而且发生在 hydration 之后。

这意味着它很适合承接:

  • 读取 window
  • 读取 localStorage
  • 读取媒体查询
  • 读取浏览器尺寸
  • 读取用户设备信息

但这不是说所有问题都应该靠 useEffect 糊过去。更理想的做法仍然是:

  • 先想办法让首帧输出稳定一致
  • 实在只能客户端计算的部分,再延后到 useEffect

dynamic(..., { ssr: false }) 是什么思路

有些组件天然只适合在客户端执行,例如:

  • 强依赖浏览器 API
  • 第三方图表库只支持浏览器
  • 需要直接访问 DOM

这时可以关闭该组件的 SSR:

import dynamic from 'next/dynamic';

const NoSSRChart = dynamic(() => import('./chart'), {
ssr: false,
});

export default function Page() {
return <NoSSRChart />;
}

这招的本质不是“修复水合”,而是直接绕开该组件的服务端预渲染。

代价也很明确:

  • 首屏这块内容不会由服务端输出
  • SEO 价值下降
  • 首次可见时间可能推迟

所以这更适合局部、明确依赖浏览器环境的组件。

suppressHydrationWarning 应该怎么理解

有些内容就是天然会不同,例如时间戳。

<time suppressHydrationWarning>{Date.now()}</time>

这表示:

  • 这里的 mismatch 是已知且可接受的
  • 不要对这一级文本差异报警

但官方也明确提醒:

  • 这只是 escape hatch
  • 只作用一层
  • 不应该滥用

如果一个页面到处都靠它压警告,通常说明结构设计本身有问题。

App Router 里哪些情况特别容易和水合扯上关系

1. use client

只要组件标了 'use client',就意味着这部分要在客户端接管交互逻辑。

所以客户端组件越多,水合工作量通常越大。

2. useSearchParams

Next 官方有专门的错误说明:如果一个预渲染路由里直接使用 useSearchParams(),又没有 Suspense 边界,整块树可能会退化成客户端渲染。

'use client';

import { useSearchParams } from 'next/navigation';

export default function SearchBar() {
const searchParams = useSearchParams();
return <div>{searchParams.get('q')}</div>;
}

更稳的做法是把它包进 Suspense

import { Suspense } from 'react';
import SearchBar from './search-bar';

function SearchFallback() {
return <div>Loading...</div>;
}

export default function Page() {
return (
<Suspense fallback={<SearchFallback />}>
<SearchBar />
</Suspense>
);
}

这里的重点是:

  • 让真正依赖客户端参数的部分局部退到客户端
  • 不要把整页都一起拖下去

3. 鉴权信息和首帧 UI 不一致

例如服务端先按“未登录”输出,客户端又立刻从 localStorage 读出“已登录”,页面首帧就会对不上。

这种场景下,更稳的做法通常是:

  • 登录态尽量走 cookie/session
  • 让服务端也能参与首帧判断
  • 不要把关键鉴权结果只放在客户端存储里

水合和性能有什么关系

水合不是只和报错有关,它也直接影响性能。

1. 客户端 JS 越多,水合成本越高

这也是 App Router 强调 Server Components 的原因之一。

2. Pages Router 下页面数据过大,会拖慢 hydration

官方关于 Large Page Data 的错误说明就明确提到:大体量 __NEXT_DATA__ 会增加解析成本,拖慢页面可交互时间。

3. 不是所有内容都需要客户端接管

如果一块内容只是展示静态文本或服务端数据,留在服务端通常更划算。

一套比较稳的排查思路

如果页面出现 hydration mismatch,可以按这个顺序看:

1. 先看首帧是不是稳定

重点搜:

  • Date.now()
  • new Date()
  • Math.random()
  • toLocaleString()
  • typeof window
  • localStorage
  • window

2. 再看是不是把浏览器逻辑放进了渲染阶段

浏览器 API、尺寸读取、媒体查询、设备判断都很容易出问题。

3. 再看 HTML 结构是否合法

这类错误往往最容易被忽略。

4. 再看是不是某个客户端 hook 拖垮了预渲染边界

典型例子就是 useSearchParams()

5. 最后再考虑是不是需要局部 No SSR

这不应该是默认第一选择。

最实用的几个经验

1. 首帧先稳定,再谈客户端增强

这是修 hydration 问题最通用的原则。

2. 不要把所有组件都变成 client component

客户端组件越多,水合成本和出错面越大。

3. 浏览器专属逻辑尽量延后

能放到 useEffect 的,就不要在首帧渲染时做。

4. 关键状态尽量让服务端也能看到

尤其是主题、语言、登录态、AB 分流这类首帧会受影响的状态。

推荐继续往下看

  1. 渲染与数据获取
  2. 缓存与重验证
  3. 鉴权、权限与 Proxy

参考资料