之前说到过 Hydration Mismatch 原理,今天来说说 为什么从设计上,它就无法完全避免
一、先把问题还原到最本质
SSR 做了两件事:
- 服务端生成 HTML
- 客户端接管(Hydration)
Hydration 的本质是:
在不重建 DOM 的情况下,让 JS 接管已有 HTML (原理详解)
这里有一个隐含前提:
客户端"重新执行一遍渲染逻辑",并且结果必须和服务端完全一致 (原理详解)
二、关键矛盾:同一份代码,在两个环境执行
这是问题的根源。
SSR 架构本质是:
text
同一套组件逻辑
在两个环境执行:
- Node(服务端)
- Browser(客户端)
问题在于:
这两个环境永远不可能完全一致
三、用一个最简单的例子说明
js
<span>{new Date().getMilliseconds()}</span>
服务端输出:
html
<span>123</span>
客户端执行:
html
<span>167</span>
结果:
不一致 → mismatch (Vite-plugin-ssr)
所以推导出一个结论:只要渲染依赖"运行时",就必然存在不一致的可能
四、为什么"不一致"是必然的
从几个维度拆开看:
1. 时间是不一致的
js
Date.now()
new Date()
- 服务端时间 ≠ 客户端时间
- 网络延迟会放大差异
结论:
只要用时间,就有 mismatch 风险 (Vite-plugin-ssr)
2. 环境是不一致的
服务端没有:
js
window
document
localStorage
客户端有,典型代码:
js
if (typeof window !== 'undefined') {
// client logic
}
结果:
- 服务端渲染 A
- 客户端渲染 B
直接 mismatch
3. 状态是不一致的
例如:
js
if (localStorage.getItem('token')) {
return <Home />
} else {
return <Login />
}
服务端:
- 没有 localStorage → Login
客户端:
- 有 token → Home
结果:
DOM 完全不同
4. 随机性是不一致的
js
Math.random()
uuid()
服务端生成一套
客户端再生成一套
结论:
不可能一致
5. 执行顺序是不一致的
例如:
- async 数据
- 并发渲染
- 不稳定 ID 生成
再次重复一开始提到的前提:SSR 本质是"重复计算",而重复计算无法保证一致性
五、为什么框架"必须要求一致"?
问题来了:
为什么一定要一致?
因为 Hydration 的优化前提是:
text
复用已有 DOM
而不是重新创建
如果不一致:
框架只能:
- 丢弃 DOM
- 重新渲染
这会导致:
- 闪屏
- 性能下降
- 交互延迟 (原理详解)
一个更本质的矛盾
SSR 想要:
提前生成 HTML(提高首屏)
但 Hydration 需要:
再执行一遍渲染逻辑
这本身就是:
一次"重复执行系统"
而所有重复执行系统都有一个问题:
一致性无法保证
六、工程上怎么"缓解",但不是"解决"(mismatch 只能减少,无法彻底消灭)
常见手段:
1. 保证初始数据一致
js
// server 计算
const data = fetch()
// 注入 HTML
window.__INITIAL_DATA__ = data
2. 延迟客户端逻辑
js
useEffect(() => {
// client only
}, [])
3. 避免不确定性
不要在 render 中用:
- Date
- Math.random
- 浏览器 API
4. 架构层优化
例如:
- Islands Architecture
- Partial Hydration
本质是:
减少需要 hydration 的范围
七、讨论一下 Islands:为什么这个架构本质是在"逃避 hydration"
先看传统 SSR 流程:
text
Server Render HTML
↓
Browser 接收 HTML
↓
整页 Hydration
↓
页面可交互
问题在于:
页面里很多内容其实根本不需要交互。
例如一个电商详情页:
- 商品标题
- 商品描述
- banner
- 评论文本
- footer
这些内容:
- 需要 SEO
- 需要首屏展示
- 但不需要 JS 接管
然而传统 SSR 会做什么?
即使这些内容完全静态,也要执行 hydration。
也就是说:
text
静态内容 + 动态内容
全部进入 hydration
这就带来两个问题:
1. 不必要的 JS 执行
例如页面结构:
html
<div>
Header
ProductInfo
Comments
BuyButton
Footer
</div>
真正需要交互的可能只有:
text
BuyButton
但传统 hydration:
text
Header hydrate
ProductInfo hydrate
Comments hydrate
BuyButton hydrate
Footer hydrate
本质上:
为了一个按钮,整页都要重复执行一遍。
这很浪费。
2. mismatch 风险被放大
前面说过:
hydration 本质是重复计算
那只要重复计算范围越大:
- 时间差异
- 环境差异
- 状态差异
都会扩大。
整页 hydration 意味着:
整页都有 mismatch 风险。
Islands 的思路:不要整页 hydration
Islands 架构把页面拆成:
- Static HTML
- Interactive Islands
例如:
html
<Header />
<ProductInfo />
<BuyButton client:load />
<Footer />
只有:
text
BuyButton
需要客户端接管。
其余部分:
- 只保留 HTML
- 永不 hydration
流程变成:
text
Server Render HTML
↓
静态部分直接展示
↓
仅局部组件 hydration
本质变化
传统 SSR:
HTML 先渲染,JS 再接管整页
Islands:
HTML 默认静态,只有少数区域需要 JS
所以它的核心思想不是:
"怎么更高效地 hydration"
而是:
尽量少 hydration,甚至不 hydration
这就是为什么说:
Islands 本质是在逃避 hydration。
不是因为 hydration 做得不好,而是因为:
hydration 天然昂贵且天然存在一致性问题。
既然如此,最好的办法不是优化它,而是减少它。
这样带来的收益
- JS 体积更小,只加载交互组件代码,不是整页 bundle。
- hydration 更快,因为只 hydration 局部。
- mismatch 风险更低,范围缩小:整页风险 → 局部风险
- 更接近"默认静态",因为现代 Web 一个趋势是:Static by default, interactive when needed,也就是:
- 默认静态
- 局部增强
这和传统 SPA 思路完全相反。
一个更高的理解
SSR 第一阶段在解决:
首屏白屏问题
Hydration 第二阶段在解决:
HTML 如何变成交互页面
而 Islands 的想法是:
并不是所有 HTML 都需要变成交互页面。
所以它直接重新定义问题。
这就是架构升级。
不是:How to hydrate better
而是:How to hydrate less
八、最终结论
Hydration mismatch 不是一个"bug",而是 SSR 架构的副作用。
因为 SSR 本质是让同一段渲染逻辑在两个不同环境执行,而只要存在时间、环境、状态或随机性的差异,就无法保证输出完全一致。
框架要求一致,是为了复用 DOM 提升性能;而不一致,就只能回退到重新渲染。
因此,hydration mismatch 并不是"可以彻底避免的问题",而是一个需要被工程化控制的问题。
Traditional SSR 的问题不是不能工作,而是默认假设"整页都需要交互"。
但现实中,大部分页面内容本质是静态的。
Islands 架构通过把页面拆分为静态区域和交互区域,只对少量组件执行 hydration。
因此它并不是在优化 hydration,而是在架构层面减少 hydration 的发生范围。
某种意义上,Islands 并不是解决 hydration 的问题,而是在逃避 hydration 本身。