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. ArticlePageArticleContentAuthorBio 在服务端渲染,永不发送到客户端
  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,会导致整个请求失败。


延展阅读