第 5 章
服务端组件与客户端组件
RSC 心智模型、use client 边界、流式渲染——React 2023+ 最大的架构变化
0. 一段必要的回顾:全客户端渲染的痛点
2013-2022 年,React 应用的标准架构是全客户端渲染:浏览器下载一个 JS bundle,React 在客户端执行所有组件逻辑,生成 DOM。服务端只负责返回一个空壳 HTML 和 JS 文件的链接。
这个模型有三个根本问题:
- 首屏慢:用户看到一个空白页 → 等 JS 下载 → 等 JS 执行 → React 渲染 → 才有内容。每一步都是延迟。
- 数据瀑布:组件挂载 →
useEffect发请求 → 等响应 → 再渲染子组件 → 子组件的useEffect再发请求 → ……请求一层的依赖关系形成瀑布,但服务端实际上可以一次性拿到所有数据。 - 无法访问后端资源: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" |
| 能做什么 | 读数据库、读文件、访问内部 API | useState、useEffect、事件处理、浏览器 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":
- 事件处理:
onClick、onChange、onSubmit等 - React Hooks:
useState、useEffect、useReducer等 - 浏览器 API:
window、document、localStorage、navigator - 第三方客户端库: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. 数据获取:从 useEffect 到 async 组件
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 提供了多层防御:
- 静态生成(SSG)——博客、文档等内容在构建时渲染成静态 HTML,零服务端开销
- 增量静态再生成(ISR)——内容更新时后台重建页面,不需要为每个请求重新渲染
- CDN 缓存——Vercel / Cloudflare 等边缘网络缓存 HTML 响应,请求不到达源服务器
- 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 代码最容易错的事
- AI 会在服务端组件里写
useState——服务端组件不能用 Hook,AI 有时忘了。编译期会报错,但 AI 可能试着加"use client"来"修复"——你把不该客户端的组件变成客户端了。 - 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。