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 将组件划分为两类:
-
Server Component,服务端组件
- 在服务端渲染。
- 可以直接访问数据库、文件系统或服务端 SDK。
- 不会作为组件代码发送到浏览器。
- 不能使用浏览器专属 API。
- 不能直接使用客户端状态和事件处理器,例如
useState、onClick。
-
Client Component,客户端组件
- 代码会进入浏览器构建产物。
- 可以使用
useState、useEffect、事件处理器和浏览器 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 页面请求可能涉及三种产物:
-
HTML
用于首屏结构和初始显示。
-
RSC Payload
也称 React Server Component Payload。它描述服务端组件执行后的结果、客户端组件引用及其 props、服务端组件树中的插槽关系等。
-
客户端 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 负责组件执行边界和组件数据传输,两者解决的问题不同。
四、服务端组件的执行模型
可以把一次服务端渲染抽象为:
其中:
- :组件树和模块代码;
- :路由参数、查询参数及组件 props;
- :服务端读取的数据;
- :执行环境,例如用户身份、请求头、运行时配置;
- :服务端产生的 RSC 结果。
服务端组件不能把任意运行时对象原样传给浏览器,因此在服务端和客户端之间存在一个序列化边界:
这不是简单的“调用 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} />;
}
如果 loadProducts 和 loadCategories 互不依赖,第二种写法的总等待时间近似为:
并行写法则近似为:
这里 和 分别是两次数据读取的耗时。真实系统还会受到连接池、数据库并发能力和框架调度影响,但依赖关系仍然决定了是否可以并行。
八、RSC 缓存不是一个缓存,而是多个层次
“RSC 会缓存组件吗?”这个问题没有单一答案。至少要区分以下缓存层:
| 层次 | 缓存内容 | 典型作用域 |
|---|---|---|
| 浏览器缓存 | HTML、RSC 响应、静态资源 | 单个浏览器 |
| CDN/代理缓存 | 页面或 RSC 响应 | 多用户共享 |
| 框架数据缓存 | fetch 或数据函数结果 |
服务端部署环境 |
| 请求内去重 | 同一请求中的重复读取 | 单次请求 |
| React 级记忆化 | 相同参数的计算结果 | 取决于运行时和框架 |
| 数据库/Redis 缓存 | 查询结果或业务对象 | 应用基础设施 |
RSC 只规定组件执行和结果传输模型,不自动保证所有服务端组件结果永久缓存。
缓存正确性的形式化条件
对于一个缓存函数:
其中:
- 是缓存键;
- 是缓存值。
只有在以下条件成立时,命中缓存才安全:
这里 是影响结果的全部输入。例如商品列表可能依赖:
路径 + 查询参数 + 租户 + 用户权限 + 语言 + 数据版本
如果缓存键只有 /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[]>;
}
这段代码表达了三件事:
- 相同 URL 的结果可以在框架允许的缓存层保存;
- 缓存最多按约 60 秒重新验证一次;
- 该缓存与
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');
}
revalidatePath、revalidateTag、updateTag 等 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} />;
}
这里的结果依赖:
如果把完整 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,
};
客户端再按货币规则格式化。
失败三:客户端组件错误导入服务端模块
诊断顺序可以是:
- 查看报错文件是否有
'use client'; - 沿静态 import 向下检查是否引入了数据库、文件系统或
server-only模块; - 检查共享工具文件是否混入服务端依赖;
- 将纯格式化函数拆到无环境依赖的模块;
- 重新构建客户端 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 的流式执行和缓存会让问题更明显。需要根据业务选择:
- 数据库事务或一致性快照;
- 统一的后端查询;
- 同一版本号下的数据读取;
- 接受最终一致性,并在界面上标明状态。
形式上,如果两个读取分别观察到版本 和 ,而页面要求同一快照,则需要:
如果业务允许最终一致性,则可以接受:
其中 是业务允许的版本差异。缓存 TTL、重新验证和流式边界都不能自动把不同数据源变成事务快照。
十七、常见误解与实际边界
误解一:服务端组件等于“不会有客户端代码”
错误。只要页面包含客户端组件,客户端仍需加载相应 JavaScript。RSC 的收益是减少必须发送的组件代码和数据访问依赖,而不是保证页面零 JavaScript。
误解二:'use client' 只是允许使用 useState
不完整。它定义了模块边界,并影响依赖打包、可传 props 范围和执行位置。文件加上该指令后,其静态依赖也必须适合客户端环境。
误解三:Server Action 是安全的 API
错误。它只是一个服务端调用入口。输入来自网络,必须校验;调用者身份必须重新读取;权限必须在服务端判断。
误解四:RSC Payload 就是 JSON
错误。RSC 使用 React Flight 数据协议,能表达组件引用、服务端结果和特定可传值。调试时看到的响应格式不应被当作稳定的公共 JSON API。
误解五:缓存只会带来性能收益
错误。错误缓存会导致:
- 用户看到其他用户的数据;
- 权限变更未及时生效;
- 写入成功后页面仍显示旧数据;
- 不同部署实例产生不一致;
- 个性化响应被 CDN 共享。
缓存设计首先是正确性和安全性问题,其次才是性能问题。
十八、如何选择组件边界
可以按照“数据访问”和“交互需求”同时判断:
| 需求 | 更适合的组件 |
|---|---|
| 直接访问数据库 | Server Component |
使用 window、document |
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 结果或触发更新"]
其中最容易被忽略的是两条独立路径:
-
读取路径
URL → Server Component → 数据读取 → RSC Payload -
写入路径
表单 → Server Action/API → 鉴权 → 数据库 → 缓存失效
如果写入路径没有连接到读取路径的缓存失效,页面就会出现“写入成功但读到旧值”的问题;如果读取路径没有重新执行服务端权限校验,页面就可能出现越权数据。
二十、生产环境中的验证清单
部署前至少应验证以下事实,而不是只验证页面能否显示:
- 构建产物中没有数据库驱动、服务端密钥和文件系统模块。
- 客户端组件的 props 可以被当前 React/框架版本序列化。
- 公共数据缓存键包含全部影响结果的参数。
- 个性化数据不会进入跨用户共享缓存。
- 写操作成功后,相关页面或数据标签确实失效。
- 多实例部署时,缓存是否共享已经明确。
- Server Action 和 API 都在服务端重新鉴权。
- 慢数据有正确的 Suspense 或路由 loading 边界。
- 数据源失败时,错误边界不会暴露内部错误。
- RSC 导航后,必须保留的客户端状态有明确归属和测试。
- API、数据库和 Server Action 的非 2xx、超时、重试和幂等行为已经定义。
- 使用的 Next.js 缓存 API 与当前版本文档一致,没有把旧版默认行为当成规范保证。
React Server Components 的核心价值不是把组件简单地“搬到服务器”,而是建立一条可分析的执行边界:数据和秘密留在服务端,交互和浏览器状态留在客户端,二者通过受约束的序列化结果、RSC Payload、导航和服务端调用连接起来。只有同时理解执行位置、序列化条件、缓存作用域和更新路径,才能判断一个组件应该放在哪里,以及一次页面更新为什么会得到当前结果。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 组件库与设计系统:组合 API、Token、主题和版本治理
- 下一篇:Next.js 应用架构:路由、渲染、数据、缓存、Server Actions 和部署
- 延伸:React 并发渲染:Transition、Deferred Value、Suspense 和一致性
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论