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 拿数据,打包体积和请求方式才一起回到正轨。这种判断没法靠框架自动完成,得靠团队自己想清楚每段代码该跑在哪里。