SSR、Hydration 这些词在 Web 前端领域非常常见,开发者经常能接触到这个概念。但是,这些是什么?为什么?怎么用?过去我都没有深究下去,关于 SSR,我承认我之前只是“会用”而已。
一、区分 CSR 还是 SSR?
回顾自己做的一些小玩意,发现自己大部分都是用 Vite + React + TS 来构建项目,是 CSR,采用的是 SPA 场景(Vite 默认是 SPA)。
虽说也用 Next.js 写过项目,属于 SSR 支持,但没有深究过里面的内容。
如何区分?
启动项目以后,检查页面源代码:
-
SSR 会看到完整的内容,而不是空的
<div id="root"> -
CSR 浏览器几乎拿到的就是空的 HTML
| 特性 | SPA | SSR |
|---|---|---|
| 首屏速度 | 慢(等 JS 下载执行) | 快(直接返回 HTML) |
| 页面切换 | 极快(局部刷新) | 较慢(可能重新请求) |
| SEO | 差 | 好 |
| 服务器压力 | 小(只传数据) | 大(需要渲染页面) |
| 复杂度 | 低 | 高 |
| 典型场景 | 后台系统、工具类应用 | 博客、电商、内容型网站 |
什么场景适合 SSR ?
-
内容型 C 端产品(电商、资讯、博客)需要 SEO 和首屏速度,比较适合
-
强交互的后台系统、内部工具就没必要硬上 SSR 了。
核心判断标准很简单:首屏速度和 SEO 对业务价值有多大?如果答案是“不大”,SSR 就是过度设计。
SEO(搜索引擎优化)是一种让网站在搜索引擎结果中更加清晰,也帮助我们将搜索结果更靠前的过程。
搜索引擎爬取网页,跟随页面之间的链接,并索引找到的内容。搜索时,搜索引擎会显示索引内容。爬行者遵守规则。如果你在为网站进行搜索引擎优化时密切关注这些规则,则会为网站提供最好的机会,以便在首批结果中显示,增加流量和可能的收入(用于电子商务和广告)。
二、为什么用 Next.js “没感觉”?
我觉得搭建一个脚手架用 Next.js 实现 SSR 确实是没有难度的。
框架本身封装了很多方法,用起来比较傻瓜,写代码的方式和写普通 React SPA 几乎一样,所以我“没感觉”,所以 Next.js 的使用没有很深刻的体会。
Next.js 框架保护了我这样的猪头。它作为框架,把 SSR 的工程化难题全解决了,开箱即用。从设计哲学的角度来说,Next.js是非常成功了。
又也许是因为自己的 toy 项目没感到 SSR 的好处——体量都比较小,没有太深的感受,反而觉得有点重,并不是太好用。
SSR 的特点是什么?
SSR 的代表框架有 React 生态的 Next.js 和 Vue 生态的 Nuxt。
它的优点是首屏加载快,用户能很快看到内容,同时 SEO 友好,搜索引擎可以直接抓取到完整的 HTML;
缺点也很明显:服务器压力更大,因为每次请求都要重新渲染页面,而且整体复杂度高,需要处理水合(Hydration)、DOM 结构匹配等一系列问题。
按照这些特点来看,之前我的猜想是有道理的。toy项目没有必要SSR,自然也就感受不到。
那么,SSR 的真正价值场景是什么呢?
SSR 常应用在首屏依赖大量异步数据(比如电商详情页、资讯详情页)、SEO 直接决定业务流量(C 端内容产品)、弱网环境下 HTML 比 JSON+JS 更早到达的情况下。
而我的小项目,SEO 无所谓,首屏慢 0.5 秒也没人投诉。这种场景强行上 SSR,反而是用复杂度换不存在的问题。
框架无好坏,场景才决定选择。
渲染模式其实是一个光谱,不是简单的二选一。
从纯静态 HTML 到 SSG、ISR、SSR、流式 SSR、CSR,再到部分水合、群岛架构,中间有很多种组合,也就衍生到一个混合渲染的概念,这在现代框架中其实是常见的。
现代趋势:混合方案
现在很多框架(Next.js、Nuxt)允许按页面或按组件选择:
- 不需要 SEO 的后台页面 → 用 SPA 模式
- 需要首屏快、SEO 好的营销页/博客 → 用 SSR 模式
这就是所谓的混合渲染。
例如 Next.js 的 'use client',一开始我以为是“只在客户端运行”的意思。但它其实是水合边界的声明,不是运行环境的切换——只有你显式标记的组件才会被水合。
标记了 'use client' 的组件,服务端照样会执行一次来生成 HTML,只是客户端需要再次激活。所以它并不解决服务端没有 window 的问题,你依然要用 useEffect 或动态导入来绕过。
再看 Next.js 的 App Router,它默认就是 React Server Components,连 JS 都不发给客户端,只有显式标记 'use client' 的组件才会被水合。这种“部分水合”的思路,其实更接近 Astro 的群岛架构。
三、SSR 到底是怎么工作的?
简单说,流程是:
用户访问 → 服务器在服务端就把组件渲染成完整 HTML → 浏览器直接看到完整页面 → 再下载 JS 补充交互 Hydration(水合)。
如果用 React 官方的底层 API 来理解,核心就是 renderToString 或者更高级的 renderToPipeableStream(流式 SSR)。
服务端把 <App /> 变成 HTML 字符串,浏览器拿到后先展示,再用 hydrateRoot 把事件绑上去。
把组件变成 HTML
这一步其实和 React 在浏览器里做的事一模一样,只是运行的地方不同。
浏览器里(CSR):
组件代码 → React 计算虚拟 DOM → 变成真实 DOM → 挂到 <div id="root"> 里
服务器上(SSR):
组件代码 → React 计算虚拟 DOM → 变成 HTML 字符串 → 直接拼到响应里返回
服务器上不需要挂载 DOM,只需要输出字符串。
React 提供了专门的 API 来做这件事:
renderToString():把一个组件变成 HTML 字符串renderToPipeableStream():流式输出,边渲染边发送
所以“在服务端把组件渲染成完整 HTML”这句话,底层就是调了 renderToString(<App />)。
hydrateRoot 的作用
已经有了 HTML 了(服务端生成的),现在 JS 加载完了,React 需要在浏览器里接管这个页面。
如果不做任何事:页面看起来是好的,但按钮点不了,输入框没反应——因为事件监听器还没绑上去。
如果用传统的 render:React 会把现有的 DOM 全部删掉,重新创建一份,再把事件绑上去。这样效率低,而且用户可能会看到页面闪烁。
hydrateRoot 的做法:
- 检查现有的 DOM 结构
- 不删除、不重建
- 直接在已有的 DOM 节点上挂载事件监听器
- 把内部状态初始化好
打个比方:你收到了一辆已经组装好的车(服务端给的 HTML),技师(
hydrateRoot)不需要把车拆了重装,只需要把方向盘、刹车、油门这些操控装置接上,车就能开了。
This will hydrate the server HTML inside the browser DOM node with the React component for your app. Usually, you will do it once at startup. If you use a framework, it might do this behind the scenes for you. 这样可以将浏览器 DOM 节点内的服务器 HTML 与应用的 React 组件进行水合。通常,你会在启动时做一次。如果你用框架,它可能在幕后帮你做到这一点。
To hydrate your app, React will “attach” your components’ logic to the initial generated HTML from the server. Hydration turns the initial HTML snapshot from the server into a fully interactive app that runs in the browser. 为了给你的应用提供水合,React 会把组件的逻辑“附加”到服务器最初生成的 HTML 上。Hydration 将服务器上的初始 HTML 快照转化为一个在浏览器中运行的完全交互式应用。
这里只是介绍了一下react 框架对于 ssr的做法,更多细节还是具体结合项目代码或者源码。
建议读一读各个框架关于 SSR ,Hydration的官方介绍文档。
用最简单的代码对比纯 CSR与SSR + Hydration
纯 CSR
import { createRoot } from 'react-dom/client'
import App from './App'
// 创建一个新的根节点,从头渲染
const root = createRoot(document.getElementById('root'))
root.render(<App />)
SSR + Hydration
import { hydrateRoot } from 'react-dom/client'
import App from './App'
// 不创建新节点,而是复用已有的 HTML
hydrateRoot(document.getElementById('root'), <App />)
区别就这一行:createRoot → hydrateRoot,其他代码一模一样。
写到这里,发现"没感觉"的真相自然水落石出——框架把这一切都隐藏了。
用 Next.js 的时候:
- 不需要手动调用
renderToString - 不需要手动调用
hydrateRoot
只管写组件,框架帮你做了。
这也就是我之前说的“框架保护了我这样的猪头”——好处是省事,坏处是不知道底层发生了什么。
React的最小示例:
server.js
import express from 'express'
import { renderToString } from 'react-dom/server'
import App from './App.jsx'
const app = express()
app.get('/', (req, res) => {
const html = renderToString(<App />)
res.send(`
<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
<script src="/client.js"></script>
</body>
</html>
`)
})
app.listen(3000)
client.js
import { hydrateRoot } from 'react-dom/client'
import App from './App.jsx'
hydrateRoot(document.getElementById('root'), <App />)
- 页面是服务端生成的(右键查看源代码能看到完整内容)
- 水合成功后,页面可以交互
- 如果 App 组件里写了
Date.now(),会报水合错误
看到第三点,也许会有疑问了,为什么会报错误呢?接下来就说说水合。
四、关于 Hydration(水合)
1. 服务端:生成静态骨架 (Server-Side Rendering)
- 动作:当用户访问页面时,服务器会快速获取数据,将你的框架代码(如 React、Vue)渲染成一段完整的 HTML 字符串。
- 结果:用户浏览器接收到的是立即可见的完整 HTML 页面,解决了传统 SPA 应用“白屏”和 SEO 难的问题。
2. 客户端:注入灵魂与交互 (Hydration)
- 动作:浏览器显示 HTML 后,会加载 JS 文件。框架(如 React 的
hydrateRoot)开始工作,它不会重新创建整个 DOM(那样会浪费性能),而是找到现有的 DOM 节点,在上面挂载事件监听器(如onClick)和绑定内部状态。 - 结果:原本“静态”的页面瞬间“活”了,变成了一个可点击、可交互的完整单页应用(SPA)。
一个生动的比喻
水合就像给一辆已经组装好的顶级跑车(静态 HTML)安装发动机和方向盘(JS 逻辑)。你在展厅里第一眼就能看到它炫酷的外形(快速首屏),但只有技师把操控系统装进去后,你才能真正点火开走它。
一开始我以为水合就是给 DOM 挂上事件监听器,后来发现没那么简单。
水合时 React 到底在比什么?
不是比内容文本,而是比 DOM 树结构。
服务端生成的 HTML 结构,必须和客户端第一次 render 生成的虚拟 DOM 结构完全一致。
如果不一致,React 会直接报错 Text content did not match。
更麻烦的后果:
水合失败后,React 不会尝试修复,而是直接丢弃服务端渲染的 HTML,在客户端重新渲染整棵子树。降级为 CSR 行为。
这意味着你付出了 SSR 的成本(服务端渲染 + 网络传输),却没有得到首屏收益,甚至比纯 CSR 更慢。
常见的水合失败场景(代码级):
// ❌ 危险:时间戳不一致
function BadComponent() {
return <div>{Date.now()}</div>
}
// ❌ 危险:条件判断不一致
function BadComponent() {
return <div>{typeof window === 'object' ? '客户端' : '服务端'}</div>
}
// ✅ 安全:把依赖浏览器的逻辑放到 useEffect
function GoodComponent() {
const [timestamp, setTimestamp] = useState(null)
useEffect(() => setTimestamp(Date.now()), [])
return <div>{timestamp}</div>
}
React 18 的选择性水合:
新版本支持用 <Suspense> 包裹组件,优先激活用户正在交互的部分,而不是等全部水合完成。这对大型页面很实用。
五、Modern.js 与新生态
了解 SSR 原理后,我们来看看新一代框架是如何优化这个过程的。
前段时间才发现字节 2021 年开源的 Modern.js 框架。可以列表对比一下这两个框架。
| 技术 | Next.js | Modern.js |
|---|---|---|
| 打包工具 | Webpack / Turbopack | Rspack (基于 Rust,速度更快) |
| 转译工具 | Babel / SWC | SWC 默认 |
| 路由 | 自研(App Router / Pages Router) | React Router 7 + 约定式路由 |
| 服务端框架 | 自研 | Hono.js(轻量高性能) |
本人在构建了许多许多次 Express 框架以后,才认识到 Express 确实比较老了,但对于新手学习成本低,生态也传统而稳定。我最近才了解到 Hono、Bun 等较新的生态,更多构建工具相关的 Rsbuild、Rspack 等内容对我来说全是新的,值得学一学,拓展一下知识面。
- Rspack:Webpack 的 Rust 替代品,API 兼容但构建速度快很多。解决大型项目 Webpack 构建慢的痛点。目前生态还不够成熟,插件支持不如 Webpack。
- Hono:轻量级 Web 框架,跨运行时,原生 Promise 支持。比 Express 更适合现代 JavaScript 生态。
这些“新东西”解决了什么问题?
我发现一个规律:从 Webpack 到 Rspack,从 Express 到 Hono,本质都是用编译型语言(Rust/Go)重写 JS 工具链。
这不是卷(也许也是哈哈哈),是因为 JS 做构建工具已经到天花板了——单线程、动态类型、运行时开销大。
Rspack 用 Rust 实现多线程并行编译,在大型项目上构建速度能提升 5-10 倍。
Hono 比 Express 强在哪?
- 跨运行时(Node、Deno、Bun、Cloudflare Workers 都能跑),原生 Promise 支持,TypeScript 一等公民。Express 设计于 2010 年,那时候还没有
async/await,异步中间件的处理方式现在看起来确实有点别扭。
Modern.js 的定位:
- 它更像一个“全家桶”方案,不仅仅是 SSR 框架,还内置了 BFF 一体化、国际化、Monorepo 支持等。和 Next.js 的“轻核心 + 社区生态”走的是两条路。字节内部大规模验证过,国内场景优化(比如 Ant Design 按需加载)做得更细致。
六、小结
回过头看,SSR 和 CSR 没有绝对的好坏,更多是场景驱动的选择。
Next.js 让我“无感”,恰恰说明它封装得足够好;而 Express 虽老,却依然适合新手入门。
技术世界变化很快,新生态、新框架、新概念总是日新月异。但好奇心永远是最好的导航。
这些新东西不需要全都学,但了解它们解决了什么问题,或许能帮我们在合适的场景做出更好的选择。
限于个人写作,文中若有疏漏,还请不吝赐教。
参考文档
SEO - MDN Web 文档术语表:Web 相关术语的定义 | MDN