本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。
表单是 Web 应用中最常见的用户交互方式,也是最容易产生代码混乱的区域。Server Actions 提供了一种全新的架构思路:将表单提交逻辑直接编写在服务端函数中,消除传统 API 路由的样板代码,实现前后端逻辑的无缝衔接。
一、传统方式的局限性
在 Server Actions 出现之前,表单提交通常遵循以下流程:
- 前端组件监听
onSubmit事件 - 事件处理函数通过
fetch调用 API 路由 - API 路由(
/api/xxx)处理业务逻辑、数据验证和数据库操作 - 前端接收响应并更新 UI
这种模式存在明显缺陷:
- 大量胶水代码:需定义前后端 API 接口、错误处理和数据序列化逻辑
- 代码分散:完整的数据流需要在多个文件间追踪
- 维护成本高:前后端代码分离,修改时需同步更新多处
Server Actions 通过将前后端逻辑统一,有效解决了这些问题。
二、Server Actions 核心概念
Server Actions 是在服务端执行的异步函数,可直接从客户端组件调用,无需显式创建 API 路由。
// Server Action 示例
async function createPost(title: string, content: string) {
'use server'; // 标记此函数在服务端执行
// 直接操作数据库
await db.insert(posts).values({ title, content });
// 重新验证缓存
revalidatePath('/blog');
}
当客户端调用此函数时,Next.js 自动发起 HTTP POST 请求至服务端执行,并将结果返回。整个过程对开发者透明——只需关注函数调用,无需处理底层通信细节。
工作原理
sequenceDiagram
participant Client as 客户端组件
participant NextJS as Next.js Runtime
participant Server as 服务端
participant DB as 数据库
Client->>NextJS: 调用 Server Action
NextJS->>Server: 发送 POST 请求
Server->>DB: 执行数据库操作
DB-->>Server: 返回结果
Server->>NextJS: 返回响应
NextJS-->>Client: 解析并返回结果
三、定义 Server Actions
方式一:服务端组件内联定义
// app/posts/new/page.tsx(服务端组件)
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export default function NewPostPage() {
// 在服务端组件内直接定义 Server Action
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// 服务端验证
if (!title || title.length < 5) {
throw new Error('标题至少需要 5 个字符');
}
// 数据库操作
await db.posts.create({ data: { title, content } });
// 缓存失效
revalidatePath('/blog');
// 重定向
redirect('/blog');
}
return (
// form 的 action 属性接收 Server Action
<form action={createPost}>
<input name="title" placeholder="文章标题" required />
<textarea name="content" placeholder="文章内容" required />
<button type="submit">发布</button>
</form>
);
}
关键特性:
<form action={createPost}>使用 HTML 原生语义,传入函数而非 URL- 渐进增强:即使 JavaScript 禁用,表单仍可正常工作(降级为标准 HTML 提交)
- 适用于简单表单场景
方式二:独立 Actions 文件
复杂项目推荐将 Server Actions 集中管理:
// app/actions/posts.ts
'use server'; // 文件级指令,整个模块均为 Server Actions
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
// Zod Schema 定义
const PostSchema = z.object({
title: z.string().min(5, '标题至少 5 个字符').max(100),
content: z.string().min(20, '内容至少 20 个字符'),
category: z.enum(['tech', 'life', 'thoughts']),
});
// 表单状态类型
export type PostFormState = {
errors?: {
title?: string[];
content?: string[];
category?: string[];
};
message?: string;
};
/**
* 创建文章
* @param prevState - 前一状态(用于 useActionState)
* @param formData - 表单数据
*/
export async function createPost(
prevState: PostFormState,
formData: FormData
): Promise<PostFormState> {
// 提取表单数据
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
category: formData.get('category'),
};
// Zod 验证
const validatedData = PostSchema.safeParse(rawData);
if (!validatedData.success) {
return {
errors: validatedData.error.flatten().fieldErrors,
message: '提交失败,请检查输入内容',
};
}
try {
// 数据库操作
await db.posts.create({ data: validatedData.data });
} catch (error) {
console.error('Database error:', error);
return { message: '数据库错误,请稍后重试' };
}
// 缓存失效
revalidatePath('/blog');
// 重定向
redirect('/blog');
}
优势:
- 集中管理所有表单逻辑
- 便于复用和测试
- 清晰的类型定义
四、客户端组件中使用 Server Actions
需要复杂交互(加载状态、错误反馈等)时,在客户端组件中使用useActionState:
// components/CreatePostForm.tsx
'use client';
import { useActionState } from 'react';
import { createPost, type PostFormState } from '@/app/actions/posts';
const initialState: PostFormState = {};
export function CreatePostForm() {
/**
* useActionState Hook
* - 专为 Server Actions 设计
* - 参数:(action, 初始状态)
* - 返回:[当前状态, 绑定的 action, 是否执行中]
*/
const [state, formAction, isPending] = useActionState(
createPost,
initialState
);
return (
<form action={formAction} className="space-y-4">
{/* 标题字段 */}
<div>
<label htmlFor="title" className="block text-sm font-medium">
文章标题
</label>
<input
id="title"
name="title"
type="text"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
disabled={isPending}
aria-describedby="title-error"
/>
{/* 字段级错误提示 */}
{state.errors?.title && (
<p id="title-error" className="text-red-500 text-sm mt-1">
{state.errors.title[0]}
</p>
)}
</div>
{/* 内容字段 */}
<div>
<label htmlFor="content" className="block text-sm font-medium">
文章内容
</label>
<textarea
id="content"
name="content"
rows={8}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
disabled={isPending}
aria-describedby="content-error"
/>
{state.errors?.content && (
<p id="content-error" className="text-red-500 text-sm mt-1">
{state.errors.content[0]}
</p>
)}
</div>
{/* 全局错误提示 */}
{state.message && (
<div
role="alert"
className="bg-red-50 text-red-700 p-3 rounded-md"
>
{state.message}
</div>
)}
{/* 提交按钮 */}
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? (
<>
<span className="animate-spin mr-2">⏳</span>
发布中...
</>
) : (
'发布文章'
)}
</button>
</form>
);
}
useActionState 核心价值:
- React 19 新引入的专用 Hook
- 自动管理 action 绑定、状态更新和执行状态追踪
- 简化表单状态管理复杂度
五、乐观更新:提升用户体验
乐观更新(Optimistic Update) 是一种 UX 优化技术:在服务器确认前假设操作成功,立即更新 UI。若服务器返回错误则回滚。这使界面响应更加流畅,即使网络延迟也不会有明显卡顿。
React 19 提供 useOptimistic Hook 配合 Server Actions 实现:
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/app/actions/likes';
interface Post {
id: string;
title: string;
likes: number;
isLiked: boolean;
}
interface PostCardProps {
post: Post;
}
export function PostCard({ post }: PostCardProps) {
const [isPending, startTransition] = useTransition();
/**
* useOptimistic Hook
* - 参数:(当前值, 乐观更新函数)
* - 返回:[乐观状态, 更新函数]
*/
const [optimisticPost, addOptimisticLike] = useOptimistic(
post,
// 乐观更新逻辑
(currentPost, liked: boolean) => ({
...currentPost,
likes: liked ? currentPost.likes + 1 : currentPost.likes - 1,
isLiked: liked,
})
);
const handleLike = () => {
startTransition(async () => {
// 1. 立即更新 UI(乐观更新)
addOptimisticLike(!optimisticPost.isLiked);
// 2. 执行实际服务端操作
try {
await toggleLike(post.id);
} catch (error) {
// 3. 若失败,React 自动回滚乐观更新
console.error('Like failed:', error);
}
});
};
return (
<article className="border rounded-lg p-4">
<h2 className="text-xl font-bold">{post.title}</h2>
<button
onClick={handleLike}
disabled={isPending}
className={`mt-2 flex items-center gap-1 ${
optimisticPost.isLiked ? 'text-red-500' : 'text-gray-400'
}`}
aria-label={optimisticPost.isLiked ? '取消点赞' : '点赞'}
>
<span>{optimisticPost.isLiked ? '❤️' : '🤍'}</span>
<span>{optimisticPost.likes}</span>
</button>
</article>
);
}
(1)适用场景:
- 点赞/收藏/关注等社交互动
- 购物车数量增减
- 任务完成状态切换
(2)注意事项:
- 必须配合
useTransition使用 - 需在
try-catch中处理错误以实现回滚 - 不适合关键业务操作(如支付)
六、文件上传处理
文件上传也是常见的表单交互,在Next.js中也可以使用Server Action轻松实现。
1. Server Action 实现
// app/actions/upload.ts
'use server';
import { writeFile } from 'fs/promises';
import path from 'path';
interface UploadResult {
url?: string;
error?: string;
}
/**
* 上传头像
* @param formData - 包含文件的表单数据
*/
export async function uploadAvatar(formData: FormData): Promise<UploadResult> {
const file = formData.get('avatar') as File;
// 验证文件存在性
if (!file || file.size === 0) {
return { error: '请选择文件' };
}
// 验证文件类型
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return { error: '仅支持 JPG、PNG、WebP 格式' };
}
// 验证文件大小(5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
return { error: '文件大小不能超过 5MB' };
}
try {
// 读取文件内容
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// 生成唯一文件名
const filename = `avatars/${Date.now()}-${file.name.replace(/\s/g, '_')}`;
const filepath = path.join(process.cwd(), 'public', filename);
// 写入文件系统(生产环境应使用云存储)
await writeFile(filepath, buffer);
// 返回访问 URL
return { url: `/${filename}` };
} catch (error) {
console.error('Upload error:', error);
return { error: '上传失败,请稍后重试' };
}
}
2. 客户端组件实现
// components/AvatarUpload.tsx
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { uploadAvatar } from '@/app/actions/upload';
interface AvatarUploadProps {
currentAvatar: string;
onUploadSuccess?: (url: string) => void;
}
export function AvatarUpload({
currentAvatar,
onUploadSuccess
}: AvatarUploadProps) {
const [preview, setPreview] = useState(currentAvatar);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string>('');
/**
* 文件选择时生成预览
*/
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 创建临时预览 URL
const objectUrl = URL.createObjectURL(file);
setPreview(objectUrl);
setError('');
// 清理之前的 URL
return () => URL.revokeObjectURL(objectUrl);
};
/**
* 表单提交处理
*/
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsUploading(true);
setError('');
const formData = new FormData(e.currentTarget);
const result = await uploadAvatar(formData);
setIsUploading(false);
if (result.error) {
setError(result.error);
// 恢复原头像
setPreview(currentAvatar);
} else if (result.url) {
// 上传成功回调
onUploadSuccess?.(result.url);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* 头像预览 */}
<div className="relative w-24 h-24 mx-auto">
<Image
src={preview}
alt="头像预览"
fill
className="rounded-full object-cover border-2 border-gray-200"
/>
</div>
{/* 文件选择 */}
<div className="flex justify-center">
<input
type="file"
name="avatar"
accept="image/*"
onChange={handleFileChange}
className="hidden"
id="avatar-input"
disabled={isUploading}
/>
<label
htmlFor="avatar-input"
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-md cursor-pointer transition-colors"
>
选择图片
</label>
</div>
{/* 错误提示 */}
{error && (
<p role="alert" className="text-red-500 text-sm text-center">
{error}
</p>
)}
{/* 提交按钮 */}
<button
type="submit"
disabled={isUploading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isUploading ? '上传中...' : '确认上传'}
</button>
</form>
);
}
生产环境建议:
- 使用云存储服务(AWS S3、Cloudinary、Vercel Blob)
- 实施图片压缩和格式转换
- 添加 CDN 加速
七、表单安全防护
1. CSRF 保护
Next.js Server Actions 内置 CSRF 保护机制,通过 Origin 请求头验证来源,防止跨站请求伪造攻击。
2. 安全最佳实践
(1) 服务端验证不可省略
// ❌ 危险:仅依赖客户端验证
// 客户端验证可被轻易绕过
// ✅ 正确:服务端必须再次验证
export async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
// 服务端验证是最后一道防线
if (!title || title.length < 5) {
return { error: '标题太短了' };
}
// ...
}
(2) 权限验证
export async function deletePost(postId: string) {
'use server';
// 1. 验证用户登录状态
const session = await getSession();
if (!session?.user) {
throw new Error('未授权访问');
}
// 2. 验证资源所有权
const post = await db.posts.findUnique({
where: { id: postId }
});
if (!post || post.authorId !== session.user.id) {
throw new Error('无权操作此资源');
}
// 3. 执行删除
await db.posts.delete({ where: { id: postId } });
// 4. 缓存失效
revalidatePath('/blog');
}
(3) 防止注入攻击
SQL 注入防护:使用 ORM(Prisma、Drizzle)自动参数化查询
XSS 防护:React 默认转义 HTML,但使用 dangerouslySetInnerHTML 时需做净化(过滤)处理:
import DOMPurify from 'isomorphic-dompurify';
// 对用户输入的 HTML 进行净化
const cleanContent = DOMPurify.sanitize(userInputHtml);
// 安全渲染
<div dangerouslySetInnerHTML={{ __html: cleanContent }} />
八、最佳实践总结
1. Server Actions vs API Routes 选择指南
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 表单提交 | Server Actions | 简洁、类型安全、自动 CSRF 保护 |
| 数据变更操作 | Server Actions | 直接调用、无需额外 API 层 |
| Webhook 接收端 | Route Handlers | 第三方服务需 HTTP 端点 |
| 流式响应(SSE) | Route Handlers | 需精确控制响应流 |
| 公开 API | Route Handlers | 外部客户端调用 |
2. Server Action 标准流程
每个数据变更的 Server Action 应遵循三步原则:
export async function updatePost(id: string, data: PostData) {
'use server';
try {
// 1. 执行业务逻辑
await db.posts.update({ where: { id }, data });
// 2. 使相关缓存失效
revalidatePath(`/posts/${id}`);
revalidateTag('posts');
// 3. 重定向或返回状态
redirect('/blog');
// 或 return { success: true };
} catch (error) {
// 错误处理
return { error: '更新失败' };
}
}
3. 职责分离原则
避免将所有逻辑堆砌在 Server Action 中:
// ❌ 不佳:逻辑混杂
export async function createPost(formData: FormData) {
'use server';
// 200+ 行代码...
}
// ✅ 推荐:职责清晰
export async function createPost(formData: FormData) {
'use server';
// 1. 数据解析
const data = parsePostFormData(formData);
// 2. 数据验证
const errors = validatePostData(data);
if (errors) return { errors };
// 3. 业务逻辑(抽离到服务层)
await postService.create(data);
// 4. 缓存管理
revalidatePath('/blog');
// 5. 导航
redirect('/blog');
}
4. 错误处理策略
- 字段级错误:返回结构化错误对象,配合
useActionState展示 - 全局错误:使用
try-catch捕获异常,返回友好提示 - 意外错误:记录日志,返回通用错误消息(避免泄露敏感信息)
九、本章小结
通过本章学习,你应该掌握了:
- Server Actions 的核心概念和工作原理
- 两种定义方式:内联定义和独立文件
- 客户端组件中的使用方法(
useActionState) - 乐观更新的实现(
useOptimistic) - 文件上传的完整流程
- 表单安全防护(CSRF、权限验证、注入防护)
- Server Actions 与 API Routes 的选择策略
下一章将探讨错误处理与加载状态管理——这是构建健壮应用的关键环节,确保在任何异常情况下都能提供良好的用户体验。