增量水合是对传统全量水合的根本性重构,即优先激活视口内可交互区块,其余延迟或按需激活,显著降低tti和tbt,尤其在弱网和低端设备下效果突出。

增量水合(Incremental Hydration)不是“让水合变快一点”的优化技巧,而是对传统全量水合模型的根本性重构:它把“整个页面等 JS 下载完再激活”改成“优先激活用户视线内、可交互的区块,其余延迟或按需激活”。这直接压降 TTI 和 TBT,尤其在低端设备和弱网下效果显著。
为什么 hydrateRoot 默认行为会拖垮 TTI
React 18 的 hydrateRoot(或 Vue 的 createSSRApp + mount)默认对整个 DOM 树执行同步水合。这意味着:
- 即使首屏只有 1 个按钮和 2 段文字,客户端仍要解析、构建、挂载所有组件树节点,包括页脚、广告位、埋点模块等不可见/非关键部分
- 水合过程阻塞主线程,期间任何点击、滚动都无响应;实测中,一个中等复杂度 SSR 页面全量水合常耗时 400–700ms
- 服务端已渲染的 HTML 被当作“待接管对象”,而非“可立即交互资产”——违背了 SSR 的原始价值
useTransition 与 startTransition 不是万能开关
React 的过渡 API 常被误用为“增量水合方案”,但它只控制状态更新的优先级,不改变水合范围。错误做法:
function App() {
const [isPending, startTransition] = useTransition();
// ❌ 错误:startTransition 包裹的是 render,不是 hydration
startTransition(() => setSomeState(true));
return <layout>...</layout>;
}
真正起效的方式是结合 Suspense 边界 + 组件级水合控制:
- 将非首屏区块(如评论区、相关推荐)包裹在
<suspense fallback="{null}"></suspense>中,服务端跳过其 HTML 输出,客户端首次交互时才拉取并水合 - 对首屏关键组件(如搜索框、购买按钮),用
useEffect+document.getElementById显式触发局部水合,绕过根节点同步遍历 - 避免在
useEffect中调用hydrateRoot—— 它只能调用一次;应使用createRoot对子容器单独挂载
服务端必须输出可预测的 DOM 分割标记
客户端无法凭空知道“哪块该先水合”。服务端渲染时需主动注入结构提示:
- 给首屏区块加稳定
id,例如<div id="hero-section">...,客户端用 <code>createRoot(document.getElementById('hero-section'))精准挂载 - 用
data-hydration="critical"或data-hydration="deferred"标记不同区域,避免依赖 class 名(易冲突)或顺序(不稳定) - 禁止用
Math.random()、Date.now()或未序列化的 context 值生成 key 或 ID —— 服务端与客户端输出不一致会导致 hydration mismatch - 若使用框架(如 Next.js),确认其
dynamic导入配置是否真正跳过了服务端渲染(ssr: false),否则标记无效 - 服务端启用流式渲染(如 Node.js 的
renderToPipeableStream),边生成 HTML 边发送,浏览器可边接收边解析渲染 - 首屏区块 HTML 优先 flush,后续区块(如侧栏、底部)延迟生成,甚至由客户端 fetch 后插入(注意 SEO 风险)
- 客户端监听
document.readyState === 'interactive',一旦首屏 DOM 可用,立刻启动关键区域水合,无需等待DOMContentLoaded - 禁用全局
hydrateRoot;改用多个createRoot实例分治,每个实例对应一个语义化区块(header、main、footer)
流式 HTML 响应 + 渐进式水合必须协同
仅做客户端增量水合,但服务端仍等全部数据就绪才返回完整 HTML,会放大 TTFB,抵消 TTI 改善。必须两端联动:
真正的难点不在代码怎么写,而在于如何定义“关键”——它取决于业务转化路径,而非技术直觉。一个电商详情页的“关键区块”是价格+加入购物车按钮;而一篇技术博客的“关键区块”可能是标题+首段+目录锚点。脱离业务目标谈增量水合,只会陷入过度工程。










