跳到主要内容

React Server Components 深入

问题

React Server Components(RSC)是什么?它的工作原理、使用场景和最佳实践是什么?

答案

一、RSC 基本概念

React Server Components 是 React 18 引入、React 19 正式稳定的组件级别服务端渲染方案。与传统 SSR 不同,RSC 允许组件只在服务端运行,其代码不会被打包到客户端 JS 中。

设计动机

  1. 减少 Bundle Size — Server Component 代码零客户端打包
  2. 直接访问后端资源 — 数据库、文件系统、内部 API 无需额外接口
  3. 流式渲染 — 配合 Suspense 实现渐进式页面加载
  4. 更好的数据获取 — 组件内直接 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 描述,包含:

  1. Server Component 的渲染结果 — 已转化为 HTML/虚拟 DOM 节点
  2. Client Component 的占位符 — 引用客户端 JS Bundle 中的组件
  3. Props 数据 — 从 Server 传递给 Client 的可序列化数据
RSC Payload 简化示意
// Server Component 渲染结果(已计算完毕,不含 JS 逻辑)
J0: ["$", "div", null, {
children: [
["$", "h1", null, { children: "Dashboard" }], // SC 渲染结果
["$", "@1", null, { data: [1, 2, 3] }] // CC 占位符引用
]
}]
// @1 → 引用客户端 Bundle 中的 <Chart /> 组件

与传统 SSR 的核心区别

维度传统 SSRRSC
渲染粒度页面级别组件级别
Hydration整个页面都需要 Hydrate只 Hydrate Client Components
客户端 JS全部组件代码打包Server Component 代码不打包
数据获取getServerSideProps 等页面级 API组件内直接 async/await
导航更新整页刷新或客户端路由局部更新 RSC Payload
状态保持导航时丢失客户端状态客户端状态在 SC 刷新时保持

三、Server Component vs Client Component

能力对比

能力Server ComponentClient Component
async/await 获取数据
访问数据库/文件系统
useState / useEffect
事件监听 onClick 等
浏览器 API(window 等)
使用 Context
代码打包到客户端
重新渲染(re-render)通过 refetch通过 setState

Server Component 数据获取示例

app/dashboard/page.tsx
// 这是 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>
);
}
对比传统方式

不再需要 useEffectuseState → loading/error 状态管理的模板代码。数据在服务端获取完毕后直接渲染,客户端看到的就是完整内容。详见 Hooks 原理 中关于 useEffect 数据获取的讨论。

四、组件组合模式

RSC 最重要的设计原则:Server Component 可以导入 Client Component,但反过来不行

Composition Pattern(推荐)

将 Server Component 作为 children 传递给 Client Component:

正确 ✅ — SC 作为 children 传给 CC
// 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>
);
}
错误 ❌ — CC 中导入 SC
'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

app/posts/page.tsx
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 自动去重:

lib/data.ts
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 流式渲染

app/dashboard/page.tsx
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。

基础用法

app/actions.ts
'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');
}
app/todos/page.tsx
import { createTodo } from '../actions';

export default function TodoPage() {
return (
<form action={createTodo}>
<input name="title" />
<SubmitButton />
</form>
);
}

useActionState + useFormStatus

components/SubmitButton.tsx
'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 乐观更新

components/TodoList.tsx
'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 端点!必须:

  1. 验证输入 — 使用 zod 等 schema 验证
  2. 检查权限 — 验证用户身份和授权
  3. 防止 CSRF — Next.js 自动处理,但自定义实现需注意
  4. 不信任客户端数据 — 所有输入都当作不可信

七、性能优化

1. 零 Bundle Size

Server Component 的代码(包括其依赖的库)不会出现在客户端 JS Bundle 中

示例:SC 中使用大型库
// 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 的优势:

PPR 概念
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传统 SSRSSGIslands (Astro)
渲染时机请求时(组件级)请求时(页面级)构建时构建时 + 请求时
Hydration选择性(仅 CC)全量全量选择性(Islands)
JS Bundle仅 CC 代码全部组件代码全部组件代码仅 Island 代码
框架绑定React 生态通用通用框架无关
数据获取组件级 async页面级 API构建时组件级
状态保持SC 更新时保持 CC 状态导航时丢失导航时丢失Island 内保持
学习曲线中高

十、常见陷阱与最佳实践

常见陷阱
  1. 'use client' 边界过宽 — 整个页面标记为 CC,失去 RSC 优势
  2. 在 SC 中使用 Hooks — useState、useEffect 等只能在 CC 中使用
  3. Props 不可序列化 — 函数、Date 对象、class 实例不能从 SC 传给 CC
  4. 数据瀑布 — 嵌套 SC 串行获取数据,应并行化
  5. 混淆指令'use server' 不是标记 Server Component,是标记 Server Action

最佳实践

✅ 将 'use client' 下推到最小范围
// ❌ 整个页面标记为 '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 有什么区别?

答案

区别传统 SSRRSC
粒度页面级别,整页在服务端渲染组件级别,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 运行时中,也就:

  1. 没有状态(useState) — 服务端无法维持组件状态
  2. 没有副作用(useEffect) — 没有 mount/update 生命周期
  3. 没有事件处理 — 渲染结果是静态 HTML,无法绑定事件

如果需要这些能力,必须将组件标记为 'use client'。更多 Hooks 限制详见 Hooks 原理

Q4: RSC Payload 是什么?描述其工作流程

答案

RSC Payload 是 React 服务端发送给客户端的序列化组件树描述,它包含:

  1. Server Component 的渲染结果 — 已经计算好的虚拟 DOM 节点
  2. Client Component 的引用 — 占位符 + 指向客户端 Bundle 中的模块
  3. 序列化的 Props — 从 SC 传给 CC 的数据

流程:服务端渲染 SC → 生成 RSC Payload → 流式传输 → 客户端解析 Payload → 渲染静态内容 + Hydrate CC。

Q5: Server Component 和 Client Component 之间如何传递数据?

答案

  1. SC → CC(通过 props) — props 必须是可序列化的(string、number、boolean、数组、纯对象、Date(自动转换)、null 等)。不能传函数、class 实例、Symbol 等。
// SC
async function Page() {
const data = await fetchData();
return <ClientChart data={data} />; // ✅ data 是可序列化的
}
  1. CC → SC(通过 Server Action) — CC 调用 Server Action,Action 内通过 revalidatePath 触发 SC 重新渲染。

  2. Composition Pattern — SC 作为 children 传入 CC,利用 React 的组合能力。

Q6: Server Actions 是什么?它解决了什么问题?

答案

Server Actions 是在服务端执行的异步函数,通过 'use server' 标记。解决的问题:

  1. 消除 API Route 样板代码 — 无需单独创建 /api/xxx 路由
  2. 端到端类型安全 — 参数和返回值类型在客户端和服务端共享
  3. 渐进式增强<form action={serverAction}> 在 JS 未加载时也能工作
  4. 与 React 生态深度集成 — 配合 useActionStateuseOptimistic 等 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 的第三方库(如 markedhighlight.js、ORM)也不打包
  • 只有 CC 的代码才出现在客户端 Bundle 中

这意味着在 SC 中可以自由使用大型服务端库而不影响前端性能。

Q8: 什么时候应该使用 Server Component?什么时候用 Client Component?

答案

使用 Server Component

  • 数据获取和展示(文章页、列表、详情)
  • 使用大型依赖(Markdown 渲染、语法高亮)
  • 访问后端资源(数据库、文件系统)
  • 静态/不需要交互的 UI

使用 Client Component

  • 需要 useStateuseEffectuseRef 等 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/awaituseEffect 获取
执行位置服务端客户端
请求瀑布可并行,服务端更快组件挂载后才发起
加载状态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 充当"流式边界":

  1. 服务端遇到 <Suspense> 时,先发送 fallback 的 HTML
  2. 被 Suspense 包裹的异步组件在后台继续渲染
  3. 渲染完毕后,服务端追加一段 <script> 替换 fallback
<Suspense fallback={<Skeleton />}>
<SlowComponent /> {/* 2 秒后才准备好 */}
</Suspense>

用户看到:骨架屏(即时)→ 真实内容(2 秒后原地替换),页面不会整体白屏。

更多渲染流程细节参考 React 渲染流程Fiber 架构

Q12: Server Actions 的安全性问题有哪些?如何防范?

答案

Server Actions 本质是公开的 HTTP 端点,任何人都能调用,因此必须:

安全的 Server Action
'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)有什么异同?

答案

维度RSCIslands (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 更简单轻量,适合内容驱动的网站。


相关链接