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。

nextjsserver-actionsreactform-handlingapp-router