SSR vs CSR
一、从一个白屏问题说起
打开一个 React SPA 应用,查看页面源代码,你大概率只会看到这样的 HTML:
<!DOCTYPE html>
<html>
<head><title>My App</title></head>
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>
</html>
浏览器拿到的是一个空的 <div id="root">,所有内容都要等 JavaScript 下载、解析、执行完毕后才能渲染出来。在这段时间里,用户看到的就是白屏。这就是 CSR(Client-Side Rendering)的典型问题。
SSR(Server-Side Rendering)则是另一种思路:服务器直接返回填充好内容的 HTML,浏览器拿到就能渲染,不用等 JS 执行完。
这两种渲染模式各有适用场景,理解它们的原理和取舍,是前端工程师绑定不了的基本功。
二、CSR 工作原理
CSR 即客户端渲染,页面的 HTML 结构和内容完全由浏览器端的 JavaScript 生成。
加载流程
- 浏览器请求页面,服务器返回一个几乎为空的 HTML 文件(只有一个挂载点和
<script>标签) - 浏览器下载并解析 JavaScript Bundle
- JS 执行,框架初始化,发起数据请求(Ajax / Fetch)
- 拿到数据后,框架渲染 DOM,页面内容呈现
用户请求 → 空 HTML → 下载 JS → 执行 JS → 请求数据 → 渲染页面
↑ ↑
白屏开始 白屏结束
白屏问题的根源
白屏时间 = HTML 下载 + JS 下载 + JS 解析执行 + 数据请求 + 渲染。这条链路上的每一环都在延长用户等待时间。Bundle 越大、网络越差、设备性能越低,白屏越严重。
CSR 的典型代码结构(以 React 为例):
// main.tsx
import { createRoot } from 'react-dom/client';
import App from './App';
const root = createRoot(document.getElementById('root')!);
root.render(<App />);
// App.tsx
import { useEffect, useState } from 'react';
function App() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(response => response.json())
.then(data => setPosts(data));
}, []);
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.summary}</p>
</article>
))}
</div>
);
}
页面内容依赖 useEffect 中的异步请求,搜索引擎爬虫在抓取时拿到的只是空 HTML,无法索引实际内容。
三、SSR 工作原理
SSR 即服务端渲染,服务器在接收到请求时,执行组件的渲染逻辑,将完整的 HTML 字符串返回给浏览器。
加载流程
- 浏览器请求页面
- 服务器执行组件渲染,调用数据接口,生成包含完整内容的 HTML
- 浏览器收到 HTML,直接解析渲染,用户立即看到页面内容
- 浏览器下载并执行 JS,框架接管页面,绑定事件监听器(Hydration)
用户请求 → 服务器渲染 HTML → 浏览器解析 HTML → 用户看到内容
↓
下载 JS → Hydration → 页面可交互
以 Next.js 的 Pages Router 为例:
// pages/posts.tsx
import type { GetServerSideProps } from 'next';
interface Post {
id: number;
title: string;
summary: string;
}
export const getServerSideProps: GetServerSideProps = async () => {
const response = await fetch('https://api.example.com/posts');
const posts: Post[] = await response.json();
return { props: { posts } };
};
export default function PostsPage({ posts }: { posts: Post[] }) {
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.summary}</p>
</article>
))}
</div>
);
}
getServerSideProps 在服务端执行,数据获取和 HTML 生成都在服务器完成,浏览器拿到的是带有完整内容的 HTML。
Hydration(注水)
服务端返回的 HTML 是"死的"——只有 DOM 结构和文本,没有事件绑定。Hydration 的作用是让客户端 JS 接管服务端生成的 DOM,为其附加事件监听器和状态管理,使页面变得可交互。
Hydration 的过程并不是重新渲染:框架会复用服务端生成的 DOM 节点,只进行事件绑定和状态同步。如果客户端渲染结果与服务端 HTML 不一致,会触发 Hydration Mismatch 警告,严重时可能导致页面闪烁或功能异常。
常见导致 Hydration Mismatch 的原因:
- 使用了
Date.now()、Math.random()等非确定性 API - 根据
window或localStorage的值条件渲染,但服务端没有这些对象 - 服务端和客户端的时区、语言环境不一致
四、SSR vs CSR 对比
| 维度 | CSR | SSR |
|---|---|---|
| 首屏性能 | 差,需等 JS 下载执行后才显示内容 | 好,服务端直出 HTML,用户快速看到内容 |
| SEO | 差,爬虫抓取到空 HTML | 好,爬虫直接获取完整内容 |
| 交互体验 | 首屏后页面切换流畅,无需刷新 | 首屏快但 Hydration 前不可交互,存在 TTI 延迟 |
| 服务器压力 | 低,服务器只提供静态文件和 API | 高,每次请求都需要服务端执行渲染 |
| 开发复杂度 | 低,纯前端开发 | 高,需处理服务端/客户端代码兼容性 |
| 缓存策略 | 前端资源 CDN 缓存即可 | 需要额外的页面级缓存策略(如 Redis 缓存 HTML) |
| FCP | 慢 | 快 |
| TTI | FCP ≈ TTI,内容出现即可交互 | FCP 快但 TTI 滞后,存在"可见不可用"阶段 |
FCP(First Contentful Paint):首次内容绘制,用户第一次看到页面内容的时间。 TTI(Time to Interactive):可交互时间,页面完全可响应用户操作的时间。
SSR 存在一个容易被忽略的问题:页面看起来已经加载完了,但按钮点不了、表单填不了。这段"可见不可用"的时间差,在 JS Bundle 较大或网络较慢的情况下尤为明显,对用户体验的伤害有时比白屏更大。
五、同构渲染
同构渲染(Isomorphic / Universal Rendering)是 SSR 和 CSR 的结合:同一套组件代码既在服务端运行生成 HTML,又在客户端运行实现交互。
核心思路
- 服务端执行组件渲染,输出完整 HTML,解决首屏性能和 SEO 问题
- 客户端下载 JS 后执行 Hydration,接管页面,实现 SPA 级别的交互体验
- 后续页面跳转走客户端路由,不再请求服务端渲染
这样既拿到了 SSR 的首屏优势,又保留了 CSR 页面切换无刷新的流畅体验。
Next.js 实现思路
Next.js 是 React 生态中最成熟的同构渲染框架。它的核心机制:
- Pages Router:通过
getServerSideProps(SSR)、getStaticProps(SSG)决定数据获取时机 - App Router(v13+):默认所有组件为 Server Component,需要交互的组件用
'use client'标记为 Client Component
// App Router 示例 - Server Component(默认)
// app/posts/page.tsx
async function PostsPage() {
const posts = await fetch('https://api.example.com/posts').then(
response => response.json()
);
return (
<div>
{posts.map((post: { id: number; title: string; summary: string }) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.summary}</p>
<LikeButton postId={post.id} />
</article>
))}
</div>
);
}
// Client Component - 需要交互的部分
// app/posts/LikeButton.tsx
'use client';
import { useState } from 'react';
export function LikeButton({ postId }: { postId: number }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
}
Server Component 的渲染在服务端完成,不会打进客户端 Bundle;Client Component 才会被发送到浏览器执行。这种拆分大幅减小了客户端 JS 体积。
Nuxt.js 实现思路
Nuxt.js 是 Vue 生态的同构方案,思路类似:
- 默认开启 SSR,页面组件在服务端渲染
- 通过
useAsyncData/useFetch在服务端获取数据 - 客户端 Hydration 后切换为 SPA 模式
<!-- pages/posts.vue -->
<script setup>
const { data: posts } = await useFetch('/api/posts');
</script>
<template>
<div>
<article v-for="post in posts" :key="post.id">
<h2>{{ post.title }}</h2>
<p>{{ post.summary }}</p>
</article>
</div>
</template>
同构开发的注意事项
同构代码需要在服务端和客户端都能运行,这引入了额外的约束:
- 不能在组件顶层访问浏览器 API(
window、document、localStorage),需要在useEffect或onMounted等客户端生命周期中使用 - 服务端没有用户交互,事件处理函数不会在服务端执行
- 第三方库兼容性:部分库依赖浏览器环境,需要动态导入或条件加载
- 内存泄漏风险:服务端渲染是多用户共享的,全局变量和单例模式可能导致数据串联
六、SSG 静态站点生成
SSG(Static Site Generation)在构建时生成所有页面的静态 HTML 文件,部署后直接通过 CDN 分发,无需服务器运行时渲染。
// Next.js Pages Router - SSG
export const getStaticProps: GetStaticProps = async () => {
const posts = await fetch('https://api.example.com/posts').then(
response => response.json()
);
return {
props: { posts },
};
};
// 动态路由需要配合 getStaticPaths
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await fetch('https://api.example.com/posts').then(
response => response.json()
);
const paths = posts.map((post: { slug: string }) => ({
params: { slug: post.slug },
}));
return { paths, fallback: false };
};
SSG 的优势:
- 性能极致:纯静态文件,CDN 缓存,响应速度最快
- 服务器成本几乎为零
- 安全性高:没有服务端运行时,攻击面极小
SSG 的局限:
- 内容更新需要重新构建部署
- 页面数量极大时(数十万页面),构建时间可能无法接受
- 无法展示实时个性化内容
适用场景:博客、文档站、营销落地页、产品介绍页等内容相对固定的站点。
七、ISR 增量静态再生
ISR(Incremental Static Regeneration)是 Next.js 提出的方案,结合了 SSG 的性能和 SSR 的数据时效性:页面在构建时静态生成,但可以按设定的时间间隔在后台重新生成。
// Next.js ISR
export const getStaticProps: GetStaticProps = async () => {
const posts = await fetch('https://api.example.com/posts').then(
response => response.json()
);
return {
props: { posts },
revalidate: 60, // 60 秒后,下一次请求触发后台重新生成
};
};
ISR 的工作机制:
- 构建时生成静态页面
- 用户请求到来时,如果页面未过期(未超过
revalidate时间),直接返回缓存的静态页面 - 如果已过期,仍然先返回旧页面(用户不会感知延迟),同时在后台触发页面重新生成
- 下一次请求将获得新生成的页面
这种"stale-while-revalidate"策略确保了用户始终能快速获得页面,同时数据也能在可接受的延迟内更新。
ISR 适用场景:电商商品详情页、新闻资讯列表等需要一定数据时效性但不要求实时的页面。
八、流式 SSR(React 18 Streaming)
传统 SSR 需要等整个页面渲染完成后才能发送 HTML 给浏览器。如果页面中有慢查询接口,整个页面的 TTFB(Time to First Byte)都会被拖慢。
React 18 引入了 Streaming SSR,核心思想是:将页面拆分为多个块,渲染完一部分就发送一部分,不必等全部完成。
Suspense + Streaming
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* 头部信息立即返回 */}
<UserInfo />
{/* 数据报表需要慢查询,用 Suspense 包裹 */}
<Suspense fallback={<div>加载报表中...</div>}>
<AnalyticsChart />
</Suspense>
{/* 推荐列表也需要异步数据 */}
<Suspense fallback={<div>加载推荐...</div>}>
<RecommendationList />
</Suspense>
</div>
);
}
流式 SSR 的工作过程:
- 服务端开始渲染,先把
<h1>Dashboard</h1>和<UserInfo />发送给浏览器 AnalyticsChart的数据还没准备好,先发送fallback占位内容- 浏览器已经可以开始渲染已收到的部分
- 当
AnalyticsChart数据就绪,服务端再推送实际内容替换占位 RecommendationList同理
关键收益:
- TTFB 大幅缩短,用户更早看到页面框架
- 慢接口不会阻塞整个页面
- 配合 Selective Hydration,先到达的部分可以优先变得可交互
与传统 SSR 的对比
| 维度 | 传统 SSR | 流式 SSR |
|---|---|---|
| HTML 发送时机 | 全部渲染完毕后一次性发送 | 渲染完一部分发送一部分 |
| TTFB | 受最慢接口影响 | 快,无需等待全部数据 |
| Hydration | 整页一次性 Hydration | Selective Hydration,逐块激活 |
| 用户体验 | 要么白屏,要么完整页面 | 渐进式加载,优先展示关键内容 |
九、技术选型:何时用什么
没有银弹,选型取决于业务场景。
用 CSR 的场景
- 后台管理系统:不需要 SEO,用户已登录,交互密集
- 需要复杂实时交互的应用:在线编辑器、实时协作工具
- 纯内部工具:不面向公众,对首屏性能要求不高
用 SSR 的场景
- 强 SEO 需求 + 频繁更新的内容:社交平台、论坛、UGC 内容站
- 首屏性能要求高:电商首页、新闻门户
- 需要根据用户身份个性化渲染:不同用户看到不同内容的页面
用 SSG 的场景
- 内容更新频率低:博客、文档、帮助中心
- 极致性能需求:营销落地页、产品官网
- 成本敏感:不想维护服务器,纯 CDN 托管
用 ISR 的场景
- 内容有一定时效性但非实时:商品详情页、新闻文章
- 页面数量大:无法接受全量 SSG 的构建时间,但又想要静态性能
混合使用
现代框架支持在同一个项目中混合使用不同渲染策略。Next.js App Router 可以在路由级别选择渲染模式:
// 静态页面 - SSG(默认行为)
// app/about/page.tsx
export default function AboutPage() {
return <div>关于我们</div>;
}
// 动态页面 - SSR
// app/profile/page.tsx
export const dynamic = 'force-dynamic';
export default async function ProfilePage() {
const user = await getCurrentUser();
return <div>欢迎, {user.name}</div>;
}
// ISR
// app/products/[id]/page.tsx
export const revalidate = 3600; // 1 小时
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return <div>{product.name}</div>;
}
十、面试高频追问
Q:SSR 一定比 CSR 快吗?
不一定。SSR 的首屏展示速度通常更快(FCP 更早),但 TTI 可能反而更慢。如果服务端接口响应慢,SSR 的 TTFB 也会很长。此外,SSR 增加了服务器计算开销,在高并发下如果没有合理的缓存策略,服务端可能成为性能瓶颈。
Q:Hydration 失败会怎样?
React 在检测到 Hydration Mismatch 时,开发模式下会在控制台打印警告。严重的不匹配会导致 React 放弃复用服务端 DOM,退化为完整的客户端重新渲染,引起页面闪烁。在 React 18 中,严重的 Hydration 错误会直接导致整个应用回退到客户端渲染。
Q:SSR 如何应对高并发?
几种常见策略:
- 页面级缓存:将渲染好的 HTML 缓存到 Redis 或 CDN 边缘节点,相同请求直接返回缓存
- 降级方案:服务端渲染超时或出错时,降级返回 CSR 版本的 HTML
- ISR / SSG 优先:将不需要实时数据的页面转为静态生成,减少服务端渲染压力
- 流式渲染:使用 Streaming SSR 缩短 TTFB,降低单次渲染的内存占用
Q:React Server Component 和 SSR 的区别?
SSR 是一种渲染时机——在服务端生成 HTML。Server Component 是一种组件类型——只在服务端执行,不会发送 JS 到客户端。SSR 生成 HTML 后仍然需要完整的客户端 JS 进行 Hydration;而 Server Component 的代码压根不会出现在客户端 Bundle 中。两者可以配合使用:Server Component 在服务端渲染,通过 Streaming SSR 流式发送给客户端。
Q:SSG 页面如何实现动态内容?
SSG 页面本身是静态的,但可以通过以下方式加入动态部分:
- 客户端 fetch:页面加载后通过 API 请求动态数据
- ISR:设置
revalidate定时更新静态页面 - 边缘函数:在 CDN 边缘节点注入个性化内容(如 Vercel Edge Middleware)
十一、总结
- CSR 适合交互密集、不需要 SEO 的场景,开发简单但首屏性能差
- SSR 解决了首屏性能和 SEO 问题,但增加了服务器压力和开发复杂度
- 同构渲染 结合两者优势,是现代 Web 应用的主流方案
- SSG 适合内容静态的站点,性能最优、成本最低
- ISR 在 SSG 基础上提供了数据更新能力,适合内容有一定时效性的场景
- 流式 SSR 通过分块发送和 Selective Hydration 进一步优化了传统 SSR 的体验
技术选型不是非此即彼,现代框架已经支持在同一项目中按路由粒度混合使用不同渲染策略。核心判断依据是:这个页面的内容是否需要 SEO、数据更新频率如何、对首屏性能的要求多高、是否有服务端基础设施支撑。