Server Actions
React 19 的数据变更新范式,简化全栈开发
1. 什么是 Server Actions?
1.1 核心概念
Server Actions = 可以在客户端调用的服务端函数,无需创建 API 路由。
jsx
// 传统方式:需要 API 路由
// pages/api/create-post.js
export default async function handler(req, res) {
const { title } = req.body;
await db.posts.create({ title });
res.json({ success: true });
}
// 组件中调用
fetch('/api/create-post', { method: 'POST', body: JSON.stringify({ title }) });jsx
// Server Actions:直接定义服务端函数
async function createPost(formData) {
'use server';
const title = formData.get('title');
await db.posts.create({ title });
}
// 直接在表单中使用
<form action={createPost}>
<input name="title" />
<button>Create</button>
</form>1.2 定义方式
方式一:内联定义
jsx
function Page() {
async function handleSubmit(formData) {
'use server'; // 标记为 Server Action
// 这里的代码只在服务端运行
await db.insert(formData);
}
return <form action={handleSubmit}>...</form>;
}方式二:独立文件
jsx
// actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
await db.posts.create({ title });
}
export async function deletePost(id: string) {
await db.posts.delete(id);
}jsx
// component.tsx
import { createPost } from './actions';
export function Form() {
return (
<form action={createPost}>
<input name="title" />
<button>Create</button>
</form>
);
}2. 表单处理
2.1 基础表单
jsx
// actions.ts
'use server';
export async function signup(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
// 验证
if (!email || !password) {
return { error: 'Missing fields' };
}
// 创建用户
await db.users.create({ email, password: hash(password) });
// 重定向
redirect('/dashboard');
}jsx
// form.tsx
import { signup } from './actions';
export function SignupForm() {
return (
<form action={signup}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign Up</button>
</form>
);
}2.2 配合 useActionState
jsx
'use client';
import { useActionState } from 'react';
import { signup } from './actions';
export function SignupForm() {
const [state, action, isPending] = useActionState(signup, { error: null });
return (
<form action={action}>
<input name="email" type="email" />
<input name="password" type="password" />
{state.error && <p className="error">{state.error}</p>}
<button disabled={isPending}>
{isPending ? 'Signing up...' : 'Sign Up'}
</button>
</form>
);
}2.3 表单验证
jsx
// actions.ts
'use server';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export async function signup(prevState: any, formData: FormData) {
const result = schema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
};
}
// 验证通过,创建用户
await db.users.create(result.data);
redirect('/dashboard');
}3. 非表单场景
3.1 事件处理
jsx
'use client';
import { likePost } from './actions';
export function LikeButton({ postId }: { postId: string }) {
return (
<button onClick={() => likePost(postId)}>
Like
</button>
);
}jsx
// actions.ts
'use server';
export async function likePost(postId: string) {
await db.posts.update(postId, {
likes: { increment: 1 }
});
revalidatePath('/posts');
}3.2 配合 useTransition
jsx
'use client';
import { useTransition } from 'react';
import { deletePost } from './actions';
export function DeleteButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
startTransition(async () => {
await deletePost(postId);
});
};
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
);
}3.3 乐观更新
jsx
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from './actions';
export function LikeButton({ postId, liked, count }) {
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked);
const [optimisticCount, setOptimisticCount] = useOptimistic(count);
const handleClick = async () => {
// 乐观更新
setOptimisticLiked(!optimisticLiked);
setOptimisticCount(optimisticLiked ? count - 1 : count + 1);
// 实际请求
await toggleLike(postId);
};
return (
<button onClick={handleClick}>
{optimisticLiked ? '❤️' : '🤍'} {optimisticCount}
</button>
);
}4. 缓存重验证
4.1 revalidatePath
jsx
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
await db.posts.create({...});
// 重验证特定路径
revalidatePath('/posts');
// 重验证动态路径
revalidatePath('/posts/[id]', 'page');
// 重验证整个布局
revalidatePath('/posts', 'layout');
}4.2 revalidateTag
jsx
// 数据获取时打标签
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
});
return res.json();
}
// Server Action 中重验证标签
'use server';
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
await db.posts.create({...});
revalidateTag('posts'); // 所有 'posts' 标签的缓存失效
}5. 错误处理
5.1 返回错误状态
jsx
'use server';
export async function createPost(prevState: any, formData: FormData) {
try {
const title = formData.get('title') as string;
if (!title) {
return { error: 'Title is required', success: false };
}
await db.posts.create({ title });
return { error: null, success: true };
} catch (e) {
return { error: 'Failed to create post', success: false };
}
}5.2 抛出错误(配合 Error Boundary)
jsx
'use server';
export async function deletePost(id: string) {
const post = await db.posts.find(id);
if (!post) {
throw new Error('Post not found'); // 会被 Error Boundary 捕获
}
await db.posts.delete(id);
}jsx
// error.tsx (Next.js)
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}6. 安全性
6.1 身份验证
jsx
'use server';
import { auth } from '@/lib/auth';
export async function createPost(formData: FormData) {
// 验证登录状态
const session = await auth();
if (!session) {
throw new Error('Unauthorized');
}
await db.posts.create({
title: formData.get('title'),
authorId: session.user.id,
});
}6.2 权限检查
jsx
'use server';
export async function deletePost(id: string) {
const session = await auth();
const post = await db.posts.find(id);
// 只能删除自己的文章
if (post.authorId !== session.user.id) {
throw new Error('Forbidden');
}
await db.posts.delete(id);
}6.3 输入验证
jsx
'use server';
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(1).max(10000),
});
export async function createPost(formData: FormData) {
// 永远验证输入!Server Actions 可以被直接调用
const validated = PostSchema.parse({
title: formData.get('title'),
content: formData.get('content'),
});
await db.posts.create(validated);
}7. 高级模式
7.1 带参数的 Action
jsx
// 方式一:bind
'use client';
import { deletePost } from './actions';
export function DeleteButton({ postId }) {
const deletePostWithId = deletePost.bind(null, postId);
return (
<form action={deletePostWithId}>
<button>Delete</button>
</form>
);
}
// actions.ts
'use server';
export async function deletePost(postId: string, formData: FormData) {
await db.posts.delete(postId);
}jsx
// 方式二:hidden input
export function DeleteButton({ postId }) {
return (
<form action={deletePost}>
<input type="hidden" name="postId" value={postId} />
<button>Delete</button>
</form>
);
}7.2 返回数据
jsx
'use server';
export async function searchPosts(query: string) {
const posts = await db.posts.search(query);
return posts; // 可以返回数据给客户端
}
// 客户端
'use client';
async function handleSearch(query: string) {
const results = await searchPosts(query);
setResults(results);
}7.3 文件上传
jsx
'use server';
export async function uploadImage(formData: FormData) {
const file = formData.get('file') as File;
// 验证文件
if (!file || file.size === 0) {
return { error: 'No file uploaded' };
}
if (file.size > 5 * 1024 * 1024) {
return { error: 'File too large' };
}
// 上传到存储服务
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const url = await uploadToStorage(buffer, file.name);
return { url };
}8. 面试高频问题
Q1: Server Actions 和 API Routes 的区别?
| 对比项 | API Routes | Server Actions |
|---|---|---|
| 定义方式 | 独立文件 | 内联或独立文件 |
| 调用方式 | fetch() | 直接调用函数 |
| 类型安全 | 手动定义 | 自动推断 |
| 表单集成 | 手动处理 | 原生 form action |
| 代码量 | 多 | 少 |
Q2: Server Actions 安全吗?
安全考虑:
- Server Actions 暴露为 HTTP 端点,可以被直接调用
- 必须验证身份和权限
- 必须验证输入数据
- 不要信任客户端传来的任何数据
Q3: 什么时候用 Server Actions vs API Routes?
Server Actions 适合:
- 表单提交
- 简单的数据变更
- 与 React 组件紧密集成
API Routes 适合:
- 需要被外部服务调用
- Webhook 处理
- 复杂的 RESTful API
Q4: 如何处理 Server Actions 的 loading 状态?
jsx
// 方式一:useActionState
const [state, action, isPending] = useActionState(serverAction, initial);
// 方式二:useTransition
const [isPending, startTransition] = useTransition();
startTransition(() => serverAction());
// 方式三:useFormStatus (在表单子组件中)
const { pending } = useFormStatus();Q5: Server Actions 可以返回什么?
可以返回任何可序列化的值:
- 原始类型(string, number, boolean)
- 对象和数组
- Date(会被序列化)
- 不能返回函数、类实例、Symbol 等