本章目标:掌握在 Next.js 中构建复杂表单的完整技术栈——React Hook Form 负责表单状态管理,Zod 负责 Schema 验证,Server Actions 负责服务端提交,三者协同实现类型安全、高性能的表单体验。
16.1 表单方案选型
三种表单方案对比
| 维度 | 原生 <form> + useState | React Hook Form | Formik |
|---|---|---|---|
| 包体积 | 0 | ~7KB gzip | ~14KB gzip |
| 性能 | 差(每次输入重渲染整个表单) | 极好(非受控组件,只重渲染变化的字段) | 中 |
| TypeScript | 手动 | ✅ 完整类型推导 | ✅ 完整类型 |
| 验证 | 手动 | 支持多种(Yup / Zod / 自定义) | Yup / 自定义 |
| 与 RSC 兼容 | ✅ | ✅ | ⚠️ 需 "use client" |
| 学习曲线 | 低 | 中 | 中 |
| 生态 | - | 丰富(Controller、useFieldArray) | 丰富 |
本教程选择:React Hook Form + Zod
理由:
- 性能极佳:基于非受控组件,输入时不会触发整个表单重渲染
- 类型安全:Zod Schema 可以直接推导 TypeScript 类型
- 验证统一:前端和后端使用同一套 Zod Schema
- 体积小:gzip 后仅 7KB
- 生态成熟:与 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
- React Hook Form + Zod 是 Next.js 表单的最佳组合:高性能、类型安全、前后端验证统一
- Zod Schema 是验证的核心:前端和后端共享同一套 Schema,避免重复定义
- useFieldArray 处理动态字段:增删排序,性能优秀
- 多步骤表单使用 trigger() 分步验证:每步只验证当前字段
- 文件上传使用 FormData + fetch:配合服务端 Route Handler 处理
- 性能关键在于非受控组件: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 架构实战
参考资料
- React Hook Form 官方文档
- Zod 官方文档
- @hookform/resolvers
- shadcn/ui Form
- React Hook Form useFieldArray
- Next.js Server Actions + Forms
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。