服务端渲染的优势是把首屏HTML直接送到浏览器,但它有一个绕不开的环节:水合。水合的作用是让已经显示出来的静态HTML恢复交互能力,比如绑定点击事件、初始化组件状态、建立响应式依赖。一般框架在客户端启动时会从根组件开始递归遍历整棵应用树,这个过程如果控制不好,首屏交互就会被明显推迟。要优化水合,得先弄清楚它到底在做什么,哪些步骤可以省略或延后。

水合的真实代价:为什么全量遍历会拖慢交互
服务端返回的HTML只是字符串,浏览器解析后形成DOM树,但这些DOM节点并没有任何JavaScript逻辑。框架需要重新创建组件树,并把服务端生成的内容与客户端组件状态一一对应,这个对应过程就是水合。以React为例,hydrateRoot会复用已有DOM,同时在内存中构建Fiber树,然后为每个可交互元素注册事件。这个过程虽然不需要重新插入节点,但依然要遍历整棵组件树,执行组件初始化逻辑,计算props和state,有时还要进行差异比较。
如果应用规模较大,比如首屏包含几百个组件、几千个DOM节点,全量水合的执行时间可能达到数百毫秒甚至上秒。用户看到页面内容已经出现,却无法点击、无法输入,只能等待主线程腾出空来。这种可见不可用的状态伤害体验,也直接影响INP等交互指标。更麻烦的是,很多内容其实并不需要交互,例如文章正文、静态导航、页脚说明等,把它们也纳入水合范围纯属浪费。
从性能分析角度看,水合的开销主要集中在三部分:组件初始化、事件系统挂载、虚拟DOM与真实DOM的对照。框架通常假设所有内容都可能在客户端更新,因此默认全量处理。要优化水合,就要打破这个假设,让框架只处理真正需要交互的部分。
import { hydrateRoot } from 'react-dom/client';
import App from './App';
const domNode = document.getElementById('root');
hydrateRoot(domNode, <App />);
按需水合:跳过静态节点和无关区域
按需水合的核心思想是显式标记哪些内容不需要在客户端激活。Vue提供了v-once指令,被标记的节点在服务端渲染后不会进入客户端的响应式系统,也就不会参与水合。对于纯展示型内容,这个指令能直接减少水合范围。React没有等价的官方指令,但可以通过将静态内容提取到独立组件外、使用memo或手动控制渲染来降低部分开销。
除了跳过静态节点,还可以把大型应用拆成更小的水合单元。React 18的选择性水合允许在Suspense边界内延迟非关键部分的水合。当用户与某个区域交互时,React会优先处理该区域,而不必等待整棵树全部水合完成。下面的代码把评论区和推荐列表用Suspense包裹,让首屏核心内容先完成水合,其余区域延后。
import { hydrateRoot } from 'react-dom/client';
import { lazy, Suspense } from 'react';
const Comments = lazy(() => import('./Comments'));
const Recommendations = lazy(() => import('./Recommendations'));
function App() {
return (
<div>
<Article />
<Suspense fallback={<span>评论加载中</span>}>
<Comments />
</Suspense>
<Suspense fallback={<span>推荐加载中</span>}>
<Recommendations />
</Suspense>
</div>
);
}
hydrateRoot(document.getElementById('root'), <App />);
这种策略的关键在于确定水合边界。边界过粗,优化效果有限;边界过细,又会增加代码分割和异步加载的复杂度。通常可以按路由、按区块、按交互密度来划分,把首屏必需的部分设为立即水合,次要内容放到空闲时间或用户接近时再处理。
渐进式水合与Islands架构
渐进式水合不要求一开始就完成所有组件的激活,而是利用浏览器的空闲时间逐步处理。结合requestIdleCallback或框架的调度机制,可以把非关键组件的水合任务拆成小块,避免长时间占用主线程。Islands架构则更进一步,它把页面看作由多个独立岛屿组成,每个岛屿负责自己的交互逻辑,岛屿之间的静态内容完全不参与客户端脚本。
Astro是Islands架构的典型代表。下面的代码中,顶部导航是纯静态内容,搜索框在空闲时水合,评论列表只有进入视口时才水合。这样首屏需要执行的JavaScript量大幅下降,几乎只有几个小岛需要激活。
<header> <nav>静态导航,不做水合</nav> </header> <main> <SearchBox client:idle /> <CommentList client:visible /> </main>
Islands架构的优势是水合范围天然受限,但代价是跨岛屿通信需要额外机制。如果页面存在大量联动状态,比如全局购物车、实时通知等,岛屿之间的数据同步会变得复杂。因此它更适合内容为主的站点,而不是重交互的中后台应用。
工程层面的水合优化实践
框架提供的能力之外,工程实现也会影响水合速度。服务端渲染时尽量减少需要在客户端初始化的全局状态,避免把大体积数据直接注入页面。比如只序列化首屏必需的少量状态,其余数据通过异步请求在客户端获取。这能缩短水合阶段的状态恢复时间,也能降低HTML体积。
流式服务端渲染同样可以改善体验。Node.js的renderToPipeableStream允许服务端先输出头部和首屏骨架,浏览器可以提前解析HTML,随后再继续输出剩余内容。虽然水合仍然要等完整应用代码加载,但用户能更早看到页面,感知性能更好。下面的示例展示了React流式渲染的基本用法。
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
app.get('/', (req, res) => {
const stream = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
res.setHeader('content-type', 'text/html');
stream.pipe(res);
}
});
});
此外,通过监控performance面板中的脚本执行时间和主线程长任务,可以定位水合瓶颈。把水合相关的代码尽量拆小,确保单次任务不超过50ms,能有效避免页面掉帧。对于低端设备,甚至可以考虑提供降级方案,例如对非核心区域使用客户端渲染替代水合,让交互优先可用。
不同框架的水合思路对比
React和Vue传统上采用全量水合,但分别在18和3版本中加入了优化手段。React的选择性水合依赖Suspense边界,Vue则通过v-once和异步组件减少水合范围。Svelte在服务端渲染时会编译出更轻量的客户端代码,水合负担相对较小,但仍是遍历式激活。
Qwik走了一条不同的路,它提出可恢复性概念,不进行传统水合。服务端把应用状态序列化到HTML中,客户端按需加载对应的事件处理器,用户点击时直接恢复执行环境,不需要重建整棵组件树。这种方式几乎消除了水合成本,但需要改变组件的编写方式,并使用其特有的序列化机制。
选择哪种方案要看具体场景。内容型站点可以优先考虑Astro或Qwik,把水合降到最低;交互密集型应用则更适合React或Vue,并结合按需水合策略控制首屏开销。无论哪种方案,核心都是减少主线程在首屏阶段的非必要工作,让用户可以更快地真正使用页面。