React 基础体系 · 第 21/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Server Components:服务端边界、序列化、缓存和客户端交互

React Server Components(RSC,服务端组件)是一种组件执行模型:组件函数在服务端执行,结果以 React 能理解的组件数据流发送给客户端。它与传统 SSR(Server-Side Rendering,服务端渲染)不是同一个概念:

  • SSR 解决的是“如何先生成 HTML”。
  • RSC 解决的是“哪些组件、哪些依赖和哪些数据访问逻辑只在服务端执行”。
  • 一个应用可以同时使用 RSC、SSR、客户端组件、流式渲染和 Suspense。

在 React 19 时代,RSC 的底层协议和构建支持仍然主要由框架负责。React 官方提供 RSC 模型和相关运行时能力,Next.js App Router 是最常见的工程化实现之一。不能把 RSC 当成仅靠 react 包、在任意静态 HTML 项目中直接打开的单一开关。


一、RSC 到底改变了什么

1. 组件不再只有“浏览器执行”这一种归属

传统 React 应用通常将组件代码打包后发送到浏览器:

function ProductList() {
  const [items, setItems] = useState([]);
  // 浏览器执行
}

RSC 将组件划分为两类:

  1. Server Component,服务端组件

    • 在服务端渲染。
    • 可以直接访问数据库、文件系统或服务端 SDK。
    • 不会作为组件代码发送到浏览器。
    • 不能使用浏览器专属 API。
    • 不能直接使用客户端状态和事件处理器,例如 useStateonClick
  2. Client Component,客户端组件

    • 代码会进入浏览器构建产物。
    • 可以使用 useStateuseEffect、事件处理器和浏览器 API。
    • 可以响应用户输入。
    • 其初始内容可能先由服务端生成 HTML,但最终仍需在浏览器中加载并执行客户端 JavaScript。

在 Next.js App Router 中,未声明 'use client' 的组件默认是服务端组件:

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await loadProducts();

  return (
    <main>
      <h1>商品</h1>
      <ProductList products={products} />
    </main>
  );
}

这里的 ProductsPage 可以直接等待服务端数据。它不是在浏览器中执行 loadProducts(),也不会把 loadProducts 的实现发送给浏览器。


二、'use client' 是边界声明,不是普通函数调用

'use client' 必须出现在模块顶部:

// app/products/ProductFilters.tsx
'use client';

import { useState } from 'react';

export function ProductFilters() {
  const [keyword, setKeyword] = useState('');

  return (
    <label>
      关键词
      <input
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />
    </label>
  );
}

它的实际含义是:该文件成为客户端模块图的入口。从这个入口继续静态导入的模块,通常也会被纳入客户端构建。

// app/products/ProductFilters.tsx
'use client';

import { formatPrice } from './formatPrice';
import { db } from '@/server/db'; // 错误边界

export function ProductFilters() {
  // ...
}

如果 formatPrice.ts 又导入了服务端数据库模块,客户端构建可能失败;即使某些构建工具没有立即报错,也说明模块边界已经设计错误。

可以使用 server-only 显式保护服务端模块:

// src/server/products.ts
import 'server-only';

import { db } from './db';

export async function loadProducts(keyword?: string) {
  return db.product.findMany({
    where: keyword
      ? { name: { contains: keyword, mode: 'insensitive' } }
      : undefined,
    orderBy: { createdAt: 'desc' },
  });
}

这样,如果客户端模块错误地导入 loadProducts,构建阶段应当直接失败,而不是等待生产环境暴露问题。

服务端组件可以导入客户端组件,但反过来不成立

// app/products/page.tsx
import { ProductFilters } from './ProductFilters';

export default async function ProductsPage() {
  const products = await loadProducts();

  return (
    <>
      <ProductFilters />
      <ProductList products={products} />
    </>
  );
}

依赖关系是:

flowchart TD
    Page["Server Component: ProductsPage"] --> Data["Server data access"]
    Page --> Client["Client Component: ProductFilters"]
    Page --> List["Server Component: ProductList"]
    Client --> Browser["Browser state and events"]

ProductsPage 在服务端执行,ProductFilters 的客户端代码被发送到浏览器。服务端组件可以把客户端组件放进自己的输出树中,但客户端组件不能直接导入服务端组件模块。


三、RSC 与 SSR 的区别:HTML、组件数据和 JavaScript 是三种不同产物

一次现代 React 页面请求可能涉及三种产物:

  1. HTML

    用于首屏结构和初始显示。

  2. RSC Payload

    也称 React Server Component Payload。它描述服务端组件执行后的结果、客户端组件引用及其 props、服务端组件树中的插槽关系等。

  3. 客户端 JavaScript

    用于执行客户端组件、注册事件、恢复客户端状态和完成交互。

SSR 的核心结果是 HTML:

服务端组件树
    ↓
HTML 字符串
    ↓
浏览器首次显示

RSC 的核心结果是组件数据流:

服务端组件树
    ↓
RSC Payload
    ↓
客户端 React 根据 Payload 组装组件树

在 Next.js 中,这些过程通常组合发生:

sequenceDiagram
    participant B as 浏览器
    participant F as 框架服务端
    participant D as 数据源

    B->>F: 请求 /products
    F->>D: 查询商品
    D-->>F: 商品数据
    F-->>B: HTML + RSC Payload + Client JS 引用
    B->>B: 显示 HTML
    B->>B: 加载并执行客户端组件
    B->>B: 建立事件和状态

因此,“服务端组件不会发送到浏览器”不等于“页面没有 JavaScript”。服务端组件本身的实现不会发送,但它引用的客户端组件及其依赖仍然会发送。

同样,“使用 RSC”也不等于“没有 SSR”。SSR 负责 HTML,RSC 负责组件执行边界和组件数据传输,两者解决的问题不同。


四、服务端组件的执行模型

可以把一次服务端渲染抽象为:

R=F(T,P,D,E)R = F(T, P, D, E)

其中:

  • TT:组件树和模块代码;
  • PP:路由参数、查询参数及组件 props;
  • DD:服务端读取的数据;
  • EE:执行环境,例如用户身份、请求头、运行时配置;
  • RR:服务端产生的 RSC 结果。

服务端组件不能把任意运行时对象原样传给浏览器,因此在服务端和客户端之间存在一个序列化边界:

PclientSerializableValuesP_{\text{client}} \in \text{SerializableValues}

这不是简单的“调用 JSON.stringify”。RSC 有自己的 Flight 数据格式,框架还会对可传值范围施加限制。工程上必须遵循一个更保守的规则:

传给客户端组件的 props 应当是可被 React/框架支持的可序列化值;普通服务端函数、数据库连接、请求对象、类实例和秘密值不应直接跨边界传递。

例如下面的写法是错误的:

// Server Component
import { ProductFilters } from './ProductFilters';

export default function Page() {
  async function searchProducts(keyword: string) {
    // 服务端函数
  }

  return <ProductFilters onSearch={searchProducts} />;
}

普通函数不能直接作为客户端事件处理器从服务端传过去。客户端需要的是浏览器中可调用的函数,而 searchProducts 的实现只存在于服务端。

可以传递什么

具体支持范围应以 React 和框架版本为准。通常安全的基础值包括:

type SafeProps = {
  id: string;
  count: number;
  enabled: boolean;
  missing: null;
  items: Array<{ id: string; name: string }>;
};

React 19 的 RSC 支持还涉及某些特殊值和服务端函数引用,但框架可能进一步限制它们。特别需要避免以下值:

type UnsafeProps = {
  dbConnection: unknown;
  request: Request;
  userClassInstance: User;
  callback: () => void;
  secret: string;
};

其中:

  • dbConnection 不是给浏览器使用的对象;
  • request 可能包含服务端请求上下文;
  • User 类实例可能丢失原型和方法;
  • 普通函数没有客户端可执行入口;
  • secret 即使“只是一个字符串”,也会被发送给浏览器。

反例:看似安全的对象实际上泄露了数据

const user = await db.user.findUnique({
  where: { id: userId },
});

return <UserCard user={user} />;

数据库返回对象可能包含:

{
  id: 'u1',
  name: 'Alice',
  email: 'alice@example.com',
  passwordHash: '...',
  internalRole: 'billing-admin'
}

即使 UserCard 只读取 name,完整对象也可能进入 RSC Payload。应在服务端边界显式投影:

const user = await db.user.findUnique({
  where: { id: userId },
  select: {
    id: true,
    name: true,
    avatarUrl: true,
  },
});

return <UserCard user={user} />;

序列化边界不仅是类型问题,也是数据泄露边界。


五、Server Component 与 Client Component 的组合方式

1. 服务端数据直接传给客户端组件

// app/products/page.tsx
import { ProductFilters } from './ProductFilters';

export default async function ProductsPage() {
  const products = await loadProducts();

  return (
    <main>
      <ProductFilters initialProducts={products} />
    </main>
  );
}

这种方式简单,但如果商品数量很大,所有商品都会作为 props 进入客户端数据流,并可能增加网络传输和客户端内存。客户端组件应只接收交互所需的最小数据。

2. 通过 children 保留服务端子树

更适合“客户端布局负责交互、服务端内容负责数据”的场景:

// app/products/ProductPanel.tsx
'use client';

import { useState } from 'react';

export function ProductPanel({
  children,
}: {
  children: React.ReactNode;
}) {
  const [compact, setCompact] = useState(false);

  return (
    <section className={compact ? 'compact' : 'comfortable'}>
      <button onClick={() => setCompact((value) => !value)}>
        切换密度
      </button>
      {children}
    </section>
  );
}
// app/products/page.tsx
import { ProductPanel } from './ProductPanel';

export default async function ProductsPage() {
  const products = await loadProducts();

  return (
    <ProductPanel>
      <ProductList products={products} />
    </ProductPanel>
  );
}

这里 ProductPanel 不会因为接收 children 就把 ProductList 改造成客户端组件。children 是由服务端生成的 React 节点树,客户端组件持有的是一个可渲染插槽。

这也是一个重要边界:

客户端组件可以渲染由服务端组件生成的 children,
但不能通过普通 import 直接执行服务端组件代码。

六、客户端交互:状态留在客户端,数据变化回到服务端

RSC 本身不替代客户端状态。交互需要根据状态的归属分成两类。

适合留在客户端的状态

例如:

  • 输入框当前值;
  • 弹窗是否打开;
  • 标签页当前索引;
  • hover、拖拽和动画状态;
  • 尚未提交的表单草稿。
'use client';

import { useState } from 'react';

export function DialogButton() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>打开</button>
      {open && (
        <dialog open>
          <button onClick={() => setOpen(false)}>关闭</button>
        </dialog>
      )}
    </>
  );
}

适合放在 URL 或服务端的数据状态

例如:

  • 搜索关键字;
  • 排序方式;
  • 分页;
  • 当前筛选条件;
  • 依赖权限和数据库的结果。

在 Next.js App Router 中,可以让客户端组件更新 URL,框架随后重新执行服务端页面:

// app/products/ProductFilters.tsx
'use client';

import { useEffect, useState, useTransition } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';

export function ProductFilters() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const [keyword, setKeyword] = useState(searchParams.get('q') ?? '');
  const [isPending, startTransition] = useTransition();

  useEffect(() => {
    setKeyword(searchParams.get('q') ?? '');
  }, [searchParams]);

  function submit() {
    const params = new URLSearchParams(searchParams);
    const value = keyword.trim();

    if (value) {
      params.set('q', value);
    } else {
      params.delete('q');
    }

    startTransition(() => {
      router.replace(`${pathname}?${params.toString()}`);
    });
  }

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        submit();
      }}
    >
      <input
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
        placeholder="搜索商品"
      />
      <button type="submit" disabled={isPending}>
        {isPending ? '加载中…' : '搜索'}
      </button>
    </form>
  );
}

服务端页面读取查询参数:

// app/products/page.tsx
import { ProductFilters } from './ProductFilters';

type PageProps = {
  searchParams: Promise<{
    q?: string;
  }>;
};

export default async function ProductsPage({
  searchParams,
}: PageProps) {
  const params = await searchParams;
  const keyword = params.q ?? '';
  const products = await loadProducts(keyword);

  return (
    <main>
      <ProductFilters />
      <h1>{keyword ? `搜索:${keyword}` : '全部商品'}</h1>
      <ProductList products={products} />
    </main>
  );
}

这里的因果链是:

输入框变化
  ↓
客户端更新本地状态
  ↓
提交后修改 URL
  ↓
路由发起新的 RSC 请求
  ↓
服务端重新读取 searchParams
  ↓
服务端重新查询数据
  ↓
客户端接收新的 RSC Payload
  ↓
React 更新服务端内容,同时尽量保留已有客户端状态

startTransition 的作用是把路由更新标记为非紧急更新,使输入等紧急交互不必与页面内容切换争抢优先级。它不会让数据库查询变快,也不会自动缓存数据。


七、Suspense、流式 RSC 和并发更新

服务端组件可以是异步组件:

async function ProductList() {
  const products = await loadProducts();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

如果页面必须等待所有组件完成,用户只能在完整结果准备好后看到页面。使用 Suspense 可以把页面拆成可独立传输的部分:

import { Suspense } from 'react';

export default function Page() {
  return (
    <main>
      <h1>商品</h1>

      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList />
      </Suspense>
    </main>
  );
}

执行过程可以表示为:

sequenceDiagram
    participant B as 浏览器
    participant S as 服务端
    participant DB as 数据库

    B->>S: 请求页面
    S-->>B: 立即发送可用的 HTML/流式壳
    S->>DB: 查询商品
    DB-->>S: 返回结果
    S-->>B: 发送 Suspense 边界内的 RSC 数据
    B->>B: 替换 skeleton,显示商品列表

Suspense 不等于缓存,也不等于并行执行所有任务:

  • Suspense 定义“等待期间显示什么”;
  • 并发渲染决定 React 如何调度更新;
  • 数据缓存决定是否需要再次访问数据源;
  • 流式传输决定结果何时分段到达浏览器。

如果组件之间存在独立数据依赖,可以并行发起读取:

async function Page() {
  const productsPromise = loadProducts();
  const categoriesPromise = loadCategories();

  const [products, categories] = await Promise.all([
    productsPromise,
    categoriesPromise,
  ]);

  return <Catalog products={products} categories={categories} />;
}

而下面的写法会产生不必要的串行等待:

async function Page() {
  const products = await loadProducts();
  const categories = await loadCategories();

  return <Catalog products={products} categories={categories} />;
}

如果 loadProductsloadCategories 互不依赖,第二种写法的总等待时间近似为:

Tserial=Tp+TcT_{\text{serial}} = T_p + T_c

并行写法则近似为:

Tparallel=max(Tp,Tc)T_{\text{parallel}} = \max(T_p, T_c)

这里 TpT_pTcT_c 分别是两次数据读取的耗时。真实系统还会受到连接池、数据库并发能力和框架调度影响,但依赖关系仍然决定了是否可以并行。


八、RSC 缓存不是一个缓存,而是多个层次

“RSC 会缓存组件吗?”这个问题没有单一答案。至少要区分以下缓存层:

层次 缓存内容 典型作用域
浏览器缓存 HTML、RSC 响应、静态资源 单个浏览器
CDN/代理缓存 页面或 RSC 响应 多用户共享
框架数据缓存 fetch 或数据函数结果 服务端部署环境
请求内去重 同一请求中的重复读取 单次请求
React 级记忆化 相同参数的计算结果 取决于运行时和框架
数据库/Redis 缓存 查询结果或业务对象 应用基础设施

RSC 只规定组件执行和结果传输模型,不自动保证所有服务端组件结果永久缓存。

缓存正确性的形式化条件

对于一个缓存函数:

C(K)=VC(K) = V

其中:

  • KK 是缓存键;
  • VV 是缓存值。

只有在以下条件成立时,命中缓存才安全:

KIK \supseteq I

这里 II 是影响结果的全部输入。例如商品列表可能依赖:

路径 + 查询参数 + 租户 + 用户权限 + 语言 + 数据版本

如果缓存键只有 /products,却遗漏了用户身份,那么两个用户可能得到同一份个性化数据。更严重的情况是,管理员数据被缓存后发送给普通用户。

因此,缓存公共数据时:

/products?q=keyboard

可能足够作为键;缓存权限相关数据时,至少要考虑:

/products?q=keyboard&tenant=acme&role=manager

实际项目中更推荐避免把高敏感、强个性化内容放入共享页面缓存,而是在服务端请求中读取身份后再决定数据。


九、Next.js 中显式配置数据缓存

不同 Next.js 版本对 fetch 默认缓存和缓存 API 的默认行为可能变化,因此不要依赖“默认会缓存”或“默认不缓存”的记忆。需要缓存时显式写出意图:

// src/server/products.ts
import 'server-only';

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

export async function loadProducts(keyword = ''): Promise<Product[]> {
  const query = new URLSearchParams({ q: keyword });

  const response = await fetch(
    `${process.env.PRODUCT_API_URL}/products?${query}`,
    {
      next: {
        revalidate: 60,
        tags: ['products'],
      },
    },
  );

  if (!response.ok) {
    throw new Error(`商品服务返回 ${response.status}`);
  }

  return response.json() as Promise<Product[]>;
}

这段代码表达了三件事:

  1. 相同 URL 的结果可以在框架允许的缓存层保存;
  2. 缓存最多按约 60 秒重新验证一次;
  3. 该缓存与 products 标签关联,以便后续按标签失效。

这不是 React 的通用保证,而是 Next.js 框架能力。部署到多个进程或多个区域时,还要确认缓存是否共享。如果每个实例只保留自己的内存缓存,用户请求可能在不同实例上看到不同的新旧数据。

缓存失效与数据变更

读取缓存只是缓存的一半,另一半是失效策略。写入成功后,必须让相关读取结果变得不可继续使用:

// app/admin/products/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function updateProduct(formData: FormData) {
  const id = String(formData.get('id') ?? '');
  const name = String(formData.get('name') ?? '');

  if (!id || !name.trim()) {
    throw new Error('参数无效');
  }

  // 生产代码必须在这里验证当前用户是否有权限
  await updateProductInDatabase({
    id,
    name: name.trim(),
  });

  revalidatePath('/products');
}

revalidatePathrevalidateTagupdateTag 等 API 的语义和签名会随 Next.js 版本演进,使用时应以当前版本文档为准。关键不在于记住某个函数名,而在于建立对应关系:

数据写入集合 A
  ↓
找出读取集合 A 的页面、请求或标签
  ↓
使它们失效或重新验证
  ↓
下一次 RSC 请求读取新数据

如果只更新数据库而不失效缓存,常见表现是:

数据库已是新值
服务端直接查询时是新值
页面通过缓存读取时仍显示旧值
刷新一段时间后才恢复

这通常不是 React 状态问题,而是数据缓存生命周期问题。


十、请求内去重、跨请求缓存和业务缓存的区别

假设页面中两个服务端组件都调用:

await fetch('https://api.example.com/products');

框架可能在同一次请求中对相同读取进行去重,但这不代表结果会跨请求保存。两者区别如下:

请求内去重

一次请求:
  ProductCount ─┐
                ├─ 只执行一次相同读取
  ProductList ──┘

它解决的是同一棵渲染树中的重复工作。

跨请求缓存

请求 1:访问 API,保存结果
请求 2:从缓存读取
请求 3:从缓存读取或重新验证

它解决的是多个请求之间的复用,但必须处理 TTL、失效、部署拓扑和数据一致性。

业务缓存

例如 Redis 中缓存按租户和筛选条件组织的结果:

const key = `products:${tenantId}:${keyword}`;

const cached = await redis.get(key);
if (cached) {
  return JSON.parse(cached) as Product[];
}

const products = await queryDatabase(tenantId, keyword);
await redis.set(key, JSON.stringify(products), { EX: 60 });

return products;

业务缓存由应用自己控制,优点是键和失效逻辑更明确,代价是需要处理序列化、并发击穿、缓存污染和 Redis 故障。

不能因为页面使用了 RSC,就认为数据库读取天然被缓存。RSC、框架缓存和业务缓存是不同层次。


十一、身份验证与动态数据:缓存边界必须先于组件边界

服务端组件可以读取 cookie、headers 或认证会话,但这会使结果依赖请求上下文:

import { getCurrentUser } from '@/server/auth';

export default async function AccountPage() {
  const user = await getCurrentUser();

  if (!user) {
    return <LoginRequired />;
  }

  return <Account user={user} />;
}

这里的结果依赖:

R=F(route,cookies,session,database)R = F(\text{route}, \text{cookies}, \text{session}, \text{database})

如果把完整 HTML 或 RSC 结果放进无区分的共享缓存,就可能发生跨用户泄露。常见安全边界是:

  • 公共商品目录可以按 URL 和语言缓存;
  • 当前用户余额不能使用所有用户共享的页面缓存;
  • 权限判断必须在服务端重新执行,不能只相信客户端传入的 role
  • 任何来自表单、URL 或客户端组件的 ID 都必须在服务端重新授权。

客户端隐藏按钮不是权限控制:

'use client';

export function DeleteButton({ canDelete }: { canDelete: boolean }) {
  if (!canDelete) return null;
  return <button>删除</button>;
}

真正的删除接口仍必须在服务端检查当前会话。客户端 props 只能改变界面,不能改变服务端授权结果。


十二、客户端交互的三种实现路径

路径一:客户端状态,不触发服务端重新渲染

适合局部 UI:

'use client';

import { useState } from 'react';

export function SortMenu() {
  const [sort, setSort] = useState('latest');

  return (
    <select value={sort} onChange={(event) => setSort(event.target.value)}>
      <option value="latest">最新</option>
      <option value="price">价格</option>
    </select>
  );
}

数据不会自动重新查询。若排序结果来自数据库,需要将状态提交给 URL、Server Action 或 API。

路径二:导航触发新的 RSC 渲染

前文的搜索示例属于这种路径。它适合让 URL 表示页面状态,具有可分享、可后退和可恢复的特点。

路径三:Server Action 执行服务端变更

Server Action 是可被客户端触发的服务端函数引用,通常用于表单提交和变更操作。它不是普通函数序列化,而是框架生成的服务端调用入口。

// app/products/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createProduct(formData: FormData) {
  const name = String(formData.get('name') ?? '').trim();
  const priceText = String(formData.get('price') ?? '');

  const price = Number(priceText);

  if (!name || !Number.isFinite(price) || price < 0) {
    throw new Error('商品名称或价格无效');
  }

  const user = await getCurrentUser();

  if (!user || !user.permissions.includes('product:create')) {
    throw new Error('没有创建商品的权限');
  }

  await insertProduct({
    name,
    price,
    createdBy: user.id,
  });

  revalidatePath('/products');
}

客户端表单:

// app/products/CreateProductForm.tsx
'use client';

import { useActionState } from 'react';
import { createProduct } from './actions';

type State = {
  error?: string;
};

async function action(
  _previousState: State,
  formData: FormData,
): Promise<State> {
  try {
    await createProduct(formData);
    return {};
  } catch (error) {
    return {
      error: error instanceof Error ? error.message : '提交失败',
    };
  }
}

export function CreateProductForm() {
  const [state, formAction, pending] = useActionState(action, {});

  return (
    <form action={formAction}>
      <input name="name" required />
      <input name="price" type="number" min="0" step="0.01" required />

      <button disabled={pending}>
        {pending ? '保存中…' : '保存'}
      </button>

      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

请求路径大致是:

浏览器提交 FormData
  ↓
框架向服务端发送 POST
  ↓
服务端重新读取身份并校验输入
  ↓
执行数据库事务
  ↓
失效相关缓存
  ↓
返回 Action 结果及必要的 RSC 更新

必须注意:

  • Server Action 的参数来自客户端,仍然是不可信输入;
  • 'use server' 不等于自动鉴权;
  • 不能把数据库对象或内部错误直接返回给客户端;
  • 变更和缓存失效应尽量保持一致,必要时使用数据库事务;
  • Server Action 适合服务端变更入口,但不应取代所有 API。需要供移动端、第三方或独立服务调用时,公开 HTTP API 往往更清晰。

十三、序列化失败的诊断方法

失败一:把普通函数传给客户端组件

<ClientButton onClick={() => saveSomething()} />

如果这个 JSX 在服务端组件中生成,普通闭包无法作为客户端可执行函数传输。可能的修复方式是:

  • 把交互函数放入客户端组件内部;
  • 使用 Server Action,并明确声明服务端调用入口;
  • 使用普通 API,让客户端通过 fetch 调用。

失败二:把服务端专用对象放进 props

<ClientTable rows={databaseRows} />

如果 databaseRows 包含类实例、BigInt、Decimal、不可枚举字段或内部元数据,构建或运行时可能出现序列化错误。应先映射成明确的 DTO:

const rows = databaseRows.map((row) => ({
  id: String(row.id),
  name: row.name,
  price: Number(row.price),
}));

金额等精确值不应无条件转换为浮点数。对于货币,通常传分为整数:

const dto = {
  id: row.id,
  priceCents: row.priceCents,
};

客户端再按货币规则格式化。

失败三:客户端组件错误导入服务端模块

诊断顺序可以是:

  1. 查看报错文件是否有 'use client'
  2. 沿静态 import 向下检查是否引入了数据库、文件系统或 server-only 模块;
  3. 检查共享工具文件是否混入服务端依赖;
  4. 将纯格式化函数拆到无环境依赖的模块;
  5. 重新构建客户端 bundle,确认服务端 SDK 未进入产物。

十四、错误边界、加载状态和失败路径

服务端组件可能在以下位置失败:

  • 数据库连接失败;
  • API 返回非 2xx;
  • 权限校验失败;
  • 序列化失败;
  • Server Action 参数非法;
  • 客户端 JavaScript 加载失败;
  • 流式传输中断。

Next.js 可以通过路由级文件组织错误和加载状态:

// app/products/loading.tsx
export default function Loading() {
  return <ProductListSkeleton />;
}
// app/products/error.tsx
'use client';

export default function ErrorPage({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <section role="alert">
      <p>商品列表加载失败。</p>
      <button onClick={() => reset()}>重试</button>
    </section>
  );
}

错误信息应区分:

  • 面向用户的稳定提示;
  • 服务端日志中的详细上下文;
  • 可关联请求的错误 ID 或 digest。

不要把数据库连接字符串、SQL、堆栈和内部权限信息直接渲染到客户端。

对于局部慢数据,优先使用局部 Suspense,而不是让整个页面只有一个全局 loading:

<Suspense fallback={<RecommendationsSkeleton />}>
  <Recommendations />
</Suspense>

这样推荐区失败或变慢时,不一定阻塞商品主体。边界的位置决定故障影响范围。


十五、客户端状态在 RSC 更新中的保留

当客户端通过导航获取新的 RSC Payload 时,React 不会简单地把整棵浏览器 DOM 销毁重建。框架会将新的服务端结果合并到现有树中,通常保留符合条件的客户端组件状态。

例如:

ProductFilters 的输入状态:保留
ProductList 的服务端结果:替换
当前滚动位置:由框架导航策略决定
未提交的客户端草稿:需要应用显式设计

但以下情况可能导致状态重置:

  • 客户端组件的 key 改变;
  • 组件在树中的位置发生变化;
  • 路由段被完全替换;
  • 组件重新挂载;
  • 应用自身在渲染中把状态初始化为新 props。

不要依赖“服务端刷新一定保留所有客户端状态”。需要保留的状态应明确归属:

  • 可分享状态放 URL;
  • 跨页面状态放客户端状态管理或持久化存储;
  • 仅当前组件使用的状态放组件内部;
  • 服务端事实放数据库或服务端缓存。

十六、RSC 与数据一致性:一次渲染中的快照问题

服务端页面可能读取多个数据源:

const user = await loadUser();
const orders = await loadOrders();

如果两次读取不在同一个一致性快照内,渲染结果可能出现:

user 显示为新状态
orders 仍是旧状态

这不是 RSC 特有问题,但 RSC 的流式执行和缓存会让问题更明显。需要根据业务选择:

  • 数据库事务或一致性快照;
  • 统一的后端查询;
  • 同一版本号下的数据读取;
  • 接受最终一致性,并在界面上标明状态。

形式上,如果两个读取分别观察到版本 v1v_1v2v_2,而页面要求同一快照,则需要:

v1=v2v_1 = v_2

如果业务允许最终一致性,则可以接受:

v1v2Δ|v_1 - v_2| \leq \Delta

其中 Δ\Delta 是业务允许的版本差异。缓存 TTL、重新验证和流式边界都不能自动把不同数据源变成事务快照。


十七、常见误解与实际边界

误解一:服务端组件等于“不会有客户端代码”

错误。只要页面包含客户端组件,客户端仍需加载相应 JavaScript。RSC 的收益是减少必须发送的组件代码和数据访问依赖,而不是保证页面零 JavaScript。

误解二:'use client' 只是允许使用 useState

不完整。它定义了模块边界,并影响依赖打包、可传 props 范围和执行位置。文件加上该指令后,其静态依赖也必须适合客户端环境。

误解三:Server Action 是安全的 API

错误。它只是一个服务端调用入口。输入来自网络,必须校验;调用者身份必须重新读取;权限必须在服务端判断。

误解四:RSC Payload 就是 JSON

错误。RSC 使用 React Flight 数据协议,能表达组件引用、服务端结果和特定可传值。调试时看到的响应格式不应被当作稳定的公共 JSON API。

误解五:缓存只会带来性能收益

错误。错误缓存会导致:

  • 用户看到其他用户的数据;
  • 权限变更未及时生效;
  • 写入成功后页面仍显示旧数据;
  • 不同部署实例产生不一致;
  • 个性化响应被 CDN 共享。

缓存设计首先是正确性和安全性问题,其次才是性能问题。


十八、如何选择组件边界

可以按照“数据访问”和“交互需求”同时判断:

需求 更适合的组件
直接访问数据库 Server Component
使用 windowdocument Client Component
使用 useState 或点击事件 Client Component
展示公共、可缓存数据 Server Component + 明确缓存策略
表单写入数据库 Server Action 或 API
搜索、分页、筛选 Client Component 修改 URL,服务端重新读取
弹窗、拖拽、输入草稿 Client Component
依赖用户权限的数据 Server Component,并谨慎处理缓存
大型第三方客户端库 放在尽可能小的 Client Component 边界内

理想边界不是“全部服务端”或“全部客户端”,而是:

服务端:
  数据读取、权限、秘密、业务组合、可缓存结果

客户端:
  事件、局部状态、浏览器 API、即时反馈

边界:
  只传递必要且可序列化的数据

十九、一个完整的请求与更新模型

以“搜索商品并创建商品”为例:

flowchart LR
    A["浏览器输入关键词"] --> B["Client Component 状态"]
    B --> C["修改 URL"]
    C --> D["RSC 请求"]
    D --> E["Server Component 读取参数"]
    E --> F["缓存/数据库/API"]
    F --> G["RSC Payload"]
    G --> H["更新服务端内容"]

    I["浏览器提交表单"] --> J["Server Action"]
    J --> K["鉴权与输入校验"]
    K --> L["数据库事务"]
    L --> M["缓存失效"]
    M --> N["返回 Action 结果或触发更新"]

其中最容易被忽略的是两条独立路径:

  1. 读取路径

    URL → Server Component → 数据读取 → RSC Payload
    
  2. 写入路径

    表单 → Server Action/API → 鉴权 → 数据库 → 缓存失效
    

如果写入路径没有连接到读取路径的缓存失效,页面就会出现“写入成功但读到旧值”的问题;如果读取路径没有重新执行服务端权限校验,页面就可能出现越权数据。


二十、生产环境中的验证清单

部署前至少应验证以下事实,而不是只验证页面能否显示:

  1. 构建产物中没有数据库驱动、服务端密钥和文件系统模块。
  2. 客户端组件的 props 可以被当前 React/框架版本序列化。
  3. 公共数据缓存键包含全部影响结果的参数。
  4. 个性化数据不会进入跨用户共享缓存。
  5. 写操作成功后,相关页面或数据标签确实失效。
  6. 多实例部署时,缓存是否共享已经明确。
  7. Server Action 和 API 都在服务端重新鉴权。
  8. 慢数据有正确的 Suspense 或路由 loading 边界。
  9. 数据源失败时,错误边界不会暴露内部错误。
  10. RSC 导航后,必须保留的客户端状态有明确归属和测试。
  11. API、数据库和 Server Action 的非 2xx、超时、重试和幂等行为已经定义。
  12. 使用的 Next.js 缓存 API 与当前版本文档一致,没有把旧版默认行为当成规范保证。

React Server Components 的核心价值不是把组件简单地“搬到服务器”,而是建立一条可分析的执行边界:数据和秘密留在服务端,交互和浏览器状态留在客户端,二者通过受约束的序列化结果、RSC Payload、导航和服务端调用连接起来。只有同时理解执行位置、序列化条件、缓存作用域和更新路径,才能判断一个组件应该放在哪里,以及一次页面更新为什么会得到当前结果。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。