发布日期

第 12 讲|Server Actions、Form 与 mutation

Server Actions 的编译产物、加密签名、Form 绑定与 mutation 模式

阶段二:App Router 核心机制 · 第 12 / 40 讲 难度:⭐⭐⭐⭐⭐ · 预计耗时:4 小时 配套 fixture:fixtures/lecture-12/

学习目标

  • 看懂 'use server' 指令的两种用法(顶层 vs 内联)与编译产物。
  • 理解 actionId 路由:flight-client-entry-plugin 怎么扫、怎么生成 serverActions manifest。
  • 掌握 encryptionKey + bound args 加密机制:客户端凭什么不能伪造。
  • 区分 <form action={fn}> 与编程式调用(startTransition / useTransition);进度态 useFormStatus / useActionState
  • 在 fixture 中跑通完整 CRUD:创建/更新/删除商品 + revalidateTag + redirect。

1. Server Actions 是什么

简单说:Server Action 是一个能从客户端调用的、由服务端执行的、带身份与签名的函数

跟传统 API 路由对比:

维度API RouteServer Action
定义方式文件 (route.ts) + HTTP method函数 + 'use server' 指令
URL显式 (/api/foo)隐式(actionId 由编译器分配)
序列化手写 JSON自动(FormData / RSC payload)
类型手写 zod / 校验编译期类型直接共享
缓存协作手写 revalidate内置 revalidatePath / revalidateTag
身份签名你自己实现内置 encryptionKey + actionId
客户端调用fetch('/api/foo')直接 await action(args)

设计哲学:把 RPC 当作 React 组件树的一种事件机制,而不是单独的 HTTP 接口体系。

2. 编译产物:从 'use server' 到 actionId

2.1 SWC transform 阶段

crates/next-custom-transforms/.../server_actions.rs(注意是 .rs,与第 7 讲的 react_server_components.rs 同目录)做:

  1. 扫描 'use server' 指令(顶层 = 整文件,函数体内 = 单函数)。
  2. 对每个被标记的函数,抽取闭包外引用(bound args)作为额外参数。
  3. 给函数生成稳定的 actionId(通常是源文件路径 + 函数 hash)。
  4. 注入元数据,让 webpack 后续能识别。

2.2 webpack 收集:flight-client-entry-plugin

99:packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts
  encryptionKey: string
}

const PLUGIN_NAME = 'FlightClientEntryPlugin'

type Actions = {
  [actionId: string]: {
    exportedName?: string
    filename?: string
    workers: {
      [name: string]: {
        moduleId: string | number
        async: boolean
      }
    }
    // Record which layer the action is in (rsc or sc_action), in the specific entry
    //
    // This is only used by Webpack to correctly output the manifest. It's value shouldn't be relied
    // upon externally. It's possible that the same action can be in different layers in a single
    // page, which cannot be modelled with this API anyway.
    layer?: {
      [name: string]: string
    }
  }
}

type ActionIdNamePair = {
  id: string
  exportedName?: string
  filename?: string
}

export type ActionManifest = {
  // Assign a unique encryption key during production build.
  encryptionKey: string
  node: Actions
  edge: Actions
}

要点:

  • 每个 actionId 映射到 moduleId —— webpack 知道 chunk 里哪个模块负责执行这个 action。
  • node / edge 分开 —— 同一个 action 在不同 runtime 下打不同 chunk。
  • encryptionKey 是 manifest 的字段 —— 加密 bound args 用,全 build 共享。

2.3 产物:server-reference-manifest.json

build 完成后你能在 .next/server/server-reference-manifest.json 看到(第 6 讲讲过):

{
  "encryptionKey": "base64-encoded-key",
  "node": {
    "1234567890abcdef": {
      "workers": { "app/products/page": { "moduleId": "...", "async": true } },
      "filename": "app/products/actions.ts",
      "exportedName": "createProduct"
    }
  },
  "edge": {}
}

dev 模式下 encryptionKey 是稳定的本机派生值;prod build 时是随机 32 字节的密钥(每次 build 不同)。

生产排查提示:滚动发布时多机 build 出来的 encryptionKey 必须一致——否则 A 机器签的 actionId B 机器解不开,触发"Failed to find Server Action"。Vercel 部署自动处理;自建部署需要把 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 注入环境变量统一所有节点。

3. 调用链路:客户端如何"调用"服务端函数

用户点击按钮
React 把 action 函数当 ref 调用
RSC client runtime 把 args 序列化为 FormData / payload
带 actionId  (next-action) POST 到当前 URL
路由层识别 ACTION_HEADER
handleAction 解码 actionId,找到对应 module
执行函数,把返回值包成 RSC payload
返回给客户端
client-router 接收到 server-action / server-patch action
更新 segment cache / 触发 re-render

3.1 入口:handleAction

545:packages/next/src/server/app-render/action-handler.ts
export async function handleAction({
  req,
  res,
  ComponentMod,
  generateFlight,
  workStore,

handleAction 是所有 server action 请求的总入口,做的事:

  • 检查 next-action header → 取到 actionId。
  • 通过 serverModuleMap[actionId] 拿 moduleId。
  • 加载 module,执行函数。
  • 把返回值包进 RSC payload 写回。

3.2 处理 multipart vs JSON

799:packages/next/src/server/app-render/action-handler.ts
            const formData = await req.request.formData()
            if (isFetchAction) {
              // A fetch action with a multipart body.

              try {
                actionModId = getActionModIdOrError(actionId, serverModuleMap)
              // ...
                formData,
                serverModuleMap,
                { temporaryReferences }
              )
            } else {
              // Multipart POST, but not a fetch action.
              // ...
              if (areAllActionIdsValid(formData, serverModuleMap) === false) {
                // TODO: This can be from skew or manipulated input. We should handle this case
                // more gracefully but this preserves the prior behavior where decodeAction would throw instead.
                throw new Error(
                  `Failed to find Server Action. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`
                )
              }

              const action = await decodeAction(formData, serverModuleMap)

两种路径:

  • fetch action(编程式 await action(...)):纯 RSC 编码 args。
  • MPA action(HTML form 提交):FormData 编码,可以含 file upload。

3.3 actionId 校验

796:packages/next/src/server/app-render/action-handler.ts
              if (areAllActionIdsValid(formData, serverModuleMap) === false) {
                // TODO: This can be from skew or manipulated input. We should handle this case
                // more gracefully but this preserves the prior behavior where decodeAction would throw instead.
                throw new Error(
                  `Failed to find Server Action. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`
                )
              }

每个 actionId 都要在 serverModuleMap 中存在——否则报错。这层检查抵御:

  • 跨版本攻击:滚动发布时旧客户端的 actionId 在新机器上不存在。
  • 伪造 ID:随便编一个 actionId。
  • encryptionKey 不一致 会先在解密阶段失败,到不了这里。

4. encryptionKey 与 bound args

'use server' 函数里有时会引用闭包变量:

async function ProductPage({ id }: { id: string }) {
  async function deleteThis() {
    "use server";
    await db.product.delete({ where: { id } }); // 引用了上层 id
    revalidatePath("/products");
  }
  return <DeleteButton action={deleteThis} />;
}

闭包变量 id 不能直接用 args 传——客户端不应该能改它。Next.js 的做法:编译期把闭包变量当作 bound args 加密附在 actionId 上

4.1 加密流程

71:packages/next/src/server/app-render/encryption.ts
async function decodeActionBoundArg(actionId: string, arg: string) {
  const key = await getActionEncryptionKey()
  if (typeof key === 'undefined') {
    throw new Error(
      `Missing encryption key for Server Action. This is a bug in Next.js`
    )
  }

  // Get the iv (16 bytes) and the payload from the arg.
  const originalPayload = atob(arg)
  const ivValue = originalPayload.slice(0, 16)
  const payload = originalPayload.slice(16)

  const decrypted = textDecoder.decode(
    await decrypt(key, stringToUint8Array(ivValue), stringToUint8Array(payload))
  )

  if (!decrypted.startsWith(actionId)) {
    throw new Error('Invalid Server Action payload: failed to decrypt.')
  }

  return decrypted.slice(actionId.length)
}

要点:

  1. 用 AES(encryption-utils.ts 内)加密。
  2. 每次随机 16 字节 IV,附在密文头部。
  3. 明文前缀必须等于 actionId —— 这是 checksum,防篡改。
  4. 客户端拿到的是 base64 字符串,无法解密、无法篡改。

4.2 安全意义

没有这套机制,客户端可以伪造闭包变量,例如:

async function deleteThis(otherUserId: string) {
  "use server";
  // 攻击者篡改 otherUserId 删别人的数据
}

加密后客户端只能传"加密 token",服务端解密后还原——攻击者改不了原值。

生产排查提示:把闭包里的敏感参数显式作为函数参数传入而非依赖 bound args,更安全也更易于测试:

async function deleteProduct(id: string) &#123;
  'use server'
  const session = await getSession() // 从 cookies 拿
  if (!session.canDelete(id)) throw new Error('forbidden')
  await db.product.delete(&#123; where: &#123; id &#125; &#125;)
&#125;

4.3 INLINE_ACTION_PREFIX

79:packages/next/src/server/app-render/action-handler.ts
const INLINE_ACTION_PREFIX = '$$RSC_SERVER_ACTION_'

匿名内联 action(如直接在 JSX 里 <form action={async () => { 'use server'; ... }}>)会得到带这个前缀的 actionId。看到日志里这个前缀,就知道是内联 action 而非具名导出。

5. 客户端使用方式

5.1 形态 A:Form action 是函数

async function createProduct(formData: FormData) {
  "use server";
  const title = formData.get("title") as string;
  await db.product.create({ data: { title } });
  revalidatePath("/products");
}

export function CreateProductForm() {
  return (
    <form action={createProduct}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

特点:

  • 浏览器 No-JS 也能用(fallback 到原生 form post)。
  • formData 自动序列化所有 <input>
  • 提交后服务端执行 → revalidate → React 重渲染 → 表单清空。

5.2 形态 B:编程式调用

"use client";
import { useTransition } from "react";

export function DeleteButton({
  id,
  action,
}: {
  id: string;
  action: (id: string) => Promise<void>;
}) {
  const [pending, start] = useTransition();
  return (
    <button
      disabled={pending}
      onClick={() =>
        start(async () => {
          await action(id);
        })
      }
    >
      {pending ? "deleting..." : "Delete"}
    </button>
  );
}

特点:

  • useTransition 把 action 调用包成低优先级渲染。
  • pending 让你显示 loading 态。
  • action 当 prop 传入——React 内部转成"客户端引用 + actionId"。

5.3 形态 C:useFormStatus

useFormStatus 必须在 <form> 子组件里调用,读父 form 的 pending 态:

"use client";
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>{pending ? "submitting..." : "Submit"}</button>
  );
}

export function CreateForm() {
  return (
    <form action={createProduct}>
      <input name="title" />
      <SubmitButton />
    </form>
  );
}

适合"按钮组件不知道 action 内容、只想读 pending 态"的场景。

5.4 形态 D:useActionState(旧 useFormState)

允许 action 返回数据 + 自动绑定到 form:

"use client";
import { useActionState } from "react";

async function login(prev: { error?: string } | null, formData: FormData) {
  "use server";
  const ok = await checkCredentials(formData);
  if (!ok) return { error: "invalid credentials" };
  redirect("/dashboard");
}

export function LoginForm() {
  const [state, formAction] = useActionState(login, null);
  return (
    <form action={formAction}>
      <input name="email" />
      <input name="password" type="password" />
      <button type="submit">Login</button>
      {state?.error && <p>{state.error}</p>}
    </form>
  );
}

state 在每次提交后更新;formAction 是绑定了 prev state 的 action。

6. revalidate 与 redirect

Server Action 内部最常做两件事:失效缓存与跳转。

6.1 revalidate

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

async function deleteProduct(id: string) {
  "use server";
  await db.product.delete({ where: { id } });
  revalidatePath("/products"); // 失效列表页
  revalidateTag(`product:${id}`); // 失效详情 tag
}

调用时机:必须在 action 函数体内、return 前。Next.js 在 action 完成后看 workStore.pendingRevalidations,统一 flush。

6.2 redirect

import { redirect } from "next/navigation";

async function checkout(formData: FormData) {
  "use server";
  const orderId = await createOrder(formData);
  redirect(`/orders/${orderId}`);
}

redirect 内部 throw 一个特殊异常,不被普通 try/catch 捕获——它会被 React 框架层接住,转换为 Location header / 客户端 navigate。

生产排查提示redirect永远不要 try/catch——不然会吞掉这个特殊异常,redirect 失效。

6.3 revalidate + redirect 顺序

async function createProduct(formData: FormData) {
  "use server";
  const id = await db.product.create({ data: parse(formData) });
  revalidateTag("product:list");
  redirect(`/products/${id}`);
}

正确顺序:先 revalidate 再 redirect——这样跳转后的列表页能立即看到新建的商品。

7. 业务案例:商品 CRUD

完整 CRUD 用 Server Actions 实现:

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

import { revalidatePath, revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";

const Product = z.object({
  title: z.string().min(1),
  price: z.coerce.number().int().positive(),
});

export async function createProduct(formData: FormData) {
  const parsed = Product.safeParse({
    title: formData.get("title"),
    price: formData.get("price"),
  });
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }
  const id = await db.product.create({ data: parsed.data });
  revalidateTag("product:list");
  redirect(`/products/${id}`);
}

export async function updateProduct(id: string, formData: FormData) {
  const parsed = Product.safeParse({
    title: formData.get("title"),
    price: formData.get("price"),
  });
  if (!parsed.success) return { error: parsed.error.flatten().fieldErrors };
  await db.product.update({ where: { id }, data: parsed.data });
  revalidateTag(`product:${id}`);
  revalidateTag("product:list");
}

export async function deleteProduct(id: string) {
  await db.product.delete({ where: { id } });
  revalidateTag(`product:${id}`);
  revalidateTag("product:list");
  redirect("/products");
}
// app/products/page.tsx
import Link from "next/link";
import { createProduct } from "./actions";

export default async function ProductList() {
  const products = await db.product.findMany();
  return (
    <div>
      <ul>
        {products.map((p) => (
          <li key={p.id}>
            <Link href={`/products/${p.id}`}>
              {p.title} - ${p.price}
            </Link>
          </li>
        ))}
      </ul>
      <h3>New product</h3>
      <form action={createProduct}>
        <input name="title" placeholder="Title" />
        <input name="price" placeholder="Price" type="number" />
        <button type="submit">Create</button>
      </form>
    </div>
  );
}
// app/products/[id]/page.tsx
import { updateProduct, deleteProduct } from "../actions";

export default async function ProductDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });
  if (!product) return <p>not found</p>;

  const update = updateProduct.bind(null, id);
  const remove = deleteProduct.bind(null, id);

  return (
    <div>
      <form action={update}>
        <input name="title" defaultValue={product.title} />
        <input name="price" defaultValue={product.price} type="number" />
        <button type="submit">Save</button>
      </form>
      <form action={remove}>
        <button type="submit">Delete</button>
      </form>
    </div>
  );
}

注意:

  • Function.prototype.bind(null, id) 把 id 作为闭包参数——id 会被 encryptionKey 加密附在 actionId 上,客户端不能改。
  • 整套 CRUD 零 fetch 接口、零 useState、零 useEffect——完全声明式。
  • revalidate + redirect 把"提交完成 → 列表更新"做成 atomic 的体验。

8. 重难点

8.1 'use server' vs 'use client'

指令含义出现位置
'use server'这个函数/文件的导出只能在服务端运行函数体顶部 / 文件顶部
'use client'这个文件中的组件只能在客户端运行文件顶部(不能函数级)

它们不是对称的——'use server' 可以函数级,'use client' 必须文件级。

8.2 Server Action 必须是 async

如果你写:

function syncAction() {
  // ← 编译报错
  "use server";
  return "hello";
}

会被 SWC transform 报错"Server Actions must be async functions"。原因:客户端调用是异步的(网络请求),返回类型必须 Promise。

8.3 引用大对象作 bound arg

bound args 的加密负担与对象大小成正比,超过几 KB 加密耗时显著。避免传整个 user 对象——传 userId 即可。

8.4 Server Actions 与 useState 共存

Server Action 不会替换客户端 state。如果你有客户端表单状态:

"use client";

export function DraftForm() {
  const [draft, setDraft] = useState("");

  async function save(formData: FormData) {
    "use server"; // ← 编译错误:不能在 client component 内定义
  }
}

'use server' 函数不能在 'use client' 文件里定义。要么把 action 放到独立文件 actions.ts,要么放在 server component 里然后传给 client 组件。

8.5 部署版本偏移

滚动发布期间客户端 chunk 是 v1(actionId=A1),服务端却是 v2(actionId=A2)→ "Failed to find Server Action"。解决:

  • 蓝绿部署,确保版本一致。
  • 让客户端 chunk URL 带 buildId(默认行为),版本切换时旧 chunk 还能访问旧服务器。
  • Vercel 提供 "skew protection" 自动处理;自建部署需自己实现 sticky routing。

9. 配套 fixture:商品 CRUD

fixtures/lecture-12/ 实现:

  • 内存版商品库(重启清零)。
  • 列表页 + 创建表单。
  • 详情页 + 修改表单 + 删除按钮。
  • 全程使用 Server Actions、useFormStatususeActionState

9.1 推荐实验

cd learning/nextjs-40-lectures/fixtures/lecture-12
pnpm install --ignore-workspace
pnpm dev
# 浏览器打开 http://localhost:3012/

实验 A:创建商品

  • /products,填写表单提交。
  • 网络面板看到 POST 到当前 URL,请求头含 next-action: <hash>
  • 创建后跳转详情页,列表也自动更新。

实验 B:useFormStatus pending

  • 提交按钮使用 useFormStatus(),提交瞬间变灰显示 "submitting..."。

实验 C:useActionState 错误处理

  • 故意提交空 title → action 返回 { error: ... } → useActionState 接到 → 表单下方显示错误。

实验 D:actionId 观察

  • 打开 prod build:pnpm build && pnpm start
  • 查看 .next/server/server-reference-manifest.json,对比 dev 模式下的 manifest。
  • 同一个 build 多次启动 actionId 一致;重新 build 后 actionId 变化。

10. 检验问题

  1. Server Action 与 API Route 在 7 个维度上的差异?
  2. SWC transform 阶段为 'use server' 函数做了什么?为什么需要稳定 actionId?
  3. encryptionKey 是怎么生成的?dev 与 prod 有什么不同?滚动发布要注意什么?
  4. INLINE_ACTION_PREFIX 的作用?怎么从日志区分匿名 action 与具名 action?
  5. fetch action vs MPA action 的处理路径在 handleAction 里如何分支?
  6. useFormStatususeActionState 各自适合什么场景?
  7. 'use server' 函数里 redirect 不能放 try/catch 的根本原因?
  8. 在 client component 里能直接 'use server' 定义函数吗?为什么?
  9. 为什么不能把整个 user 对象当 bound arg?
  10. fixture 实验 D 中 dev / prod 模式 manifest 文件最大的差异是什么?

11. 延伸阅读

  • 源码:packages/next/src/server/app-render/action-handler.ts
  • 源码:packages/next/src/server/app-render/encryption.ts / encryption-utils.ts
  • 源码:packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts
  • 源码:crates/next-custom-transforms/src/transforms/server_actions.rs
  • 文档:docs/01-app/03-api-reference/04-functions/{revalidatePath,revalidateTag,redirect,unstable_cache}.mdx
  • 配套 fixture:fixtures/lecture-12/

下一讲预告

第 13 讲|Caching 全景:4 层缓存的真相:fetch cache、Data Cache、Full Route Cache、Router Cache 四层各管什么?谁先命中?怎么调试?我们将把这一系列缓存合并到一张图,并讲解 'use cache' 在 PPR 下的全新行为。