表单规范
核心原则
必须使用 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类型作为表单数据类型