Skip to content

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 字符串 ​

jsx
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) ​

jsx
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 里操作

排查方法 ​

  1. 开发环境 React 会打印具体是哪个节点不一致;
  2. 用 suppressHydrationWarning 只能屏蔽文本级差异(如时间戳),不是万能药;
  3. 浏览器会自动纠正非法 HTML 嵌套 —— 这类问题在浏览器里看不出来,必须用 SSR 输出的 HTML 对照。

四、流式 SSR(Streaming) ​

传统 SSR 要等整个页面渲染完才开始发送,首字节时间(TTFB)被最慢的那部分拖住。

流式 SSR 的思路:渲染出一块就发一块,配合 Suspense 让慢的部分后到。

jsx
// 服务端(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)             // 外壳好了就开始流式发送
  },
})
jsx
// 组件里用 Suspense 划出「慢区域」
<Suspense fallback={<Skeleton />}>
  <SlowComments />        {/* 它的数据还没好时,先发 fallback */}
</Suspense>
// 组件里用 Suspense 划出「慢区域」
<Suspense fallback={<Skeleton />}>
  <SlowComments />        {/* 它的数据还没好时,先发 fallback */}
</Suspense>

收益:外壳(导航、布局)先到,用户能立刻看到内容;慢接口不再阻塞整页。

React 18+ 还有 Selective Hydration:用户可以优先与已经水合的部分交互,而不是等全页水合完。

五、React Server Components(RSC) ​

RSC 不是 SSR 的替代,是另一层东西。搞混这两个是高频失分点。

SSRRSC
粒度整页一次性渲染成 HTML组件级:部分组件只在服务端运行
产物HTML一种特殊的序列化格式(RSC Payload)
客户端 JS所有组件都要下载 JS 才能水合服务端组件的 JS 不下发,直接省掉
能否访问数据库能(在服务端)能(在服务端,且更自然)
交互水合后可用Server Component 本身没有状态和事件
jsx
// 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} />
}
jsx
// 需要交互的组件,显式声明为客户端组件
'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 默认带缓存语义
jsx
// 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 ​

jsx
// 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)
jsx
// 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