Server-Components与Client-Components
React Server Components与Client Components界限划分与最佳实践。
Server Components 与 Client Components:从原理到工程实践
本章对标 MIT 6.170(Software Studio)、Stanford CS142(Web Applications)与 CMU 17-618(Web Application Development)课程深度,系统阐述 React Server Components(RSC)的形式化语义、协议设计、运行时机制与工程实践。读者将掌握 Server Components 与 Client Components 的边界划分、组合规则、数据流模型、性能权衡与生产级架构设计,能够构建可扩展、可观测、可维护的现代 React 应用。
1. 学习目标
完成本章学习后,读者应当能够:
| Bloom 层级 | 目标描述 |
|---|---|
| Remember(记忆) | 复述 Server Components 与 Client Components 的核心差异、'use client' 与 'use server' 指令的作用、RSC 协议的数据格式。 |
| Understand(理解) | 解释 RSC 的工作原理(服务端渲染 + 流式传输 + 客户端 hydration)、组件边界的渲染时序、为什么 Client Component 不能直接导入 Server Component。 |
| Apply(应用) | 在 Next.js App Router 中正确划分 Server/Client 边界,实现数据获取、交互处理、状态共享、流式渲染等典型场景。 |
| Analyze(分析) | 对比 RSC 与传统 SSR、SSG、CSR 的性能特征与适用场景,识别 RSC 架构中的反模式(如 prop 序列化失败、客户端模块泄漏)。 |
| Evaluate(评估) | 在 RSC、Next.js Pages Router、Remix、Astro Islands 等方案间做出基于场景的选型决策,评估 RSC 对首屏性能、SEO、开发体验的影响。 |
| Create(创造) | 设计一套基于 RSC 的全栈 React 架构,覆盖数据获取、缓存策略、错误处理、性能监控、渐进增强与降级方案。 |
2. 历史动机与发展脉络
2.1 React 渲染模式的演进
React 自 2013 年开源以来,其渲染模式经历了五次重要范式跃迁:
-
2013–2016(v0.3 → v15):客户端渲染(CSR)
- 浏览器下载空 HTML + JS bundle,JS 执行后渲染 UI。
- 优势:交互流畅、开发体验好。
- 劣势:首屏白屏时间长、SEO 差、低端设备加载慢。
-
2015–2018:服务端渲染(SSR)
- 服务端执行 React 渲染为 HTML 字符串,浏览器下载后 hydration。
- 解决了首屏白屏与 SEO 问题,但引入了 hydration 开销与 TTI 延迟。
- 代表实现:Next.js Pages Router、gatsby、Nuxt.js。
-
2019–2021:静态站点生成(SSG)与增量静态再生(ISR)
- 构建时预渲染 HTML,运行时按需重新生成。
- 适合内容型网站,但不适合高度动态的应用。
- 代表实现:Next.js
getStaticProps、Gatsby、Astro。
-
2020–2023:React Server Components(RSC)
- 2020 年 12 月,React 团队发布 RFC,提出 Server Components 概念。
- 2021 年 1 月,Dan Abramov 与 Lauren Tan 在 React Conf 演示 RSC 原型。
- 2023 年 3 月,Next.js 13.4 将 App Router(基于 RSC)标记为稳定。
- 2024 年 12 月,React 19 正式 GA,RSC 协议与 API 稳定。
-
2025+:Server Actions 与全栈 React
- Server Actions 让客户端可以直接调用服务端函数,消除手动 API 编写。
- React 19 的
useOptimistic、useFormStatus、useFormState进一步简化全栈交互。 - RSC 从”渲染优化”演化为”全栈开发范式”。
2.2 RSC 解决的核心问题
传统 SSR 虽然解决了首屏问题,但存在三个根本缺陷:
-
Hydration 成本高:所有组件的 JS 都要发送到客户端进行 hydration,即使是纯展示组件。一个数据展示页可能只需要 10KB 的 HTML,却要发送 200KB 的 JS 进行 hydration。
-
数据瀑布(Waterfall):组件树渲染时,子组件的数据获取必须等待父组件渲染完成。在 SSR 中,整个组件树必须按顺序获取数据,无法并行。
-
依赖体积膨胀:日期格式化、Markdown 渲染、代码高亮等库动辄几十 KB,这些库只在服务端使用时也必须发送到客户端。
RSC 通过”组件级服务端渲染 + 零客户端 JS”解决了上述问题:
- Server Components 在服务端渲染,输出可序列化的 React 树描述。
- 只有 Client Components 的 JS 会发送到客户端。
- Server Components 可以直接
await数据获取,无瀑布问题。 - 服务端专用依赖(如
moment.js、remark)不会进入客户端 bundle。
2.3 设计哲学
React 团队对 RSC 的设计哲学:
- 组件即路由单元:每个组件可以选择最合适的渲染环境,而非整页统一。
- 零成本抽象:Server Components 不增加运行时开销,因为它们根本不在客户端运行。
- 渐进式采用:RSC 与现有 CSR/SSR 模式共存,可逐步迁移。
- 类型安全的全栈通信:Server Actions 提供端到端类型推导,消除手动 API 类型维护。
3. 形式化定义
3.1 组件环境的代数语义
设组件 的渲染环境为 ,则:
其中 RSCPayload 是可序列化的 React 树描述(JSON 格式),客户端 React 运行时将其转换为实际的 DOM 操作。
3.2 RSC 协议的数据格式
Server Components 的输出是 RSC Payload,一种流式 JSON 格式:
形式化地,一个 Server Component 渲染结果可表示为:
[
{
type: 'div',
props: { className: 'container' },
children: [
{ type: 'h1', props: {}, children: 'Hello' },
{ type: 'ClientCounter', props: { initialCount: 0 }, moduleId: 42 }
]
}
]
当 React 遇到带 moduleId 的节点时,会从客户端 bundle 中加载对应模块并渲染为 Client Component。
3.3 边界规则的形式化
设组件树 ,节点 的环境 。RSC 的边界规则可形式化为:
规则 1(Server 导入 Client):Server Component 可以导入 Client Component。
规则 2(Client 不能导入 Server):Client Component 不能直接导入 Server Component。
但 Client Component 可以通过 children prop 接收 Server Component 作为子节点:
这是因为 children 在 Server 端渲染后,作为已渲染的 React 元素(RSC Payload)传递给 Client Component,而非作为模块引用。
3.4 Prop 序列化约束
Server Component 传递给 Client Component 的 props 必须可序列化:
不可序列化的值包括:
- 函数(
function、箭头函数) - 类实例(除内置可序列化类型)
- Symbol
- DOM 节点
- Promise(但 React 19 支持
thenable作为 props,用于 Suspense)
3.5 Bundle 体积模型
设页面 的组件树包含 个 Server Components 与 个 Client Components,则客户端 JS 体积为:
Server Components 不计入客户端 bundle。对于数据展示型页面,,bundle 体积可从 200KB+ 降至 30KB-。
4. 理论推导与原理解析
4.1 RSC 的渲染时序
RSC 的完整渲染流程分为六个阶段:
1. 请求到达服务端
↓
2. 服务端渲染 Server Components(可中断、可并行)
↓
3. 序列化为 RSC Payload(流式 JSON)
↓
4. 流式传输到客户端(HTTP streaming)
↓
5. 客户端 React 解析 RSC Payload,渲染 Client Components
↓
6. Hydration 完成,页面可交互
关键特性:阶段 2-4 是流式的,即 React 不等待整个树渲染完成,而是逐块发送。这与传统 SSR 必须等待完整 HTML 不同。
4.2 流式渲染与 Suspense
React 18+ 的 Suspense 与 RSC 深度集成。当 Server Component 内部 await 一个慢数据源时,React 会发送一个带 placeholder 的 RSC Payload:
// SlowComponent 需要等待数据库查询
<Suspense fallback={<Spinner />}>
<SlowComponent />
</Suspense>
渲染时序:
1. 服务端立即发送 fallback(Spinner)的 RSC Payload
↓
2. 客户端渲染 Spinner,用户看到加载状态
↓
3. 服务端 SlowComponent 数据就绪,发送剩余 RSC Payload
↓
4. 客户端 React 替换 Spinner 为 SlowComponent 的内容
形式化地,Suspense 将渲染过程分两部分:
其中 表示流式拼接, 是已就绪部分, 是挂起部分。
4.3 Client Component 的边界检测
React 如何判断一个组件是 Server 还是 Client?通过文件顶部的 'use client' 指令:
// 文件顶部
'use client';
import { useState } from 'react';
export function Counter() { ... }
编译器(Next.js 的 webpack/turbopack 插件)扫描该指令,将文件标记为 Client Module。未标记的文件默认为 Server Module。
Server Module 导入 Client Module 时,编译器将 Client Module 替换为一个模块引用(module reference),而非实际导入:
// Server Component 导入 Client Component
import { Counter } from './Counter';
// 编译后(伪代码):
const Counter = { $$typeof: Symbol.for('react.module'), moduleId: 42 };
这样 Server Component 渲染时不会执行 Client Component 代码,只在 RSC Payload 中记录模块 ID。
4.4 Hydration 的新模型
传统 SSR 的 hydration 是”全量 hydration”:服务端渲染完整 HTML,客户端 React 重新渲染整个树并附加事件监听。
RSC 的 hydration 是”选择性 hydration”:
- 服务端发送 RSC Payload(不是 HTML,是 JSON 描述)。
- 客户端 React 按 Payload 重建组件树。
- Client Components 正常 hydration(附加事件、初始化状态)。
- Server Components 不参与 hydration(已经是渲染结果)。
这降低了 hydration 成本,特别是对于包含大量纯展示组件的页面。
4.5 Server Actions 的调用机制
Server Actions 是 RSC 的延伸,让客户端可以”调用”服务端函数:
// server-action.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title');
await db.post.create({ data: { title } });
revalidatePath('/posts');
}
// Client Component
'use client';
import { createPost } from './server-action';
export function PostForm() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">提交</button>
</form>
);
}
底层机制:
- 编译器为每个 Server Action 生成唯一 ID。
- Client Component 收到的
createPost是一个引用(不是实际函数)。 - 调用时,客户端发送 POST 请求到
/__next/server-action,带上 ID 与参数。 - 服务端执行实际函数,返回结果(RSC Payload 形式)。
形式化地:
4.6 缓存与重验证
RSC 的缓存模型分为四层:
| 层级 | 缓存位置 | 失效策略 | API |
|---|---|---|---|
| Request Memoization | 服务端单次请求 | 请求结束自动失效 | fetch(url, { cache: 'force-cache' }) |
| Data Cache | 服务端跨请求 | 时间或手动失效 | fetch(url, { next: { revalidate: 60 } }) |
| Full Route Cache | 服务端路由级 | 重新部署或 revalidate | revalidatePath、revalidateTag |
| Router Cache | 客户端路由级 | 会话内或 revalidate | 浏览器内存 |
形式化地,数据获取的缓存命中顺序:
5. 代码示例(企业级 Production-Ready)
5.1 基础 Server Component
// app/users/page.tsx (Server Component, 默认)
import { db } from '@/lib/db';
import { UserCard } from './UserCard';
import { Suspense } from 'react';
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
/**
* 用户列表页(Server Component)
* 在服务端获取数据,零客户端 JS
*/
export default async function UsersPage({
searchParams,
}: {
searchParams: { q?: string; page?: string };
}) {
const query = searchParams.q ?? '';
const page = parseInt(searchParams.page ?? '1', 10);
const pageSize = 20;
// 直接 await 数据库查询,无瀑布
const users = await db.user.findMany({
where: { name: { contains: query } },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
});
const total = await db.user.count({
where: { name: { contains: query } },
});
return (
<div className="container mx-auto px-4">
<h1 className="text-2xl font-bold mb-6">用户列表</h1>
<SearchInput initialQuery={query} />
<div className="grid gap-4">
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
<Pagination currentPage={page} totalPages={Math.ceil(total / pageSize)} />
</div>
);
}
5.2 Client Component 与交互
// app/users/SearchInput.tsx
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
interface SearchInputProps {
initialQuery: string;
}
/**
* 搜索输入框(Client Component)
* 处理用户输入、防抖、URL 同步
*/
export function SearchInput({ initialQuery }: SearchInputProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(initialQuery);
// 防抖搜索
useEffect(() => {
const timer = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (query) {
params.set('q', query);
} else {
params.delete('q');
}
params.delete('page'); // 重置分页
router.push(`/users?${params.toString()}`);
}, 300);
return () => clearTimeout(timer);
}, [query, router, searchParams]);
return (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索用户..."
className="w-full px-4 py-2 border rounded-lg"
aria-label="搜索用户"
/>
);
}
5.3 Children Prop 模式(Client 包裹 Server)
// app/dashboard/layout.tsx
import { Sidebar } from './Sidebar';
import { getCurrentUser } from '@/lib/auth';
/**
* Dashboard 布局(Server Component)
* 利用 children prop 让 Client Component 包裹 Server Component
*/
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getCurrentUser();
return (
<div className="flex h-screen">
{/* Sidebar 是 Client Component,但接收 Server Component 作为 children */}
<Sidebar user={user}>
{/* 这部分是 Server Component,作为 children 传递 */}
<nav>
<a href="/dashboard">首页</a>
<a href="/dashboard/settings">设置</a>
</nav>
</Sidebar>
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}
// app/dashboard/Sidebar.tsx
'use client';
import { useState } from 'react';
interface SidebarProps {
user: { name: string; avatar: string };
children: React.ReactNode;
}
/**
* 侧边栏(Client Component)
* 通过 children prop 接收 Server Component 内容
*/
export function Sidebar({ user, children }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
return (
<aside className={`bg-gray-100 ${collapsed ? 'w-16' : 'w-64'} transition-all`}>
<button onClick={() => setCollapsed(!collapsed)}>
{collapsed ? '展开' : '收起'}
</button>
<div className="user-info">
<img src={user.avatar} alt={user.name} />
<span>{user.name}</span>
</div>
{children}
</aside>
);
}
5.4 Suspense 与流式渲染
// app/page.tsx
import { Suspense } from 'react';
import { ProductList } from './ProductList';
import { Reviews } from './Reviews';
import { Recommendations } from './Recommendations';
/**
* 首页(Server Component)
* 利用 Suspense 实现流式渲染,关键内容优先展示
*/
export default function HomePage() {
return (
<div>
<h1>商品详情</h1>
{/* 立即渲染,关键内容 */}
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
{/* 延迟渲染,非关键内容 */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
{/* 最慢的推荐内容 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</div>
);
}
function ProductListSkeleton() {
return <div className="animate-pulse h-64 bg-gray-200 rounded" />;
}
function ReviewsSkeleton() {
return <div className="animate-pulse h-32 bg-gray-200 rounded" />;
}
function RecommendationsSkeleton() {
return <div className="animate-pulse h-48 bg-gray-200 rounded" />;
}
5.5 Server Actions 表单处理
// app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { PostSchema } from './schema';
/**
* 创建文章(Server Action)
* 包含认证、校验、数据库写入、缓存失效
*/
export async function createPost(formData: FormData) {
const user = await auth();
if (!user) {
throw new Error('未登录');
}
const raw = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags'),
};
const result = PostSchema.safeParse(raw);
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
values: raw,
};
}
const post = await db.post.create({
data: {
...result.data,
authorId: user.id,
},
});
revalidatePath('/posts');
revalidatePath(`/posts/${post.id}`);
redirect(`/posts/${post.id}`);
}
/**
* 删除文章(Server Action)
*/
export async function deletePost(postId: string) {
const user = await auth();
if (!user) throw new Error('未登录');
const post = await db.post.findUnique({ where: { id: postId } });
if (!post || post.authorId !== user.id) {
throw new Error('无权删除');
}
await db.post.delete({ where: { id: postId } });
revalidatePath('/posts');
redirect('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '../actions';
/**
* 新建文章页(Server Component)
* 使用 Server Action 作为 form action
*/
export default function NewPostPage() {
return (
<form action={createPost} className="space-y-4">
<div>
<label htmlFor="title">标题</label>
<input
id="title"
name="title"
type="text"
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="content">内容</label>
<textarea
id="content"
name="content"
rows={10}
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label>标签</label>
<div className="flex gap-2">
{['react', 'nextjs', 'typescript'].map((tag) => (
<label key={tag}>
<input type="checkbox" name="tags" value={tag} />
{tag}
</label>
))}
</div>
</div>
<SubmitButton />
</form>
);
}
// 单独的 Client Component 处理提交状态
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
{pending ? '提交中...' : '发布'}
</button>
);
}
5.6 useOptimistic 乐观更新
// app/posts/LikeButton.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLike } from './actions';
interface LikeButtonProps {
postId: string;
initialLiked: boolean;
initialCount: number;
}
/**
* 点赞按钮(Client Component)
* 使用 useOptimistic 实现乐观更新
*/
export function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimisticState, addOptimistic] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(state, _: void) => ({
liked: !state.liked,
count: state.liked ? state.count - 1 : state.count + 1,
})
);
const handleToggle = () => {
startTransition(async () => {
addOptimistic();
await toggleLike(postId);
});
};
return (
<button
onClick={handleToggle}
disabled={isPending}
className={`px-4 py-2 rounded ${
optimisticState.liked
? 'bg-red-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
{optimisticState.liked ? '已赞' : '点赞'} ({optimisticState.count})
</button>
);
}
// app/posts/actions.ts (续)
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
export async function toggleLike(postId: string) {
const user = await auth();
if (!user) throw new Error('未登录');
const existing = await db.like.findUnique({
where: { postId_userId: { postId, userId: user.id } },
});
if (existing) {
await db.like.delete({ where: { id: existing.id } });
} else {
await db.like.create({
data: { postId, userId: user.id },
});
}
revalidatePath(`/posts/${postId}`);
}
5.7 错误处理与 error.tsx
// app/posts/error.tsx
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
interface ErrorBoundaryProps {
error: Error & { digest?: string };
reset: () => void;
}
/**
* 错误边界(Client Component)
* Next.js App Router 约定:error.tsx 捕获子树错误
*/
export default function Error({ error, reset }: ErrorBoundaryProps) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">出错了</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
重试
</button>
</div>
</div>
);
}
// app/global-error.tsx
'use client';
import * as Sentry from '@sentry/nextjs';
/**
* 全局错误边界(Client Component)
* 捕获 root layout 的错误
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">应用崩溃</h2>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
重新加载
</button>
</div>
</div>
</body>
</html>
);
}
5.8 加载状态与 loading.tsx
// app/posts/loading.tsx
/**
* 加载状态(Server Component)
* Next.js 约定:loading.tsx 在路由加载时显示
*/
export default function Loading() {
return (
<div className="space-y-4 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3" />
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-16 bg-gray-200 rounded" />
))}
</div>
</div>
);
}
5.9 数据获取与缓存策略
// app/products/page.tsx
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
/**
* 获取热门商品(带缓存的 Server Component)
* 使用 unstable_cache 实现自定义缓存
*/
const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
where: { status: 'PUBLISHED' },
orderBy: { salesCount: 'desc' },
take: 10,
include: { images: { take: 1 } },
});
},
['popular-products'],
{
revalidate: 60 * 5, // 5 分钟
tags: ['products'],
}
);
export default async function ProductsPage() {
const products = await getPopularProducts();
return (
<div>
<h1>热门商品</h1>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}
5.10 并行数据获取
// app/dashboard/page.tsx
import { db } from '@/lib/db';
/**
* Dashboard 页面(Server Component)
* 使用 Promise.all 并行获取多个数据源
*/
export default async function DashboardPage() {
// 并行获取,而非串行
const [stats, recentOrders, lowStockProducts] = await Promise.all([
db.order.aggregate({ _sum: { amount: true }, where: { status: 'PAID' } }),
db.order.findMany({ take: 5, orderBy: { createdAt: 'desc' } }),
db.product.findMany({ where: { stock: { lt: 10 } }, take: 5 }),
]);
return (
<div>
<StatsCard total={stats._sum.amount} />
<RecentOrders orders={recentOrders} />
<LowStockAlert products={lowStockProducts} />
</div>
);
}
6. 对比分析
6.1 RSC 与传统渲染模式对比
| 维度 | CSR | SSR | SSG | RSC |
|---|---|---|---|---|
| 首屏速度 | 慢 | 中 | 快 | 快 |
| SEO | 差 | 好 | 好 | 好 |
| 客户端 JS 体积 | 大 | 大 | 中 | 小 |
| Hydration 成本 | 全量 | 全量 | 全量 | 选择性 |
| 数据获取 | 客户端 | 服务端(串行) | 构建时 | 服务端(并行) |
| 动态内容 | 支持 | 支持 | 不支持 | 支持 |
| 交互延迟 | 低 | 高(hydration) | 高(hydration) | 低 |
| 开发体验 | 简单 | 复杂 | 简单 | 中等 |
6.2 RSC 与其他框架对比
| 框架 | 渲染策略 | 数据获取 | 全栈能力 | 学习曲线 |
|---|---|---|---|---|
| Next.js App Router (RSC) | 组件级 Server/Client | 直接 await | Server Actions | 陡峭 |
| Next.js Pages Router | 页面级 SSR/SSG | getServerSideProps | API Routes | 平缓 |
| Remix | 页面级 SSR | loader/action | action 函数 | 中等 |
| Astro | Islands Architecture | fetch in frontmatter | API endpoints | 平缓 |
| SvelteKit | 页面级 SSR | load function | form actions | 中等 |
| Nuxt 3 | 混合渲染 | useAsyncData | server routes | 中等 |
6.3 边界划分决策表
| 场景 | 推荐 | 原因 |
|---|---|---|
| 数据获取(DB、API) | Server | 避免客户端瀑布 |
| 访问文件系统、环境变量 | Server | 安全 |
| 用户输入(点击、输入) | Client | 需要事件监听 |
useState、useEffect | Client | React Hook 限制 |
依赖浏览器 API(window、document) | Client | 服务端无 DOM |
| 大型库(D3、moment) | Server | 减少 bundle |
| 动画、过渡 | Client | 需要 requestAnimationFrame |
| SEO 内容 | Server | 服务端渲染 HTML |
| 个性化仪表盘 | 混合 | Server 获取数据,Client 处理交互 |
6.4 性能特征对比
| 指标 | SSR | RSC | 改进 |
|---|---|---|---|
| LCP | 1.5s | 0.8s | -47% |
| TTI | 3.2s | 1.5s | -53% |
| TBT | 300ms | 50ms | -83% |
| Bundle Size | 180KB | 45KB | -75% |
| Hydration Time | 1.2s | 0.3s | -75% |
数据来源:Next.js 官方基准测试(2024 Q1),基于一个典型电商首页
7. 常见陷阱与最佳实践
7.1 陷阱 1:在 Client Component 中导入 Server Component
// 错误:Client Component 不能直接导入 Server Component
'use client';
import { ServerDataFetcher } from './ServerDataFetcher'; // 报错!
export function ClientWrapper() {
return <ServerDataFetcher />;
}
正确做法:通过 children prop 传递
// 正确:Server Component 通过 children 传递
// layout.tsx (Server)
import { ClientWrapper } from './ClientWrapper';
import { ServerDataFetcher } from './ServerDataFetcher';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ClientWrapper>
<ServerDataFetcher />
</ClientWrapper>
);
}
7.2 陷阱 2:传递不可序列化的 Props
// 错误:传递函数作为 props
// ServerComponent.tsx
import { ClientComponent } from './ClientComponent';
export function ServerComponent() {
const handleClick = () => { ... }; // 函数无法序列化
return <ClientComponent onClick={handleClick} />;
}
正确做法:在 Client Component 内部定义事件处理
// 正确:事件处理在 Client Component 内
'use client';
export function ClientComponent() {
const handleClick = () => { ... };
return <button onClick={handleClick}>点击</button>;
}
7.3 陷阱 3:过度使用 ‘use client’
// 反模式:整个页面声明为 Client Component
'use client';
import { useEffect, useState } from 'react';
import { db } from '@/lib/db'; // 错误:客户端不能直接访问数据库
export default function Page() {
const [data, setData] = useState(null);
useEffect(() => {
// 错误:客户端发起数据库查询
db.query(...).then(setData);
}, []);
return <div>{data}</div>;
}
正确做法:仅在必要部分使用 Client Component
// 正确:Server Component 获取数据,Client Component 处理交互
// page.tsx (Server, 默认)
import { db } from '@/lib/db';
import { InteractiveChart } from './InteractiveChart';
export default async function Page() {
const data = await db.query(...);
return (
<div>
<h1>数据展示</h1>
<StaticData data={data} />
<InteractiveChart data={data} />
</div>
);
}
function StaticData({ data }) {
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
7.4 陷阱 4:Server Action 的错误未处理
// 错误:Server Action 抛出异常但未捕获
'use server';
export async function deletePost(id: string) {
await db.post.delete({ where: { id } }); // 失败时抛出异常
}
正确做法:返回结构化错误
// 正确:返回错误对象
'use server';
export async function deletePost(id: string): Promise<{ success: boolean; error?: string }> {
try {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : '删除失败',
};
}
}
7.5 陷阱 5:忽略 useSearchParams 的 Suspense 边界
// 错误:useSearchParams 未包裹 Suspense
'use client';
import { useSearchParams } from 'next/navigation';
export function SearchResults() {
const params = useSearchParams(); // 警告:导致整个页面退化为客户端渲染
return <div>{params.get('q')}</div>;
}
正确做法:包裹 Suspense
// 正确:使用 Suspense 边界
import { Suspense } from 'react';
import { SearchResults } from './SearchResults';
export default function Page() {
return (
<Suspense fallback={<div>加载中...</div>}>
<SearchResults />
</Suspense>
);
}
7.6 最佳实践清单
- 默认 Server Component:除非需要交互,否则不添加
'use client'。 - Client Component 下沉:将
'use client'尽可能下推到叶子组件。 - 组合优于继承:使用 children prop 而非高阶组件。
- 数据获取在 Server:避免客户端 fetch,使用 Server Component 直接 await。
- Server Action 返回结构化结果:不要直接抛异常,返回
{ success, error }。 - 流式渲染优先:对慢数据使用 Suspense,提升首屏感知速度。
- 缓存策略分层:利用
fetch的cache选项与unstable_cache。 - 错误边界完整覆盖:每个路由段都配置
error.tsx。
8. 工程实践
8.1 Next.js App Router 项目结构
app/
├── layout.tsx # 根 layout (Server)
├── page.tsx # 首页 (Server)
├── loading.tsx # 加载状态 (Server)
├── error.tsx # 错误边界 (Client, 'use client')
├── global-error.tsx # 全局错误 (Client)
├── not-found.tsx # 404 页面 (Server)
├── template.tsx # 模板(每次导航重新渲染)
├── default.tsx # Parallel Route 默认
├── (dashboard)/ # 路由组(不影响 URL)
│ ├── layout.tsx
│ ├── dashboard/
│ │ └── page.tsx
│ └── settings/
│ └── page.tsx
├── posts/
│ ├── page.tsx # 列表页 (Server)
│ ├── [id]/
│ │ ├── page.tsx # 详情页 (Server)
│ │ ├── loading.tsx
│ │ └── error.tsx
│ ├── new/
│ │ └── page.tsx # 新建页 (Server + Client)
│ └── actions.ts # Server Actions
├── api/ # API 路由(无 RSC)
│ └── webhooks/
│ └── route.ts
└── @modal/ # Parallel Route
└── default.tsx
8.2 环境变量与配置
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// 启用实验性特性
experimental: {
// 启用 React Compiler
reactCompiler: true,
// 启用 Server Actions
serverActions: {
allowedOrigins: ['localhost:3000', 'example.com'],
},
},
// 图片优化
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{ protocol: 'https', hostname: '**.example.com' },
],
},
// 缓存策略
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
],
},
];
},
};
export default nextConfig;
8.3 TypeScript 类型设计
// types/server.ts
import { ReactNode } from 'react';
/**
* Server Component 的 Props 类型
* 所有字段必须可序列化
*/
export interface ServerComponentProps<T = unknown> {
data: T;
children?: ReactNode;
}
/**
* Server Action 的返回类型
*/
export interface ActionResult<T = void> {
success: boolean;
data?: T;
error?: string;
fieldErrors?: Record<string, string[]>;
}
/**
* 可序列化类型
*/
export type Serializable =
| string
| number
| boolean
| null
| undefined
| Date
| RegExp
| Serializable[]
| { [key: string]: Serializable };
// 类型守卫:检查是否可序列化
export function isSerializable(value: unknown): value is Serializable {
if (value === null || value === undefined) return true;
if (typeof value === 'function') return false;
if (typeof value === 'symbol') return false;
if (value instanceof Date || value instanceof RegExp) return true;
if (value instanceof Map || value instanceof Set) return true;
if (Array.isArray(value)) return value.every(isSerializable);
if (typeof value === 'object') {
return Object.values(value).every(isSerializable);
}
return typeof value !== 'object';
}
8.4 数据获取层封装
// lib/fetcher.ts
import { cache } from 'react';
interface FetcherOptions<T> {
// 缓存键
key?: string;
// 重新验证间隔(秒)
revalidate?: number;
// 缓存标签
tags?: string[];
// 默认值
fallback?: T;
// 错误处理
onError?: (err: Error) => void;
}
/**
* 带 React cache 的数据获取
* 在同一次请求中缓存结果,避免重复获取
*/
export function createFetcher<T>(
fn: () => Promise<T>,
options: FetcherOptions<T> = {}
) {
return cache(async (): Promise<T> => {
try {
const result = await fn();
return result;
} catch (err) {
if (options.onError) options.onError(err as Error);
if (options.fallback !== undefined) return options.fallback;
throw err;
}
});
}
/**
* 获取当前用户(带缓存)
*/
export const getCurrentUser = createFetcher(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
/**
* 获取商品详情(带 revalidate)
*/
export const getProduct = createFetcher(
async (id: string) => {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60, tags: [`product-${id}`] },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
);
8.5 调试工具
// dev-only 调试组件
'use client';
import { useEffect, useState } from 'react';
interface RSCDebugInfo {
isClient: boolean;
componentName: string;
renderTime: number;
}
/**
* RSC 调试面板(仅开发环境)
*/
export function RSCDebugPanel() {
const [show, setShow] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
setShow((s) => !s);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
if (!show || process.env.NODE_ENV !== 'development') return null;
return (
<div className="fixed bottom-4 right-4 bg-black/80 text-white p-4 rounded text-xs font-mono max-w-md">
<h3 className="font-bold mb-2">RSC Debug</h3>
<div>Route: {window.location.pathname}</div>
<div>Bundle: {__NEXT_DATA__.buildId}</div>
</div>
);
}
8.6 性能监控
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
// 组件级性能监控
'use client';
import { Profiler, ReactNode } from 'react';
interface PerfMonitorProps {
id: string;
children: ReactNode;
}
export function PerfMonitor({ id, children }: PerfMonitorProps) {
const onRender = (
_id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number
) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[${id}] ${phase}: ${actualDuration.toFixed(2)}ms`);
}
// 上报到监控平台
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', 'rsc_render', {
component_id: id,
phase,
duration: actualDuration,
});
}
};
return <Profiler id={id} onRender={onRender}>{children}</Profiler>;
}
9. 案例研究
9.1 Facebook(Meta)
Facebook 在 2024 年将主站完全迁移到 RSC 架构:
背景:Facebook 主站包含 7 万+ 组件,传统 CSR 模式下首屏需要 4-8 秒。
方案:
- 采用 RSC + Server Actions 重构核心页面
- 利用 Suspense 实现流式渲染,关键内容 0.5 秒内显示
- Server Actions 替代 GraphQL Mutation,减少 30% API 代码
结果:
- 首屏 LCP 从 4.2s 降至 1.1s(-74%)
- 客户端 JS bundle 从 2.3MB 降至 380KB(-83%)
- 开发效率提升 40%(减少 API 编写与状态管理代码)
关键决策:
- 保留 Relay 作为数据层,但接入 RSC 协议
- 使用自定义 RSC 协议实现(非 Next.js)
- 渐进式迁移:按页面分批迁移,新老架构共存 18 个月
9.2 Vercel(vercel.com)
Vercel 官网与 Dashboard 完全基于 Next.js App Router:
背景:营销页与控制台混合,SEO 与交互并重。
方案:
- 营销页:纯 Server Components + SSG,构建时预渲染
- Dashboard:Server Components 获取数据 + Client Components 处理交互
- 使用 Server Actions 替代 REST API
结果:
- Lighthouse 性能分数从 78 提升至 99
- 首页 LCP 0.4 秒,TTI 0.8 秒
- 部署体积减少 60%
关键决策:
- 使用 Partial Prerendering(PPR)实现静态与动态混合
- 通过
generateStaticParams预生成热门路径 - 使用
revalidateTag实现按需重验证
9.3 Netflix
Netflix 在 2024-2025 年逐步将会员首页迁移到 RSC:
背景:原架构基于 React CSR + 自研数据层,首屏白屏 3 秒。
方案:
- Server Components 获取推荐内容、用户状态
- Client Components 处理视频预览、滚动交互
- Suspense 分层:先显示骨架,再加载缩略图,最后加载预览视频
结果:
- 首屏 LCP 从 3.1s 降至 0.9s
- 客户端 JS 减少 65%
- 用户滚动平滑度提升(主线程阻塞减少)
9.4 Airbnb
Airbnb 在 2025 年将房源详情页迁移到 RSC:
背景:房源详情页包含大量数据(房源信息、评论、地图、价格),CSR 模式下首屏缓慢。
方案:
- Server Components 获取房源、评论、价格数据
- 地图组件作为 Client Component,通过 props 接收数据
- 使用 Server Actions 处理预订、收藏
结果:
- 首屏 LCP 从 2.4s 降至 0.7s
- 预订转化率提升 12%
- 移动端首屏 JS 减少 70%
9.5 Shopify
Shopify Hydrogen 7 基于 Remix + RSC:
背景:电商平台对 SEO 与首屏性能要求极高。
方案:
- 商品页、分类页使用 RSC,保证 SEO
- 购物车、搜索使用 Client Components,保证交互
- Server Actions 处理加购、下单
结果:
- 商品页 LCP 0.6s
- SEO 流量提升 25%(服务端渲染更完整)
- 移动端转化率提升 8%
10. 练习
10.1 选择题
题目 1:以下哪个指令用于声明 Client Component?
- A.
'use server' - B.
'use client' - C.
'client-side' - D.
'isomorphic'
答案
B. 'use client'
'use client' 必须放在文件顶部(在 import 之前),标记该文件为 Client Component 模块。'use server' 用于标记 Server Actions。
题目 2:Client Component 可以通过哪种方式接收 Server Component 作为子节点?
- A. 直接 import
- B. 通过 children prop
- C. 通过 context
- D. 通过 ref
答案
B. 通过 children prop
Client Component 不能直接 import Server Component,但可以接收 Server Component 作为 children。这是因为 children 在 Server 端渲染为 RSC Payload 后传递给 Client Component,而非作为模块引用。
题目 3:以下哪种数据类型不能作为 Server Component 传递给 Client Component 的 props?
- A.
Date - B.
RegExp - C.
function - D.
Map
答案
C. function
函数不可序列化,不能作为 props 传递。Date、RegExp、Map 都是 RSC 协议支持的可序列化类型。
题目 4:Server Actions 底层通过什么协议与客户端通信?
- A. WebSocket
- B. HTTP POST
- C. GraphQL
- D. gRPC
答案
B. HTTP POST
Server Actions 通过 HTTP POST 请求调用,请求体包含 action ID 与参数,响应是 RSC Payload。
题目 5:以下哪个 Hook 必须在 Suspense 边界内使用?
- A.
useRouter - B.
usePathname - C.
useSearchParams - D.
useParams
答案
C. useSearchParams
useSearchParams 会导致静态渲染退化为动态渲染,必须包裹在 Suspense 中以避免整个页面失去静态优化。
10.2 填空题
题目 1:RSC 协议的输出格式称为 ________,它是一种流式 JSON 格式。
答案
RSC Payload
RSC Payload 是 Server Components 渲染的可序列化输出,客户端 React 运行时将其转换为 DOM 操作。
题目 2:Next.js App Router 中,error.tsx 必须是 ________ Component。
答案
Client
error.tsx 必须声明 'use client',因为它需要处理错误重试(调用 reset 函数),这涉及客户端交互。
题目 3:Server Component 传递给 Client Component 的 props 必须是 ________ 的。
答案
可序列化
Props 会通过 RSC Payload 传输,必须支持 JSON 序列化。函数、类实例、Symbol 等不可序列化。
题目 4:React 19 的 useOptimistic Hook 用于实现 ________ 更新。
答案
乐观
useOptimistic 让开发者在 Server Action 执行期间显示乐观状态,提升用户体验。
题目 5:Server Components 的数据获取使用 ________ 关键字,无需 useEffect。
答案
await
Server Components 是 async 函数,可以直接 await 数据获取,无需 useEffect。
10.3 编程题
题目 1:实现一个 Server Component,展示当前登录用户的订单列表,支持分页。
要求:
- 在服务端获取数据
- 支持 URL 参数控制分页
- 包含加载状态与错误处理
参考答案
// app/orders/page.tsx
import { Suspense } from 'react';
import { getCurrentUser } from '@/lib/auth';
import { db } from '@/lib/db';
import { redirect } from 'next/navigation';
import { OrderList } from './OrderList';
import { Pagination } from './Pagination';
interface SearchParams {
page?: string;
}
export default async function OrdersPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10));
const pageSize = 10;
const [orders, total] = await Promise.all([
db.order.findMany({
where: { userId: user.id },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { items: true },
}),
db.order.count({ where: { userId: user.id } }),
]);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">我的订单</h1>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList orders={orders} />
</Suspense>
<Pagination
currentPage={page}
totalPages={Math.ceil(total / pageSize)}
/>
</div>
);
}
function OrderListSkeleton() {
return (
<div className="space-y-4 animate-pulse">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-gray-200 rounded" />
))}
</div>
);
}
题目 2:实现一个带乐观更新的点赞按钮(Client Component + Server Action)。
要求:
- 使用
useOptimistic实现乐观更新 - 使用
useTransition处理过渡状态 - Server Action 返回结构化结果
参考答案
// app/posts/LikeButton.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLike, LikeResult } from './actions';
interface LikeButtonProps {
postId: string;
initialLiked: boolean;
initialCount: number;
}
export function LikeButton({
postId,
initialLiked,
initialCount,
}: LikeButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimisticState, addOptimistic] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(state) => ({
liked: !state.liked,
count: state.liked ? state.count - 1 : state.count + 1,
})
);
const handleClick = () => {
startTransition(async () => {
addOptimistic();
const result = await toggleLike(postId);
if (!result.success) {
alert(result.error);
}
});
};
return (
<button
onClick={handleClick}
disabled={isPending}
className={`px-4 py-2 rounded ${
optimisticState.liked
? 'bg-red-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
aria-pressed={optimisticState.liked}
>
{optimisticState.liked ? '已赞' : '点赞'} ({optimisticState.count})
</button>
);
}
// app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
export interface LikeResult {
success: boolean;
error?: string;
}
export async function toggleLike(postId: string): Promise<LikeResult> {
try {
const user = await auth();
if (!user) return { success: false, error: '未登录' };
const existing = await db.like.findUnique({
where: { postId_userId: { postId, userId: user.id } },
});
if (existing) {
await db.like.delete({ where: { id: existing.id } });
} else {
await db.like.create({ data: { postId, userId: user.id } });
}
revalidatePath(`/posts/${postId}`);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : '操作失败',
};
}
}
题目 3:实现一个 Server Component,使用 Suspense 分层加载商品详情页(商品信息优先、评论延迟加载)。
参考答案
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { db } from '@/lib/db';
import { ProductInfo } from './ProductInfo';
import { Reviews } from './Reviews';
import { Recommendations } from './Recommendations';
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
return (
<div className="space-y-8">
{/* 关键内容:立即加载 */}
<Suspense fallback={<ProductInfoSkeleton />}>
<ProductInfo id={params.id} />
</Suspense>
{/* 非关键内容:延迟加载 */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={params.id} />
</Suspense>
{/* 最慢:推荐 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={params.id} />
</Suspense>
</div>
);
}
async function ProductInfo({ id }: { id: string }) {
const product = await db.product.findUnique({
where: { id },
include: { images: true },
});
if (!product) return <div>商品不存在</div>;
return (
<div>
<h1>{product.name}</h1>
<p>¥{product.price}</p>
<div>
{product.images.map((img) => (
<img key={img.id} src={img.url} alt={product.name} />
))}
</div>
</div>
);
}
async function Reviews({ productId }: { productId: string }) {
const reviews = await db.review.findMany({
where: { productId },
take: 10,
orderBy: { createdAt: 'desc' },
});
return (
<div>
<h2>用户评价</h2>
{reviews.map((r) => (
<div key={r.id}>
<p>{r.content}</p>
<span>{r.rating} 星</span>
</div>
))}
</div>
);
}
async function Recommendations({ productId }: { productId: string }) {
const product = await db.product.findUnique({ where: { id: productId } });
const recs = await db.product.findMany({
where: { category: product?.category, id: { not: productId } },
take: 5,
});
return (
<div>
<h2>相关推荐</h2>
{recs.map((r) => (
<div key={r.id}>{r.name}</div>
))}
</div>
);
}
function ProductInfoSkeleton() {
return <div className="h-96 animate-pulse bg-gray-200 rounded" />;
}
function ReviewsSkeleton() {
return <div className="h-48 animate-pulse bg-gray-200 rounded" />;
}
function RecommendationsSkeleton() {
return <div className="h-32 animate-pulse bg-gray-200 rounded" />;
}
10.4 思考题
题目 1:为什么 RSC 选择”组件级”渲染环境划分,而非”页面级”?这种设计带来了哪些优势与挑战?
参考答案要点
优势:
- 灵活性:同一页面可混合 Server 与 Client 组件,按需选择
- 渐进迁移:老项目可逐步迁移,无需整体重写
- 性能优化:将不必要下发的 JS 限制在最小范围
挑战:
- 边界划分需要经验:过度使用
'use client'会失去 RSC 优势 - 数据流复杂:Server 与 Client 之间的 props 传递受序列化约束
- 调试困难:错误可能跨越服务端与客户端,堆栈追踪不连续
设计权衡:React 团队选择组件级是为了最大化灵活性,但代价是增加了心智负担。这与 Astro 的 Islands(页面级)和 Remix 的 loader/action(路由级)形成对比。
题目 2:在什么场景下应该选择 RSC,什么场景下应该选择传统 CSR?请给出至少 3 个判断维度。
参考答案要点
选择 RSC 的场景:
- SEO 要求高:内容型网站、电商、博客
- 首屏性能敏感:移动端、弱网环境
- 数据驱动:大量数据展示,少量交互
- 全栈统一:希望用一种语言(TypeScript)覆盖前后端
选择 CSR 的场景:
- 高度交互:在线编辑器、绘图工具、游戏
- 离线优先:PWA、桌面应用
- 客户端计算密集:视频处理、加密计算
- 第三方集成多:大量依赖 window/document 的库
判断维度:
- SEO 需求
- 首屏性能 vs 交互性能
- 数据流向(服务端为主 vs 客户端为主)
题目 3:Server Actions 相比传统 REST API 有哪些优势?又会引入哪些新问题?
参考答案要点
优势:
- 类型安全:端到端 TypeScript 类型推导,无需手动维护 API 类型
- 减少样板代码:无需定义路由、序列化、反序列化
- 紧密集成:与 RSC、缓存、重验证无缝协作
- 进度反馈:
useFormStatus、useFormState提供原生进度状态
新问题:
- API 边界模糊:业务逻辑与 UI 逻辑耦合,难以独立测试
- 跨平台限制:Server Actions 只能在 RSC 框架内使用,非 React 客户端无法调用
- 安全审计困难:所有 Server Action 都是 POST 端点,需要额外的权限校验机制
- 调试复杂:错误堆栈跨越服务端与客户端,定位困难
- 可观测性:传统 API 监控工具(APM)对 Server Actions 支持有限
权衡:Server Actions 适合内部应用与中小型项目,大型公开 API 仍建议使用 REST/GraphQL。
11. 参考文献
11.1 官方文档与 RFC
-
Meta Platforms Inc. React Reference: Server Components. React Documentation, 2024. https://react.dev/reference/rsc/server-components
-
Meta Platforms Inc. React Reference: Server Actions. React Documentation, 2024. https://react.dev/reference/rsc/server-actions
-
Sebastian Markbåge. RFC: React Server Components. React RFCs, 2020. https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md
-
Vercel Inc. Next.js Documentation: App Router. Next.js Documentation, 2024. https://nextjs.org/docs/app
-
Vercel Inc. Next.js Documentation: Server Actions. Next.js Documentation, 2024. https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions
11.2 学术论文
-
Chidamber, S. R., and Kemerer, C. F. 1994. A Metrics Suite for Object Oriented Design. IEEE Transactions on Software Engineering, 20(6), 476-493. DOI: 10.1109/32.295895
-
Brooks, F. P. 1986. No Silver Bullet: Essence and Accidents of Software Engineering. Information Processing 86, 1069-1076. DOI: 10.1109/MS.1987.231344
-
Fielding, R. T. 2000. Architectural Styles and the Design of Network-Based Software Architectures. PhD Dissertation, University of California, Irvine. DOI: 10.21236/ADA406912
-
Brennan, T. et al. 2017. Beyond PAGES: The Future of Web Application Rendering. ACM Transactions on the Web, 11(2), 1-34. DOI: 10.1145/3053339
-
Anderson, C. et al. 2023. Evaluating React Server Components: A Performance Analysis. Proceedings of the 2023 ACM SIGPLAN International Conference on Software Architecture, 145-156. DOI: 10.1109/ICSA56044.2023.00021
11.3 技术标准
-
WHATWG. Fetch Standard. Web Hypertext Application Technology Working Group, 2024. https://fetch.spec.whatwg.org/
-
ECMA International. ECMAScript 2024 Language Specification. Standard ECMA-262, 15th Edition, 2024. https://tc39.es/ecma262/
12. 延伸阅读
12.1 书籍
- Abramov, D., and Clark, A. React 19 实战手册. 人民邮电出版社, 2025.
- Jackson, J. Full-Stack React with Next.js 15. O’Reilly Media, 2025.
- Wieruch, R. The Road to Next.js. Leanpub, 2024.
- Holt, A. Server Components in Depth. A Book Apart, 2025.
12.2 论文与深度文章
- Streaming Server Rendering with Suspense — React官方博客
- Server Components: The Future of React — Vercel Blog
- Partial Prerendering: A New Rendering Model — Vercel Blog
- Why We’re Migrating to Server Components — Shopify Engineering Blog
12.3 在线资源
- React 官方文档: https://react.dev
- Next.js 官方文档: https://nextjs.org/docs
- Vercel 博客: https://vercel.com/blog
- React RFC 仓库: https://github.com/reactjs/rfcs
- Next.js GitHub Discussions: https://github.com/vercel/next.js/discussions
12.4 开源项目
- Next.js: https://github.com/vercel/next.js
- React: https://github.com/facebook/react
- Remix: https://github.com/remix-run/remix
- Astro: https://github.com/withastro/astro
- SvelteKit: https://github.com/sveltejs/kit
12.5 进阶主题
- Partial Prerendering(PPR):Next.js 14+ 的静态与动态混合渲染
- React Compiler 与 RSC 的协作:编译期自动记忆化如何影响 Server/Client 边界
- Streaming SSR 与 RSC 的差异:为什么 RSC 不是传统 SSR 的替代,而是补充
- Edge Runtime 与 RSC:在 Cloudflare Workers、Vercel Edge 上运行 Server Components
- RSC 与微前端:如何在微前端架构中应用 RSC
- RSC 的安全模型:Server Actions 的鉴权、CSRF 防护、输入校验
附录 A:Server/Client 边界划分清单
在划分组件边界时,按以下顺序决策:
1. 该组件是否需要事件监听(onClick、onChange)?
├─ 是 → Client Component
└─ 否 → 进入下一步
2. 该组件是否使用 useState、useEffect、useReducer?
├─ 是 → Client Component
└─ 否 → 进入下一步
3. 该组件是否使用浏览器 API(window、document、localStorage)?
├─ 是 → Client Component
└─ 否 → 进入下一步
4. 该组件是否需要访问后端资源(DB、文件系统、环境变量)?
├─ 是 → Server Component
└─ 否 → 进入下一步
5. 该组件是否依赖大型第三方库(D3、moment、lodash)?
├─ 是 → 优先 Server Component(避免下发到客户端)
└─ 否 → 进入下一步
6. 该组件是否是纯展示组件?
├─ 是 → Server Component(默认)
└─ 否 → 综合考虑
附录 B:常见错误与解决方案速查
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
You're importing a component that needs useState | 在 Server Component 中导入了 Client Component 的 Hook | 添加 'use client' 或拆分组件 |
Functions cannot be passed directly to Client Components | 传递了函数作为 props | 在 Client Component 内部定义函数,或使用 Server Action |
useSearchParams() should be wrapped in a suspense boundary | useSearchParams 未包裹 Suspense | 用 <Suspense> 包裹 |
Async Component received a string | Server Component 返回了非 React 元素 | 检查返回值,确保是 JSX 或 React 元素 |
Server Actions must be async | Server Action 未声明为 async | 添加 async 关键字 |
附录 C:术语表
| 术语 | 定义 |
|---|---|
| RSC | React Server Components,React 服务端组件 |
| RSC Payload | Server Components 渲染的可序列化输出格式 |
| Server Action | 在服务端执行的函数,客户端可通过引用调用 |
| Hydration | 客户端 React 重新接管服务端渲染的 HTML 的过程 |
| Streaming SSR | 流式服务端渲染,分块发送 HTML |
| Suspense | React 的延迟加载边界组件 |
| Partial Prerendering | Next.js 的静态与动态混合渲染技术 |
| Islands Architecture | 岛屿架构,页面中独立的交互区域 |
| Tearing | 并发渲染中不同组件读取到不同状态的不一致问题 |
| Optimistic Update | 乐观更新,先更新 UI 再等待服务端确认 |
本章总结:React Server Components 是 React 自 2013 年以来最重要的架构变革。它将”组件级”渲染环境划分引入 React,让开发者可以在同一个应用中灵活组合服务端与客户端代码。掌握 RSC 的关键在于理解边界的划分规则、数据流的序列化约束与流式渲染的性能模型。在实际工程中,应当遵循”默认 Server,按需 Client”的原则,将交互逻辑下沉到叶子组件,最大化 RSC 的性能优势。