一、四种渲染模式的架构对比
Next.js 13+的App Router引入了全新的渲染体系,支持在路由级别甚至组件级别自由组合渲染策略。理解各模式的数据流差异,是做出正确技术选型的前提。
1.1 四种渲染模式对比
// Next.js 13 App Router 渲染模式
//
// ① Static Generation (SSG)
// 渲染时机:Build时
// 缓存:CDN(永不失效,除非手动revalidate)
// 适用:博客、文档、Marketing页面
// 特点:最快,无服务端开销
//
// ② ISR (Incremental Static Regeneration)
// 渲染时机:首次请求时 + 后台定时
// 缓存:CDN(按revalidate时间失效)
// 适用:频繁更新但有缓存需求的页面
// 特点:静态速度 + 动态更新
//
// ③ SSR (Server-Side Rendering)
// 渲染时机:每个请求时
// 缓存:no-cache(每次服务端渲染)
// 适用:需要实时数据、用户相关数据
// 特点:最新数据,较高服务端开销
//
// ④ PPR (Partial Prerendering)
// 渲染时机:Build时静态骨架 + 请求时动态
// 适用:静态内容+动态个性化混合
// 特点:Streaming + Suspense组合
// 性能对比(1000次请求测试环境):
// 模式 TTFB(中位数) P99延迟
// SSG 28ms 45ms ← 最快
// ISR(60s) 28ms→620ms 45ms→800ms (缓存过期瞬间)
// SSR 180ms 450ms
// PPR 30ms 65ms
二、App Router的缓存策略
2.1 fetch请求的缓存层级
// Next.js 13 App Router的fetch缓存配置
// 缓存粒度细化到每个fetch请求
// app/blog/[slug]/page.tsx
// 缓存策略一:force-cache(默认,SSG)
async function getBlogPost(slug: string) {
const res = await fetch('https://api.example.com/posts/' + slug, {
cache: 'force-cache' // 默认值,可省略
});
return res.json();
}
// → Build时预渲染,结果永久缓存(等效SSG)
// 缓存策略二:no-store(SSR,每次请求都重新获取)
async function getUserProfile(userId: string) {
const res = await fetch(`https://api.example.com/users/${userId}`, {
cache: 'no-store' // 关闭缓存,每次SSR
});
return res.json();
}
// → 每次请求执行服务端渲染,数据最新
// 缓存策略三:revalidate(ISR,N秒后自动失效)
async function getNews() {
const res = await fetch('https://news.api/latest', {
next: { revalidate: 60 } // 60秒内使用缓存
});
return res.json();
}
// → 60秒内:返回缓存(CDN)
// → 60秒后:第一个请求触发revalidate,后台重新获取
// 缓存策略四:标签级失效(精确控制)
async function getProduct(id: string) {
const res = await fetch(`https://shop.api/products/${id}`, {
next: { tags: ['product', `product-${id}`] }
});
return res.json();
}
// 调用 revalidateTag('product-123') → 只清除特定标签的缓存
2.2 动态路由与generateStaticParams
// 动态路由的预渲染策略
// app/products/[category]/[slug]/page.tsx
// 方式一:全量预渲染(SSG,适合小规模)
// build时生成所有产品页
export async function generateStaticParams() {
const products = await fetchAllProducts(); // 获取全量数据
return products.map(p => ({
category: p.category,
slug: p.slug
}));
}
// 优点:所有页面预渲染,TTFB极低
// 缺点:产品数量大时Build时间爆炸(10万SKU = 10万次fetch)
// 方式二:部分预渲染 + fallback
// 只预渲染热门产品,其他动态生成
export async function generateStaticParams() {
const hotProducts = await fetchHotProducts(); // 只取热门1000个
return hotProducts.map(p => ({
category: p.category,
slug: p.slug
}));
}
// fallback: 'blocking'(默认)
// 非预渲染页面:首次请求时服务端渲染,之后CDN缓存
// 访问 /products/electronics/xyz-123(非热门)
// → SSR → 渲染后缓存 → 后续请求走缓存
// fallback: false
// → 未预渲染的页面直接返回404
// 方式三:ISR + 动态混合(推荐大规模电商)
export const revalidate = 3600; // 每小时重新验证
export async function generateStaticParams() {
// 只预渲染一级分类页
const categories = await fetchCategories();
return categories.map(c => ({ category: c.slug }));
}
// 产品详情页(非预渲染,动态生成)
export default async function ProductPage({ params }) {
// 使用revalidate实现"准实时"缓存
const product = await getProduct(params.slug);
return <ProductDetail product={product} />;
}
三、Streaming SSR与Suspense的结合
3.1 流式渲染的原理
// Streaming SSR = 分块传输 + Suspense边界
//
// 传统SSR:全部数据获取 → 完整HTML → 一次性返回
// Streaming SSR:先返回骨架 → 数据就绪后逐块推送
//
// 传输流(HTTP Chunked Transfer Encoding):
// HTTP/1.1 200 OK
// Content-Type: text/html
// Transfer-Encoding: chunked
//
// ① 先发送静态HTML骨架(立即)
//
//
//
// ⚡ ← Suspense fallback
//
//
// ② 然后发送动态内容(数据就绪后)
//
// app/dashboard/page.tsx
import { Suspense } from 'react';
// 慢数据组件(独立Suspense边界)
async function ProductList() {
const products = await fetchProductsSlow(); // 模拟慢查询 2s
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
async function RecentOrders() {
const orders = await fetchRecentOrders(); // 快,200ms
return <div>{orders.length} recent orders</div>;
}
// 页面骨架立即返回,动态区域逐步填充
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* 快速组件:先渲染 */}
<Suspense fallback={<div>Loading orders...</div>}>
<RecentOrders />
</Suspense>
{/* 慢速组件:骨架屏 → 数据填充 */}
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
</div>
);
}
3.2 Streaming vs 非Streaming的时序差异
// 时序对比(Dashboard页面,有快慢两个数据源)
//
// 传统SSR时序:
// t=0ms → 收到请求
// t=0-200ms → 获取RecentOrders (200ms)
// t=200-2200ms → 获取ProductList (2000ms)
// t=2200ms → HTML完整,发送到客户端
// t=2200ms+ → First Contentful Paint
// 结论:用户等了2.2秒才看到任何内容
// Streaming SSR时序:
// t=0ms → 收到请求
// t=0ms → 发送HTML骨架(立即)
// t=200ms → RecentOrders数据就绪,flush更新
// t=200ms → FCP(用户看到骨架+快速内容)
// t=2000ms → ProductList数据就绪,flush更新
// t=2000ms → 完整内容
// 结论:200ms时用户就看到内容,不用等待2秒
// 实际测试(真实网络):
// TTFB(非Streaming):2200ms → FCP 2200ms
// TTFB(Streaming): 200ms → FCP 200ms
// TTFB差距:10倍
// 感知体验:骨架屏 + 逐步填充 vs 长时间白屏
四、RSC(React Server Components)架构
4.1 Server Components vs Client Components
// App Router的组件树:
// Server Components(默认,不带'use client')
// ├── 直接访问数据库/文件系统
// ├── 不参与hydration(零JS发送到客户端)
// ├── 可以使用async/await
// └── 可以<ClientComponent>包装客户端组件
//
// Client Components(带'use client'指令)
// ├── 参与React hydration
// ├── 可以使用useState、useEffect、事件处理
// └── 不能直接访问服务端资源
// app/blog/page.tsx(Server Component,默认)
import { db } from '@/lib/db';
export default async function BlogIndex() {
// 直接在服务端访问数据库,不经过API层
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10
});
// posts只在服务端存在,不会发送到浏览器
return (
<main>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
{/* LikeButton是Client Component,嵌入Server Component */}
<LikeButton postId={posts[0].id} initialCount={0} />
</main>
);
}
// components/LikeButton.tsx(Client Component)
'use client'; // 必须声明'use client'
import { useState } from 'react';
export function LikeButton({ postId, initialCount }) {
const [count, setCount] = useState(initialCount);
return (
<button onClick={() => setCount(c => c + 1)}>
❤️ {count}
</button>
);
}
4.2 水合(Hydration)优化
// 传统SSR + Hydration的问题:
// 服务端渲染HTML → 浏览器下载JS → 执行JS → 绑定事件
// "两遍渲染":服务端一遍,客户端一遍
// Next.js的优化:减少Hydration体积
//
// app/layout.tsx(根布局是Server Component)
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
);
}
// ❌ 误区:在根布局使用useState
// 'use client'
// export default function RootLayout({ children }) {
// const [theme, setTheme] = useState('dark'); // ❌ 无法工作
// }
// 根布局是Server Component,不能使用hook
// ✅ 正确做法:创建ThemeProvider包装
// app/providers.tsx
'use client';
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// app/layout.tsx
import { ThemeProvider } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}