×

从零开始理解React Server Components

hqy hqy 发表于2025-12-07 22:36:41 浏览13 评论0

抢沙发发表评论

从零开始理解React Server Components

从零开始理解React Server Components


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

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

虽然现在RemixWaku这些框架也在支持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 等。
- 无状态、无交互:不能使用 useState、useEffect 等 Hooks,也不能绑定事件监听器。
客户端组件 (Client Component)*.client.ts服务器 (SSR/SSG) + 客户端传统的 React 组件:我们所熟悉的一切,包含状态、生命周期、交互逻辑。
代码被打包:其代码和依赖项会发送到客户端。
在客户端“水合” (Hydrate):在浏览器中变得可交互。
共享组件 (Shared Component)*.ts服务器 + 客户端- 同构组件:可以在两种环境中运行,但必须遵守双方的约束。- 不能包含特定环境的 API:例如,不能在共享组件中使用 Node.js 的 fs 模块,也不能使用浏览器的 window 对象。

零客户端包体(Zero Client Bundle)

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

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

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

// 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动态引入

// 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结果。是不是很棒!

// 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联系起来,但是后面经过详细的了解,才发现二者有着本质的区别,虽然都在服务器上运行,但解决的问题和工作方式完全不同。

-传统SSRReact 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: 渲染 <UserProfile> 组件...

    rect rgba(255, 223, 223, 0.5)
        note over Browser: <UserProfile> 在 useEffect 中发起 API 请求
        Browser->>APIServer: 3. GET /api/user (获取用户数据)
        APIServer-->>Browser: 4. 返回用户数据 (JSON)
    end
    note over Browser: 收到用户数据,React 重新渲染
    note over Browser: 渲染 <UserPosts> 组件...

    rect rgba(255, 223, 223, 0.5)
        note over Browser: <UserPosts> 在 useEffect 中发起 API 请求
        Browser->>APIServer: 5. GET /api/posts?userId=... (获取文章数据)
        APIServer-->>Browser: 6. 返回文章数据 (JSON)
    end

    note over Browser: 收到文章数据,再次重新渲染,页面最终完整显示

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

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

// 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直接把这层壁垒给打破了。既然在服务器上跑,那就可以像后端代码一样直接连数据库

// UserDashboard.server.jsimport 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,比如:

// CommentSection.client.js'use client';import { useState } from 'react';import PostCommentForm from './PostCommentForm';// 这个客户端组件接收一个服务器组件作为 childrenexport default function CommentSection({ children }) {
  const [showComments, setShowComments] = useState(true);

  return (
    <div>
      <button onClick={() => setShowComments(!showComments)}>
        {showComments ? '隐藏评论' : '显示评论'}
      </button>
      {showComments && (
        <>
          {children} {/* `children` 是在服务器上渲染好的评论列表 */}
          <PostCommentForm /> {/* 这是一个交互式的表单 */}
        </>
      )}
    </div>
  );}// page.server.jsimport CommentSection from './CommentSection.client';import CommentList from './CommentList.server'; // 一个获取并渲染评论列表的 RSCexport 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. 数据只能从服务器流向客户端

  2. 服务器组件可以导入和渲染客户端组件

  3. 客户端组件不能直接import服务器组件(因为服务器组件代码根本不在客户端)

  4. 例外:可以把服务器组件作为children传给客户端组件,这是框架帮你做的"魔法"

  5. 传给客户端的Props必须能序列化

  6. 从服务器组件传props给客户端组件时,这些props必须能转成JSON

  7. 能传的:字符串、数字、布尔值、对象、数组、Date

  8. 不能传的:函数、Class实例等,因为函数代码在服务器上,客户端执行不了

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

  • 数据获取层:肯定是服务器组件

  • 交互逻辑层:肯定是客户端组件

  • 展示层:静态的用服务器组件,动态的用客户端组件

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

RSC工作原理

渲染和流式传输的过程

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

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

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

  3. RSC渲染

  4. React在服务器上开始渲染页面组件树

  5. 遇到async服务器组件时,会暂停等数据,但可以继续渲染其他分支

  6. 生成RSC Payload:这是个特殊的JSON流,不是HTML,而是UI的描述,包含:

  • 渲染好的字符串和HTML标签

  • 客户端组件的"占位符"和要传给它们的props

  • 客户端组件JS文件的引用

  1. 流式传输:RSC Payload边生成边发送,不等全部完成,浏览器可以尽早开始处理

  2. 浏览器端处理

  3. 浏览器接收RSC Payload流

  4. React在客户端解析这个流

  5. 立即把静态、非交互的部分渲染成DOM,用户马上能看到内容(流式HTML效果,FCP很快)

  6. 遇到客户端组件占位符时,检查对应的JS bundle是否已加载

  7. 加载客户端JS:如果客户端组件的JS还没加载,React会在<head>里插入<script>标签去请求,通常是并行的

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

  9. 完成:所有可见的客户端组件都水合完毕,页面完全可交互(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 并行获取数据<br/>(例如: 数据库, API)"];
        B -- 否 --> D[开始渲染组件树];
        C --> D;

        D --> E[遍历组件树节点];
        E --> F{"当前节点是客户端组件吗?<br>('use client')"};

        F -- 否 (是RSC) --> G[在服务器上渲染该组件<br>生成 UI 描述];
        G --> H["将渲染结果(UI描述)推入流 (Stream)"];
        H --> I{还有子节点吗?};
        I -- 是 --> E;

        F -- 是 (是Client Component) --> J["跳过渲染, 生成占位符<br>(包含组件引用和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<br>用户快速看到内容];
        P --> Q{流是否结束?};

        O -- 客户端组件占位符 --> R["[[<Suspense> 渲染降级UI<br>(如加载指示器)]]"];
        R --> S{该组件的JS加载了吗?};
        S -- 是 --> V;
        S -- 否 --> T[通过 <script> 标签请求JS包];
        T --> U[等待JS下载并执行];
        U --> V;

        V["[客户端 React '水合'(Hydrate)组件<br>(附加状态和事件)]"];
        V --> Q;

        Q -- 否 --> N;
        Q -- 是 --> End(页面完全可交互);
    end

    %% 应用样式
    class Start,End,A,B,C,D,E,F,G,I,J,L server;
    class H,K io;
    class M,N,O,P,Q,R,S,T,U,V client;
    class End client;
    class B,F,I,O,Q,S decision;

实战:用Next.js写RSC

说了这么多理论,来看个具体例子。下面是个博客文章页面,展示RSC怎么用:

app/ 
├── posts/ 
│   └── [slug]/ 
│       └── page.tsx # 动态路由的服务器组件 (RSC) 
└── components/ 
    └── LikeButton.tsx # 可交互的客户端组件
// app/posts/[slug]/page.tsx

import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import LikeButton from '@/app/components/LikeButton'; // 导入客户端组件

// 为文章数据定义一个类型接口
interface Post {
  id: number;
  title: string;
  body: string;
}

// 动态生成页面元数据 (SEO) - 在服务器上运行
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const post = await getPost(params.slug);

  return {
    title: post?.title || '文章未找到',
    description: post?.body.substring(0, 160) || '这是一个博客文章页面',
  };
}

// 封装的数据获取函数
// Next.js 会自动缓存 fetch 请求,除非特殊指定
async function getPost(slug: string): Promise<Post | null> {
  try {
    const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${slug}`, {
      // 增量静态再生 (ISR): 每小时重新验证一次数据
      next: { revalidate: 3600 }, 
    });

    if (!res.ok) {
      return null;
    }

    return res.json();
  } catch (error) {
    console.error('获取文章失败:', error);
    return null;
  }
}

// 页面主组件,它是一个异步的 RSC
export default async function PostPage({ params }: { params: { slug: string } }) {
  // 1. 在服务器上直接获取数据
  const post = await getPost(params.slug);

  // 2. 如果数据不存在,显示 404 页面
  if (!post) {
    notFound();
  }

  // 模拟一个初始的点赞数
  const initialLikes = Math.floor(Math.random() * 100);

  // 3. 渲染静态内容,并将数据通过 props 传递给客户端组件
  return (
    <article className="max-w-4xl p-4 mx-auto prose lg:prose-xl">
      <h1>{post.title}</h1>
      <p className="text-slate-600">文章 ID: {post.id}</p>

      <p className="text-lg leading-relaxed">{post.body}</p>

      <hr className="my-8" />

      <div className="flex items-center">
        {/* LikeButton 是客户端组件,负责交互 */}
        <LikeButton postId={post.id} initialLikes={initialLikes} />
      </div>
    </article>
  );
}

写在最后

RSC算是React生态的一次大变革,不只是个新功能,更像是对Web应用架构的重新思考,随着React 19发布和更多框架支持RSC,个人觉得它会成为构建高性能Web应用的标准做法。当然需要注意的是:RSC是个强大的工具,但RSC不是银弹,它有自己的适用场景和局限性。我们需要理解它适合什么场景,合理设计组件架构,在实践中不断优化。


打赏

本文链接:https://kinber.cn/post/6028.html 转载需授权!

分享到:


推荐本站淘宝优惠价购买喜欢的宝贝:

image.png

 您阅读本篇文章共花了: 

群贤毕至

访客