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
例如:
windowlocalStoragedocument
如果在服务端渲染阶段就依赖这些东西,结果通常会不一致,甚至直接报错。
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 windowlocalStoragewindow
2. 再看是不是把浏览器逻辑放进了渲染阶段
浏览器 API、尺寸读取、媒体查询、设备判断都很容易出问题。