引言
渲染模式决定了用户看到第一个字节的速度、SEO 的可见性与服务器的成本。CSR、SSR、SSG、ISR 各有适用边界,Next.js App Router 的 React Server Components 与流式渲染又进一步模糊了传统分类。本文系统梳理渲染模式全景,深入 Next.js、Nuxt、Astro 等框架的实现机制,并给出按业务场景的选型建议。文中涉及的 Next.js App Router、generateStaticParams、streaming、Astro 岛屿、Nitro 均为真实生产能力。
一、渲染模式全景:CSR / SSR / SSG / ISR
1.1 四种模式的定义
| 模式 | 渲染时机 | HTML 来源 | 首屏 TTFB | 动态性 | 适用场景 |
|---|---|---|---|---|---|
| CSR | 浏览器运行时 | 空壳 + JS 渲染 | 快(但空白) | 高 | 后台、内网工具 |
| SSR | 每次请求时 | 服务器动态生成 | 慢 | 高 | 需要 SEO 的动态页 |
| SSG | 构建时 | 预生成静态文件 | 最快 | 低 | 博客、文档、营销页 |
| ISR | 构建时 + 按需 | 静态 + 增量更新 | 最快 | 中 | 内容周期更新的列表页 |
CSR(Client-Side Rendering)的典型问题是首屏白屏:浏览器先下载空的 HTML,再加载 JS bundle,最后渲染内容。SSR 让服务器在响应中直接返回渲染好的 HTML,解决 SEO 与首屏,代价是每个请求都消耗服务器 CPU。SSG 把渲染前置到构建期,得到的是纯静态文件,可由 CDN 直接服务,但对"随用户变化"的内容无能为力。
1.2 模式不是二选一
现代框架强调混合渲染:同一应用中不同路由可以使用不同模式。Next.js 在 App Router 中让开发者按路由声明渲染策略,这在第六节会展开。先理解一个关键权衡维度——新鲜度 vs 速度:
| 需求 | 最佳模式 |
|---|---|
| 内容几乎不变,访问量大 | SSG + CDN |
| 内容会变但可接受延迟 | ISR(revalidate) |
| 每次访问都不同(个性化) | SSR |
| 纯交互应用,无需 SEO | CSR |
| 内容大、可拆块 | SSR + 流式渲染 |
渲染模式的本质是在「新鲜度」与「速度」之间定价:内容越静态、越值得被 CDN 缓存;越个性化,越必须走服务器动态渲染。
二、Next.js App Router:Server Components 与新模型
2.1 App Router 的核心概念
Next.js 13 引入 App Router,以 app/ 目录为根,page.js、layout.js、loading.js、error.js 等文件约定路由。与旧 Pages Router 最大的不同是:App Router 中的组件默认是 Server Components(RSC)——它们在服务器上执行,不向浏览器发送 JS,直接产出 HTML。
// app/products/page.tsx —— 服务端组件
import { db } from '@/lib/db';
export default async function ProductsPage() {
// 直接 await 数据库查询,数据在服务端拿到
const products = await db.product.findMany({ take: 20 });
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<span>{p.name}</span>
<span>{p.price}</span>
</li>
))}
</ul>
);
}
Server Components 里可以写 async function 并直接 await 数据源——没有 useEffect、没有 loading state,数据就在 HTML 里。浏览器收到的是一段已经渲染好的内容,无需水合的 JS 逻辑(该组件不参与客户端交互时)。
2.2 客户端组件的显式声明
当组件需要交互(useState、事件)时,用 'use client' 指令显式标记为 Client Component:
// app/products/[id]/AddToCartButton.tsx
'use client';
import { useState } from 'react';
export function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false);
return (
<button
onClick={() => {
setAdded(true);
// 调用购物车 API
}}
>
{added ? '已加入' : '加入购物车'}
</button>
);
}
2.3 缓存与重新验证
App Router 引入了 fetch 级缓存语义:默认 GET 请求会被缓存,通过 revalidate 或 cache: 'no-store' 控制新鲜度:
// app/products/page.tsx
export default async function Page() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }, // 每 60 秒重新验证一次
});
const products = await res.json();
// ...
}
这与 ISR 的语义一致:静态生成 + 后台增量更新。App Router 把"渲染模式"从页面级下沉到数据级,这是架构上的重要变化。
三、流式渲染与 Suspense
3.1 流式渲染:TTFB 不再等待最慢的块
传统 SSR 需要整页数据全部就绪才返回响应。流式渲染(Streaming)则先把 HTML 外壳发出去,各异步区块就绪后通过 <script> 块增量注入。Next.js 用 loading.js 与 <Suspense> 声明流式边界:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { SlowWidget } from './SlowWidget';
import { FastWidget } from './FastWidget';
export default function Dashboard() {
return (
<div>
<FastWidget /> {/* 立即渲染,随首包输出 */}
<Suspense fallback={<div>慢速组件加载中…</div>}>
<SlowWidget /> {/* 通过流式注入,不阻塞首包 */}
</Suspense>
</div>
);
}
3.2 流式渲染的协议基础
流式渲染依赖 HTTP 的 chunked transfer 与浏览器的增量解析。服务器边渲染边 flush,浏览器边接收边显示。用户感知上的收益是首屏可交互时间提前:外壳先出现,慢数据区域用占位符,就绪后平滑替换。
| 指标 | 传统 SSR | 流式 SSR |
|---|---|---|
| TTFB | 等待最慢数据 | 立即返回外壳 |
| FCP | 慢 | 快 |
| LCP | 受最慢区块影响 | 各区块独立 |
| 复杂度 | 低 | 需管理 Suspense 边界 |
流式的代价是边界管理:Suspense 切分粒度太细会产生大量小响应块,反而增加解析开销;太粗则退化为整页等待。实践上按"视觉区块"划分即可。
四、Nuxt/Nitro:Vue 生态的服务端能力
4.1 Nuxt 的渲染选项
Nuxt 3 基于 Vite + Nitro 服务器引擎,提供 ssr、static、spa 三种部署模式,并在页面级支持 defineRouteRules 声明 ISR 式的缓存规则:
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
routeRules: {
'/blog/**': { swr: 3600 }, // 每小时重新验证
'/docs/**': { static: true }, // 纯静态
'/admin/**': { ssr: false }, // 关闭 SSR,纯客户端
'/api/**': { proxy: '/api/**' }, // API 转发
},
});
4.2 Nitro 与通用 API 层
Nitro 是 Nuxt 的服务端引擎,也是 Nuxt 的通用 API 层——可以用 server/api/ 目录编写服务端函数,自动暴露为 API 路由。这意味着一套代码既能做 SSR,也能提供接口:
// server/api/products.get.ts
export default defineEventHandler(async (event) => {
const { name } = getQuery(event);
const products = await getProducts({ keyword: name });
return products;
});
Nitro 支持部署到 Node、Serverless、Edge 等多种环境,routeRules 在不同环境映射为不同的缓存策略(CDN cache、ISR、SWR)。
五、岛屿架构:Astro 与 Partial Hydration
5.1 岛屿架构的动机
水合(Hydration)是 SSR 的隐性成本:服务器渲染了 HTML,浏览器还要为每个组件下载并执行 JS,把事件重新挂上去。对于以内容为主的页面,大部分组件根本没有交互,全部水合是巨大的浪费。**岛屿架构(Islands Architecture)**的核心是:默认零 JS,只有需要交互的"岛屿"组件才会水合。
Astro 是岛屿架构的代表实现。页面默认输出纯静态 HTML,交互组件通过 client:* 指令按需加载:
---
// src/pages/index.astro
import Header from '../components/Header.astro';
import CartButton from '../components/CartButton.jsx';
---
<Layout>
<!-- 静态组件:不输出任何 JS -->
<Header />
<!-- 岛屿:只有这个按钮会水合并加载 JS -->
<CartButton client:load />
<!-- 进入视口时才加载 -->
<LazyChart client:visible />
</Layout>
5.2 Partial Hydration 的收益
| 方案 | JS 体积 | 水合成本 | 适用内容页 |
|---|---|---|---|
| 全量 SSR + 全量水合 | 全量 bundle | 所有组件 | 否 |
| 岛屿架构 | 仅交互组件 | 仅岛屿 | 是 |
| 流式 SSR + 局部水合 | 渐进加载 | 按区块 | 中 |
岛屿架构非常适合内容驱动的站点:文档、博客、营销页、电商详情页。交互密集的复杂应用(如富文本编辑器密集的后台)则收益有限。Astro 也支持混合多框架:React、Vue、Svelte 组件可以共存于同一页面,各自作为岛屿水合。
六、水合(Hydration)与失效
6.1 水合的本质与常见错误
水合是"把服务器生成的 DOM 变成响应式 DOM"的过程:React 会比对服务器 HTML 与客户端首次渲染结果,若不一致则报 mismatch 错误。经典触发源:
- 依赖
Date.now()、Math.random()等非确定性值,服务器与客户端渲染结果不同。 - 依赖浏览器 API(
window、localStorage)在渲染期读取。 - 第三方库未做 SSR 兼容。
// 错误:服务器与客户端首渲染不一致
export function Timestamp() {
const now = new Date().toISOString(); // 服务端和客户端时间不同
return <time>{now}</time>;
}
// 正确:挂载后再读取浏览器值
'use client';
import { useEffect, useState } from 'react';
export function Timestamp() {
const [now, setNow] = useState('');
useEffect(() => setNow(new Date().toISOString()), []);
return <time>{now}</time>;
}
6.2 减少水合体积
水合体积与 JS 成正比,优化手段:
- 尽量让组件保持为 Server Component,减少
'use client'范围。 - 使用
react-dom的流式水合与渐进增强。 - 对非首屏交互组件延迟加载(Next.js 的
dynamic配合ssr: false)。
import dynamic from 'next/dynamic';
// 只在客户端加载、且按需加载的弹窗
const ConfirmDialog = dynamic(() => import('./ConfirmDialog'), {
ssr: false,
loading: () => <div>加载弹窗…</div>,
});
七、SEO 与性能权衡
7.1 SEO 对渲染模式的硬性要求
搜索引擎爬虫(Googlebot、Bingbot)虽然已能执行 JavaScript,但对 SSR/SSG 生成的内容仍更有好感——HTML 直接携带内容与结构化数据,无需等待 JS 执行。需要 SEO 的页面应至少保证关键内容在首包 HTML 中可见:
- SSR/SSG:内容天然在 HTML 中。
- CSR:爬虫需执行 JS,索引不稳定且延迟。
- 元信息(title、description、og 标签)应服务端生成,避免客户端后补。
// Next.js App Router 的 metadata 导出,服务端生成 SEO 元信息
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'SSR/SSG 渲染模式全景',
description: '从 CSR 到岛屿架构的完整选型指南',
openGraph: {
title: 'SSR/SSG 渲染模式全景',
type: 'article',
},
};
7.2 性能指标权衡矩阵
| 业务类型 | 首屏体验优先 | SEO 优先 | 成本优先 |
|---|---|---|---|
| 电商详情页 | SSG/ISR | SSR/SSG | SSG + CDN |
| 内容博客 | SSG | SSG | SSG |
| 个性化仪表盘 | CSR/流式 SSR | 不需要 | CSR |
| 动态社区 | 流式 SSR | SSR | ISR |
性能上的铁律:TTFB 快的模式,动态性必然受限。SSG 的 TTFB 最快但内容在构建期冻结;SSR 动态性最强但每个请求都产生服务器负载。ISR 与流式渲染正是为了在这两端之间提供中间带。
一个反直觉的事实:SSR 不必然比 CSR 慢,也不必然比 SSG 快。真正的分水岭是「每请求的服务器成本」——SSR 的快只能通过分层缓存兑现。
八、缓存与 CDN
8.1 缓存策略分层
SSR/SSG 的规模化瓶颈是服务器负载与回源延迟,解决方案是缓存分层:
| 层 | 缓存什么 | 手段 |
|---|---|---|
| CDN 边缘 | 完整 HTML(静态/ISR 产物) | CDN cache、stale-while-revalidate |
| 应用层 | 渲染结果/API 响应 | Redis、内存缓存 |
| 数据层 | 数据库查询结果 | 查询缓存 |
以 Vercel 为代表的平台把 ISR 缓存放到了 CDN 边缘:revalidate 时间内用户直接命中边缘缓存,到期后后台重新生成。自建时可用 s-maxage、stale-while-revalidate 控制:
// 服务端响应的缓存头控制
export async function GET() {
const data = await fetchFeed();
return new Response(JSON.stringify(data), {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
},
});
}
8.2 缓存失效的复杂度
缓存失效是缓存系统最难的部分。ISR 用"时间窗口"规避显式失效——内容变更后最多延迟一个 revalidate 周期可见。对"提交后立即可见"的场景(如评论发布),需要主动失效:
// Next.js 的按需重新验证
export async function POST(request) {
// 提交新评论
await createComment(await request.json());
revalidatePath('/blog/hello-world'); // 立即重新生成该路径
return Response.json({ ok: true });
}
缓存与动态内容的边界应尽早划清:哪些页面允许"最多延迟 N 秒可见",哪些必须实时。前者交给缓存,后者只能走 SSR 或客户端实时拉取。
九、选型建议与工程实践
9.1 按团队与项目选型
| 场景 | 推荐 | 理由 |
|---|---|---|
| React 生态、SEO 敏感 | Next.js App Router | RSC + 流式 + ISR 一体化 |
| Vue 生态 | Nuxt 3 | routeRules + Nitro 全栈 |
| 内容站、文档站 | Astro | 岛屿架构,零 JS 默认 |
| 纯后台、无需 SEO | Vite + React CSR | 最简单、开发体验最好 |
| 老项目渐进改造 | 保留 CSR,局部上 SSG | 降低改造风险 |
9.2 迁移路线与常见陷阱
从 CSR 迁移到 SSR 的经典陷阱:
- 窗口对象不可用:把
window/document的读取移到useEffect或守卫中。 - 水合 mismatch:非确定性渲染(时间、随机数)需延迟到客户端。
- 依赖体积:Node 环境打包需注意 Node 专属依赖被错误打进客户端 bundle。
- 数据库连接泄漏:SSR 每请求创建连接,需连接池与超时管理。
9.3 最后的实践建议
现代前端已经很少"整站选一种模式",而是按路由声明式地混合。Next.js 的 App Router、Nuxt 的 routeRules、Astro 的默认静态 + 岛屿,都在推动同一个方向:让"静态优先、按需动态"成为默认哲学。静态能覆盖的,绝不动态;动态必须的部分,尽量窄化到最小组件。
# 本地验证生产模式的构建行为
npm run build
npm run start
结语
SSR、SSG、ISR、流式渲染与岛屿架构,本质是同一道权衡题的不同解:在新鲜度、速度、成本之间找到业务最优平衡点。Next.js App Router 把渲染模式下沉到数据与路由粒度,Nuxt 通过 routeRules 声明式管理,Astro 用岛屿架构把默认 JS 降到零。选型的核心依据是内容的新鲜度需求与 SEO 敏感性,而不是对某个框架的偏好。
对大多数团队,我推荐从"SSG/ISR 优先 + 关键动态路由走流式 SSR"起步,用 CDN 承载静态内容,让服务器只处理真正的动态请求。渲染模式的演进方向始终清晰:让静态的东西更便宜,让动态的东西更精准。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。