Skip to content

第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 中,这意味着:

  1. 调用期间,旧 UI 保持交互性——不会出现空白或 loading 闪烁
  2. 返回的不仅是数据,而是重新渲染后的 RSC 流——服务端组件树自动更新
  3. 乐观更新、错误回退、并发调用都由 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} />;
}

基于 VitePress 构建