前端水合(Hydration)是现代服务端渲染(SSR)应用里最容易被忽视、却又直接影响首屏体验与 SEO 的关键环节。很多团队把页面用 SSR 跑起来后,却发现点击没反应、控制台疯狂报“Text content does not match server-rendered HTML”,或者首屏白屏好几秒——根因往往就出在水合上。本文用一套可复现的示例,讲清水合到底是什么、为什么会 mismatch,以及如何用流式 SSR 和局部水合把首屏真正提起来。
一、什么是水合:从 CSR 到 SSR/SSG
早期的 SPA 用纯客户端渲染(CSR):浏览器拿到一个几乎空白的 HTML,再靠 JS 把整棵树渲染出来。好处是交互流畅,坏处是首屏慢、搜索引擎难以抓取——这正是一批团队转向 SSR 的原因。
服务端渲染(SSR)在服务端用 Node 把组件跑一遍,吐出带内容的 HTML;静态站点生成(SSG)则把这一步提前到构建期。两者都解决了“首屏有内容、爬虫能读”的问题,但留下一个空白:HTML 里的按钮点了没反应,因为事件还没“接上”。这就引出了水合。
二、为什么必须水合:HTML 与 JS 的“接棒”
水合(Hydration)指的是:在客户端,框架复用服务端已经生成好的 DOM 结构,只把事件监听器、组件状态、副作用“激活”上去,而不重新生成一遍 DOM。换句话说,服务端负责“画皮”(静态结构),客户端负责“点睛”(交互能力)。如果水合顺利,用户看到的内容与 DOM 完全一致,只是从“死”变“活”。
服务端:renderToString(<App/>) -> <div><button>计数</button></div>
客户端:hydrateRoot(<App/>, domNode) // 不重建 DOM,只绑定 onClick
关键点:水合假设“服务端渲染的 HTML”与“客户端首次渲染结果”完全一致。一旦两边不一致,就触发 hydration mismatch。
这也是为什么 SSR 页面的首屏文字能立刻被搜索引擎抓取,但按钮要等 JS 下载并执行完才有反应——抓取的“内容”和交互的“能力”来自两段不同的链路,水合正是把二者缝合的那道工序。
三、Hydration mismatch 是什么
Hydration mismatch(水合不匹配)是指:客户端首次渲染生成的虚拟 DOM,与服务端下发的 HTML 在结构或文本上不一致,导致框架无法安全地复用 DOM,只能丢弃并重建,严重时还会抛出警告、丢失状态。下表列出四种最典型的不匹配类型与表现:
| 类型 | 触发原因 | 典型报错 / 表现 |
|---|---|---|
| 时间/随机数 | render 里直接调 Date.now()、Math.random() | 服务端和客户端拿到不同值,文本对不上 |
| 浏览器 API | 渲染阶段访问 window / document / localStorage | 服务端无此对象直接报错或拿到 undefined |
| 不稳定 key | 列表用数组下标或随机值作 key | 列表顺序/数量对不上,DOM 被重建 |
| 条件分支 | 依赖客户端状态(如 isMobile)做首屏分支 | 两端首屏渲染结果不同 |
四、四类最常见的 mismatch 错误
4.1 时间与随机数不一致
最常见的坑:在组件渲染逻辑里写 new Date().toLocaleString() 或 Math.random()。服务端渲染那一刻和客户端水合那一刻,时间、随机数必然不同,文本就对不上。
4.2 渲染期访问浏览器 API
在组件的渲染函数(而非 effect/生命周期)里读取 window.innerWidth、localStorage.getItem,服务端因为没有 window 对象,要么报错,要么拿到 undefined,而客户端拿到真实值,于是 mismatch。
4.3 列表缺少稳定 key
用数组下标 index 作 key,或每次渲染生成随机 key,会让框架无法正确对应两端节点。
4.4 条件渲染分支不一致
根据“是否在浏览器”这一客户端才有的信息(例如 typeof window !== ‘undefined’)做首屏分支,会导致服务端输出 A、客户端输出 B。
五、代码实战:复现并修复
下面用 React 18 复现一个时间 mismatch,并给出修复。
错误写法(服务端渲染时生成时间,客户端水合时又生成一次,两端值不同):
function Clock() {
// 每次渲染都 new 一次 Date,两端值不同 -> mismatch
const now = new Date().toLocaleTimeString();
return <span>当前时间:{now}</span>;
}
修复思路:首屏只渲染“稳定的占位内容”,把会变的值推迟到客户端 effect 之后再读。
import { useState, useEffect } from "react";
function Clock() {
const [now, setNow] = useState("");
useEffect(() => {
setNow(new Date().toLocaleTimeString()); // 挂载后才在客户端读取
}, []);
return <span>当前时间:{now || "加载中…"}</span>;
}
Vue 3 的写法同理:把浏览器相关逻辑放进 onMounted,避免在 setup 顶层直接访问 window。
import { ref, onMounted } from "vue";
const width = ref(0);
onMounted(() => {
width.value = window.innerWidth; // 此时已在客户端,不会 mismatch
});
六、水合性能优化:流式 SSR 与局部水合
光修 mismatch 还不够。即便水合顺利,如果整页 JS 都要下载执行完才开始水合,首屏依然会“有内容但点不动”。两条优化主线:
1)流式 SSR(Streaming):用 renderToPipeableStream(React)或 renderToNodeStream,让 HTML 分块到达,浏览器边收边画,配合 Suspense 把非关键内容“后置”,首屏关键内容先到。
2)局部水合(Partial / Island Hydration):只有真正需要交互的“岛屿”才水合,静态区永远不下载 JS。Astro、Islands 架构、Next.js App Router 的 Server Component 都是这个思路。
想进一步压首屏,可结合前端性能优化清单里的 Lighthouse 指标,与前端工程化构建中的代码分割策略一起看。
一个务实的落地顺序是:先用流式 SSR 把首屏关键内容提前到“秒出”,再用 Island / 局部水合砍掉非必要 JS,最后才考虑更激进的 Server Component 拆分。顺序反过来往往事倍功半——先做大拆改,却连首屏为何白屏都没定位清楚。
七、框架现状与选型
React 18 引入并发特性,hydrateRoot + Suspense 支持流式水合;Vue 3 的 createSSRApp + hydration: true 提供成熟 SSR 水合;Next.js App Router 默认 Server Component,把“不需要交互的部分”留在服务端,天然减少水合体积;Nuxt 3 默认开启混合渲染,可按路由设 ssr: false 做边缘水合。
理解水合,是读懂 React Server Components 渲染范式变革 的前置知识——RSC 本质就是把“哪些代码需要水合”的决定权上移到了组件边界。
八、小结
水合不是“SSR 之后自动就好”的魔法,而是一套需要刻意规避 mismatch、并主动优化体积的工程实践。记住三件事:首屏渲染别碰会变的值和浏览器 API、列表一定要稳定 key、交互区优先用局部水合。把首屏从“白屏几秒”做到“内容秒出、点击即应”,水合这一关必须过。
顺带一提,水合与浏览器事件循环与渲染时机互为表里:事件循环决定“什么时候渲染”,水合决定“渲染出来的东西能不能交互”。




