Appearance
第13章 Server Actions 与数据流
本章要点
- 从 API Routes 到 Server Actions:服务端调用范式的三次跃迁
- "use server" 指令的编译时处理:如何将函数调用转化为网络请求
- Action ID 的生成算法:哈希、模块路径与函数位置的三元组
- FormData 序列化与渐进增强:没有 JavaScript 的表单如何工作
- 乐观更新与错误处理的统一模型:useOptimistic 与 useActionState 的协作
- CSRF 防护与闭包变量泄露:Server Actions 的安全攻击面分析
- Server Actions 与 React Server Components 的数据流闭环
在传统的 Web 开发中,前端与后端之间始终存在一道鸿沟——HTTP 协议。无论你使用 REST、GraphQL 还是 tRPC,开发者都必须手动定义请求格式、维护 API 端点、处理序列化与反序列化。这些"胶水代码"在一个全栈应用中往往占据了惊人的比例。React Server Actions 的出现,正是为了消除这道鸿沟。
Server Actions 让你可以在客户端组件中直接调用服务端函数,就像调用一个普通的异步函数一样。编译器负责将这个"函数调用"转化为一个 HTTP 请求,将参数序列化为请求体,将返回值反序列化为客户端可用的数据。这听起来像是 RPC(远程过程调用)的老概念,但 React 的实现远比传统 RPC 深刻——它将服务端调用与表单提交、乐观更新、错误边界、并发渲染等 React 核心机制深度融合,形成了一套完整的数据变更(mutation)基础设施。
本章将从编译时到运行时,完整剖析 Server Actions 的内部机制。我们会看到"use server"这两个字背后的编译器魔法,理解 Action ID 的生成策略,分析 FormData 序列化的工程细节,深入乐观更新的双层状态模型,最后严肃审视 Server Actions 引入的安全风险。
13.1 从 API Routes 到 Server Actions:服务端调用范式的进化
13.1.1 三代服务端调用范式
让我们用一个简单的"创建待办事项"场景,回顾服务端调用范式的三次进化:
第一代:手动 fetch + API Routes
typescript
// pages/api/todos.ts(服务端)
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { title } = req.body;
const todo = await db.todo.create({ data: { title } });
res.status(201).json(todo);
}
}
// components/TodoForm.tsx(客户端)
function TodoForm() {
const [title, setTitle] = useState('');
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsPending(true);
setError(null);
try {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Failed to create todo');
const todo = await res.json();
// 还需要手动更新 UI...
router.refresh();
} catch (e) {
setError(e.message);
} finally {
setIsPending(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={e => setTitle(e.target.value)} />
<button disabled={isPending}>
{isPending ? 'Adding...' : 'Add'}
</button>
{error && <p className="error">{error}</p>}
</form>
);
}数一数这段代码中的"胶水":API 路由定义、HTTP 方法判断、请求头设置、JSON 序列化/反序列化、手动 pending 状态管理、手动错误处理、手动 UI 刷新。真正的业务逻辑只有一行:db.todo.create({ data: { title } })。
第二代:tRPC / React Query
typescript
// server/routers/todo.ts
export const todoRouter = router({
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(async ({ input }) => {
return db.todo.create({ data: { title: input.title } });
}),
});
// components/TodoForm.tsx
function TodoForm() {
const utils = trpc.useUtils();
const mutation = trpc.todo.create.useMutation({
onSuccess: () => utils.todo.list.invalidate(),
});
return (
<form onSubmit={e => {
e.preventDefault();
mutation.mutate({ title: e.currentTarget.title.value });
}}>
<input name="title" />
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add'}
</button>
{mutation.error && <p className="error">{mutation.error.message}</p>}
</form>
);
}tRPC 消除了 HTTP 层的样板代码,提供了端到端的类型安全。但它仍然是一个独立于 React 的解决方案——pending 状态、错误处理、缓存失效都由第三方库管理,与 React 的并发渲染和 Suspense 是"贴合"而非"融合"。
第三代:Server Actions
typescript
// app/actions.ts
'use server';
export async function createTodo(formData: FormData) {
const title = formData.get('title') as string;
const todo = await db.todo.create({ data: { title } });
revalidatePath('/todos');
return todo;
}
// components/TodoForm.tsx
import { createTodo } from '@/app/actions';
function TodoForm() {
const [state, formAction, isPending] = useActionState(createTodo, null);
return (
<form action={formAction}>
<input name="title" />
<button disabled={isPending}>
{isPending ? 'Adding...' : 'Add'}
</button>
</form>
);
}Server Actions 带来的简化是质的飞跃:没有 API 路由、没有 fetch、没有手动序列化、pending 状态由 React 内建追踪、表单即使在 JavaScript 未加载时也能通过原生 HTML 表单提交工作。更重要的是,这不是一个独立的数据层方案,而是 React 渲染引擎的一部分。
13.1.2 Server Actions 的本质:RPC 的 React 化
Server Actions 的设计灵感来自远程过程调用(RPC),但它超越了传统 RPC 的范畴。传统 RPC 框架关注的是"如何让远程调用看起来像本地调用",而 Server Actions 关注的是"如何让服务端数据变更与 React 的渲染模型无缝集成"。
传统 RPC: Client Function Call → Network → Server Function Execution → Return Value
Server Actions: Client Form/Action → Transition → Network → Server Function → RSC Re-render → Streaming UI Update下图展示了 Server Actions 的完整调用链路,从客户端触发到 UI 更新的闭环:
关键区别在于中间的"Transition"和末尾的"RSC Re-render"。Server Actions 的调用被包装在 React 的 Transition 中,这意味着:
- 调用期间,旧 UI 保持交互性——不会出现空白或 loading 闪烁
- 返回的不仅是数据,而是重新渲染后的 RSC 流——服务端组件树自动更新
- 乐观更新、错误回退、并发调用都由 React 统一调度——开发者无需自建状态机
深度洞察:Server Actions 最深刻的创新不在于"消除 API 路由",而在于将数据变更(mutation)纳入了 React 的声明式范式。在 Server Actions 之前,React 是一个优秀的"读取框架"——从 state 到 UI 的映射是声明式的。但数据的写入、提交、乐观更新却是命令式的。Server Actions 让数据写入也变成了声明式的:你声明一个 action,React 负责执行时机、状态追踪、错误恢复和 UI 更新。
13.2 "use server" 指令的编译时处理
13.2.1 指令的语义
"use server" 是 React 引入的第二个指令(第一个是 "use client")。它有两种使用方式:
typescript
// 方式 1:模块级指令——整个文件中导出的所有函数都是 Server Actions
'use server';
export async function createTodo(formData: FormData) {
// 这是一个 Server Action
}
export async function deleteTodo(id: string) {
// 这也是一个 Server Action
}
// 方式 2:函数级指令——单个函数声明为 Server Action
export function TodoList() {
async function handleDelete(id: string) {
'use server';
// 这个内联函数是一个 Server Action
await db.todo.delete({ where: { id } });
revalidatePath('/todos');
}
return <DeleteButton onDelete={handleDelete} />;
}下图展示了 "use server" 指令的编译时分裂过程 -- 同一份源码被编译为两个截然不同的版本:
从语法上看,"use server" 只是一个字符串字面量。但编译器对它的处理极为关键——它决定了一段代码是在服务端执行还是被替换为一个网络调用的 stub。
13.2.2 编译器的转换过程
当编译器遇到 "use server" 指令时,它执行以下转换。让我们以一个具体的例子来追踪整个过程:
编译前(开发者编写的代码):
typescript
// app/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createTodo(title: string, priority: number) {
const todo = await db.todo.create({
data: { title, priority, completed: false },
});
revalidatePath('/todos');
return { id: todo.id, title: todo.title };
}
export async function toggleTodo(id: string) {
const todo = await db.todo.findUnique({ where: { id } });
await db.todo.update({
where: { id },
data: { completed: !todo.completed },
});
revalidatePath('/todos');
}编译后(客户端收到的代码):
typescript
// app/actions.ts(客户端版本)
import { createServerReference } from 'react-server-dom-webpack/client';
import { callServer } from '@/lib/server-runtime';
export const createTodo = createServerReference(
'a1b2c3d4e5f6', // Action ID
callServer
);
export const toggleTodo = createServerReference(
'f6e5d4c3b2a1', // Action ID
callServer
);注意编译器做了什么:
- 删除了所有的服务端代码——
db导入、数据库操作、revalidatePath调用全部消失 - 为每个导出函数生成了唯一的 Action ID
- 用
createServerReference创建了代理函数——它们看起来是普通的异步函数,但实际上会发起网络请求
编译后(服务端保留的代码):
typescript
// app/actions.ts(服务端版本)
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import { registerServerReference } from 'react-server-dom-webpack/server';
async function createTodo(title: string, priority: number) {
const todo = await db.todo.create({
data: { title, priority, completed: false },
});
revalidatePath('/todos');
return { id: todo.id, title: todo.title };
}
async function toggleTodo(id: string) {
const todo = await db.todo.findUnique({ where: { id } });
await db.todo.update({
where: { id },
data: { completed: !todo.completed },
});
revalidatePath('/todos');
}
// 编译器注入的注册代码
registerServerReference(createTodo, 'app/actions.ts', 'createTodo');
registerServerReference(toggleTodo, 'app/actions.ts', 'toggleTodo');服务端版本保留了原始逻辑,但额外调用了 registerServerReference 将函数注册到一个全局映射表中,以便运行时通过 Action ID 找到对应的函数。
下图展示了 Action ID 在客户端代理与服务端实现之间的桥接机制:
13.2.3 Action ID 的生成算法
Action ID 是连接客户端代理和服务端实现的桥梁。它的生成需要满足两个看似矛盾的要求:
- 确定性——同一个函数在每次编译中必须生成相同的 ID,否则客户端缓存的引用会失效
- 唯一性——不同函数的 ID 必须不同,否则会调用错误的函数
React 的参考实现使用以下策略生成 Action ID:
typescript
// react-server-dom-webpack 的 Action ID 生成逻辑
function generateActionId(
moduleId: string, // 模块的文件路径或构建 ID
exportName: string // 导出的函数名
): string {
// 对于模块级 "use server",使用模块 ID + 导出名
// 例如:hash("app/actions.ts" + "#" + "createTodo")
const raw = moduleId + '#' + exportName;
// 使用 SHA-1 或类似的哈希算法生成固定长度的 ID
const hash = createHash(raw);
return hash;
}
// 对于函数级 "use server"(内联 Server Action),情况更复杂
function generateInlineActionId(
moduleId: string,
functionName: string,
closureVariables: string[] // 闭包捕获的变量列表
): string {
// 内联 Action 可能捕获外部变量
// 这些变量需要被序列化传输到服务端
// Action ID 需要编码闭包信息
const raw = moduleId + '#' + functionName + '(' + closureVariables.join(',') + ')';
return createHash(raw);
}深度洞察:Action ID 的生成策略揭示了一个深层的架构决策——Server Actions 是编译时特性,不是运行时特性。你不能在运行时动态创建一个 Server Action,就像你不能在运行时创建一个新的 API 端点一样。编译器必须在构建时枚举所有可能的 Server Actions,为每个生成 ID,并建立客户端到服务端的映射。这个约束也是安全性的基础——只有编译时注册的函数才能被远程调用,运行时无法注入新的服务端函数。
13.2.4 闭包变量的序列化
内联 Server Actions 可以捕获外部作用域的变量,这带来了独特的编译挑战:
typescript
function TodoItem({ todo }: { todo: Todo }) {
async function handleToggle() {
'use server';
// 这里捕获了 todo.id——一个来自客户端的值
await db.todo.update({
where: { id: todo.id },
data: { completed: !todo.completed },
});
revalidatePath('/todos');
}
return (
<form action={handleToggle}>
<button>{todo.title}</button>
</form>
);
}编译器对这段代码的处理相当精妙:
typescript
// 编译后的客户端代码
function TodoItem({ todo }: { todo: Todo }) {
// 闭包变量被提取为 bound arguments
const handleToggle = createServerReference('x7y8z9', callServer)
.bind(null, todo.id); // todo.id 作为绑定参数
return (
<form action={handleToggle}>
<button>{todo.title}</button>
</form>
);
}
// 编译后的服务端代码
async function handleToggle(
todoId: string // 闭包变量变成了函数参数
) {
await db.todo.update({
where: { id: todoId },
data: { completed: true }, // 注意:todo.completed 的值在编译时不可知
});
revalidatePath('/todos');
}
registerServerReference(handleToggle, 'components/TodoItem.tsx', 'handleToggle');