React 服务端组件

RSC 解决的问题、为什么需要服务端组件、服务端组件 vs 客户端组件的边界、Server Component 和 Client Component 的通信方式,以及流式 SSR 和 React 18 的结合。

React 服务端组件

概述

React Server Components(RSC,服务端组件)是 React 18 和 Next.js 13 App Router 引入的最重要的新架构。它解决了一个根本性问题:客户端(浏览器)和服务端之间,应该如何分配 UI 渲染工作?

传统的 React SSR(Server-Side Rendering)方案(如 Next.js Pages Router)本质上是"在服务端渲染 HTML,然后在客户端激活(hydration)"。但这种方式的问题是:所有组件代码都必须打包发送到客户端,无论这个组件是否需要交互。

RSC 改变了这一点:某些组件可以永远只在服务端运行,它们的代码永远不会发送到客户端。这从根本上优化了 bundle 大小和首屏加载性能。

理解 RSC,不仅仅是学习一个新 API,更是理解 React 在"服务端能力"和"客户端能力"之间如何划分边界。


RSC 解决的问题

传统 SSR 的困境

考虑一个典型的博客文章页面:

// ArticlePage.jsx
function ArticlePage({ articleId }) {
  const [comments, setComments] = useState([]);

  useEffect(() => {
    fetchComments(articleId).then(setComments);
  }, [articleId]);

  return (
    <article>
      <ArticleContent article={article} />  {/* 纯展示 */}
      <AuthorBio author={author} />         {/* 纯展示 */}
      <CommentSection comments={comments} /> {/* 需要交互 */}
    </article>
  );
}

问题:整个 ArticlePage 及其所有子组件都会被打包发送到客户端,包括那些永远不需要 JavaScript 的纯展示组件(ArticleContent、AuthorBio)。

在传统 SSR 模式下:

  1. 服务端渲染完整的 HTML
  2. 客户端下载包含 ArticlePage 所有代码的 JS bundle
  3. 客户端执行 hydration,让页面可交互

结果:用户下载了大量永远不需要执行的 JavaScript,只因为"也许未来某个时刻会需要交互"。

RSC 的解决思路

RSC 的核心洞察是:不是所有组件都需要在客户端运行。

// ArticlePage.tsx (Server Component - 默认)
async function ArticlePage({ articleId }) {
  // 可以直接 await 数据库或 API
  const article = await db.articles.find(articleId);
  const author = await db.users.find(article.authorId);

  return (
    <article>
      <ArticleContent article={article} />   {/* Server Component */}
      <AuthorBio author={author} />          {/* Server Component */}
      <CommentSection articleId={articleId} /> {/* Client Component */}
    </article>
  );
}

// CommentSection.tsx (Client Component)
'use client';
function CommentSection({ articleId }) {
  const [comments, setComments] = useState([]);

  useEffect(() => {
    fetchComments(articleId).then(setComments);
  }, [articleId]);

  return (
    <div>
      {comments.map(c => <Comment key={c.id} text={c.text} />)}
      <CommentForm onSubmit={submitComment} />
    </div>
  );
}

RSC 模式:

  1. ArticlePage、ArticleContent、AuthorBio 在服务端渲染,永不发送到客户端
  2. 只有 CommentSection(需要交互)会被打包发送到客户端
  3. 服务端直接返回渲染后的 HTML,不需要完整 hydration

结果:

  • JS bundle 大幅减小
  • 首屏加载更快
  • SEO 友好(HTML 完整)

Server Component vs Client Component

核心区别

特性 Server Component Client Component
指令 无(默认) 'use client'
代码位置 服务端 客户端(但服务端也可渲染)
JS Bundle 不包含 包含
可以使用 Hooks ❌ ✅
可以使用浏览器 API ❌ ✅
可以使用事件监听 ❌ ✅
支持 async/await ✅ ❌(在 render 中)
直接访问数据库/文件系统 ✅ ❌

关键理解:客户端组件也能 SSR

重要:'use client' 并不意味着组件"只在客户端运行"。Client Component 仍然可以在服务端渲染(SSR),'use client' 只是表示:

"这个组件需要客户端 JavaScript 来实现完整交互能力"

'use client';

// 这个组件在 Next.js 中会:
// 1. 服务端渲染一次,生成 HTML
// 2. 客户端再渲染一次(hydration),绑定事件
function LikeButton({ initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);

  return (
    <button onClick={() => setLikes(l => l + 1)}>
      {likes} likes
    </button>
  );
}

边界原则

核心原则:将 'use client' 边界推到组件树的最低层。

// ✅ 正确:只在真正需要交互的地方用 'use client'
// ServerComponent.tsx
async function ServerComponent() {
  const data = await fetchData();

  return (
    <div>
      <ExpensiveChart data={data} />      {/* Server Component */}
      <LikeButton initialLikes={100} />   {/* Client Component */}
    </div>
  );
}

// ❌ 错误:把整个父组件变成 Client Component
'use client';
async function ServerComponent() {  // 错误:'use client' 和 async 不能共存
  const data = await fetchData();
  return <ExpensiveChart data={data} />;
}

常见的边界错误

错误 1:Client Component 嵌套 Server Component

'use client';
function ParentComponent() {
  return (
    <div>
      <ServerChild />  {/* ❌ 错误:不能从 Client Component 导入 Server Component */}
    </div>
  );
}

为什么不行:Server Component 在客户端无法渲染(它需要服务端环境)。如果你在一个 Client Component 内部尝试使用 Server Component,客户端会尝试执行它,但 Server Component 可能需要数据库访问等服务端能力。

错误 2:Server Component 中使用 Hooks

// ❌ 错误:Server Component 不能使用 useState
function ServerComponent() {
  const [count, setCount] = useState(0);  // 编译错误
  return <div>{count}</div>;
}

错误 3:Server Component 中使用 async 和 state 混合

// ❌ 错误
function ServerComponent() {
  const [data, setData] = useState(null);  // ❌ 不能用 useState

  useEffect(() => {  // ❌ 不能用 useEffect
    fetchData().then(setData);
  }, []);

  return <div>{data}</div>;
}

Server Component 和 Client Component 的通信

Props 传递:数据序列化

Server Component 和 Client Component 之间只能通过 props 传递可序列化的数据。

// Server Component
async function Page() {
  const data = await fetchData();  // data 必须是可序列化的

  return (
    <ClientComponent
      // 这些 props 会序列化后传递
      initialData={data}           // ✅ plain object
      userName={data.user.name}    // ✅ string
      createdAt={data.createdAt}  // ✅ Date 会转为 string
    />
  );
}

可序列化的类型:strings、numbers、booleans、arrays、objects、Date(转为 ISO string)、null。

不可序列化的类型:functions、class instances、React elements。

Server Component 的数据获取

Server Component 可以使用 async/await,这意味着数据获取可以直接在组件层完成:

// app/posts/page.tsx
async function PostsPage() {
  // 直接在组件中 await,不需要 useEffect
  const posts = await db.posts.findMany({
    where: { published: true },
    include: { author: true },
    orderBy: { createdAt: 'desc' },
    take: 10,
  });

  return (
    <main>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </main>
  );
}

好处:

  1. 减少数据获取的样板代码
  2. 可以使用 await,而不用处理 Promise
  3. 便于 colocation——数据获取逻辑和组件放在一起

客户端组件接收数据

'use client';

// 接收序列化的数据作为 props
function PostList({ initialPosts }) {
  const [posts, setPosts] = useState(initialPosts);

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

// 父组件(Server Component)
async function Page() {
  const posts = await db.posts.findMany();

  return <PostList initialPosts={posts} />;  // 传递序列化后的数据
}

组合模式:服务端与客户端的协作

模式 1:Props 传递数据

最简单的方式:通过 props 将 Server Component 的数据传递给 Client Component。

// Server Component
async function Dashboard() {
  const metrics = await fetchMetrics();

  return (
    <div>
      <MetricsChart data={metrics} />  {/* Client Component */}
      <LastUpdated time={metrics.updatedAt} />
    </div>
  );
}

// Client Component
'use client';
function MetricsChart({ data }) {
  const canvasRef = useRef(null);

  useEffect(() => {
    // 用 Chart.js 绑定 canvas
    const chart = new Chart(canvasRef.current, { data });
    return () => chart.destroy();
  }, [data]);

  return <canvas ref={canvasRef} />;
}

模式 2:组合(Children Slot)

// Server Component
async function Dashboard({ children }) {
  const sidebar = await fetchSidebarData();

  return (
    <div className="dashboard">
      <aside>{children}</aside>
      <main>
        <Sidebar data={sidebar} />
      </main>
    </div>
  );
}

// 父组件(Server Component)
async function Page() {
  return (
    <Dashboard>
      <NotificationsPanel />  {/* 这是另一个 Server Component */}
    </Dashboard>
  );
}

模式 3:Context(仅限 Client Component)

Context 只能在 Client Component 中使用。如果 Server Component 需要共享数据,有几种方案:

方案 A:通过 props 层层传递

// Server Component
async function App({ userId }) {
  const user = await fetchUser(userId);
  return <MainLayout user={user}><PageContent /></MainLayout>;
}

// Client Component
'use client';
function MainLayout({ user, children }) {
  return (
    <UserContext.Provider value={user}>
      <header>{user.name}</header>
      <main>{children}</main>
    </UserContext.Provider>
  );
}

方案 B:用 Server Component 作为数据层

// Server Component 专门负责数据获取
async function DataProvider({ userId }) {
  const user = await fetchUser(userId);
  return <UserContext.Provider value={user} {...props} />;
}

// 在 layout 中使用
async function RootLayout() {
  const user = await getUser();  // 先获取数据
  return <DataProvider user={user}><App /></DataProvider>;
}

模式 4:Boundary 组件

创建"边界组件"来分隔服务端和客户端区域:

// Boundary.tsx
'use client';
export function Boundary({ children, fallback }) {
  return (
    <ErrorBoundary fallback={fallback}>
      <Suspense fallback={fallback}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

// ServerComponent.tsx
async function ServerComponent() {
  return (
    <Boundary fallback={<Loading />}>
      <HeavyClientComponent />
    </Boundary>
  );
}

流式 SSR 与 React 18

什么是流式 SSR

传统的 SSR 是"同步"的:服务端必须等所有数据都准备好,才能开始发送 HTML。

请求 → 服务端获取所有数据 → 服务端渲染完整 HTML → 发送 HTML → 客户端 hydration

流式 SSR(Streaming SSR)是"异步"的:服务端可以先发送 HTML,再逐步流式发送更多内容。

请求 → 服务端发送初始 HTML
     → 开始获取数据 A(同时浏览器解析已收到的 HTML)
     → 数据 A 就绪,发送对应的 HTML 片段
     → 开始获取数据 B
     → 数据 B 就绪,发送对应的 HTML 片段
     → ...

React 18 的 Suspense 与流式渲染

React 18 的 <Suspense> 组件与流式 SSR 深度集成:

// app/blog/page.tsx
async function BlogPage() {
  return (
    <html>
      <body>
        <h1>My Blog</h1>
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />  {/* PostList 内部可以 await */}
        </Suspense>
      </body>
    </html>
  );
}

// app/components/PostList.tsx
async function PostList() {
  // 这个 await 不会阻塞初始 HTML 发送
  const posts = await db.posts.findMany();

  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

工作流程:

  1. 服务端立即发送 <PostListSkeleton /> 作为 fallback
  2. 同时,PostList 组件在服务端后台 await 数据
  3. 数据就绪后,React 自动用实际内容替换 skeleton

服务端错误处理

Suspense boundary 也会处理服务端错误:

<Suspense fallback={<Loading />}>
  <ErrorBoundary fallback={<Error />}>
    <ProblematicServerComponent />
  </ErrorBoundary>
</Suspense>
  • 如果 ProblematicServerComponent 在服务端抛出错误:Error Boundary 显示 <Error />
  • 如果 ProblematicServerComponent 在客户端 hydration 时失败:Error Boundary 显示 <Error />

实践指南

什么时候用 Server Component

  • 数据获取逻辑
  • 访问服务端资源(数据库、文件系统)
  • 大型依赖(heavy libraries)不需要发送到客户端
  • 纯展示组件

什么时候用 Client Component

  • 需要使用 React Hooks(useState、useEffect、useContext)
  • 需要浏览器 API(window、document、localStorage)
  • 需要事件监听(onClick、onChange)
  • 需要动画(requestAnimationFrame、transition)

迁移策略

从传统 React SSR 迁移到 RSC:

1. 从叶子节点开始

// 原来:整个组件是 Client Component
'use client';
function Dashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, []);

  return <div>{data && <Chart data={data} />}</div>;
}

// 迁移后:只把需要交互的部分改成 Client Component
// Dashboard.tsx (Server Component)
async function Dashboard() {
  const data = await fetchData();
  return <ChartComponent data={data} />;  // 传递数据
}

// ChartComponent.tsx (Client Component)
'use client';
function ChartComponent({ data }) {
  return <Chart data={data} />;
}

2. 识别"数据获取 + 展示"的模式

// 原来:数据获取和展示混在一起
'use client';
function UserProfile() {
  const [user, setUser] = useState(null);
  useEffect(() => { fetchUser().then(setUser); }, []);
  // ...
}

// 迁移后:分离关注点
// UserProfilePage.tsx (Server Component)
async function UserProfilePage({ userId }) {
  const user = await fetchUser(userId);
  return <UserInfo user={user} />;  // 只传递必要数据
}

// UserInfo.tsx (Client Component)
'use client';
function UserInfo({ user }) {
  // 只负责交互,不再负责数据获取
}

常见问题

Q:Server Component 能不能用 useState?

不能。Server Component 没有状态,因为它们不参与 React 的更新机制。

Q:Server Component 能不能用 Context?

不能直接用。但可以通过 Server Component 作为 Provider 的子元素来间接使用。

Q:一个组件能同时是 Server 和 Client Component 吗?

不能。每个组件在运行时要么是 Server Component,要么是 Client Component。但一个组件可以:

  • Server Component(在服务端渲染)
  • Client Component(在客户端 hydration)

Q:Server Component 抛出的错误谁来处理?

服务端渲染时的错误会被最近的 Error Boundary(如果有)捕获。如果没有 Error Boundary,会导致整个请求失败。


延展阅读