Next.js Server Actions:服务端逻辑的直接表达

深入解析 Server Actions 的本质:如何实现在服务端直接执行函数、渐进增强如何做到无 JavaScript 工作、以及 Server Actions 与 API Routes 的取舍。

Server Actions 解决的问题

在传统的 Next.js 应用中,如果要在用户交互时更新服务端数据,你需要:

  1. 创建一个 API Route(app/api/xxx/route.ts
  2. 在客户端组件中用 fetchaxios 调用这个 API
  3. 处理请求、响应、错误

这意味着一个简单的"提交表单"功能,需要两个文件、两次网络往返(一次获取表单页面,一次提交数据),以及处理 JSON 序列化的逻辑。

Server Actions 改变了这个范式:你可以在服务端直接定义一个函数,然后在客户端直接调用它,就像调用本地函数一样——但函数实际运行在服务端。


一、Server Actions 是什么

1.1 本质定义

Server Action 是一个在服务端执行的异步函数,客户端可以通过引用直接调用它。Next.js 自动为这个函数生成了 RPC(远程过程调用)层,所以你写的是函数调用,底层是网络请求。

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

// 这是一个 Server Action
export async function submitForm(formData: FormData) {
  'use server';

  const email = formData.get('email');
  const message = formData.get('message');

  // 直接在服务端执行:访问数据库、文件系统等
  await db.message.create({
    data: { email, message }
  });

  revalidatePath('/messages');
}
// app/contact/page.tsx
'use client';

import { submitForm } from '@/app/actions';

export default function ContactPage() {
  async function handleSubmit(formData: FormData) {
    await submitForm(formData); // 像调用本地函数,实际运行在服务端
    alert('消息已发送!');
  }

  return (
    <form action={handleSubmit}>
      <input name="email" type="email" />
      <textarea name="message" />
      <button type="submit">发送</button>
    </form>
  );
}

1.2 幕后发生了什么

当你调用 submitForm(formData) 时,Next.js 实际上:

  1. 序列化了 formData(包括所有 input 的 name/value)
  2. 通过 HTTP POST 请求将数据发送到 Next.js 的内部 RPC 端点
  3. 服务端执行函数,获取结果
  4. 结果序列化为 JSON 返回
  5. 客户端收到结果,Promise resolve

这个过程对开发者是透明的——你写的代码看起来就是同步函数调用。

1.3 与 API Routes 的对比

特性 Server Actions API Routes
文件数量 单一文件(服务端逻辑) 需要两个文件(route + 调用方)
类型安全 天然类型共享 需要手动维护类型
网络开销 单次调用 可能需要两次(数据 + mutation)
渐进增强 原生支持 需要额外处理
可访问性 可以访问服务端所有资源 可以访问服务端所有资源

二、渐进增强:没有 JavaScript 时也能工作

2.1 什么是渐进增强

渐进增强(Progressive Enhancement)的核心理念:网页应该基本功能可用,再谈增强体验。具体到表单提交:如果用户的浏览器禁用了 JavaScript,点击提交按钮仍然应该能提交表单。

Server Actions 原生支持这个特性。<form action={serverAction}> 在没有 JavaScript 时会执行标准的表单提交(POST 到服务端),有 JavaScript 时会通过 fetch 透明地调用 Server Action。

2.2 实现方式

// 方式一:直接传递 Server Action 给 form
// 没有 onSubmit,没有 handleSubmit,最简洁
export default function ContactForm() {
  return (
    <form action={submitForm}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">发送</button>
    </form>
  );
}
// 方式二:使用 bind 预填充参数
export default function ContactForm({ defaultMessage }) {
  const boundSubmit = submitForm.bind(null, defaultMessage);
  // 当用户提交时,会以 (defaultMessage, formData) 的形式调用

  return (
    <form action={boundSubmit}>
      <textarea name="message" defaultValue={defaultMessage} />
      <button type="submit">发送</button>
    </form>
  );
}
// 方式三:结合 useFormState 做状态管理
'use client';

import { useFormState } from 'react-dom';
import { submitForm } from '@/app/actions';

const initialState = { message: '' };

export default function ContactForm() {
  const [state, formAction] = useFormState(submitForm, initialState);

  return (
    <form action={formAction}>
      <input name="email" type="email" />
      <textarea name="message" />
      <button type="submit">发送</button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}

2.3 无 JS 时的行为

当 JavaScript 失败或被禁用时:

  1. 用户点击提交按钮
  2. 浏览器执行标准的 HTML form POST
  3. Server Action 作为 POST endpoint 处理请求
  4. 服务端重定向到成功/失败页面(或返回 HTML 片段)

整个过程不需要任何 JavaScript——Server Action 本身就是一个合法的 POST endpoint。


三、表单处理和 useFormStatus

3.1 useFormStatus 的用途

useFormStatus 是一个 React Hook,用于在表单提交过程中显示 loading 状态。它只能在使用 action 属性的表单中使用。

// components/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending, data, method, action } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  );
}
// app/contact/page.tsx
import { SubmitButton } from '@/components/SubmitButton';

export default function ContactPage() {
  return (
    <form action={submitForm}>
      <input name="email" type="email" />
      <textarea name="message" />
      <SubmitButton />
    </form>
  );
}

注意useFormStatus 只报告同一个表单提交链中的状态。如果 SubmitButton 和 form 不在同一个组件树中(比如 form 在父组件,button 在子组件),useFormStatus 只能感知到自己父组件链中的表单状态。

3.2 实际场景:带验证的表单

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

export type FormState = {
  errors?: {
    email?: string[];
    message?: string[];
  };
  message?: string;
};

export async function submitForm(prevState: FormState, formData: FormData) {
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  // 验证
  const errors: FormState['errors'] = {};

  if (!email || !email.includes('@')) {
    errors.email = ['请输入有效的邮箱地址'];
  }

  if (!message || message.length < 10) {
    errors.message = ['消息至少需要 10 个字符'];
  }

  if (Object.keys(errors).length > 0) {
    return { errors };
  }

  // 保存到数据库
  await db.message.create({ data: { email, message } });

  revalidatePath('/messages');
  return { message: '消息已发送!' };
}
// app/contact/page.tsx
'use client';

import { useFormState } from 'react-dom';
import { submitForm, type FormState } from '@/app/actions';

export default function ContactPage() {
  const [state, formAction] = useFormState<FormState>(submitForm, {});

  return (
    <form action={formAction}>
      <div>
        <input name="email" type="email" />
        {state.errors?.email && (
          <span className="error">{state.errors.email[0]}</span>
        )}
      </div>
      <div>
        <textarea name="message" />
        {state.errors?.message && (
          <span className="error">{state.errors.message[0]}</span>
        )}
      </div>
      <button type="submit">发送</button>
      {state.message && <p className="success">{state.message}</p>}
    </form>
  );
}

四、与 API Routes 的取舍

4.1 选择 Server Actions 的场景

  • 表单提交和 mutations:这是 Server Actions 最适合的场景
  • 需要渐进增强的交互:表单必须能在无 JS 时工作
  • 简单的服务端逻辑:不需要复杂的 HTTP 方法、头部管理
  • 类型共享:想要服务端和客户端共享类型定义

4.2 选择 API Routes 的场景

  • 第三方 Webhook:外部服务需要调用你的 API
  • 需要精细控制 HTTP 响应:设置特定的状态码、响应头、CORS
  • 需要 GET 请求:Server Actions 只支持 POST(默认)和特定的 mutations
  • 公开的 API:需要版本控制、文档、开发者工具体验的 API
  • 复杂的请求/响应处理:文件上传、multipart 编码等

4.3 混用策略

在实际应用中,Server Actions 和 API Routes 可以共存:

// app/api/upload/route.ts
// 复杂的文件上传,适合 API Route
export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File;

  // 处理文件上传到 S3 等
  const url = await uploadToS3(file);

  return Response.json({ url });
}
// app/actions.ts
'use server';
// 简单的数据 mutations,适合 Server Action
export async function updateUserProfile(userId: string, data: ProfileData) {
  await db.user.update({ where: { id: userId }, data });
  revalidatePath(`/profile/${userId}`);
}

五、权限控制和安全性

5.1 Server Actions 的执行上下文

Server Actions 在服务端执行,可以访问完整的请求上下文:

'use server';

import { cookies, headers } from 'next/headers';
import { auth } from '@/lib/auth';

export async function updateProfile(formData: FormData) {
  // 获取当前用户(基于 cookie/session)
  const session = await auth();
  if (!session?.user) {
    throw new Error('Unauthorized');
  }

  const userId = session.user.id;
  const name = formData.get('name') as string;

  await db.user.update({
    where: { id: userId },
    data: { name }
  });

  revalidatePath(`/profile/${userId}`);
}

5.2 关键安全建议

  • 始终验证用户身份:不要假设 Server Action 只会被合法用户调用
  • 验证用户权限:用户是否真的有权执行这个操作
  • 清理输入:Server Actions 的参数来自客户端,始终当作不可信输入处理
  • 使用 zod 等库做运行时验证:TypeScript 类型在运行时不起作用
'use server';

import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  message: z.string().min(10).max(1000)
});

export async function submitForm(formData: FormData) {
  // 运行时验证
  const result = schema.safeParse({
    email: formData.get('email'),
    message: formData.get('message')
  });

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

  // 安全地使用验证后的数据
  await db.message.create({ data: result.data });
}

六、面试高频问题

Q1: Server Actions 和 API Routes 的核心区别是什么?

Server Actions 是"函数调用"语义,适合表单提交和 mutations;API Routes 是"HTTP endpoint"语义,适合第三方调用、webhooks、需要精细 HTTP 控制的场景。Server Actions 支持渐进增强,API Routes 不支持。

Q2: useFormStatus 的限制是什么?

useFormStatus 只能感知同一个组件父链中的表单状态。它必须在 <form> 的子组件(或更深层的后代组件)中调用才能工作。如果表单和按钮在完全分离的组件树中,需要自己管理 pending 状态。

Q3: Server Actions 如何保证安全性?

Server Actions 在服务端执行,但参数来自客户端,所以需要:1)始终验证用户身份和权限;2)使用 zod 等库做运行时输入验证;3)清理和验证所有输入数据。

Q4: Server Actions 可以做 GET 请求吗?

不可以。Server Actions 只支持 POST 请求(以及通过 useFormState 的读取操作)。如果需要 GET 请求,应该使用 API Routes 或直接在服务端组件中获取数据。


延展阅读