跳到主要内容
预计阅读 51 分钟

React篇 | 服务器组件

前言

如果你最近关注 React 生态,一定听说过 Server Components(RSC)。这可能是 React 自 Hooks 以来最大的范式转变。

但很多开发者对 RSC 的理解停留在”在服务端渲染的组件”这个层面,和传统的 SSR 混为一谈。面试中一追问就暴露了:

  • Server Components 和 SSR 到底有什么区别?不都是在服务端运行吗?
  • “use client” 和 “use server” 这两个指令到底是什么意思?
  • Server Components 为什么不能用 useState、useEffect?
  • RSC Payload 是什么?和 HTML 有什么区别?
  • Server Actions 是什么?为什么需要它?
  • 什么组件应该是 Server Component,什么应该是 Client Component?

本章就来把 React Server Components 的核心概念、工作原理、和实际使用策略讲清楚。


诊断自测

Q1:下面哪些组件可以是 Server Component?哪些必须是 Client Component?

// A:显示用户名
function UserName({ name }) {
  return <h1>{name}</h1>;
}

// B:带点击计数的按钮
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c + 1)}>{count}</button>;
}

// C:从数据库读取数据并显示
async function UserList() {
  const users = await db.query('SELECT * FROM users');
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
点击查看答案
  • A:可以是 Server Component。纯展示组件,没有交互,没有 state 和 effect
  • B:必须是 Client Component。使用了 useState 和事件处理器(onClick),需要在浏览器中运行
  • C:可以(且应该)是 Server Component。直接访问数据库是 Server Component 的典型场景——这在客户端是不可能做到的

Q2:“use client” 是什么意思?它标记的是单个组件还是一个边界?

点击查看答案

"use client" 是一个边界标记(boundary),放在文件顶部。它的意思不是”这个文件里的组件在客户端运行”,而是**“从这个文件开始,以及这个文件 import 的所有模块,都属于客户端模块图”**。

它标记的是一个客户端边界的入口。在这个边界之上的组件可以是 Server Component,之下的都是 Client Component。

Q3:Server Components 和 SSR(服务端渲染)有什么区别?

点击查看答案

三个关键区别:

  1. SSR 输出 HTML,客户端需要注水(hydrate)才能变成可交互的。RSC 输出的是RSC Payload(一种序列化的组件树描述),客户端用它来构建/更新 React 树,不需要 hydrate Server Component 部分
  2. SSR 的组件代码仍然被发送到客户端(因为需要 hydrate),RSC 的代码不会被发送到客户端——这意味着 Server Component 的代码不计入客户端 bundle 大小
  3. SSR 只在首次请求时运行(生成初始 HTML),RSC 可以在后续导航中重新运行(通过 RSC Payload 实现增量更新)

简单说:SSR 是”在服务端生成 HTML”,RSC 是”在服务端运行组件逻辑”。两者可以结合使用。


一、Server Components vs Client Components

1.1 两种组件的本质区别

在 RSC 架构下,React 组件被分为两种:

特性Server ComponentClient Component
运行环境仅在服务端在客户端(也可能在服务端 SSR)
能否用 state / effect不能
能否用事件处理器不能
能否直接访问服务端资源能(数据库、文件系统)不能
代码是否发送到客户端不发送发送
默认身份默认就是需要 “use client” 标记

1.2 默认行为

在 RSC 架构中,组件默认是 Server Component。只有当你在文件顶部加了 "use client" 时,该文件中的组件才会变成 Client Component。

// 这是一个 Server Component(默认)
async function ProductPage({ id }) {
  const product = await db.products.findById(id);
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={id} /> {/* Client Component */}
    </div>
  );
}
// AddToCartButton.jsx
"use client"; // ← 标记为 Client Component

import { useState } from 'react';

export function AddToCartButton({ productId }) {
  const [added, setAdded] = useState(false);

  return (
    <button onClick={() => setAdded(true)}>
      {added ? 'Added!' : 'Add to Cart'}
    </button>
  );
}

1.3 Server Component 的能力

Server Component 可以做到很多 Client Component 做不到的事情:

// ✅ 直接访问数据库
async function UserProfile({ userId }) {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  return <div>{user.name}</div>;
}

// ✅ 读取文件系统
import { readFile } from 'fs/promises';

async function MarkdownPage({ slug }) {
  const content = await readFile(`./content/${slug}.md`, 'utf-8');
  return <article dangerouslySetInnerHTML={{ __html: marked(content) }} />;
}

// ✅ 使用仅服务端的 npm 包(不会增加客户端 bundle)
import { highlight } from 'shiki'; // 语法高亮库,几 MB 大

async function CodeBlock({ code, lang }) {
  const html = await highlight(code, { lang });
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

1.4 Server Component 的限制

// ❌ 不能用 useState
function ServerComp() {
  const [count, setCount] = useState(0); // 报错
}

// ❌ 不能用 useEffect
function ServerComp() {
  useEffect(() => { /* ... */ }, []); // 报错
}

// ❌ 不能用事件处理器
function ServerComp() {
  return <button onClick={() => {}}> // 报错
    Click
  </button>;
}

// ❌ 不能用浏览器 API
function ServerComp() {
  const width = window.innerWidth; // 报错,服务端没有 window
}

为什么有这些限制? 因为 Server Component 只在服务端运行一次(渲染时),不会在客户端再运行。useState 需要在客户端维护状态、useEffect 需要在 DOM 挂载后执行、事件处理器需要在用户交互时触发——这些都是客户端的事情,Server Component 做不到。


二、“use client” 和 “use server” 指令

2.1 “use client”:客户端边界

"use client" 不是一个”装饰器”,而是一个模块级别的边界声明

"use client";

// 从这行开始,这个文件里的所有导出都是 Client Component
// 这个文件 import 的所有模块也被视为客户端模块

关键理解:"use client" 标记的是边界的入口,不是单个组件。

Server Component Tree

       ├── ServerLayout
       │     ├── ServerHeader
       │     └── "use client" boundary ─── ClientNav (+ 它 import 的所有模块)

       └── ServerContent
             └── "use client" boundary ─── ClientForm (+ 它 import 的所有模块)

2.2 Server Component 可以渲染 Client Component

这是一个容易混淆的点:Server Component 可以 将 Client Component 作为子组件渲染:

// ServerPage.jsx (Server Component)
import { ClientCounter } from './ClientCounter';

async function ServerPage() {
  const data = await fetchData();
  return (
    <div>
      <h1>{data.title}</h1>
      {/* Server Component 渲染 Client Component */}
      <ClientCounter initialCount={data.count} />
    </div>
  );
}

但 Client Component 不能 import Server Component:

// ClientWidget.jsx
"use client";

// ❌ 不能这样做
import { ServerOnlyComponent } from './ServerOnlyComponent';

不过 Client Component 可以通过 children 接收 Server Component:

// ClientLayout.jsx
"use client";

export function ClientLayout({ children }) {
  const [sidebarOpen, setSidebarOpen] = useState(true);
  return (
    <div>
      <button onClick={() => setSidebarOpen(!sidebarOpen)}>Toggle</button>
      {sidebarOpen && <aside>Sidebar</aside>}
      <main>{children}</main> {/* children 可以是 Server Component */}
    </div>
  );
}

// ServerPage.jsx (Server Component)
import { ClientLayout } from './ClientLayout';

async function ServerPage() {
  const content = await fetchContent();
  return (
    <ClientLayout>
      {/* 这里是 Server Component,通过 children 传入 */}
      <ServerContent data={content} />
    </ClientLayout>
  );
}

2.3 “use server”:Server Actions

"use server""use client" 不是对称的。"use client" 标记客户端模块边界,"use server" 标记的是 Server Action——可以从客户端调用的服务端函数。

// actions.js
"use server";

export async function addToCart(productId) {
  // 这个函数在服务端执行
  const cart = await db.carts.findFirst({ where: { userId: currentUser.id } });
  await db.cartItems.create({
    data: { cartId: cart.id, productId },
  });
}
// ClientButton.jsx
"use client";

import { addToCart } from './actions';

export function AddToCartButton({ productId }) {
  return (
    <button onClick={() => addToCart(productId)}>
      Add to Cart
    </button>
  );
}

当客户端调用 addToCart 时,React 会自动发送一个 HTTP 请求到服务端,执行这个函数,然后返回结果。你不需要手动创建 API 路由。


三、RSC 的数据流

3.1 首次加载

用户首次访问页面时的流程:

1. 浏览器请求页面
2. 服务端运行 Server Components,生成 RSC Payload
3. 服务端同时做 SSR,生成初始 HTML(用于快速显示)
4. 浏览器收到 HTML,立即显示(静态内容)
5. 浏览器下载 Client Component 的 JavaScript bundle
6. React 在客户端 hydrate Client Components(让它们变得可交互)
7. Server Components 不需要 hydrate(它们不在客户端运行)

3.2 RSC Payload 是什么?

RSC Payload 是 Server Component 渲染结果的序列化表示。它不是 HTML,而是一种特殊的格式,描述了组件树的结构:

// 简化的 RSC Payload 示例(实际格式是流式的)
0: ["$", "div", null, {
  "children": [
    ["$", "h1", null, { "children": "Product Name" }],
    ["$", "$Lazy", null, { /* Client Component 引用 */ }]
  ]
}]

RSC Payload 包含:

  • Server Component 的渲染结果(类似虚拟 DOM 的序列化)
  • Client Component 的引用(指向 JS bundle 中的模块)
  • 传给 Client Component 的 props(必须是可序列化的)

3.3 后续导航

用户在应用内导航时(如点击链接),RSC 的行为和首次加载不同:

1. 客户端向服务端请求新路由的 RSC Payload
2. 服务端运行新路由的 Server Components,生成 RSC Payload
3. 客户端接收 RSC Payload,更新 React 树
4. 不需要完整的页面 HTML
5. Client Components 的状态可以保留(如果它们在新旧路由中都存在)

这就是 RSC 和传统 SSR 的关键区别之一:SSR 在后续导航中退化为客户端渲染(或需要完整的页面重载),而 RSC 可以在后续导航中继续利用服务端能力。

3.4 props 必须可序列化

Server Component 传给 Client Component 的 props 必须是可序列化的——因为这些 props 需要通过 RSC Payload 从服务端传输到客户端。

// ✅ 可序列化的 props
<ClientComp
  name="Alice"
  count={42}
  items={['a', 'b', 'c']}
  config={{ theme: 'dark' }}
/>

// ❌ 不可序列化的 props
<ClientComp
  onClick={() => console.log('click')} // 函数不可序列化
  ref={someRef}                         // ref 不可序列化
  element={<ServerOnlyComp />}          // Server Component 实例不可直接传
/>

但有一个例外:Server Actions 可以作为 props 传递。React 会自动把它们序列化为一个引用(endpoint),客户端调用时会发送请求到服务端。

// ✅ Server Action 可以传给 Client Component
async function ServerForm() {
  async function submitForm(formData) {
    "use server";
    await db.forms.create({ data: Object.fromEntries(formData) });
  }

  return <ClientForm onSubmit={submitForm} />;
}

四、Server Actions

4.1 什么是 Server Actions?

Server Actions 是可以从客户端直接调用的服务端函数。它们解决的核心问题是:让表单提交、数据变更等操作不需要手动创建 API 路由。

// 传统方式:需要创建 API 路由
// /api/submit.js
export async function POST(req) {
  const data = await req.json();
  await db.save(data);
  return Response.json({ success: true });
}

// 客户端
const handleSubmit = async (data) => {
  await fetch('/api/submit', {
    method: 'POST',
    body: JSON.stringify(data),
  });
};
// Server Actions 方式:直接定义函数
async function submitAction(formData) {
  "use server";
  const name = formData.get('name');
  await db.users.create({ data: { name } });
}

function SignupForm() {
  return (
    <form action={submitAction}>
      <input name="name" />
      <button type="submit">Sign Up</button>
    </form>
  );
}

4.2 Server Actions 的工作原理

当客户端调用一个 Server Action 时,React 会:

  1. 序列化函数参数
  2. 发送 HTTP POST 请求到服务端
  3. 服务端执行函数
  4. 返回结果(包括可能的 UI 更新)
  5. 客户端更新 React 树

整个过程对开发者是透明的——你只需要写一个 async 函数,React 处理所有网络通信。

4.3 配合 useActionState

React 提供了 useActionState(之前叫 useFormState)来管理 Server Action 的状态:

"use client";

import { useActionState } from 'react';
import { createUser } from './actions';

function SignupForm() {
  const [state, formAction, isPending] = useActionState(createUser, {
    error: null,
    success: false,
  });

  return (
    <form action={formAction}>
      <input name="name" disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Submitting...' : 'Sign Up'}
      </button>
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p>Account created!</p>}
    </form>
  );
}

五、RSC 与 SSR 的区别

这是面试中必考的对比题,很多人会混淆。

5.1 关键区别

维度SSRRSC
输出HTML 字符串RSC Payload(序列化组件树)
客户端 JS所有组件的 JS 都要发送只发送 Client Component 的 JS
Hydration整个页面需要 hydrate只有 Client Components 需要 hydrate
后续导航退化为 CSR 或完整页面刷新可以请求新的 RSC Payload
运行时机只在首次请求时运行每次渲染都可以在服务端运行
状态交互Hydrate 后可以有状态Server Component 本身没有状态

5.2 它们不是互斥的

RSC 和 SSR 不是二选一,它们通常一起使用

首次请求流程(RSC + SSR):

1. 服务端运行 Server Components → 生成 RSC Payload
2. 用 RSC Payload + Client Component 代码做 SSR → 生成 HTML
3. HTML 发送到浏览器 → 用户立即看到内容
4. Client Component JS 加载并 hydrate → 页面变得可交互
  • RSC 负责”哪些逻辑在服务端运行”
  • SSR 负责”首次加载时生成 HTML 以加快首屏显示”

5.3 一个直观的类比

想象一个电商产品页面:

  • 纯 CSR:浏览器下载空 HTML → 下载 JS → 执行 JS → 请求数据 → 渲染页面。用户看到白屏好几秒
  • SSR:服务端渲染完整 HTML → 浏览器显示 → 下载 JS → hydrate → 可交互。首屏快但 JS bundle 大
  • RSC + SSR:Server Component 直接查数据库渲染产品信息(代码不发到客户端)→ Client Component 只负责”加入购物车”按钮(少量 JS)→ 首屏快且 JS 更小

常见误区

误区一:“Server Components 就是 SSR 的新叫法”

完全不是。SSR 是把组件渲染成 HTML 字符串的过程,所有组件代码仍然要发送到客户端做 hydration。RSC 是一种新的组件类型,它的代码永远不会出现在客户端,运行结果以 RSC Payload 的形式传输。两者解决的问题不同,且通常一起使用。

误区二:“‘use client’ 意味着组件不会在服务端运行”

不对。标记了 "use client" 的组件仍然可能在服务端做 SSR(生成初始 HTML)。"use client" 的意思是”这个组件需要在客户端 hydrate 并运行”,而不是”这个组件只在客户端运行”。在首次请求时,Client Component 可能同时在服务端(SSR)和客户端(hydration)运行。

误区三:“所有组件都应该尽量是 Server Component”

虽然 Server Component 有很多优势,但不是所有组件都适合。需要交互(state、事件、动画)、需要浏览器 API(localStorage、window)、需要实时更新的组件,都应该是 Client Component。正确的策略是:让交互边界尽量靠近叶子节点——把需要交互的最小部分标记为 “use client”,其他部分保持为 Server Component。

误区四:“Server Actions 就是 API 路由”

虽然底层都是 HTTP 请求,但 Server Actions 和手写 API 路由有本质区别:Server Actions 与 React 的渲染系统深度集成——调用 Server Action 后,React 可以自动重新渲染受影响的 Server Components,更新 UI,无需手动管理状态同步。API 路由是通用的 HTTP 端点,需要你自己处理所有这些。


小结

本章我们系统讲解了 React Server Components 的核心概念和工作原理。

核心要点

  1. Server Component 默认:组件默认是 Server Component,需要交互时用 “use client” 标记
  2. “use client” 是边界:标记的是客户端模块图的入口,不是单个组件
  3. RSC Payload:Server Component 的渲染结果,不是 HTML,是序列化的组件树
  4. Server Actions(“use server”):可从客户端调用的服务端函数,替代手写 API 路由
  5. RSC 不等于 SSR:RSC 是组件类型和运行位置的划分,SSR 是首屏 HTML 生成的技术
  6. 两者协作:RSC 决定”什么在服务端运行”,SSR 提供”首屏快速展示”
  7. props 可序列化:Server → Client 的 props 必须可以 JSON 序列化(Server Actions 除外)

本章思维导图

React:服务器组件(RSC)
  • Server Component vs Client Component
    • 运行环境:服务端 vs 客户端
    • 能力差异:数据库/文件系统 vs state/effect/事件
    • 代码分发:不发送 vs 发送到客户端
    • 默认身份:默认 Server / "use client" 标记 Client
  • "use client" 指令
    • 模块级边界声明
    • 标记客户端模块图的入口
    • Server 可以渲染 Client(作为子组件)
    • Client 不能 import Server(但可以通过 children)
  • "use server" 指令
    • 标记 Server Action
    • 可从客户端调用的服务端函数
    • React 自动处理网络通信
  • RSC 数据流
    • 首次加载:RSC Payload + SSR HTML + Hydration
    • RSC Payload:序列化的组件树描述
    • 后续导航:请求新的 RSC Payload,增量更新
    • props 必须可序列化
  • Server Actions
    • 替代手写 API 路由
    • 与 form action 集成
    • useActionState 管理状态
  • RSC vs SSR
    • 输出不同:RSC Payload vs HTML
    • 代码分发不同:不发送 vs 需要发送
    • 不互斥,通常一起使用

练习挑战

第一题 ⭐(基础):判断组件类型

对于以下场景,判断应该使用 Server Component 还是 Client Component,并说明原因:

  1. 博客文章页面(从数据库读取 Markdown 内容并渲染)
  2. 点赞按钮(用户点击后计数 +1,需要动画效果)
  3. 导航栏(显示固定的链接列表)
  4. 搜索框(用户输入时实时过滤结果)
点击查看答案与解析
  1. Server Component。直接在服务端读取数据库、解析 Markdown,代码(如 Markdown 解析库)不需要发送到客户端
  2. Client Component。需要 useState 管理计数、onClick 处理点击、动画需要在浏览器中运行
  3. Server Component。纯展示,没有交互逻辑。如果导航栏有”当前高亮”效果需要读取路由状态,可能需要 Client Component
  4. Client Component。需要 useState 管理输入值、onChange 处理输入事件、实时过滤需要在客户端执行

思考题:对于第 2 个场景,如果点赞数需要持久化到数据库,应该怎么做?答案是组合使用——点赞按钮是 Client Component,但可以调用 Server Action 来更新数据库。

第二题 ⭐⭐(进阶):重构组件结构

下面的组件全都是 Client Component。请重构它,把能放到服务端的部分提取为 Server Component,只保留必要的 “use client” 部分。

"use client";

import { useState } from 'react';

export function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);
  const [inCart, setInCart] = useState(false);

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then(r => r.json())
      .then(setProduct);
  }, [productId]);

  if (!product) return <Loading />;

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
      <img src={product.image} alt={product.name} />
      <button onClick={() => setInCart(true)}>
        {inCart ? 'In Cart' : 'Add to Cart'}
      </button>
    </div>
  );
}
点击查看答案与解析
// ProductPage.jsx (Server Component - 不需要 "use client")
import { AddToCartButton } from './AddToCartButton';

async function ProductPage({ productId }) {
  // 直接在服务端获取数据,不需要 useEffect + fetch
  const product = await db.products.findById(productId);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
      <img src={product.image} alt={product.name} />
      <AddToCartButton productId={productId} />
    </div>
  );
}
// AddToCartButton.jsx (Client Component)
"use client";

import { useState } from 'react';

export function AddToCartButton({ productId }) {
  const [inCart, setInCart] = useState(false);

  return (
    <button onClick={() => setInCart(true)}>
      {inCart ? 'In Cart' : 'Add to Cart'}
    </button>
  );
}

重构要点:

  1. 数据获取移到 Server Component,直接 await 数据库查询,去掉 useEffect + fetch
  2. 交互逻辑(加购按钮)提取为独立的 Client Component
  3. 纯展示部分(标题、描述、价格、图片)保留在 Server Component
  4. 结果:客户端 JS 只包含一个按钮组件的代码,大幅减少 bundle 大小

第三题 ⭐⭐⭐(综合):实现一个带 Server Action 的表单

实现一个用户注册表单,要求:

  1. 表单本身是 Client Component(需要管理 loading 和错误状态)
  2. 提交逻辑是 Server Action(在服务端验证数据并写入数据库)
  3. 使用 useActionState 管理状态
  4. 处理验证错误
点击查看参考实现
// actions.js
"use server";

export async function registerUser(prevState, formData) {
  const name = formData.get('name');
  const email = formData.get('email');
  const password = formData.get('password');

  // 服务端验证
  if (!name || name.length < 2) {
    return { error: 'Name must be at least 2 characters', success: false };
  }
  if (!email || !email.includes('@')) {
    return { error: 'Invalid email address', success: false };
  }
  if (!password || password.length < 8) {
    return { error: 'Password must be at least 8 characters', success: false };
  }

  // 检查邮箱是否已存在
  const existing = await db.users.findFirst({ where: { email } });
  if (existing) {
    return { error: 'Email already registered', success: false };
  }

  // 创建用户
  await db.users.create({
    data: { name, email, password: await hash(password) },
  });

  return { error: null, success: true };
}
// RegisterForm.jsx
"use client";

import { useActionState } from 'react';
import { registerUser } from './actions';

export function RegisterForm() {
  const [state, formAction, isPending] = useActionState(registerUser, {
    error: null,
    success: false,
  });

  if (state.success) {
    return <p>Registration successful! Please log in.</p>;
  }

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" required disabled={isPending} />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required disabled={isPending} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" required disabled={isPending} />
      </div>
      {state.error && <p className="error">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Registering...' : 'Register'}
      </button>
    </form>
  );
}

核心要点:

  • Server Action 接收 (prevState, formData) 两个参数
  • 返回新的状态(包含错误信息或成功标志)
  • useActionState 自动管理 pending 状态和表单状态
  • 验证逻辑在服务端执行,客户端只负责 UI 展示

自我检测

读完本章后,对照下面的清单检验一下自己的掌握程度。

  • 能说出 Server Component 和 Client Component 的至少五个区别
  • 能解释 “use client” 是模块边界标记而不是组件标记
  • 能描述 RSC Payload 是什么,以及它与 HTML 的区别
  • 能说清楚 Server Component 传给 Client Component 的 props 为什么必须可序列化
  • 能解释 RSC 和 SSR 的区别,以及它们如何协作
  • 能描述 Server Actions 的工作原理(客户端调用 → HTTP 请求 → 服务端执行)
  • 在给定场景下能判断应该使用 Server Component 还是 Client Component
  • 能将一个全 Client Component 的页面重构为 Server + Client 组合

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90