跳到主内容

5

服务端组件与客户端组件

RSC 心智模型、use client 边界、流式渲染——React 2023+ 最大的架构变化

必学10 分钟

0. 一段必要的回顾:全客户端渲染的痛点

2013-2022 年,React 应用的标准架构是全客户端渲染:浏览器下载一个 JS bundle,React 在客户端执行所有组件逻辑,生成 DOM。服务端只负责返回一个空壳 HTML 和 JS 文件的链接。

这个模型有三个根本问题:

  1. 首屏慢:用户看到一个空白页 → 等 JS 下载 → 等 JS 执行 → React 渲染 → 才有内容。每一步都是延迟。
  2. 数据瀑布:组件挂载 → useEffect 发请求 → 等响应 → 再渲染子组件 → 子组件的 useEffect 再发请求 → ……请求一层的依赖关系形成瀑布,但服务端实际上可以一次性拿到所有数据。
  3. 无法访问后端资源:React 运行在浏览器里,不能直接读数据库、读文件系统、调用内部 API(不暴露在公网的服务)。这些操作需要一个额外的 API 层。

2023 年 Next.js 13.4 引入的 React Server Components(RSC) 从架构上解决了这三个问题——把渲染从"纯客户端"拆成服务端 + 客户端两层

为什么不直接从 "use client" 开始学? 因为如果不先理解"为什么服务端组件是默认",你会把 "use client" 当成"让组件工作的开关"——那就完全理解反了。RSC 的世界里,服务端组件是默认,客户端组件是例外


1. 两种组件:一张图理清

前置说明:RSC 是 React 的特性,但它需要一个支持 RSC 的框架(如 Next.js 13.4+)来提供"服务端"这个运行环境。纯 react + react-dom(没有框架)只有客户端组件。本章以 Next.js App Router 为默认 RSC 环境——这也是目前最广泛使用的 RSC 载体。

在使用 Next.js App Router 时,组件分为两种,它们的运行环境完全不同:

服务端组件(Server Component)客户端组件(Client Component)
运行位置服务端(构建时或请求时)浏览器
标记方式默认——不需要任何标记文件顶部加 "use client"
能做什么读数据库、读文件、访问内部 APIuseStateuseEffect、事件处理、浏览器 API
不能做什么Hooks(useState 等)、事件处理(onClick直接读数据库、直接读文件
JS 是否发给浏览器

核心规则"use client" 标记的不是"这个组件要在客户端渲染"——它标记的是"从这个文件开始,往下所有 import 的组件都在客户端渲染"

// ✅ 这是服务端组件——可以 await 数据库查询
async function ArticlePage({ id }) {
  const article = await db.articles.findById(id); // 直接在服务端读数据库
  return (
    <article>
      <h1>{article.title}</h1>
      <p>{article.body}</p>
      <LikeButton articleId={id} /> {/* 客户端组件:有交互 */}
    </article>
  );
}
 
// LikeButton.tsx 顶部有 "use client"
// 因为 onClick 只能在浏览器执行

关键心智转变:以前所有组件都在客户端渲染。现在默认走服务端——把数据获取、后端访问放在离数据最近的地方。但不是一个极端走向另一个——服务端不是"全放"、客户端不是"尽量少"。判断标准就一条:这个组件需要浏览器环境(交互/浏览器 API)吗? 需要→客户端,不需要→服务端。

小心矫枉过正:一个列表页渲染 200 个 <ListItem />,每个都是纯展示——放服务端也能跑,但服务端要为每个请求渲染 200 次。这种情况下放客户端(静态 HTML + CSS)反而更合理——服务端渲染不是"越多越好",是"该放哪放哪"。


2. "use client" 边界:实际怎么划

2.1 什么时候必须加 "use client"

以下场景的服务端组件中如果需要使用,子组件必须标记 "use client"

  • 事件处理onClickonChangeonSubmit
  • React HooksuseStateuseEffectuseReducer
  • 浏览器 APIwindowdocumentlocalStoragenavigator
  • 第三方客户端库:Framer Motion、Chart.js 等依赖浏览器行为的库

2.2 边界画在哪

错误做法:把 "use client" 放最外层——变成全客户端渲染,RSC 优势全丢。

// ❌ 整个页面都成客户端组件了
"use client"
export default function WholePage() { ... }

正确做法:把交互隔离到叶子组件

// ArticlePage——服务端组件(默认,无标记)
async function ArticlePage({ id }) {
  const article = await db.articles.findById(id);
  return (
    <div>
      <ArticleHeader article={article} /> {/* 服务端渲染——纯展示 */}
      <ArticleBody body={article.body} /> {/* 服务端渲染——纯展示 */}
      <CommentSection articleId={id} /> {/* 客户端——有输入框和提交 */}
    </div>
  );
}
 
// CommentSection.tsx
("use client");
function CommentSection({ articleId }) {
  const [comments, setComments] = useState([]);
  // ...交互逻辑
}

经验法则:先全部写成服务端组件(默认),遇到需要交互的组件时才加 "use client"——不是反过来。推交互到边缘,让大部分组件保持在服务端。

2.3 服务端组件可以 import 客户端组件,反之不行

  • ServerComp → import → ClientComp ✅ 服务端引入客户端,正常
  • ClientComp → import → ServerComp ❌ 不行——服务端组件被导入到客户端后,变成客户端组件,失去服务端能力

如果客户端组件需要"包住"服务端组件,通过 children prop 传:

// ✅ ClientWrapper 用 children 接收服务端内容
"use client";
function ClientWrapper({ children }) {
  const [open, setOpen] = useState(false);
  return <div onClick={() => setOpen(!open)}>{children}</div>;
}
 
// ✅ 服务端组件作为 children 传入
<ClientWrapper>
  <ServerContent /> {/* 这是服务端组件,不会被拉到客户端 */}
</ClientWrapper>;

3. 数据获取:从 useEffectasync 组件

RSC 带来的最大变化之一是数据获取方式——不再是"挂载后发请求",而是"渲染前就拿到数据"。

// 旧世界:客户端组件——挂载后发请求
"use client";
function OldPage({ id }) {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch(`/api/posts/${id}`)
      .then((r) => r.json())
      .then(setData);
  }, [id]);
  if (!data) return <p>加载中...</p>;
  return <h1>{data.title}</h1>;
}
 
// 新世界:服务端组件——直接 await
async function NewPage({ id }) {
  const data = await db.posts.findById(id); // 服务端直接查数据库
  return <h1>{data.title}</h1>;
}

区别

  • 旧写法:浏览器发 HTTP 请求到 BFF/API 层 → API 层查数据库 → 返回 → React 渲染
  • 新写法:React 在服务端渲染时就拿到数据 → 直接渲染 HTML → 浏览器只收到 HTML

减少了整整一层网络往返。而且数据获取和渲染在同一个 async 函数里——不需要管理 loading 状态,不需要写 useEffect


4. 流式渲染:一点一点地给

即使服务端组件很快,查询也可能慢(比如复杂的数据库查询)。如果等所有数据都就绪才返回 HTML,用户还是看着白屏等待。

RSC 的流式渲染解决了这个问题:边渲染边发送。不需要等所有组件都渲染完——先渲染好的部分先发给浏览器。

// 流式渲染:每个 Suspense 边界都是一个"可以独立发送的块"
async function Dashboard() {
  return (
    <div>
      <PageHeader /> {/* 这个先渲染完 → 先发 */}
      <Suspense fallback={<Skeleton />}>
        <SlowChart data={await getChartData()} /> {/* 这个慢 → 后发 */}
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <RecentActivity feed={await getActivityFeed()} /> {/* 这个也慢 → 也后发 */}
      </Suspense>
    </div>
  );
}

执行顺序:PageHeader 渲染完 → 立刻作为 HTML 发给浏览器 → 用户看到导航栏 → SlowChart 还在等数据 → 显示 <Skeleton /> → 数据就绪 → 渲染 HTML → 追加发送 → 骨架屏换成图表。

RSC + 流式渲染 = 服务端组件的 await 不会阻塞整个页面。每个 Suspense 边界独立等待、独立发送。


5. Server Actions:在服务端组件里处理表单

如果服务端组件不能有 onClick,那表单提交怎么处理?React 19 引入的 Server Actions 解决了这个问题:

// 一个函数,标记 "use server" → 只在服务端执行
async function createPost(formData: FormData) {
  "use server"
  const title = formData.get("title");
  await db.posts.create({ title });  // 服务端写数据库
}
 
// 服务端组件里直接用
function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">发布</button>
    </form>
  );
}

<form action={createPost}> 的含义:表单提交时,浏览器把表单数据发给服务端,createPost 在服务端执行——不经过任何客户端 JS。这比"前端 POST 到 API → API 写数据库"少了一层——表单直接对接数据库操作

不需要 "use client"——这个表单没有 onClick、没有 useState、没有任何客户端交互。HTML 的 <form> 本身就是可提交的。Server Actions 让你的表单在客户端 JS 完全禁用的情况下也能工作(渐进增强)。


6. 何时用 RSC,何时用 CSR:一张决策图

这个组件需要什么?
 
├─ 只读数据(数据库 / 文件 / 内部 API)
│  └─ 服务端组件(默认)
│     不需要任何标记

├─ 纯展示(根据 props 渲染 UI)
│  └─ 服务端组件(默认)
│     除非它被客户端组件 import

├─ 用户交互(点击 / 输入 / 拖拽)
│  └─ 客户端组件
│     加 "use client"

├─ 需要使用 React Hook(useState / useEffect 等)
│  └─ 客户端组件
│     加 "use client"

└─ 需要在服务端执行但希望像前端 API 一样调用
   └─ Server Action
      函数内加 "use server"

7. 本节不讲什么:高并发下的服务端性能

RSC 把渲染从客户端移到服务端,自然带来一个问题:高流量时服务端 CPU 会不会爆? 答案是会的——如果每个请求都实时渲染服务端组件,Node.js 进程会被打满。

这也是为什么 Next.js 提供了多层防御:

  1. 静态生成(SSG)——博客、文档等内容在构建时渲染成静态 HTML,零服务端开销
  2. 增量静态再生成(ISR)——内容更新时后台重建页面,不需要为每个请求重新渲染
  3. CDN 缓存——Vercel / Cloudflare 等边缘网络缓存 HTML 响应,请求不到达源服务器
  4. Cache Components——"use cache" 把昂贵计算的输出持久化,后继请求直接读缓存

这些是工程化话题——属于第 7 章质量与交付的范围,包括:ISR 配置策略、缓存失效时机、Edge Function 冷启动优化、容器资源规划。本章聚焦 RSC 的心智模型和 API,性能调优在第 7 章展开。

现阶段需要记住的:RSC 不是"每个请求都实时渲染"——合理使用静态生成 + CDN 缓存可以让大部分流量完全不触及服务端。本站(前端工程师手册)就是纯静态生成——所有内容页面在构建时预渲染为 HTML,依赖 CDN 分发,没有服务端运行时开销。


8. 设计判断暗线

  • 默认服务端,例外客户端:RSC 的设计哲学是把计算推到离数据最近的地方——如果数据在服务端(数据库/文件系统),那渲染也在服务端。只在"必须要有浏览器环境才能做的事"(交互、浏览器 API)时才推到客户端。
  • Props 是边界:服务端组件传给客户端组件的 props 必须是可序列化的(string、number、boolean、plain object、数组等)。函数、Promise、Date 等不能直接传——它们经过网络传输后无法还原。
  • 渐进增强:HTML <form> + Server Action 让表单在没有 JS 的环境下也能提交。这不是"降级方案"——它是"先做基础版(零 JS),再叠增强体验(JS 校验+动画)"。

9. AI 时代写 RSC 的最小集

你必须把握的部分

  • "use client" 边界位置:AI 会倾向把所有组件标成客户端(因为它写的代码假设有浏览器环境)。你需要反向推动——问"这个组件真的一定要在浏览器跑吗?"——把不需要交互的组件移回服务端。
  • props 序列化:AI 可能传函数或复杂对象给客户端组件——你需要审查每个"跨边界"的 prop 是否可序列化。
  • 数据获取思路转变:AI 容易用旧习惯(useEffect + fetch)写服务端组件——你需要识别并改为 async + 直接数据库访问。

你可以委托 AI 的部分

  • CRUD 页面:列表、详情、创建表单——模板化,AI 写得快
  • Server Action 函数体:数据库增删改查——AI 写初版你 review 权限和错误处理
  • Suspense 边界 + 骨架屏:AI 知道模式

你对 AI 写的 RSC 代码最容易错的事

  1. AI 会在服务端组件里写 useState——服务端组件不能用 Hook,AI 有时忘了。编译期会报错,但 AI 可能试着加 "use client" 来"修复"——你把不该客户端的组件变成客户端了。
  2. AI 会在不需要 "use client" 的组件上标记——因为它的训练数据里很多旧代码默认是客户端组件。你需要审查每个 "use client" 是否真的必要。

10. 三档归位

你应该能对应 Tier
解释 RSC 和传统 CSR 的区别(运行位置、能力边界)必学
正确标记 "use client" 边界——哪些组件需要必学
写出 async 服务端组件替代 useEffect + fetch 模式必学
使用 Suspense + 流式渲染优化首屏理解
用 Server Action 处理表单(替代 API route)理解
设计"推交互到边缘"的组件拆分策略理解
审查 AI 写的 RSC 代码中不必要的 "use client" 标记理解
为已有 SPA 制定 RSC 迁移方案可委托 AI

11. 延伸阅读


下一节:§5.4 Next.js App Router——路由、数据获取、缓存策略、ISR。