- 发布日期
第 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怎么扫、怎么生成serverActionsmanifest。 - 掌握
encryptionKey+ bound args 加密机制:客户端凭什么不能伪造。 - 区分
<form action={fn}>与编程式调用(startTransition/useTransition);进度态useFormStatus/useActionState。 - 在 fixture 中跑通完整 CRUD:创建/更新/删除商品 + revalidateTag + redirect。
1. Server Actions 是什么
简单说:Server Action 是一个能从客户端调用的、由服务端执行的、带身份与签名的函数。
跟传统 API 路由对比:
| 维度 | API Route | Server 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 同目录)做:
- 扫描
'use server'指令(顶层 = 整文件,函数体内 = 单函数)。 - 对每个被标记的函数,抽取闭包外引用(bound args)作为额外参数。
- 给函数生成稳定的
actionId(通常是源文件路径 + 函数 hash)。 - 注入元数据,让 webpack 后续能识别。
2.2 webpack 收集:flight-client-entry-plugin
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
export async function handleAction({
req,
res,
ComponentMod,
generateFlight,
workStore,
handleAction 是所有 server action 请求的总入口,做的事:
- 检查
next-actionheader → 取到 actionId。 - 通过
serverModuleMap[actionId]拿 moduleId。 - 加载 module,执行函数。
- 把返回值包进 RSC payload 写回。
3.2 处理 multipart vs JSON
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 校验
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 加密流程
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)
}
要点:
- 用 AES(
encryption-utils.ts内)加密。 - 每次随机 16 字节 IV,附在密文头部。
- 明文前缀必须等于 actionId —— 这是 checksum,防篡改。
- 客户端拿到的是 base64 字符串,无法解密、无法篡改。
4.2 安全意义
没有这套机制,客户端可以伪造闭包变量,例如:
async function deleteThis(otherUserId: string) {
"use server";
// 攻击者篡改 otherUserId 删别人的数据
}
加密后客户端只能传"加密 token",服务端解密后还原——攻击者改不了原值。
生产排查提示:把闭包里的敏感参数显式作为函数参数传入而非依赖 bound args,更安全也更易于测试:
async function deleteProduct(id: string) { 'use server' const session = await getSession() // 从 cookies 拿 if (!session.canDelete(id)) throw new Error('forbidden') await db.product.delete({ where: { id } }) }
4.3 INLINE_ACTION_PREFIX
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
- 内存版商品库(重启清零)。
- 列表页 + 创建表单。
- 详情页 + 修改表单 + 删除按钮。
- 全程使用 Server Actions、
useFormStatus、useActionState。
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. 检验问题
- Server Action 与 API Route 在 7 个维度上的差异?
- SWC transform 阶段为
'use server'函数做了什么?为什么需要稳定 actionId? - encryptionKey 是怎么生成的?dev 与 prod 有什么不同?滚动发布要注意什么?
INLINE_ACTION_PREFIX的作用?怎么从日志区分匿名 action 与具名 action?- fetch action vs MPA action 的处理路径在
handleAction里如何分支? useFormStatus与useActionState各自适合什么场景?'use server'函数里redirect不能放 try/catch 的根本原因?- 在 client component 里能直接
'use server'定义函数吗?为什么? - 为什么不能把整个 user 对象当 bound arg?
- 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 下的全新行为。