Server Actions:无需单独 API 的表单处理
1. 总览
以前,要在 Next.js 中处理表单,你必须编写一个 API route(app/api/.../route.ts),然后从客户端用 fetch 来提交数据。Server Actions 消除了这个中间步骤:你写一个在服务器上运行的函数,直接附加到 <form> 的 action 属性,完成。
读完这篇文章,你将学会如何声明 Server Action,如何在表单中使用它进行验证,使用 useFormState 和 useFormStatus 处理 pending/error 状态,在提交后重新验证数据,以及避免从 API route 切换时的常见错误。
注意: Server Actions 是 App Router 的功能(Next.js 13.4+),从 Next.js 14 开始稳定。如果你仍在使用 Pages Router,本文不适用。
2. 第一个 Server Action
Server Action 是一个带有 "use server" 指令的异步函数,可以放在单独的文件中或直接放在组件中(如果该组件是 Server Component)。
// app/actions/customer-actions.ts
"use server";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
console.log("创建客户:", name, email);
// 模拟保存到数据库
}
// app/customers/new/page.tsx
import { createCustomer } from "@/app/actions/customer-actions";
export default function NewCustomerPage() {
return (
<form action={createCustomer}>
<input name="name" placeholder="客户名称" />
<input name="email" placeholder="电子邮件" />
<button type="submit">创建</button>
</form>
);
}
- 文件开头的
"use server"告诉 Next.js 这个文件中的所有导出都是 Server Action,只在服务器上运行,不会被打包到发送给客户端的 JS 中。 action={createCustomer}将函数直接附加到表单。Next.js 会自动将表单序列化为FormData并在提交时在服务器上调用该函数。- 不需要
onSubmit,不需要preventDefault,不需要fetch。即使 JavaScript 还没加载完成,表单也能工作(渐进式增强)。
提交时的结果:日志出现在服务器终端(不是浏览器控制台),因为函数在服务器上运行。
3. 使用 useFormState 向客户端返回结果
实际上,你需要显示验证错误或成功消息。来自 react-dom 的 useFormState 帮助 Server Action 将状态返回给客户端。
// app/actions/customer-actions.ts
"use server";
type ActionResult = { success: boolean; message: string };
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || !email) {
return { success: false, message: "缺少名称或电子邮件。" };
}
if (!email.includes("@")) {
return { success: false, message: "电子邮件无效。" };
}
// 模拟保存到数据库
return { success: true, message: `已创建客户 ${name}。` };
}
// app/customers/new/page.tsx
"use client";
import { useFormState } from "react-dom";
import { createCustomer } from "@/app/actions/customer-actions";
const initialState = { success: false, message: "" };
export default function NewCustomerForm() {
const [state, formAction] = useFormState(createCustomer, initialState);
return (
<form action={formAction}>
<input name="name" placeholder="客户名称" />
<input name="email" placeholder="电子邮件" />
<button type="submit">创建</button>
{state.message && (
<p style={{ color: state.success ? "green" : "red" }}>
{state.message}
</p>
)}
</form>
);
}
useFormState(action, initialState)包装 Server Action,返回[state, formAction]。state是 action 最后返回的值,formAction是实际附加到<form action={...}>的函数。- Server Action 接收一个额外的第一参数
prevState— 当与useFormState一起使用时这是必需的,即使不使用它。 - 因为有
useFormState(一个 React hook),这个文件必须是 Client Component,所以需要"use client"。
# 提交时缺少电子邮件
> 缺少名称或电子邮件。
# 有效提交
> 已创建客户 Nguyễn Văn A。
4. 使用 useFormStatus 处理 pending 状态
提交时,你想禁用按钮并显示加载状态。useFormStatus 读取最近的父表单的 pending 状态 — 但必须位于单独的子组件中。
// app/customers/new/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "正在创建..." : "创建"}
</button>
);
}
useFormStatus仅在组件被渲染到<form>内部 时才有效。如果你把<SubmitButton />放在包含<form>的同一个组件内,它仍然能正确读取,因为 React 在 DOM 树中查找最近的父表单,而不是在同一组件中。- 在 Server Action 在服务器上运行期间,
pending为true,这帮助你禁用按钮以避免重复提交。
注意: 不要在包含
<form>的同一组件中调用useFormStatus后期望它自动了解表单状态 — 实际上它确实能工作,因为它按照 DOM 树追踪,但把它分离成子组件是最佳实践,可以在同一页面有多个表单时避免混淆。
5. 使用 Zod 验证数据
用 if/else 手动验证很快就会变得混乱。使用 Zod 来清楚地声明 schema。
<!-- 不适用 — 这是 npm 依赖,见下文 -->
npm install zod
// app/actions/customer-actions.ts
"use server";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(2, "名称至少需要 2 个字符。"),
email: z.string().email("电子邮件无效。"),
});
type ActionResult = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const parsed = customerSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!parsed.success) {
return {
success: false,
message: "数据无效。",
errors: parsed.error.flatten().fieldErrors,
};
}
return { success: true, message: `已创建客户 ${parsed.data.name}。` };
}
safeParse返回一个有success和data/error的对象,不抛出异常 — 适合将错误返回给客户端而不是让 action 崩溃。flatten().fieldErrors返回形如{ name: ["名称必须..."], email: [...] }的对象,便于在每个输入下方显示错误。- Schema 定义一次,可以重复使用以进行客户端和服务器验证(如需要)— 避免重复逻辑。
6. 提交后重新验证数据
创建客户后,客户列表页面(由 Next.js 缓存)需要更新。使用 revalidatePath 或 revalidateTag。
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
// ... 保存到数据库
revalidatePath("/customers");
redirect("/customers");
}
revalidatePath("/customers")删除该页面的缓存,下一次访问时 Next.js 会使用新数据重新呈现。redirect抛出一个特殊异常,Next.js 会捕获它以进行重定向 — 在revalidatePath之后 调用它,并且不要将其放在try/catch中吞掉错误,因为这会阻止重定向工作。- 这两个函数只能在 Server Action 或 Server Component 中调用,不能从客户端调用。
7. 从 Client Component 调用 Server Action(不通过表单)
Server Action 不一定要附加到 <form>。你可以像调用普通异步函数一样直接调用它,例如在 onClick 中。
// app/customers/[id]/delete-button.tsx
"use client";
import { useState } from "react";
import { deleteCustomer } from "@/app/actions/customer-actions";
export function DeleteButton({ customerId }: { customerId: string }) {
const [isDeleting, setIsDeleting] = useState(false);
async function handleClick() {
setIsDeleting(true);
await deleteCustomer(customerId);
setIsDeleting(false);
}
return (
<button onClick={handleClick} disabled={isDeleting}>
{isDeleting ? "正在删除..." : "删除"}
</button>
);
}
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function deleteCustomer(id: string) {
console.log("删除客户 id:", id);
revalidatePath("/customers");
}
- Server Action 实际上被 Next.js 构建成一个隐藏的 POST 端点,客户端通过自动创建的网络请求调用它 — 你看不到 fetch 代码但它确实存在,可以在 Network 标签中看到。
- 这种方式便于删除按钮、点赞、切换状态 — 不需要完整的
<form>。
8. 为 Server Action 编写测试
因为 Server Action 只是接收 FormData 的异步函数,你可以像测试普通单元函数一样测试它,无需 mock HTTP。
npm install -D vitest
// app/actions/customer-actions.test.ts
import { describe, it, expect } from "vitest";
import { createCustomer } from "./customer-actions";
function buildFormData(data: Record<string, string>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(data)) {
fd.append(key, value);
}
return fd;
}
describe("createCustomer", () => {
it("当电子邮件无效时返回错误", async () => {
const formData = buildFormData({ name: "An", email: "khong-hop-le" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(false);
expect(result.errors?.email).toContain("电子邮件无效。");
});
it("有效数据时成功", async () => {
const formData = buildFormData({ name: "An", email: "an@example.com" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(true);
expect(result.message).toContain("An");
});
});
buildFormData模拟浏览器发送的FormData,无需渲染组件或 mock 服务器。- 因为 Server Action 是纯函数(接收输入,返回输出),测试运行快速,无需启动 Next.js 开发服务器。
注意: 如果 Server Action 调用
redirect或revalidatePath,测试会抛出错误,因为这些函数只在真实的 Next.js 请求上下文中工作。将业务逻辑(验证、计算)从调用redirect/revalidatePath的部分分离出来以便于测试。
9. 常见错误
忘记 "use server" 或放在错误的位置。 指令必须在文件的第一行(或函数开头,如果在 Server Component 中内联声明)。缺少它,Next.js 会将函数视为普通代码,打包到客户端并在运行时出错。
在 Server Component 中使用 useFormState/useFormStatus。 这两个 hook 属于 react-dom,只能在 Client Component 中运行。忘记 "use client" 会导致构建错误。
从 Server Action 返回复杂对象(class 实例、未序列化的 Date)。 返回的数据必须能通过网络序列化,就像 JSON 一样。返回原始 Date 有时会导致隐藏错误 — 先转换为字符串。
为所有事情调用 Server Action,包括不需要服务器的逻辑。 如果只是简单的验证而不涉及数据库,在客户端处理会更快,避免不必要的往返。
没有 JavaScript 失败时的回退。 Server Actions 支持渐进式增强,但仅当你正确使用 <form action={...}> 时。如果你使用 preventDefault() 然后手动调用 action,你就失去了这个好处。
10. 何时不使用 Server Actions
Server Actions 适合 Next.js 应用的内部 CRUD 表单。但如果你需要:
- 一个公开的 API 供移动应用或第三方调用 — 编写真正的 Route Handler(
route.ts),带有明确的版本控制和认证。 - 流式响应或处理非常大的文件上传并进行细粒度控制 — Route Handler 更灵活。
- 逻辑需要独立运行,在 Next.js 上下文外重复使用(例如 worker、cron job)— 应该分离成单独的服务,Server Action 只是调用该服务。
11. 结论
Server Actions 帮助你编写在服务器上处理数据的表单,而无需构建单独的 API route,大大减少了 fetch/JSON 的样板代码。结合 useFormState 显示错误、useFormStatus 报告 pending 状态和 revalidatePath 更新缓存,你就有了一个整洁的 CRUD 流程,即使在 JavaScript 还没来得及加载时也能工作。使用 Zod 清晰地验证,记住如果需要向外部公开 API,要将 action 分离成真正的 Route Handler。