Lesson 14:复杂表单构建 — React Hook Form 与 Zod 校验
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 2(进阶篇)
- 推荐时长:90~120 分钟(首次学习)
- 先修要求:完成 Lesson 13,已有 shadcn 表单组件的路径别名与远端项目 API
- 学习产出:实现带 Zod 校验、提交状态和错误提示的新建项目弹窗
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:使用 React Hook Form 管理字段和提交状态,使用 Zod 校验输入。
📦 本节产出:通过组合 React Hook Form 和 Zod,创建一个可向 Mock API 提交的“新建项目”弹窗表单;任务表单作为迁移练习。
一、原生 React 表单的痛点
Phase 1 只有简单的任务输入。下面用多字段受控表单说明状态组织问题,并非重现 Phase 1 的输入实现:
// 受控组件示意(省略 JSX),不是错误用法
function SignupForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
// 多字段需要组织状态与验证逻辑
// 更新 email 会重新执行 SignupForm。
// 这不等于所有 DOM 都会重建或浏览器都会重绘。
}受控组件适合许多表单。字段增多、校验与错误展示复杂时,可以用 React Hook Form (RHF) 集中管理这些行为;是否存在性能问题应通过测量判断。
RHF 的核心方式:字段注册与订阅
RHF 的 register 常用于非受控原生输入框,通过 ref 和事件追踪字段;Controller 则用于受控组件。它可以缩小更新范围,但 watch、formState 订阅和校验仍会触发相关组件渲染。本课 shadcn 的 FormField 基于 Controller,不能承诺输入时完全不重新渲染。useForm 官方说明。
💡 表单无障碍:useId 生成唯一 ID
在表单中,<label> 的 htmlFor 属性需要关联 <input> 的 id,这对屏幕阅读器(无障碍)至关重要。但在 SSR 环境中,用 Math.random() 生成 ID 会导致 Hydration Mismatch(服务端和客户端生成的随机数不同)。
React 18+ 提供了 useId,在服务端与客户端组件树一致的前提下,生成可匹配的关联 ID:
import { useId } from 'react'
function FormField({ label }: { label: string }) {
const id = useId() // 生成供可访问性关联使用的 ID;不要依赖具体字符串格式
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} className="border rounded-xl px-4 py-3" />
</div>
)
}
// 多个字段时,用前缀扩展
function ComplexForm() {
const id = useId()
return (
<>
<label htmlFor={`${id}-name`}>名称</label>
<input id={`${id}-name`} />
<label htmlFor={`${id}-email`}>邮箱</label>
<input id={`${id}-email`} />
</>
)
}TIP
在 Phase 3 的 Next.js SSR 项目中,useId 尤为重要。如果你用 Math.random() 或者自增计数器生成 ID,很容易在服务端与客户端得到不同值。useId 也不能用作列表 key,列表 key 应来自数据。
二、引入 Zod 建立类型校验墙
如何定义一份严格的数据规则? 比如:“新建项目”弹窗中,项目名称 name 去除首尾空白后长度为 2 ~ 20,图标 icon 从允许的 Emoji 中选择,同时还要给这些规则配上中文报错语。
Zod 根据 schema 在运行时验证数据,并提供 TypeScript 类型推导。客户端验证用于及时反馈;真实后端也必须独立校验和授权,json-server 不会自动执行这些规则。
2.1 安装全家桶
npm install react-hook-form@7 zod@4 @hookform/resolvers@5本课明确使用 Zod 4、RHF 7 与 resolvers 5。@hookform/resolvers/zod 把 Zod 校验接入 RHF;不要混用旧版 Zod 的错误参数 API。
2.2 定义你的 Zod Schema
我们将在独立文件里统一定义校验规则。
// src/lib/validations.ts
import { z } from "zod"
// 方案声明:定义“新建项目”表单应该长什么样
export const projectFormSchema = z.object({
name: z.string().trim()
.min(2, { error: '名称至少需要 2 个字符' })
.max(20, { error: '名称最多 20 个字符' }),
icon: z.string().refine(
value => ['', '📁', '🚀', '🎯', '📋'].includes(value),
{ error: '请选择 📁、🚀、🎯、📋,或留空' },
),
})
// 🎉 自动推导出 TypeScript 类型 (不用手写 interface 了)
export type ProjectFormValues = z.infer<typeof projectFormSchema>z.string().min/max 按 JavaScript 字符串长度(UTF-16 码元)计数,并非用户可见字符数。Emoji 可能由多个码点组成,不能用 .length(2) 判断“一个 Emoji”;本例用固定允许列表。
三、实战:结合 shadcn/ui 创建表单
shadcn/ui 的 Form 组件封装了 react-hook-form,并连接字段标签、描述与错误提示。
npx [email protected] add form
npx [email protected] add input label如果 CLI 提示覆盖已有 button.tsx,选择保留,避免覆盖上一课的修改。运行 lint 后,若 form.tsx 的 useFormField 导出触发 react-refresh/only-export-components,本课仅在该文件内部使用此 Hook,可只从文件末尾的 export { ... } 列表移除它,保留函数定义。需要跨文件复用时,再拆成独立 Hook 模块。
创建项目表单如下:
// src/components/CreateProjectDialog.tsx
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { projectFormSchema, type ProjectFormValues } from "@/lib/validations"
// 引入刚刚通过 shadcn 生成的大量原语
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { postNewProject } from "@/api/projectRequests"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { toast } from "sonner"
export function CreateProjectDialog() {
const [open, setOpen] = useState(false)
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: postNewProject,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
})
// 1. 初始化 UseForm!挂上 Zod
const form = useForm<ProjectFormValues>({
resolver: zodResolver(projectFormSchema),
defaultValues: {
name: "",
icon: "📁",
},
})
// 2. 通过 Zod 校验后才执行请求
async function onSubmit(data: ProjectFormValues) {
try {
await mutation.mutateAsync({ id: crypto.randomUUID(), name: data.name, icon: data.icon || '📁' })
form.reset()
setOpen(false)
toast.success('项目已创建')
} catch {
form.setError('root', { message: '创建失败,请检查服务后重试。' })
}
}
return (
<Dialog open={open} onOpenChange={value => { if (!form.formState.isSubmitting) setOpen(value) }}>
<DialogTrigger asChild><Button>新建项目</Button></DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>新建项目</DialogTitle>
<DialogDescription>填写项目名称,并选择一个图标。</DialogDescription>
</DialogHeader>
<Form {...form}>
{/* 4. 执行 handleSubmit 高阶函数拦截原生提交事件 */}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6" noValidate>
{/* 字段 1:项目名称 */}
<FormField
control={form.control}
name="name" // 与 Zod 的对应
render={({ field }) => (
<FormItem>
<FormLabel>项目名称</FormLabel>
<FormControl>
{/* {...field} 把 value, onChange, onBlur 等所有必要的事件解构挂上去! */}
<Input placeholder="例如:Q3 营销计划" {...field} />
</FormControl>
{/* 这行组件会自动感知验证错误,变成红字展示出我们在 zod 里写好的汉字提示 */}
<FormMessage />
</FormItem>
)}
/>
{/* 字段 2:项目图标 */}
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>图标 (选填)</FormLabel>
<FormControl>
{/* 各种复杂的输入组件,甚至日期选择器都可以塞在这里 */}
<Input placeholder="📁 / 🚀 / 🎯 / 📋" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && <p role="alert">{form.formState.errors.root.message}</p>}
{/* 按钮控制 */}
<div className="flex justify-end gap-2">
{/* 保留无效表单的提交入口,让 handleSubmit 显示字段错误;请求期间禁用 */}
<Button
type="submit"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? '创建中...' : '建立项目'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
}在 ProjectList.tsx 中导入 CreateProjectDialog 并用 <CreateProjectDialog /> 替换上一课的新增按钮。不要调用已移除的本地 useProjectActions,否则新项目不会出现在远端列表。
默认提交时校验:名称为空时,handleSubmit 显示字段错误并阻止请求;异步提交被 await 后,isSubmitting 才能覆盖网络等待。失败时保留输入并显示错误,成功后刷新列表、清空表单并关闭弹窗。输入及校验期间,订阅相关状态的组件仍可能重新渲染。
四、🧠 深度专题:为何 FormField 采用 Render Props 模式?
下面截取 FormField 的 render 属性;实际使用仍需上例的 control 与 name:
<FormField
render={ ({ field }) => (
<Input {...field} />
)}
/>为什么 React Hook Form 不直接提供一个 <Input>,而一定要用个包裹器传递 ({ field }),让你把 ...field 原封不动复制进你的组件里呢?
这种模式叫 Render Props 渲染属性(用函数指定渲染方式)。
无痛对接任何异构 DOM 库
现实世界中,不可能所有的表单都是简单的 <input>,尤其是接入有自定义值和事件接口的第三方组件:
- 自定义下拉选框
Select - 复杂的富文本编辑器
TipTap或Quill - 可点选并拥有自己专属状态的日期选框
DatePicker
这些组件的值、事件与 ref 接口可能不同,需要逐项适配。 ({ field }) 把包含 { onChange, onBlur, value, ref } 这一大堆连接管线直接送给了你。你要做的,仅仅是把它们插入到第三方的 Props 中去!如果组件支持 blur 或可聚焦元素,还应连接 field.onBlur 与 field.ref;不能假定所有组件都接受同样的 props。
// 对接一个毫不相干的、极其冷门的第三方开关组件:
<FormField
render={({ field }) => (
<MyWeirdToggleLibrary
checkedState={field.value} // 对准它的 value prop
handleToggle={field.onChange} // 对准它的改变回调 prop
/>
)}
/>这类接口映射让表单状态管理与控件外观分开。上面第三方开关仅示意 render 属性,需要提供实际组件与字段类型。
五、🧠 深度专题:React 19 函数组件接收 ref
5.1 问题背景:为什么组件需要传递 ref?
在 React Hook Form 中,field 对象包含一个 ref。RHF 需要通过这个 ref 直接访问 DOM 输入框,以便在校验失败时自动 focus() 到出错的字段。
但是在 React 18 及以前,函数组件默认无法接收 ref prop。如果你尝试给自定义组件传 ref,React 会忽略它!
5.2 React 18:用 forwardRef 包装
// React 18 暴露 DOM ref 的常见做法:forwardRef
import { forwardRef, type ComponentPropsWithoutRef } from 'react'
type InputProps = ComponentPropsWithoutRef<'input'> & { label: string }
const MyInput = forwardRef<HTMLInputElement, InputProps>(
function MyInput({ label, ...props }, ref) {
return (
<div>
<label>{label}<input ref={ref} {...props} /></label>
</div>
)
}
)
// 在父组件内调用 useRef 后,可使用 <MyInput ref={inputRef} label="用户名" />。问题:
- 每个需要暴露 ref 的组件都要包一层
forwardRef - 类型签名变得冗长(
forwardRef<HTMLInputElement, Props>) - 初学者经常忘记包裹,导致 ref 莫名丢失
5.3 React 19:通过 props 接收 ref
// React 19:在函数组件的 props 中接收 ref,再传给 DOM
import { useRef, type ComponentPropsWithRef } from 'react'
type InputProps = ComponentPropsWithRef<'input'> & { label: string }
function MyInput({ label, ref, ...props }: InputProps) {
return (
<div>
<label>{label}<input ref={ref} {...props} /></label>
</div>
)
}
function Example() {
const inputRef = useRef<HTMLInputElement>(null)
return <MyInput ref={inputRef} label="用户名" />
}React 19 函数组件可以直接接收 ref。forwardRef 仍可使用,并未被移除;自定义组件仍须显式把 ref 传给 DOM 或 useImperativeHandle。React 官方说明。
TIP
你现在就能在 RHF 的 {...field} 中看到这个变化的好处 —— field 展开后包含 ref,在 React 19 中,原生 <input> 直接支持它,自定义输入组件也可以通过 props 接收;但仍须把 ref 传到内部可聚焦元素。
六、练习
- 为前面的
TaskItem中的status修改增加一个高级编辑弹框,不仅包含名称编辑,还引入一个textarea当做任务详细说明字段。使用 Zod 校验描述字段不能超过 200 个字。 - (进阶挑战)尝试去查阅
react-hook-form的文档中有关于watch和useWatch的区别,比较在根组件使用watch与在局部组件使用useWatch的订阅范围,例如勾选后显示说明输入框。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 区分表单状态更新和 DOM 更新 | 受控与非受控输入、字段订阅的适用范围 |
| 安装使用了一套 Zod Schema | 彻底利用 TypeScript 及利用它的 inference 反推导类型能力 |
| 绑定了 Shadcn Form 组合套件 | 利用 RHF resolver 把 schema 校验接入表单流程 |
| 为第三方控件适配值、事件与 ref | Controller Render Props 设计模式 |