Skip to content

Lesson 14:复杂表单构建 — React Hook Form 与 Zod 校验 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 2(进阶篇)
  • 推荐时长:90~120 分钟(首次学习)
  • 先修要求:完成 Lesson 13,已有 shadcn 表单组件的路径别名与远端项目 API
  • 学习产出:实现带 Zod 校验、提交状态和错误提示的新建项目弹窗
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:使用 React Hook Form 管理字段和提交状态,使用 Zod 校验输入。

📦 本节产出:通过组合 React Hook Form 和 Zod,创建一个可向 Mock API 提交的“新建项目”弹窗表单;任务表单作为迁移练习。

一、原生 React 表单的痛点 ​

Phase 1 只有简单的任务输入。下面用多字段受控表单说明状态组织问题,并非重现 Phase 1 的输入实现:

tsx
// 受控组件示意(省略 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:

tsx
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 安装全家桶 ​

bash
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 ​

我们将在独立文件里统一定义校验规则。

ts
// 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,并连接字段标签、描述与错误提示。

bash
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 模块。

创建项目表单如下:

tsx
// 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:

tsx
<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。

tsx
// 对接一个毫不相干的、极其冷门的第三方开关组件:
<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 包装 ​

tsx
// 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 ​

tsx
// 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 传到内部可聚焦元素。


六、练习 ​

  1. 为前面的 TaskItem 中的 status 修改增加一个高级编辑弹框,不仅包含名称编辑,还引入一个 textarea 当做任务详细说明字段。使用 Zod 校验描述字段不能超过 200 个字。
  2. (进阶挑战)尝试去查阅 react-hook-form 的文档中有关于 watch 和 useWatch 的区别,比较在根组件使用 watch 与在局部组件使用 useWatch 的订阅范围,例如勾选后显示说明输入框。

📌 本节小结 ​

你做了什么你学到了什么
区分表单状态更新和 DOM 更新受控与非受控输入、字段订阅的适用范围
安装使用了一套 Zod Schema彻底利用 TypeScript 及利用它的 inference 反推导类型能力
绑定了 Shadcn Form 组合套件利用 RHF resolver 把 schema 校验接入表单流程
为第三方控件适配值、事件与 refController Render Props 设计模式

项目驱动 · 边写边学 · React 19