万字长文带你从零理解React Server Components

如果你对 React 源码解析感兴趣,欢迎访问我的个人博客:《深入浅出 React 19:AI 视角下的源码解析与进阶》 或者我的微信公众号- 【前端小卒】

在我的博客和公众号中,你可以找到:

🔍 完整的 React 源码解析电子书 - 从基础概念到高级实现,全面覆盖 React 19 的核心机制 📖 系统化的学习路径 - 按照 React 的执行流程,循序渐进地深入每个模块 💡 实战案例分析 - 结合真实场景,理解 React 设计思想和最佳实践 🚀 最新技术动态 - 持续更新 React 新特性和性能优化技巧

从零开始理解React Server Components

自从2020年底React团队首次公布这个概念,到现在已经快4年了,期间争议不断。有人说它是React的未来,有人说它把简单的事情搞复杂了。

作为一个在生产环境中使用过RSC的开发者,我想分享一些实际的使用心得。不管你是否认同Vercel的商业策略,RSC确实已经成为React官方力推的技术方向。今天咱们就来聊聊:RSC到底是个啥?为什么React要搞这么个东西?我们又该怎么用好它?

虽然现在Remix、Waku这些框架也在支持RSC,但说实话,Next.js在这方面确实走得最前面,所以文章里的例子主要基于Next.js。

RSC到底是什么东西

简单来说,React Server Components就是一种只在服务器上跑的React组件。在传统的React组件,不管是SSR还是CSR,最终都要在浏览器里被激活(hydrate)。但RSC不一样,它不会出现在浏览器里。服务器渲染完之后,会生成一种特殊的数据格式(叫RSC Payload),然后流式传输给浏览器,浏览器的React再把这些数据"翻译"成真正的DOM。

这种设计带来了几个很有意思的特性

全新的组件类型

有了RSC之后,React组件的类型就发生了变化,从原来的一种变成了三种:

组件类型 文件扩展名 运行环境 主要特点
服务器组件 (Server Component) *.server.ts 服务器 零客户端包体 :其代码和依赖库完全不进入客户端的 JavaScript bundle。 直接访问后端资源 :可以像 Node.js 程序一样直接访问数据库、文件系统、内部 API 等。 - 无状态、无交互 :不能使用 useStateuseEffect 等 Hooks,也不能绑定事件监听器。
客户端组件 (Client Component) *.client.ts 服务器 (SSR/SSG) + 客户端 传统的 React 组件 :我们所熟悉的一切,包含状态、生命周期、交互逻辑。 代码被打包 :其代码和依赖项会发送到客户端。 在客户端"水合" (Hydrate):在浏览器中变得可交互。
共享组件 (Shared Component) *.ts 服务器 + 客户端 - 同构组件 :可以在两种环境中运行,但必须遵守双方的约束。- 不能包含特定环境的 API :例如,不能在共享组件中使用 Node.js 的 fs 模块,也不能使用浏览器的 window 对象。

零客户端包体(Zero Client Bundle)

零客户端包体这个名词听起来有点怪,我们拆开来理解:客户端包体指的是需要下载到用户浏览器中执行的JavaScript代码包。而零客户端包体指服务器组件的代码压根不会出现在浏览器里,在构建出来 浏览器 javascript产物中不会包含这部分内容。

从react官网上抄来的一个例子:

按照以前的做法,如果要在要在页面上显示一篇Markdown文章,我们需要这样写代码:

javascript 复制代码
// Post.client.js - 传统方式
import { useState, useEffect } from 'react';
import marked from 'marked'; // 一个 50KB+ 的库

function Post({ postId }) {
  const [markdown, setMarkdown] = useState('');

  useEffect(() => {
    fetch(`/api/posts/${postId}`)
      .then(res => res.text())
      .then(text => setMarkdown(text));
  }, [postId]);

  const html = marked(markdown);
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

看到问题了吗?marked这个50KB+的库会被打包到客户端,用户每次访问都得下载。尽管我们也可以通过 import动态引入

javascript 复制代码
// Post.client.js - 使用动态导入优化
import { useState, useEffect, Suspense, lazy } from 'react';

const MarkdownRenderer = lazy(() => 
  import('./MarkdownRenderer') 
  // MarkdownRenderer.js 内部会 import marked 并导出渲染逻辑
);

function Post({ postId }) {
  const [markdown, setMarkdown] = useState('');

  useEffect(() => {
    // 假设从 API 获取 Markdown 文本
    fetch(`/api/posts/${postId}`)
      .then(res => res.text())
      .then(text => setMarkdown(text));
  }, [postId]);

  return (
    <div>
      {/* 2. 使用 Suspense 包裹,提供加载中的后备 UI */}
      <Suspense fallback={<div>正在加载渲染器...</div>}>
        <MarkdownRenderer content={markdown} />
      </'Suspense'>
    </div>
  );
}

它确实优化了初始加载。marked 这个库不会被打包进主 JS 文件(main.js)里。只有当 Post 组件被渲染时,浏览器才会去下载一个包含 marked 库的、独立的 JavaScript 文件(例如 MarkdownRenderer-chunk.js),然后在浏览器中执行它,最后渲染出 HTML。单实际上其还是需要引入、下载、运行 marked,只是其运行在浏览器中,而不是在服务器中。

明明只是看个文章,为啥要引入、下载、运行Markdown解析器?想象一下,你在服务器组件里引入了一个500KB的Markdown解析库,但用户下载的JS包里完全没有这个库的代码。因为引入、下载、运行工作已经在服务器完成了,浏览器只需要接收最终的HTML结果。是不是很棒!

javascript 复制代码
// Post.server.js - RSC 方式
import { promises as fs } from 'fs'; // 直接访问文件系统
import path from 'path';
import marked from 'marked'; // 这个库只在服务器上运行

async function Post({ postId }) {
  // 直接从数据库或文件系统读取数据,无需 API
  const content = await fs.readFile(path.join(process.cwd(), `posts/${postId}.md`), 'utf8');
  
  // 在服务器上完成转换
  const html = marked(content);
  
  // 渲染结果发送到客户端
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

这样做的好处立竿见影:marked库不会在浏览器中下载和运行,服务器把活儿干完,页面加载快了,特别是那些用了很多大型库但不需要交互的组件,效果立竿见影。

RSC !== SSR

很多人包括我刚开始一听到服务端组件,下意识的将其和SSR联系起来,但是后面经过详细的了解,才发现二者有着本质的区别,虽然都在服务器上运行,但解决的问题和工作方式完全不同。

- 传统SSR React Server Components (RSC)
渲染时机 每次请求时在服务器渲染HTML 构建时或请求时渲染组件树
输出格式 HTML字符串 RSC Payload(特殊的序列化格式)
客户端包体 包含所有组件代码 仅包含客户端组件代码
数据获取 通过API或在渲染前获取 组件内直接访问后端资源
水合过程 整页水合 选择性水合
主要目标 提升首屏渲染速度 减少客户端包体积和优化数据流

RSC解决了什么问题

说实话,从推出到现在,RSC都带来了不少的争议,在社区吵得不可开交,抛开争议不谈,它确实解决了几个问题。

bundle体积包越发膨胀

你有没有遇到过这种情况:项目刚开始的时候bundle才几十上百KB,后面随着业务需求不停的迭代,我们引入UI库、状态管理、工具库等等,随之带来的就是bundle size的膨胀。

当然我们也有一些优化手段,比如代码分割和Tree Shaking,但说实话,这些还是有些局限性:

  • 代码分割 :用React.lazy和动态import()按需加载。听起来不错,但其实只是把下载时间推迟了,浏览器还是需要下载、运行、解析。
  • Tree Shaking:删掉没用的代码。这个确实有用,但问题是很多库你确实在用啊!哪怕只是为了格式化一个日期,整个日期库还是得打包进去。

RSC的思路就很直接:既然这个组件不需要交互,那它就不会分发到浏览器上?

就像前面marked的例子,所有只是用来"算个数、显示个结果"的代码都留在服务器就行了。这样用户下载的JS包就只包含真正需要交互的部分,包体积能小很多。对LCP、TTI这些性能指标的提升是实实在在的。

数据请求瀑布

除了包体积,还有一个更让人头疼的问题:数据请求瀑布。简单来说,在业务开发中,往往会出现数据依赖的问题,组件A要等组件B的数据,组件B要等组件C的数据,串行的请求往往会拖慢页面的加载,让我们来看一个非常经典的例子:

sequenceDiagram participant Browser as 浏览器 participant APIServer as API 服务器 title 场景一:传统客户端渲染 (CSR) 的请求瀑布流 Browser->>APIServer: 1. 请求初始页面 (HTML + JS Bundle) APIServer-->>Browser: 2. 返回应用外壳和 JS 包 note over Browser: 浏览器解析 JS, React 开始渲染 note over Browser: 渲染 组件... rect rgba(255, 223, 223, 0.5) note over Browser: 在 useEffect 中发起 API 请求 Browser->>APIServer: 3. GET /api/user (获取用户数据) APIServer-->>Browser: 4. 返回用户数据 (JSON) end note over Browser: 收到用户数据,React 重新渲染 note over Browser: 渲染 组件... rect rgba(255, 223, 223, 0.5) note over Browser: 在 useEffect 中发起 API 请求 Browser->>APIServer: 5. GET /api/posts?userId=... (获取文章数据) APIServer-->>Browser: 6. 返回文章数据 (JSON) end note over Browser: 收到文章数据,再次重新渲染,页面最终完整显示

在上面的例子中,文章数据必须等用户数据拿到了才能请求,这是传统CSR的问题。当然,也许我们可以使用 Promise.all一次性获取所有数据,但这样父组件就得知道所有子组件要什么数据,组件封装性就被破坏掉了。

RSC就不一样了,因为在服务器上跑,组件可以直接写成async函数,数据获取和渲染合二为一。

javascript 复制代码
// Page.server.js
// 假设这是两个不同的数据获取函数
import { getUser } from '@/lib/data';
import { getPosts } from '@/lib/data';

import UserProfile from './UserProfile.server';
import UserPosts from './UserPosts.server';

export default async function Page({ userId }) {
  // 在服务器上,数据请求可以并行发起
  const userPromise = getUser(userId);
  const postsPromise = getPosts(userId);

  // 等待所有数据返回
  const user = await userPromise;
  const posts = await postsPromise;

  return (
    <div>
      <UserProfile user={user} />
      <UserPosts posts={posts} />
    </div>
  );
}

看到区别了吗?数据获取都在服务器完成,而且可以并行请求,不用等来等去。拿到数据后直接渲染,把结果流式传给浏览器。

sequenceDiagram participant Browser as 浏览器 participant RSCServer as RSC 服务器 (运行 React) participant Backend as 数据库/后端服务 title 场景二:使用 React Server Components (RSC) 后的优化流程 Browser->>RSCServer: 1. 请求页面 rect rgba(223, 255, 223, 0.6) note over RSCServer: 收到请求, 开始渲染 RSC 树 par 并行数据获取 RSCServer->>Backend: 2a. 获取用户数据 Backend-->>RSCServer: 3a. 返回用户数据 and RSCServer->>Backend: 2b. 获取文章数据 Backend-->>RSCServer: 3b. 返回文章数据 end note over RSCServer: 4. 数据全部到达, 在服务器上完成组件渲染 end RSCServer-->>Browser: 5. 流式传输渲染好的 UI 描述 (RSC Payload) note over Browser: 接收数据流并立即渲染 UI, 无需额外 API 请求

前后端分离提高了复杂度

第三个问题就是前后端分离搞得太复杂了。按照传统做法,前端想要个数据都得通过API,这就带来了一堆麻烦事儿:

  • 写不完的样板代码:每个数据需求都得写API端点、路由、请求响应处理。
  • 安全问题:API密钥、数据库凭证这些敏感信息得小心翼翼,一不小心就泄露了。
  • 限制太多:前端想要的数据格式API不支持,有时候还需要前端自己做BFF层面的开发

RSC直接把这层壁垒给打破了。既然在服务器上跑,那就可以像后端代码一样直接连数据库

javascript 复制代码
// UserDashboard.server.js
import db from './lib/db'; // 直接导入数据库客户端实例
import { cache } from 'react'; // React 提供的请求级缓存

// 使用 cache 可以确保在同一次渲染中,对相同参数的调用只执行一次
const getUserData = cache(async (userId) => {
  const user = await db.user.findUnique({ where: { id: userId } });
  const permissions = await db.permissions.findMany({ where: { userId } });
  return { user, permissions };
});

export default async function UserDashboard({ userId }) {
  const { user, permissions } = await getUserData(userId);

  if (!permissions.canViewDashboard) {
    return <p>您没有权限访问此页面。</p>;
  }

  return (
    <div>
      <h1>欢迎, {user.name}!</h1>
      {/* ... 更多仪表盘内容 ... */}
    </div>
  );
}

这样一来,全栈开发就简单多了,一套React代码就可以全部搞定。

什么时候该用RSC

尽管RSC有这么多的好处,但RSC并不是银弹,对于普通的前端开发来说,其带来了很大的开发、部署、运维成本,算算总体收益,贸然上车其实并不太划算。

考虑到这些,我个人认为对于以下的场景,RSC的确是个好武器:

  • 大型数据密集应用:电商、社交媒体、复杂后台这种巨型应用,数据多、交互复杂,RSC的优势能发挥出来。

  • 全栈开发者和小团队:一个人或几个人搞定前后端,使用RSC一套打天下。

  • 性能强迫症团队:追求极致的性能。

如何选择使用哪种组件

在Next.js App Router里,默认所有组件都是服务器组件 。这个思路转变很重要:只有真的需要客户端功能时,才在文件顶部加"use client";

这种"服务器优先"的策略其实挺好的,逼着你把逻辑尽量留在服务器上,性能自然就好了。所以现在"用哪种组件"成了个重要的架构决策。

我总结了个简单原则:能放服务器就放服务器,实在不行了再搬到客户端。

还有个巧妙的用法:客户端组件可以接收服务器组件作为children,比如:

javascript 复制代码
// CommentSection.client.js
'use client';

import { useState } from 'react';
import PostCommentForm from './PostCommentForm';

// 这个客户端组件接收一个服务器组件作为 children
export default function CommentSection({ children }) {
  const [showComments, setShowComments] = useState(true);

  return (
    <div>
      <button onClick={() => setShowComments(!showComments)}>
        {showComments ? '隐藏评论' : '显示评论'}
      </button>
      {showComments && (
        <>
          {children} {/* `children` 是在服务器上渲染好的评论列表 */}
          <PostCommentForm /> {/* 这是一个交互式的表单 */}
        </>
      )}
    </div>
  );
}

// page.server.js
import CommentSection from './CommentSection.client';
import CommentList from './CommentList.server'; // 一个获取并渲染评论列表的 RSC

export default function Page({ postId }) {
  return (
    <article>
      {/* ... 文章内容 ... */}
      <CommentSection>
        {/* 将 RSC 作为 prop 传递给客户端组件 */}
        <CommentList postId={postId} />
      </CommentSection>
    </article>
  );
}

这里CommentList.server.js在服务器上拿数据、渲染评论,然后把结果作为children传给客户端的CommentSection。客户端组件只管显示隐藏的交互,根本不知道评论怎么来的,就像是服务器在客户端组件里"打了个洞",也被称为"Hole Punching"。

大体上,可以使用下面的原则去进行划分:

用服务器组件的情况:

  • 要连数据库的:直接读写数据库、访问文件系统、调用内部API的,肯定是服务器组件。
  • 用大型库但不交互的:比如Markdown解析、代码高亮、数据可视化这些,库很大但用户不需要交互。
  • 纯展示内容:文章内容、产品详情、新闻列表、页头页脚这些,天生就适合服务器组件。
  • 应用骨架:页面布局、顶层组件这些,负责拿全局数据然后传给子组件。

用客户端组件的情况:

  • 需要状态管理 :用了useStateuseEffect相关Hook。
  • 需要进行交互:按钮点击、表单提交、输入框变化这些UI交互。
  • 用浏览器API :访问windowdocumentlocalStorage这些只有浏览器才有的东西。

RSC到底在哪跑

搞清楚了组件怎么选,现在来看看RSC具体在哪运行。这就涉及两个问题:物理上在哪跑,以及在代码架构中的位置。

物理运行环境

服务器组件只在服务器上跑,这个"服务器"可以是:

  • 传统服务器:你自己的云服务器,比如阿里云ECS、腾讯云这些。
  • Serverless函数:AWS Lambda、Vercel Functions这种,来一个请求就启动一个函数实例。
  • 边缘计算:Cloudflare Workers、Vercel Edge Runtime,在全球各地的节点上跑,离用户更近,延迟更低。RSC的流式特性和边缘计算配合得特别好。

关键是,RSC的代码绝对不会跑到用户浏览器里,这就保证了安全性和性能。

代码架构中的位置

RSC在你的代码里画了条服务器-客户端边界,这条线很重要,得遵守规则:

核心规则:

  1. 数据只能从服务器流向客户端

    • 服务器组件可以导入和渲染客户端组件
    • 客户端组件不能 直接import服务器组件(因为服务器组件代码根本不在客户端)
    • 例外 :可以把服务器组件作为children传给客户端组件,这是框架帮你做的"魔法"
  2. 传给客户端的Props必须能序列化

    • 从服务器组件传props给客户端组件时,这些props必须能转成JSON
    • 能传的:字符串、数字、布尔值、对象、数组、Date
    • 不能传的:函数、Class实例等,因为函数代码在服务器上,客户端执行不了

这种硬性规定其实约束了我们进行更清晰的架构设计,长远来看是有助于代码的可维护性。

  • 数据获取层:肯定是服务器组件
  • 交互逻辑层:肯定是客户端组件
  • 展示层:静态的用服务器组件,动态的用客户端组件

虽然多了约束,但长远看对代码维护性是好事。

RSC工作原理

渲染和流式传输的过程

当一个请求到达支持 RSC 的服务器时,会发生以下一系列事件:

  1. 请求进来:用户浏览器请求一个URL

  2. 路由匹配:服务器的路由系统(比如Next.js App Router)找到对应的页面组件,通常是个服务器组件

  3. RSC渲染

    • React在服务器上开始渲染页面组件树
    • 遇到async服务器组件时,会暂停等数据,但可以继续渲染其他分支
    • 生成RSC Payload :这是个特殊的JSON流,不是HTML,而是UI的描述,包含:
      • 渲染好的字符串和HTML标签
      • 客户端组件的"占位符"和要传给它们的props
      • 客户端组件JS文件的引用
  4. 流式传输 :RSC Payload边生成边发送,不等全部完成,浏览器可以尽早开始处理

  5. 浏览器端处理

    • 浏览器接收RSC Payload流
    • React在客户端解析这个流
    • 立即把静态、非交互的部分渲染成DOM,用户马上能看到内容(流式HTML效果,FCP很快)
    • 遇到客户端组件占位符时,检查对应的JS bundle是否已加载
  6. 加载客户端JS :如果客户端组件的JS还没加载,React会在<head>里插入<script>标签去请求,通常是并行的

  7. 水合 :客户端组件JS加载完后,React用真实组件替换占位符,附加事件监听器,让它变得可交互。这是选择性、非阻塞的,不像传统SSR要水合整个页面

  8. 完成:所有可见的客户端组件都水合完毕,页面完全可交互(TTI)

整个流程的本质是: 服务器负责执行 RSC、获取数据、编排 UI 结构,并将一份详细的"渲染说明书"(RSC Payload)流式传输给浏览器;浏览器则根据这份说明书,逐步绘制 UI、按需加载交互逻辑并激活它们。

用下面的流程图来展示整个过程:

graph TD %% 定义样式 classDef server fill:#D5E8D4,stroke:#82B366,stroke-width:2px; classDef client fill:#DAE8FC,stroke:#6C8EBF,stroke-width:2px; classDef decision fill:#FFE6CC,stroke:#D79B00; classDef io fill:#F8CECC,stroke:#B85450; %% 主流程开始 Start(用户发起页面请求) --> A[服务器路由匹配到 RSC 页面]; subgraph 服务器端 A --> B{是 async 组件吗?}; B -- 是 --> C["await 并行获取数据
(例如: 数据库, API)"]; B -- 否 --> D[开始渲染组件树]; C --> D; D --> E[遍历组件树节点]; E --> F{"当前节点是客户端组件吗?
('use client')"}; F -- 否 (是RSC) --> G[在服务器上渲染该组件
生成 UI 描述]; G --> H["将渲染结果(UI描述)推入流 (Stream)"]; H --> I{还有子节点吗?}; I -- 是 --> E; F -- 是 (是Client Component) --> J["跳过渲染, 生成占位符
(包含组件引用和Props)"]; J --> K["将占位符推入流 (Stream)"]; K --> I; I -- 否 --> L[/关闭流, 传输完成/]; end %% 将服务器流连接到浏览器 H -.-> M; K -.-> M; subgraph 浏览器端 M[浏览器接收 RSC Payload 数据流]; M --> N[客户端 React 解析流内容]; N --> O{是静态UI描述还是占位符?}; O -- 静态UI --> P[直接渲染成 DOM
用户快速看到内容]; P --> Q{流是否结束?}; O -- 客户端组件占位符 --> R["[[ 渲染降级UI
(如加载指示器)]]"]; R --> S{该组件的JS加载了吗?}; S -- 是 --> V; S -- 否 --> T[通过