antd、React Hook Form 与 Zod
使用 antd 构建表单界面、React Hook Form 管理状态,并通过 Zod 统一运行时校验和 TypeScript 类型。
职责分层
antd、React Hook Form 和 Zod 不应重复做同一件事:antd 负责控件与反馈,RHF 保存字段状态并组织提交,Zod 是校验与类型的唯一事实来源。
组件关系与数据流
Form State / DataUI / SubmitAdapter / ValidationSchema
协作焦点Controller 负责适配 antd 受控组件;Checkbox 需要把 event.target.checked 转换成布尔值。
实线表示值、状态或校验结果的传递,虚线表示用户修正错误后的再次输入。
flowchart TB
accTitle: antd、React Hook Form 与 Zod 表单数据流
accDescr: antd 控件通过 Controller 把值写入 React Hook Form,失焦或提交时 zodResolver 调用 Zod schema。校验失败时错误返回 Form.Item,成功时生成有类型保障的数据并交给提交函数。
controls["antd 表单控件"] --> controller{{"Controller 适配值与事件"}}
controller --> state(["React Hook Form 状态"])
state -->|"onBlur / submit"| resolver{{"zodResolver"}}
resolver --> schema[("Zod schema")]
schema --> result{{"校验结果"}}
result -->|"失败"| errors(["fieldState.error"])
errors --> feedback["Form.Item 错误反馈"]
feedback -.->|"用户修正"| controls
result -->|"通过"| values(["ProfileFormValues"])
values --> submit["submit(values) / API"]
class state,errors,values state
class controls,feedback,submit component
class controller,resolver,result process
class schema resource
classDef state fill:#fffdf8,stroke:#149eca,color:#25221d,stroke-width:1.2px
classDef component fill:#e4f5fa,stroke:#149eca,color:#25221d,stroke-width:2px,font-weight:600
classDef process fill:#f4fafb,stroke:#149eca,color:#25221d,stroke-width:1.6px
classDef resource fill:#eee9df,stroke:#817a70,color:#25221d,stroke-width:1.4pxRHF 维护字段状态,Zod 返回校验结果;只有通过校验的数据才会进入提交函数。
完整 Demo 源码
以下代码直接读取在线 Demo 及其本地依赖,没有省略或改写。
React 源码
import { useState } from "react";import type { SubmitHandler } from "react-hook-form";import { Controller, useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { Alert, Button, Checkbox, Form, Input, Select,} from "antd";import { SiteAntdProvider } from "@/components/SiteAntdProvider";import { profileSchema } from "./formSchema";import type { ProfileFormValues } from "./formSchema";import "./FormDemo.css";const defaultValues: ProfileFormValues = { name: "", email: "", direction: "react", agreement: false,};const directionOptions = [ { label: "React", value: "react" }, { label: "Vue", value: "vue" }, { label: "两者对比", value: "both" },];function wait(duration: number) { return new Promise<void>((resolve) => { setTimeout(resolve, duration); });}export function FormDemo() { const [submittedData, setSubmittedData] = useState<ProfileFormValues | null>(null); const { control, handleSubmit, reset, formState: { isSubmitting }, } = useForm<ProfileFormValues>({ resolver: zodResolver(profileSchema), defaultValues, mode: "onBlur", }); const submit: SubmitHandler<ProfileFormValues> = async (values) => { await wait(500); setSubmittedData(values); }; function resetForm() { reset(defaultValues); setSubmittedData(null); } return ( <SiteAntdProvider> <div className="antd-form-demo not-content"> <div className="antd-form-demo__panel"> <form onSubmit={handleSubmit(submit)} noValidate> <Form component={false} layout="vertical"> <Controller name="name" control={control} render={({ field, fieldState }) => ( <Form.Item label="姓名" htmlFor="profile-name" validateStatus={fieldState.error ? "error" : undefined} help={fieldState.error?.message} > <Input {...field} id="profile-name" placeholder="例如:小明" autoComplete="name" /> </Form.Item> )} /> <Controller name="email" control={control} render={({ field, fieldState }) => ( <Form.Item label="邮箱" htmlFor="profile-email" validateStatus={fieldState.error ? "error" : undefined} help={fieldState.error?.message} > <Input {...field} id="profile-email" type="email" placeholder="name@example.com" autoComplete="email" /> </Form.Item> )} /> <Controller name="direction" control={control} render={({ field, fieldState }) => ( <Form.Item label="学习方向" htmlFor="profile-direction" validateStatus={fieldState.error ? "error" : undefined} help={fieldState.error?.message} > <Select {...field} id="profile-direction" options={directionOptions} /> </Form.Item> )} /> <Controller name="agreement" control={control} render={({ field, fieldState }) => ( <Form.Item className="antd-form-demo__agreement" validateStatus={fieldState.error ? "error" : undefined} help={fieldState.error?.message} > <Checkbox name={field.name} ref={field.ref} checked={field.value} onBlur={field.onBlur} onChange={(event) => field.onChange(event.target.checked)} > 同意保存这份学习资料 </Checkbox> </Form.Item> )} /> <div className="antd-form-demo__actions"> <Button type="primary" htmlType="submit" loading={isSubmitting} > 提交资料 </Button> <Button htmlType="button" onClick={resetForm}> 重置 </Button> </div> </Form> </form> </div> <div className="antd-form-demo__result" aria-live="polite"> {submittedData ? ( <Alert type="success" showIcon title="校验通过,已获得类型安全的数据" description={ <pre>{JSON.stringify(submittedData, null, 2)}</pre> } /> ) : ( <Alert type="info" showIcon title="提交后会在这里显示通过 Zod 校验的数据" /> )} </div> </div> </SiteAntdProvider> );}在线 Demo
填写并提交表单,观察必填、格式和勾选校验。校验通过后会展示提交函数实际收到的数据。
提交后会在这里显示通过 Zod 校验的数据
组合原则
- 使用
zodResolver接入 schema,避免手动同步两套校验结果。 - antd 受控组件通过
Controller连接 RHF;Checkbox需要把event.target.checked转为布尔值。 Form.Item只负责布局与错误展示,不再配置重复的rules。- 通过
z.infer从 schema 推导表单类型,让运行时规则与 TypeScript 结构保持一致。
何时不需要这套组合
字段很少、没有复杂校验的表单可以直接使用受控状态。库的价值来自字段规模、性能和校验一致性,而不是表单出现就必须引入。
先复习受控表单与 v-model,再决定是否需要专门的表单状态库。