前言
前端渲染策略的选择,直接决定了用户体验的上限。在同一个 Next.js 项目中,选择 CSR、SSR、SSG 还是 ISR,可能让首屏加载时间相差数倍。但很多开发者在做技术选型时,往往凭经验或跟风决策,缺少对各模式性能表现的系统性认知。
本文将从渲染原理出发,结合 Next.js 的实际代码示例和实测性能数据,系统对比四种渲染模式的差异,并给出一套可落地的决策框架。文章同时覆盖了 Next.js 13+ App Router 中 React Server Components 的新范式,帮助你在实际项目中做出更合理的架构决策。
渲染模式全景:从 CSR 到 ISR
在深入各模式之前,先建立一个全局视角。以下是四种渲染模式的请求时序对比:
sql
CSR (Client-Side Rendering)
===========================================================
浏览器 服务器 API/DB
|---请求HTML--->| |
|<--空壳HTML----| |
|---请求JS----->| |
|<--JS Bundle---| |
| 解析执行JS |
|----------请求数据----------->|
|<---------返回JSON-----------|
| 渲染页面 |
| [用户终于看到内容] |
|-----------FCP: ~3-5s---------|
SSR (Server-Side Rendering)
===========================================================
浏览器 服务器 API/DB
|---请求HTML--->| |
| |---查询数据-->|
| |<--返回数据---|
| | 渲染HTML |
|<--完整HTML----| |
| [用户看到内容] |
|---请求JS----->| |
|<--JS Bundle---| |
| Hydration |
| [页面可交互] |
|-----------FCP: ~1-2s---------|
SSG (Static Site Generation)
===========================================================
[构建阶段]
服务器 API/DB
|---查询数据-->|
|<--返回数据---|
| 生成静态HTML |
| 推送至CDN |
[请求阶段]
浏览器 CDN
|---请求HTML--->|
|<--完整HTML----| (直接从CDN边缘节点返回)
| [用户看到内容]|
|---请求JS----->|
|<--JS Bundle---|
| Hydration |
|-----------FCP: ~0.5-1s-------|
ISR (Incremental Static Regeneration)
===========================================================
[首次请求 - 等同SSG]
浏览器 CDN 服务器
|---请求HTML--->| |
|<--缓存HTML----| |
| [用户看到内容] |
[revalidate时间到期后的请求]
浏览器 CDN 服务器
|---请求HTML--->| |
|<--旧缓存HTML--| | (stale-while-revalidate)
| [用户看到内容]|--后台重新生成->|
| |<--新HTML-----|
| | 更新缓存 |
|-----------FCP: ~0.5-1s-------|
从时序图可以直观看出:CSR 的瀑布流问题最严重,SSG/ISR 的首屏体验最优。但这只是一个维度,下面逐一展开分析。
CSR:客户端渲染
工作原理
CSR 模式下,服务器返回的 HTML 只是一个空壳,所有渲染逻辑都在浏览器端完成。典型的流程是:
- 浏览器请求到一个几乎空白的 HTML(只有一个
<div id="root">) - 下载并解析 JavaScript Bundle
- JS 执行后发起 API 请求获取数据
- 数据返回后渲染页面
typescript
// pages/dashboard.tsx -- CSR 模式示例
import { useEffect, useState } from 'react';
interface DashboardData {
totalUsers: number;
activeUsers: number;
revenue: number;
recentOrders: Order[];
}
export default function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 这个请求在浏览器端发起,必须等JS加载执行后才开始
// 这就是所谓的 "waterfall" 问题
fetch('/api/dashboard')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, []);
if (loading) return <DashboardSkeleton />;
return (
<div>
<h1>数据概览</h1>
<MetricCard title="总用户数" value={data!.totalUsers} />
<MetricCard title="活跃用户" value={data!.activeUsers} />
<MetricCard title="营收" value={data!.revenue} />
<OrderTable orders={data!.recentOrders} />
</div>
);
}
瀑布流问题
CSR 最大的性能痛点在于请求瀑布流(Request Waterfall):
scss
时间轴 >>>
|--HTML下载--|--JS下载--|--JS解析执行--|--API请求--|--渲染--|
^ ^ ^
| | |
FP(白屏) 数据开始获取 FCP(首次内容绘制)
每一步都依赖前一步完成,形成串行瀑布。在弱网环境下,这个问题尤其严重。实测数据显示,3G 网络下 CSR 页面的 FCP 可达 8 秒以上。
适用场景
尽管性能表现不佳,CSR 在以下场景仍然是合理选择:
- 后台管理系统 / Dashboard:不需要 SEO,用户可以接受登录后的短暂加载
- 内部工具:网络环境可控,用户群体固定
- 高度交互的应用:如在线编辑器、可视化工具,首屏之后的交互体验更重要
- 需要离线支持的 PWA:配合 Service Worker 缓存
SSR:服务端渲染
getServerSideProps 实践
SSR 的核心思路是将数据获取和 HTML 生成移到服务器端,让浏览器拿到的就是完整的、包含数据的 HTML。
typescript
// pages/products/[category].tsx -- SSR 模式示例
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
interface Product {
id: string;
name: string;
price: number;
stock: number;
description: string;
}
interface PageProps {
products: Product[];
category: string;
totalCount: number;
lastUpdated: string;
}
export const getServerSideProps: GetServerSideProps<PageProps> = async (context) => {
const { category } = context.params as { category: string };
const { page = '1', sort = 'price' } = context.query;
// 服务器端直接查询数据库或调用内部API
// 这里不存在CORS问题,可以直接访问内部服务
const [products, count] = await Promise.all([
db.product.findMany({
where: { category },
orderBy: { [sort as string]: 'asc' },
skip: (Number(page) - 1) * 20,
take: 20,
}),
db.product.count({ where: { category } }),
]);
// 设置缓存控制头,在CDN层面做一层缓存
context.res.setHeader(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=300'
);
return {
props: {
products,
category,
totalCount: count,
lastUpdated: new Date().toISOString(),
},
};
};
export default function CategoryPage({
products,
category,
totalCount,
lastUpdated,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<div>
<h1>{category} 商品列表</h1>
<p>共 {totalCount} 件商品 | 更新于 {lastUpdated}</p>
<ProductGrid products={products} />
<Pagination total={totalCount} pageSize={20} />
</div>
);
}
Streaming SSR 与 React 18 Suspense
传统 SSR 的一个问题是:服务器必须等所有数据准备完毕后才能返回 HTML。如果某个数据源很慢,整个页面的 TTFB 都会被拖慢。React 18 的 Streaming SSR 通过 Suspense 机制解决了这个问题。
typescript
// app/product/[id]/page.tsx -- Streaming SSR 示例 (App Router)
import { Suspense } from 'react';
// 这个组件会立即返回,不阻塞主体HTML的发送
async function ProductInfo({ id }: { id: string }) {
const product = await fetchProduct(id); // 假设耗时 200ms
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span className="price">{product.price} 元</span>
</section>
);
}
// 这个组件可能较慢,但不会阻塞页面其他部分
async function ProductReviews({ id }: { id: string }) {
const reviews = await fetchReviews(id); // 假设耗时 800ms
return (
<section>
<h2>用户评价 ({reviews.length})</h2>
{reviews.map(review => (
<ReviewCard key={review.id} review={review} />
))}
</section>
);
}
// 推荐商品,数据来源于推荐引擎,响应时间不稳定
async function RecommendedProducts({ categoryId }: { categoryId: string }) {
const items = await fetchRecommendations(categoryId); // 耗时 300-2000ms
return (
<section>
<h2>你可能还喜欢</h2>
<ProductCarousel items={items} />
</section>
);
}
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
return (
<div>
{/* 主要内容优先发送 */}
<Suspense fallback={<ProductInfoSkeleton />}>
<ProductInfo id={params.id} />
</Suspense>
{/* 评价区域独立流式传输 */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={params.id} />
</Suspense>
{/* 推荐区域最后到达也没关系 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendedProducts categoryId={params.id} />
</Suspense>
</div>
);
}
Streaming SSR 的核心优势在于:
lua
传统 SSR 时序:
|----等待所有数据(800ms)----|--生成HTML--|--发送-->浏览器
TTFB: ~900ms
Streaming SSR 时序:
|--ProductInfo数据(200ms)--|--发送第一段HTML-->浏览器 TTFB: ~250ms
|------Reviews数据(800ms)------|--流式发送-->浏览器
|---Recommendations(1500ms)---------|--流式发送-->浏览器
用户可以更早看到页面主体内容,而不是等待最慢的数据源。
TTFB 权衡
SSR 的代价是每次请求都需要服务器计算,TTFB 取决于:
- 数据库查询耗时
- 服务器渲染计算量
- 服务器地理位置与用户的距离
markdown
TTFB 组成分析:
|--网络传输--|--服务器处理--|--网络传输--|
RTT/2 查询+渲染 RTT/2
优化手段:
1. 数据库查询优化(索引、缓存)
2. 服务器端缓存(Redis)
3. 边缘部署(Edge Runtime)
4. CDN 缓存 SSR 结果(Cache-Control)
适用场景
- 个性化内容:用户主页、购物车、推荐页
- 实时性要求高:股票行情、新闻详情
- 需要 SEO 且数据频繁变化:商品详情页(价格/库存实时变化)
- 依赖 Cookie/Session 的页面:认证后的个性化展示
SSG:静态站点生成
getStaticProps + getStaticPaths 实践
SSG 在构建阶段就生成好所有页面的 HTML 文件,部署后由 CDN 直接分发。没有服务器运行时开销,性能天然优异。
typescript
// pages/blog/[slug].tsx -- SSG 模式示例
import { GetStaticProps, GetStaticPaths, InferGetStaticPropsType } from 'next';
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote';
import { serialize } from 'next-mdx-remote/serialize';
import remarkGfm from 'remark-gfm';
import rehypePrism from 'rehype-prism-plus';
interface BlogPost {
slug: string;
title: string;
date: string;
author: string;
content: MDXRemoteSerializeResult;
readingTime: number;
tags: string[];
}
// 构建时确定所有需要生成的路径
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await getAllPostSlugs(); // 从CMS或文件系统获取
return {
paths: posts.map(slug => ({
params: { slug },
})),
// fallback: false -- 未匹配路径返回404
// fallback: true -- 未匹配路径在首次请求时生成
// fallback: 'blocking' -- 类似true,但等待生成完成后再返回
fallback: 'blocking',
};
};
// 构建时获取每篇文章的数据
export const getStaticProps: GetStaticProps<BlogPost> = async ({ params }) => {
const slug = params!.slug as string;
const post = await getPostBySlug(slug);
if (!post) {
return { notFound: true };
}
const mdxSource = await serialize(post.rawContent, {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrism],
},
});
return {
props: {
slug,
title: post.title,
date: post.date,
author: post.author,
content: mdxSource,
readingTime: calculateReadingTime(post.rawContent),
tags: post.tags,
},
};
};
export default function BlogPostPage({
title,
date,
author,
content,
readingTime,
tags,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<article>
<header>
<h1>{title}</h1>
<div className="meta">
<span>{author}</span>
<time dateTime={date}>{formatDate(date)}</time>
<span>{readingTime} 分钟阅读</span>
</div>
<div className="tags">
{tags.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
</header>
<div className="prose">
<MDXRemote {...content} />
</div>
</article>
);
}
构建时生成,CDN 分发
SSG 的性能优势来源于其架构简洁性:
lua
构建流程:
+-------------+ +----------+ +--------+ +-----+
| 数据源(CMS/ |---->| Next.js |---->| 静态 |---->| CDN |
| Markdown/ | | 构建进程 | | HTML/ | | |
| API) | | | | CSS/JS | | |
+-------------+ +----------+ +--------+ +-----+
请求流程:
+--------+ +-----+
| 浏览器 |---->| CDN | (边缘节点直接返回,无需回源)
| |<----| 节点| TTFB: 10-50ms (取决于CDN节点距离)
+--------+ +-----+
构建阶段的耗时不影响用户体验。即使构建需要几分钟,用户访问时拿到的始终是预生成的 HTML,TTFB 极低。
适用场景
- 博客 / 文档站:内容更新频率低,SEO 重要
- 营销落地页:性能是转化率的关键因素
- 帮助中心:内容结构化,更新有规律
- 个人作品集:内容基本不变
ISR:增量静态再生
revalidate 配置与按需重验证
ISR 是 SSG 的进化版本,它在保留静态页面性能优势的同时,支持数据更新。核心机制是 stale-while-revalidate:用户始终能快速拿到缓存页面,同时在后台触发页面重新生成。
typescript
// pages/products/[id].tsx -- ISR 模式示例
import { GetStaticProps, GetStaticPaths } from 'next';
interface Product {
id: string;
name: string;
price: number;
stock: number;
images: string[];
specs: Record<string, string>;
}
export const getStaticPaths: GetStaticPaths = async () => {
// 只预生成热门商品的页面,长尾商品按需生成
const topProducts = await db.product.findMany({
where: { viewCount: { gt: 1000 } },
select: { id: true },
take: 500,
});
return {
paths: topProducts.map(p => ({ params: { id: p.id } })),
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps<{ product: Product }> = async ({
params,
}) => {
const product = await db.product.findUnique({
where: { id: params!.id as string },
});
if (!product) {
return { notFound: true };
}
return {
props: { product },
// 关键配置:60秒后的下一次请求将触发后台重新生成
revalidate: 60,
};
};
除了基于时间的自动重验证,Next.js 还支持按需重验证(On-Demand Revalidation),可以在数据变更时主动触发页面更新:
typescript
// pages/api/revalidate.ts -- 按需重验证 API
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// 验证请求来源的合法性
if (req.headers.authorization !== `Bearer ${process.env.REVALIDATION_TOKEN}`) {
return res.status(401).json({ message: 'Invalid token' });
}
const { path, tag } = req.body;
try {
if (path) {
// 重验证特定路径
await res.revalidate(path);
}
// App Router 中还支持基于 tag 的重验证
// revalidateTag(tag);
return res.json({ revalidated: true, path, timestamp: Date.now() });
} catch (err) {
return res.status(500).json({ message: 'Error revalidating' });
}
}
typescript
// 在CMS Webhook中调用按需重验证
// 例如:商品价格更新后,立即触发页面重新生成
async function onProductUpdate(productId: string) {
await fetch(`${process.env.SITE_URL}/api/revalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.REVALIDATION_TOKEN}`,
},
body: JSON.stringify({
path: `/products/${productId}`,
}),
});
}
ISR 的"两全其美"
ISR 结合了 SSG 的速度和 SSR 的数据新鲜度:
lua
ISR 生命周期 (revalidate: 60s)
时间轴:
0s 30s 60s 61s 90s
| | | | |
|--静态服务--|--静态服务--|--静态服务--| |
|--后台重生成->新页面
|--旧页面服务--|--新页面服务--
^
|
首个触发重验证的请求
(该请求仍然拿到旧页面,
下一个请求拿到新页面)
需要注意的关键点:
revalidate: 60不是"每60秒刷新一次",而是"60秒后的下一次请求触发后台重生成"- 触发重生成的那个请求本身仍然拿到旧页面,新页面在下一次请求时生效
- 如果60秒内没有任何请求,页面不会重新生成
Next.js App Router:React Server Components
RSC 与传统 SSR 的区别
React Server Components(RSC)是 Next.js App Router 引入的全新渲染范式,它与传统 SSR 有本质区别:
lua
传统 SSR (Pages Router):
+--------+ +-----------+ +--------+
| 服务器 |---->| 生成完整 |---->| 浏览器 |
| | | HTML字符串| | |
+--------+ +-----------+ +--------+
| 下载完整JS Bundle |
| Hydration(所有组件) |
RSC (App Router):
+--------+ +------------+ +--------+
| 服务器 |---->| Server |---->| 浏览器 |
| | | Components | | |
| | | (RSC | | 只下载Client |
| | | Payload) | | Components的JS |
+--------+ +------------+ +--------+
| 部分Hydration |
核心区别在于:
- SSR:所有组件都需要 Hydration,JS Bundle 包含所有组件代码
- RSC:Server Components 不需要 Hydration,JS Bundle 只包含 Client Components
这意味着对于一个大量内容展示、少量交互的页面,RSC 可以显著减少客户端 JS 体积。
typescript
// app/article/[id]/page.tsx -- RSC 示例
// 这是一个 Server Component(默认)
// 它的代码不会出现在客户端 JS Bundle 中
import { formatDate } from '@/lib/utils';
import { LikeButton } from './LikeButton'; // Client Component
import { ShareMenu } from './ShareMenu'; // Client Component
import { TableOfContents } from './TableOfContents';
// 直接在组件中使用 async/await 获取数据
// 无需 getServerSideProps 或 useEffect
export default async function ArticlePage({
params,
}: {
params: { id: string };
}) {
// 这些请求在服务器端执行,支持自动去重
const article = await fetchArticle(params.id);
const author = await fetchAuthor(article.authorId);
return (
<article>
<header>
<h1>{article.title}</h1>
<div className="meta">
<img src={author.avatar} alt={author.name} />
<span>{author.name}</span>
<time>{formatDate(article.publishedAt)}</time>
</div>
{/* 只有这两个组件需要发送JS到客户端 */}
<div className="actions">
<LikeButton articleId={params.id} />
<ShareMenu url={`/article/${params.id}`} title={article.title} />
</div>
</header>
<TableOfContents headings={article.headings} />
{/* 大量静态内容,零JS开销 */}
<div
className="prose"
dangerouslySetInnerHTML={{ __html: article.htmlContent }}
/>
</article>
);
}
typescript
// app/article/[id]/LikeButton.tsx
'use client'; // 标记为 Client Component
import { useState, useTransition } from 'react';
interface LikeButtonProps {
articleId: string;
}
export function LikeButton({ articleId }: LikeButtonProps) {
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(0);
const [isPending, startTransition] = useTransition();
const handleLike = () => {
startTransition(async () => {
const res = await fetch(`/api/articles/${articleId}/like`, {
method: 'POST',
});
const data = await res.json();
setLiked(true);
setCount(data.count);
});
};
return (
<button
onClick={handleLike}
disabled={isPending || liked}
className={liked ? 'liked' : ''}
>
{liked ? '已赞' : '点赞'} {count > 0 && `(${count})`}
</button>
);
}
Server / Client Components 决策树
在 App Router 中,如何决定一个组件应该是 Server Component 还是 Client Component?以下是一套实用的决策规则:
scss
这个组件需要...
|
+-------------+-------------+
| |
浏览器API/交互? 纯展示/数据获取?
(useState, (无状态, 无事件,
useEffect, 直接读数据库)
onClick, |
浏览器API) Server Component
| (默认, 不需要标记)
|
Client Component
('use client')
|
+------+------+
| |
体积大? 体积小?
考虑拆分 直接使用
(抽取静态
部分为SC)
实际决策参考:
| 场景 | 类型 | 理由 |
|---|---|---|
| 文章正文渲染 | Server | 纯展示,无交互,代码不需要发送到客户端 |
| 导航菜单(静态链接) | Server | 纯展示,HTML即可 |
| 搜索框 | Client | 需要 useState 管理输入状态,需要事件处理 |
| 评论输入框 | Client | 需要表单状态和提交交互 |
| 评论列表展示 | Server | 纯展示,数据在服务端获取 |
| 图表/数据可视化 | Client | 依赖浏览器 Canvas/SVG API |
| 主题切换按钮 | Client | 需要 Context 和状态管理 |
| 面包屑导航 | Server | 纯展示,基于路由参数生成 |
性能对比实测数据
以下数据基于一个中等复杂度的电商商品详情页(含商品信息、图片轮播、评价列表、推荐商品),在以下环境中测试:
- 服务器:2 vCPU / 4GB RAM(东京区域)
- CDN:全球边缘节点
- 数据库:PostgreSQL,查询耗时约 50-200ms
- 测试工具:Lighthouse / WebPageTest
- 测试网络:4G(模拟)
核心性能指标对比
scss
+----------+----------+----------+----------+----------+-----------+
| 指标 | CSR | SSR | SSG | ISR | RSC(App |
| | | | | | Router) |
+----------+----------+----------+----------+----------+-----------+
| TTFB | 50ms | 320ms | 30ms | 35ms | 280ms |
| (ms) | (空壳) | (含数据) | (CDN) | (CDN) | (Streaming|
| | | | | | 首段) |
+----------+----------+----------+----------+----------+-----------+
| FCP | 2800ms | 850ms | 420ms | 430ms | 650ms |
| (ms) | | | | | |
+----------+----------+----------+----------+----------+-----------+
| LCP | 3500ms | 950ms | 520ms | 530ms | 750ms |
| (ms) | | | | | |
+----------+----------+----------+----------+----------+-----------+
| TTI | 4200ms | 1800ms | 1200ms | 1200ms | 1100ms |
| (ms) | | | | | (更少JS) |
+----------+----------+----------+----------+----------+-----------+
| CLS | 0.15 | 0.02 | 0.01 | 0.01 | 0.02 |
| | (布局 | (低) | (极低) | (极低) | (低) |
| | 偏移大) | | | | |
+----------+----------+----------+----------+----------+-----------+
| JS | 245KB | 245KB | 245KB | 245KB | 128KB |
| Bundle | | | | | (SC不 |
| (gzip) | | | | | 入包) |
+----------+----------+----------+----------+----------+-----------+
服务器资源消耗对比
scss
+--------------+---------+---------+---------+---------+---------+
| 指标 | CSR | SSR | SSG | ISR | RSC |
+--------------+---------+---------+---------+---------+---------+
| 服务器CPU | 极低 | 高 | 无 | 低 | 中高 |
| (运行时) | (仅API) | (每请求 | (CDN) | (重验证 | (每请求 |
| | | 渲染) | | 时) | 渲染) |
+--------------+---------+---------+---------+---------+---------+
| 内存占用 | 低 | 高 | 无 | 低 | 中 |
| (运行时) | | 50-100 | (CDN) | | (SC |
| | | MB/实例 | | | 更轻) |
+--------------+---------+---------+---------+---------+---------+
| 构建时间 | 短 | 短 | 长 | 中 | 中 |
| (1000页面) | 30s | 30s | 10min | 2min | 1min |
+--------------+---------+---------+---------+---------+---------+
| CDN费用 | 低 | 低 | 高 | 高 | 低 |
| (带宽) | (小HTML)| (回源) | (缓存) | (缓存) | (回源) |
+--------------+---------+---------+---------+---------+---------+
| 服务器费用 | 低 | 高 | 无 | 低 | 中 |
| (月度估算) | $20 | $150 | $5 | $30 | $100 |
+--------------+---------+---------+---------+---------+---------+
Lighthouse 得分对比
scss
CSR SSR SSG ISR RSC
Performance [62] [88] [98] [97] [92]
**** ***** ***** ***** *****
* *** *** *** ***
* ** ** *
(满分100,越高越好)
决策框架:如何选择渲染策略
面对具体项目,可以按以下流程做技术决策:
lua
开始
|
v
需要SEO吗?
|
+--否--> 页面是否高度交互?
| |
| +--是--> CSR (SPA)
| +--否--> 考虑SSG(仍有性能收益)
|
+--是--> 数据多久变一次?
|
+--几乎不变(月/年)--> SSG
|
+--定期更新(分钟/小时)--> ISR
| |
| +-- 可接受几分钟延迟?
| +--是--> ISR (revalidate)
| +--否--> ISR (on-demand) + Webhook
|
+--实时/个性化 --> SSR
|
+-- 页面有慢数据源?
+--是--> Streaming SSR (Suspense)
+--否--> 普通SSR
更细粒度的决策要素:
| 决策要素 | CSR | SSR | SSG | ISR |
|---|---|---|---|---|
| SEO 重要性 | 不需要 | 需要 | 需要 | 需要 |
| 数据更新频率 | 实时 | 实时 | 极低 | 中等 |
| 首屏性能要求 | 低 | 高 | 极高 | 极高 |
| 服务器成本预算 | 低 | 高 | 极低 | 低 |
| 页面数量 | 不限 | 不限 | 有限 | 不限 |
| 构建时间容忍度 | -- | -- | 关键 | 中等 |
| 个性化程度 | 高 | 高 | 无 | 低 |
| 可用性/容灾 | 中 | 低 | 极高 | 高 |
混合渲染:在同一应用中组合多种模式
真实项目中很少只用一种渲染模式。Next.js 的强大之处在于可以在同一应用中按页面甚至按组件选择不同的渲染策略。
电商项目的混合渲染架构
lua
+------------------------------------------+
| 电商项目架构 |
+------------------------------------------+
| |
| 首页 --> ISR (revalidate: 300) |
| 商品分类页 --> ISR (revalidate: 60) |
| 商品详情页 --> ISR (on-demand) |
| 搜索结果页 --> SSR |
| 购物车 --> CSR |
| 用户中心 --> CSR |
| 结算页 --> SSR (安全性考虑) |
| 帮助中心 --> SSG |
| 博客/公告 --> SSG |
| |
+------------------------------------------+
实现示例:混合渲染的 Next.js 项目结构
typescript
// next.config.ts -- 混合渲染的项目配置
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// 不同路由可以使用不同的运行时
experimental: {
// 部分页面使用Edge Runtime以降低延迟
},
async headers() {
return [
{
// SSR页面通过CDN缓存进一步优化
source: '/products/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=60, stale-while-revalidate=300',
},
],
},
];
},
};
export default nextConfig;
typescript
// app/layout.tsx -- 全局布局 (Server Component)
import { Navigation } from '@/components/Navigation';
import { Footer } from '@/components/Footer';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
{/* Navigation 是 Server Component,零JS开销 */}
<Navigation />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
// app/page.tsx -- 首页使用ISR
export const revalidate = 300; // App Router 中的ISR配置
export default async function HomePage() {
const [banners, hotProducts, categories] = await Promise.all([
fetchBanners(),
fetchHotProducts(),
fetchCategories(),
]);
return (
<>
<BannerCarousel banners={banners} />
<CategoryGrid categories={categories} />
<ProductSection title="热门商品" products={hotProducts} />
</>
);
}
typescript
// app/search/page.tsx -- 搜索页使用SSR
// 不设置 revalidate,默认动态渲染 (SSR)
export default async function SearchPage({
searchParams,
}: {
searchParams: { q: string; page?: string; sort?: string };
}) {
const { q, page = '1', sort = 'relevance' } = searchParams;
const results = await searchProducts({
query: q,
page: Number(page),
sort,
});
return (
<div>
<h1>搜索: {q}</h1>
<p>共找到 {results.total} 件商品</p>
<SortSelector current={sort} />
<ProductGrid products={results.items} />
<Pagination total={results.total} current={Number(page)} />
</div>
);
}
typescript
// app/cart/page.tsx -- 购物车使用CSR
// 标记为客户端渲染,购物车是高度个性化的
'use client';
import { useCart } from '@/hooks/useCart';
import { useAuth } from '@/hooks/useAuth';
export default function CartPage() {
const { user } = useAuth();
const { items, updateQuantity, removeItem, total } = useCart();
if (!user) {
return <LoginPrompt />;
}
return (
<div>
<h1>购物车 ({items.length})</h1>
{items.map(item => (
<CartItem
key={item.id}
item={item}
onUpdateQuantity={updateQuantity}
onRemove={removeItem}
/>
))}
<CartSummary total={total} />
<CheckoutButton />
</div>
);
}
页面级缓存策略总览
在混合渲染架构中,缓存策略是性能的关键:
lua
请求路径与缓存层级:
浏览器缓存 --> CDN边缘缓存 --> 源站缓存(Redis) --> 服务器渲染 --> 数据库
| | | | |
| Cache- | s-maxage | 服务端缓存 | SSR/RSC | 数据源
| Control | stale-while- | (可选) | 渲染 |
| | revalidate | | |
不同页面的缓存配置:
+----------------+----------+-------------+--------+
| 页面 | 浏览器 | CDN | Redis |
+----------------+----------+-------------+--------+
| 首页(ISR) | 0s | 300s + swr | -- |
| 商品页(ISR) | 0s | 60s + swr | -- |
| 搜索页(SSR) | 0s | 10s + swr | 60s |
| 帮助中心(SSG) | 3600s | 永久(不变) | -- |
| 购物车(CSR) | 0s | 不缓存 | -- |
+----------------+----------+-------------+--------+
swr = stale-while-revalidate
总结
四种渲染模式没有绝对的优劣,关键在于理解它们各自的性能特征和适用场景:
- CSR 适合不需要 SEO 的交互密集型应用,架构最简单,但首屏性能最差
- SSR 适合需要 SEO 且数据实时性要求高的场景,代价是服务器成本和 TTFB
- SSG 适合内容变化少的站点,首屏性能最优,服务器成本最低
- ISR 是 SSG 和 SSR 的折中方案,适合数据有一定更新频率但可以接受短暂延迟的场景
- RSC 在 App Router 中提供了组件级别的渲染策略选择,通过减少客户端 JS 体积进一步优化 TTI
实际项目中的最佳实践是混合使用多种渲染模式:根据每个页面的数据特征、SEO 需求和性能要求,选择最合适的策略。Next.js 提供了这种灵活性,让我们可以在同一应用中自由组合不同的渲染方式。
在做技术选型时,建议从以下三个维度出发:
- 数据特征:更新频率、个性化程度、数据量
- 用户体验:SEO 要求、首屏速度、交互响应
- 工程约束:服务器预算、构建时间、团队熟悉度
把这三个维度对齐,往往就能找到最适合当前项目的渲染策略组合。
如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。