第 16 章:表单与验证(React Hook Form + Zod)

在 Next.js App Router 中构建类型安全的表单系统——React Hook Form + Zod 深度集成,涵盖动态字段、多步骤表单、文件上传、异步验证与 Server Action 提交。

本章目标:掌握在 Next.js 中构建复杂表单的完整技术栈——React Hook Form 负责表单状态管理,Zod 负责 Schema 验证,Server Actions 负责服务端提交,三者协同实现类型安全、高性能的表单体验。


16.1 表单方案选型

三种表单方案对比

维度原生 <form> + useStateReact Hook FormFormik
包体积0~7KB gzip~14KB gzip
性能差(每次输入重渲染整个表单)极好(非受控组件,只重渲染变化的字段)
TypeScript手动✅ 完整类型推导✅ 完整类型
验证手动支持多种(Yup / Zod / 自定义)Yup / 自定义
与 RSC 兼容⚠️ 需 "use client"
学习曲线
生态-丰富(Controller、useFieldArray)丰富

本教程选择:React Hook Form + Zod

理由:

  1. 性能极佳:基于非受控组件,输入时不会触发整个表单重渲染
  2. 类型安全:Zod Schema 可以直接推导 TypeScript 类型
  3. 验证统一:前端和后端使用同一套 Zod Schema
  4. 体积小:gzip 后仅 7KB
  5. 生态成熟:与 shadcn/ui 的 <Form> 组件深度集成

16.2 安装与基础配置

安装

npm install react-hook-form zod @hookform/resolvers
npm install @radix-ui/react-label @radix-ui/react-slot

shadcn/ui Form 组件

npx shadcn@latest add form input textarea select label

核心 API 速览

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 1. 定义 Schema
const schema = z.object({
  name: z.string().min(1, '姓名不能为空'),
  email: z.string().email('邮箱格式不正确'),
});

// 2. 推导类型
type FormData = z.infer<typeof schema>;

// 3. 初始化表单
const {
  register,       // 注册字段
  handleSubmit,   // 提交处理
  formState: {
    errors,       // 错误信息
    isSubmitting, // 是否提交中
    isValid,      // 是否验证通过
    isDirty,      // 是否有修改
  },
  watch,          // 监听字段值
  setValue,       // 设置字段值
  reset,          // 重置表单
  control,        // Controller 控制对象
} = useForm<FormData>({
  resolver: zodResolver(schema),
});

16.3 基础表单

简单联系表单

// app/components/forms/contact-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';

// 定义验证 Schema
const contactSchema = z.object({
  name: z.string().min(2, '姓名至少 2 个字符').max(50, '姓名最多 50 个字符'),
  email: z.string().email('请输入有效的邮箱地址'),
  subject: z.string().min(1, '主题不能为空').max(200),
  message: z.string().min(10, '内容至少 10 个字符').max(5000, '内容最多 5000 个字符'),
});

// 推导类型
type ContactFormData = z.infer<typeof contactSchema>;

export function ContactForm() {
  const form = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      name: '',
      email: '',
      subject: '',
      message: '',
    },
  });

  async function onSubmit(data: ContactFormData) {
    // 模拟提交
    await new Promise((resolve) => setTimeout(resolve, 1000));
    console.log('Submitted:', data);
    form.reset();
    alert('提交成功!');
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        {/* 姓名 */}
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>姓名</FormLabel>
              <FormControl>
                <Input placeholder="张三" {...field} />
              </FormControl>
              <FormDescription>您的真实姓名</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* 邮箱 */}
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>邮箱</FormLabel>
              <FormControl>
                <Input type="email" placeholder="your@email.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* 主题 */}
        <FormField
          control={form.control}
          name="subject"
          render={({ field }) => (
            <FormItem>
              <FormLabel>主题</FormLabel>
              <FormControl>
                <Input placeholder="关于..." {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* 内容 */}
        <FormField
          control={form.control}
          name="message"
          render={({ field }) => (
            <FormItem>
              <FormLabel>内容</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="请输入您的消息..."
                  rows={6}
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button
          type="submit"
          disabled={form.formState.isSubmitting}
          className="w-full"
        >
          {form.formState.isSubmitting ? '提交中...' : '提交'}
        </Button>
      </form>
    </Form>
  );
}

16.4 Zod Schema 进阶

常用验证规则

import { z } from 'zod';

// 字符串
z.string().min(1).max(100).email().url().uuid().regex(/pattern/).trim();

// 数字
z.number().min(0).max(100).int().positive().nonnegative();

// 布尔值
z.boolean();

// 枚举
z.enum(['draft', 'published', 'archived']);

// 可选 / 默认值
z.string().optional();             // string | undefined
z.string().default('hello');       // string(默认 'hello')
z.string().nullable();             // string | null

// 数组
z.array(z.string()).min(1, '至少一项').max(10, '最多十项');

// 对象
z.object({
  name: z.string(),
  age: z.number(),
});

// 联合类型
z.union([z.string(), z.number()]);
z.discriminatedUnion('type', [
  z.object({ type: z.literal('text'), content: z.string() }),
  z.object({ type: z.literal('image'), url: z.string() }),
]);

交叉字段验证

// 密码确认
const passwordSchema = z.object({
  password: z.string().min(8, '密码至少 8 个字符'),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: '两次密码不一致',
    path: ['confirmPassword'], // 错误显示在 confirmPassword 字段
  }
);

// 日期范围
const dateRangeSchema = z.object({
  startDate: z.string(),
  endDate: z.string(),
}).refine(
  (data) => new Date(data.endDate) > new Date(data.startDate),
  {
    message: '结束日期必须晚于开始日期',
    path: ['endDate'],
  }
);

// 条件必填
const formSchema = z.object({
  type: z.enum(['personal', 'business']),
  companyName: z.string().optional(),
}).refine(
  (data) => {
    if (data.type === 'business') {
      return !!data.companyName && data.companyName.length > 0;
    }
    return true;
  },
  {
    message: '企业类型必须填写公司名称',
    path: ['companyName'],
  }
);

异步验证

const registerSchema = z.object({
  email: z.string().email().refine(
    async (email) => {
      // 调用 API 检查邮箱是否已注册
      const res = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
      const data = await res.json();
      return !data.exists;
    },
    {
      message: '该邮箱已被注册',
    }
  ),
  username: z.string().min(3).refine(
    async (username) => {
      const res = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`);
      const data = await res.json();
      return !data.exists;
    },
    {
      message: '该用户名已被使用',
    }
  ),
});

16.5 与 Server Action 集成

Schema 复用(前后端统一验证)

// lib/validators/article.ts(Server & Client 共用)

import { z } from 'zod';

export const createArticleSchema = z.object({
  title: z
    .string()
    .min(1, '标题不能为空')
    .max(200, '标题最多 200 个字符')
    .trim(),
  slug: z
    .string()
    .min(1, 'Slug 不能为空')
    .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug 格式不正确(只允许小写字母、数字和连字符)'),
  content: z
    .string()
    .min(10, '内容至少 10 个字符'),
  excerpt: z
    .string()
    .max(500, '摘要最多 500 个字符')
    .optional(),
  category: z.enum(['frontend', 'backend', 'devops', 'design']),
  tags: z.array(z.string()).max(5, '最多 5 个标签').optional(),
  published: z.boolean().default(false),
});

export type CreateArticleInput = z.infer<typeof createArticleSchema>;

Server Action

// app/actions/article.ts
'use server';

import { prisma } from '@/lib/prisma';
import { createArticleSchema, CreateArticleInput } from '@/lib/validators/article';
import { requireAuth } from '@/lib/auth-utils';
import { revalidatePath } from 'next/cache';

export type ActionState = {
  error?: string;
  fieldErrors?: Record<string, string>;
  success?: boolean;
};

export async function createArticle(
  prevState: ActionState,
  data: CreateArticleInput
): Promise<ActionState> {
  const user = await requireAuth();

  // 服务端再次验证(防止绕过客户端验证)
  const validation = createArticleSchema.safeParse(data);
  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 };
  }

  // 检查 slug 唯一性
  const existing = await prisma.article.findUnique({
    where: { slug: data.slug },
  });
  if (existing) {
    return { fieldErrors: { slug: '该 Slug 已被使用' } };
  }

  // 创建文章
  try {
    const article = await prisma.article.create({
      data: {
        ...data,
        authorId: user.id,
        publishedAt: data.published ? new Date() : null,
      },
    });

    revalidatePath('/articles');
    return { success: true };
  } catch (error) {
    console.error('Failed to create article:', error);
    return { error: '创建文章失败,请稍后重试' };
  }
}

表单组件

// app/components/forms/article-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useActionState } from 'react';
import { useRouter } from 'next/navigation';
import { createArticleSchema, CreateArticleInput } from '@/lib/validators/article';
import { createArticle, ActionState } from '@/app/actions/article';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';

export function ArticleForm() {
  const router = useRouter();

  const form = useForm<CreateArticleInput>({
    resolver: zodResolver(createArticleSchema),
    defaultValues: {
      title: '',
      slug: '',
      content: '',
      excerpt: '',
      category: 'frontend',
      tags: [],
      published: false,
    },
  });

  const [state, formAction] = useActionState<ActionState, FormData>(
    async (prevState, formData) => {
      // 从 FormData 转换为 typed object
      const data: CreateArticleInput = {
        title: formData.get('title') as string,
        slug: formData.get('slug') as string,
        content: formData.get('content') as string,
        excerpt: (formData.get('excerpt') as string) || undefined,
        category: formData.get('category') as CreateArticleInput['category'],
        tags: formData.getAll('tags') as string[],
        published: formData.get('published') === 'on',
      };

      const result = await createArticle(prevState, data);

      if (result.success) {
        router.push('/dashboard/articles');
        router.refresh();
      }

      return result;
    },
    {}
  );

  // 自动根据标题生成 slug
  function generateSlug(title: string) {
    return title
      .toLowerCase()
      .replace(/[^\w\s-]/g, '')
      .replace(/\s+/g, '-')
      .replace(/-+/g, '-')
      .trim();
  }

  return (
    <Form {...form}>
      <form action={formAction} className="space-y-6">
        {/* 全局错误 */}
        {state.error && (
          <div className="p-3 bg-destructive/10 text-destructive text-sm rounded-lg">
            {state.error}
          </div>
        )}

        {/* 标题 */}
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>标题</FormLabel>
              <FormControl>
                <Input
                  placeholder="输入文章标题..."
                  {...field}
                  onChange={(e) => {
                    field.onChange(e);
                    // 自动生成 slug(仅当 slug 未手动修改时)
                    if (!form.formState.dirtyFields.slug) {
                      form.setValue('slug', generateSlug(e.target.value));
                    }
                  }}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Slug */}
        <FormField
          control={form.control}
          name="slug"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Slug</FormLabel>
              <FormControl>
                <div className="flex items-center gap-2">
                  <span className="text-sm text-muted-foreground">/articles/</span>
                  <Input placeholder="article-slug" {...field} />
                </div>
              </FormControl>
              <FormDescription>URL 友好标识符,只允许小写字母、数字和连字符</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* 分类 */}
        <FormField
          control={form.control}
          name="category"
          render={({ field }) => (
            <FormItem>
              <FormLabel>分类</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="选择分类" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="frontend">前端</SelectItem>
                  <SelectItem value="backend">后端</SelectItem>
                  <SelectItem value="devops">DevOps</SelectItem>
                  <SelectItem value="design">设计</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* 内容 */}
        <FormField
          control={form.control}
          name="content"
          render={({ field }) => (
            <FormItem>
              <FormLabel>内容</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="使用 Markdown 编写文章内容..."
                  rows={20}
                  className="font-mono text-sm"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* 摘要 */}
        <FormField
          control={form.control}
          name="excerpt"
          render={({ field }) => (
            <FormItem>
              <FormLabel>摘要</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="简短描述文章内容(可选,用于 SEO 和列表展示)"
                  rows={3}
                  {...field}
                />
              </FormControl>
              <FormDescription>
                {field.value?.length ?? 0}/500 字符
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* 发布状态 */}
        <FormField
          control={form.control}
          name="published"
          render={({ field }) => (
            <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
              <div className="space-y-0.5">
                <FormLabel>立即发布</FormLabel>
                <FormDescription>
                  {field.value ? '文章将对所有用户可见' : '保存为草稿,仅自己可见'}
                </FormDescription>
              </div>
              <FormControl>
                <Switch
                  checked={field.value}
                  onCheckedChange={field.onChange}
                />
              </FormControl>
            </FormItem>
          )}
        />

        {/* 操作按钮 */}
        <div className="flex items-center gap-4">
          <Button type="submit" disabled={form.formState.isSubmitting}>
            {form.formState.isSubmitting
              ? '保存中...'
              : form.watch('published')
              ? '发布文章'
              : '保存草稿'
            }
          </Button>
          <Button
            type="button"
            variant="outline"
            onClick={() => router.back()}
          >
            取消
          </Button>
        </div>
      </form>
    </Form>
  );
}

16.6 动态表单字段

useFieldArray

// components/forms/tag-form.tsx
'use client';

import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

const formSchema = z.object({
  tags: z.array(
    z.object({
      name: z.string().min(1, '标签名不能为空'),
      color: z.string().optional(),
    })
  ).min(1, '至少添加一个标签').max(10, '最多 10 个标签'),
});

type FormData = z.infer<typeof formSchema>;

export function TagForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      tags: [{ name: '', color: '#3b82f6' }],
    },
  });

  const { fields, append, remove, move } = useFieldArray({
    control: form.control,
    name: 'tags',
  });

  const onSubmit = (data: FormData) => {
    console.log('Tags:', data.tags);
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
      <div className="space-y-2">
        {fields.map((field, index) => (
          <div key={field.id} className="flex items-center gap-2">
            <Input
              placeholder="标签名称"
              {...form.register(`tags.${index}.name`)}
              className="flex-1"
            />
            <Input
              type="color"
              {...form.register(`tags.${index}.color`)}
              className="w-10 h-10 p-1"
            />
            <Button
              type="button"
              variant="outline"
              size="icon"
              onClick={() => remove(index)}
              disabled={fields.length <= 1}
            >
              ×
            </Button>
          </div>
        ))}
      </div>

      <Button
        type="button"
        variant="outline"
        onClick={() => append({ name: '', color: '#3b82f6' })}
        disabled={fields.length >= 10}
      >
        + 添加标签
      </Button>

      <Button type="submit" className="ml-2">
        保存
      </Button>
    </form>
  );
}

16.7 多步骤表单

分步向导

// components/forms/multi-step-form.tsx
'use client';

import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
  FormField,
  FormItem,
  FormLabel,
  FormControl,
  FormMessage,
} from '@/components/ui/form';

// 每一步的 Schema
const step1Schema = z.object({
  name: z.string().min(2, '姓名至少 2 个字符'),
  email: z.string().email('邮箱格式不正确'),
  phone: z.string().regex(/^1\d{10}$/, '手机号格式不正确'),
});

const step2Schema = z.object({
  company: z.string().min(1, '公司名称不能为空'),
  position: z.string().min(1, '职位不能为空'),
  industry: z.string().min(1, '行业不能为空'),
});

const step3Schema = z.object({
  message: z.string().min(10, '留言至少 10 个字符'),
  budget: z.enum(['small', 'medium', 'large']),
});

// 合并为完整 Schema
const formSchema = step1Schema.merge(step2Schema).merge(step3Schema);

type FormData = z.infer<typeof formSchema>;

const steps = [
  { title: '个人信息', schema: step1Schema, fields: ['name', 'email', 'phone'] },
  { title: '公司信息', schema: step2Schema, fields: ['company', 'position', 'industry'] },
  { title: '需求描述', schema: step3Schema, fields: ['message', 'budget'] },
];

export function MultiStepForm() {
  const [currentStep, setCurrentStep] = useState(0);

  const form = useForm<FormData>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      name: '',
      email: '',
      phone: '',
      company: '',
      position: '',
      industry: '',
      message: '',
      budget: 'medium',
    },
    mode: 'onChange',
  });

  async function handleNext() {
    const stepFields = steps[currentStep].fields as (keyof FormData)[];

    // 验证当前步骤的字段
    const isValid = await form.trigger(stepFields);
    if (isValid && currentStep < steps.length - 1) {
      setCurrentStep((prev) => prev + 1);
    }
  }

  function handlePrev() {
    if (currentStep > 0) {
      setCurrentStep((prev) => prev - 1);
    }
  }

  function onSubmit(data: FormData) {
    console.log('Submitted:', data);
    alert('提交成功!');
  }

  return (
    <FormProvider {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="max-w-lg mx-auto space-y-8">
        {/* 步骤指示器 */}
        <div className="flex items-center justify-between">
          {steps.map((step, index) => (
            <div key={index} className="flex items-center">
              <div className={`
                w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
                ${index === currentStep
                  ? 'bg-primary text-primary-foreground'
                  : index < currentStep
                  ? 'bg-success text-white'
                  : 'bg-muted text-muted-foreground'
                }
              `}>
                {index < currentStep ? '✓' : index + 1}
              </div>
              <span className="ml-2 text-sm hidden sm:inline">{step.title}</span>
              {index < steps.length - 1 && (
                <div className="w-12 h-px bg-border mx-4" />
              )}
            </div>
          ))}
        </div>

        {/* 步骤 1:个人信息 */}
        {currentStep === 0 && (
          <div className="space-y-4 animate-fade-in">
            <h3 className="text-lg font-semibold">个人信息</h3>

            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>姓名</FormLabel>
                  <FormControl>
                    <Input placeholder="张三" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>邮箱</FormLabel>
                  <FormControl>
                    <Input type="email" placeholder="your@email.com" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name="phone"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>手机号</FormLabel>
                  <FormControl>
                    <Input placeholder="13800138000" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
        )}

        {/* 步骤 2:公司信息 */}
        {currentStep === 1 && (
          <div className="space-y-4 animate-fade-in">
            <h3 className="text-lg font-semibold">公司信息</h3>

            <FormField
              control={form.control}
              name="company"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>公司名称</FormLabel>
                  <FormControl>
                    <Input placeholder="XX 科技有限公司" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name="position"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>职位</FormLabel>
                  <FormControl>
                    <Input placeholder="技术总监" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name="industry"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>行业</FormLabel>
                  <FormControl>
                    <Input placeholder="互联网" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
        )}

        {/* 步骤 3:需求描述 */}
        {currentStep === 2 && (
          <div className="space-y-4 animate-fade-in">
            <h3 className="text-lg font-semibold">需求描述</h3>

            <FormField
              control={form.control}
              name="message"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>详细需求</FormLabel>
                  <FormControl>
                    <Textarea
                      placeholder="请描述您的需求..."
                      rows={6}
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name="budget"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>预算范围</FormLabel>
                  <FormControl>
                    <div className="flex gap-4">
                      {[
                        { value: 'small', label: '< 5 万' },
                        { value: 'medium', label: '5-20 万' },
                        { value: 'large', label: '> 20 万' },
                      ].map((option) => (
                        <label
                          key={option.value}
                          className={`
                            flex-1 p-3 border rounded-lg text-center cursor-pointer transition-colors
                            ${field.value === option.value
                              ? 'border-primary bg-primary/5'
                              : 'border-border hover:border-primary/50'
                            }
                          `}
                        >
                          <input
                            type="radio"
                            value={option.value}
                            checked={field.value === option.value}
                            onChange={(e) => field.onChange(e.target.value)}
                            className="sr-only"
                          />
                          {option.label}
                        </label>
                      ))}
                    </div>
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
        )}

        {/* 导航按钮 */}
        <div className="flex items-center justify-between pt-4 border-t">
          <Button
            type="button"
            variant="outline"
            onClick={handlePrev}
            disabled={currentStep === 0}
          >
            上一步
          </Button>

          {currentStep < steps.length - 1 ? (
            <Button type="button" onClick={handleNext}>
              下一步
            </Button>
          ) : (
            <Button type="submit">提交</Button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}

16.8 文件上传表单

单文件上传

// components/forms/avatar-upload-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState, useRef } from 'react';
import Image from 'next/image';
import { Button } from '@/components/ui/button';

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

const formSchema = z.object({
  avatar: z
    .any()
    .refine((file) => file?.length > 0, '请选择头像')
    .refine((file) => file?.[0]?.size <= MAX_FILE_SIZE, '文件大小不能超过 5MB')
    .refine(
      (file) => ACCEPTED_TYPES.includes(file?.[0]?.type),
      '仅支持 JPEG / PNG / WebP 格式'
    ),
});

type FormData = z.infer<typeof formSchema>;

export function AvatarUploadForm() {
  const [preview, setPreview] = useState<string | null>(null);
  const [uploading, setUploading] = useState(false);

  const form = useForm<FormData>({
    resolver: zodResolver(formSchema),
  });

  function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (file) {
      form.setValue('avatar', e.target.files);
      const reader = new FileReader();
      reader.onload = () => setPreview(reader.result as string);
      reader.readAsDataURL(file);
    }
  }

  async function onSubmit(data: FormData) {
    setUploading(true);
    try {
      const formData = new FormData();
      formData.append('file', data.avatar[0]);

      const res = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      });

      if (!res.ok) throw new Error('Upload failed');

      const result = await res.json();
      console.log('Uploaded:', result.url);
      alert('头像上传成功!');
    } catch (error) {
      alert('上传失败');
    } finally {
      setUploading(false);
    }
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
      <div className="flex items-center gap-6">
        {/* 预览 */}
        <div className="w-24 h-24 rounded-full bg-muted overflow-hidden flex items-center justify-center">
          {preview ? (
            <Image
              src={preview}
              alt="Preview"
              width={96}
              height={96}
              className="object-cover"
            />
          ) : (
            <span className="text-2xl text-muted-foreground">👤</span>
          )}
        </div>

        <div>
          <input
            type="file"
            accept={ACCEPTED_TYPES.join(',')}
            onChange={handleFileChange}
            className="block text-sm text-muted-foreground
              file:mr-4 file:py-2 file:px-4
              file:rounded-lg file:border-0
              file:text-sm file:font-medium
              file:bg-primary file:text-primary-foreground
              hover:file:bg-primary/90
              file:cursor-pointer file:transition-colors"
          />
          <p className="text-xs text-muted-foreground mt-1">
            JPEG / PNG / WebP,最大 5MB
          </p>
          {form.formState.errors.avatar && (
            <p className="text-xs text-destructive mt-1">
              {form.formState.errors.avatar.message as string}
            </p>
          )}
        </div>
      </div>

      <Button type="submit" disabled={uploading || !preview}>
        {uploading ? '上传中...' : '上传头像'}
      </Button>
    </form>
  );
}

16.9 表单性能优化

非受控组件(React Hook Form 的默认行为)

// ✅ React Hook Form 默认使用非受控组件
// 输入时不会触发整个表单重渲染
<input {...form.register('name')} />

// ❌ 避免:手动使用 useState 管理每个字段
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// 每次输入都会触发整个组件重渲染

使用 Controller 处理第三方组件

import { Controller } from 'react-hook-form';
import DatePicker from 'react-datepicker';

// 第三方组件必须用 Controller 包裹
<Controller
  name="date"
  control={form.control}
  render={({ field }) => (
    <DatePicker
      selected={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
    />
  )}
/>

延迟验证(debounce)

// 使用 watch + useEffect 实现搜索去抖
'use client';

import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';

export function SearchForm() {
  const form = useForm<{ query: string }>();
  const query = form.watch('query');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    const timer = setTimeout(async () => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = await res.json();
      setResults(data);
    }, 300); // 300ms debounce

    return () => clearTimeout(timer);
  }, [query]);

  return (
    <form>
      <input {...form.register('query')} placeholder="搜索..." />
      <ul>
        {results.map((r: any) => (
          <li key={r.id}>{r.title}</li>
        ))}
      </ul>
    </form>
  );
}

减少不必要的渲染

// ✅ 使用 useFormState 单独订阅表单状态
import { useFormState } from 'react-hook-form';

function SubmitButton({ control }: { control: any }) {
  const { isSubmitting, isValid } = useFormState({ control });

  return (
    <button type="submit" disabled={isSubmitting || !isValid}>
      {isSubmitting ? '提交中...' : '提交'}
    </button>
  );
}

// ✅ 使用 useWatch 监听特定字段
import { useWatch } from 'react-hook-form';

function PriceDisplay({ control }: { control: any }) {
  const price = useWatch({ control, name: 'price' });
  const quantity = useWatch({ control, name: 'quantity' });

  return <p>总计:¥{(price * quantity).toFixed(2)}</p>;
}

16.10 实战:完整的用户设置表单

// app/dashboard/settings/page.tsx

import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { SettingsForm } from '@/app/components/forms/settings-form';

export default async function SettingsPage() {
  const session = await auth();
  if (!session?.user?.id) redirect('/login');

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: {
      name: true,
      email: true,
      bio: true,
      avatar: true,
    },
  });

  if (!user) redirect('/login');

  return (
    <div className="max-w-2xl mx-auto">
      <h1 className="text-2xl font-bold mb-6">账户设置</h1>
      <SettingsForm initialData={user} />
    </div>
  );
}
// app/components/forms/settings-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useActionState } from 'react';
import { updateProfile, UpdateProfileState } from '@/app/actions/profile';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { toast } from 'sonner';

const settingsSchema = z.object({
  name: z.string().min(2, '昵称至少 2 个字符').max(50, '昵称最多 50 个字符'),
  email: z.string().email('邮箱格式不正确'),
  bio: z.string().max(500, '个人简介最多 500 个字符').optional(),
});

type SettingsFormData = z.infer<typeof settingsSchema>;

type Props = {
  initialData: {
    name: string | null;
    email: string;
    bio: string | null;
    avatar: string | null;
  };
};

export function SettingsForm({ initialData }: Props) {
  const form = useForm<SettingsFormData>({
    resolver: zodResolver(settingsSchema),
    defaultValues: {
      name: initialData.name ?? '',
      email: initialData.email,
      bio: initialData.bio ?? '',
    },
  });

  const [state, formAction] = useActionState<UpdateProfileState, FormData>(
    async (prevState, formData) => {
      const data: SettingsFormData = {
        name: formData.get('name') as string,
        email: formData.get('email') as string,
        bio: (formData.get('bio') as string) || undefined,
      };

      const result = await updateProfile(prevState, data);

      if (result.success) {
        toast.success('设置已保存');
      }

      return result;
    },
    {}
  );

  return (
    <Form {...form}>
      <form action={formAction} className="space-y-6">
        {state.error && (
          <div className="p-3 bg-destructive/10 text-destructive text-sm rounded-lg">
            {state.error}
          </div>
        )}

        {/* 头像(只读展示) */}
        <div className="flex items-center gap-4">
          <div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center text-xl font-bold text-primary">
            {initialData.name?.[0]?.toUpperCase() || '?'}
          </div>
          <div>
            <p className="font-medium">{initialData.name || '未设置昵称'}</p>
            <p className="text-sm text-muted-foreground">{initialData.email}</p>
          </div>
        </div>

        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>昵称</FormLabel>
              <FormControl>
                <Input placeholder="你的昵称" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>邮箱</FormLabel>
              <FormControl>
                <Input type="email" {...field} />
              </FormControl>
              <FormDescription>修改邮箱需要重新验证</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="bio"
          render={({ field }) => (
            <FormItem>
              <FormLabel>个人简介</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="介绍一下自己..."
                  rows={4}
                  {...field}
                />
              </FormControl>
              <FormDescription>
                {field.value?.length ?? 0}/500 字符
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <div className="flex items-center gap-4 pt-4 border-t">
          <Button
            type="submit"
            disabled={form.formState.isSubmitting || !form.formState.isDirty}
          >
            {form.formState.isSubmitting ? '保存中...' : '保存设置'}
          </Button>
          {form.formState.isDirty && (
            <Button
              type="button"
              variant="ghost"
              onClick={() => form.reset()}
            >
              重置
            </Button>
          )}
        </div>
      </form>
    </Form>
  );
}

配套的 Server Action

// app/actions/profile.ts
'use server';

import { prisma } from '@/lib/prisma';
import { requireAuth } from '@/lib/auth-utils';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const updateProfileSchema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  bio: z.string().max(500).optional(),
});

export type UpdateProfileState = {
  error?: string;
  fieldErrors?: Record<string, string>;
  success?: boolean;
};

export async function updateProfile(
  prevState: UpdateProfileState,
  data: z.infer<typeof updateProfileSchema>
): Promise<UpdateProfileState> {
  const user = await requireAuth();

  const validation = updateProfileSchema.safeParse(data);
  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 };
  }

  // 检查邮箱是否被其他用户使用
  if (data.email !== user.email) {
    const existing = await prisma.user.findUnique({ where: { email: data.email } });
    if (existing) {
      return { fieldErrors: { email: '该邮箱已被其他用户使用' } };
    }
  }

  try {
    await prisma.user.update({
      where: { id: user.id },
      data: {
        name: data.name,
        email: data.email,
        bio: data.bio,
      },
    });

    revalidatePath('/dashboard/settings');
    return { success: true };
  } catch (error) {
    console.error('Failed to update profile:', error);
    return { error: '保存失败,请稍后重试' };
  }
}

本章小结

Key Takeaways

  1. React Hook Form + Zod 是 Next.js 表单的最佳组合:高性能、类型安全、前后端验证统一
  2. Zod Schema 是验证的核心:前端和后端共享同一套 Schema,避免重复定义
  3. useFieldArray 处理动态字段:增删排序,性能优秀
  4. 多步骤表单使用 trigger() 分步验证:每步只验证当前字段
  5. 文件上传使用 FormData + fetch:配合服务端 Route Handler 处理
  6. 性能关键在于非受控组件:React Hook Form 默认行为已经是最优的

下一步

Phase 5(UI 工程化)到此全部完成!🎉

下一卷 卷 VI:高级架构与生产部署 将涵盖:

  • 第 17 章:性能优化(Core Web Vitals / Bundle 分析 / 图片优化)
  • 第 18 章:SEO 与 Metadata API
  • 第 19 章:测试体系(单元测试 / 集成测试 / E2E 测试)
  • 第 20 章:错误处理与监控
  • 第 21 章:Docker 部署与 CI/CD
  • 第 22 章:多租户 SaaS 架构实战

参考资料

继续阅读

探索更多技术文章

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

全部文章 返回首页