React Server Components 深入
问题
React Server Components(RSC)是什么?它的工作原理、使用场景和最佳实践是什么?
答案
一、RSC 基本概念
React Server Components 是 React 18 引入、React 19 正式稳定的组件级别服务端渲染方案。与传统 SSR 不同,RSC 允许组件只在服务端运行,其代码不会被打包到客户端 JS 中。
设计动机
- 减少 Bundle Size — Server Component 代码零客户端打包
- 直接访问后端资源 — 数据库、文件系统、内部 API 无需额外接口
- 流式渲染 — 配合 Suspense 实现渐进式页面加载
- 更好的数据获取 — 组件内直接
async/await,消除客户端瀑布流
RSC 的本质是将组件树分为两类:服务端运行的 Server Component 和客户端运行的 Client Component,它们可以自由组合在同一棵组件树中。
'use client' 与 'use server' 指令
// 'use client' — 标记客户端边界,该文件及其导入的模块会打包到客户端
'use client';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// 'use server' — 标记 Server Action,函数只在服务端执行
'use server';
export async function createPost(formData: FormData) {
await db.post.create({ data: { title: formData.get('title') as string } });
}
'use client' 不是"在客户端运行"的意思,而是声明客户端边界。没有标记的组件默认是 Server Component。'use server' 也不是"在服务端运行组件",而是专门标记 Server Action 函数。
二、RSC 工作原理
RSC 请求-渲染流程
RSC Payload(RSC 协议)
RSC Payload 是服务端发送给客户端的序列化虚拟 DOM 描述,包含:
- Server Component 的渲染结果 — 已转化为 HTML/虚拟 DOM 节点
- Client Component 的占位符 — 引用客户端 JS Bundle 中的组件
- Props 数据 — 从 Server 传递给 Client 的可序列化数据
// Server Component 渲染结果(已计算完毕,不含 JS 逻辑)
J0: ["$", "div", null, {
children: [
["$", "h1", null, { children: "Dashboard" }], // SC 渲染结果
["$", "@1", null, { data: [1, 2, 3] }] // CC 占位符引用
]
}]
// @1 → 引用客户端 Bundle 中的 <Chart /> 组件
与传统 SSR 的核心区别
| 维度 | 传统 SSR | RSC |
|---|---|---|
| 渲染粒度 | 页面级别 | 组件级别 |
| Hydration | 整个页面都需要 Hydrate | 只 Hydrate Client Components |
| 客户端 JS | 全部组件代码打包 | Server Component 代码不打包 |
| 数据获取 | getServerSideProps 等页面级 API | 组件内直接 async/await |
| 导航更新 | 整页刷新或客户端路由 | 局部更新 RSC Payload |
| 状态保持 | 导航时丢失客户端状态 | 客户端状态在 SC 刷新时保持 |
三、Server Component vs Client Component
能力对比
| 能力 | Server Component | Client Component |
|---|---|---|
| async/await 获取数据 | ✅ | ❌ |
| 访问数据库/文件系统 | ✅ | ❌ |
| useState / useEffect | ❌ | ✅ |
| 事件监听 onClick 等 | ❌ | ✅ |
| 浏览器 API(window 等) | ❌ | ✅ |
| 使用 Context | ❌ | ✅ |
| 代码打包到客户端 | ❌ | ✅ |
| 重新渲染(re-render) | 通过 refetch | 通过 setState |
Server Component 数据获取示例
// 这是 Server Component(默认,无需声明)
async function DashboardPage() {
const stats = await db.stats.findMany(); // 直接查数据库
const user = await fetch('/api/user').then(r => r.json()); // 或调 API
return (
<div>
<h1>Welcome, {user.name}</h1>
<StatsChart data={stats} /> {/* Client Component */}
</div>
);
}
不再需要 useEffect → useState → loading/error 状态管理的模板代码。数据在服务端获取完毕后直接渲染,客户端看到的就是完整内容。详见 Hooks 原理 中关于 useEffect 数据获取的讨论。
四、组件组合模式
RSC 最重要的设计原则:Server Component 可以导入 Client Component,但反过来不行。
Composition Pattern(推荐)
将 Server Component 作为 children 传递给 Client Component:
// ServerWrapper.tsx(Server Component)
import { ClientLayout } from './ClientLayout';
import { ServerContent } from './ServerContent';
export function ServerWrapper() {
return (
<ClientLayout>
<ServerContent /> {/* SC 作为 children 传入 CC */}
</ClientLayout>
);
}
// ClientLayout.tsx
'use client';
export function ClientLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="layout">
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<main>{children}</main> {/* SC 渲染结果在此展示 */}
</div>
);
}
'use client';
import { ServerContent } from './ServerContent'; // ❌ 会变成 Client Component!
export function ClientLayout() {
return <div><ServerContent /></div>;
}
在 'use client' 文件中直接 import 的组件会被当作 Client Component 处理,即使它原本没有 'use client' 标记。要在 CC 中使用 SC,只能通过 props(如 children)传入。
五、数据获取模式
1. 直接 async/await
export default async function PostsPage() {
const posts = await prisma.post.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
});
return <PostList posts={posts} />;
}
2. React cache 请求去重
多个 Server Component 请求同一数据时,React 自动去重:
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
const user = await db.user.findUnique({ where: { id } });
return user;
});
// 组件 A 和组件 B 都调用 getUser('123'),实际只执行一次查询
3. 配合 Suspense 流式渲染
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* 快速内容先展示 */}
<UserInfo />
{/* 慢查询用 Suspense 包裹,流式推送 */}
<Suspense fallback={<ChartSkeleton />}>
<SlowChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<SlowTable />
</Suspense>
</div>
);
}
async function SlowChart() {
const data = await fetchAnalytics(); // 耗时 2s
return <Chart data={data} />;
}
六、Server Actions
Server Actions 是标记了 'use server' 的异步函数,在客户端调用时自动发送请求到服务端执行。它是表单提交和数据变更的推荐方式,替代了传统的 API Route。
基础用法
'use server';
import { revalidatePath } from 'next/cache';
export async function createTodo(formData: FormData) {
const title = formData.get('title') as string;
// 服务端直接操作数据库
await db.todo.create({ data: { title } });
// 刷新页面数据
revalidatePath('/todos');
}
import { createTodo } from '../actions';
export default function TodoPage() {
return (
<form action={createTodo}>
<input name="title" />
<SubmitButton />
</form>
);
}
useActionState + useFormStatus
'use client';
import { useFormStatus } from 'react-dom';
import { useActionState } from 'react';
import { createTodo } from '../actions';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? '提交中...' : '提交'}</button>;
}
function TodoForm() {
const [state, formAction, isPending] = useActionState(createTodo, null);
return (
<form action={formAction}>
<input name="title" />
{state?.error && <p className="error">{state.error}</p>}
<SubmitButton />
</form>
);
}
useOptimistic 乐观更新
'use client';
import { useOptimistic } from 'react';
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
);
async function handleAdd(formData: FormData) {
const title = formData.get('title') as string;
addOptimistic({ id: 'temp', title, completed: false }); // 立即更新 UI
await createTodo(formData); // 实际请求
}
return (
<>
<form action={handleAdd}>...</form>
{optimisticTodos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
</>
);
}
更多 React 19 新 API 详见 React 19 新特性。
Server Actions 是公开的 HTTP 端点!必须:
- 验证输入 — 使用 zod 等 schema 验证
- 检查权限 — 验证用户身份和授权
- 防止 CSRF — Next.js 自动处理,但自定义实现需注意
- 不信任客户端数据 — 所有输入都当作不可信
七、性能优化
1. 零 Bundle Size
Server Component 的代码(包括其依赖的库)不会出现在客户端 JS Bundle 中:
// Server Component — 这些 import 不增加客户端 Bundle
import { marked } from 'marked'; // 35KB
import hljs from 'highlight.js'; // 180KB
import { format } from 'date-fns'; // 用多少打包多少
export async function BlogPost({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.content); // 服务端执行,客户端 0 成本
return <article dangerouslySetInnerHTML={{ __html: html }} />;
}
2. Streaming SSR + 选择性 Hydration
- Streaming:服务端边渲染边发送 HTML,用户更早看到内容
- 选择性 Hydration:只 Hydrate Client Component,Server Component 部分无需 Hydrate
3. Partial Prerendering(PPR)
Next.js 15 引入的实验特性,结合 SSG 和 SSR 的优势:
export default function ProductPage() {
return (
<div>
{/* 静态壳 — 构建时预渲染 */}
<Header />
<ProductInfo />
{/* 动态孔洞 — 请求时渲染 */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice /> {/* 实时价格 */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<UserReviews /> {/* 个性化评论 */}
</Suspense>
</div>
);
}
静态部分从 CDN 即时返回,动态部分流式填充 — 兼具 SSG 的速度和 SSR 的动态性。更多 Next.js 优化详见 Next.js 核心知识。
八、使用决策
| 场景 | 推荐 | 原因 |
|---|---|---|
| 数据展示(文章、列表、详情页) | Server Component | 直接获取数据、减少 Bundle |
| 表单交互(输入、提交) | Client Component | 需要 state 和事件 |
| 导航/布局壳 | Server Component | 静态结构 |
| 动画/手势 | Client Component | 需要浏览器 API |
| Markdown/代码渲染 | Server Component | 大型库不影响 Bundle |
| 实时数据(WebSocket) | Client Component | 需要持久连接 |
九、与其他方案对比
| 维度 | RSC | 传统 SSR | SSG | Islands (Astro) |
|---|---|---|---|---|
| 渲染时机 | 请求时(组件级) | 请求时(页面级) | 构建时 | 构建时 + 请求时 |
| Hydration | 选择性(仅 CC) | 全量 | 全量 | 选择性(Islands) |
| JS Bundle | 仅 CC 代码 | 全部组件代码 | 全部组件代码 | 仅 Island 代码 |
| 框架绑定 | React 生态 | 通用 | 通用 | 框架无关 |
| 数据获取 | 组件级 async | 页面级 API | 构建时 | 组件级 |
| 状态保持 | SC 更新时保持 CC 状态 | 导航时丢失 | 导航时丢失 | Island 内保持 |
| 学习曲线 | 中高 | 低 | 低 | 中 |
十、常见陷阱与最佳实践
'use client'边界过宽 — 整个页面标记为 CC,失去 RSC 优势- 在 SC 中使用 Hooks — useState、useEffect 等只能在 CC 中使用
- Props 不可序列化 — 函数、Date 对象、class 实例不能从 SC 传给 CC
- 数据瀑布 — 嵌套 SC 串行获取数据,应并行化
- 混淆指令 —
'use server'不是标记 Server Component,是标记 Server Action
最佳实践:
// ❌ 整个页面标记为 'use client'
// 'use client';
// export default function Page() { ... }
// ✅ 只把需要交互的部分抽为 CC
export default async function Page() {
const data = await getData(); // SC 获取数据
return (
<div>
<StaticContent data={data} /> {/* SC */}
<InteractiveWidget /> {/* CC — 只有这部分需要 'use client' */}
</div>
);
}
export default async function Dashboard() {
// ❌ 串行
// const user = await getUser();
// const posts = await getPosts();
// ✅ 并行
const [user, posts] = await Promise.all([getUser(), getPosts()]);
return <DashboardView user={user} posts={posts} />;
}
更多 React 性能优化方案参考 React 性能优化。
常见面试问题
Q1: React Server Components 和传统 SSR 有什么区别?
答案:
| 区别 | 传统 SSR | RSC |
|---|---|---|
| 粒度 | 页面级别,整页在服务端渲染 | 组件级别,SC 和 CC 混合 |
| Hydration | 全量 Hydration,全部 JS 都发客户端 | 选择性 Hydration,SC 代码不发客户端 |
| 数据获取 | getServerSideProps 等页面级 API | 组件内 async/await |
| 导航 | 重新请求整个 HTML | 只请求变化部分的 RSC Payload |
| 客户端状态 | 导航时 CC 状态丢失 | SC 刷新时 CC 状态保持 |
传统 SSR 只是把 HTML"提前画好"发给客户端,客户端仍要下载全部 JS 做 Hydration。RSC 则让 SC 的代码永远不出现在客户端,从根本上减少了 JS 体积。
Q2: 'use client' 和 'use server' 指令分别是什么作用?
答案:
'use client'— 声明客户端边界。该文件及其所有导入会被打包到客户端 JS Bundle 中。它不是说"这个组件只在客户端运行"(SSR 时也会在服务端预渲染),而是标记"从这里开始是 Client 领域"。'use server'— 标记 Server Action 函数。在客户端调用时会自动生成 HTTP 请求发送到服务端执行。它不是用来标记 Server Component 的(默认就是 SC)。
// 'use client' — 标记客户端边界
'use client';
export function Button() { /* 可用 useState、onClick */ }
// 'use server' — 标记 Server Action
'use server';
export async function saveData(data: FormData) { /* 服务端执行 */ }
Q3: Server Component 中能使用 useState 和 useEffect 吗?为什么?
答案:
不能。因为 Server Component 在服务端执行一次后就变成了静态的渲染结果(RSC Payload),它不存在于客户端的 React 运行时中,也就:
- 没有状态(useState) — 服务端无法维持组件状态
- 没有副作用(useEffect) — 没有 mount/update 生命周期
- 没有事件处理 — 渲染结果是静态 HTML,无法绑定事件
如果需要这些能力,必须将组件标记为 'use client'。更多 Hooks 限制详见 Hooks 原理。
Q4: RSC Payload 是什么?描述其工作流程
答案:
RSC Payload 是 React 服务端发送给客户端的序列化组件树描述,它包含:
- Server Component 的渲染结果 — 已经计算好的虚拟 DOM 节点
- Client Component 的引用 — 占位符 + 指向客户端 Bundle 中的模块
- 序列化的 Props — 从 SC 传给 CC 的数据
流程:服务端渲染 SC → 生成 RSC Payload → 流式传输 → 客户端解析 Payload → 渲染静态内容 + Hydrate CC。
Q5: Server Component 和 Client Component 之间如何传递数据?
答案:
- SC → CC(通过 props) — props 必须是可序列化的(string、number、boolean、数组、纯对象、Date(自动转换)、null 等)。不能传函数、class 实例、Symbol 等。
// SC
async function Page() {
const data = await fetchData();
return <ClientChart data={data} />; // ✅ data 是可序列化的
}
-
CC → SC(通过 Server Action) — CC 调用 Server Action,Action 内通过
revalidatePath触发 SC 重新渲染。 -
Composition Pattern — SC 作为
children传入 CC,利用 React 的组合能力。
Q6: Server Actions 是什么?它解决了什么问题?
答案:
Server Actions 是在服务端执行的异步函数,通过 'use server' 标记。解决的问题:
- 消除 API Route 样板代码 — 无需单独创建
/api/xxx路由 - 端到端类型安全 — 参数和返回值类型在客户端和服务端共享
- 渐进式增强 —
<form action={serverAction}>在 JS 未加载时也能工作 - 与 React 生态深度集成 — 配合
useActionState、useOptimistic等 Hook
'use server';
export async function deletePost(id: string) {
await checkAuth(); // 权限验证
await db.post.delete({ where: { id } }); // 数据库操作
revalidatePath('/posts'); // 刷新缓存
}
Q7: RSC 如何实现零 Bundle Size?
答案:
Server Component 的代码只在服务端执行,执行结果序列化为 RSC Payload 发送给客户端。客户端收到的是渲染好的内容而非组件代码,因此:
- SC 文件本身不打包到客户端 JS
- SC 中
import的第三方库(如marked、highlight.js、ORM)也不打包 - 只有 CC 的代码才出现在客户端 Bundle 中
这意味着在 SC 中可以自由使用大型服务端库而不影响前端性能。
Q8: 什么时候应该使用 Server Component?什么时候用 Client Component?
答案:
使用 Server Component:
- 数据获取和展示(文章页、列表、详情)
- 使用大型依赖(Markdown 渲染、语法高亮)
- 访问后端资源(数据库、文件系统)
- 静态/不需要交互的 UI
使用 Client Component:
- 需要
useState、useEffect、useRef等 Hooks - 需要事件监听(onClick、onChange)
- 需要浏览器 API(window、localStorage、IntersectionObserver)
- 需要实时更新(WebSocket、定时器)
原则:默认用 SC,只在需要交互时才加 'use client',且将 'use client' 下推到最小组件。
Q9: 如何在 Client Component 中使用 Server Component?
答案:
不能在 CC 中直接 import SC(会被当作 CC 处理)。正确方式是 Composition Pattern:
// ✅ SC 作为 children 传入 CC
// ServerParent.tsx(Server Component)
export function ServerParent() {
return (
<ClientLayout>
<ServerChild /> {/* SC 通过 children 传入 */}
</ClientLayout>
);
}
// ClientLayout.tsx
'use client';
export function ClientLayout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return <div>{open && children}</div>;
}
children 在 CC 中是一个不透明的 React 节点,CC 不知道(也不关心)它是 SC 还是 CC 的产物。
Q10: RSC 中的数据获取与 useEffect 获取有什么区别?
答案:
| 维度 | SC async/await | useEffect 获取 |
|---|---|---|
| 执行位置 | 服务端 | 客户端 |
| 请求瀑布 | 可并行,服务端更快 | 组件挂载后才发起 |
| 加载状态 | Suspense fallback | 手动管理 loading state |
| SEO | 内容在 HTML 中 | 空壳 HTML,爬虫看不到 |
| 安全性 | 密钥在服务端,不暴露 | API Key 可能泄露 |
| 用户体验 | 无闪烁,内容直出 | loading → content 闪烁 |
// ❌ useEffect 模式(瀑布 + 闪烁)
'use client';
function Posts() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/posts').then(r => r.json()).then(setPosts).finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;
return <PostList posts={posts} />;
}
// ✅ RSC 模式(直出)
async function Posts() {
const posts = await db.post.findMany();
return <PostList posts={posts} />;
}
Q11: 解释 Streaming SSR 与 Suspense 在 RSC 中的配合
答案:
Streaming SSR 让服务端边渲染边发送 HTML,而不是等全部渲染完。Suspense 充当"流式边界":
- 服务端遇到
<Suspense>时,先发送fallback的 HTML - 被 Suspense 包裹的异步组件在后台继续渲染
- 渲染完毕后,服务端追加一段
<script>替换 fallback
<Suspense fallback={<Skeleton />}>
<SlowComponent /> {/* 2 秒后才准备好 */}
</Suspense>
用户看到:骨架屏(即时)→ 真实内容(2 秒后原地替换),页面不会整体白屏。
更多渲染流程细节参考 React 渲染流程 和 Fiber 架构。
Q12: Server Actions 的安全性问题有哪些?如何防范?
答案:
Server Actions 本质是公开的 HTTP 端点,任何人都能调用,因此必须:
'use server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().max(10000),
});
export async function createPost(formData: FormData) {
// 1. 身份验证
const session = await auth();
if (!session) throw new Error('Unauthorized');
// 2. 输入验证
const parsed = schema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!parsed.success) throw new Error('Invalid input');
// 3. 权限检查
if (session.user.role !== 'admin') throw new Error('Forbidden');
// 4. 安全地操作数据
await db.post.create({ data: parsed.data });
revalidatePath('/posts');
}
关键安全措施:输入验证(zod)、身份认证、权限授权、速率限制、CSRF 防护(框架自动处理)。
Q13: RSC 的序列化限制有哪些?如何处理?
答案:
SC 传给 CC 的 props 必须可序列化(通过 RSC Payload 传输),不支持:
| 不可传 | 替代方案 |
|---|---|
| 函数/回调 | 用 Server Action 替代,或在 CC 内定义 |
| class 实例 | 转为纯对象(JSON.parse(JSON.stringify()) 或手动映射) |
| Map / Set | 转为数组/对象 |
| Symbol | 不传递,在 CC 内创建 |
| DOM 节点 | 不传递 |
| 循环引用对象 | 打平结构 |
// ❌ 不能传函数
<ClientComponent onClick={() => console.log('click')} />
// ✅ 在 CC 内部定义交互逻辑
// ClientComponent.tsx
'use client';
export function ClientComponent({ itemId }: { itemId: string }) {
const handleClick = () => console.log('click', itemId);
return <button onClick={handleClick}>Click</button>;
}
Q14: Partial Prerendering(PPR)是什么?
答案:
PPR 是 Next.js 引入的渲染策略,同一个页面中同时包含静态和动态部分:
- 静态壳(Static Shell) — 构建时预渲染,从 CDN 毫秒级返回
- 动态孔洞(Dynamic Holes) — 用
<Suspense>包裹,请求时流式填充
优势:结合了 SSG 的速度(静态部分)和 SSR 的灵活性(动态部分),TTFB 极低同时支持个性化内容。
这相当于自动把一个页面拆成"可缓存"和"不可缓存"两层,无需开发者手动管理。
Q15: RSC 与 Islands Architecture(Astro)有什么异同?
答案:
| 维度 | RSC | Islands (Astro) |
|---|---|---|
| 核心思想 | 组件分 Server/Client 两类 | 静态 HTML + 可交互"岛屿" |
| 默认行为 | 默认 Server Component | 默认零 JS |
| Hydration | 选择性(仅 CC) | 选择性(仅 Islands) |
| 框架 | React 专属 | 框架无关(React/Vue/Svelte) |
| 路由导航 | 客户端导航 + RSC Payload | 传统 MPA 或可选 SPA |
| 组件交互 | CC 和 SC 在同一棵树 | Islands 彼此独立 |
| 数据获取 | 组件级 async | 页面级 frontmatter |
| 适用场景 | 全栈 React 应用 | 内容型网站 |
相同点:都追求"只发送必要的 JS 到客户端",都是选择性 Hydration。 不同点:RSC 深度集成 React 生态,支持客户端导航和状态保持;Islands 更简单轻量,适合内容驱动的网站。