21.7 渲染模式:CSR / SSR / SSG / ISR / Streaming SSR
深入对比客户端渲染、服务端渲染、静态生成、增量静态再生和流式渲染的技术原理、适用场景与性能权衡
原理
渲染模式决定了 HTML 内容在何时、何地、以何种方式生成。从纯客户端渲染(CSR)到流式服务端渲染(Streaming SSR),每种模式都在首屏性能、交互性、服务器成本和开发复杂度之间做出不同的权衡。理解这些模式的底层机制,是架构选型的基础。
CSR(Client-Side Rendering,客户端渲染)
CSR 模式下,服务器返回一个几乎空的 HTML 外壳(通常只有一个 <div id="root"></div>),所有内容由浏览器下载并执行 JavaScript 后动态生成。
浏览器执行流程:
- 下载 HTML(~2KB)
- 解析 HTML,发现
<script src="app.js">,发起 JS 请求 - 下载 JS(通常 100~500KB)
- 解析并执行 JS,构建虚拟 DOM
- 执行数据获取(
fetch/axios) - 接收到数据后,渲染完整内容
CSR 的性能特征:
- TTFB:极低(空 HTML 立即返回)
- FCP:取决于 JS 下载和执行时间,通常 1~3s
- LCP:通常与 FCP 接近或稍晚
- TTI:JS 执行完成后,通常 FCP + 500ms~2s
- INP:可能较差,因为主线程被 hydration 占用
CSR 的根本问题: 内容渲染被 JavaScript 执行阻塞。在低端设备或弱网环境下,用户可能面对数秒的白屏或骨架屏。
SSR(Server-Side Rendering,服务端渲染)
SSR 模式下,服务器在收到请求时执行应用代码,生成完整的 HTML 字符串返回给浏览器。浏览器接收到 HTML 后立即开始渲染,无需等待 JavaScript 下载。
SSR 的执行流程:
- 服务器收到请求
- 执行 React/Vue 组件树,调用数据获取逻辑(如
getServerSideProps) - 生成完整 HTML 字符串
- 返回 HTML + 内联的初始数据(
window.__INITIAL_STATE__) - 浏览器解析 HTML,立即渲染内容(FCP 提前)
- 下载并执行 JS,进行 hydration(将事件监听器绑定到已有 DOM)
Hydration 的底层机制:
Hydration 不是重新创建 DOM,而是"接管"服务器生成的 DOM。React/Vue 在客户端重新执行组件树,将虚拟 DOM 与真实 DOM 对比,确认结构一致后附加事件监听器。若服务器输出与客户端输出不一致(如使用了 Date.now() 或随机数),会产生 hydration mismatch 警告,甚至需要重新渲染 DOM。
SSR 的性能特征:
- TTFB:较高(服务器需要执行应用逻辑和数据查询)
- FCP:显著提前(HTML 到达即可渲染)
- LCP:通常接近 FCP
- TTI:HTML 渲染后仍需等待 JS hydration 完成才能交互
- INP:hydration 期间主线程繁忙,交互响应可能延迟
SSG(Static Site Generation,静态站点生成)
SSG 在构建时预渲染所有页面为静态 HTML 文件。用户请求时,服务器直接返回预生成的 HTML,无需执行任何应用逻辑。
SSG 的执行流程:
- 构建时:遍历所有路由,执行组件树,生成 HTML 文件
- 部署时:将 HTML 文件上传到 CDN/静态托管
- 请求时:CDN 直接返回缓存的 HTML
- 浏览器:解析 HTML 立即渲染,随后下载 JS 进行 hydration
SSG 的性能特征:
- TTFB:极低(CDN 边缘节点直接返回缓存 HTML)
- FCP/LCP:极快(通常 < 1s)
- TTI:仍需等待 hydration
- 服务器成本:几乎为零(纯静态托管)
SSG 的局限: 无法为每个用户生成个性化内容,不适合强动态页面(如用户仪表盘、实时数据)。
ISR(Incremental Static Regeneration,增量静态再生)
ISR 是 Next.js 提出的混合模式:页面首次请求时由 SSR 生成并缓存,后续请求返回缓存的静态 HTML,同时后台异步重新生成更新版本。
ISR 的执行流程:
- 首次请求:SSR 生成 HTML,返回给客户端,存入缓存
- 后续请求:直接返回缓存 HTML(SSG 体验)
- 后台:在指定时间(
revalidate: 60)后,下次请求触发重新生成 - 重新生成完成:更新缓存,后续请求获得新版本
ISR 的"过时-然后失效"(Stale-While-Revalidate)语义:
ISR 本质上将 HTTP 的 stale-while-revalidate 策略应用到了页面级别。用户在重新生成期间看到的是略微过时的内容,但获得了极快的响应速度。
Streaming SSR(流式服务端渲染)
Streaming SSR 是 React 18 引入的革命性特性。传统 SSR 必须等待整个页面的 HTML 生成完毕后才能发送第一个字节;Streaming SSR 允许服务器分块发送 HTML,浏览器在接收过程中逐步渲染。
Streaming SSR 的执行流程:
- 服务器立即发送 HTML 头部和无需数据的部分(如导航栏、布局框架)
- 浏览器收到头部后立即开始渲染,无需等待完整响应
- 服务器并行获取数据,数据就绪后流式发送剩余 HTML
- 对于特别慢的数据源,使用
Suspense边界包裹,先发送 fallback UI(如骨架屏/加载圈) - 数据就绪后,通过内联
<script>推送剩余 HTML(HTML Streaming + Selective Hydration)
React 18 的 Selective Hydration:
传统 hydration 是"全有或全无"的——必须等待所有组件的 JS 下载完成,才能开始 hydration。React 18 的 Selective Hydration 允许:
- 优先 hydration 用户交互的组件(如点击了某个按钮)
- 将非关键组件的 hydration 推迟到主线程空闲时
- 配合
Suspense实现组件级的流式渲染
用法
Next.js 渲染模式配置
// Next.js 14 App Router 渲染模式示例
// 1. SSG:默认静态生成
// app/page.js
export default function HomePage() {
return <h1>静态首页</h1>;
}
// 构建时生成静态 HTML
// 2. SSR:强制动态渲染
// app/dashboard/page.js
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const data = await fetch('https://api.example.com/data', { cache: 'no-store' });
const json = await data.json();
return <Dashboard data={json} />;
}
// 3. ISR:增量静态再生
// app/blog/[slug]/page.js
export const revalidate = 60; // 60 秒后重新生成
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPostPage({ params }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: { revalidate: 60 },
}).then(r => r.json());
return <article><h1>{post.title}</h1><div>{post.content}</div></article>;
}
// 4. Streaming SSR + Suspense
// app/page.js
import { Suspense } from 'react';
import { ProductList, ProductListSkeleton } from './components/ProductList';
import { Reviews, ReviewsSkeleton } from './components/Reviews';
export default function ProductPage() {
return (
<div>
<h1>产品详情</h1>
{/* 产品列表:立即流式渲染 */}
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
{/* 评论:可能较慢,单独 Suspense 边界 */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</div>
);
}
// components/ProductList.js
async function ProductList() {
const products = await fetch('https://api.example.com/products').then(r => r.json());
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Nuxt 3 渲染模式配置
<!-- Nuxt 3 页面级渲染模式 -->
<script setup>
// SSG:静态生成
definePageMeta({
prerender: true,
});
// SSR:服务端渲染(默认)
// 无需特殊配置
// CSR:纯客户端渲染
definePageMeta({
ssr: false,
});
</script>
<template>
<div>页面内容</div>
</template>
// nuxt.config.ts - 全局渲染策略
export default defineNuxtConfig({
routeRules: {
// 首页静态生成
'/': { prerender: true },
// 博客文章 ISR
'/blog/**': { isr: 60 },
// 仪表盘客户端渲染
'/dashboard/**': { ssr: false },
// API 代理
'/api/**': { proxy: 'https://backend.example.com/api/**' },
},
});
Astro 的群岛架构(Islands Architecture)
---
// Astro 组件:默认零 JavaScript
const response = await fetch('https://api.example.com/posts');
const posts = await response.json();
---
<html>
<head><title>博客</title></head>
<body>
<h1>最新文章</h1>
<ul>
{posts.map(post => (
<li>{post.title}</li>
))}
</ul>
{/* 交互式组件:仅在需要时加载 JS */}
<Search client:load />
{/* below-the-fold 组件:进入视口时加载 */}
<Comments postId={posts[0].id} client:visible />
{/* 轻交互:使用 client:idle 在空闲时加载 */}
<LikeButton postId={posts[0].id} client:idle />
</body>
</html>
Astro 的 Islands Architecture 默认输出纯 HTML,仅在显式标记 client:* 的组件上加载 JavaScript。这实现了 SSR/SSG 的首屏性能,同时保留了交互能力。
实践
渲染模式性能对比
| 指标 | CSR | SSR | SSG | ISR | Streaming SSR | |------|-----|-----|-----|-----|---------------| | TTFB | 极低 | 高 | 极低 | 低 | 极低 | | FCP | 慢 | 快 | 极快 | 快 | 极快 | | LCP | 慢 | 快 | 极快 | 快 | 极快 | | TTI | 中 | 中 | 中 | 中 | 快 | | 服务器成本 | 低 | 高 | 极低 | 低 | 中 | | 动态内容 | 是 | 是 | 否 | 是(延迟) | 是 | | 构建时间 | 无 | 无 | 长 | 中 | 无 |
案例:电商站渲染模式选型
某电商站包含以下页面类型,采用混合渲染策略:
| 页面 | 模式 | 原因 | |------|------|------| | 首页 | SSG + Client Fetch | 内容相对固定,但推荐商品需实时 | | 商品列表 | ISR (revalidate=300) | 库存和价格需定期更新,但可接受 5 分钟延迟 | | 商品详情 | ISR (revalidate=60) + Streaming | 价格敏感,但评论等非关键内容可流式加载 | | 购物车 | SSR | 强用户相关,必须实时 | | 结账 | CSR | 强交互,大量表单验证,SSR 收益低 | | 用户中心 | SSR | 完全个性化,无法缓存 | | 帮助文档 | SSG | 纯内容,极少更新 |
渲染模式选型决策树
页面是否强个性化(每个用户内容不同)?
是 -> 是否强交互(大量表单/动画)?
是 -> CSR 或 SSR + hydration
否 -> SSR
否 -> 内容是否频繁更新?
是 -> ISR(设置合适的 revalidate)
否 -> SSG(最佳性能)
是否需要优化 TTI?
是 -> Streaming SSR + Selective Hydration
陷阱
| 陷阱 | 描述 | 后果 |
|------|------|------|
| Hydration Mismatch | 服务端输出与客户端输出不一致(如使用了 Date.now()、Math.random()、window 对象) | React 警告,可能重新渲染整个页面,性能倒退 |
| SSR 内存泄漏 | 每次请求创建的事件监听器、定时器未清理 | 服务器内存持续增长,最终崩溃 |
| 过度使用 SSR | 对几乎静态的内容使用 SSR,浪费服务器资源 | 服务器成本增加,响应延迟 |
| ISR 的首次请求延迟 | ISR 页面首次请求需要 SSR 生成,响应较慢 | 冷启动体验差,需配合预热策略 |
| Streaming SSR 的 CSS-in-JS 问题 | 某些 CSS-in-JS 库(如 Styled Components)不支持流式样式提取 | 样式闪烁或无样式内容闪烁(FOUC) |
| 忽略 hydration 后的交互延迟 | 认为 SSR 后页面立即可交互,忽视 hydration 时间 | 用户点击无响应,INP 差 |
| 全站统一渲染模式 | 所有页面使用同一种渲染模式 | 部分内容页过度渲染,部分交互页白屏过久 |
Hydration Mismatch 的根因与解决
Hydration Mismatch 最常见的原因是服务端和客户端的初始状态不一致。典型触发器包括:1) 直接使用 Date.now() 或 new Date() 生成内容;2) 使用 Math.random() 生成 ID 或 key;3) 在服务端访问 window/document;4) 从 localStorage 读取初始状态。解决方案:1) 使用 useEffect 包裹客户端-only 逻辑;2) 使用 suppressHydrationWarning 处理已知无害的差异(如时间戳);3) 确保数据获取逻辑在服务端和客户端一致。
关联章节网络
相关推荐
18a.1 现代 Web 渲染架构
现代 Web 渲染架构
11.9 React 服务端渲染:CSR vs SSR vs SSG vs ISR、Next.js、RSC
CSR vs SSR vs SSG vs ISR、Next.js、RSC
12.10 Vue SSR:Nuxt 3、Vite-SSG
Nuxt 3、Vite-SSG
26.9 SSR 框架
SSR 框架
11.8 React 18 新特性:Automatic Batching、Streaming SSR、useId、useDeferredValue
Automatic Batching、Streaming SSR、useId、useDeferredValue