React Server Components 入门

大家好!最近在个人项目里用上了 React Server Components (RSC),觉得这东西有点意思,能让应用更快、更轻。以前 React 组件全在浏览器跑,现在部分移到服务器。今天我就来聊聊 RSC,从基础说起,帮你快速上手。

什么是 React Server Components?

简单说,RSC 是 React 的一种新组件类型,只在服务器上渲染,不发到浏览器。传统 React 组件(Client Components)需要在客户端执行 JavaScript,RSC 则直接输出静态内容,比如 HTML 或 JSX 树,减少了 bundle 大小和客户端负载。

为什么需要 RSC?想象一个博客页面:文章内容从数据库拉,评论列表也从服务器取。如果全用客户端组件,浏览器得下载所有代码再 fetch 数据。RSC 让服务器直接处理这些,浏览器只管交互部分。

RSC 不是 SSR(Server-Side Rendering)的翻版。SSR 是服务器生成完整 HTML,然后浏览器 hydrate(激活)成互动组件。RSC 更灵活,能混用服务器和客户端组件,数据流更高效。
graph TD A[浏览器请求] --> B[服务器渲染 RSC] B --> C[拉取数据: DB/文件] B --> D[输出静态内容 + 客户端组件引用] D --> E[浏览器渲染静态 + hydrate 交互] subgraph 服务器 B C end subgraph 客户端 E end

图注:服务器处理数据,客户端只渲染交互。

RSC vs Client Components:区别在哪?

  • RSC(Server Components):服务器独占。能直接访问数据库、文件系统,不用 API。不能用 state、effect 或事件处理器,因为不跑在浏览器。
  • Client Components:浏览器执行。支持 useState、useEffect、onClick 等互动。文件加 "use client" 指令。

比较起来,RSC 像静态生成器,Client Components 负责动态。RSC 包裹 Client Components,数据从服务器直传。

比如,一个 RSC 页面组件:

jsx 复制代码
import ClientButton from './ClientButton';

async function Page() {
  const data = await fetchDataFromDB(); // 服务器直取数据,从数据库查询数据,无需额外 API 调用,减少网络开销。
  return (
    <div>
      <h1>{data.title}</h1> {/* 静态标题,直接渲染 */}
      <ClientButton /> {/* 客户端组件处理点击,RSC 传递 props 给它 */}
    </div>
  );
}
jsx 复制代码
'use client';

import { useState } from 'react';

export default function ClientButton() {
  const [count, setCount] = useState(0); // 使用状态钩子,浏览器端管理计数
  return <button onClick={() => setCount(count + 1)}>点击 {count}</button>; // 事件处理器,只在浏览器执行
}

这个示例中,Page 是服务器组件,能异步获取数据并渲染静态部分;ClientButton 是客户端组件,处理用户交互。优势:服务器数据直传,避免客户端再 fetch。

下面用 Mermaid 绘制 RSC vs SSR 对比:
graph LR A[SSR] -->|生成全 HTML| B[浏览器 hydrate 全部] C[RSC] -->|组件级输出| D[浏览器只 hydrate 交互部分] subgraph 传统 SSR A B end subgraph RSC C D end

图注:服务器组件更注重组件级渲染,减少 hydrate 开销。

如何工作?渲染流程拆解

RSC 的魔力在"流式渲染"。服务器不是一次性吐出整个页面,而是分块发送。浏览器边收边渲染,减少白屏时间。

流程详解:

  1. 用户请求页面:浏览器发送 GET 请求到服务器。
  2. 服务器渲染 RSC 树:从根组件开始,递归渲染,遇到 async 时等待数据(如 DB 查询)。
  3. 输出 RSC Payload:一种序列化格式(非纯 HTML),包含静态内容和客户端组件边界标记。
  4. 浏览器解析:收到 payload 后,渲染静态 HTML,然后加载 JS hydrate 客户端组件,实现交互。

优势:组件离数据近,避免客户端 API 请求造成的昂贵的客户端-服务器瀑布。传统方式得服务器 API + 客户端 fetch,现在一步到位。流式允许部分组件先渲染,比如头部先显示,而底部数据慢点无妨。

你可以打开一个 nextjs 网站,查看 script,这一堆东西就是从服务端生成发送到客户端的 RSC payload。

示例:服务端预取数据 + 客户端排序

服务器组件负责一次性把数据拉回来,客户端组件接管后续交互。

下面用 ProductList(服务器组件) + SortableTable(客户端组件)演示:服务端把商品数据一次性吐出来,客户端在浏览器里完成点击表头排序。

tsx 复制代码
// 服务器组件
import SortableTable from './SortableTable';

async function ProductList() {
  // 只在服务端跑一次
  const products = await db.products.find().toArray();

  return (
    <section>
      <h2>全部商品({products.length} 件)</h2>
      {/* 把数据一次性塞给客户端组件 */}
      <SortableTable items={products} />
    </section>
  );
}
tsx 复制代码
// 客户端组件
"use client";

import { useState } from 'react';

type Product = { id: number; name: string; price: number; stock: number };

export default function SortableTable({ items }: { items: Product[] }) {
  const [sortKey, setSortKey] = useState<keyof Product>('name');
  const [asc, setAsc] = useState(true);

  const sorted = [...items].sort((a, b) => {
    const valA = a[sortKey];
    const valB = b[sortKey];
    if (typeof valA === 'string') {
      return asc
        ? valA.localeCompare(valB as string)
        : (valB as string).localeCompare(valA);
    }
    return asc ? (valA as number) - (valB as number) : (valB as number) - (valA as number);
  });

  const handleSort = (key: keyof Product) => {
    if (key === sortKey) setAsc((v) => !v);
    else {
      setSortKey(key);
      setAsc(true);
    }
  };

  return (
    <table>
      <thead>
        <tr>
          <th onClick={() => handleSort('name')}>名称 {sortKey === 'name' && (asc ? '▲' : '▼')}</th>
          <th onClick={() => handleSort('price')}>单价 {sortKey === 'price' && (asc ? '▲' : '▼')}</th>
          <th onClick={() => handleSort('stock')}>库存 {sortKey === 'stock' && (asc ? '▲' : '▼')}</th>
        </tr>
      </thead>
      <tbody>
        {sorted.map((p) => (
          <tr key={p.id}>
            <td>{p.name}</td>
            <td>¥{p.price}</td>
            <td>{p.stock}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

打包阶段,框架会给 SortableTable 单独打一份客户端 bundle;浏览器收到 HTML 后, hydrate 时由这份 bundle 接管排序逻辑。这样既享受了服务端直出首屏的秒开速度,又保留了交互。

示例:客户端组件使用 use 等待服务器组件数据获取

核心思路:

  1. 服务器先渲染关键内容(商品详情),立即 flush 给浏览器,首屏不等待。
  2. 对非关键区块(推荐列表)只"扔"一个 Promise 下去,浏览器收到后自己决定什么时候等它。
  3. 客户端用 use 把同一个 Promise 捡起来,复用服务器已经启动的查询,既不会重复请求,也不会阻塞首屏。
tsx 复制代码
// ProductDetail.server.tsx
import db from '~/lib/db';
import { Suspense } from 'react';
import RelatedProducts from './RelatedProducts.client';

export default async function ProductDetail({ sku }: { sku: string }) {
  // 关键数据:必须有了才能继续,服务器直出
  const product = await db.product.one(sku);

  // 非关键数据:只启动查询,不 await,把 Promise 直接丢给客户端
  const relatedPromise = db.product.related(sku, { limit: 8 });

  return (
    <article>
      <h1>{product.name}</h1>
      <p className="price">¥{product.price}</p>

      {/* 首屏骨架先过去,数据随后流式填充 */}
      <Suspense fallback={<section className="related-skeleton">加载推荐...</section>}>
        <RelatedProducts productsPromise={relatedPromise} />
      </Suspense>
    </article>
  );
}
tsx 复制代码
// RelatedProducts.client.tsx
"use client";

import { use } from "react";

interface Product {
  id: string;
  name: string;
  price: number;
}

export default function RelatedProducts({ productsPromise }: {
  productsPromise: Promise<Product[]>;
}) {
  // 复用服务器创建的同一个 Promise,真正渲染前暂停
  const list = use(productsPromise);

  return (
    <section className="related">
      <h2>看了又看</h2>
      <ul>
        {list.map((p) => (
          <li key={p.id}>
            <span>{p.name}</span>
            <strong>¥{p.price}</strong>
          </li>
        ))}
      </ul>
    </section>
  );
}

React 首先渲染 "关键路径 "上的内容,即带价格的完整 HTML,而不会等待 Suspense 中的异步组件完成数据获取,这是第一个数据块。 然后,服务器将把这个数据块发送到客户端,并在等待 Suspense 组件的过程中保持连接打开,即等待 Promise resolve。 在 RelatedProducts 数据完成后,其 Suspense 边界被解决,另一个数据块就准备好了,并发送到客户端。 消息也是如此。总结一下:

  • 浏览器快速收到带价格的完整 HTML,立即可见。
  • 推荐区域先占位"骨架",同一套 TCP 连接里后续流式补全,无需额外往返。
  • 客户端组件通过 use 暂停渲染,复用服务端 Promise。

优势与注意事项

  • 性能:JavaScript捆绑包中不会包含服务端组件,从而减少了JavaScript的大小。少发 JS,数据预取,流式加载。LCP、可交互耗时减少。不必反复调用所有这些服务器组件函数并将其返回值转换为树,从而减少编译和执行JavaScript所需的时间。
  • 安全:敏感代码(如 DB 密钥)留在服务器,不暴露给浏览器。
  • SEO友好:静态内容易被搜索引擎爬取。

但注意:RSC 不可变,不能有 state(否则用客户端组件)。导入客户端组件时,必须加 "use client"。调试时,检查服务器日志和浏览器 console。遇到异步错误,用 try-catch 包裹。

tsx 复制代码
const BlogPost = async ({ id }) => {
  try {
    const post = await db.posts.get(id);
    return <div>{post.title}</div>;
  } catch (error) {
    console.error("Error fetching post:", error);
    return <div>Something went wrong. Please try again later.</div>;
  }
};

结语

RSC 让 React 更贴近全栈,服务器和客户端无缝协作。个人认为 RSC 提供了更高的性能上限,但是对开发者的要求更高了,它也不适合大多数项目。对于产品来说,性能常常不是第一优先级的。国内还是 SPA 应用主流,哈哈,有空可以玩玩 Next.js。

相关推荐
chszs1 年前
从React服务器组件(RSC)反思Jakarta Faces技术
前端·ssr·rsc·jakarta faces·jsf
封印师请假去地球钓鱼2 年前
Academic Inquiry|投稿状态分享(ACS,Wiley,RSC,Elsevier,MDPI,Springer Nature出版社)
acs·学术期刊投稿·wiley·rsc·elsevier·spring nature