为什么理解渲染策略至关重要
选择错误的渲染策略是 Next.js 应用最常见的性能问题来源。在面试中,能否清晰解释这四种渲染模式的差异、trade-offs,以及在不同场景下的选择依据,是区分「会用框架」和「理解原理」的关键。
渲染策略直接影响三个核心指标:首屏时间(FCP/LCP)、TTI(可交互时间)、以及服务端资源消耗。理解这些策略不是为了记住规则,而是为了能够在具体业务场景下做出工程判断。
一、四种渲染策略的定义
1.1 SSG(Static Site Generation,静态站点生成)
SSG 在构建时(build time) 生成页面的 HTML。每个 URL 对应一个预先生成好的 HTML 文件。用户访问时,服务端直接返回这个静态文件,不需要任何服务端计算。
构建时:
posts/ → 生成 posts/index.html, posts/first-post.html 等
用户请求:
GET /posts/first-post → 返回已生成的 HTML(CDN 可缓存)
核心特征:
- 构建一次,反复使用
- 极快的 TTFB(Time to First Byte)
- 天然适合 CDN 边缘节点缓存
- 适合内容不频繁变化的页面
适用场景:
- 博客文章、文档页面
- 产品营销页面
- 不需要个性化内容的页面
// app/posts/[slug]/page.tsx
// 默认静态生成(在 App Router 中)
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({ slug: post.slug }));
}
export default async function PostPage({ params }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}
1.2 SSR(Server-Side Rendering,服务端渲染)
SSR 在每次请求时(per-request) 动态生成 HTML。用户访问页面时,服务端执行页面组件,获取数据,渲染 HTML,然后返回。
用户请求:
GET /dashboard → 服务端执行 React 组件树(可能需要数据库查询)
→ 生成 HTML
→ 返回给浏览器
每次请求都执行完整的服务端计算
核心特征:
- 每次请求都有服务端计算成本
- 数据始终新鲜(always fresh)
- 可以访问请求上下文(cookies、headers)
- 可以做个性化渲染
适用场景:
- 需要用户认证的页面(个性化内容)
- 频繁变化的数据(股票行情、实时数据)
- 需要 SEO 且内容个性化的页面
// app/dashboard/page.tsx
// 强制每次请求都服务端渲染
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const user = await getUser(); // 基于 cookie 获取当前用户
const data = await fetchFreshData();
return <Dashboard user={user} data={data} />;
}
1.3 ISR(Incremental Static Regeneration,增量静态再生)
ISR 是 Next.js 最独特的能力:页面静态生成,但可以在运行时(at runtime)重新验证(revalidate)和更新。这打破了"静态 = 构建时固定"的等式。
构建时:生成 HTML
运行时:定期重新验证,更新 HTML
用户请求:
GET /products/shoes → 返回缓存的 HTML
→ 同时触发后台重新验证
→ 下次请求时,缓存已更新
核心特征:
- 静态的性能(CDN 缓存、fast TTFB)
- 动态的内容(定期更新)
- 按需重新验证(on-demand revalidation)
// app/products/[id]/page.tsx
// 每 60 秒重新验证一次
export const revalidate = 60;
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
// 这个数据会在后台定期重新获取
return <ProductDetail product={product} />;
}
1.4 CSR(Client-Side Rendering,客户端渲染)
CSR 完全在浏览器中渲染页面。服务端返回的 HTML 只是一个空壳(或者包含一个 loading 状态),真正的内容由 JavaScript 在浏览器中生成。
服务端返回:
HTML 包含 <div id="root"></div> 和 bundle.js
浏览器执行:
bundle.js → React 组件树 → DOM 操作 → 显示内容
核心特征:
- 首屏 HTML 几乎无内容(SEO 差)
- 需要下载并执行 JS 才能看到内容
- 适合高度交互的"应用"型页面
在 Next.js App Router 中的位置:
App Router 中,页面默认是服务端组件,但页面内部可以包含客户端组件。真正的全 CSR 页面需要使用 dynamic 导入并禁用 SSR:
'use client';
// app/heavy-interactive/page.tsx
// 这个组件及其子树全部在客户端渲染
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
ssr: false, // 完全禁用服务端渲染
loading: () => <ChartSkeleton />
});
export default function HeavyInteractivePage() {
return <HeavyChart data={chartData} />;
}
二、Next.js 如何决定每个页面的渲染策略
Next.js 的渲染策略由多个配置点共同决定,理解它们的优先级顺序很重要。
2.1 决定树(Decision Tree)
页面有 generateStaticParams 吗?
→ 是:该页面 SSG(预渲染)
→ 否:进入动态渲染判断
页面有 dynamic = 'force-dynamic' 吗?
→ 是:SSR(每次请求渲染)
页面有 revalidate 设置吗?
→ 是:ISR(在后台定期重新验证)
以上都没有:
→ SSR(默认行为,除非是叶子节点且无动态数据?)
→ 实际上 App Router 默认是 SSG/ISR
2.2 核心配置项
| 配置 | 值 | 渲染策略 |
|---|---|---|
generateStaticParams |
存在 | SSG |
dynamic = 'force-dynamic' |
设置 | SSR(每请求) |
dynamic = 'force-static' |
设置 | SSG(强制静态) |
revalidate = number |
设置 | ISR(定时重新验证) |
| 默认(无配置) | - | SSG(自动静态优化) |
2.3 fetch 请求的 cache 选项
在 App Router 中,fetch() 请求可以通过 cache 选项控制缓存行为:
// 默认:服务端组件的 fetch 会自动被缓存(等同于 SSG)
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache' // 默认值
});
// 每次请求重新获取(等同于 SSR)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
// 定时重新验证(等同于 ISR)
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
});
对于非 fetch 的数据获取(直接数据库查询等),使用 unstable_cache 或框架提供的数据缓存 API。
三、revalidate 和增量生成
3.1 时间基础重新验证(Time-based Revalidation)
// app/blog/page.tsx
// 每小时重新验证一次
export const revalidate = 3600;
async function getBlogPosts() {
const posts = await fetch('https://cms.example.com/posts', {
next: { revalidate: 3600 }
});
return posts.json();
}
3.2 按需重新验证(On-demand Revalidation)
当 CMS 更新了内容,需要立即重新验证缓存,而不是等待下一次定时重新验证:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 });
}
if (body.type === 'post') {
// 重新验证特定的路径
revalidatePath(`/blog/${body.slug}`);
}
if (body.type === 'all-posts') {
// 重新验证所有带有 'posts' 标签的缓存
revalidateTag('posts');
}
return Response.json({ revalidated: true });
}
3.3 重新验证的传播机制
当 revalidatePath('/blog/my-post') 被调用时:
- Full Route Cache 中
/blog/my-post的 HTML 被标记为过期 - Data Cache 中所有与这个路径关联的 fetch 请求被标记为过期
- 下一次请求到达时,Next.js 在后台重新渲染页面并缓存结果
四、流式渲染和 React Suspense 的结合
4.1 什么是流式渲染
传统 SSR 是"全有或全无":要么整个页面都渲染好了返回,要么就等待。但一个页面可能有多个数据来源,速度各不相同——快的部分等待慢的部分是不合理的。
流式渲染(Streaming)将页面拆分成多个Chunk,当一个 chunk 的数据准备好时就立即发送给浏览器,而不是等待所有数据都准备好。
传统 SSR:
[========等待所有数据========]
→ 一次性返回完整 HTML
流式渲染:
[Header(快)] → [Footer(快)] → [Content(慢)] → 逐步返回 HTML
4.2 Suspense:React 的流式原语
React 的 Suspense 是实现流式渲染的机制。<Suspense fallback={<Loading />}> 包裹一个异步组件,当异步数据还在加载时显示 fallback UI,数据就绪后自动切换到实际内容。
import { Suspense } from 'react';
// 慢的组件:数据获取可能耗时
async function UserProfile({ userId }) {
const user = await fetchUser(userId); // 可能很慢
return <Profile user={user} />;
}
async function UserActivity({ userId }) {
const activity = await fetchActivity(userId); // 另一个慢的来源
return <ActivityFeed activity={activity} />;
}
// loading 组件
function ProfileSkeleton() {
return <div className="skeleton-profile">加载中...</div>;
}
// 主页面
export default function UserPage({ params }) {
return (
<div>
<Header />
{/* 这两个 Suspense 边界独立运作,互不阻塞 */}
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={params.id} />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<UserActivity userId={params.id} />
</Suspense>
<Footer />
</div>
);
}
在这个例子中,UserProfile 和 UserActivity 是并行获取数据的。如果 UserProfile 先完成,就先显示;不需要等 UserActivity 也完成。
4.3 loading.tsx:Next.js 的 Suspense 集成
loading.tsx 是 Next.js 对 Suspense 的声明式封装。Next.js 会自动把页面内容包裹在 Suspense 边界中:
// app/posts/loading.tsx
// 这个 UI 会在 posts 页面数据加载时显示
export default function PostsLoading() {
return (
<div className="posts-grid">
{Array(6).fill(null).map((_, i) => (
<PostSkeleton key={i} />
))}
</div>
);
}
// app/posts/page.tsx
// Next.js 自动给这个组件加上 Suspense 边界
export default async function PostsPage() {
const posts = await getPosts();
return <PostsGrid posts={posts} />;
}
4.4 流式渲染的 HTTP 基础
流式渲染依赖 HTTP 的 Transfer-Encoding: chunked。当浏览器开始收到 chunked 响应时,会立即解析并渲染已收到的部分,而不是等待整个响应完成。这就是为什么用户会看到页面内容"逐步出现"而不是"一次性弹出"。
Next.js 在服务rito 渲染时自动使用 chunked 编码,但需要确保你的部署环境(Vercel、Node.js server 等)支持流式响应。
五、渲染策略的选择框架
选择渲染策略不是靠死记硬背,而是基于几个关键问题的回答:
| 问题 | SSG | ISR | SSR | CSR |
|---|---|---|---|---|
| 内容多久变化一次? | 很少(天/周级别) | 频繁(小时/分钟级别) | 每请求都变 | 取决于用户交互 |
| 需要个性化内容? | 否 | 有限 | 是 | 是 |
| SEO 重要吗? | 非常重要 | 重要 | 重要 | 不重要 |
| 性能要求(FCP/LCP)? | 极高 | 高 | 中等 | 低 |
| 服务端资源成本? | 低(构建时计算) | 低 | 高(每次请求) | 无 |
实际场景选择
博客文章列表:SSG(构建时生成,不变化) 电商产品详情:ISR(价格库存可能变化,但不需要秒级更新) ** Twitter 时间线**:SSR + CSR(个性化+实时,但首屏需要 SEO) ** Google 表格应用**:CSR(完全用户交互驱动,无 SEO 需求)
六、面试高频问题
Q1: SSG 和 ISR 的区别是什么?
SSG 在构建时一次性生成 HTML,之后不更新。ISR 在构建时生成 HTML,但设置了 revalidate 后会在运行时定期重新验证和更新内容。ISR 本质上是"可更新的 SSG"。
Q2: 什么时候应该用 force-dynamic?
当页面内容在每次请求时都不同,必须实时计算时使用。例如:需要读取 cookie 判断用户身份、需要展示实时数据、需要基于请求参数的个性化页面。
Q3: 流式渲染和传统 SSR 的性能差异在哪里?
传统 SSR 必须等待所有数据就绪才能开始发送 HTML,用户看到的是"白屏等待→完整页面"。流式渲染让快的数据先呈现,用户看到的是"骨架屏→逐步填充"。对用户感知性能(Perceived Performance)有显著提升。
Q4: ISR 的 revalidate 是如何工作的?
revalidate 设置后,Next.js 会在后台定期重新验证缓存的 fresh 数据。当缓存过期时,下一个请求会触发重新渲染并更新缓存。On-demand revalidate(revalidatePath/revalidateTag)可以立即使缓存失效,常用于 CMS 更新时主动推送刷新。
延展阅读
- Next.js 官方文档 — Rendering — 渲染策略完整文档
- Next.js 官方文档 — Streaming and Suspense — 流式渲染
- Vercel 博客 — The World of React Server Components — RSC 与渲染
- Jason Miller — Is SSR actually faster than CSR? — 性能对比分析