React 服务端组件(RSC):从入门到原理的全面解析

620 阅读6分钟

React 服务端组件(RSC):从入门到原理的全面解析

React 正在经历一场自 Hooks 诞生以来最深刻的范式变革。这场变革的核心,就是 React 服务端组件(React Server Components, RSC)。它不仅仅是一项新功能,更是一种全新的应用构建方式,旨在将服务端渲染(SSR)和客户端渲染(CSR)的优点无缝地融合在同一个组件模型中。

本文将结合 Dan Abramov 的多篇深度解析、Parcel 的技术实现以及 React 官方文档,为您彻底讲透 RSC 的是什么、怎么用,以及它为什么是这样工作的。

目录

  1. 核心思想:前端开发的“范式转移”
  2. "use client":划分客户端与服务端的边界
  3. 如何使用:一个实战演练
    • 创建默认的服务器组件
    • 创建交互式的客户端组件
    • 组件导入规则:跨越边界的桥梁
    • 无缝组合与“打洞”模式
  4. 工作原理:打包工具与 React 的深度集成
    • 构建时的双环境图谱
    • 渲染时的流式传输与 RSC 载荷(Payload)
  5. 总结:RSC 带来的核心优势

核心思想:前端开发的“范式转移”

在理解 RSC 之前,我们先回顾一下传统模式。过去,我们的 React 组件要么完全在客户端运行(CSR),要么在服务器上生成 HTML 后在客户端“注水”(SSR)。但无论如何,几乎所有组件的 JavaScript 代码最终都会被打包发送到浏览器

RSC 彻底颠覆了这一点。在新的模型中:

默认情况下,所有 React 组件都是在服务器上运行的服务器组件。

这是一个根本性的转变。这意味着,除非你特别指定,否则你的组件代码将只存在于服务器,永远不会被打包进客户端的 JavaScript 文件中。

我们可以用几个类比来理解这个概念:

  • 对于 Astro 开发者: 服务器组件就像 .astro 文件。它默认在服务器上运行,生成静态内容,并且不向客户端发送任何 JavaScript。它的主要职责是布局和获取数据。
  • 对于 Lisp 开发者: 服务器组件就像 Lisp 中的“宏”(Macro)。它在“编译期”(即服务器构建或请求时)运行,其工作是“展开”成一个更基础的 UI 描述,而不是直接生成可执行代码。它本身不会进入“运行期”(即浏览器)环境。

因此,服务器组件非常适合执行那些只应在服务器上进行的操作,例如:

  • 直接访问数据库或微服务。
  • 读取本地文件系统 (fs)。
  • 使用敏感的 API 密钥。
  • 处理大量依赖库,而无需增加客户端的负担。

"use client":划分客户端与服务端的边界

如果所有组件默认都在服务器上,那么交互性(如 onClick 事件、useState 状态)如何实现呢?答案是 客户端组件(Client Components),而划分它们的边界就是 "use client" 这个指令。

很多人对 "use client" 的最大误解是:“这个组件只在客户端渲染”。这是不准确的。

"use client" 的真正含义是:

“在这里划定一个边界。从这个文件开始,以及它导入的所有模块,都属于客户端 JavaScript 包的一部分。”

"use client" 是从服务器环境进入客户端环境的入口。它就像一座桥,连接了两个截然不同的执行环境。

一个带有 "use client" 的组件依然会在服务器上进行初始渲染(SSR),生成 HTML。然后,它的 JavaScript 代码会被发送到浏览器,在客户端完成“注水”(Hydration),从而变得可交互。这与 Next.js pages 目录或 Remix 的工作方式非常相似。

类型用途是否能使用 Hooks (useState, useEffect)是否能访问后端资源JavaScript 是否发送到客户端
服务器组件 (默认)数据获取、访问后端、静态内容
客户端组件 ("use client")交互性、状态管理、浏览器 API

如何使用:一个实战演练

下面我们以 Next.js App Router 为例,看看 RSC 在实践中如何工作。

1. 创建默认的服务器组件

app/page.js 默认是服务器组件,可以直接在其中执行服务器端操作。

// app/services/post-service.js (仅服务器端代码)
import fs from 'fs/promises';
export const getPost = async (slug) => {
  const content = await fs.readFile(`./posts/${slug}.md`, 'utf8');
  return { content };
};

// app/posts/[slug]/page.js (这是一个服务器组件)
import { getPost } from '@/app/services/post-service';

export default async function PostPage({ params }) {
  const post = await getPost(params.slug);
  return (
    <article>
      <h1>文章详情</h1>
      <p>{post.content}</p>
    </article>
  );
}

2. 创建交互式的客户端组件

交互式组件必须标记为 "use client"

// app/components/LikeButton.js
'use client'; // <-- 关键指令!

import { useState } from 'react';

export default function LikeButton({ initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
  // ... 其他交互逻辑 ...
  return <button onClick={() => setLikes(likes + 1)}>👍 点赞 ({likes})</button>;
}

3. 组件导入规则:跨越边界的桥梁

在 RSC 中,import 语句的行为取决于你从哪里导入,以及要导入到哪里。

  • ✅ 允许: 服务器组件 import 客户端组件 这是最常见的模式。服务器组件作为应用的骨架,可以自由地引用和渲染客户端组件“孤岛”。当服务器渲染时,它会识别出这是一个客户端组件,并按我们稍后将讨论的特殊方式处理它。

  • ❌ 禁止: 客户端组件 import 服务器组件 这是绝对不允许的。原因很简单:服务器组件的代码可能包含只有服务器才能运行的逻辑(如访问数据库、文件系统)。这些代码根本不存在于浏览器环境中。在客户端组件中 import 一个服务器组件,就如同试图在浏览器中运行 import fs from 'fs',这在概念上就是错误的。

那么,如果你想在一个客户端组件(如一个布局或弹窗)内部显示一些由服务器组件渲染的内容,该怎么办呢?这就引出了 RSC 最优雅的设计之一。

4. 无缝组合与“打洞”模式

如果你不能 import,那就通过 props 传递。

RSC 最强大的模式是将服务器组件作为 children prop 传递给客户端组件。这允许你在一个交互式的“外壳”(客户端组件)中,渲染一个完全静态的、零客户端 JS 的“内容”(服务器组件)。

// app/components/Modal.js (客户端组件)
'use-client';

import { useState } from 'react';

export default function Modal({ children, buttonText }) {
  const [isOpen, setIsOpen] = useState(false);
  
  // Modal 组件自身不知道 children 是什么,它只负责提供一个“洞”
  return (
    <>
      <button onClick={() => setIsOpen(true)}>{buttonText}</button>
      {isOpen && (
        <div className="modal-content">
          <button onClick={() => setIsOpen(false)}>关闭</button>
          {children} {/* <-- 这个“洞”由服务器预先填充 */}
        </div>
      )}
    </>
  );
}

// app/components/ServerInfo.js (服务器组件)
export default async function ServerInfo() {
  const serverTime = new Date().toLocaleTimeString();
  return <p>当前服务器时间: {serverTime}</p>;
}

// app/page.js (服务器组件)
import Modal from '@/app/components/Modal';
import ServerInfo from '@/app/components/ServerInfo';

export default function HomePage() {
  return (
    <div>
      <Modal buttonText="显示服务器信息">
        {/*
          这里是魔法发生的地方:
          1. ServerInfo 是一个服务器组件。
          2. 它在服务器上被完全渲染。
          3. 它的渲染结果被传递给 Modal 组件的 children prop。
          4. Modal 组件在客户端运行时,只是简单地把这个已经渲染好的内容放在正确的位置。
        */}
        <ServerInfo />
      </Modal>
    </div>
  );
}

Modal 组件本身是交互式的,但它内部的 ServerInfo 组件及其逻辑完全保留在服务器上。这种模式被称为“打洞”(Hole Punching),它完美地解决了客户端不能直接引用服务器组件的问题。

工作原理:打包工具与 React 的深度集成

RSC 并非单纯的 React 库功能,它是一个需要与打包工具(如 Webpack, Parcel, Turbopack)深度集成的规范

1. 构建时的双环境图谱

打包工具会构建两个独立的模块依赖图:

  1. 服务器图谱:包含应用中的所有组件,包括服务器组件和客户端组件。
  2. 客户端图谱:只包含以 "use client" 为入口的模块及其依赖。

通过这个过程,打包工具能精确地知道:

  • 哪些代码(如数据库客户端)只应存在于服务器。
  • 哪些代码(如 useState 和事件处理器)需要被打包发送到客户端。

2. 渲染时的流式传输与 RSC 载荷(Payload)

这是理解 RSC 运作方式的核心。当一个请求到达时,React 在服务器上渲染组件,但它不直接生成 HTML。相反,它生成一种特殊的、可流式传输的 UI 描述格式,我们称之为 RSC 载荷 (RSC Payload)

这个载荷是一种指令集,告诉客户端的 React 如何逐步构建和更新 UI。它为什么不直接用 HTML?因为 HTML 无法承载足够的信息,比如:

  • 组件的边界在哪里。
  • 哪个组件是客户端组件,需要加载哪个 JS 文件。
  • 传递给客户端组件的非字符串 props。

RSC 载荷解决了这些问题。让我们看看它里面有什么:

  • 对于服务器组件:载荷包含其渲染结果的 VDOM-like 描述。这可以看作是“序列化”的 JSX,而不是最终的 HTML 标签。

    // 伪代码,表示一个服务器组件的输出
    ['div', { className: 'prose' },  ['h1', {}, '文章标题'],
      ['p', {}, '这是段落内容...']
    ]
    
  • 对于客户端组件:载荷不包含它的渲染结果,而是包含一个占位符引用。这个引用告诉客户端 React:“嘿,这里应该渲染一个客户端组件。”

    // 伪代码,表示一个客户端组件的占位符
    {
      "$$id": "1", // 对应需要加载的 JS chunk ID
      "$$async": true,
      "name": "LikeButton", // 导出的组件名
      "chunks": ["/static/chunks/LikeButton.js"], // JS 文件路径
      "props": { // 可序列化的 props
        "initialLikes": 10
      }
    }
    
  • 对于“打洞”模式:当服务器组件作为 children 传递给客户端组件时,RSC 载荷会同时包含这两部分信息。

    // 伪代码,表示 <Modal><ServerInfo/></Modal> 的部分载荷
    {
      "$$id": "2",
      "name": "Modal",
      "chunks": ["/static/chunks/Modal.js"],
      "props": {
        "buttonText": "显示服务器信息",
        "children": [ // children 的内容是预先渲染好的服务器组件 VDOM
          ['p', {}, '当前服务器时间: 10:30:00 PM']
        ]
      }
    }
    

整个流程是流式的。浏览器接收到载荷后,可以立即开始渲染服务器组件的静态部分。当它遇到客户端组件的占位符时,它会异步加载对应的 JS 文件。一旦脚本加载完成,React 就会在客户端完成该组件的渲染和注水,使其变得可交互。

总结:RSC 带来的核心优势

  • 极致的包体积优化:默认零客户端 JS。只有标记为 "use client" 的交互部分才会增加包体积。
  • 简化的数据获取async/await 直接在组件中使用,代码更直观、更内聚,无需 useEffect 和复杂的客户端状态管理。
  • 避免数据获取瀑布流:在服务器端并行获取数据,提高初始加载性能。
  • 自动代码分割:基于交互边界("use client")进行更精细、更有效的代码分割。
  • 更安全的后端访问:敏感数据和逻辑天然地保留在服务器上,杜绝了泄露风险。
  • 统一的开发体验:尽管底层机制复杂,但通过巧妙的 import 规则和 children prop 模式,RSC 提供了一个强大且符合直觉的统一组件模型。

React 服务端组件代表了 React 的未来。通过将服务器和客户端的优势整合进一个统一的模型,它为构建更快、更轻、更强大的 Web 应用开辟了全新的可能性。

参考文献说明

在撰写本文的过程中,我深受overreacted.io网站内文章的启发与指导。该网站由Dan Abramov(React核心团队成员之一)维护,内容涵盖React、JavaScript以及相关前端技术的深入见解与实践经验。