React Server Components 实践:哪些组件留服务端,哪些下放客户端
我第一次认真看 React Server Components 的时候,注意力几乎都在性能上。
组件跑在服务端,少发一点 JavaScript,数据不用再到客户端 useEffect 里请求,首屏可能更快。这些点都很吸引人,也很容易变成讨论焦点。
但越看真实项目里的使用方式,我越觉得语法只是最容易的那部分——难的是判断哪些组件该留在服务端、哪些必须下放到客户端。
哪些组件应该留在服务端?
哪些交互必须放到客户端?
数据应该在哪一层读?
错误、缓存、权限又应该怎么收口?
这些问题不想清楚,RSC 不会自动让项目变清晰,反而会让“这段代码到底跑在哪里”变成新的困惑。
接手一个 Next.js App Router 项目时,我看到有人在一个 import 链路的顶端组件里随手加了 "use client",结果整个详情页连同十几个本来纯展示的子组件全被拖进了客户端 bundle。打包体积没省下来不说,原本可以在服务端直接 await 拿数据的逻辑,又被人改回了 useEffect 加 loading 态。RSC 给的不是“免费的性能”,而是一组需要主动维护的边界约定。约定一旦失守,带来的混乱比传统 SPA 还难排查——因为你连报错栈里那行代码到底在 Node 进程还是浏览器里跑都要愣一下。
我先把它理解成“读数据和组装页面”的默认层
现在我更愿意把 Server Component 当成页面的默认组织层。
它适合做这些事:
- 读取服务端数据。
- 根据权限组织页面结构。
- 渲染不需要浏览器状态的内容。
- 组合布局、标题、面包屑、详情信息。
- 减少不必要的客户端 JavaScript。
比如一篇文章页,正文、作者、标签、相关推荐,大部分都不需要在浏览器里维护状态。
1export default async function ArticlePage({ params }) { 2 const article = await getArticle(params.slug); 3 const related = await getRelatedArticles(article.tags); 4 5 return ( 6 <main> 7 <ArticleHeader article={article} /> 8 <ArticleContent content={article.content} /> 9 <RelatedArticles items={related} /> 10 </main> 11 ); 12}
这类代码让我喜欢的地方,是数据获取和页面结构靠近了。以前客户端页面经常先渲染空壳,再 useEffect 请求,再处理 loading、error、empty。对于只读内容页,这套流程确实有点绕。
Client Component 不是“退化方案”
我刚开始看 RSC 时,也容易把 Client Component 看成不得已才用。
后来发现这个想法不对。
只要组件需要浏览器能力,它就应该在客户端:
- 输入框状态。
- 弹窗开关。
- 拖拽排序。
- 键盘快捷键。
- 浏览器存储。
- 动画和即时反馈。
比如点赞按钮、评论输入、筛选面板,这些本来就是交互组件。
1"use client"; 2 3import { useState } from "react"; 4 5export function LikeButton({ initialCount }) { 6 const [count, setCount] = useState(initialCount); 7 const [pending, setPending] = useState(false); 8 9 async function handleClick() { 10 setPending(true); 11 12 try { 13 await fetch("/api/like", { method: "POST" }); 14 setCount((value) => value + 1); 15 } finally { 16 setPending(false); 17 } 18 } 19 20 return ( 21 <button disabled={pending} onClick={handleClick}> 22 喜欢 {count} 23 </button> 24 ); 25}
它放在客户端是合理的。不要为了“用上服务端组件”,把简单交互绕成复杂流程。
真正容易出问题的是边界放太高
我现在最警惕的一种写法,是在很高的位置加 "use client"。
1"use client"; 2 3export default function PageShell({ children }) { 4 const [sidebarOpen, setSidebarOpen] = useState(false); 5 6 return ( 7 <div> 8 <Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} /> 9 {children} 10 </div> 11 ); 12}
这段代码本身不一定错,但它会扩大客户端边界。子树里很多本来可以留在服务端的内容,可能被迫进入客户端世界。
更稳的思路通常是:服务端组件负责页面结构,客户端组件只包住需要交互的那一小块。
1export default async function ArticlePage({ params }) { 2 const article = await getArticle(params.slug); 3 4 return ( 5 <article> 6 <ArticleContent content={article.content} /> 7 <CommentEntry articleId={article.id} /> 8 </article> 9 ); 10}
这里 ArticleContent 可以是 Server Component,CommentEntry 是 Client Component。页面整体仍然由服务端组织,只有评论输入区需要浏览器状态。
边界切小,不只是为了少发 JS,也是为了让职责清楚。
数据获取不能到处散
RSC 让组件里 await 数据变得自然,但这也带来一个新问题:数据获取很容易分散。
如果每个组件都自己读数据,后面可能出现:
- 同一个接口被多个组件重复请求。
- 页面数据依赖关系看不出来。
- 缓存策略散在很多文件。
- 错误处理各写各的。
我现在更倾向于在页面级或模块级明确主要数据边界。核心数据在 page 层准备,局部独立模块可以自己读取,但要有约束。
1export default async function DashboardPage() { 2 const [user, stats, notices] = await Promise.all([ 3 getCurrentUser(), 4 getDashboardStats(), 5 getNotices(), 6 ]); 7 8 return ( 9 <DashboardLayout user={user}> 10 <StatsPanel data={stats} /> 11 <NoticeList items={notices} /> 12 </DashboardLayout> 13 ); 14}
这种写法没有把数据获取组件化到极致,但页面依赖很清楚。以后排查接口慢、缓存不生效、权限异常,也更容易找到入口。
错误处理要比以前更认真
服务端组件里读数据失败,错误会更早进入渲染链路。
业务里常见错误不能都靠默认 error page 兜底:
- 未登录。
- 无权限。
- 数据不存在。
- 上游接口超时。
- 数据格式异常。
我更希望服务端数据方法抛出稳定的业务错误,而不是随手 throw new Error()。
1export class AppError extends Error { 2 constructor(public code: string, message: string) { 3 super(message); 4 } 5} 6 7export async function getArticle(slug: string) { 8 const article = await articleService.findBySlug(slug); 9 10 if (!article) { 11 throw new AppError("ARTICLE_NOT_FOUND", "文章不存在"); 12 } 13 14 return article; 15}
页面层再决定是渲染 404、权限提示、登录入口,还是通用错误页。
RSC 没有降低统一错误处理的重要性。相反,它把前端和服务端渲染链路拉得更近了,谁来处理哪一类错误,需要说得更清楚。
缓存不是“能缓存就缓存”
RSC 和服务端渲染经常会一起讨论缓存,但缓存策略不能只看性能。
我会把数据分成三类:
1公共内容:文章、文档、帮助页,适合缓存 2用户相关:菜单、权限、个人配置,谨慎缓存 3实时状态:订单、库存、审批流,优先准确
缓存公共内容通常收益很高。缓存用户私有信息就要认真考虑隔离和失效。缓存实时状态则可能直接造成业务错误。
所以我现在看到“服务端组件可以缓存”这句话时,会自动补一句:要先知道这份数据是谁的、多久变、错了会怎样。
落地下来,前端职责其实是在重新拆分
RSC 带来的不是“React 又多了一个能力”,而是前端职责正在重新拆分。
过去很多事情都发生在浏览器里:读数据、组页面、处理交互、兜错误。现在一部分读数据和组页面的工作可以回到服务端,浏览器可以更专注交互。
这对内容页、详情页、文档页、部分中后台只读页面都很有价值。
但它对团队也提出了更高要求:你必须知道某段代码跑在哪里,哪些数据能传给客户端,哪些对象不能序列化,哪些逻辑必须留在能捕获错误的那一层里。
这些判断以前散落在各人的经验里,遇到问题靠个人排查经验补上;现在这套判断需要变成团队里能说清楚、能写进 code review checklist 的具体标准,不然新同事随手一个 "use client" 就可能把边界重新捅破。
我现在看 React Server Components,不会只盯着“少发 JS”。
它真正有价值的地方,是让读数据、组装页面和处理交互有机会重新分工。服务端负责更适合服务端的事情,客户端负责真正需要浏览器的事情。
回到开头那次接手的详情页:把 "use client" 从链路顶端挪到只包住评论输入区之后,其余子组件重新回到服务端直接 await 拿数据,打包体积和请求方式才一起回到正轨。这种判断没法靠框架自动完成,得靠团队自己想清楚每段代码该跑在哪里。