React Server Components 实战指南:从入门到性能优化
React Server Components 实战指南:从入门到性能优化
React 19 稳定发布已经有一段时间了,Server Components(简称 RSC)也从"实验性功能"变成了"推荐默认"。但我发现很多团队虽然在用 Next.js App Router,却并没有真正理解和用好 RSC。
今天这篇文章,我会从原理到实践,带你系统性地掌握 React Server Components。
一、先搞清楚:RSC 到底是什么?
很多人对 RSC 的理解停留在"在服务器上运行的组件",但这只是表面。RSC 的本质是:一种新的组件渲染模型,它将组件分为服务端运行和客户端运行两类,通过网络流式传输渲染结果。
1.1 传统 vs RSC 对比
| 维度 | 传统 Client Only | RSC 混合模式 |
|---|---|---|
| 组件运行位置 | 全部在浏览器 | 服务端 + 浏览器 |
| 数据获取 | useEffect + API | 组件内直接 async/await |
| Bundle 体积 | 所有组件 JS 都要下载 | 只下载 Client Component |
| 首屏请求 | HTML + JS + API 数据 | 一次性返回 HTML |
| 敏感逻辑 | 不能放前端 | 可以放在 Server Component |
1.2 一个直观的例子
传统方式(全客户端):
// 用户打开页面 → 下载 JS → 执行 JS → 发 API 请求 → 渲染列表
'use client';
export default function UsersPage() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users').then(res => res.json()).then(setUsers);
}, []);
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
RSC 方式:
// 用户打开页面 → 服务端获取数据 → 渲染 HTML → 直接展示
export default async function UsersPage() {
const users = await db.user.findMany(); // 直接查数据库
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
看到区别了吗?RSC 把数据获取和渲染都搬到了服务端,客户端只需要展示最终的 HTML。
二、什么时候用 Server Component?什么时候用 Client Component?
这是初学者最常问的问题。我的判断标准很简单:
2.1 优先用 Server Component
以下情况应该用 Server Component(默认):
- ✅ 需要获取数据(数据库、API、文件系统)
- ✅ 包含敏感逻辑(API Key、业务规则)
- ✅ 纯展示型组件(列表、卡片、文本)
- ✅ 大依赖库(markdown 解析、日期处理、图表配置)
- ✅ 不需要交互(没有 onClick、onChange 等)
2.2 必须用 Client Component
以下情况必须用 Client Component(加 'use client'):
- ❌ 需要使用 state(useState、useReducer)
- ❌ 需要使用 effect(useEffect)
- ❌ 需要使用浏览器 API(window、document、localStorage)
- ❌ 需要事件处理(onClick、onSubmit)
- ❌ 需要使用自定义 Hook 且依赖 state/effect
- ❌ 需要使用 React Context
2.3 一个常见的误区
很多人以为"页面是 Server Component,那里面的子组件也全都是",这是对的。但反过来,"加了 'use client' 的文件里所有组件都是 Client Component",这也是对的。
关键在于:Client Component 可以 import Server Component 吗?不行。 但 Server Component 可以 import Client Component,并且可以把 Server Component 作为 children 传给 Client Component。
// ✅ 正确:Server Component 组合 Client Component
import InteractiveChart from './interactive-chart'; // Client
import DataTable from './data-table'; // Server
export default function DashboardPage() {
return (
<div>
<InteractiveChart />
<DataTable />
</div>
);
}
// ✅ 正确:用 children 模式突破限制
// layout.tsx (Server Component)
import ClientShell from './client-shell';
import ServerSidebar from './server-sidebar';
export default function Layout({ children }) {
return (
<ClientShell>
<ServerSidebar /> {/* Server 组件作为 children 传入 */}
{children}
</ClientShell>
);
}
这个 children 模式非常重要,它是 RSC 架构的核心设计模式之一。
三、实战:重构一个博客详情页
让我们通过一个真实场景来理解 RSC 的优势。
3.1 重构前(传统方式)
// app/blog/[slug]/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { marked } from 'marked'; // 35KB
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; // 200KB+
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; // 额外体积
export default function BlogPost({ params }) {
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/posts/${params.slug}`)
.then(res => res.json())
.then(data => {
setPost(data);
setLoading(false);
});
}, [params.slug]);
if (loading) return <div>加载中...</div>;
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
</article>
);
}
问题:
- marked 库(35KB)要下载到客户端
- 整个组件 JS 要下载
- 需要等 JS 加载完才能发请求获取文章内容
- 首屏有 loading 状态,用户体验不好
3.2 重构后(RSC 方式)
// app/blog/[slug]/page.tsx
// 注意:没有 'use client',这是 Server Component
import { marked } from 'marked'; // 在服务端运行,不增加客户端 Bundle
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { getPostBySlug } from '@/lib/posts';
import LikeButton from './like-button'; // Client Component
export default async function BlogPost({ params }) {
// 直接在服务端获取数据
const post = await getPostBySlug(params.slug);
const htmlContent = marked(post.content);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
{/* 需要交互的地方单独抽成 Client Component */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
);
}
// app/blog/[slug]/like-button.tsx
'use client';
import { useState } from 'react';
interface LikeButtonProps {
postId: string;
initialLikes: number;
}
export default function LikeButton({ postId, initialLikes }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(false);
const handleClick = async () => {
if (liked) return;
setLikes(l => l + 1);
setLiked(true);
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
};
return (
<button onClick={handleClick} className="like-btn">
❤️ {likes}
</button>
);
}
重构后的收益:
- marked、syntax-highlighter 等大库全部在服务端运行,客户端 Bundle 减少 200KB+
- 文章内容直接在 HTML 中返回,首屏无 loading,FCP 提升明显
- 只有点赞按钮需要下载到客户端,体积可以忽略不计
四、性能优化技巧
4.1 合理划分 Client 边界
很多人的做法是"整个页面加 'use client'",这是最浪费的。正确的做法是:
把 Client Component 尽量下沉到叶子节点
也就是说,尽可能晚地引入 'use client'。页面整体是 Server Component,只有真正需要交互的小组件才是 Client Component。
4.2 利用 Suspense 做流式渲染
RSC 支持 Suspense + 流式传输,可以让慢的组件不阻塞快的组件:
// app/page.tsx
import { Suspense } from 'react';
import FastSidebar from './fast-sidebar';
import SlowMainContent from './slow-main-content';
import Skeleton from './skeleton';
export default function HomePage() {
return (
<div className="flex">
<FastSidebar /> {/* 快的先展示 */}
<Suspense fallback={<Skeleton />}>
<SlowMainContent /> {/* 慢的边加载边展示 loading */}
</Suspense>
</div>
);
}
用户可以先看到侧边栏和骨架屏,等主内容加载完后自动替换。这比等所有内容都加载完再展示体验好太多。
4.3 用 Server Actions 减少 API 路由
RSC 搭配 Server Actions 可以进一步简化架构:
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
export async function addComment(formData: FormData) {
const postId = formData.get('postId') as string;
const content = formData.get('content') as string;
await prisma.comment.create({
data: { postId, content }
});
revalidatePath(`/blog/${postId}`);
}
// app/blog/[slug]/comment-form.tsx
'use client';
import { addComment } from '../actions';
export default function CommentForm({ postId }) {
return (
<form action={addComment}>
<input type="hidden" name="postId" value={postId} />
<textarea name="content" required />
<button type="submit">发表评论</button>
</form>
);
}
不需要写 API Route,直接在 Server Action 中操作数据库,客户端组件通过 form action 调用。代码量减少 50% 以上。
五、常见坑与避坑指南
5.1 不要在 Server Component 中使用 onClick
这是初学者最常犯的错误。Server Component 输出的是 HTML,没有 JS 运行时,所以事件处理器不会生效。
解决方法:把需要交互的部分抽成 Client Component。
5.2 不要尝试把 Server Component 导入 Client Component
这会导致编译错误。Client Component 运行在浏览器里,不能直接 import 一个需要在服务端运行的组件。
解决方法:用 children 模式传递,或者重新设计组件层级。
5.3 注意 props 的序列化
Server Component 传给 Client Component 的 props 必须是可序列化的(JSON 能表示的类型)。不能传函数、类实例、Symbol 等。
解决方法:只传必要的数据,把逻辑放在各自的运行环境中。
5.4 不要过度使用 Client Component
我见过很多人因为"不知道怎么用 Server Component"就全用 'use client',这样等于完全放弃了 RSC 的优势。
建议:从默认 Server Component 开始,遇到需要交互的地方再局部引入 Client Component。
六、总结
React Server Components 不是银弹,但它是 React 生态中最有价值的架构演进之一。用好 RSC,你可以获得:
- 📦 更小的 Bundle 体积 — 服务端组件不增加客户端 JS
- ⚡ 更快的首屏加载 — 数据获取和渲染在服务端完成
- 🔒 更好的安全性 — 敏感逻辑留在服务端
- 🧹 更简洁的代码 — Server Actions 替代大量 API 路由
RSC 的学习曲线确实比纯客户端开发要陡一些,但一旦掌握了"Server 优先、Client 兜底"的思维方式,你会发现全栈开发从未如此简单高效。
如果你还在全项目用 'use client',不妨从下一个页面开始,试试纯 Server Component 的开发方式,相信你会回来感谢我的。