本章目标:全面掌握 Next.js Server Actions——理解
"use server"指令的本质,学会在表单提交、数据变更、乐观更新中高效使用 Server Actions,并建立 Server Action vs Route Handler 的清晰选型判断。
9.1 Server Actions 概述
什么是 Server Action?
Server Action 是 Next.js 中一种特殊的异步函数,它在 服务端执行,但可以从 Server Components 和 Client Components 直接调用,无需手动构建 API 端点。
核心思想:把"变更数据"这件事,变成一次函数调用,而不是一次 HTTP 请求。
// 定义 Server Action
export async function createComment(postId: string, content: string) {
'use server';
await db.comment.create({ data: { postId, content } });
}
// 在 Server Component 中使用
<form action={createComment.bind(null, postId)}>
<input name="content" />
<button type="submit">提交</button>
</form>
// 在 Client Component 中使用
'use client';
import { createComment } from './actions';
<button onClick={() => createComment(postId, 'Great post!')}>点赞</button>
设计动机
传统 Web 开发中,一次数据变更涉及大量样板代码:
Client Component → fetch('/api/comments') → Route Handler → Database
↑ 手动序列化
↑ 手动处理 HTTP 状态码
↑ 手动管理 loading/error 状态
Server Actions 将这条链路简化为:
Client Component → createComment(postId, content) → Database
↑ 自动 POST 请求
↑ 自动序列化/反序列化
↑ 自动重新验证缓存
9.2 "use server" 指令详解
两种使用方式
方式一:函数顶部声明(Server / Client Component 均可)
// actions.ts
// 单独标记某个函数
export async function updateProfile(userId: string, name: string) {
'use server';
await db.user.update({ where: { id: userId }, data: { name } });
}
// 文件中可以有普通函数(不会被导出为 Server Action)
function validateName(name: string) {
return name.length >= 2;
}
方式二:文件顶部声明(整文件均为 Server Actions)
// app/actions/comment.ts
'use server';
// 文件内所有导出函数均为 Server Actions
export async function createComment(postId: string, content: string) {
await db.comment.create({ data: { postId, content } });
}
export async function deleteComment(commentId: string) {
await db.comment.delete({ where: { id: commentId } });
}
export async function updateComment(commentId: string, content: string) {
await db.comment.update({
where: { id: commentId },
data: { content },
});
}
"use server 的工作原理
当 Next.js 编译时遇到 "use server":
编译前:
export async function createItem(name: string) {
'use server';
await db.item.create({ data: { name } });
}
编译后(概念性):
export async function createItem(name: string) {
// 1. 自动创建隐藏的 API 端点(POST /api/actions/createItem)
// 2. 客户端调用时,自动发送 fetch 请求
// 3. 服务端执行真正的函数体
// 4. 返回值自动序列化回客户端
}
"use server" 的规则与限制
// ✅ 正确:异步函数
export async function addItem(name: string) {
'use server';
}
// ❌ 错误:不能是同步函数
export function addItem(name: string) {
'use server'; // 编译错误
}
// ✅ 正确:参数必须是可序列化的
export async function addItem(name: string, count: number) {
'use server';
}
// ❌ 错误:不能传递函数、class 实例等不可序列化对象
export async function addItem(callback: () => void) {
'use server'; // 运行时错误
}
// ✅ 正确:返回值必须是可序列化的
export async function getItem(id: string) {
'use server';
return { id: '1', name: 'Item' }; // 普通对象,可序列化
}
// ❌ 错误:不能返回函数或 class 实例
export async function getHandler() {
'use server';
return () => console.log('hello'); // 不可序列化
}
9.3 表单提交链路
基础表单提交
// app/actions/article.ts
'use server';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function createArticle(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const category = formData.get('category') as string;
if (!title || !content) {
return { error: '标题和内容不能为空' };
}
await prisma.article.create({
data: { title, content, category },
});
// 自动重新验证列表页缓存
revalidatePath('/articles');
}
// app/articles/new/page.tsx (Server Component)
import { createArticle } from '@/app/actions/article';
export default function NewArticlePage() {
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">创建文章</h1>
<form action={createArticle} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
标题
</label>
<input
id="title"
name="title"
type="text"
required
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<div>
<label htmlFor="category" className="block text-sm font-medium mb-1">
分类
</label>
<select
id="category"
name="category"
className="w-full px-3 py-2 border rounded-md"
>
<option value="frontend">前端</option>
<option value="backend">后端</option>
<option value="devops">DevOps</option>
</select>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium mb-1">
内容
</label>
<textarea
id="content"
name="content"
rows={10}
required
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
发布文章
</button>
</form>
</div>
);
}
传递额外参数(bind)
当 Server Action 需要除 FormData 之外的参数时,使用 .bind():
// app/actions/comment.ts
'use server';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function addComment(postId: string, formData: FormData) {
const content = formData.get('content') as string;
const authorName = formData.get('authorName') as string;
if (!content || !authorName) {
return { error: '请填写评论内容和昵称' };
}
await prisma.comment.create({
data: {
postId,
content,
authorName,
},
});
revalidatePath(`/posts/${postId}`);
}
// app/posts/[id]/page.tsx (Server Component)
import { addComment } from '@/app/actions/comment';
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await getPost(id);
// bind 将 postId 预绑定到第一个参数
const addCommentWithPostId = addComment.bind(null, id);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* 评论表单 */}
<section className="mt-8">
<h2>发表评论</h2>
<form action={addCommentWithPostId}>
<input name="authorName" placeholder="昵称" required />
<textarea name="content" placeholder="评论内容" required />
<button type="submit">提交评论</button>
</form>
</section>
</article>
);
}
在 Client Component 中使用表单
// app/components/CommentForm.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { addComment } from '@/app/actions/comment';
type ActionState = {
error?: string;
success?: boolean;
};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{pending ? '提交中...' : '提交评论'}
</button>
);
}
export function CommentForm({ postId }: { postId: string }) {
const [state, formAction] = useActionState(
async (prevState: ActionState, formData: FormData) => {
const result = await addComment(postId, formData);
if (result?.error) {
return { error: result.error };
}
return { success: true };
},
{ error: undefined, success: undefined }
);
return (
<form action={formAction} className="space-y-3">
{state.error && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded">
{state.error}
</div>
)}
{state.success && (
<div className="p-3 bg-green-50 text-green-600 text-sm rounded">
评论发布成功!
</div>
)}
<input
name="authorName"
placeholder="昵称"
required
className="w-full px-3 py-2 border rounded"
/>
<textarea
name="content"
placeholder="评论内容"
rows={4}
required
className="w-full px-3 py-2 border rounded"
/>
<SubmitButton />
</form>
);
}
9.4 表单状态管理 Hooks
React 提供了三个专用 Hook 来管理 Server Action 表单状态:
useFormStatus
获取当前表单的提交状态(只能在 Client Component 中使用):
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
return (
<div>
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
{pending && data && (
<p className="text-sm text-gray-500">
正在提交:{data.get('title') as string}
</p>
)}
</div>
);
}
注意:useFormStatus 必须在 <form> 标签内部使用,且只能感知父级 <form> 的状态。
useActionState(React 19)
管理 Server Action 的返回值状态(替代旧版 useFormState):
'use client';
import { useActionState } from 'react';
import { createArticle } from '@/app/actions/article';
type State = {
error?: string;
fieldErrors?: Record<string, string>;
success?: boolean;
};
export function ArticleForm() {
const [state, formAction, isPending] = useActionState<State, FormData>(
async (prevState: State, formData: FormData) => {
const result = await createArticle(formData);
return result ?? { success: true };
},
{ error: undefined, fieldErrors: undefined, success: undefined }
);
return (
<form action={formAction}>
{state.error && <div className="text-red-600">{state.error}</div>}
<input name="title" />
{state.fieldErrors?.title && (
<span className="text-red-500 text-sm">{state.fieldErrors.title}</span>
)}
<textarea name="content" />
{state.fieldErrors?.content && (
<span className="text-red-500 text-sm">{state.fieldErrors.content}</span>
)}
<button type="submit" disabled={isPending}>
{isPending ? '创建中...' : '创建文章'}
</button>
</form>
);
}
useOptimistic(乐观更新)
'use client';
import { useOptimistic, useRef } from 'react';
import { addTodo } from '@/app/actions/todo';
type Todo = { id: string; text: string; pending?: boolean };
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
initialTodos,
(state: Todo[], newTodo: string) => [
...state,
{ id: `temp-${Date.now()}`, text: newTodo, pending: true },
]
);
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
// 立即在 UI 上添加(乐观更新)
addOptimistic(text);
// 清空表单
formRef.current?.reset();
// 实际提交到服务端
await addTodo(text);
}
return (
<div>
<ul>
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className={todo.pending ? 'opacity-50 italic' : ''}
>
{todo.text}
{todo.pending && ' (提交中...)'}
</li>
))}
</ul>
<form ref={formRef} action={handleSubmit}>
<input name="text" placeholder="新增待办" required />
<button type="submit">添加</button>
</form>
</div>
);
}
三个 Hook 对比
| Hook | 用途 | 使用位置 | 返回值 |
|---|---|---|---|
useFormStatus | 表单提交状态(loading) | <form> 内部的 Client Component | { pending, data, method, action } |
useActionState | Action 返回值 + loading | Client Component | [state, formAction, isPending] |
useOptimistic | 乐观更新 UI | Client Component | [optimisticState, addOptimistic] |
9.5 错误处理
服务端错误返回
// app/actions/user.ts
'use server';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email('邮箱格式不正确'),
name: z.string().min(2, '昵称至少 2 个字符'),
password: z.string().min(8, '密码至少 8 个字符'),
});
export type RegisterState = {
error?: string;
fieldErrors?: Record<string, string>;
success?: boolean;
};
export async function register(
prevState: RegisterState,
formData: FormData
): Promise<RegisterState> {
const raw = {
email: formData.get('email'),
name: formData.get('name'),
password: formData.get('password'),
};
// 验证
const validation = registerSchema.safeParse(raw);
if (!validation.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of validation.error.issues) {
const field = issue.path[0]?.toString() ?? 'form';
fieldErrors[field] = issue.message;
}
return { fieldErrors };
}
const { email, name, password } = validation.data;
// 检查邮箱是否已注册
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return { error: '该邮箱已被注册' };
}
// 创建用户
try {
const hashedPassword = await hashPassword(password);
await prisma.user.create({
data: { email, name, password: hashedPassword },
});
return { success: true };
} catch (error) {
console.error('Registration failed:', error);
return { error: '注册失败,请稍后重试' };
}
}
客户端错误展示
// app/components/RegisterForm.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { register, RegisterState } from '@/app/actions/user';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{pending ? '注册中...' : '立即注册'}
</button>
);
}
export function RegisterForm() {
const [state, formAction] = useActionState<RegisterState, FormData>(
register,
{ error: undefined, fieldErrors: undefined, success: undefined }
);
if (state.success) {
return (
<div className="p-6 bg-green-50 text-center rounded-lg">
<h3 className="text-lg font-semibold text-green-800">注册成功!</h3>
<p className="text-green-600 mt-2">请检查邮箱完成验证。</p>
</div>
);
}
return (
<form action={formAction} className="space-y-4">
{/* 全局错误 */}
{state.error && (
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{state.error}
</div>
)}
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
邮箱
</label>
<input
id="email"
name="email"
type="email"
className="w-full px-3 py-2 border rounded-lg"
aria-invalid={!!state.fieldErrors?.email}
/>
{state.fieldErrors?.email && (
<p className="mt-1 text-sm text-red-500">{state.fieldErrors.email}</p>
)}
</div>
{/* 昵称 */}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
昵称
</label>
<input
id="name"
name="name"
type="text"
className="w-full px-3 py-2 border rounded-lg"
aria-invalid={!!state.fieldErrors?.name}
/>
{state.fieldErrors?.name && (
<p className="mt-1 text-sm text-red-500">{state.fieldErrors.name}</p>
)}
</div>
{/* 密码 */}
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
密码
</label>
<input
id="password"
name="password"
type="password"
className="w-full px-3 py-2 border rounded-lg"
aria-invalid={!!state.fieldErrors?.password}
/>
{state.fieldErrors?.password && (
<p className="mt-1 text-sm text-red-500">{state.fieldErrors.password}</p>
)}
</div>
<SubmitButton />
</form>
);
}
9.6 安全策略
认证检查
Server Actions 不会自动继承页面级别的认证状态,必须在每个 Action 中手动验证:
// app/actions/admin.ts
'use server';
import { prisma } from '@/lib/prisma';
import { getUserFromCookie } from '@/lib/auth';
import { redirect } from 'next/navigation';
// 鉴权辅助函数
async function requireAuth() {
const user = await getUserFromCookie();
if (!user) {
redirect('/login');
}
return user;
}
async function requireAdmin() {
const user = await requireAuth();
if (user.role !== 'admin') {
throw new Error('Unauthorized: Admin access required');
}
return user;
}
export async function deleteUser(userId: string) {
// 每次调用都验证权限
const currentUser = await requireAdmin();
// 不能删除自己
if (currentUser.id === userId) {
return { error: '不能删除自己的账号' };
}
await prisma.user.delete({ where: { id: userId } });
return { success: true };
}
export async function toggleUserStatus(userId: string) {
const currentUser = await requireAdmin();
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return { error: '用户不存在' };
}
await prisma.user.update({
where: { id: userId },
data: { isActive: !user.isActive },
});
return { success: true };
}
输入验证(必须做)
// app/actions/post.ts
'use server';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { getUserFromCookie } from '@/lib/auth';
import { redirect } from 'next/navigation';
// 严格的输入验证 Schema
const createPostSchema = z.object({
title: z
.string()
.min(1, '标题不能为空')
.max(200, '标题不能超过 200 个字符')
.trim(),
content: z
.string()
.min(10, '内容至少 10 个字符')
.max(50000, '内容不能超过 50000 个字符'),
category: z.enum(['frontend', 'backend', 'devops', 'design']),
tags: z
.array(z.string().max(30))
.max(5, '最多 5 个标签')
.optional(),
});
export async function createPost(formData: FormData) {
const user = await getUserFromCookie();
if (!user) redirect('/login');
const raw = {
title: formData.get('title'),
content: formData.get('content'),
category: formData.get('category'),
tags: formData.getAll('tags'),
};
const validation = createPostSchema.safeParse(raw);
if (!validation.success) {
return {
error: '输入验证失败',
fieldErrors: Object.fromEntries(
validation.error.issues.map(i => [i.path[0], i.message])
),
};
}
const { title, content, category, tags } = validation.data;
const post = await prisma.post.create({
data: {
title,
content,
category,
tags,
authorId: user.id,
},
});
redirect(`/posts/${post.id}`);
}
CSRF 防护
Next.js Server Actions 自动内置了 CSRF 防护:
// Next.js 自动处理:
// 1. 每个 Server Action 调用都会携带 Origin 头
// 2. 服务端验证 Origin 与 Host 是否匹配
// 3. 不匹配时拒绝请求
// 如需自定义,可在 next.config.js 中配置:
// module.exports = {
// experimental: {
// serverActions: {
// allowedOrigins: ['localhost:3000', 'yourdomain.com'],
// },
// },
// }
权限矩阵
// lib/permissions.ts
export type Permission = 'create' | 'read' | 'update' | 'delete';
export type Resource = 'post' | 'comment' | 'user' | 'settings';
const rolePermissions: Record<string, Record<Resource, Permission[]>> = {
admin: {
post: ['create', 'read', 'update', 'delete'],
comment: ['create', 'read', 'update', 'delete'],
user: ['create', 'read', 'update', 'delete'],
settings: ['read', 'update'],
},
editor: {
post: ['create', 'read', 'update'],
comment: ['create', 'read', 'update'],
user: ['read'],
settings: ['read'],
},
user: {
post: ['create', 'read'],
comment: ['create', 'read'],
user: ['read'],
settings: ['read'],
},
};
export function hasPermission(
userRole: string,
resource: Resource,
permission: Permission
): boolean {
return rolePermissions[userRole]?.[resource]?.includes(permission) ?? false;
}
// 在 Server Action 中使用
export async function deletePost(postId: string) {
'use server';
const user = await getUserFromCookie();
if (!user) redirect('/login');
if (!hasPermission(user.role, 'post', 'delete')) {
return { error: '没有权限执行此操作' };
}
await prisma.post.delete({ where: { id: postId } });
return { success: true };
}
9.7 Server Action vs Route Handler
对比总览
| 维度 | Server Action | Route Handler |
|---|---|---|
| 定义方式 | 'use server' 函数 | app/api/*/route.ts |
| HTTP 方法 | 始终 POST(Next.js 内部处理) | GET / POST / PUT / DELETE 等 |
| 调用方式 | 函数调用 action() | fetch('/api/...') |
| 表单集成 | 原生 <form action={fn}> | 需要手动 onSubmit + fetch |
| 渐进增强 | 无 JS 也可提交(HTML 表单) | 必须有 JS |
| 缓存重新验证 | 自动(revalidatePath) | 需要手动 |
| 流式响应 | 不支持 | 支持 |
| 文件上传 | 支持(FormData) | 支持(更灵活) |
| WebSocket / SSE | 不支持 | 支持 |
| 外部系统对接 | 不适合(仅限内部调用) | 适合(标准 REST) |
| SEO 友好 | 不直接相关 | 可被爬虫索引(GET) |
选型决策树
需要处理数据变更?
├── 是 → 从表单触发?
│ ├── 是 → ✅ Server Action(首选)
│ └── 否 → 需要流式/SSE/WebSocket?
│ ├── 是 → ✅ Route Handler
│ └── 否 → 需要被外部系统调用?
│ ├── 是 → ✅ Route Handler
│ └── 否 → ✅ Server Action(更简洁)
└── 否 → 需要 GET 请求获取数据?
├── 是 → ✅ Route Handler(或 RSC 直接 fetch)
└── 否 → ✅ Route Handler
可以组合使用
// Route Handler 作为对外 API
// app/api/v1/articles/route.ts
export async function POST(request: NextRequest) {
// 鉴权、验证...
const article = await createArticle(body); // 复用 Server Action 逻辑
return NextResponse.json(article);
}
// Server Action 处理核心逻辑
// app/actions/article.ts
'use server';
export async function createArticle(data: CreateArticleInput) {
const article = await prisma.article.create({ data });
revalidatePath('/articles');
return article;
}
9.8 高级用法
从 Client Component 直接调用
'use client';
import { likePost, unlikePost } from '@/app/actions/like';
import { useOptimistic, useTransition } from 'react';
export function LikeButton({
postId,
initialLiked,
initialCount,
}: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const [isPending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(state, newLiked: boolean) => ({
liked: newLiked,
count: newLiked ? state.count + 1 : state.count - 1,
})
);
function handleToggle() {
const newLiked = !optimistic.liked;
setOptimistic(newLiked);
startTransition(async () => {
try {
if (newLiked) {
await likePost(postId);
} else {
await unlikePost(postId);
}
} catch {
// 回滚乐观更新
setOptimistic(!newLiked);
}
});
}
return (
<button
onClick={handleToggle}
disabled={isPending}
className={`flex items-center gap-1 ${optimistic.liked ? 'text-red-500' : 'text-gray-500'}`}
>
{optimistic.liked ? '❤️' : '🤍'}
<span>{optimistic.count}</span>
</button>
);
}
redirect 与 notFound
// app/actions/auth.ts
'use server';
import { redirect } from 'next/navigation';
import { notFound } from 'next/navigation';
export async function login(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const user = await authenticate(email, password);
if (!user) {
return { error: '邮箱或密码错误' };
}
// 设置 cookie...
// 登录成功,重定向到仪表盘
redirect('/dashboard');
}
export async function deleteArticle(articleId: string) {
const article = await prisma.article.findUnique({ where: { id: articleId } });
if (!article) {
// 触发 404 页面
notFound();
}
await prisma.article.delete({ where: { id: articleId } });
redirect('/articles');
}
Progressive Enhancement(渐进增强)
Server Actions 的一大优势是无 JavaScript 也能工作:
// 这个表单即使 JS 未加载也可以提交
// 因为 <form action={serverAction}> 会被渲染为标准 HTML form
<form action={createArticle}>
<input name="title" />
<button type="submit">提交</button>
</form>
// 当 JS 加载后,Next.js 会自动拦截提交,
// 转为 fetch 请求,实现无刷新体验
9.9 实战:完整的博客评论系统
Server Actions 定义
// app/actions/comment.ts
'use server';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
// 验证 Schema
const createCommentSchema = z.object({
content: z
.string()
.min(1, '评论内容不能为空')
.max(2000, '评论不能超过 2000 个字符')
.trim(),
parentId: z.string().optional(),
});
export type CommentState = {
error?: string;
fieldErrors?: Record<string, string>;
success?: boolean;
};
// 获取当前用户(从 cookie 中解析)
async function getCurrentUser() {
const cookieStore = await cookies();
const token = cookieStore.get('auth-token')?.value;
if (!token) return null;
try {
const decoded = verify(token, process.env.JWT_SECRET!) as { id: string };
return decoded;
} catch {
return null;
}
}
// 创建评论
export async function createComment(
postId: string,
prevState: CommentState,
formData: FormData
): Promise<CommentState> {
const user = await getCurrentUser();
if (!user) {
return { error: '请先登录后再评论' };
}
const raw = {
content: formData.get('content'),
parentId: formData.get('parentId') || undefined,
};
const validation = createCommentSchema.safeParse(raw);
if (!validation.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of validation.error.issues) {
const field = issue.path[0]?.toString() ?? 'form';
fieldErrors[field] = issue.message;
}
return { fieldErrors };
}
const { content, parentId } = validation.data;
// 如果是回复,验证父评论存在
if (parentId) {
const parent = await prisma.comment.findUnique({ where: { id: parentId } });
if (!parent || parent.postId !== postId) {
return { error: '回复的评论不存在' };
}
}
try {
await prisma.comment.create({
data: {
content,
postId,
authorId: user.id,
parentId,
},
});
revalidatePath(`/posts/${postId}`);
return { success: true };
} catch (error) {
console.error('Failed to create comment:', error);
return { error: '评论发布失败,请稍后重试' };
}
}
// 删除评论
export async function deleteComment(
commentId: string
): Promise<{ success?: boolean; error?: string }> {
const user = await getCurrentUser();
if (!user) {
return { error: '请先登录' };
}
const comment = await prisma.comment.findUnique({
where: { id: commentId },
include: { post: { select: { slug: true } } },
});
if (!comment) {
return { error: '评论不存在' };
}
// 只有评论作者或管理员可以删除
if (comment.authorId !== user.id && user.role !== 'admin') {
return { error: '没有权限删除此评论' };
}
try {
await prisma.comment.delete({ where: { id: commentId } });
revalidatePath(`/posts/${comment.post.slug}`);
return { success: true };
} catch (error) {
console.error('Failed to delete comment:', error);
return { error: '删除失败,请稍后重试' };
}
}
// 点赞评论
export async function likeComment(
commentId: string
): Promise<{ success?: boolean; error?: string }> {
const user = await getCurrentUser();
if (!user) {
return { error: '请先登录' };
}
try {
// 检查是否已点赞
const existing = await prisma.commentLike.findUnique({
where: {
userId_commentId: {
userId: user.id,
commentId,
},
},
});
if (existing) {
// 取消点赞
await prisma.commentLike.delete({
where: { id: existing.id },
});
} else {
// 点赞
await prisma.commentLike.create({
data: { userId: user.id, commentId },
});
}
revalidatePath('/');
return { success: true };
} catch (error) {
console.error('Failed to like comment:', error);
return { error: '操作失败' };
}
}
评论表单组件
// app/components/CommentForm.tsx
'use client';
import { useActionState, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import { createComment, CommentState } from '@/app/actions/comment';
function SubmitButton({ replyMode }: { replyMode: boolean }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{pending ? '发布中...' : replyMode ? '回复' : '发布评论'}
</button>
);
}
export function CommentForm({
postId,
parentId,
replyTo,
onSuccess,
onCancel,
}: {
postId: string;
parentId?: string;
replyTo?: string;
onSuccess?: () => void;
onCancel?: () => void;
}) {
const formRef = useRef<HTMLFormElement>(null);
const [state, formAction] = useActionState<CommentState, FormData>(
createComment.bind(null, postId),
{ error: undefined, fieldErrors: undefined, success: undefined }
);
// 提交成功后清空表单
useEffect(() => {
if (state.success) {
formRef.current?.reset();
onSuccess?.();
}
}, [state.success, onSuccess]);
return (
<form ref={formRef} action={formAction} className="space-y-3">
{replyTo && (
<div className="flex items-center justify-between text-sm text-gray-500">
<span>回复 {replyTo}</span>
{onCancel && (
<button type="button" onClick={onCancel} className="text-red-500">
取消
</button>
)}
</div>
)}
{parentId && <input type="hidden" name="parentId" value={parentId} />}
{state.error && (
<div className="p-2 bg-red-50 text-red-600 text-sm rounded">
{state.error}
</div>
)}
<textarea
name="content"
rows={3}
placeholder={replyTo ? `回复 ${replyTo}...` : '写下你的评论...'}
className="w-full px-3 py-2 border rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
aria-invalid={!!state.fieldErrors?.content}
/>
{state.fieldErrors?.content && (
<p className="text-red-500 text-xs">{state.fieldErrors.content}</p>
)}
<div className="flex items-center gap-2">
<SubmitButton replyMode={!!parentId} />
</div>
</form>
);
}
评论列表组件
// app/components/CommentList.tsx
'use client';
import { useState, useTransition } from 'react';
import { deleteComment, likeComment } from '@/app/actions/comment';
import { CommentForm } from './CommentForm';
type Comment = {
id: string;
content: string;
createdAt: string;
author: {
id: string;
name: string;
avatar: string | null;
};
_count: {
likes: number;
replies: number;
};
replies?: Comment[];
};
export function CommentList({
comments,
postId,
currentUserId,
}: {
comments: Comment[];
postId: string;
currentUserId?: string;
}) {
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold">
评论 ({comments.length})
</h3>
{comments.length === 0 ? (
<p className="text-gray-500 text-sm">暂无评论,来说两句吧!</p>
) : (
comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
postId={postId}
currentUserId={currentUserId}
depth={0}
/>
))
)}
</div>
);
}
function CommentItem({
comment,
postId,
currentUserId,
depth,
}: {
comment: Comment;
postId: string;
currentUserId?: string;
depth: number;
}) {
const [showReplyForm, setShowReplyForm] = useState(false);
const [isPending, startTransition] = useTransition();
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(comment._count.likes);
const isAuthor = currentUserId === comment.author.id;
const maxDepth = 3;
function handleDelete() {
if (!confirm('确定要删除这条评论吗?')) return;
startTransition(async () => {
const result = await deleteComment(comment.id);
if (result.error) {
alert(result.error);
}
});
}
function handleLike() {
setLiked(!liked);
setLikeCount(liked ? likeCount - 1 : likeCount + 1);
startTransition(async () => {
await likeComment(comment.id);
});
}
return (
<div className={`${depth > 0 ? 'ml-8 border-l-2 border-gray-100 pl-4' : ''}`}>
<div className="flex gap-3">
{/* 头像 */}
<div className="w-8 h-8 rounded-full bg-gray-200 flex-shrink-0 overflow-hidden">
{comment.author.avatar ? (
<img
src={comment.author.avatar}
alt={comment.author.name}
className="w-full h-full object-cover"
/>
) : (
<span className="w-full h-full flex items-center justify-center text-sm text-gray-500">
{comment.author.name[0]}
</span>
)}
</div>
{/* 内容 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{comment.author.name}</span>
<time className="text-xs text-gray-400">
{new Date(comment.createdAt).toLocaleDateString('zh-CN')}
</time>
</div>
<p className="mt-1 text-sm text-gray-700 whitespace-pre-wrap">
{comment.content}
</p>
{/* 操作栏 */}
<div className="flex items-center gap-4 mt-2">
<button
onClick={handleLike}
disabled={isPending}
className={`text-xs flex items-center gap-1 ${liked ? 'text-red-500' : 'text-gray-500'} hover:text-red-500`}
>
{liked ? '❤️' : '🤍'} {likeCount}
</button>
{depth < maxDepth && (
<button
onClick={() => setShowReplyForm(!showReplyForm)}
className="text-xs text-gray-500 hover:text-blue-600"
>
回复
</button>
)}
{isAuthor && (
<button
onClick={handleDelete}
disabled={isPending}
className="text-xs text-gray-500 hover:text-red-600"
>
删除
</button>
)}
</div>
{/* 回复表单 */}
{showReplyForm && (
<div className="mt-3">
<CommentForm
postId={postId}
parentId={comment.id}
replyTo={comment.author.name}
onSuccess={() => setShowReplyForm(false)}
onCancel={() => setShowReplyForm(false)}
/>
</div>
)}
</div>
</div>
{/* 子评论 */}
{comment.replies && comment.replies.length > 0 && (
<div className="mt-4 space-y-4">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
postId={postId}
currentUserId={currentUserId}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
整合到文章页面
// app/posts/[slug]/page.tsx
import { prisma } from '@/lib/prisma';
import { notFound } from 'next/navigation';
import { CommentList } from '@/app/components/CommentList';
import { CommentForm } from '@/app/components/CommentForm';
import { getCurrentUser } from '@/lib/auth';
import { Suspense } from 'react';
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await prisma.article.findUnique({
where: { slug },
include: {
author: {
select: { id: true, name: true, avatar: true },
},
comments: {
where: { parentId: null }, // 只获取顶级评论
orderBy: { createdAt: 'desc' },
include: {
author: { select: { id: true, name: true, avatar: true } },
_count: { select: { likes: true, replies: true } },
replies: {
orderBy: { createdAt: 'asc' },
include: {
author: { select: { id: true, name: true, avatar: true } },
_count: { select: { likes: true, replies: true } },
replies: {
orderBy: { createdAt: 'asc' },
include: {
author: { select: { id: true, name: true, avatar: true } },
_count: { select: { likes: true, replies: true } },
},
},
},
},
},
},
},
});
if (!post) notFound();
const currentUser = await getCurrentUser();
return (
<article className="max-w-3xl mx-auto py-8 px-4">
{/* 文章头部 */}
<header className="mb-8">
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-3 text-sm text-gray-500">
<span>{post.author.name}</span>
<span>·</span>
<time>{new Date(post.createdAt).toLocaleDateString('zh-CN')}</time>
<span>·</span>
<span>{post.comments.length} 条评论</span>
</div>
</header>
{/* 文章内容 */}
<div
className="prose prose-lg max-w-none mb-12"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
{/* 评论区 */}
<section className="border-t pt-8">
{/* 发表评论 */}
<div className="mb-8">
<h3 className="text-lg font-semibold mb-4">发表评论</h3>
{currentUser ? (
<CommentForm postId={post.id} />
) : (
<p className="text-gray-500">
请先 <a href="/login" className="text-blue-600 hover:underline">登录</a> 后再评论
</p>
)}
</div>
{/* 评论列表 */}
<Suspense fallback={<div>加载评论中...</div>}>
<CommentList
comments={post.comments}
postId={post.id}
currentUserId={currentUser?.id}
/>
</Suspense>
</section>
</article>
);
}
本章小结
Key Takeaways
- Server Actions 是数据变更的首选方案:比 Route Handler 更简洁,自动处理缓存重新验证、CSRF 防护
"use server"有两种使用方式:函数级声明(混用)和文件级声明(专用文件)- 三个 React Hook 是表单开发的核心:
useFormStatus(loading)、useActionState(状态管理)、useOptimistic(乐观更新) - 安全是不可跳过的步骤:每个 Server Action 都必须验证认证状态和输入数据
- Server Action vs Route Handler 各有适用场景:表单提交用 Server Action,对外 API 用 Route Handler
- 渐进增强是 Server Actions 的独特优势:无 JavaScript 时表单仍可提交
下一步
Phase 3(数据获取)到此完成。下一卷 卷 IV:数据库、认证与中间件 将从第 10 章开始,涵盖:
- 第 10 章:数据库集成(Prisma + PostgreSQL / MongoDB / PlanetScale)
- 第 11 章:认证体系(NextAuth.js v5 / Lucia / Clerk)
- 第 12 章:Middleware 中间件(路由守卫、国际化、A/B 测试)
参考资料
- Next.js Server Actions 官方文档
- React useFormStatus
- React useActionState
- React useOptimistic
- React useTransition
- Progressive Enhancement
- Zod Schema Validation
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。