React 19 内核探秘

第13章 Server Actions 与数据流

作者 杨艺韬 · 11,395 字 · 发布于 · 更新于

第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

// 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

// 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

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

// 经 useActionState 调用时,第一个参数是上一次的 state,FormData 在第二个
export async function createTodo(prevState: unknown, formData: FormData) {
  const title = formData.get('title') as string;
  const todo = await db.todo.create({ data: { title } });
  revalidatePath('/todos'); // Next.js 提供(next/cache),不是 React API
  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 更新的闭环:

sequenceDiagram
    participant U as 用户
    participant CC as Client Component
    participant RT as React Transition
    participant NET as 网络层
    participant SA as Server Action
    participant RSC as RSC 重渲染
    U->>CC: 提交表单 / 触发 action
    CC->>RT: startTransition 包装调用
    Note over CC: 旧 UI 保持交互性
    RT->>NET: 序列化参数为 HTTP 请求
    NET->>SA: POST /action (Action ID + 参数)
    SA->>SA: 执行服务端逻辑<br/>(数据库操作等)
    SA->>RSC: revalidatePath 触发重渲染(Next.js)
    RSC->>NET: 返回 RSC Payload 流
    NET->>RT: 流式接收新的 UI 树
    RT->>CC: 更新 UI(替换旧内容)
    CC->>U: 显示最新结果

关键区别在于中间的"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")。它有两种使用方式:

// 方式 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" 指令的编译时分裂过程 -- 同一份源码被编译为两个截然不同的版本:

graph TD
    SRC["源代码<br/>app/actions.ts<br/>use server"] --> COMPILER["编译器"]
    COMPILER -->|"客户端版本"| CLIENT["客户端 Stub<br/>createServerReference(ActionID, callServer)<br/>实际发起 HTTP 请求"]
    COMPILER -->|"服务端版本"| SERVER["服务端实现<br/>保留原始逻辑<br/>+ registerServerReference(fn, id)"]
    CLIENT -->|"调用时"| NET["HTTP POST<br/>Action ID + 序列化参数"]
    NET -->|"路由到"| SERVER
    SERVER --> REG["server manifest(bundler 生成)<br/>ActionID -> 模块/导出"]
    NET --> REG
    style SRC fill:#fff3e0,stroke:#e65100,stroke-width:2px
    style CLIENT fill:#e3f2fd,stroke:#1565c0
    style SERVER fill:#e8f5e9,stroke:#2e7d32

从语法上看,"use server" 只是一个字符串字面量。但编译器对它的处理极为关键——它决定了一段代码是在服务端执行还是被替换为一个网络调用的 stub。

13.2.2 编译器的转换过程

当编译器遇到 "use server" 指令时,它执行以下转换。让我们以一个具体的例子来追踪整个过程:

编译前(开发者编写的代码):

// 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');
}

编译后(客户端收到的代码):

// 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
);

注意编译器做了什么:

  1. 删除了所有的服务端代码——db 导入、数据库操作、revalidatePath 调用全部消失
  2. 为每个导出函数生成了唯一的 Action ID
  3. 用 createServerReference 创建了代理函数——它们看起来是普通的异步函数,但实际上会发起网络请求

编译后(服务端保留的代码):

// 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。它并不把函数登记进任何映射表,只是在函数对象上定义 $$typeof(Symbol.for('react.server.reference'))、$$id(id + '#' + exportName,这里即 'app/actions.ts#createTodo')、$$bound(初始为 null),并覆写 bind(packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js:106-137)。运行时"按 Action ID 找函数"靠的是 bundler 生成的 server manifest:decodeAction / decodeReply 经 resolveServerReference 查 manifest 再加载模块(packages/react-server/src/ReactFlightActionServer.js:31-53)。上面客户端 stub 里的 'a1b2c3d4e5f6' 只是示意,实际要与 manifest 的键一致。

下图展示了 Action ID 在客户端代理与服务端实现之间的桥接机制:

graph LR
    subgraph 客户端["客户端运行时"]
        PROXY["createTodo (代理函数)<br/>Action ID: a1b2c3d4"]
        PROXY -->|"调用"| CALL["callServer(id, args)"]
        CALL -->|"HTTP POST"| REQ["请求体:<br/>Action ID + FormData"]
    end
    subgraph 服务端["服务端运行时"]
        REQ -->|"路由匹配"| MAP["Action ID 映射表<br/>a1b2c3d4 -> createTodo"]
        MAP -->|"执行"| FN["createTodo 函数<br/>数据库操作 + revalidate"]
        FN -->|"返回"| RSC["RSC Payload 流"]
    end
    RSC -->|"流式传输"| 客户端
    style 客户端 fill:#e3f2fd,stroke:#1565c0
    style 服务端 fill:#e8f5e9,stroke:#2e7d32

13.2.3 Action ID 的生成算法

Action ID 是连接客户端代理和服务端实现的桥梁。它的生成需要满足两个看似矛盾的要求:

  1. 确定性——同一个函数在每次编译中必须生成相同的 ID,否则客户端缓存的引用会失效
  2. 唯一性——不同函数的 ID 必须不同,否则会调用错误的函数

需要先澄清归属:Action ID 的生成不是 react 包本体的行为——React 只规定"每个 Server Reference 必须携带一个稳定的字符串 ID"(registerServerReference 接受它、不生产它),怎么生成完全是 bundler/框架层的职责。以 Next.js 的编译器(SWC 插件)为例,其策略概念上如下:

// Next.js 编译器层的 Action ID 生成逻辑(概念化;不在 react 包中)
function generateActionId(
  hashSalt: string,        // 含服务端密钥参与的 salt,防止 ID 被离线枚举
  moduleId: string,        // 模块的文件路径或构建 ID
  exportName: string       // 导出的函数名
): string {
  // 对于模块级 "use server",使用 salt + 模块 ID + 导出名
  // 例如:hash(salt + "app/actions.ts" + "#" + "createTodo")
  const raw = hashSalt + moduleId + '#' + exportName;

  // 使用 SHA-1 或类似的哈希算法生成固定长度的 ID
  const hash = createHash(raw);

  return hash;
}

// 对于函数级 "use server"(内联 Server Action),情况更复杂
function generateInlineActionId(
  hashSalt: string,
  moduleId: string,
  functionName: string,
  closureVariables: string[]  // 闭包捕获的变量列表
): string {
  // 内联 Action 可能捕获外部变量
  // 这些变量需要被序列化传输到服务端
  // Action ID 需要编码闭包信息
  const raw = hashSalt + moduleId + '#' + functionName + '(' + closureVariables.join(',') + ')';
  return createHash(raw);
}

深度洞察:Action ID 的生成策略揭示了一个深层的架构决策——Server Actions 是编译时特性,不是运行时特性。你不能在运行时动态创建一个 Server Action,就像你不能在运行时创建一个新的 API 端点一样。编译器必须在构建时枚举所有可能的 Server Actions,为每个生成 ID,并建立客户端到服务端的映射。这个约束也是安全性的基础——只有编译时注册的函数才能被远程调用,运行时无法注入新的服务端函数。

13.2.4 闭包变量的序列化

内联 Server Actions 可以捕获外部作用域的变量,这带来了独特的编译挑战:

function TodoItem({ todo }: { todo: Todo }) {
  async function handleToggle() {
    'use server';
    // 这里捕获了 todo.id 和 todo.completed——来自渲染作用域的值
    await db.todo.update({
      where: { id: todo.id },
      data: { completed: !todo.completed },
    });
    revalidatePath('/todos');
  }

  return (
    <form action={handleToggle}>
      <button>{todo.title}</button>
    </form>
  );
}

编译器对这段代码的处理相当精妙(概念示意,各框架编译产物的细节不同):

// 提升到模块顶层的 action:闭包变量变成了函数参数
async function handleToggleAction(
  todoCompleted: boolean,
  todoId: string
) {
  await db.todo.update({
    where: { id: todoId },
    data: { completed: !todoCompleted }, // 取反运算保留在服务端,请求时才求值
  });
  revalidatePath('/todos');
}
registerServerReference(handleToggleAction, 'components/TodoItem.tsx', 'handleToggle');

// 编译后的组件:TodoItem 是 Server Component,这段仍在服务端运行
function TodoItem({ todo }: { todo: Todo }) {
  // 闭包变量被提取为 bound arguments——序列化传输的是"值",不是表达式
  const handleToggle = handleToggleAction
    .bind(null, todo.completed, todo.id); // 被捕获的值作为绑定参数

  return (
    <form action={handleToggle}>
      <button>{todo.title}</button>
    </form>
  );
}

编译器将闭包变量"提升"为函数参数,并在渲染时用 bind 预填充这些参数。这里的 bind 是 registerServerReference 覆写过的版本:返回的新函数照样带 $$typeof / $$id,并把参数追加进 $$bound(packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js:65-104)。Flight 序列化这个引用时,输出的元数据就是 {id, bound}(packages/react-server/src/ReactFlightServer.js:2065 起的 serializeServerReference),bound 里是参数的原值,React 不做加密。客户端调用这个 action 时,绑定的参数会随请求一起发回服务端。

这个机制优雅但也引入了安全风险——我们将在 13.5 节详细讨论。

13.3 Actions 与表单:渐进增强的全栈表单

13.3.1 <form action> 的复兴

HTML 原生的 <form> 元素一直支持 action 属性——它指定表单提交的 URL。React 19 对这个属性进行了扩展:当 action 是一个函数时,React 会接管表单提交行为。

// React 19 的 form action 支持三种类型
<form action="/api/submit">        {/* 传统 URL:原生表单提交 */}
<form action={serverAction}>       {/* Server Action 函数 */}
<form action={clientFunction}>     {/* 客户端函数 */}

当 action 是一个函数时,React 的处理流程如下:

// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:158-175
// form 提交拦截的核心逻辑(简化;不在 ReactDOMComponent.js)
function extractEvents(/* submit 路径 */) {
  // 仅处理 submit 事件;action 可为 string 或 function
  // 若 action 是函数:
  // 1. preventDefault,阻止原生导航
  // 2. 收集 FormData(含 submitter)
  // 3. 经 startHostTransition 派发,进入 Transition + Action 队列
  //    (调用 action 前先 requestFormReset:action 所在 transition 提交时表单自动重置,
  //     见 packages/react-reconciler/src/ReactFiberHooks.js:3193-3196)
  const formData = submitter
    ? createFormDataWithSubmitter(form, submitter)
    : new FormData(form);
  const pendingState = {
    pending: true,
    data: formData,
    method: form.method,
    action,
  };
  startHostTransition(formInst, pendingState, action, formData);
}

这段逻辑里有几个关键设计决策:

  1. 走 startHostTransition——表单提交期间旧 UI 保持响应,并驱动 useFormStatus
  2. 自动构造 FormData——开发者不需要手动收集表单数据
  3. submitter 信息保留——多个提交按钮场景下能区分是哪个按钮触发了提交
  4. 错误进入 Transition / Error Boundary 路径——与 React 的异步更新错误处理统一

13.3.2 渐进增强:没有 JavaScript 的表单

Server Actions 最具工程意义的设计之一是渐进增强(Progressive Enhancement)。当页面的 JavaScript 还未加载或执行失败时,表单仍然可以工作。

这是如何实现的?关键在于服务端渲染的 HTML 输出:

// 使用 Server Action 的组件
function SearchForm() {
  async function search(formData: FormData) {
    'use server';
    const query = formData.get('q') as string;
    redirect(`/search?q=${encodeURIComponent(query)}`);
  }

  return (
    <form action={search}>
      <input name="q" placeholder="Search..." />
      <button type="submit">Search</button>
    </form>
  );
}

服务端渲染输出的 HTML:

<form action="" enctype="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_ID_a1b2c3d4e5f6"/>
  <input name="q" placeholder="Search..." />
  <button type="submit">Search</button>
</form>

注意服务端渲染做了什么:

  1. 添加了 method="POST"——Server Actions 默认使用 POST 方法
  2. action 写成空字符串——React 自带的 defaultEncodeFormAction(packages/react-client/src/ReactFlightReplyClient.js:912)只返回 {name, method: 'POST', encType: 'multipart/form-data', data}、并不产出 action,Fizz 那边 formAction = customFields.action || '' 于是写成 action=""(提交回当前页面 URL,见 packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js:1980);框架可以传入自定义的 encodeFormAction 改成别的 URL
  3. 注入了隐藏字段,Action ID 编码在字段名里——$ACTION_ID_<id>(只有 name,没有 value)——服务端靠遍历字段名前缀定位函数;闭包场景则是 $ACTION_REF_<n> 配合一组 $ACTION_<n>:... 字段携带 bound 参数(见 §13.6.5 的 decodeAction)
  4. enctype 设为 multipart/form-data——确保二进制数据(文件上传)也能正确传输

当 JavaScript 未加载时,浏览器的原生表单提交机制会将数据 POST 到 action URL。服务端收到请求后:

// 框架的 Server Action 请求处理器(示意,与 §13.6.5 的 decodeAction 同构)
async function handleServerActionRequest(request: Request): Promise<Response> {
  const formData = await request.formData();

  // 1. 遍历字段名,从 $ACTION_ID_ 前缀中提取 Action ID
  //   (闭包场景走 $ACTION_REF_<n> 分支,经 Flight 反序列化还原 bound 参数)
  let actionId: string | null = null;
  for (const key of formData.keys()) {
    if (key.startsWith('$ACTION_ID_')) {
      actionId = key.slice('$ACTION_ID_'.length);
      formData.delete(key);
      break;
    }
  }

  // 2. 从注册表中查找对应的函数
  const actionFn = serverActionRegistry.get(actionId);
  if (!actionFn) {
    return new Response('Action not found', { status: 404 });
  }

  // 3. 执行 action
  try {
    const result = await actionFn(formData);

    // 4. 判断请求类型
    const isRSCRequest = request.headers.get('Accept')?.includes('text/x-component');

    if (isRSCRequest) {
      // JavaScript 已加载:返回 RSC 流
      return new Response(renderToRSCStream(result), {
        headers: { 'Content-Type': 'text/x-component' },
      });
    } else {
      // JavaScript 未加载:这里选择重定向触发整页刷新(策略之一)
      return Response.redirect(request.url, 303);
    }
  } catch (error) {
    // 错误处理
    return new Response('Action failed', { status: 500 });
  }
}

这段代码的第 4 步揭示了渐进增强的核心:服务端通过请求头区分两种请求。检查哪个头由框架决定:上面的示意看 Accept,React 自带的 fixtures/flight/server/region.js 看有没有 rsc-action 头,Next.js 看 Next-Action 头。如果是 JavaScript 已加载的调用,返回 RSC 流式响应;如果是 JavaScript 未加载的原生提交,可以像上面这样重定向、触发整页刷新,也可以像 React 自带示例那样用 decodeAction 执行后调用 decodeFormState,直接渲染整页,把 action 结果带回 useActionState(fixtures/flight/server/region.js:214-220)。

深度洞察:渐进增强不仅是一个"优雅降级"的策略,它实际上改变了 Web 应用的可靠性模型。在传统 SPA 中,如果 JavaScript 加载失败(CDN 故障、网络不稳定、浏览器扩展冲突),整个应用就变成了一个空白页面。而使用 Server Actions 的表单在 JavaScript 失效时仍然能够提交数据——用户体验会降级(没有乐观更新、没有即时反馈),但核心功能不会丧失。这是 Server Actions 相比 tRPC 或 React Query 等方案最本质的区别。

13.3.3 FormData 序列化的细节

当 JavaScript 已加载时,React 不使用原生表单提交,而是交给框架的 callServer 通过 fetch 发送 Server Action 请求。此时的序列化过程更为精细:

// 框架层 callServer 的请求构造逻辑(示意)
// react-server-dom-webpack/client 只提供 encodeReply,fetch 由框架的 callServer 发起
async function callServerAction(
  actionId: string,
  args: unknown[]
): Promise<unknown> {
  // 1. 序列化参数
  // Server Actions 使用 React 的 Flight 序列化格式
  // 它比 JSON 更强大(见 ReactFlightReplyClient.js 的 encodeReply),支持:
  // - FormData / Blob / File 原生传输
  // - Date 对象、BigInt、Map、Set、ArrayBuffer/TypedArray、Iterator
  // - Promise(thenable 会被等待后再编码)
  // - Server Reference(服务端函数引用可原样传回)
  // 注意:React 元素默认不能从客户端传给 Server Function——
  // 只有提供 temporaryReferences 时才作为临时引用传回,否则直接抛错

  // 参数统一经 encodeReply 编码,结果是字符串(纯 JSON 能表达时)或 FormData:
  // 根模型放在字段 "0";FormData 参数的各字段加上 "<n>_" 前缀拷进去,
  // Blob、Promise 等各占一个编号字段
  // (packages/react-client/src/ReactFlightReplyClient.js:544-560、859-868)
  // 注意:Action ID 不进请求体——它由框架层携带
  // (Next.js 放在 `Next-Action` 请求头里;无 JS 降级路径
  //   才用 $ACTION_ID_<id> 字段名,见 §13.3.2)
  const body = await encodeReply(args);
  // body 为 FormData 时不设置 Content-Type——让浏览器自动生成 boundary

  // 2. 发送请求:POST 到当前页面 URL,Action ID 在请求头中
  const response = await fetch(currentUrl, {
    method: 'POST',
    body,
    headers: { 'Next-Action': actionId },
  });

  // 3. 解析响应(RSC Flight 流)
  return decodeFlightResponse(response);
}

这里有一个微妙但重要的细节:当请求体是 FormData 时,callServer 不能手动设置 Content-Type 头。这是因为 multipart/form-data 格式需要一个随机生成的 boundary 字符串来分隔各个字段,而这个 boundary 必须出现在 Content-Type 头中。如果手动设置了 Content-Type,浏览器不会自动添加 boundary,导致服务端解析失败。

// 正确的请求头(浏览器自动生成)
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

// 错误:手动设置但缺少 boundary
Content-Type: multipart/form-data

13.3.4 useActionState 的完整运作机制

useActionState 是 Server Actions 在客户端的核心 API,它将 action 函数、状态管理、pending 追踪三者合一:

// useActionState 的类型签名(payload 是泛型 P,不写死 FormData)
// <form action> 场景下 P 通常是 FormData;手动调用 dispatch 时可传任意 P
function useActionState<State, P>(
  action: (prevState: State, payload: P) => State | Promise<State>,
  initialState: State,
  permalink?: string
): [state: State, dispatch: (payload: P) => void, isPending: boolean];

让我们看看它的内部实现原理:

// useActionState 实现于 react-reconciler 包的 ReactFiberHooks.js(mountActionState / updateActionState)
// react 包的 ReactHooks.js 只是转发给 dispatcher.useActionState
// 注意:react-dom-bindings/src/shared/ReactDOMFormActions.js 导出的 Hook/API 是
// useFormStatus / useFormState(兼容别名)/ requestFormReset,并不实现 useActionState
// 以下是概念简化,对应 Hooks 层的 Action 队列 + Transition 协作

function useActionState<State, P>(
  action: (prevState: State, payload: P) => State | Promise<State>,
  initialState: State,
  permalink?: string
): [State, (payload: P) => void, boolean] {
  // 内部通过 Hook 队列维护 state,并以 actionStateReducer 把"新状态"写回
  // pending 状态由 Transition / 异步 Action 队列自动追踪
  // dispatch 时:入队 → 串行执行 action(prevState, payload) → 用返回值更新 state

  // 如果提供了 permalink,在 SSR 时生成原生表单目标
  // 这是渐进增强的关键
  // ...
  return [state, formAction, isPending];
}

function actionStateReducer<State>(
  prevState: State,
  newState: State
): State {
  return newState;
}

permalink 参数值得特别关注。它用于渐进增强场景——当 JavaScript 未加载时,表单要提交到一个真实的 URL,默认是当前页面(action=""),permalink 可以另行指定这个 URL:

function SearchForm() {
  const [results, formAction, isPending] = useActionState(
    searchAction,
    [],
    '/search' // permalink:JS 未加载时的回退 URL
  );

  return (
    <form action={formAction}>
      <input name="q" />
      <button>Search</button>
      {results.map(r => <SearchResult key={r.id} result={r} />)}
    </form>
  );
}

服务端渲染时,若 action 是 Server Function 且传了 permalink,生成的表单 action 就是 /search(method 仍是 POST,enctype 仍是 multipart/form-data),而不是默认的空字符串(提交回当前页面)——Fizz 用 permalink 覆盖了表单的 action(packages/react-server/src/ReactFizzHooks.js:700-706);普通客户端函数不走这条路径,permalink 不起作用。这样没有 JavaScript 时用户仍然会被导航到有意义的页面。

13.4 乐观更新与错误处理的统一模型

13.4.1 乐观更新的需求

在任何涉及网络请求的 UI 中,"等待响应"都是用户体验的杀手。如果点击"点赞"按钮后需要等待 200ms 的网络延迟才能看到数字变化,整个界面就会让人感到迟钝。乐观更新(Optimistic Update)的策略是:先假设操作会成功,立即更新 UI,如果操作失败再回退。

React 19 通过 useOptimistic 将这个模式标准化:

import { useState, useOptimistic, startTransition } from 'react';
import { likePost } from './actions'; // 'use server' 文件里导出的 Server Action

function LikeButton({ postId, initialCount }: { postId: string; initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  const [optimisticCount, addOptimistic] = useOptimistic(
    count,
    (currentCount: number, increment: number) => currentCount + increment
  );

  async function handleLike() {
    addOptimistic(1); // 立即 +1(乐观)

    // 实际的 Server Action 调用
    const newCount = await likePost(postId);
    // await 之后的 setState 不再自动属于这次 transition,要再包一层 startTransition
    startTransition(() => {
      setCount(newCount); // 用真实值替换乐观值
    });
  }

  return (
    <form action={handleLike}>
      <button>❤️ {optimisticCount}</button>
    </form>
  );
}

13.4.2 双层状态模型的实现

useOptimistic 的内核是一个双层状态模型:

// packages/react-reconciler/src/ReactFiberHooks.js:1952 中 useOptimistic 的核心实现(简化)
// mountOptimistic 在 L1952,dispatchOptimisticSetState 在 L3605
function mountOptimistic<S, A>(
  passthrough: S,
  reducer: ((state: S, action: A) => S) | null
): [S, (action: A) => void] {
  const hook = mountWorkInProgressHook();

  hook.memoizedState = hook.baseState = passthrough;
  const queue: UpdateQueue<S, A> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    // v19.0.0:乐观更新不走 eager state 优化,二者均为 null
    lastRenderedReducer: null,
    lastRenderedState: null,
  };
  hook.queue = queue;

  const dispatch = dispatchOptimisticSetState.bind(
    null,
    currentlyRenderingFiber,
    true, // throwIfDuringRender:渲染期间调用直接抛错
    queue
  );
  queue.dispatch = dispatch;

  return [passthrough, dispatch];
}

function updateOptimistic<S, A>(
  passthrough: S,
  reducer: ?(S, A) => S,
): [S, (A) => void] {
  const hook = updateWorkInProgressHook();
  return updateOptimisticImpl(
    hook,
    ((currentHook: any): Hook),
    passthrough,
    reducer,
  );
}

function updateOptimisticImpl<S, A>(
  hook: Hook,
  current: Hook | null,
  passthrough: S,
  reducer: ?(S, A) => S,
): [S, (A) => void] {
  // Optimistic updates are always rebased on top of the latest value passed in
  // as an argument. It's called a passthrough because if there are no pending
  // updates, it will be returned as-is.
  //
  // Reset the base state to the passthrough. Future updates will be applied
  // on top of this.
  hook.baseState = passthrough;

  // If a reducer is not provided, default to the same one used by useState.
  const resolvedReducer: (S, A) => S =
    typeof reducer === 'function' ? reducer : (basicStateReducer: any);

  // 没有单独的"乐观队列"——直接复用 useReducer 的更新队列处理逻辑:
  // 把 baseState 重置为 passthrough 后,让 updateReducerImpl 在其上
  // 重放仍在 pending 的乐观 update
  return updateReducerImpl(hook, ((currentHook: any): Hook), resolvedReducer);
}

这个实现的关键在于 passthrough 参数。它是"真实状态"——当 Server Action 完成后,passthrough 会更新为服务端返回的真实值。但 passthrough 变化本身不会丢弃乐观更新:updateOptimisticImpl 只是把 baseState 重置为新的 passthrough,尚未撤销的乐观 update 会在它之上重放。决定撤销时机的是 revertLane:源码层面,dispatchOptimisticSetState 派发的 update 是 lane: SyncLane 加上 revertLane: requestTransitionLane(transition)——transition 一结束,带 revertLane 的 update 就在 updateReducerImpl 里被撤销,所以乐观值只活在 action 进行期间,不需要手动清理。

时间线:

t0: passthrough=10, 乐观更新 +1  → 显示 11
t1: passthrough=10, 又点了一次 +1 → 显示 12
t2: 第一个 action 完成,setCount(11) 包在 startTransition 里;两个 action 同处一个
    纠缠的 action 作用域(ReactFiberAsyncAction.js),第二个还没结束,这次更新暂不提交
    → 仍显示 12
t3: 第二个 action 完成,setCount(12);作用域结束,passthrough=12 与撤销乐观 update
    一起提交 → 显示 12

13.4.3 错误处理:Error Boundary 与 Action 的协作

Server Actions 的错误处理遵循 React 的统一错误模型——通过 Error Boundary 捕获:

function TodoApp() {
  return (
    <ErrorBoundary fallback={<p>Something went wrong</p>}>
      <TodoForm />
    </ErrorBoundary>
  );
}

function TodoForm() {
  const [state, formAction, isPending] = useActionState(createTodo, null);

  return (
    <form action={formAction}>
      <input name="title" required />
      <button disabled={isPending}>Add Todo</button>
    </form>
  );
}

当 Server Action 抛出错误时,React 的处理流程是:

// Server Action 错误传播的简化流程(示意,非 React 真实 API)
async function processServerActionResponse(response: Response) {
  const flightData = await decodeFlightResponse(response);

  if (flightData.error) {
    // 服务端错误被编码在 Flight 响应中
    // React 将其作为 Transition 中的错误抛出
    // 这会触发最近的 Error Boundary
    throw deserializeError(flightData.error);
  }

  // 成功:应用返回的 RSC 树更新
  applyFlightUpdate(flightData);
}

但有一个重要的细节:Server Action 的错误不会导致乐观更新"悬挂"。乐观 update 的撤销只看所属 transition 是否结束,不区分成功还是失败;action 抛错后 transition 同样会结束,useOptimistic 的乐观状态随之回退到 passthrough 值:

// 乐观更新 + 错误回退的完整流程
function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state: Todo[], newTodo: Todo) => [...state, newTodo]
  );

  async function addTodo(formData: FormData) {
    const title = formData.get('title') as string;
    const tempTodo = { id: 'temp', title, completed: false };

    addOptimistic(tempTodo); // 乐观添加

    try {
      await createTodo(formData); // Server Action
      // 成功:revalidation 会更新 todos prop
      // transition 结束时乐观值被撤销,界面改由新的 todos 驱动
    } catch (error) {
      // 失败:Transition 结束,乐观值自动回退
      // 用户看到的列表恢复到 addOptimistic 之前的状态
      throw error; // 重新抛出,让 Error Boundary 处理
    }
  }

  return (
    <div>
      <form action={addTodo}>
        <input name="title" />
        <button>Add</button>
      </form>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} style={{ opacity: todo.id === 'temp' ? 0.5 : 1 }}>
            {todo.title}
          </li>
        ))}
      </ul>
    </div>
  );
}

13.4.4 并发 Actions 的调度

当用户快速连续触发同一个 useActionState 的 dispatch 时,React 会用一条 action 队列保证它们的执行顺序和状态一致性(直接在 startTransition 里调用的 Server Action 不进这条队列,彼此并不串行):

// React 内部的 Action 队列管理(概念模型;payload 泛型与 useActionState 一致)
type ActionQueueNode<State, P = unknown> = {
  action: (prevState: State, payload: P) => Promise<State>;
  payload: P;
  next: ActionQueueNode<State, P> | null;
  resolve: (state: State) => void;
  reject: (error: unknown) => void;
};

// 多个 action 按顺序执行,而不是并发
// 这确保每个 action 都能拿到前一个 action 的最新 state
async function processActionQueue<State, P = unknown>(
  queue: ActionQueueNode<State, P>,
  initialState: State
) {
  let currentState = initialState;
  let node: ActionQueueNode<State, P> | null = queue;

  while (node !== null) {
    try {
      currentState = await node.action(currentState, node.payload);
      node.resolve(currentState);
      node = node.next;
    } catch (error) {
      // v19 真实语义(`packages/react-reconciler/src/ReactFiberHooks.js:2281` 的 onActionError):
      // 1. reject 当前节点
      // 2. 循环 reject 队列里所有后续节点("Mark all the following actions as rejected")
      // 3. 清空 actionQueue.action,阻止继续 dispatch
      // 因此:一个 action 失败会阻断整条后续队列,而不是"失败后继续跑"
      node.reject(error);
      let rest = node.next;
      while (rest !== null) {
        rest.reject(error);
        rest = rest.next;
      }
      break;
    }
  }
}

这种串行执行策略是有意为之的。如果允许同一个 useActionState 的 action 并发执行,它们可能会看到过时的 prevState,导致状态冲突。例如,两个"添加待办事项"的 action 如果并发执行,都会基于相同的旧列表追加,可能导致数据丢失。而一旦队列中某个 action 失败,后续已入队的 action 也会被一并 reject——避免在错误状态上继续叠写。

深度洞察:React 的 Action 队列模型体现了一个深刻的设计哲学:在数据变更场景中,可预测性比并发性更重要。并发渲染(Concurrent Rendering)是 React 的读取优化——它让 UI 在数据获取时保持响应。而 useActionState 队列的串行执行(以及失败后整队阻断)是 React 的写入保障——它确保每次变更都基于最新状态,且错误不会被静默吞掉后继续跑。这种"读并发、写串行"的模式,与数据库事务的隔离级别有异曲同工之妙。

13.5 安全性考量:Server Actions 的攻击面分析

13.5.1 Server Actions 是公开的 HTTP 端点

这是使用 Server Actions 时最容易忽视的安全事实:每一个 Server Action 都是一个可以被任意 HTTP 客户端调用的公开端点。"use server" 标记的函数不是某种私有 API——它只是意味着"这个函数可以通过网络调用"。

# 任何人都可以用 curl 调用你的 Server Action
# 注意 Action ID 编码在字段名里($ACTION_ID_<id>),字段值为空
curl -X POST https://your-app.com/api/action \
  -F '$ACTION_ID_a1b2c3d4e5f6=' \
  -F 'title=Malicious Data'

这意味着 Server Action 中必须包含身份验证和授权检查:

'use server';

import { auth } from '@/lib/auth';

export async function deletePost(postId: string) {
  // ❌ 危险:没有验证调用者身份
  // await db.post.delete({ where: { id: postId } });

  // ✅ 正确:始终验证身份和权限
  const session = await auth();
  if (!session?.user) {
    throw new Error('Unauthorized');
  }

  const post = await db.post.findUnique({ where: { id: postId } });
  if (post.authorId !== session.user.id) {
    throw new Error('Forbidden');
  }

  await db.post.delete({ where: { id: postId } });
  revalidatePath('/posts');
}

13.5.2 闭包变量泄露

内联 Server Actions 的闭包变量会被序列化到客户端 HTML 中。这可能导致敏感信息泄露:

// ⚠️ 安全隐患:secretKey 会被序列化到客户端
function AdminPanel({ secretKey }: { secretKey: string }) {
  async function performAction() {
    'use server';
    // secretKey 被闭包捕获
    // 编译器会将它作为 bound argument,随 RSC payload 发到客户端
    await callExternalAPI(secretKey);
  }

  return <form action={performAction}><button>Execute</button></form>;
}

按 React 默认的编码(defaultEncodeFormAction,packages/react-client/src/ReactFlightReplyClient.js:912-961),服务端渲染出的 HTML 会包含:$ACTION_REF_<n> 定位引用,配套的 $ACTION_<n>:... 字段携带 bound 参数:

<input type="hidden" name="$ACTION_REF_1"/>
<input type="hidden" name="$ACTION_1:0" value="{&quot;id&quot;:&quot;a1b2c3...&quot;,&quot;bound&quot;:&quot;$@1&quot;}"/>
<input type="hidden" name="$ACTION_1:1" value="[&quot;sk-live-...&quot;]"/>

React 本身不加密 bound 参数——secretKey 就以明文出现在 $ACTION_1:1 里,RSC payload 中同样如此。加密是框架层的实现:Next.js 的编译器会插入加密调用,渲染时用服务端密钥加密闭包变量后再作为 bound 参数(React 源码中没有这一步)。即便如此,把敏感值交给客户端保管也增加了攻击面。正确的做法是在服务端获取敏感数据,而不是通过闭包传递:

// ✅ 正确:敏感数据在服务端获取
function AdminPanel({ userId }: { userId: string }) {
  async function performAction() {
    'use server';
    // 只捕获 userId(非敏感信息)
    // secretKey 在服务端从安全存储获取
    const secretKey = await getSecretFromVault(userId);
    await callExternalAPI(secretKey);
  }

  return <form action={performAction}><button>Execute</button></form>;
}

13.5.3 CSRF 防护

跨站请求伪造(CSRF)是 Server Actions 面临的经典 Web 安全威胁。由于 Server Actions 通过 POST 请求执行副作用操作,攻击者可能构造恶意页面诱导用户提交表单到你的应用。

React 本身不做 CSRF 校验,下面这些防御由框架(如 Next.js)在请求处理层实现,多层叠加:

// 层 1:Origin 检查
// 服务端验证请求的 Origin 头与应用域名匹配
function validateOrigin(request: Request): boolean {
  const origin = request.headers.get('Origin');
  const host = request.headers.get('Host');

  if (!origin) {
    // 没有 Origin 头的 POST 请求(现代浏览器的 POST 都会带 Origin,
    // 缺失多见于旧浏览器或非浏览器客户端)需要额外的验证机制
    return checkReferer(request);
  }

  return new URL(origin).host === host;
}

// 层 2:自定义请求头
// 框架的 callServer 发 fetch 时会添加特定的请求头(React 本身不发 fetch)
// 由于 CORS 的限制,跨域请求无法添加自定义头
// 这提供了额外的 CSRF 防护
const response = await fetch(actionEndpoint, {
  method: 'POST',
  body: formData,
  headers: {
    'Next-Action': actionId,  // 自定义头,跨域 form 提交无法携带
  },
});

// 层 3:加密绑定参数(框架层实现,React 不加密 bound 参数)
// 闭包变量使用服务端密钥加密
// 攻击者无法伪造有效的 Action 请求
function encryptBoundArgs(args: unknown[], secretKey: string): string {
  const payload = JSON.stringify(args);
  return encrypt(payload, secretKey);
}

Origin 检查是第一道防线——如果请求不是来自同源页面,直接拒绝。自定义请求头是第二道防线——浏览器不允许跨域表单提交携带自定义头(除非通过 CORS preflight 授权)。加密绑定参数是第三道防线——即使攻击者知道 Action ID,也无法伪造合法的加密参数。

13.5.4 输入验证:永远不要信任客户端

Server Actions 的参数来自网络,本质上与传统 API 的请求参数没有区别——它们可以被任意篡改。类型签名只是编译时的保证,运行时必须进行严格验证:

'use server';

import { z } from 'zod';

// 定义验证 schema
const CreateTodoSchema = z.object({
  title: z.string().min(1).max(200),
  priority: z.enum(['low', 'medium', 'high']),
});

export async function createTodo(formData: FormData) {
  // 始终验证输入
  const parsed = CreateTodoSchema.safeParse({
    title: formData.get('title'),
    priority: formData.get('priority'),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten() };
  }

  // 验证通过后才执行业务逻辑
  const { title, priority } = parsed.data;
  const session = await auth();
  if (!session) {
    return { error: { message: 'Unauthorized' } };
  }

  const todo = await db.todo.create({
    data: {
      title,
      priority,
      userId: session.user.id,
    },
  });

  revalidatePath('/todos');
  return { data: todo };
}

13.5.5 速率限制与滥用防护

由于 Server Actions 可以执行数据库操作、发送邮件、调用外部 API 等有成本的操作,缺乏速率限制会导致资源耗尽或财务损失:

'use server';

import { rateLimit } from '@/lib/rate-limit';

const limiter = rateLimit({
  interval: 60 * 1000, // 1 分钟
  uniqueTokenPerInterval: 500,
});

export async function sendMessage(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');

  // 基于用户 ID 的速率限制
  try {
    await limiter.check(10, session.user.id); // 每分钟最多 10 次
  } catch {
    return { error: 'Too many requests. Please try again later.' };
  }

  // 业务逻辑...
}

深度洞察:Server Actions 的安全模型暴露了一个深层的架构张力——开发体验与安全意识之间的矛盾。Server Actions 的魔力在于让远程调用"看起来像本地调用",但正是这种无缝体验容易让开发者忘记一个关键事实:这些函数的参数来自不可信的网络。传统 API Routes 的"冗余"——手动解析请求、验证参数、检查权限——实际上是安全检查点的物理体现。Server Actions 消除了这些物理边界,开发者必须用纪律和工具来弥补。在团队中推行 Server Actions 时,建立代码审查清单和 lint 规则(例如要求每个 Server Action 必须调用 auth())是至关重要的。

13.6 Server Actions 与 RSC 的数据流闭环

13.6.1 完整的读写循环

React Server Components(RSC)解决了数据的"读取"问题——服务端组件可以直接查询数据库并渲染 UI。Server Actions 解决了数据的"写入"问题——客户端组件可以调用服务端函数来修改数据。两者结合,形成了完整的数据流闭环:

┌─────────────────────────────────────────────────────┐
│                    Server                            │
│                                                      │
│  ┌──────────────┐         ┌──────────────────┐      │
│  │ Server       │  读取   │   Database /     │      │
│  │ Components   │◄───────│   External API    │      │
│  │ (RSC)        │         │                  │      │
│  └──────┬───────┘         └───────▲──────────┘      │
│         │                         │                  │
│         │ RSC Stream              │ 写入             │
│         │                         │                  │
│         ▼                   ┌─────┴──────────┐      │
│  ┌──────────────┐          │ Server          │      │
│  │ Flight       │          │ Actions         │      │
│  │ Response     │          │                 │      │
│  └──────┬───────┘          └───────▲─────────┘      │
│         │                          │                 │
└─────────┼──────────────────────────┼─────────────────┘
          │                          │
          │  Streaming               │  POST + FormData
          │  HTML/Flight             │  或 Flight-encoded
          ▼                          │
┌─────────────────────────────────────────────────────┐
│                    Client                            │
│                                                      │
│  ┌──────────────┐         ┌──────────────────┐      │
│  │ Client       │ action  │   <form>         │      │
│  │ Components   │◄───────│   useActionState  │      │
│  │              │         │   useOptimistic   │      │
│  └──────────────┘         └──────────────────┘      │
│                                                      │
└─────────────────────────────────────────────────────┘

13.6.2 revalidation:写入后的自动刷新

Server Action 完成后,通常需要刷新页面上的数据。React 本身没有 revalidation API,下面两种机制都由 Next.js 提供:

'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function updatePost(postId: string, formData: FormData) {
  const title = formData.get('title') as string;
  await db.post.update({ where: { id: postId }, data: { title } });

  // 方式 1:基于路径的 revalidation
  // 使指定路径的缓存失效,触发该路径下所有 Server Components 重新渲染
  revalidatePath(`/posts/${postId}`);

  // 方式 2:基于标签的 revalidation
  // 使指定缓存标签失效(更精细的控制)
  revalidateTag(`post-${postId}`);
}

当 Server Action 调用 revalidatePath 或 revalidateTag 后,框架会在响应中包含重新渲染后的 RSC 树。客户端收到这个响应后,会将新的 RSC 树与当前 UI 树进行协调(reconciliation),只更新变化的部分。

这个过程的技术实现链路如下:

// 简化的 revalidation 响应处理流程(框架层示意,非 React API)
async function handleServerActionResponse(response: Response) {
  // 1. 解码 Flight 响应
  const flightData = await decodeFlightResponse(response);

  // 2. 响应可能包含两部分:
  //    - actionResult:Server Action 的返回值
  //    - tree:重新渲染后的 RSC 树(如果有 revalidation)

  if (flightData.actionResult !== undefined) {
    // 更新 useActionState 的状态
    resolveActionState(flightData.actionResult);
  }

  if (flightData.tree !== undefined) {
    // 用新的 RSC 树触发客户端协调
    // 这类似于一次"无感刷新"——用户看到的是 UI 平滑更新
    // 而不是整个页面重新加载
    startTransition(() => {
      applyRSCTreeUpdate(flightData.tree);
    });
  }
}

13.6.3 一个完整的全栈数据流示例

让我们用一个完整的示例来展示 Server Actions 与 RSC 的协作:

// app/todos/page.tsx(Server Component)
import { db } from '@/lib/db';
import { TodoForm } from './TodoForm';
import { TodoList } from './TodoList';

export default async function TodosPage() {
  // 服务端直接查询数据库
  const todos = await db.todo.findMany({
    orderBy: { createdAt: 'desc' },
  });

  return (
    <main>
      <h1>My Todos</h1>
      <TodoForm />
      <TodoList todos={todos} />
    </main>
  );
}

// app/todos/actions.ts(Server Actions)
'use server';

import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const TodoSchema = z.object({
  title: z.string().min(1).max(200),
});

export async function createTodo(
  prevState: { error?: string } | null,
  formData: FormData
) {
  const session = await auth();
  if (!session) return { error: 'Please sign in' };

  const parsed = TodoSchema.safeParse({
    title: formData.get('title'),
  });

  if (!parsed.success) {
    return { error: parsed.error.issues[0].message };
  }

  await db.todo.create({
    data: {
      title: parsed.data.title,
      userId: session.user.id,
    },
  });

  revalidatePath('/todos');
  return null; // 成功:无错误
}

export async function toggleTodo(id: string, completed: boolean) {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');

  // 验证所有权
  const todo = await db.todo.findUnique({ where: { id } });
  if (todo?.userId !== session.user.id) throw new Error('Forbidden');

  await db.todo.update({
    where: { id },
    data: { completed },
  });

  revalidatePath('/todos');
}

// app/todos/TodoForm.tsx(Client Component)
'use client';

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

export function TodoForm() {
  const [state, formAction, isPending] = useActionState(createTodo, null);

  return (
    <form action={formAction}>
      <input
        name="title"
        placeholder="What needs to be done?"
        required
        disabled={isPending}
      />
      <button disabled={isPending}>
        {isPending ? 'Adding...' : 'Add Todo'}
      </button>
      {state?.error && (
        <p role="alert" style={{ color: 'red' }}>{state.error}</p>
      )}
    </form>
  );
}

// app/todos/TodoList.tsx(Client Component)
'use client';

import { useOptimistic, useTransition } from 'react';
import { toggleTodo } from './actions';

type Todo = { id: string; title: string; completed: boolean };

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, updateOptimistic] = useOptimistic(
    todos,
    (state: Todo[], updatedTodo: { id: string; completed: boolean }) =>
      state.map(todo =>
        todo.id === updatedTodo.id
          ? { ...todo, completed: updatedTodo.completed }
          : todo
      )
  );
  const [, startTransition] = useTransition();

  function handleToggle(id: string, currentCompleted: boolean) {
    const newCompleted = !currentCompleted;
    startTransition(async () => {
      updateOptimistic({ id, completed: newCompleted });
      await toggleTodo(id, newCompleted);
    });
  }

  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li key={todo.id}>
          <label style={{ opacity: todo.completed ? 0.5 : 1 }}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggle(todo.id, todo.completed)}
            />
            {todo.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

这个示例展示了一个完整的数据流:

  1. 读取:Server Component 直接查询数据库,将数据作为 props 传递给 Client Component
  2. 写入:Client Component 调用 Server Actions 修改数据
  3. 乐观更新:useOptimistic 在 Server Action 完成前就更新 UI
  4. 自动刷新:revalidatePath(Next.js 提供)触发 Server Component 重新渲染,新数据自动流向客户端
  5. 错误处理:useActionState 管理错误状态,表单显示验证错误
  6. 渐进增强:即使 JavaScript 未加载,表单通过原生 HTML 提交仍然可以创建待办事项

13.6.4 从客户端调用到服务端函数的协议边界

到这里,读写闭环已经齐了:Server Component 读、Server Action 写、revalidation 把两边重新接上。还差一层"协议边界"——浏览器提交的 FormData 如何被还原成真正的服务端函数调用。下一节按 v19.0.0 源码核对这条最短路径。

13.6.5 源码核对:FormData 到函数调用的最短路径

Server Actions 看起来像"从客户端直接调用服务端函数",源码里实际多了一层非常明确的协议转换。v19.0.0 的 packages/react-server/src/ReactFlightActionServer.js 中,decodeAction(body, serverManifest) 定义在约 L83,输入是浏览器提交来的 FormData 和服务端 manifest;随后 body.forEach(约 L94)遍历表单字段,普通字段会复制到新的 formData,以 $ACTION_ 开头的字段则被视为框架协议字段。也就是说,业务表单数据和框架动作元数据在同一个 HTML 表单里提交,但 React 会在服务端把它们拆开。

动作定位分两条路:约 L102-110 处理 $ACTION_REF_,先通过 Flight response 解出带 bound 参数的 server reference;约 L112-116 处理 $ACTION_ID_,直接根据 id 从 manifest 加载 server reference。最后,约 L119-123 在没有动作时返回 null,有动作时把剩余的 FormData 绑定为服务端函数的第一个参数。这个实现解释了为什么 Server Actions 天然适合 <form action={fn}>:HTML 表单负责传输字段,Flight 协议负责定位函数,React server 层只需要把二者重新组合成一次函数调用。

13.7 本章小结

Server Actions 是 React 从"UI 渲染库"向"全栈应用基础设施"演进的关键一步。通过 "use server" 指令、编译时代码转换、FormData 序列化协议和与 React 渲染引擎的深度集成,它实现了以下目标:

关键要点:

  1. 消除了前后端的 API 胶水层:开发者直接在组件中调用服务端函数,编译器负责将函数调用转化为网络请求
  2. "use server" 是编译时指令,不是运行时标记:它触发代码分割——客户端获得代理 stub,服务端保留真实实现,两者通过 Action ID 关联
  3. 渐进增强是一等公民:Server Actions 的表单在 JavaScript 未加载时通过原生 HTML 表单提交工作,确保核心功能的可靠性
  4. 乐观更新与错误回退被纳入 React 的声明式模型:useOptimistic 的双层状态和 Error Boundary 的自动集成,让数据变更场景的状态管理变得可预测
  5. 安全性需要开发者主动保障:Server Actions 是公开的 HTTP 端点,身份验证、输入验证、速率限制缺一不可
  6. 与 RSC 形成数据流闭环:Server Components 负责读取,Server Actions 负责写入,框架的 revalidation 机制(如 Next.js 的 revalidatePath)确保写入后的 UI 自动更新

在下一章中,我们将深入 React 的合成事件系统——这是 React 与浏览器 DOM 交互的核心抽象层。理解事件系统的委托模型、优先级协作和 React 19 中的简化变化,是掌握 React 渲染管线"最后一公里"的关键。


思考题

  1. Server Actions 的闭包变量序列化存在安全风险。 假设一个内联 Server Action 闭包捕获了一个包含 userId 和 role 的对象,攻击者篡改了序列化后的 role 字段为 "admin"。框架的加密绑定参数机制能否完全防御这种攻击?如果不能,还需要什么额外的防护措施?

  2. 在高并发场景下,多个 Server Actions 被串行执行可能导致用户感知到显著的延迟。 假设用户在一个待办列表中快速连续勾选了 5 个复选框,每个触发一个 toggleTodo Server Action(每次耗时 200ms),且这些调用经由同一个 useActionState 的 dispatch 排队。在这种串行队列模型下,最后一个操作需要等待 1000ms 才能完成。请设计一种方案,在保证最终一致性的前提下,将用户感知延迟降低到接近 200ms。提示:考虑批处理(batching)和合并(coalescing)策略。

  3. useActionState 的 permalink 参数实现了渐进增强,但它引入了一个微妙的状态同步问题。 当 JavaScript 未加载时,表单提交会导致全页面导航到 permalink 指定的 URL。此时,Server Action 的返回值(新的 state)如何传递给重新渲染的页面?如果 Server Action 返回了一个错误状态,permalink 对应的页面是否能正确显示这个错误?分析当前框架实现的解决方案及其局限性。

  4. Server Actions 的编译时代码转换依赖于静态分析。 构造一个合法的 TypeScript 代码示例,其中 "use server" 指令的行为可能不符合开发者的直觉——例如,某些变量看起来应该在服务端执行,但实际上被泄露到了客户端。解释编译器在这种边界情况下的具体行为。