React 服务端渲染
「SSR 就是服务端生成 HTML」—— 这句话只对了第一步。 真正容易踩坑的是第二步:水合(hydration),以及 React 18 之后流式和 RSC 带来的新范式。
一、先把四种渲染模式分清楚
| 模式 | HTML 何时生成 | 首屏 | SEO | 服务器成本 | 典型场景 |
|---|---|---|---|---|---|
| CSR | 浏览器里跑 JS 生成 | 慢(白屏) | 差 | 无 | 后台管理 |
| SSR | 每次请求时服务端生成 | 快 | 好 | 高(每请求都渲染) | 商品详情、个性化首页 |
| SSG | 构建时生成 | 最快(可 CDN) | 好 | 无 | 博客、文档 |
| ISR | 构建时生成 + 定时/按需重生成 | 最快 | 好 | 低 | 内容型站点、电商列表 |
选型口诀:能静态就静态(SSG),要更新就 ISR,只有「每个用户不一样且要 SEO」才上 SSR。
CSR: 请求 → 空 HTML + JS → 下载 → 执行 React → 有内容(白屏期长)
SSR: 请求 → 服务端渲染出完整 HTML → 立刻可见 → 下载 JS → 水合 → 可交互
SSG: 构建时 → 完整 HTML → CDN 直接返回CSR: 请求 → 空 HTML + JS → 下载 → 执行 React → 有内容(白屏期长)
SSR: 请求 → 服务端渲染出完整 HTML → 立刻可见 → 下载 JS → 水合 → 可交互
SSG: 构建时 → 完整 HTML → CDN 直接返回注意 SSR 的「可交互」不等于「可见」:HTML 一回来用户就能看到内容,但要等 JS 下载并水合完成,按钮才真的能点。
二、SSR 的两个阶段
阶段 1:服务端把组件渲染成 HTML 字符串
import { renderToString } from 'react-dom/server'
const html = renderToString(<App />)
// 返回完整 HTML 字符串,塞进模板返回给浏览器import { renderToString } from 'react-dom/server'
const html = renderToString(<App />)
// 返回完整 HTML 字符串,塞进模板返回给浏览器| API | 用途 |
|---|---|
renderToString | 返回 HTML 字符串,带 React 记录水合所需的标记 |
renderToStaticMarkup | 同上但不生成水合标记,体积更小;用于纯静态片段(邮件模板、非交互内容) |
阶段 2:浏览器水合(hydration)
import { hydrateRoot } from 'react-dom/client' // React 18+
hydrateRoot(document.getElementById('root'), <App />)import { hydrateRoot } from 'react-dom/client' // React 18+
hydrateRoot(document.getElementById('root'), <App />)水合做了什么:React 不重新创建 DOM,而是遍历已有 DOM,把事件监听和 state 绑上去,复用服务端生成的 HTML。
对比:
| 会重建 DOM 吗 | 适用 | |
|---|---|---|
createRoot().render() | 会(先清空再渲染) | 纯客户端 |
hydrateRoot() | 不会(复用 + 绑定) | SSR 后的页面 |
⚠️ 老代码里的
ReactDOM.render/ReactDOM.hydrate在 React 18 已废弃、React 19 已移除,统一换成createRoot/hydrateRoot。
三、水合不一致(Hydration Mismatch)
这是 SSR 最容易踩的坑:服务端渲染的 HTML 与客户端首次渲染的结果不一致,React 会报错并丢弃服务端的 DOM 重新渲染(性能与体验双输)。
五个常见原因
| 原因 | 例子 | 解法 |
|---|---|---|
| 用了浏览器 API | 服务端没有 window/localStorage | 放到 useEffect 里,或 typeof window !== 'undefined' 判断 |
| 随机/时间相关 | Math.random()、new Date() | 服务端与客户端用同一个值(由 props 传入) |
| 环境差异 | 服务端时区/语言不同 | 显式传 locale,别依赖运行环境 |
| 无效的 HTML 嵌套 | <p> 里套 <div>,浏览器自动纠正 | 修正结构(浏览器会重排 DOM,导致对不上) |
| 第三方库副作用 | 组件在渲染期直接操作 DOM | 改成 effect 里操作 |
排查方法
- 开发环境 React 会打印具体是哪个节点不一致;
- 用
suppressHydrationWarning只能屏蔽文本级差异(如时间戳),不是万能药; - 浏览器会自动纠正非法 HTML 嵌套 —— 这类问题在浏览器里看不出来,必须用 SSR 输出的 HTML 对照。
四、流式 SSR(Streaming)
传统 SSR 要等整个页面渲染完才开始发送,首字节时间(TTFB)被最慢的那部分拖住。
流式 SSR 的思路:渲染出一块就发一块,配合 Suspense 让慢的部分后到。
// 服务端(Node)
import { renderToPipeableStream } from 'react-dom/server'
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
res.setHeader('content-type', 'text/html')
pipe(res) // 外壳好了就开始流式发送
},
})// 服务端(Node)
import { renderToPipeableStream } from 'react-dom/server'
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
res.setHeader('content-type', 'text/html')
pipe(res) // 外壳好了就开始流式发送
},
})// 组件里用 Suspense 划出「慢区域」
<Suspense fallback={<Skeleton />}>
<SlowComments /> {/* 它的数据还没好时,先发 fallback */}
</Suspense>// 组件里用 Suspense 划出「慢区域」
<Suspense fallback={<Skeleton />}>
<SlowComments /> {/* 它的数据还没好时,先发 fallback */}
</Suspense>收益:外壳(导航、布局)先到,用户能立刻看到内容;慢接口不再阻塞整页。
React 18+ 还有 Selective Hydration:用户可以优先与已经水合的部分交互,而不是等全页水合完。
五、React Server Components(RSC)
RSC 不是 SSR 的替代,是另一层东西。搞混这两个是高频失分点。
| SSR | RSC | |
|---|---|---|
| 粒度 | 整页一次性渲染成 HTML | 组件级:部分组件只在服务端运行 |
| 产物 | HTML | 一种特殊的序列化格式(RSC Payload) |
| 客户端 JS | 所有组件都要下载 JS 才能水合 | 服务端组件的 JS 不下发,直接省掉 |
| 能否访问数据库 | 能(在服务端) | 能(在服务端,且更自然) |
| 交互 | 水合后可用 | Server Component 本身没有状态和事件 |
// app/page.jsx —— Next.js App Router 里默认就是 Server Component
export default async function Page() {
const posts = await db.post.findMany() // 直接查库,不用 API 中转
return <PostList posts={posts} />
}// app/page.jsx —— Next.js App Router 里默认就是 Server Component
export default async function Page() {
const posts = await db.post.findMany() // 直接查库,不用 API 中转
return <PostList posts={posts} />
}// 需要交互的组件,显式声明为客户端组件
'use client'
export function LikeButton() {
const [liked, setLiked] = useState(false)
return <button onClick={() => setLiked(true)}>{liked ? '已赞' : '点赞'}</button>
}// 需要交互的组件,显式声明为客户端组件
'use client'
export function LikeButton() {
const [liked, setLiked] = useState(false)
return <button onClick={() => setLiked(true)}>{liked ? '已赞' : '点赞'}</button>
}边界规则:Server Component 可以渲染 Client Component,反过来不行(Client 不能 import Server 组件,只能通过 props 传 children)。
六、数据获取的演进
| 时代 | 做法 |
|---|---|
| Pages Router(旧) | getServerSideProps(每次请求)/ getStaticProps(构建时) |
| App Router(新) | 直接在 async 组件里 await,fetch 默认带缓存语义 |
// Pages Router
export async function getServerSideProps(ctx) {
const data = await fetchData(ctx.params.id)
return { props: { data } }
}
// App Router:组件里直接拿
export default async function Page({ params }) {
const data = await fetchData(params.id)
return <Detail data={data} />
}// Pages Router
export async function getServerSideProps(ctx) {
const data = await fetchData(ctx.params.id)
return { props: { data } }
}
// App Router:组件里直接拿
export default async function Page({ params }) {
const data = await fetchData(params.id)
return <Detail data={data} />
}七、SSR 的代价(面试常问)
| 代价 | 说明 |
|---|---|
| 服务器压力 | 每次请求都要跑一遍 React 渲染,CPU 开销远高于静态文件 |
| TTFB 变高 | 要等服务端渲染完(流式可以缓解) |
| 缓存难 | 个性化内容没法走 CDN,只能缓存片段 |
| 复杂度 | 要处理 window 缺失、水合不一致、双端差异 |
什么时候不该用 SSR:
- 后台管理、登录后才可见的页面(不需要 SEO)→ 直接 CSR;
- 内容不常变(博客、文档)→ SSG;
- 内容变化但不个性化 → ISR。
一句话:为了 SEO 上 SSR 之前,先试试预渲染(prerender)或 SSG。
八、最小可跑的 SSR
// server.js
import express from 'express'
import { renderToString } from 'react-dom/server'
import App from './App'
const app = express()
app.use(express.static('public'))
app.get('*', (req, res) => {
const html = renderToString(<App url={req.url} />)
res.send(`<!DOCTYPE html>
<html><body>
<div id="root">${html}</div>
<script src="/main.js"></script>
</body></html>`)
})
app.listen(3000)// server.js
import express from 'express'
import { renderToString } from 'react-dom/server'
import App from './App'
const app = express()
app.use(express.static('public'))
app.get('*', (req, res) => {
const html = renderToString(<App url={req.url} />)
res.send(`<!DOCTYPE html>
<html><body>
<div id="root">${html}</div>
<script src="/main.js"></script>
</body></html>`)
})
app.listen(3000)// main.js(客户端入口)
import { hydrateRoot } from 'react-dom/client'
import App from './App'
hydrateRoot(document.getElementById('root'), <App />)// main.js(客户端入口)
import { hydrateRoot } from 'react-dom/client'
import App from './App'
hydrateRoot(document.getElementById('root'), <App />)关键点:服务端和客户端必须渲染同一个 <App />,且数据一致 —— 否则就会水合不一致。
九、速查
| 问题 | 答案 |
|---|---|
| CSR/SSR/SSG/ISR 怎么选 | 能静态就 SSG,要更新 ISR,要个性化+SEO 才 SSR |
| SSR 的两步 | 服务端 renderToString → 浏览器 hydrateRoot |
| 为什么要水合 | 复用服务端 HTML,绑定事件与状态 |
| 水合不一致的常见原因 | 用了 window/随机数/时间、非法 HTML 嵌套、渲染期操作 DOM |
| 流式 SSR 用什么 | renderToPipeableStream + Suspense |
| RSC 与 SSR 的区别 | SSR 是整页出 HTML;RSC 是组件级,服务端组件的 JS 不下发 |
| Server Component 能有状态吗 | 不能,交互必须 'use client' |
| ReactDOM.render 还能用吗 | 不能,18 废弃、19 移除,用 createRoot |
Cao Note