Skip to content

表单规范

核心原则

必须使用 React Hook Form 处理表单,必须使用 Zod 定义验证规则,遵循 shadcn/ui Form 组件的使用方式。

基础设置

安装依赖

bash
npm install react-hook-form @hookform/resolvers zod
npx shadcn@latest add form input button

基础表单结构

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

// 1. 定义 Schema
const userSchema = z.object({
  name: z.string().min(2, '姓名至少 2 个字符'),
  email: z.string().email('邮箱格式不正确'),
});

// 2. 推断类型
type UserFormData = z.infer<typeof userSchema>;

// 3. 表单组件
export function UserForm() {
  const form = useForm<UserFormData>({
    resolver: zodResolver(userSchema),
    defaultValues: {
      name: '',
      email: '',
    },
  });

  const onSubmit = (data: UserFormData) => {
    console.log(data);
  };

  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="请输入邮箱" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit">提交</Button>
      </form>
    </Form>
  );
}

Zod Schema 定义

基础类型验证

typescript
import { z } from 'zod';

const basicSchema = z.object({
  // 字符串
  name: z.string().min(1, '必填'),

  // 邮箱
  email: z.string().email('邮箱格式不正确'),

  // 手机号
  phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),

  // 密码
  password: z.string().min(8, '密码至少 8 位').max(32, '密码最多 32 位'),

  // 数字
  age: z.coerce.number().min(1, '年龄必须大于 0').max(150, '年龄不能超过 150'),

  // 布尔值
  agree: z.boolean().refine((val) => val === true, '必须同意条款'),

  // 枚举
  role: z.enum(['admin', 'user', 'guest'], {
    errorMap: () => ({ message: '请选择角色' }),
  }),

  // 可选字段
  nickname: z.string().optional(),

  // 日期
  birthday: z.coerce.date({
    errorMap: () => ({ message: '请选择日期' }),
  }),
});

条件验证

typescript
const passwordSchema = z
  .object({
    password: z.string().min(8, '密码至少 8 位'),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: '两次密码不一致',
    path: ['confirmPassword'],
  });

复用 Schema

typescript
// src/schemas/common.ts
export const emailSchema = z.string().email('邮箱格式不正确');
export const phoneSchema = z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确');
export const passwordSchema = z.string().min(8, '密码至少 8 位');

// src/schemas/user.ts
import { emailSchema, phoneSchema } from './common';

export const createUserSchema = z.object({
  email: emailSchema,
  phone: phoneSchema,
  name: z.string().min(2, '姓名至少 2 个字符'),
});

export const updateUserSchema = createUserSchema.partial();

表单组件

Select 选择器

typescript
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';

<FormField
  control={form.control}
  name="role"
  render={({ field }) => (
    <FormItem>
      <FormLabel>角色</FormLabel>
      <Select onValueChange={field.onChange} defaultValue={field.value}>
        <FormControl>
          <SelectTrigger>
            <SelectValue placeholder="请选择角色" />
          </SelectTrigger>
        </FormControl>
        <SelectContent>
          <SelectItem value="admin">管理员</SelectItem>
          <SelectItem value="user">普通用户</SelectItem>
          <SelectItem value="guest">访客</SelectItem>
        </SelectContent>
      </Select>
      <FormMessage />
    </FormItem>
  )}
/>

Checkbox 复选框

typescript
import { Checkbox } from '@/components/ui/checkbox';

<FormField
  control={form.control}
  name="agree"
  render={({ field }) => (
    <FormItem className="flex flex-row items-start space-x-3 space-y-0">
      <FormControl>
        <Checkbox
          checked={field.value}
          onCheckedChange={field.onChange}
        />
      </FormControl>
      <div className="space-y-1 leading-none">
        <FormLabel>同意服务条款</FormLabel>
        <FormDescription>
          点击查看<a href="/terms" className="text-primary">服务条款</a>
        </FormDescription>
      </div>
      <FormMessage />
    </FormItem>
  )}
/>

Textarea 文本域

typescript
import { Textarea } from '@/components/ui/textarea';

<FormField
  control={form.control}
  name="bio"
  render={({ field }) => (
    <FormItem>
      <FormLabel>个人简介</FormLabel>
      <FormControl>
        <Textarea
          placeholder="介绍一下自己..."
          className="resize-none"
          {...field}
        />
      </FormControl>
      <FormDescription>最多 200 字</FormDescription>
      <FormMessage />
    </FormItem>
  )}
/>

RadioGroup 单选组

typescript
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';

<FormField
  control={form.control}
  name="gender"
  render={({ field }) => (
    <FormItem className="space-y-3">
      <FormLabel>性别</FormLabel>
      <FormControl>
        <RadioGroup
          onValueChange={field.onChange}
          defaultValue={field.value}
          className="flex flex-row space-x-4"
        >
          <FormItem className="flex items-center space-x-2 space-y-0">
            <FormControl>
              <RadioGroupItem value="male" />
            </FormControl>
            <FormLabel className="font-normal"></FormLabel>
          </FormItem>
          <FormItem className="flex items-center space-x-2 space-y-0">
            <FormControl>
              <RadioGroupItem value="female" />
            </FormControl>
            <FormLabel className="font-normal"></FormLabel>
          </FormItem>
        </RadioGroup>
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>

异步验证

邮箱唯一性验证

typescript
const userSchema = z.object({
  email: z
    .string()
    .email('邮箱格式不正确')
    .refine(
      async (email) => {
        const response = await fetch(`/api/check-email?email=${email}`);
        const { available } = await response.json();
        return available;
      },
      { message: '该邮箱已被注册' }
    ),
});

在表单中处理异步验证

typescript
export function RegisterForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(userSchema),
    mode: 'onBlur', // 失去焦点时验证
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>邮箱</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
      </form>
    </Form>
  );
}

表单状态处理

提交状态

typescript
export function SubmitForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const { isSubmitting, isValid } = form.formState;

  const onSubmit = async (data: FormData) => {
    await submitData(data);
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        {/* 表单字段 */}

        <Button type="submit" disabled={isSubmitting || !isValid}>
          {isSubmitting ? '提交中...' : '提交'}
        </Button>
      </form>
    </Form>
  );
}

错误处理

typescript
export function ErrorHandlingForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = async (data: FormData) => {
    try {
      await submitData(data);
    } catch (error) {
      // 设置服务端错误
      form.setError('root', {
        message: '提交失败,请稍后重试',
      });

      // 或设置特定字段错误
      form.setError('email', {
        message: '该邮箱已存在',
      });
    }
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        {/* 显示全局错误 */}
        {form.formState.errors.root && (
          <div className="text-destructive text-sm">
            {form.formState.errors.root.message}
          </div>
        )}

        {/* 表单字段 */}
      </form>
    </Form>
  );
}

动态表单

字段数组

typescript
import { useFieldArray } from 'react-hook-form';
import { Plus, Trash2 } from 'lucide-react';

const schema = z.object({
  contacts: z.array(
    z.object({
      name: z.string().min(1, '必填'),
      phone: z.string().min(1, '必填'),
    })
  ).min(1, '至少添加一个联系人'),
});

export function DynamicForm() {
  const form = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema),
    defaultValues: {
      contacts: [{ name: '', phone: '' }],
    },
  });

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

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        {fields.map((field, index) => (
          <div key={field.id} className="flex gap-4 items-end">
            <FormField
              control={form.control}
              name={`contacts.${index}.name`}
              render={({ field }) => (
                <FormItem className="flex-1">
                  <FormLabel>姓名</FormLabel>
                  <FormControl>
                    <Input {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name={`contacts.${index}.phone`}
              render={({ field }) => (
                <FormItem className="flex-1">
                  <FormLabel>电话</FormLabel>
                  <FormControl>
                    <Input {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

            <Button
              type="button"
              variant="destructive"
              size="icon"
              onClick={() => remove(index)}
              disabled={fields.length === 1}
            >
              <Trash2 className="h-4 w-4" />
            </Button>
          </div>
        ))}

        <Button
          type="button"
          variant="outline"
          onClick={() => append({ name: '', phone: '' })}
        >
          <Plus className="h-4 w-4 mr-2" />
          添加联系人
        </Button>

        <Button type="submit">提交</Button>
      </form>
    </Form>
  );
}

表单重置

typescript
export function ResettableForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: {
      name: '',
      email: '',
    },
  });

  const onSubmit = async (data: FormData) => {
    await submitData(data);
    form.reset(); // 重置为默认值
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        {/* 表单字段 */}

        <div className="flex gap-4">
          <Button type="submit">提交</Button>
          <Button
            type="button"
            variant="outline"
            onClick={() => form.reset()}
          >
            重置
          </Button>
        </div>
      </form>
    </Form>
  );
}

禁止的做法

  • 禁止使用其他表单库(如 Formik、Final Form)
  • 禁止手动编写验证逻辑
  • 禁止使用非受控表单(除非有特殊性能需求)
  • 禁止在 Zod Schema 外定义验证规则
  • 禁止省略 FormMessage 组件(错误提示)
  • 禁止使用 any 类型作为表单数据类型