React 服务端渲染
问题
什么是 SSR(服务端渲染)?React 如何实现 SSR?Streaming SSR 带来了哪些改进?SSR 中常见的 Hydration Mismatch 如何解决?
什么是 SSR(服务端渲染)? 服务端把 React 组件渲染成完整 HTML 字符串发给浏览器,用户立即看到内容,再由客户端 JS Hydration(水合)绑事件让页面活起来:
- 解决 CSR 两大痛点:首屏白屏(要等 JS 下载执行)、SEO 差(爬虫拿到空 HTML)。
- 代价:服务端有计算成本、TTFB 变慢、需要同构代码、要处理 Node 环境无
window/document的问题。 - 兄弟模式:CSR(实时 / 服务器零压力)、SSG(构建期生成 / 最快但数据是快照)、ISR(SSG + 按需 revalidate)。
React 如何实现 SSR? 核心是「服务端 render → 客户端 hydrate」两步:
- 服务端用
renderToString(<App />)(同步、阻塞)或renderToPipeableStream(流式、推荐)把组件转 HTML。 - 把首屏数据序列化进
window.__INITIAL_DATA__,避免客户端再请求一次。 - 客户端用
hydrateRoot(container, <App data={...} />),不重建 DOM,只比对并绑事件。 - Next.js 里 Pages Router 用
getServerSideProps,App Router 直接async function Page()+ Server Components。
Streaming SSR 带来了哪些改进? 传统 SSR 是「全有或全无」:要等所有数据 → 全部渲染完 → 全部 JS 加载完 → 全部 hydrate 才能交互。Streaming 把这三步都打散:
renderToPipeableStream+<Suspense>划分边界:Shell 部分立即推给浏览器,慢数据准备好后再流式追加。- 选择性 Hydration:每个 Suspense 边界独立 hydrate,用户点哪儿优先 hydrate 哪儿,不用等整页 JS。
- 结果:TTFB / FCP 大幅下降,慢接口不再拖累整页。
SSR 中常见的 Hydration Mismatch 如何解决? Mismatch 是指客户端首次渲染的虚拟 DOM 和服务端 HTML 对不上,会触发警告甚至 UI 错乱:
- 常见原因:用了
Date.now()/Math.random()/new Date().toLocaleString()这种「服务端和客户端结果不一致」的逻辑;直接读window/localStorage;浏览器扩展改了 DOM;时区/Locale 差异。 - 处理思路:① 把不一致逻辑放进
useEffect(只在客户端跑);② 用suppressHydrationWarning临时屏蔽(最后手段);③ 用 Next.js 的'use client'+ 动态 import 关 SSR:dynamic(() => import('./X'), { ssr: false });④ 数据用服务端注入,避免客户端二次取拿到不同结果。
答案
SSR(Server-Side Rendering) 是指在服务端将 React 组件渲染成 HTML 字符串,将完整的 HTML 发送给浏览器,浏览器先展示静态页面,再通过 Hydration(水合/激活) 让页面具备交互能力。SSR 是解决 SPA 首屏性能和 SEO 问题的核心方案。
一、SSR 概念与动机
为什么需要 SSR
传统 CSR(Client-Side Rendering)的页面初始是一个空的 <div id="root"></div>,所有内容依赖 JavaScript 下载执行后才能渲染。这带来两个核心问题:
- 首屏白屏时间长 — 用户需要等待 JS 下载、解析、执行、数据请求完成后才能看到内容
- SEO 不友好 — 搜索引擎爬虫抓取到的是空 HTML,无法索引页面内容
SSR 在服务端直接输出完整 HTML,用户可以立即看到页面内容,搜索引擎也能正常抓取。
CSR vs SSR vs SSG vs ISR 对比
| 特性 | CSR | SSR | SSG | ISR |
|---|---|---|---|---|
| 渲染时机 | 客户端运行时 | 每次请求时 | 构建时 | 构建时 + 按需更新 |
| 首屏速度 | 慢 | 快 | 最快 | 快 |
| SEO | 差 | 好 | 好 | 好 |
| 服务器压力 | 低 | 高 | 低 | 低 |
| 数据实时性 | 实时 | 实时 | 构建时快照 | 准实时(revalidate) |
| TTFB | 快 | 较慢 | 最快 | 快 |
| 适用场景 | 后台管理 | 动态内容页 | 博客/文档 | 电商/新闻 |
| 代表框架 | CRA、Vite | Next.js SSR | Next.js SSG | Next.js ISR |
SSR 的本质是 时间换空间 — 用服务端计算时间换取用户更快看到内容的时间。但代价是增加了服务器负载和架构复杂度。
二、React SSR 核心 API
React 提供了一组 API 在服务端将组件渲染为 HTML:
| API | 环境 | 特点 |
|---|---|---|
renderToString | Node.js | 同步渲染,返回完整 HTML 字符串 |
renderToPipeableStream | Node.js | 流式渲染,支持 Suspense,React 18+ |
renderToReadableStream | Web Stream(Edge) | 流式渲染,适用于 Edge Runtime |
hydrateRoot | 浏览器 | 客户端激活,绑定事件和状态 |
2.1 renderToString(传统方式)
import { renderToString } from 'react-dom/server';
import App from './App';
app.get('/', (req, res) => {
// 服务端获取数据
const data = await fetchData();
const html = renderToString(<App data={data} />);
res.send(`
<!DOCTYPE html>
<html>
<head><title>SSR App</title></head>
<body>
<div id="root">${html}</div>
<script>
// 将数据序列化到页面,供客户端 hydrate 使用
window.__INITIAL_DATA__ = ${JSON.stringify(data)};
</script>
<script src="/bundle.js"></script>
</body>
</html>
`);
});
renderToString 是同步阻塞的 — 整个组件树必须全部渲染完成才会返回。对于大型页面,这会导致 TTFB(Time To First Byte)过长。React 18 推荐使用流式 API 替代。
2.2 Hydration — 客户端激活
服务端返回的 HTML 是静态的,没有事件绑定和状态管理。客户端需要通过 Hydration 让页面"活"过来:
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// 从服务端注入的数据中恢复初始状态
const data = window.__INITIAL_DATA__;
// hydrateRoot 不会重新创建 DOM,而是复用服务端生成的 DOM
// 只绑定事件监听器和恢复 React 内部状态
hydrateRoot(document.getElementById('root')!, <App data={data} />);
- React 在客户端重新执行组件渲染逻辑(虚拟 DOM)
- 将虚拟 DOM 与服务端生成的真实 DOM 进行 比对
- 如果匹配,直接 复用 现有 DOM 节点,只绑定事件
- 如果不匹配,产生 Hydration Mismatch 警告,可能导致 UI 异常
三、SSR 渲染流程
四、数据获取策略
SSR 中数据获取是最核心的环节之一,需要在服务端完成数据准备后再渲染组件。
服务端获取 vs 客户端获取
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 服务端获取 | SEO 关键内容、首屏数据 | FCP 快、SEO 好 | 增加 TTFB |
| 客户端获取 | 用户交互后的数据、非关键内容 | 服务器压力小 | 用户看到 loading 状态 |
| 混合策略 | 大多数场景 | 平衡首屏和交互 | 架构复杂度高 |
首屏关键数据在服务端获取,非关键数据和用户交互数据在客户端获取。这是目前业界通用的混合策略。
五、React 18 Streaming SSR
React 18 引入的 Streaming SSR 是 SSR 的一次重大升级,解决了传统 SSR 的 "全有或全无" 问题。
5.1 传统 SSR 的问题
传统 SSR 存在三个瓶颈,每一步都必须等上一步全部完成:
- 数据获取 — 必须在服务端获取所有数据后才能开始渲染
- 渲染 — 必须渲染完整个组件树后才能发送 HTML
- Hydration — 必须加载完所有 JS 后才能开始 hydrate,必须 hydrate 完所有组件后页面才能交互
5.2 renderToPipeableStream + Suspense
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/bundle.js'],
onShellReady() {
// Shell(Suspense 边界外的内容)准备好后立即开始流式发送
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onError(error) {
console.error(error);
res.statusCode = 500;
},
}
);
});
在组件中使用 Suspense 划分流式边界:
function App() {
return (
<html>
<body>
<Header /> {/* Shell:立即渲染 */}
<MainContent /> {/* Shell:立即渲染 */}
{/* Suspense 包裹的部分会延迟发送 */}
<Suspense fallback={<CommentsLoading />}>
<Comments /> {/* 数据就绪后流式注入 */}
</Suspense>
<Suspense fallback={<SidebarLoading />}>
<Sidebar /> {/* 另一个独立的流式块 */}
</Suspense>
</body>
</html>
);
}
5.3 选择性 Hydration(Selective Hydration)
React 18 的 Streaming SSR 配合 Suspense 实现了 选择性 Hydration:
- 不再需要等待所有 JS 加载完才开始 hydrate
- 每个
Suspense边界可以独立 hydrate - 用户交互的区域会被优先 hydrate(紧急 hydration)
5.4 Streaming SSR vs 传统 SSR
| 特性 | 传统 SSR | Streaming SSR |
|---|---|---|
| TTFB | 慢(等所有数据就绪) | 快(Shell 立即发送) |
| FCP | 慢 | 快 |
| 渲染方式 | 全量渲染后一次性返回 | 流式分块发送 |
| Hydration | 全量 Hydration | 选择性 Hydration |
| 用户交互 | 等全部 hydrate 完成 | 交互区域优先 hydrate |
| 慢数据源影响 | 阻塞整个页面 | 仅阻塞对应 Suspense 块 |
Streaming SSR 的核心价值在于解耦 — 快的部分先走,慢的部分不拖累整体,用户可以更快看到并操作页面。
六、Next.js SSR 实践
Next.js 是 React SSR 的主流实践框架,提供了两套路由系统。
6.1 Pages Router
// 每次请求时在服务端执行
export async function getServerSideProps(context) {
const { id } = context.params;
const res = await fetch(`https://api.example.com/posts/${id}`);
const post = await res.json();
return {
props: { post }, // 传递给页面组件
};
}
export default function Post({ post }) {
return <article>{post.title}</article>;
}
| 方法 | 执行时机 | 用途 |
|---|---|---|
getServerSideProps | 每次请求 | SSR,动态内容 |
getStaticProps | 构建时 | SSG,静态内容 |
getStaticPaths | 构建时 | 动态路由的 SSG |
6.2 App Router(推荐)
App Router 基于 React Server Components,默认所有组件都是 Server Component:
// Server Component — 直接 async/await 获取数据
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
// 无需 getServerSideProps,直接在组件内获取数据
const post = await fetch(`https://api.example.com/posts/${params.id}`, {
cache: 'no-store', // SSR:每次请求获取最新数据
}).then(res => res.json());
return <article>{post.title}</article>;
}
6.3 ISR(增量静态再生成)
ISR 结合了 SSG 的性能和 SSR 的数据实时性:
export default async function PostPage({ params }) {
const post = await fetch(`https://api.example.com/posts/${params.id}`, {
next: { revalidate: 60 }, // 60 秒后重新生成
}).then(res => res.json());
return <article>{post.title}</article>;
}
更多 Next.js 细节请参考 Next.js 核心知识 和 SSR 与 SSG。
七、SSR 性能优化
7.1 组件级缓存
对于不依赖用户数据的组件,可以在服务端缓存渲染结果:
import LRU from 'lru-cache';
const componentCache = new LRU<string, string>({ max: 1000, ttl: 60_000 });
function renderWithCache(key: string, element: React.ReactElement): string {
const cached = componentCache.get(key);
if (cached) return cached;
const html = renderToString(element);
componentCache.set(key, html);
return html;
}
7.2 流式渲染减少 TTFB
使用 renderToPipeableStream 替代 renderToString,让 Shell 部分立即返回,显著降低 TTFB。
7.3 代码分割与懒加载
配合 React.lazy 和代码分割,减少客户端需要下载的 JS 体积,加速 Hydration:
import { lazy, Suspense } from 'react';
// 重型组件延迟加载,不阻塞首屏
const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<div>
<Header />
<Suspense fallback={<div>加载图表中...</div>}>
<HeavyChart />
</Suspense>
</div>
);
}
7.4 Edge Runtime
将 SSR 逻辑部署到 Edge(如 Cloudflare Workers、Vercel Edge),利用全球分布的边缘节点减少网络延迟:
// Next.js App Router 指定 Edge Runtime
export const runtime = 'edge';
export default async function Page() {
const data = await fetch('https://api.example.com/data');
return <div>{/* ... */}</div>;
}
八、SSR 常见问题
8.1 Hydration Mismatch
原因:服务端和客户端渲染的 HTML 不一致。
常见场景:
- 使用
Date.now()、Math.random()等不确定值 - 根据
window.innerWidth等浏览器 API 条件渲染 - 服务端和客户端的时区、语言设置不同
解决方案:
// 方案一:useEffect 延迟渲染客户端专属内容
function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
// useEffect 只在客户端执行,服务端渲染时 mounted 为 false
useEffect(() => {
setMounted(true);
}, []);
return mounted ? <>{children}</> : null;
}
// 方案二:suppressHydrationWarning(仅用于已知差异)
function Timestamp() {
return (
<time suppressHydrationWarning>
{new Date().toLocaleString()}
</time>
);
}
suppressHydrationWarning 只应用于确实预期不一致的场景(如时间戳),不要滥用它来掩盖真正的 bug。
8.2 window / document 未定义
服务端没有浏览器环境,直接访问 window 或 document 会报错。
// 方案一:typeof 守卫
if (typeof window !== 'undefined') {
// 仅在客户端执行
window.addEventListener('resize', handler);
}
// 方案二:动态导入(适用于依赖浏览器 API 的第三方库)
import dynamic from 'next/dynamic';
const MapComponent = dynamic(
() => import('../components/Map'),
{ ssr: false } // 禁止服务端渲染此组件
);
8.3 内存泄漏
SSR 服务是长期运行的 Node.js 进程,需要特别注意内存管理:
- 避免模块级全局可变状态 — 会在多次请求间共享,造成数据串扰
- 每次请求创建独立的状态 — 如 Redux Store、数据上下文
- 及时清理定时器和订阅 — 服务端不会触发
useEffect清理函数
// ❌ 错误:全局单例 Store,所有请求共享
// const store = createStore();
// ✅ 正确:每次请求创建新的 Store
export function createRequestStore() {
return createStore();
}
8.4 CSS-in-JS SSR 适配
CSS-in-JS 库需要在服务端收集样式并注入到 HTML 中:
import { ServerStyleSheet } from 'styled-components';
const sheet = new ServerStyleSheet();
try {
const html = renderToString(sheet.collectStyles(<App />));
const styleTags = sheet.getStyleTags(); // 获取收集到的 CSS
// 将 styleTags 注入到 <head> 中
} finally {
sheet.seal();
}
由于 CSS-in-JS 在 SSR 中的性能开销和配置复杂度,越来越多的项目转向 CSS Modules、Tailwind CSS 等零运行时方案,它们天然支持 SSR 无需额外配置。
九、何时使用 SSR — 决策流程图
常见面试问题
Q1: SSR 和 CSR 的核心区别是什么?
答案:
CSR(客户端渲染)在浏览器端执行 JavaScript 渲染页面,初始 HTML 为空壳;SSR(服务端渲染)在服务器上将组件渲染为完整 HTML 再返回给浏览器。核心区别在于首次渲染的执行位置。SSR 的优势是首屏速度快(HTML 直出)和 SEO 友好,劣势是增加了服务器负载和架构复杂度。
Q2: 什么是 Hydration?为什么需要它?
答案:
Hydration(水合)是指客户端 React 复用服务端生成的 DOM 节点,给静态 HTML 绑定事件监听器和恢复 React 内部状态的过程。之所以需要 Hydration,是因为 SSR 返回的 HTML 是纯静态的,没有事件绑定、没有状态管理,需要通过 hydrateRoot 让页面具备交互能力。
Q3: Hydration Mismatch 是什么?如何排查和解决?
答案:
Hydration Mismatch 是指服务端渲染的 HTML 与客户端 Hydration 时生成的虚拟 DOM 不一致。常见原因:使用 Date.now() 等不确定值、根据 window 对象条件渲染、HTML 嵌套不合法(如 <p> 内嵌 <div>)。解决方案:用 useEffect + useState 延迟渲染客户端专属内容,对已知差异使用 suppressHydrationWarning,确保服务端和客户端的渲染逻辑一致。
Q4: renderToString 和 renderToPipeableStream 有什么区别?
答案:
renderToString 是同步的,必须等整个组件树渲染完才返回完整 HTML 字符串,大页面会阻塞 TTFB。renderToPipeableStream 是 React 18 的流式 API,配合 Suspense 可以先发送 Shell 部分,等待数据就绪后再流式注入剩余内容,显著降低 TTFB 并支持选择性 Hydration。
Q5: React 18 Streaming SSR 解决了哪些问题?
答案:
传统 SSR 的"全有或全无"问题:获取所有数据 → 渲染所有 HTML → 加载所有 JS → Hydrate 所有组件,每步都必须完全完成才能开始下一步。Streaming SSR 通过 Suspense 边界将这些步骤解耦,每个部分独立完成。快的部分先到达用户,慢数据源不阻塞整个页面,用户点击的区域优先 Hydrate。
Q6: 选择性 Hydration 是什么?
答案:
选择性 Hydration(Selective Hydration)是 React 18 的特性,配合 Suspense 实现。它允许页面的不同部分独立进行 Hydration,而不必等待所有 JS 加载完成。更进一步,如果用户在 Hydration 完成前就点击了某个区域,React 会优先 Hydrate 该区域(紧急 Hydration),让用户更快获得交互能力。
Q7: Next.js 的 getServerSideProps 和 getStaticProps 有什么区别?
答案:
getServerSideProps 在每次请求时在服务端执行,适用于需要实时数据的页面(如个性化内容、频繁更新的数据)。getStaticProps 在构建时执行,生成静态 HTML,适用于内容较少变化的页面。getStaticProps 还可以配合 revalidate 实现 ISR,在静态生成的基础上按时间间隔重新生成。
Q8: SSR 中如何处理 window is not defined 错误?
答案:
服务端(Node.js)没有 window、document 等浏览器全局对象。解决方案有三种:(1)使用 typeof window !== 'undefined' 做运行环境判断;(2)将浏览器专属逻辑放在 useEffect 中执行;(3)使用 next/dynamic 的 ssr: false 选项动态导入组件,使其仅在客户端渲染。
Q9: SSR 应用如何避免内存泄漏?
答案:
SSR 服务器是长期运行的进程,关键注意事项:(1)避免模块级全局可变状态,每次请求应创建独立的状态实例(如 Redux Store);(2)注意闭包引用,确保请求处理函数不会持有对已完成请求的引用;(3)使用连接池管理数据库连接;(4)定期监控 Node.js 进程的堆内存使用。
Q10: ISR(增量静态再生成)的工作原理是什么?
答案:
ISR 结合了 SSG 和 SSR 的优势。首次构建时生成静态页面,之后在 revalidate 时间窗口过期后,下一次访问会触发后台重新生成。在重新生成期间,用户仍然看到缓存的旧页面(stale-while-revalidate 策略),新页面生成完成后替换旧缓存。这样既保持了静态页面的高性能,又能定期更新内容。
Q11: Edge Runtime 对 SSR 有什么优势?
答案:
Edge Runtime 将 SSR 逻辑部署到全球分布的边缘节点(如 Cloudflare Workers、Vercel Edge Functions),优势:(1)更低的网络延迟 — 就近响应用户请求;(2)冷启动快 — 基于 V8 isolate,毫秒级启动;(3)全球扩展 — 无需手动管理多区域服务器。限制是 Edge 环境的 API 受限(不支持 Node.js 完整 API),适用于轻量 SSR 场景。
Q12: CSS-in-JS 在 SSR 中有什么问题?如何解决?
答案:
CSS-in-JS(如 styled-components、Emotion)在 SSR 中需要额外处理:(1)服务端必须收集渲染过程中生成的样式,注入到 HTML 的 <head> 中,否则首屏会出现无样式闪烁(FOUC);(2)增加了服务端渲染的性能开销。解决方案是使用各库提供的 SSR 适配 API(如 ServerStyleSheet),或转向零运行时方案(CSS Modules、Tailwind CSS),后者天然支持 SSR。
Q13: Server Components 和 SSR 是什么关系?
答案:
SSR 和 Server Components(RSC)是互补但不同的技术。SSR 是渲染策略 — 在服务端生成 HTML 发送给客户端,组件代码仍然会打包到客户端 JS 中用于 Hydration。RSC 是组件类型 — Server Component 只在服务端运行,其代码不会出现在客户端 Bundle 中。两者结合使用效果最佳:RSC 减少了客户端 JS 体积,SSR(尤其是 Streaming SSR)让 RSC 的输出能更快地到达用户。更多细节见 Server Components 深入。
Q14: 如何在 SSR 和 CSR 之间做技术选型?
答案:
核心决策依据:(1)SEO 需求 — 需要搜索引擎抓取的内容页面选 SSR/SSG;(2)首屏性能要求 — 对首屏白屏敏感的用户端产品选 SSR;(3)数据实时性 — 静态内容选 SSG/ISR,动态内容选 SSR;(4)服务器预算 — SSR 需要服务器资源,CSR/SSG 只需 CDN;(5)团队能力 — SSR 架构复杂度更高。后台管理系统通常选 CSR,内容型网站选 SSG/ISR,电商/社交等用户端产品选 SSR。