Next.js从入门到实战保姆级教程(第十章):表单处理与 Server Actions

0 阅读8分钟

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

表单是 Web 应用中最常见的用户交互方式,也是最容易产生代码混乱的区域。Server Actions 提供了一种全新的架构思路:将表单提交逻辑直接编写在服务端函数中,消除传统 API 路由的样板代码,实现前后端逻辑的无缝衔接。

一、传统方式的局限性

在 Server Actions 出现之前,表单提交通常遵循以下流程:

  1. 前端组件监听 onSubmit 事件
  2. 事件处理函数通过 fetch 调用 API 路由
  3. API 路由(/api/xxx)处理业务逻辑、数据验证和数据库操作
  4. 前端接收响应并更新 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需精确控制响应流
公开 APIRoute 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 的选择策略

下一章将探讨错误处理与加载状态管理——这是构建健壮应用的关键环节,确保在任何异常情况下都能提供良好的用户体验。