430 lines
14 KiB
Markdown
430 lines
14 KiB
Markdown
|
|
---
|
|||
|
|
tags: [React, Next.js, SSR, SSG, ISR, Frontend, Server Components]
|
|||
|
|
create time: 2026-04-29 22:13
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Server-Side Rendering
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
服务端渲染(SSR)让 React 组件在服务器端预渲染为 HTML,显著改善首屏加载速度和 SEO。本文档以 Next.js App Router 为核心,介绍 SSR/SSG/ISR 的渲染策略、Server Components 体系与最佳实践。
|
|||
|
|
|
|||
|
|
> [!question] 思考:用户从输入 URL 到看到页面,中间经历了哪些步骤?
|
|||
|
|
>
|
|||
|
|
> 传统 CSR 模式下,浏览器先拿到一个几乎空的 HTML,然后下载 JS bundle,执行 React 来生成内容——用户需要等待两件事都完成才能看到页面。SSR 把「生成 HTML」这一步搬到服务器做,用户请求回来时就已经有可读的内容了。
|
|||
|
|
|
|||
|
|
## 渲染模式对比
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TB
|
|||
|
|
subgraph CSR["客户端渲染 CSR"]
|
|||
|
|
A["HTML空白页"] --> B["下载JS Bundle"]
|
|||
|
|
B --> C["执行React hydration"]
|
|||
|
|
C --> D["显示内容"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph SSR["服务端渲染 SSR"]
|
|||
|
|
E["请求页面"] --> F["服务器渲染React -> HTML"]
|
|||
|
|
F --> G["发送含内容的HTML"]
|
|||
|
|
G --> H["客户端hydration"]
|
|||
|
|
H --> I["交互可用"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph SSG["静态生成 SSG"]
|
|||
|
|
J["构建时渲染"] --> K["生成纯HTML文件"]
|
|||
|
|
K --> L["CDN分发"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
style F fill:#4FC08D,color:#fff
|
|||
|
|
style H fill:#F5A87D,color:#000
|
|||
|
|
style J fill:#61DAFB,color:#000
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 三种策略决策表
|
|||
|
|
|
|||
|
|
| 策略 | 适用场景 | 数据时效性 | 构建参与 |
|
|||
|
|
|------|----------|-----------|---------|
|
|||
|
|
| **SSR**(Server Render) | 个性化页面、实时数据 | ✅ 每次请求实时生成 | ❌ |
|
|||
|
|
| **SSG**(Static Site Generation) | 博客、文档、营销页 | ⏱️ 构建时生成 | ✅ |
|
|||
|
|
| **ISR**(Incremental Static Regeneration) | 新闻列表、商品目录 | 🔄 定时后台更新 | ✅(增量) |
|
|||
|
|
|
|||
|
|
> [!tip] 核心区别一句话
|
|||
|
|
>
|
|||
|
|
> - **SSR**:每个用户请求都触发一次服务端的完整渲染。
|
|||
|
|
> - **SSG**:构建时生成一次 HTML,所有用户共享同一个静态页面。
|
|||
|
|
> - **ISR**:先用 SSG 生成的静态页面响应,后台静默重新生成后替换——对用户无感知。
|
|||
|
|
|
|||
|
|
## Next.js App Router 架构
|
|||
|
|
|
|||
|
|
> [!note] Server Component 是默认值
|
|||
|
|
>
|
|||
|
|
> Next.js App Router 中,所有组件默认就是 **Server Component**——除非你在文件顶部声明 `"use client"`。这意味着你可以放心地在组件里 await 数据库查询、读取环境变量或访问文件系统,这些代码永远不会发送到浏览器。
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// app/layout.tsx —— 根布局(所有页面共享)
|
|||
|
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|||
|
|
return (
|
|||
|
|
<html lang="zh">
|
|||
|
|
<body>
|
|||
|
|
<Navbar />
|
|||
|
|
{children}
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// app/page.tsx —— 首页(SSR by default)
|
|||
|
|
async function HomePage() {
|
|||
|
|
// ✅ 直接在组件中 await API
|
|||
|
|
const posts = await fetchPosts();
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<main>
|
|||
|
|
{posts.map(post => <PostCard key={post.id} post={post} />)}
|
|||
|
|
</main>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// app/blog/[slug]/page.tsx —— 动态路由页
|
|||
|
|
async function PostPage({ params }: { params: { slug: string } }) {
|
|||
|
|
const post = await getPostBySlug(params.slug);
|
|||
|
|
return <article>{post.content}</article>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Streaming SSR + Suspense
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant Client as 浏览器
|
|||
|
|
participant Server as 服务器
|
|||
|
|
|
|||
|
|
Client->>Server: GET /dashboard
|
|||
|
|
Server->>Server: 并行请求 user/profile/orders
|
|||
|
|
Note over Server: 每个请求可有自己的Suspense boundary
|
|||
|
|
|
|||
|
|
Server-->>Client: HTML: Navbar + Sidebar<br/>(立即显示,约200ms)
|
|||
|
|
|
|||
|
|
Server-->>Client: Stream: Profile card<br/>(中等优先级,约800ms)
|
|||
|
|
|
|||
|
|
Server-->>Client: Stream: Order history<br/>(低优先级,约1500ms)
|
|||
|
|
|
|||
|
|
Note over Client: 用户体验:渐进式展示,而非等全部完成
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// Dashboard layout(流式渲染的关键)
|
|||
|
|
function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
{/* 非关键 UI 用 Suspense 包裹 */}
|
|||
|
|
<Suspense fallback={<SidebarSkeleton />}>
|
|||
|
|
<Sidebar />
|
|||
|
|
</Suspense>
|
|||
|
|
|
|||
|
|
{children}
|
|||
|
|
|
|||
|
|
{/* 各区域独立 Suspense */}
|
|||
|
|
<Suspense fallback={<StatsSkeleton />}>
|
|||
|
|
<StatsPanel />
|
|||
|
|
</Suspense>
|
|||
|
|
<Suspense fallback={<OrdersSkeleton />}>
|
|||
|
|
<OrdersPanel />
|
|||
|
|
</Suspense>
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!note] Streaming SSR 的工作原理
|
|||
|
|
>
|
|||
|
|
> 服务端将 HTML 分成多个 chunk,通过网络流逐步发送给浏览器。浏览器边收边渲染——不需要等所有数据就绪。**优先级高的区域先出,低的在后**,用户体验从「全部等」变成「渐进可见」。
|
|||
|
|
|
|||
|
|
## Data Fetching 策略
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 方案1:直接 await(默认缓存 + 共享缓存)
|
|||
|
|
async function Page() {
|
|||
|
|
const data = await fetchData(); // 自动缓存,同路径请求去重
|
|||
|
|
return <div>{data}</div>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 方案2:带 revalidate 的 ISR
|
|||
|
|
async function Page() {
|
|||
|
|
const data = await fetchData({ next: { revalidate: 60 } }); // 60秒后后台重新验证
|
|||
|
|
return <div>{data}</div>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 方案3:no-store(强制 SSR,不走缓存)
|
|||
|
|
async function Page() {
|
|||
|
|
const data = await fetchData({ cache: "no-store" }); // 每次请求都获取最新数据
|
|||
|
|
return <div>{data}</div>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 方案4:client component 中的 fetch(使用 TanStack Query)
|
|||
|
|
"use client";
|
|||
|
|
function Page() {
|
|||
|
|
const { data } = useQuery({ queryKey: ["data"], queryFn: () => fetch("/api/data").then(r => r.json()) });
|
|||
|
|
return <div>{data}</div>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Cache vs Revalidate vs no-store 决策指南
|
|||
|
|
|
|||
|
|
| 策略 | 行为 | 适合场景 |
|
|||
|
|
|------|------|---------|
|
|||
|
|
| `fetch(url)` 不加配置 | 基于 HTTP 协议的永久缓存(直到下次部署失效) | 不常变化的配置数据 |
|
|||
|
|
| `{ next: { revalidate: N } }` | CDN 级别缓存,N 秒后后台重新验证 | 商品信息、文章列表等 |
|
|||
|
|
| `{ cache: "no-store" }` | 完全不走缓存,每次都回源 | 用户面板、实时仪表盘 |
|
|||
|
|
| `{ next: { tags: ["posts"] } }` + `revalidateTag("posts")` | 按标签精确失效 | CMS 系统发布新文章时触发更新 |
|
|||
|
|
|
|||
|
|
## Server Component vs Client Component 边界
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// server component(默认,无需声明)
|
|||
|
|
async function ServerComponent() {
|
|||
|
|
// ✅ 可以直接访问数据库、API密钥、文件系统
|
|||
|
|
const db = await dbConnection.query("SELECT * FROM users");
|
|||
|
|
return <UsersList users={db} />;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// client component(需显式声明)
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
function InteractiveChart() {
|
|||
|
|
// ✅ 可以使用 useState/useEffect/DOM API
|
|||
|
|
const [zoom, setZoom] = useState(1);
|
|||
|
|
useEffect(() => { ... }, []);
|
|||
|
|
|
|||
|
|
return <canvas ref={canvasRef} />;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Parent
|
|||
|
|
function Dashboard() {
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<ServerComponent /> {/* 在服务端渲染 */}
|
|||
|
|
<InteractiveChart /> {/* 在客户端渲染 */}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!warning] Client → Server 通信限制
|
|||
|
|
>
|
|||
|
|
> - Client Component 无法直接调用 Server Component 的方法或 props
|
|||
|
|
> - Server Component 也不能传给 Client Component 引用了函数、Promise 或 Generator 的值
|
|||
|
|
> - 解决方案:通过 URL 参数、cookies、或后端 API 传递数据
|
|||
|
|
|
|||
|
|
### 何时该用 Server Component?何时该用 Client Component?
|
|||
|
|
|
|||
|
|
> [!question] 如何判断一个组件应该放在哪一边?
|
|||
|
|
>
|
|||
|
|
> 记住一条黄金法则:**能放服务器的就放服务器**。Server Component 是零 bundle size 的——它们不会增加客户端 JavaScript 体积。只有当你确实需要浏览器专属 API(事件监听、状态管理、DOM 操作)时才降级到 Client Component。
|
|||
|
|
|
|||
|
|
| 需要浏览器能力吗? | 推荐方案 |
|
|||
|
|
|-------------------|---------|
|
|||
|
|
| ❌ 不需要(只渲染数据) | Server Component ✅ |
|
|||
|
|
| ✅ 需要 `useState` / `useEffect` / `onClick` | Client Component (`"use client"`) |
|
|||
|
|
| ✅ 需要第三方交互库(地图、图表) | Client Component |
|
|||
|
|
| ✅ 需要浏览器 API(localStorage、Geolocation) | Client Component |
|
|||
|
|
| ❌ 只需要从 API 获取数据并展示 | Server Component ✅ |
|
|||
|
|
|
|||
|
|
## Server Actions
|
|||
|
|
|
|||
|
|
Server Action 允许你在服务端定义可直接从 Client Component 调用的异步函数——无需手动写 API 路由。
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// app/actions.ts —— 独立的 Server Action 模块
|
|||
|
|
"use server";
|
|||
|
|
|
|||
|
|
import { revalidatePath } from "next/cache";
|
|||
|
|
|
|||
|
|
export async function createPost(formData: FormData) {
|
|||
|
|
const title = formData.get("title") as string;
|
|||
|
|
const content = formData.get("content") as string;
|
|||
|
|
|
|||
|
|
// 数据库写入
|
|||
|
|
await db.post.create({ data: { title, content } });
|
|||
|
|
|
|||
|
|
// 成功后刷新对应页面的缓存
|
|||
|
|
revalidatePath("/blog");
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// app/blog/new/page.tsx —— 表单页面(Client Component)
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { createPost } from "@/app/actions";
|
|||
|
|
|
|||
|
|
function NewPostForm() {
|
|||
|
|
async function handleSubmit(formData: FormData) {
|
|||
|
|
await createPost(formData);
|
|||
|
|
// 提交后跳转到列表页
|
|||
|
|
router.push("/blog");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<form action={handleSubmit}>
|
|||
|
|
<input name="title" placeholder="标题" required />
|
|||
|
|
<textarea name="content" placeholder="内容" required />
|
|||
|
|
<button type="submit">发布</button>
|
|||
|
|
</form>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!tip] Server Actions 的优势
|
|||
|
|
>
|
|||
|
|
> 1. **零样板代码**:不再需要手动编写 API Route + fetch 调用链
|
|||
|
|
> 2. **类型安全**:函数签名天然携带类型信息
|
|||
|
|
> 3. **内置序列化和校验**:FormData 自动解析,配合 Zod 做 schema 校验
|
|||
|
|
> 4. **直接操作服务端状态**:数据库读写、文件操作、认证上下文一步到位
|
|||
|
|
|
|||
|
|
## Metadata API & 动态 SEO
|
|||
|
|
|
|||
|
|
Next.js 提供了声明式的元数据 API,自动生成 `<head>` 中的标签。
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// app/blog/[slug]/page.tsx —— 动态元数据
|
|||
|
|
import { getPostBySlug } from "@/lib/posts";
|
|||
|
|
|
|||
|
|
// 静态 generateMetadata(构建时可确定)
|
|||
|
|
export async function generateMetadata({ params }) {
|
|||
|
|
const post = await getPostBySlug(params.slug);
|
|||
|
|
return {
|
|||
|
|
title: `${post.title} | My Blog`,
|
|||
|
|
description: post.excerpt,
|
|||
|
|
openGraph: { title, images: [post.coverImage] },
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 动态 generateViewport / robots / sitemap
|
|||
|
|
export function generateStaticParams() {
|
|||
|
|
const posts = getAllPosts();
|
|||
|
|
return posts.map(post => ({ slug: post.slug }));
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!note] generateStaticParams vs dynamicParams
|
|||
|
|
>
|
|||
|
|
> 如果未列出的动态路由被访问到,Next.js 默认会返回 404。你可以通过 `next.config.js` 设置 `dangerouslyAllowHostnameMismatch = true` 或在路由目录中添加 `not-found.tsx` 来自定义 404 行为。对于 API 驱动的项目,可以设置 `dynamicParams = false` 确保安全性。
|
|||
|
|
|
|||
|
|
## Cookies、Headers 与 Auth
|
|||
|
|
|
|||
|
|
Server Component 可以直接读取和修改 cookies/headers,这是构建认证系统的基石。
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// app/dashboard/page.tsx —— Server Component
|
|||
|
|
import { cookies, headers } from "next/headers";
|
|||
|
|
import { redirect } from "next/navigation";
|
|||
|
|
|
|||
|
|
async function DashboardPage() {
|
|||
|
|
// 读取 cookie
|
|||
|
|
const sessionCookie = (await cookies()).get("session");
|
|||
|
|
|
|||
|
|
// 验证 token
|
|||
|
|
if (!sessionCookie) {
|
|||
|
|
redirect("/login");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 读取请求头
|
|||
|
|
const userAgent = (await headers()).get("user-agent");
|
|||
|
|
|
|||
|
|
// 使用 session 数据渲染页面
|
|||
|
|
return <DashboardContent session={sessionCookie.value} />;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 错误处理
|
|||
|
|
|
|||
|
|
SSR 环境下的错误处理需要考虑服务端和网络异常的双重场景。
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// app/error.tsx —— 捕获渲染阶段的错误
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<h2>出错了</h2>
|
|||
|
|
<p>{error.message}</p>
|
|||
|
|
<button onClick={() => reset()}>重试</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// app/not-found.tsx —— 自定义 404 页面
|
|||
|
|
export default function NotFound() {
|
|||
|
|
return <h2>页面不存在</h2>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 异步组件中的优雅降级
|
|||
|
|
async function CommentsSection({ postId }: { postId: string }) {
|
|||
|
|
// 评论组件失败不影响整个页面
|
|||
|
|
let comments;
|
|||
|
|
try {
|
|||
|
|
comments = await fetchComments(postId);
|
|||
|
|
} catch {
|
|||
|
|
comments = []; // 降级为空数组
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 性能调优
|
|||
|
|
|
|||
|
|
### 关键指标与优化方向
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph LR
|
|||
|
|
A["TTFB<br/>Time to First Byte"] -->|"降低服务器处理时间"| B["边缘缓存<br/>ISR / CDN"]
|
|||
|
|
C["LCP<br/>Largest Contentful Paint"] -->|"减小首屏 JS 体积"| D["Server Components ✅"]
|
|||
|
|
E["CLS<br/>Cumulative Layout Shift"] -->|"预留空间防抖动"| F["固定尺寸 + Aspect Ratio"]
|
|||
|
|
|
|||
|
|
style A fill:#F5A87D,color:#000
|
|||
|
|
style C fill:#F5A87D,color:#000
|
|||
|
|
style E fill:#F5A87D,color:#000
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Image Optimization & Font Optimization
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import Image from "next/image";
|
|||
|
|
import { Inter } from "next/font/google";
|
|||
|
|
|
|||
|
|
const inter = Inter({ subsets: ["latin"] }); // 零 CLS 字体加载
|
|||
|
|
|
|||
|
|
// next/image 自动做 WebP 转换 + responsive srcset + lazy loading
|
|||
|
|
<Image
|
|||
|
|
src="/hero.jpg"
|
|||
|
|
alt="Hero image"
|
|||
|
|
width={1200}
|
|||
|
|
height={600}
|
|||
|
|
placeholder="blur" // 懒加载时用模糊缩略图占位
|
|||
|
|
priority // 首屏图片提前加载
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 最佳实践
|
|||
|
|
|
|||
|
|
> [!tip] 实战经验总结
|
|||
|
|
|
|||
|
|
### 要点清单
|
|||
|
|
|
|||
|
|
1. **Server Component 优先**:默认情况下所有组件都是 Server Component,充分利用其零 bundle size 和数据直取的能力。只有在需要交互时才添加 `"use client"`。
|
|||
|
|
2. **Suspense 粒度要合理**:不要把所有东西包在一个大 Suspense 里(起不到流式效果),也不要每个小组件都加(overhead)。按数据依赖层级划分边界。
|
|||
|
|
3. **合理使用数据缓存策略**:`revalidate` 适合大多数内容型数据,`no-store` 用于用户相关实时数据,`tags` 用于精确实时失效控制。
|
|||
|
|
4. **Server Actions 替代手写 API**:减少样板代码的同时保持类型安全,但注意不要滥用——简单 CRUD 以外仍建议走标准的 API Route 模式。
|
|||
|
|
5. **做好错误降级**:SSR 中一个组件出错会导致整页白屏,对非关键组件要用 try-catch 兜底或 `<Suspense>` 隔离。
|
|||
|
|
6. **善用 Metadata API**:SEO 相关的工作都应该在 `generateMetadata` 等 API 中完成,避免在 JSX 中手动操作 head。
|
|||
|
|
7. **关注 Core Web Vitals**:Server Components 天然利好 LCP(减少 JS),ISR + CDN 利好 TTFB,Image/Font Optimization 利好 CLS。
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[13-并发特性]]
|
|||
|
|
- [[15-性能优化]]
|
|||
|
|
- [[09-状态管理]]
|