Server Actions 解决的问题
在传统的 Next.js 应用中,如果要在用户交互时更新服务端数据,你需要:
- 创建一个 API Route(
app/api/xxx/route.ts) - 在客户端组件中用
fetch或axios调用这个 API - 处理请求、响应、错误
这意味着一个简单的"提交表单"功能,需要两个文件、两次网络往返(一次获取表单页面,一次提交数据),以及处理 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 实际上:
- 序列化了
formData(包括所有 input 的 name/value) - 通过 HTTP POST 请求将数据发送到 Next.js 的内部 RPC 端点
- 服务端执行函数,获取结果
- 结果序列化为 JSON 返回
- 客户端收到结果,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 失败或被禁用时:
- 用户点击提交按钮
- 浏览器执行标准的 HTML form POST
- Server Action 作为 POST endpoint 处理请求
- 服务端重定向到成功/失败页面(或返回 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 或直接在服务端组件中获取数据。
延展阅读
- Next.js 官方文档 — Server Actions — 最权威的文档来源
- Next.js 官方博客 — Server Actions in Next.js 14 — 设计动机和使用场景
- React 官方文档 — useFormState — 表单状态管理
- React 官方文档 — useFormStatus — 提交状态