第 9 章:Server Actions(后端函数调用)

深入理解 Next.js Server Actions 的工作原理与使用方式,从 "use server" 语法到表单链路、乐观更新、错误处理,并对比 Route Handlers 的适用场景。

本章目标:全面掌握 Next.js Server Actions——理解 "use server" 指令的本质,学会在表单提交、数据变更、乐观更新中高效使用 Server Actions,并建立 Server Action vs Route Handler 的清晰选型判断。


9.1 Server Actions 概述

什么是 Server Action?

Server Action 是 Next.js 中一种特殊的异步函数,它在 服务端执行,但可以从 Server ComponentsClient 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 }
useActionStateAction 返回值 + loadingClient Component[state, formAction, isPending]
useOptimistic乐观更新 UIClient 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 ActionRoute 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>
  );
}

redirectnotFound

// 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

  1. Server Actions 是数据变更的首选方案:比 Route Handler 更简洁,自动处理缓存重新验证、CSRF 防护
  2. "use server" 有两种使用方式:函数级声明(混用)和文件级声明(专用文件)
  3. 三个 React Hook 是表单开发的核心useFormStatus(loading)、useActionState(状态管理)、useOptimistic(乐观更新)
  4. 安全是不可跳过的步骤:每个 Server Action 都必须验证认证状态和输入数据
  5. Server Action vs Route Handler 各有适用场景:表单提交用 Server Action,对外 API 用 Route Handler
  6. 渐进增强是 Server Actions 的独特优势:无 JavaScript 时表单仍可提交

下一步

Phase 3(数据获取)到此完成。下一卷 卷 IV:数据库、认证与中间件 将从第 10 章开始,涵盖:

  • 第 10 章:数据库集成(Prisma + PostgreSQL / MongoDB / PlanetScale)
  • 第 11 章:认证体系(NextAuth.js v5 / Lucia / Clerk)
  • 第 12 章:Middleware 中间件(路由守卫、国际化、A/B 测试)

参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页