序章: 范式革命与工程基石——搭建现代化表单开发环境 摘要 : 在本章中,我们将首先从 理论层面 出发,颠覆您对 React 表单的传统认知,深刻理解为什么“非受控”才是高性能的基石。接着,我们将进入 工程实践 环节,手把手地搭建一个集成了 React Hook Form (RHF), Zod, Ant Design, Vite 和 TypeScript 的、结构清晰、配置完善的现代化开发环境。最后,我们将通过官方的“快速开始”示例,完成第一个表单,亲身体验这套黄金组合的威力,为您后续的深度学习扫清一切障碍。
0.1. 战略定位:为什么必须告别 useState 表单? 痛点背景 : 作为一名经验丰富的 Vue 开发者,您已经习惯了 v-model 带来的双向绑定的丝滑体验。当您转向 React 时,最自然的想法就是使用 useState 来模拟这个行为,我们称之为 受控表单 。
1 2 3 4 5 6 7 8 9 10 11 function ControlledInput ( ) { const [value, setValue] = useState ('' ); return ( <input value ={value} onChange ={e => setValue(e.target.value)} /> ); }
这在简单场景下工作得很好。但当表单变得复杂——拥有几十个字段、复杂的联动逻辑时——灾难便降临了:每一次按键,每一个字符的输入,都会触发 setValue,从而导致整个表单组件(甚至整个页面)的重渲染 。这种巨大的性能浪费,在复杂表单中会导致用户能明显感知到的输入卡顿。
核心概念转译:受控 vs. 非受控 单一数据源 : React State (例如 useState)。工作模式 : 输入框的值由 React State 决定。用户的任何输入都必须先更新 State,再由 React 将更新后的值“同步”回输入框。这正是性能瓶颈的根源。知识转译 : 这是对 Vue v-model 的“字面翻译”,但它忽略了 React 的渲染机制与 Vue 的响应式更新机制在底层实现上的根本不同。单一数据源 : DOM 自身。工作模式 : 我们允许输入框像原生 HTML 一样自由地接收用户输入,React 在此期间“不闻不问”,不触发任何重渲染。只有在我们需要的时刻(例如表单提交时),才通过 ref 等方式一次性地从 DOM 中“读取”出最终的值。优势与劣势 : 性能极高,但纯手动操作 DOM ref 会让代码变得繁琐且难以管理。
技术架构决策
2025-10-09
所以,我们面临一个两难选择:要么忍受受控组件的性能问题,要么接受非受控组件繁琐的开发体验。
架构师
正是如此。而这就是 React Hook Form (RHF) 诞生的原因。
架构师
*RHF 是一个“非受控优先”的表单库。它为你提供了非受控组件的极致性能,同时通过一系列强大的 Hooks,带来了远超受控组件的、声明式的、易于管理的开发体验。它完美地解决了这个两难问题。
技术选型决策:我们的“武断”但正确的选择 在 2025 年,对于严肃的 React 项目,表单技术选型已经没有太多争议。
方案 核心优势 核心劣势 我们的选择 RHF + Zod 性能极致、开发体验优秀、类型安全 学习曲线相对陡峭 💎 唯一选择 。这是社区公认的黄金组合,兼顾了性能、健壮性与开发效率。 Formik 社区老牌,功能齐全 核心基于受控模式,性能较差,作者已不再活跃维护 🔴 时代眼泪 。除非维护旧项目,否则不应在新项目中使用。 Ant Design Form 与 AntD 生态深度集成 同样基于受控模式,API 设计较为繁琐,性能不佳 🟡 仅做备选 。可用于非常简单的后台管理页面,但 RHF 能更好地驾驭复杂场景。 Yup 经典的验证库 类型推断能力弱于 Zod 🔴 已被超越 。Zod 的 z.infer 提供了无与伦比的类型安全体验,是更现代的选择。
本节小结 我们明确了 React 中“受控表单”的性能瓶颈源于其“State 即数据源”的工作模式。React Hook Form 通过“非受控优先”的策略,将数据源交还给 DOM,从根本上解决了不必要的重渲染问题,是构建高性能、复杂表单的必然选择。而 Zod 则通过其强大的类型推断能力,成为 RHF 的最佳搭档,为我们提供无与伦比的类型安全保障。
0.2. 工程实践:搭建企业级项目骨架 理论建立后,我们立刻动手,将这套最佳实践落地为一个干净、结构合理、配置完善的 React 项目模板。
第一步:初始化与依赖安装 首先,打开您的终端,执行以下命令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 pnpm create vite react-hook-form-practice --template react-ts cd react-hook-form-practicepnpm add react-hook-form zod @hookform/resolvers antd react-router-dom pnpm add -D tailwindcss@next @tailwindcss/vite json-server@0.17.4
第二步:配置样式、路径别名与 TS 文件路径 : vite.config.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' export default defineConfig ({ plugins : [react (), tailwindcss ()], resolve : { alias : { '@' : path.resolve (__dirname, './src' ), }, }, })
文件路径 : src/index.css (清空后)
文件路径 : tsconfig.app.json (复制粘贴)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 { "compilerOptions" : { "tsBuildInfoFile" : "./node_modules/.tmp/tsconfig.app.tsbuildinfo" , "target" : "ES2022" , "useDefineForClassFields" : true , "lib" : [ "ES2022" , "DOM" , "DOM.Iterable" ] , "module" : "ESNext" , "types" : [ "vite/client" ] , "skipLibCheck" : true , "moduleResolution" : "bundler" , "allowImportingTsExtensions" : true , "verbatimModuleSyntax" : true , "moduleDetection" : "force" , "noEmit" : true , "jsx" : "react-jsx" , "strict" : true , "noUnusedLocals" : false , "noUnusedParameters" : false , "erasableSyntaxOnly" : true , "noFallthroughCasesInSwitch" : true , "noUncheckedSideEffectImports" : true , "baseUrl" : "." , "paths" : { "@/*" : [ "./src/*" ] } } , "include" : [ "src" ] }
关键一步 : 配置完成后,请在 VSCode 中使用 Ctrl+Shift+P (或 Cmd+Shift+P ),然后选择 TypeScript: Restart TS Server 以使路径别名生效。
第三步:搭建清晰的项目结构 在 src 目录下,创建以下文件夹和文件,形成我们后续开发的基础骨架:
我们可以在 src 目录下键入如下代码快速创建结构:
1 mkdir api, assets, components, pages, router, schemas; mkdir components/form; ni App.tsx, index.css, main.tsx, pages/SimpleForm.tsx, router/index.tsx, schemas/userSchema.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ├── api/ ├── App.tsx ├── assets/ ├── components/ │ └── form/ ├── index.css ├── main.tsx ├── pages/ │ └── SimpleForm.tsx ├── router/ │ └── index.tsx └── schemas/ └── userSchema.ts
第四步:配置应用入口与基础路由 文件路径 : src/main.tsx (完整代码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import React from "react" ;import ReactDOM from "react-dom/client" ;import { RouterProvider } from "react-router-dom" ;import { ConfigProvider , App as AntdApp } from "antd" ;import zhCN from "antd/locale/zh_CN" ;import router from "@/router" ;import "./index.css" ;ReactDOM .createRoot (document .getElementById ("root" )!).render ( <React.StrictMode > <ConfigProvider locale ={zhCN} > <AntdApp > <RouterProvider router ={router} /> </AntdApp > </ConfigProvider > </React.StrictMode > );
文件路径 : src/App.tsx (作为基础布局)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { Outlet } from "react-router-dom" ;import { Layout , Typography } from "antd" ;const { Header , Content } = Layout ;export default function App ( ) { return ( <Layout > <Header className ="flex items-center" > <Typography.Title level ={3} className ="!text-white !m-0" > RHF + Zod 实战 </Typography.Title > </Header > <Content className ="p-6" > <div className ="p-6 bg-white rounded-md" > <Outlet /> </div > </Content > </Layout > ); }
文件路径 : src/router/index.tsx (基础路由配置)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { createBrowserRouter } from 'react-router-dom' import App from '@/App' import SimpleForm from '@/pages/SimpleForm' const router = createBrowserRouter ([ { path : '/' , element : <App /> , children : [ { index : true , element : <SimpleForm /> , }, ], }, ]) export default router
本节小结 我们已经成功搭建了一个现代化的 React 开发环境。这个项目骨架不仅包含了所有必要的库,还配置好了路径别名和清晰的目录结构。这是一个专业的、可扩展的起点,为我们后续高效、愉快地编写表单代码奠定了坚实的基础。
0.3. 快速开始:完成第一个“啊哈!”时刻 现在,我们聚焦核心,将前面搭建好的环境与 Zod 4 的强大功能结合起来,用最精简的代码完成我们的第一个表单。这个过程将清晰地展示 React Hook Form (RHF) 与 Zod 协同工作的核心流程。
第一步:定义 Zod 4 Schema 在编写组件之前,我们首先为表单数据定义一个验证模式 (Schema)。这个 Schema 是我们数据的“单一事实来源”,它同时负责运行时验证和编译时类型推断。
文件路径 : src/schemas/userSchema.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 import * as z from "zod/v4" ;export const userSchema = z.object ({ username : z.string ().min (2 , "用户名至少需要 2 个字符" ), email : z.string ().email ("请输入有效的邮箱地址" ), }); export type UserSchema = z.infer <typeof userSchema>;
第二步:创建表单页面 (核心 API 整合) 有了验证模式,我们现在开始构建 React 组件。我们将逐步拆解 useForm 这个核心 Hook 返回的 API,理解它们各自的职责以及如何整合。
一切的起点是 useForm hook。它负责创建表单的管理实例,我们通过泛型传入 UserSchema 来启用完整的 TypeScript 类型支持。
1 2 3 4 5 6 7 8 9 10 11 12 13 import { useForm, type SubmitHandler } from "react-hook-form" ;import { userSchema, type UserSchema } from "@/schemas/userSchema" ;export default function SimpleForm ( ) { const { } = useForm<UserSchema >(); }
集成验证逻辑:resolver 为了让 RHF 使用 Zod 进行验证,我们需要配置 resolver 选项。zodResolver 是官方提供的适配器,用于将 Zod Schema 集成到 RHF 的验证流程中。
1 2 3 4 5 6 7 8 9 10 11 12 13 import { zodResolver } from "@hookform/resolvers/zod" ;export default function SimpleForm ( ) { const { } = useForm<UserSchema >({ resolver : zodResolver (userSchema), }); }
字段注册:register register 函数用于将一个原生 HTML 输入元素注册到 RHF 的管理体系中。它通过 ref 直接操作 DOM,这是 RHF 高性能(非受控模式)的基础。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 export default function SimpleForm ( ) { const { register, } = useForm<UserSchema >({ }); return ( <form > <label htmlFor ="username" > Username</label > <input id ="username" // 👇 通过展开运算符 ,将字段 "username " 与此 input 元素绑定 {...register ("username ")} /> </form > ); }
提交处理与错误反馈:handleSubmit 与 errors handleSubmit 是一个高阶函数,它包裹我们的提交逻辑。其核心职责是:在执行我们的回调前,先触发验证。formState.errors 是一个响应式对象,包含了当前所有字段的验证错误信息。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 export default function SimpleForm ( ) { const { register, handleSubmit, formState : { errors }, } = useForm<UserSchema >({ }); const onSubmit : SubmitHandler <UserSchema > = (data ) => { alert (`提交成功: ${JSON .stringify(data)} ` ); }; return ( <form onSubmit ={handleSubmit(onSubmit)} > <input {...register ("username ")} /> {/* 👇 根据 errors 对象中是否存在对应字段的错误来条件渲染错误信息 */} {errors.username && ( <p > {errors.username.message}</p > )} </form > ); }
整合所有部分:最终代码 将以上各个核心 API 整合在一起,我们就得到了一个完整、健壮且逻辑清晰的表单组件。
文件路径 : src/pages/SimpleForm.tsx (最终版)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 import { useForm, type SubmitHandler } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { userSchema, type UserSchema } from "@/schemas/userSchema" ;export default function SimpleForm ( ) { const { register, handleSubmit, formState : { errors }, } = useForm<UserSchema >({ resolver : zodResolver (userSchema), }); const onSubmit : SubmitHandler <UserSchema > = (data ) => { alert (`提交成功: ${JSON .stringify(data)} ` ); }; return ( <form onSubmit ={handleSubmit(onSubmit)} className ="max-w-md mx-auto" > <h1 className ="text-2xl font-bold mb-4" > Zod 4 Form</h1 > <div > <label htmlFor ="username" className ="block mb-1" > Username </label > <input id ="username" // 5.通过register注册表单元素 {...register ("username ")} className ="block w-full p-2 mb-1 border border-gray-300 rounded" /> {/* 6.通过errors对象获取表单验证错误信息 */} {errors.username && ( <p className ="text-red-500 text-xs" > {errors.username.message}</p > )} </div > <div className ="mt-4" > <label htmlFor ="email" className ="block mb-1" > Email </label > <input id ="email" {...register ("email ")} className ="block w-full p-2 mb-1 border border-gray-300 rounded" /> {errors.email && ( <p className ="text-red-500 text-xs" > {errors.email.message}</p > )} </div > <button type ="submit" className ="px-4 py-2 mt-2 bg-blue-500 text-white rounded hover:bg-blue-600" > Submit </button > </form > ); }
总结: 我们通过 useForm, zodResolver, register, handleSubmit, errors 这一系列核心 API 的组合,构建了一个完整的、类型安全的、高性能的表单。这个从 Schema 定义到 UI 绑定的工作流,是 React Hook Form 开发的核心范式,也是我们后续学习的基础。
第一章: 范式革命与心智重建 摘要 : 在序章中,我们搭建了现代化的开发环境,并完成了第一个 RHF + Zod 表单,对这套黄金组合有了初步的“体感”。现在,我们将从“体感”上升到“心智”。本章不急于学习新的 API,而是要从根本上回答两个问题:第一,RHF 究竟是如何通过“非受控”模式实现极致性能的?第二,Zod 是如何通过“Schema-First”思想,成为我们表单乃至整个应用类型安全的基石的? 理解了这两个核心问题的答案,您才算真正掌握了这套方案的灵魂。
1.1. 性能之基石:非受控组件的“文艺复兴” 承上启下 : 在序章中,我们得出了一个结论:useState 管理的受控表单存在性能瓶颈。现在我们通过实际代码对比和 React 开发者工具,亲眼见证并剖析这一瓶颈,从而深刻理解为什么说 RHF 带来了“非受控组件的文艺复兴”。
性能剖析:一次按键引发的“渲染风暴” 为了直观感受性能差异,我们创建一个包含多个输入框的表单,并引入一个简单的日志组件来监控渲染行为。
心智模型转译:从“数据驱动”到“所有权转移” 深入探讨
2025-10-10
我理解了性能差异。但从 Vue 的思维来看,v-model 也是“数据驱动视图”,为什么就没有这个性能问题?
架构师
这是一个绝佳的问题,直指核心!Vue 的响应式系统是基于依赖收集的,它可以精确地知道数据变化需要更新哪个具体的 DOM 节点。而 React 的模型是“状态变更,触发组件及其子组件的重渲染”。
所以,在 React 里,受控组件的“数据驱动”模式,因为 setState 的机制,成了一个“全局”更新,而非 Vue 那样的“精确”更新。
架构师
完全正确!所以 RHF 的设计理念是,既然无法改变 React 的渲染机制,那我们就改变 状态的所有权 。它将表单字段的值的所有权从 React State “转移”给了原生的 DOM 元素。输入时,你只是在和 DOM 打交道,React 根本“不知道”值的变化,自然也就没有重渲染了。
啊哈!我明白了。RHF 并没有去“优化”React 的渲染,而是从策略上“绕过”了它。register 函数就像是给 DOM 元素打上了一个标记,告诉 RHF:“请接管这个元素,它的状态归你管了。”
受控边界:Controller 组件的“翻译官”定位 痛点背景 : 并非所有组件都能用 register。register 的高性能源于它通过 ref 直接操作原生 DOM。然而,许多第三方 UI 库(如 Ant Design)的组件(如 Input, Select)是完全受控的,它们不暴露原生 ref 接口,而是通过 value 和 onChange props 来工作。
这时,我们就需要一个“翻译官”——Controller 组件。根据官方文档,Controller 的核心职责是 一个包装器组件,用以简化与外部受控组件的集成过程 。它在 RHF 的非受控世界和第三方受控组件之间,搭建了一座沟通的桥梁。
让我们以序章的 SimpleForm 为例,将其完全改造为 Ant Design 风格,来实战 Controller 的用法。
文件路径 : src/pages/SimpleForm.tsx (Ant Design Version)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 import { useForm, type SubmitHandler } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { userSchema, type UserSchema } from "@/schemas/userSchema" ;import { Form , Typography , Input , Button } from "antd" ;import { Controller } from "react-hook-form" ;export default function SimpleForm ( ) { const { control, handleSubmit, formState : { errors }, } = useForm<UserSchema >({ resolver : zodResolver (userSchema), mode : "onChange" , defaultValues : { username : "" , email : "" , }, }); const onSubmit : SubmitHandler <UserSchema > = (data ) => { alert (`提交成功: ${JSON .stringify(data)} ` ); }; return ( <Form onFinish ={handleSubmit(onSubmit)} className ="max-w-md" layout ="vertical" > <Typography.Title level ={2} className ="text-center !mb-6" > RHF + Ant Design Form </Typography.Title > {/* 用户名输入框 */} <Form.Item label ="Username" validateStatus ={errors.username ? "error " : ""} help ={errors.username?.message} > <Controller name ="username" control ={control} render ={({ field }) => ( // field 对象包含了 value, onChange, onBlur 等所有必要属性 // 直接将其解构给 Antd Input 即可完成绑定 <Input {...field } placeholder ="请输入用户名" /> )} /> </Form.Item > {/* 邮箱输入框 */} <Form.Item label ="Email" validateStatus ={errors.email ? "error " : ""} help ={errors.email?.message} > <Controller name ="email" control ={control} render ={({ field }) => <Input {...field } placeholder ="请输入邮箱" /> } /> </Form.Item > <Form.Item > <Button type ="primary" htmlType ="submit" className ="w-full" > Submit </Button > </Form.Item > </Form > ); }
Controller 就像一个适配器。它从 RHF 的内核(control 对象)中获取状态管理逻辑,然后通过 render prop 将这些逻辑包装成一个 field 对象暴露出来。这个 field 对象完美地包含了受控组件所需要的一切(value, onChange, onBlur),我们只需通过 {...field} 将其传递给 Ant Design 的 Input 组件,就完成了无缝集成。

本节小结 性能核心 : RHF 通过将表单状态的所有权从 React State 转移到 DOM,利用 register 直接操作 ref,从根本上避免了因输入导致的组件重渲染。适用场景 :register: 用于原生 HTML 元素(<input>, <select>, <textarea>)或任何直接暴露 ref 的自定义组件,是 性能最高的选择 。Controller: 用于集成第三方受控组件(如 Ant Design, Material-UI),充当 RHF 内核与这些组件之间的“翻译官”,是 兼容性最好的选择 。1.2. Zod:超越验证的“单一事实来源” 在传统的开发模式中,我们通常会手写 TypeScript 类型定义,然后再为之编写一套独立的验证逻辑。这两者之间依靠开发者的自觉来维持同步,在复杂项目中极易出错。
Zod 倡导一种全新的工作流:Schema-First 。它彻底颠覆了上述模式,让我们只用维护一份代码,就能同时获得类型安全和运行时验证。
第一步:定义 Schema 作为“单一事实来源” 我们不再分开定义类型和验证,而是只做一件事:定义一个 Zod Schema 。这个 Schema 将成为我们项目中关于此数据结构所有知识的唯一集合。
文件路径 : src/schemas/userSchema.ts
1 2 3 4 5 6 7 8 9 10 import * as z from 'zod/v4' ;export const userSchema = z.object ({ username : z.string ().min (2 , "用户名至少需要 2 个字符" ), email : z.string ().email ("请输入有效的邮箱地址" ), age : z.number ().min (18 , "必须年满18岁" ).optional (), });
这个 userSchema 对象现在既包含了每个字段的类型信息(如 string, number),也包含了它们的验证规则(如 min, email)。
第二步:使用 z.infer 自动推导 TypeScript 类型 接下来,我们使用 Zod 提供的魔法工具 z.infer,从刚刚定义的 Schema 中自动“提取”出相应的 TypeScript 类型。
文件路径 : src/schemas/userSchema.ts (续)
1 2 3 4 5 6 7 8 9 10 11 12 13 export type UserSchema = z.infer <typeof userSchema>;
第三步:在应用中全局复用 现在,我们拥有了两个紧密关联的“产物”:
userSchema: 一个 运行时验证器 ,可以解析和验证数据。UserSchema: 一个 编译时 TypeScript 类型 ,提供代码提示和静态检查。它们共同构成了关于用户数据的 单一事实来源 ,我们可以在应用的任何地方安全、一致地使用它们。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { useForm } from 'react-hook-form' ;import { zodResolver } from '@hookform/resolvers/zod' ;import { userSchema, type UserSchema } from '@/schemas/userSchema' ;function UserForm ( ) { const form = useForm<UserSchema >({ resolver : zodResolver (userSchema), }); } async function updateUser (data : UserSchema ) { }
这种模式彻底消除了手动维护类型与验证规则同步的可能,极大地提升了项目的健壮性。
第二章: 核心实现与 UI 集成 摘要 : 在本章中,我们将从理论转向实践,深入剖析 React Hook Form 的“大脑”——useForm Hook。我们将系统学习其核心配置项,解构 handleSubmit 的工作机制,并最终将 RHF 的强大能力与我们熟悉的 Ant Design 组件库完美结合。本章的目标是,让您具备独立构建一个功能完备、体验良好、且与主流 UI 库无缝集成的表单的能力。
在前面的章节中,我们已经多次与 useForm 这个核心 Hook 打过照面。它正是我们所有表单逻辑的起点和总控制中心。一个简单的 const form = useForm() 就能让表单跑起来,但这仅仅是冰山一角。
为了应对真实世界中复杂的业务场景,我们需要深入理解 useForm 的“输入”与“输出”。它的 输入 是一个配置对象,为表单“大脑”注入精细的指令;它的 输出 是一系列强大的 API,是我们操控表单的具体“武器”。
输入:通过配置定义表单行为 useForm 接收一个可选的配置对象作为参数,它决定了整个表单实例的行为模式。下面是我们最关心的几个核心配置:
配置项 关键作用与说明 defaultValues提供一个与 Zod Schema 结构匹配的对象,用于初始化表单。这不仅是初始值,更是 RHF 进行类型推断和后续 reset 操作的依据。 resolver通过 zodResolver(userSchema) 将 Zod 的验证能力无缝接入 RHF 的验证流程。 mode定义验证逻辑在何时触发,可选值包括 onSubmit (默认), onBlur, onChange 等,直接影响用户交互体验。
输出:解构核心 API 以操控表单 当调用 useForm 后,它会返回一个包含多个方法和状态的对象。我们通常通过解构赋值来获取需要使用的部分。这些 API 各司其职,共同构成了我们与表单交互的工具箱。
核心 API 关键作用与说明 register用于将 原生 HTML 元素 注册到 RHF 中,实现非受控模式下的高性能数据绑定。 handleSubmit表单提交的“守门员” 。它是一个高阶函数,在调用我们的业务提交逻辑前,会先执行验证。controlRHF 的“引擎”对象 。它是连接 RHF 内核与 UI 组件(尤其是第三方受控组件)的核心。在后续使用 Controller 或 useFieldArray 等高级 Hooks 时,control 对象是 必须 传递的。formState一个响应式对象,包含了关于整个表单的 元数据状态 。我们最常用的是 formState.errors 来获取所有字段的验证错误信息。此外,它还包含 isDirty (是否已修改), isValid (是否有效) 等布尔值状态。 watch一个“侦听器”函数,可以订阅并响应一个或多个字段值的变化,用于实现表单内的实时联动效果。 reset一个函数,用于将表单字段的值重置为 defaultValues 或你指定的任意值。
现在,让我们将“输入”和“输出”结合起来,看一个集成了这些配置和 API 的实际例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 import { useForm } from 'react-hook-form' ;import { zodResolver } from '@hookform/resolvers/zod' ;import { userSchema, type UserSchema } from '@/schemas/userSchema' ;function UserProfileForm ( ) { const { control, handleSubmit, formState : { errors, isDirty, isValid }, watch, reset, } = useForm<UserSchema >({ resolver : zodResolver (userSchema), mode : 'onBlur' , defaultValues : { username : '' , email : '' , }, }); }
通过这个结构,我们清晰地看到 useForm 是如何作为我们表单的中枢系统运作的:我们通过 配置 告诉它该如何工作,然后它返回一套 API 让我们去执行具体的操作。
技术架构决策
2025-10-11
我注意到,即使我不提供 defaultValues,RHF 也能工作。
架构师
是的,但这是一个坏习惯。不提供 defaultValues,RHF 内部会将所有字段初始化为 undefined。这会导致两个问题:第一,当你想用 reset API 清空表单时,行为可能不符合预期;第二,更重要的是,TypeScript 将无法从 useForm 中获得最精确的类型推断。始终为你的 useForm 提供一个完整的 defaultValues 对象,才是最佳实践
2.2. 连接 UI 组件库:封装组件的最佳实践 配置好我们表单的“大脑”后,下一步就是将它与用户能看到的 UI 界面连接起来。这主要涉及两个关键环节:处理表单提交 和 构建可复用、可维护的表单输入组件 。
handleSubmit:表单提交的“守门员”我们不能直接将自己的提交函数绑定到 <form> 的 onSubmit 事件上。RHF 为我们提供了一个名为 handleSubmit 的高阶函数,它像一个尽职尽责的“守门员”,在数据真正提交给后端之前,处理了所有验证的脏活累活。
它的工作流程非常清晰:
你提供一个处理业务逻辑的函数(例如 onValid)。 handleSubmit 会返回一个 新函数 ,我们将这个新函数绑定到 <form> 的 onSubmit。当用户点击提交,handleSubmit 会首先运行 Zod 验证。 只有在 验证通过 的情况下,它才会调用你的 onValid 函数,并传入类型安全的数据。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import { useForm, type SubmitHandler } from "react-hook-form" ;export default function SimpleAntdForm ( ) { const { control, handleSubmit, formState : { errors } } = useForm<UserSchema >({ }); const onValid : SubmitHandler <UserSchema > = (data ) => { alert (`提交成功: ${JSON .stringify(data)} ` ); }; const onInvalid = ( ) => { console .log ("表单验证失败!" ); }; return ( <Form onFinish ={handleSubmit(onValid, onInvalid )}> {/* ... 表单项 ... */} </Form > ); }
告别样板代码:封装高内聚的表单域组件 在 1.1 的代码示例中,我们直接在表单组件中使用了 Ant Design 的 Form.Item 和 RHF 的 Controller。虽然这能实现功能,但您可能已经敏锐地察觉到了一个问题:每增加一个输入框,我们就必须重复编写一段结构几乎完全相同的 JSX 代码。
1 2 3 4 5 6 7 8 9 10 11 12 <Form .Item label="Username" validateStatus={errors.username ? "error" : "" } help={errors.username ?.message } > <Controller name ="username" control ={control} render ={({ field }) => <Input {...field } /> } /> </Form .Item >
当表单变得复杂时,这种重复会让我们的代码迅速膨胀,变得难以阅读和维护。这违背了软件工程的 DRY (Don’t Repeat Yourself) 原则。
我们的解决方案是:封装。
我们将创建一个高内聚、低耦合的通用表单域组件。这个组件的职责非常单一:将 Ant Design 的某个输入控件(如 Input)与 RHF 的 Controller 逻辑完美结合,并自动处理布局、标签和错误展示。 这样,我们的主表单组件就可以从繁琐的连接工作中解放出来,只专注于业务逻辑本身。
第一步:创建分层的组件结构 为了保持项目结构清晰,我们为这类封装好的表单组件创建一个专属目录。
新的文件路径 : src/components/form/ControlledInput.tsx
这个组件将作为 RHF 和 Ant Design Input 之间的“专业翻译官”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 import { Controller , useFormContext, type FieldValues , type Path , } from "react-hook-form" ; import { Form , Input , type InputProps } from "antd" ;type ControlledInputProps <T extends FieldValues > = { name : Path <T>; label : string ; } & InputProps ; export function ControlledInput <T extends FieldValues >({ name, label, ...rest }: ControlledInputProps <T>) { const { control } = useFormContext<T>(); return ( <Controller name ={name} control ={control} render ={({ field , fieldState }) => { // 👇 使用 fieldState 获取该字段的错误信息(支持嵌套路径) const errorMessage = fieldState.error?.message; return ( // Form.Item 负责布局、标签和错误信息的展示 <Form.Item label ={label} validateStatus ={errorMessage ? "error " : ""} help ={errorMessage} > {/* 将 RHF 的 field 属性(value, onChange, onBlur 等)注入 antd Input */} <Input {...field } {...rest } /> </Form.Item > ); }} /> ); }
第三步:重构我们的主表单 现在,我们用崭新的 ControlledInput 组件来重构之前的表单。你会发现代码变得前所未有的简洁和清晰。
文件路径 : src/pages/SimpleForm.tsx (更新版本)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import { useForm, FormProvider , type SubmitHandler } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { userSchema, type UserSchema } from "@/schemas/userSchema" ;import { Form , Typography , Button } from "antd" ;import { ControlledInput } from "@/components/form/ControlledInput" ; export default function SimpleForm ( ) { const methods = useForm<UserSchema >({ resolver : zodResolver (userSchema), mode : "onChange" , defaultValues : { username : "" , email : "" }, }); const onSubmit : SubmitHandler <UserSchema > = (data ) => { alert (`提交成功: ${JSON .stringify(data)} ` ); }; return ( <FormProvider {...methods}> <Form onFinish={methods.handleSubmit(onSubmit)} layout="vertical" className="max-w-md" > <Typography.Title level={2}>RHF + Ant Design (封装版)</Typography.Title> {/* 👇 看!现在添加一个表单域是多么的声明式和简洁 */} <ControlledInput<UserSchema> name="username" label="Username" placeholder="请输入用户名" /> <ControlledInput<UserSchema> name="email" label="Email" placeholder="请输入邮箱" /> <Form.Item> <Button type="primary" htmlType="submit" className="w-full"> Submit </Button> </Form.Item> </Form> </FormProvider> ); }
通过这套组合拳,我们不仅实现了功能,更重要的是,我们构建了一个可扩展、可维护的表单架构。RHF 和 Zod 负责状态管理与验证(模型层),Ant Design 负责基础 UI 呈现(视图层),而我们自己封装的 ControlledInput 等组件,则成为了连接这两者的、可无限复用的“控制器”层。代码各司其职,清晰且健壮。
本章小结 核心概念 关键作用 useForm 配置通过 defaultValues, resolver, mode 等选项,精细化定义表单的行为。 handleSubmit作为验证“守门员”,解耦了验证逻辑与业务提交逻辑。 Controller 组件充当“翻译官”,将 RHF 的非受控内核与第三方受控 UI 组件(如 Antd)连接起来。 formState.errors响应式地存储所有字段的验证错误,是我们向用户展示错误信息的直接数据来源。
2.3. 高级验证:.refine()自定义验证 基础的链式验证(如 z.string().min(2).email())能够覆盖大约 80% 的场景。而剩下的 20%,那些棘手的、动态的、涉及外部依赖的验证,正是区分一个“能用”的表单和一个“专业”的表单的关键。本节,我们将深入 Zod 的 refine、transform 等高级 API,让我们的验证逻辑无所不能。
1. 跨字段验证:当字段不再孤立 很多时候,我们需要验证的不是单个字段,而是多个字段之间的关系。比如,注册时的“确认密码”必须与“密码”字段一致。这时,我们需要在整个对象 schema 的层面上进行验证。Zod 为此提供了 .refine() 方法。
.refine() 是一个强大的自定义验证工具,它可以接收一个函数,这个函数会在所有字段的基础验证通过后执行。
实战场景:密码一致性校验 让我们来定义一个包含密码确认的注册表单 Schema。
文件路径 : src/schemas/registerSchema.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import * as z from 'zod/v4' ;export const registerSchema = z.object ({ username : z.string ().min (3 , "用户名至少需要 3 个字符" ), password : z.string ().min (6 , "密码至少需要 6 位" ), confirmPassword : z.string ().min (6 , "确认密码至少需要 6 位" ), }) .refine ( (data ) => data.password === data.confirmPassword , { message : "两次输入的密码不一致" , path : ["confirmPassword" ], } ); export type RegisterSchema = z.infer <typeof registerSchema>;
通过这种方式,我们建立了一个依赖关系:confirmPassword 字段的最终有效性,取决于 password 字段的值。当验证失败时,错误信息会精准地附加到 confirmPassword 字段上。
2. 异步验证:与服务器对话 另一个常见的复杂场景是需要与后端通信才能完成的验证,最典型的就是“检查用户名或邮箱是否已被注册”。Zod 的 .refine() 同样支持异步函数,这让实现服务端验证变得异常简单。
与其在代码里模拟请求,不如我们来搭建一个真实的 Mock 后端服务。我们将使用在序章中安装的 json-server 来完成这个任务。
第一步:搭建 Mock API 服务器 创建数据文件 : 在你的项目 根目录 (与 package.json 同级) 下,创建一个名为 db.json 的文件。这个文件将作为我们简易的数据库。
文件路径 : db.json
1 2 3 4 5 6 { "users" : [ { "id" : 1 , "username" : "admin" , "email" : "admin@example.com" } , { "id" : 2 , "username" : "test" , "email" : "test@example.com" } ] }
添加启动脚本 : 打开 package.json 文件,在 scripts 部分添加一条命令来启动 json-server。
文件路径 : package.json
1 2 3 4 5 6 7 8 9 10 11 { "scripts" : { "dev" : "vite" , "build" : "tsc && vite build" , "lint" : "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" , "preview" : "vite preview" , "server" : "json-server --watch db.json --port 3001" } , }
我们使用 3001 端口以避免与 Vite 的默认端口冲突。
启动服务 : 打开 一个新的终端窗口 (不要关闭你正在运行 Vite 的终端),然后运行以下命令:
如果一切顺利,你会看到 json-server 已经启动,并监听在 http://localhost:3001。现在,你可以在浏览器中访问 http://localhost:3001/users 来查看你的用户数据。
第二步:封装 API 请求函数 将数据请求逻辑从组件或 Schema 中分离出来,是一种良好的工程实践。我们在 src/api 目录下创建一个文件来专门处理用户相关的 API 请求。
文件路径 : src/api/userApi.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 export const checkUsernameAvailability = async (username : string ): Promise <boolean > => { try { const response = await fetch (`http://localhost:3001/users?username=${username} ` ); const users = await response.json (); return users.length === 0 ; } catch (error) { console .error ("检查用户名时出错:" , error); return false ; } };
第三步:在 Zod Schema 中调用 API 现在,我们万事俱备,可以更新我们的 userSchema,让它调用刚刚封装好的 API 函数。
文件路径 : src/schemas/userSchema.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import * as z from 'zod/v4' ;import { checkUsernameAvailability } from '@/api/userApi' ;export const userSchema = z.object ({ username : z.string () .min (3 , "用户名至少 3 个字符" ) .refine (async (username) => { return await checkUsernameAvailability (username); }, "该用户名已被占用" ), email : z.string ().email ("请输入有效的邮箱地址" ), });
关键点 : 当你在 Schema 中使用了异步 refine,zodResolver 会自动处理这一切,它会智能地等待你的 API 请求完成后,再决定表单的最终有效性。我们作为开发者,无需进行任何额外配置,体验如丝般顺滑。
3. 数据转换:在验证前后“净化”数据 HTML 表单的一个“天坑”是,无论你输入的是数字还是日期,<input> 元素的值永远是字符串。但在我们的应用逻辑或数据库中,需要的却是 number 或 Date 类型。Zod 提供了强大的数据转换工具,让我们可以在验证流程中优雅地处理类型转换。
Zod API 执行时机 核心用途 示例 z.coerce.*验证前 强制类型转换 。将输入值(通常是字符串)强制转换为目标类型,然后再进行后续验证。z.coerce.number().transform()验证后 数据形态转换 。在数据已通过验证后,将其转换为另一种我们需要的格式。z.string().transform(str => new Date(str))
实战场景:处理年龄和活动日期 我们先定义一个活动报名表单的 Schema,它将处理从输入框接收到的字符串数据。
文件路径 : src/schemas/eventSchema.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import * as z from 'zod/v4' ;export const eventSchema = z.object ({ eventName : z.string ().min (1 , "活动名称不能为空" ), age : z.coerce .number ({ error : '请输入有效的年龄' }).int ("年龄必须是整数" ).min (18 , "参与者必须年满18岁" ), eventDate : z.string () .min (1 , '请选择活动日期' ) .transform ((dateStr, ctx ) => { const date = new Date (dateStr); if (isNaN (date.getTime ())) { ctx.addIssue ({ code : z.ZodIssueCode .custom , message : "无效的日期格式" , }); return z.NEVER ; } return date; }), }); export type EventSchema = z.infer <typeof eventSchema>;
付诸实践:构建活动报名表单 现在,我们来构建一个使用此 Schema 的 React 组件。注意 onSubmit 函数中 data 的类型,以及我们如何使用原生 <input type="date"> 来配合 Schema。
文件路径 : src/pages/EventForm.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 import { useForm, Controller } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { eventSchema, type EventSchema } from "@/schemas/eventSchema" ;import { Form , Input , Button , Typography } from "antd" ;export default function EventForm ( ) { const { control, handleSubmit, formState : { errors }, } = useForm ({ resolver : zodResolver (eventSchema), defaultValues : { eventName : "RHF + Zod 分享会" , age : "25" , eventDate : new Date ().toISOString ().split ("T" )[0 ], }, }); const onSubmit = async (data : any ) => { const validatedData = data as EventSchema ; console .log ("表单提交的数据:" , validatedData); console .log ("年龄的类型:" , typeof validatedData.age ); console .log ("活动日期的实例:" , validatedData.eventDate instanceof Date ); alert (`提交成功: \n${JSON .stringify(validatedData, null , 2 )} ` ); }; return ( <Form onFinish ={handleSubmit(onSubmit)} layout ="vertical" className ="max-w-md" > <Typography.Title level ={2} > 活动报名</Typography.Title > <Form.Item label ="活动名称" help ={errors.eventName?.message} validateStatus ={errors.eventName ? "error " : ""} > <Controller name ="eventName" control ={control} render ={({ field }) => <Input {...field } /> } /> </Form.Item > <Form.Item label ="年龄" help ={errors.age?.message} validateStatus ={errors.age ? "error " : ""} > <Controller name ="age" control ={control} render ={({ field }) => ( // 即便使用 Antd 的 Input,输入的值也是 string,z.coerce 在此生效 <Input {...field } value ={field.value as string } placeholder ="请输入年龄" /> )} /> </Form.Item > <Form.Item label ="活动日期" help ={errors.eventDate?.message} validateStatus ={errors.eventDate ? "error " : ""} > <Controller name ="eventDate" control ={control} render ={({ field }) => ( // 使用原生 input type="date" 来演示从 string 到 Date 的转换 <input type ="date" {...field } value ={field.value as string } className ="ant-input" /> )} /> </Form.Item > <Button type ="primary" htmlType ="submit" className ="w-full" > 报名 </Button > </Form > ); }
运行分析 当你填写表单并点击提交后,打开浏览器的开发者控制台。你会清晰地看到,即使我们在输入框中输入的是字符串,最终打印出的 data 对象里,age 已经是一个 number 类型,而 eventDate 也已经是一个 Date 对象。
这一切都归功于 Zod 在验证流程中为我们自动完成了“净化”和转换。这确保了我们的业务逻辑代码接收到的是干净、类型正确的数据,极大地提升了代码的健壮性。
本章小结 我们从 useForm 的核心配置出发,一步步将 React Hook Form 的强大能力与 Ant Design 的 UI 组件无缝集成。更重要的是,我们借助 Zod 解决了表单开发中最棘手的几类问题:复杂的跨字段关联、与服务端的异步通信,以及恼人的数据类型转换。
至此,我们已经掌握了构建一个专业、健壮、高性能表单的全部核心技能。
核心 API / 概念 主要用途 z.object().superRefine(...)终极验证方案 。用于统一处理复杂的 跨字段 与 异步 验证,避免 UI 状态更新不同步的问题。z.coerce.*()验证前-类型转换 。解决 HTML 表单输入值皆为字符串的问题,例如 z.coerce.number()。.transform(...)验证后形态转换 。将已验证通过的数据转换为业务逻辑需要的最终形态(如 string -> Date)。
第三章: 高级模式与动态交互 摘要 : 在前两章中,我们已经完全掌握了构建一个健壮、高性能的“静态”表单所需的所有核心技能。然而,真实世界的表单是“活”的:用户可以动态增删条目,表单项之间会相互影响,复杂的布局也要求我们更优雅地管理状态。本章,我们将直面这些动态与交互的挑战,深入 useFieldArray、watch 和 useFormContext 等高级 Hooks,让您具备驾驭任何复杂动态表单的能力。
3.1. 动态形态:useFieldArray 的应用 痛点背景 :在很多业务场景中,我们无法预知用户需要填写多少条数据。例如,在一个简历表单中,用户的工作经历数量是不固定的;在一个订单系统中,用户购买的商品条目也是动态的。如果为这种场景手动管理一个 state 数组,相关的增、删、改、移操作以及状态同步会变得异常繁琐且极易出错。
useFieldArray 正是 RHF 为解决此类“动态列表表单”问题提供的官方解决方案。它是一个专门用于处理字段数组的自定义 Hook,能以极高的性能和简洁的 API 来管理动态表单项。
3.1.1. 基础 CRUD 操作 我们将从最常见的增、删、改(Create, Read, Update, Delete)操作入手,构建一个可以动态添加/删除团队成员的表单。我们将像拼装乐高一样,一块一块地将 useFieldArray 的核心功能搭建起来。
第一步:定义 Schema 作为蓝图 在触碰组件代码之前,我们首先为动态数据定义一个清晰的“蓝图”。这个 Schema 将指导我们后续所有的开发工作。
文件路径 : src/schemas/teamSchema.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import * as z from 'zod/v4' ;const memberSchema = z.object ({ name : z.string ().min (1 , '成员姓名不能为空' ), role : z.string ().min (1 , '成员角色不能为空' ), }); export const teamSchema = z.object ({ teamName : z.string ().min (1 , "团队名称不能为空" ), members : z.array (memberSchema).min (1 , "至少需要一名团队成员" ), }); export type TeamSchema = z.infer <typeof teamSchema>;
第二步:创建表单页面 (逐步整合) 有了数据蓝图,我们现在开始构建 React 组件。我们将逐步拆解 useFieldArray 返回的 API,理解它们各自的职责。
初始化 Hooks
一切的起点是 useForm 和 useFieldArray。我们首先在组件中初始化这两个核心 Hooks。
文件路径 : src/pages/DynamicForm.tsx (片段 1: 初始化 Hooks)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import { useForm, useFieldArray, FormProvider } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { teamSchema, type TeamSchema } from "@/schemas/teamSchema" ;import { Form , Button , Typography } from "antd" ;export default function DynamicForm ( ) { const methods = useForm<TeamSchema >({ resolver : zodResolver (teamSchema), defaultValues : { teamName : "精英团队" , members : [{ name : "张三" , role : "前端开发" }], }, }); const { fields, append, remove } = useFieldArray ({ control : methods.control , name : "members" , }); const onSubmit = (data : TeamSchema ) => { alert (`提交成功: \n${JSON .stringify(data, null , 2 )} ` ); }; return ( <FormProvider {...methods }> <Form onFinish ={methods.handleSubmit(onSubmit)} layout ="vertical" > {/* ... */} </Form > </FormProvider > ); }
渲染动态列表
useFieldArray 返回的 fields 对象是一个数组,它包含了我们需要渲染到页面上的所有动态项。我们通过遍历它来创建 UI。
文件路径 : src/pages/DynamicForm.tsx (片段 2: 渲染表单项)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <Typography .Title level={2 }>动态团队管理</Typography .Title > {} {} {fields.map ((field, index ) => ( <div key ={field.id} className ="flex items-center space-x-2 mb-4" > {/* 注意这里的 name 属性构造方式, 它告诉 RHF 这个输入框属于 'members' 数组的第 'index' 项的 'name' 字段 */} <input {...methods.register (`members. ${index }.name `)} placeholder ="成员姓名" className ="ant-input" /> <input {...methods.register (`members. ${index }.role `)} placeholder ="成员角色" className ="ant-input" /> {/* 删除按钮稍后实现 */} </div > ))} {}
“啊哈!”时刻
2025-10-12
我注意到你特别强调了 key={field.id}。我以前在 React 里写列表,有时候会图省事用 key={index},这里为什么不行?
架构师
这是一个至关重要的问题,也是无数 bug 的源头!在 React 中,key 是用来帮助识别哪个项目被更改、添加或删除的。
架构师
但 index 是不稳定的。想象一下,你有三个成员,它们的 index 分别是 0, 1, 2。当你删除第 0 个成员后,原来 index 为 1 和 2 的成员,它们的 index 会变成 0 和 1。React 会看到 key 为 2 的组件消失了,然后它会试图去更新 key 为 0 和 1 的组件。但实际上,你是想删除第一个,保留后两个。这就导致了 React 的状态更新与你的数据模型不匹配,引发各种奇怪的 UI 问题。
我明白了!而 field.id 是 useFieldArray 为每个动态项生成的唯一且稳定的标识符。即使你删除了一个项,其他项的 id 依然保持不变。React 就能精准地知道哪个组件被移除了,而哪个组件只是位置变了,从而正确地保留组件内部的状态。
架构师
完全正确!永远、永远使用 field.id 作为动态列表的 key。这是使用 useFieldArray 的第一条铁律。
实现增删操作
现在,我们利用 useFieldArray 返回的 append 和 remove 函数来赋予表单“生命”。
文件路径 : src/pages/DynamicForm.tsx (片段 3: 实现增删操作)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <div key={field.id } className="flex items-center space-x-2 mb-4" > {} <Button type ="primary" danger onClick={() => remove (index)}> 删除 </Button > </div> <Button type ="dashed" // 👇 append 一个新的 、符合 Schema 结构的空对象 onClick ={() => append({ name: "", role: "" })} className="w-full mt-4" > + 添加新成员 </Button >
append(obj): 在数组末尾添加一个新项。我们传入一个符合 memberSchema 结构的对象。remove(index): 移除指定索引的项。整合所有部分:最终代码 将以上各个部分整合起来,并使用我们在第二章封装的 ControlledInput 组件来优化代码,我们就得到了一个功能完备、代码清晰的动态表单组件。
文件路径 : src/pages/DynamicForm.tsx (最终版)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 import { useForm, useFieldArray, FormProvider } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { teamSchema, type TeamSchema } from "@/schemas/teamSchema" ;import { Form , Button , Typography , Card , Space } from "antd" ;import { ControlledInput } from "@/components/form/ControlledInput" ;export default function DynamicForm ( ) { const methods = useForm<TeamSchema >({ resolver : zodResolver (teamSchema), defaultValues : { teamName : "精英团队" , members : [{ name : "张三" , role : "前端开发" }], }, }); const { fields, append, remove } = useFieldArray ({ control : methods.control , name : "members" , }); const onSubmit = (data : TeamSchema ) => { alert (`提交成功: \n${JSON .stringify(data, null , 2 )} ` ); }; return ( <FormProvider {...methods}> <Form onFinish={methods.handleSubmit(onSubmit)} layout="vertical" className="max-w-2xl" > <Typography.Title level={2}>动态团队管理</Typography.Title> <ControlledInput<TeamSchema> name="teamName" label="团队名称" /> <Card title="团队成员" className="mt-4"> {fields.map((field, index) => ( <Space key={field.id} className="flex mb-4" align="baseline"> <ControlledInput<TeamSchema> name={`members.${index}.name`} label={`成员 ${index + 1} 姓名`} /> <ControlledInput<TeamSchema> name={`members.${index}.role`} label="角色" /> <Button type="primary" danger onClick={() => remove(index)}> 删除 </Button> </Space> ))} </Card> <Button type="dashed" onClick={() => append({ name: "", role: "" })} className="w-full mt-4" > + 添加新成员 </Button> <Button type="primary" htmlType="submit" className="w-full mt-6"> 提交团队信息 </Button> </Form> </FormProvider> ); }
通过这种分解步骤,我们清晰地看到了 useFieldArray 是如何一步步与我们的 UI 结合,最终实现强大的动态表单功能的。
3.1.2. 嵌套 useFieldArray 前置知识:使用 FormProvider 和 useFormContext 告别 Prop Drilling
在我们开始构建嵌套表单之前,必须先掌握一个解决组件通信的关键模式。在之前的示例中,useForm 和 useFieldArray 都在同一个组件中被调用,因此 control 对象可以被直接访问。
但现在,我们要将“选项列表”拆分成一个独立的子组件 QuestionOptions。这就带来了一个问题:我们如何将顶层 NestedDynamicForm 组件中由 useForm 创建的 control 对象,传递给深层嵌套的 QuestionOptions 组件?
最直接的方法是“属性钻探”(Prop Drilling):NestedDynamicForm -> Card -> QuestionOptions,一层层地将 control 作为 prop 传递下去。但这会让代码变得冗长且难以维护。
React Hook Form 为此提供了完美的解决方案:FormProvider 和 useFormContext。
FormProvider : 这是一个包裹组件。我们在顶层表单组件中,将 useForm 返回的所有方法和状态(我们称之为 methods)通过 ...methods 注入到 FormProvider 中。它就像一个“状态提供者”,将整个表单实例广播给其下的所有子组件。useFormContext : 这是一个 Hook。任何被 FormProvider 包裹的子组件,无论嵌套多深,都可以通过调用 useFormContext() 来直接获取被广播的表单实例,从而轻松访问 control, formState, register 等所有 API。这个模式极大地解耦了我们的组件,让深层组件无需关心状态是如何逐层传递的。在接下来的示例中,您将看到这个模式的实际应用。
真实世界的数据结构往往是嵌套的。useFieldArray 同样支持在另一个 useFieldArray 内部使用,从而优雅地处理复杂层级。
实战场景 :构建一个问卷,每个问题下有多个可动态增删的选项。
第一步:定义嵌套 Schema 我们的蓝图需要能够描述这种层级关系:一个问卷(Survey)包含多个问题(Question),每个问题又包含多个选项(Option)。
文件路径 : src/schemas/surveySchema.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import * as z from 'zod/v4' ;const optionSchema = z.object ({ value : z.string ().min (1 , '选项内容不能为空' ), }); const questionSchema = z.object ({ text : z.string ().min (1 , '问题描述不能为空' ), options : z.array (optionSchema).min (2 , '每个问题至少需要两个选项' ), }); export const surveySchema = z.object ({ title : z.string ().min (1 , '问卷标题不能为空' ), questions : z.array (questionSchema), }); export type SurveySchema = z.infer <typeof surveySchema>;
第二步:创建嵌套动态表单 (逐步整合) 为了让代码结构保持高内聚、低耦合,我们的策略是:先创建一个专门负责渲染“选项列表”的内层组件,然后将它集成到主表单中。
创建内层组件:QuestionOptions
这个组件的职责很单一:根据传入的“问题索引”,渲染并管理其对应的选项列表。
文件路径 : src/components/form/QuestionOptions.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import { useFieldArray, useFormContext } from 'react-hook-form' ;import { Button , Space } from 'antd' ;import { ControlledInput } from './ControlledInput' ;import { type SurveySchema } from '@/schemas/surveySchema' ;interface QuestionOptionsProps { questionIndex : number ; } export function QuestionOptions ({ questionIndex }: QuestionOptionsProps ) { const { control } = useFormContext<SurveySchema >(); const { fields, append, remove } = useFieldArray ({ control, name : `questions.${questionIndex} .options` , }); return ( <div className ="pl-8" > {fields.map((option, optionIndex) => ( <Space key ={option.id} className ="flex mb-2" align ="baseline" > <ControlledInput<SurveySchema > name={`questions.${questionIndex}.options.${optionIndex}.value`} label={`选项 ${optionIndex + 1}`} /> <Button danger onClick ={() => remove(optionIndex)}>删除选项</Button > </Space > ))} <Button type ="link" onClick ={() => append({ value: '' })}> + 添加选项 </Button > </div > ); }
创建主表单并集成
现在,我们来构建主表单。首先初始化顶层的 useFieldArray 来管理“问题列表”,然后在遍历问题时,渲染我们刚刚创建的 QuestionOptions 组件。
文件路径 : src/pages/NestedDynamicForm.tsx (片段 1: 初始化与集成)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import { useForm, useFieldArray, FormProvider } from 'react-hook-form' ;import { zodResolver } from '@hookform/resolvers/zod' ;import { surveySchema, type SurveySchema } from '@/schemas/surveySchema' ;import { Form , Button , Typography , Card } from 'antd' ;import { ControlledInput } from '@/components/form/ControlledInput' ;import { QuestionOptions } from '@/components/form/QuestionOptions' ; export default function NestedDynamicForm ( ) { const methods = useForm<SurveySchema >({ }); const { fields, append, remove } = useFieldArray ({ control : methods.control , name : 'questions' , }); return ( <FormProvider {...methods}> <Form onFinish={methods.handleSubmit(onSubmit)} layout="vertical"> <Typography.Title level={2}>嵌套动态问卷</Typography.Title> <ControlledInput<SurveySchema> name="title" label="问卷标题" /> {/* 遍历外层 'questions' */} {fields.map((question, index) => ( <Card key={question.id} title={`问题 ${index + 1}`} className="my-4"> <ControlledInput<SurveySchema> name={`questions.${index}.text`} label="问题描述" /> {/* 👇 渲染内层组件,并将当前问题的索引传递下去 */} <QuestionOptions questionIndex={index} /> <Button danger onClick={() => remove(index)} className="mt-4"> 删除此问题 </Button> </Card> ))} {/* ... Add Question and Submit Buttons ... */} </Form> </FormProvider> ); }
整合所有部分:最终代码 将所有部分组合在一起,我们就得到了一个结构清晰、功能强大的嵌套动态表单。
文件路径 : src/pages/NestedDynamicForm.tsx (最终版)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 import { useForm, useFieldArray, FormProvider } from 'react-hook-form' ;import { zodResolver } from '@hookform/resolvers/zod' ;import { surveySchema, type SurveySchema } from '@/schemas/surveySchema' ;import { Form , Button , Typography , Card } from 'antd' ;import { ControlledInput } from '@/components/form/ControlledInput' ;import { QuestionOptions } from '@/components/form/QuestionOptions' ;export default function NestedDynamicForm ( ) { const methods = useForm<SurveySchema >({ resolver : zodResolver (surveySchema), defaultValues : { title : '用户满意度调查' , questions : [ { text : '您对我们的产品满意吗?' , options : [{ value : '满意' }, { value : '不满意' }] }, ], }, }); const { fields, append, remove } = useFieldArray ({ control : methods.control , name : 'questions' , }); const onSubmit = (data : SurveySchema ) => { alert (`提交成功: \n${JSON .stringify(data, null , 2 )} ` ); }; return ( <FormProvider {...methods}> <Form onFinish={methods.handleSubmit(onSubmit)} layout="vertical"> <Typography.Title level={2}>嵌套动态问卷</Typography.Title> <ControlledInput<SurveySchema> name="title" label="问卷标题" /> {fields.map((question, index) => ( <Card key={question.id} title={`问题 ${index + 1}`} className="my-4"> <ControlledInput<SurveySchema> name={`questions.${index}.text`} label="问题描述" /> <QuestionOptions questionIndex={index} /> <Button danger onClick={() => remove(index)} className="mt-4"> 删除此问题 </Button> </Card> ))} <Button type="dashed" onClick={() => append({ text: '', options: [{ value: '' }, { value: '' }] })} className="w-full" > + 添加新问题 </Button> <Button type="primary" htmlType="submit" className="w-full mt-6"> 提交问卷 </Button> </Form> </FormProvider> ); }
3.1.3. 状态管理与交互:排序功能 除了增删,useFieldArray 还提供了 move 和 swap API 来实现列表项的重新排序。这在需要用户自定义顺序的场景中(如任务列表、优先级排序)非常有用。
我们将为“动态团队管理”表单增加“上移”和“下移”功能,来演示 move API 的核心用法。
逐步实现排序功能 第一步:获取 move API
首先,我们需要从 useFieldArray 的返回值中解构出 move 函数。
文件路径 : src/pages/DynamicForm.tsx (片段 1: 获取 API)
1 2 3 4 5 6 7 8 9 10 11 12 13 export default function DynamicForm ( ) { const methods = useForm<TeamSchema >({ }); const { fields, append, remove, move } = useFieldArray ({ control : methods.control , name : "members" , }); }
第二步:添加交互按钮
接下来,在遍历 fields 数组时,为每一项添加“上移”和“下移”按钮,并绑定 move 函数。
文件路径 : src/pages/DynamicForm.tsx (片段 2: 绑定事件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 {fields.map ((field, index ) => ( <Card key ={field.id} size ="small" className ="mb-4" > <Space className ="flex" align ="baseline" > {/* ... ControlledInput for name and role ... */} <Space > {/* 上移按钮 */} <Button onClick ={() => move(index, index - 1)} disabled={index === 0} > 上移 </Button > {/* 下移按钮 */} <Button onClick ={() => move(index, index + 1)} disabled={index === fields.length - 1} > 下移 </Button > <Button type ="primary" danger onClick ={() => remove(index)}> 删除 </Button > </Space > </Space > </Card > ))}
move(from: number, to: number) : 这个 API 接收两个索引作为参数,并将 from 位置的元素移动到 to 位置。边界处理 : 我们通过 disabled 属性来防止对第一项进行“上移”和对最后一项进行“下移”,增强了用户体验。运行分析 : 此时,key={field.id} 的重要性再次凸显。当您点击“上移”或“下移”时,RHF 内部的数据数组顺序发生了改变。因为我们使用了稳定的 id 作为 key,React 能够智能地识别出这仅仅是 DOM 节点的顺序移动,而不是销毁旧节点、创建新节点。这确保了每个输入框在移动后依然能保持其输入焦点和内部状态(如果有的话),提供了流畅的用户体验。
整合所有部分:最终代码 将排序功能完全集成到我们的团队管理表单中,最终的代码如下。
文件路径 : src/pages/DynamicFormWithSort.tsx (最终版)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 import { useForm, useFieldArray, FormProvider } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { teamSchema, type TeamSchema } from "@/schemas/teamSchema" ;import { Form , Button , Typography , Card , Space } from "antd" ;import { ControlledInput } from "@/components/form/ControlledInput" ;export default function DynamicForm ( ) { const methods = useForm<TeamSchema >({ resolver : zodResolver (teamSchema), defaultValues : { teamName : "精英团队" , members : [{ name : "张三" , role : "前端开发" }], }, }); const { fields, append, remove,move } = useFieldArray ({ control : methods.control , name : "members" , }); const onSubmit = (data : TeamSchema ) => { alert (`提交成功: \n${JSON .stringify(data, null , 2 )} ` ); }; return ( <FormProvider {...methods}> <Form onFinish={methods.handleSubmit(onSubmit)} layout="vertical" className="max-w-2xl" > <Typography.Title level={2}>动态团队管理</Typography.Title> <ControlledInput<TeamSchema> name="teamName" label="团队名称" /> <Card title="团队成员" className="mt-4"> {fields.map((field, index) => ( <Space key={field.id} className="flex mb-4" align="baseline"> {/* 上移按钮 */} <Button onClick={() => move(index, index - 1)} disabled={index === 0} > 上移 </Button> {/* 下移按钮 */} <Button onClick={() => move(index, index + 1)} disabled={index === fields.length - 1} > 下移 </Button> <ControlledInput<TeamSchema> name={`members.${index}.name`} label={`成员 ${index + 1} 姓名`} /> <ControlledInput<TeamSchema> name={`members.${index}.role`} label="角色" /> <Button type="primary" danger onClick={() => remove(index)}> 删除 </Button> </Space> ))} </Card> <Button type="dashed" onClick={() => append({ name: "", role: "" })} className="w-full mt-4" > + 添加新成员 </Button> <Button type="primary" htmlType="submit" className="w-full mt-6"> 提交团队信息 </Button> </Form> </FormProvider> ); }
本节小结 我们通过 useFieldArray 成功地将静态表单升级为了动态表单,并掌握了其核心 API 的使用方法。
核心 API 关键作用 useFieldArray({ control, name })初始化一个动态数组字段的控制器。 fields一个包含所有动态字段项的数组,每一项都含有稳定的 id,必须用于 key。 append(obj)在数组末尾添加一个新项。 remove(index)移除指定索引的项。 move(from, to)移动项,用于实现排序等交互功能。 嵌套使用 通过构造 name 属性的路径 (arrayName.${index}.nestedArrayName),可以实现层级化的动态表单。
3.2. 实时交互:watch 与性能优化 痛点背景 :现代化的用户体验要求表单能够即时响应用户的输入。例如,当用户选择“其他”作为退货原因时,我们应该动态地显示一个文本框让他们填写具体原因;或者,只有当用户修改了表单内容且所有字段都有效时,“保存”按钮才应被激活。这些实时交互如果处理不当,很容易就会触发不必要的组件重渲染,再次将我们带入性能的陷阱。
React Hook Form 提供了一套精密的“侦测”工具——watch, getValues, 和 useFormState——让我们能够在实现复杂交互的同时,将性能牢牢掌控在自己手中。
3.2.1. 订阅输入变更 (watch) watch 是我们实现条件渲染和实时 UI 更新最直接的工具。它允许你“订阅”一个或多个字段的值。当被订阅的字段值发生变化时,watch 会 触发所在组件的重渲染 ,从而让你能用最新的值来更新 UI。
这个重渲染是 watch 的核心特性 ——正是因为它,我们才能轻松实现声明式的条件渲染。
实战场景 :构建一个反馈表单,当用户在下拉框中选择“其他”时,动态显示一个“请输入具体原因”的输入框。
第一步:定义支持条件验证的 Schema 一个专业的 Schema 不仅定义字段,还应定义字段间的逻辑关系。这里,我们将使用 .superRefine() 来实现:只有当 feedbackType 为 'other' 时,otherReason 字段才是必填的。
文件路径 : src/schemas/interactionSchema.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import * as z from "zod/v4" ;export const interactionSchema = z .object ({ feedbackType : z.enum (["bug" , "suggestion" , "other" ], { message : "请选择一个反馈类型" , }), otherReason : z.string ().optional (), }) .superRefine ((data, ctx ) => { if ( data.feedbackType === "other" && (!data.otherReason || data.otherReason .length < 1 ) ) { ctx.addIssue ({ code : z.ZodIssueCode .custom , message : "请填写具体原因" , path : ["otherReason" ], }); } }); export type InteractionSchema = z.infer <typeof interactionSchema>;
第二步:在组件中使用 watch 实现条件渲染 文件路径 : src/pages/ConditionalFieldForm.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 import { useForm, FormProvider , Controller } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { interactionSchema, type InteractionSchema , } from "@/schemas/interactionSchema" ; import { Form , Select , Button , Typography } from "antd" ;import { ControlledInput } from "@/components/form/ControlledInput" ;const feedbackOptions = [ { label : "Bug" , value : "bug" }, { label : "建议" , value : "suggestion" }, { label : "其他" , value : "other" }, ]; export default function ConditionalFieldForm ( ) { const methods = useForm<InteractionSchema >({ resolver : zodResolver (interactionSchema), defaultValues : { feedbackType : "suggestion" , otherReason : "" , }, }); const watchedFeedbackType = methods.watch ("feedbackType" ); const onSubmit = (data : InteractionSchema ) => { alert (`提交成功: \n${JSON .stringify(data, null , 2 )} ` ); }; return ( <FormProvider {...methods }> <Form onFinish ={methods.handleSubmit(onSubmit)} layout ="vertical" className ="max-w-md" > <Typography.Title level ={2} > 条件字段表单</Typography.Title > <Controller control ={methods.control} name ="feedbackType" render ={({ field }) => { return <Select options ={feedbackOptions} {...field } /> ; }} ></Controller > {/* 👇 基于订阅到的值,进行条件渲染 */} {watchedFeedbackType === "other" && ( <ControlledInput<InteractionSchema > name="otherReason" label="其他原因" placeholder="请填写具体原因" /> )} <Button type ="primary" htmlType ="submit" > 提交 </Button > </Form > </FormProvider > ); }
3.2.2. 性能优化:watch vs. getValues watch 的重渲染特性是实现条件 UI 的利器,但如果你的目的仅仅是在 某个事件回调(如 onClick)中读取字段的当前值 ,使用 watch 就是一种性能浪费。每一次按键都会触发重渲染,而你其实只在点击按钮的那一刻才需要数据。
为此,RHF 提供了 getValues。这是一个 非响应式 的方法,它像一个“快照”工具,只在你调用它的那一刻,去 DOM 中读取并返回最新的表单值,它本身不会触发任何重渲染 。
API 响应性 核心用途 性能影响 watch()高 (订阅值,触发重渲染)条件渲染 ,实时 UI 更新可能较高,取决于组件复杂度 getValues()无 (命令式,一次性读取)在 事件回调 中获取当前值 极低,无重渲染
实战场景 :在一个复杂的表单中,提供一个“在控制台打印当前值”的调试按钮,我们不希望用户的任何输入都导致整个表单重绘。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const { getValues } = useForm ();const handleDebug = ( ) => { const currentValues = getValues (); console .log ('Current form values:' , currentValues); alert ('请打开控制台查看当前表单值' ); }; return ( <Form > {/* ... 大量输入框 ... */} <Button onClick ={handleDebug} > 打印当前值到控制台</Button > </Form > )
在这个例子中,无论用户在输入框中如何操作,组件都不会因为调试功能而重渲染。只有当用户点击按钮时,getValues 才会执行一次数据读取操作。
我们已经知道 watch 会重渲染整个组件。但如果某个 UI 的更新,只依赖于表单的 元数据状态 (比如 isValid, isDirty),而不是具体某个字段的值呢?让整个大表单为了一个按钮的 disabled 状态而重渲染,显然也是不划算的。
useFormState 正是为此而生的性能优化利器。它允许你 精准地订阅你关心的表单状态 ,并将重渲染的范围隔离到最小。
实战场景 :一个提交按钮,只有在表单被修改过 (isDirty) 并且所有字段都通过验证 (isValid) 时才可点击。我们希望只有这个按钮本身重渲染,而不是整个表单。
第一步:将依赖状态的 UI 隔离成子组件 这是实现性能优化的关键前提:将需要根据状态变化而更新的 UI(这里是提交按钮)封装成一个独立的子组件。
文件路径 : src/components/form/SmartSubmitButton.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import { useFormState } from 'react-hook-form' ;import { Button } from 'antd' ;import type { Control , FieldValues } from 'react-hook-form' ;interface SmartSubmitButtonProps <T extends FieldValues > { control : Control <T>; } export function SmartSubmitButton <T extends FieldValues >({ control }: SmartSubmitButtonProps <T>) { const { isDirty, isValid } = useFormState ({ control, }); console .log ('✅ SmartSubmitButton is re-rendering...' ); return ( <Button type ="primary" htmlType ="submit" className ="w-full mt-4" // 👇 按钮的 disabled 状态只由这两个精准订阅的状态决定 disabled ={!isDirty || !isValid } > 提交 </Button > ); }
运行分析 :将 <SmartSubmitButton control={methods.control} /> 放入你的主表单组件中,并在主表单组件里也加上 console.log。你会观察到:
当你在输入框中输入内容时,isDirty 和 isValid 状态会变化。 只有 ✅ SmartSubmitButton is re-rendering... 的日志被打印。 主表单组件的 console.log 不会 被打印。 我们成功地将状态更新导致的重渲染,从庞大的主表单隔离到了这个微小的按钮组件中,实现了极致的性能优化。
我: 看来 RHF 提供了一整套应对实时交互的工具,我应该如何选择?架构师: 没错,记住这个选择原则,它能帮你应对 99% 的场景:我: 请讲。架构师: “UI-View 用 watch,Event-Callback 用 getValues,Meta-State 用 useFormState。”
如果你需要根据 字段值 的变化来 动态渲染/隐藏/改变界面元素 ,用 watch。 如果你只是想在 点击事件 或其他回调函数里 一次性读取 字段值,用 getValues。 如果你需要根据 表单的整体状态 (如 isDirty, isValid)来更新一小块 UI,将这块 UI 封装成子组件,并在其中使用 useFormState。始终选择能引起最小范围重渲染的那个工具,是通往高性能表单的必经之路。 本节小结 我们学习了 RHF 中处理实时交互与性能优化的三大核心 API,并掌握了它们的适用场景。
核心 API 关键作用 最佳应用场景 watch订阅字段值变化,触发组件重渲染。 条件渲染 :根据一个输入的值,显示或隐藏另一个输入。getValues非响应式地获取表单当前值快照。 事件回调 :在 onClick, onChange 等事件处理器中读取数据,而无需触发重渲染。useFormState在隔离的子组件中,精准订阅表单的元数据状态。 性能优化 :根据 isValid, isDirty 等状态更新独立的 UI 片段(如提交按钮),避免整个父表单重渲染。
3.3. 复杂布局:useFormContext 痛点背景 :随着表单业务变得复杂,我们通常会遵循组件化思想,将一个巨大的表单拆分成多个独立的、可复用的子组件。例如,一个“用户设置”页面可能被拆分为 个人信息、联系方式、通知设置 等多个组件,每个组件内部可能还有更深层次的嵌套。
这时,一个严峻的问题摆在我们面前:位于顶层页面的 useForm 实例(尤其是 control 和 register 对象)如何高效、优雅地传递给那些深埋在组件树底层的输入框组件 ?
手动一层层地通过 props 传递,即“属性钻探”,无疑是一场噩梦。它不仅让代码冗长,更让子组件与父组件产生了不必要的强耦合。useFormContext 正是 RHF 提供的、用于根治此顽疾的“传送门”。
3.3.1. 解决 Prop Drilling useFormContext 的核心思想借鉴了 React 的 Context API。它由两个部分组成:
<FormProvider> : 一个提供者(Provider)组件,我们将 useForm 返回的整个实例“广播”出去。useFormContext() : 一个消费者(Consumer)Hook,任何在 <FormProvider> 包裹下的子组件,无论多深,都可以通过它“接收”到广播的表单实例。实战场景 :构建一个包含多个独立分区的“用户资料”表单。
第一步:定义一个结构化的 Schema 为了匹配我们的组件结构,Schema 也应该被设计成结构化的。
文件路径 : src/schemas/profileSchema.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import * as z from 'zod/v4' ;export const profileSchema = z.object ({ personalInfo : z.object ({ firstName : z.string ().min (1 , '姓氏不能为空' ), lastName : z.string ().min (1 , '名字不能为空' ), }), contactInfo : z.object ({ email : z.string ().email ('无效的邮箱地址' ), phone : z.string ().optional (), }), }); export type ProfileSchema = z.infer <typeof profileSchema>;
第二步:创建独立的表单分区组件 我们先创建两个子组件,它们将完全不知道 useForm 的存在,只关心如何通过 useFormContext 获取所需的东西。
文件路径 : src/components/form/PersonalInfoSection.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { Card } from "antd" ;import { ControlledInput } from "./ControlledInput" ;import { type ProfileSchema } from "@/schemas/profileSchema" ;export function PersonalInfoSection ( ) { return ( <Card title="个人信息页" extra={<a href="#">More</a>}> <ControlledInput<ProfileSchema> name="personalInfo.firstName" label="姓氏" /> <ControlledInput<ProfileSchema> name="personalInfo.lastName" label="名字" /> </Card> ); }
文件路径 : src/components/form/ContactInfoSection.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { Card } from "antd" ;import { ControlledInput } from "./ControlledInput" ;import { type ProfileSchema } from "@/schemas/profileSchema" ;export function ContactInfoSection ( ) { return ( <Card title="联系方式" className="mt-4"> <ControlledInput<ProfileSchema> name="contactInfo.email" label="邮箱" /> <ControlledInput<ProfileSchema> name="contactInfo.phone" label="电话(可选)" /> </Card> ); }
现在,我们在主表单组件中调用 useForm,并用 <FormProvider> 将子组件包裹起来,完成“传送门”的搭建。
文件路径 : src/pages/ComplexLayoutForm.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import { useForm, FormProvider } from 'react-hook-form' ;import { zodResolver } from '@hookform/resolvers/zod' ;import { profileSchema, type ProfileSchema } from '@/schemas/profileSchema' ;import { Form , Button , Typography } from 'antd' ;import { PersonalInfoSection } from '@/components/form/PersonalInfoSection' ;import { ContactInfoSection } from '@/components/form/ContactInfoSection' ;export default function ComplexLayoutForm ( ) { const methods = useForm<ProfileSchema >({ resolver : zodResolver (profileSchema), defaultValues : { personalInfo : { firstName : '' , lastName : '' }, contactInfo : { email : '' }, }, }); const onSubmit = (data : ProfileSchema ) => { alert (`提交成功: \n${JSON .stringify(data, null , 2 )} ` ); }; return ( <FormProvider {...methods }> <Form onFinish ={methods.handleSubmit(onSubmit)} layout ="vertical" className ="max-w-xl" > <Typography.Title level ={2} > 复杂布局表单</Typography.Title > {/* 3. 直接渲染子组件,无需手动传递任何 props */} <PersonalInfoSection /> <ContactInfoSection /> <Button type ="primary" htmlType ="submit" className ="w-full mt-6" > 保存资料 </Button > </Form > </FormProvider > ); }
通过这个模式,我们彻底消除了属性钻探。PersonalInfoSection 和 ContactInfoSection 变得完全独立和解耦,它们可以被放置在组件树的任何(被 FormProvider 包裹的)位置,而无需修改任何代码。
第四章: 宏观架构与企业级协同 摘要 : 在本章,我们将把视野从独立的表单组件提升到整个应用的宏观架构。您将学习如何将 React Hook Form (RHF) 作为一个“数据交互终端”,与服务器状态管理器 (TanStack Query) 和客户端状态管理器 (Zustand) 这两大核心进行双向协同。我们将通过构建“编辑模式”、“带缓存的服务端验证”、“多步表单”等企业级实战,最终掌握将复杂表单逻辑封装为“表单即服务”的终极抽象模式,打通数据流的“任督二脉”。
4.1 状态协同 I:TanStack Query 与表单的双向绑定 到目前为止,我们已经精通了 React Hook Form 对 表单内部状态 的管理。但一个完整的表单功能,必然涉及与后端的交互——加载表单的初始数据,并将修改后的数据提交回去。
本节,我们将引入一个强大的 服务器状态管理器 —— TanStack Query (在社区中常被称为 React Query),并专注于解决 RHF 与它之间最核心的交互问题:如何将从服务器获取的数据,优雅地 填充 进表单。
4.1.0 前置准备:在项目中集成 TanStack Query 这是我们首次在项目中引入 TanStack Query,因此必须先完成基础的安装和配置。它将作为我们应用中处理所有服务器状态的“引擎”。
第一步:安装核心依赖
1 2 pnpm add @tanstack/react-query @tanstack/react-query-devtools axios
第二步:创建并配置 QueryClient
QueryClient 是 TanStack Query 的核心,它管理着所有的缓存和请求。最佳实践是将其创建在-一个独立的模块中,以避免循环依赖。
文件路径 : src/lib/queryClient.ts (新建文件夹和文件)
1 2 3 4 5 6 7 8 9 10 11 import { QueryClient } from "@tanstack/react-query" ;export const queryClient = new QueryClient ({ defaultOptions : { queries : { staleTime : 1000 * 60 , }, }, });
第三步:在应用入口处提供 QueryClientProvider
与 FormProvider 类似,我们需要在应用的顶层使用 QueryClientProvider 来包裹整个应用,使得所有子组件都能访问到 queryClient 实例。
文件路径 : src/main.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import React from "react" ;import ReactDOM from "react-dom/client" ;import { RouterProvider } from "react-router-dom" ;import { ConfigProvider , App as AntdApp } from "antd" ;import zhCN from "antd/locale/zh_CN" ;import router from "@/router" ;import "./index.css" ;import { QueryClientProvider } from "@tanstack/react-query" ;import { queryClient } from "@/lib/queryClient" ;import { ReactQueryDevtools } from "@tanstack/react-query-devtools" ;ReactDOM .createRoot (document .getElementById ("root" )!).render ( <React.StrictMode > {/* 3. 使用 QueryClientProvider 包裹应用 */} <QueryClientProvider client ={queryClient} > <ConfigProvider locale ={zhCN} > <AntdApp > <RouterProvider router ={router} /> </AntdApp > </ConfigProvider > {/* 4. 在应用底部添加开发工具,用于调试 */} <ReactQueryDevtools initialIsOpen ={false} /> </QueryClientProvider > </React.StrictMode > );
至此,我们的项目已成功集成 TanStack Query,具备了管理服务器状态的能力。
4.1.1 数据填充 (编辑模式) 业务场景与痛点分析
场景 : 用户在用户列表页点击某位用户的“编辑”按钮,跳转到 /users/1/edit 这样的专属编辑页。页面需要异步获取该用户的数据,并将其填充到表单中供用户修改。
痛点 : 一个常见的错误想法是:
1 2 3 4 5 6 const { data : user } = useQuery (...);const { control } = useForm ({ defaultValues : user, });
这种方式注定会失败。因为 useQuery 是异步的,在组件首次渲染时,user 的值是 undefined。useForm 只会在其 首次初始化时 读取一次 defaultValues。后续当 user 数据从服务器返回时,useForm 不会再响应这个变化,导致表单永远是空的。
架构核心:“先挂载,后填充”的水合模式
要解决这个问题,我们必须遵循一个清晰的架构模式:
独立初始化 : useForm 应使用静态的、结构完整的默认值(例如空字符串)进行 同步初始化 。异步获取 : useQuery 负责独立地、异步地从服务器获取数据。事后填充 (Hydration) : 使用 useEffect Hook 来“监听”useQuery 返回的数据。一旦数据成功返回,就调用 React Hook Form 提供的 reset API ,将整个表单的状态 一次性、原子化地 更新为服务器返回的数据。逐步实战:构建用户编辑表单
第一步:启动后端并创建 API 层
首先,请确保您已根据【序章】的指导,在 package.json 中添加了 server 脚本,并在一个独立的终端中运行了 pnpm run server 命令。我们的 db.json 文件中的 users 数据将作为数据源。
接下来,我们创建与 TanStack Query 配套的 API 请求层。
文件路径 : src/utils/axios.ts (新建文件)
1 2 3 4 5 6 7 import axios from 'axios' ;const axiosInstance = axios.create ({ baseURL : 'http://localhost:3001' , }); export default axiosInstance;
文件路径 : src/api/userApi.ts (修改/创建)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import axiosInstance from "@/utils/axios" ;import type { UserSchema as User } from "@/schemas/userSchema" ; export const getUserById = async (id : number ): Promise <User > => { const response = await axiosInstance.get (`/users/${id} ` ); return response.data ; }; export const getUsers = async (): Promise <User []> => { const response = await axiosInstance.get ("/users" ); return response.data ; };
第二步:创建 Query 层
我们将所有与 TanStack Query 相关的配置集中管理,实现高内聚。
文件路径 : src/queries/userQueries.ts (新建文件夹和文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import { queryOptions } from "@tanstack/react-query" ;import { getUserById, getUsers } from "@/api/userApi" ;export const usersQueryOptions = queryOptions ({ queryKey : ["users" ], queryFn : getUsers, }); export const userQueryOptions = (userId : number ) => queryOptions ({ queryKey : ["users" , userId], queryFn : () => getUserById (userId), });
第三步:创建路由与 Loader 预取
现在,我们在应用的“交通枢纽”——路由配置中,为新页面规划好路线。
文件路径 : src/router/index.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import { queryClient } from '@/lib/queryClient' ;import { usersQueryOptions, userQueryOptions } from '@/queries/userQueries' ;import EditUserPage from '@/pages/EditUserPage' ; import UsersListPage from '@/pages/UsersListPage' ; const router = createBrowserRouter ([ { path : '/' , element : <App /> , children : [ { path : 'users' , element : <UsersListPage /> , loader : () => { queryClient.prefetchQuery (usersQueryOptions); return null ; }, }, { path : 'users/:userId/edit' , element : <EditUserPage /> , loader : ({ params } ) => { if (!params.userId ) throw new Error ('需要用户ID' ); const userId = Number (params.userId ); queryClient.prefetchQuery (userQueryOptions (userId)); return { userId }; }, }, ], }, ]); export default router;
第四步:创建用户列表页以打通流程
为了能够导航到编辑页,我们需要一个简单的列表页。
文件路径 : src/pages/UsersListPage.tsx (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import { useQuery } from '@tanstack/react-query' ;import { usersQueryOptions } from '@/queries/userQueries' ;import { Button , List , Typography , Skeleton } from 'antd' ;import { Link } from 'react-router-dom' ;export default function UsersListPage ( ) { const { data : users, isLoading } = useQuery (usersQueryOptions); if (isLoading) { return <Skeleton active /> ; } return ( <div > <Typography.Title level ={2} > 用户列表</Typography.Title > <List bordered dataSource ={users} renderItem ={(user) => ( <List.Item actions ={[ <Link to ={ `/users /${user.id }/edit `}> <Button type ="link" > 编辑</Button > </Link > ]}> <List.Item.Meta title ={user.username} description ={user.email} /> </List.Item > )} /> </div > ); }
第五步:构建最终的 EditUserPage 组件
这是我们将所有部分整合在一起的地方。
文件路径 : src/pages/EditUserPage.tsx (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import { useEffect } from 'react' ;import { useLoaderData, useNavigate } from 'react-router-dom' ;import { useQuery } from '@tanstack/react-query' ;import { useForm, FormProvider } from 'react-hook-form' ;import { zodResolver } from '@hookform/resolvers/zod' ;import { userSchema, type UserSchema } from '@/schemas/userSchema' ;import { userQueryOptions } from '@/queries/userQueries' ;import { Form , Button , Typography , Skeleton , Alert } from 'antd' ;import { ControlledInput } from '@/components/form/ControlledInput' ;export default function EditUserPage ( ) { const { userId } = useLoaderData () as { userId : number }; const navigate = useNavigate (); const { data : user, isLoading, isError } = useQuery (userQueryOptions (userId)); const methods = useForm<UserSchema >({ resolver : zodResolver (userSchema), defaultValues : { username : '' , email : '' , }, }); useEffect (() => { if (user) { methods.reset (user); } }, [user, methods.reset ]); const onSubmit = (data : UserSchema ) => { alert (`(模拟) 提交更新: \n${JSON .stringify(data, null , 2 )} ` ); navigate ('/users' ); }; if (isLoading) { return <Skeleton active paragraph ={{ rows: 4 }} /> ; } if (isError) { return <Alert message ="用户数据加载失败!" type ="error" showIcon /> ; } return ( <FormProvider {...methods}> <Form onFinish={methods.handleSubmit(onSubmit)} layout="vertical" className="max-w-md"> <Typography.Title level={2}>编辑用户: {user?.username}</Typography.Title> <ControlledInput<UserSchema> name="username" label="用户名" /> <ControlledInput<UserSchema> name="email" label="邮箱" /> <Button type="primary" htmlType="submit" className="w-full mt-4"> 保存更改 </Button> </Form> </FormProvider> ); }
至此,一个完整的、自成体系的、遵循我们所有最佳实践的“编辑”功能就完成了!您现在可以启动项目,访问 /users,然后点击“编辑”来体验整个流程。
4.2 状态协同 II:useMutation 与服务端验证 业务场景与痛点分析 我们已经使用 Zod 实现了强大的 客户端验证 (例如,邮箱格式是否正确)。但是,有很多验证逻辑 只能在服务器端执行 。最典型的例子就是 唯一性校验 :当用户修改邮箱时,只有后端数据库才知道这个新邮箱是否已经被其他用户注册了。
痛点 : 当后端校验失败时,我们不能只给用户一个模糊的“提交失败”提示。一个优秀的用户体验,要求我们将后端返回的错误信息(例如“此邮箱已被占用”)精准地显示在对应的输入框下方 ,其展现形式应与 Zod 客户端验证错误完全一致,形成统一的反馈闭环。
架构核心:useMutation + setError API 要实现这个目标,我们需要搭建一座桥梁,连接 TanStack Query 的“变更层”和 React Hook Form 的“状态层”。
useMutation : 我们将使用它来处理所有“写”操作(如 POST, PATCH, DELETE)。它的 onError 回调函数是捕获后端错误的完美时机。RHF setError API : 这是 RHF 提供的一个“命令式”接口,允许我们从外部手动向指定的表单字段添加一个错误。数据流将是这样的 :表单提交 → handleSubmit (Zod 验证通过) → useMutation.mutate() → API 请求 → 服务器返回 400 错误 → useMutation.onError 捕获 → setError() → RHF 更新 formState.errors → UI 自动显示错误。
逐步实战:集成服务端验证错误 第一步:升级 Mock 后端以模拟业务错误
json-server 的默认行为无法模拟业务逻辑校验。因此,我们需要通过自定义中间件来为它“注入灵魂”。
首先,在您的项目根目录下(与 package.json 同级)创建一个名为 server.js 的文件。
文件路径 : server.js (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 import jsonServer from "json-server" ;const server = jsonServer.create ();const router = jsonServer.router ("db.json" );const middlewares = jsonServer.defaults ();server.use (middlewares); server.use (jsonServer.bodyParser ); server.use ((req, res, next ) => { if (req.method === "PATCH" && req.path .startsWith ("/users/" )) { const db = router.db ; const updatedUser = req.body ; const userIdToUpdate = parseInt (req.path .split ("/" )[2 ], 10 ); if (!updatedUser) { return res.status (400 ).json ({ message : "请求体不能为空" , }); } const existingUser = db .get ("users" ) .find ( (user ) => user.email === updatedUser.email && user.id !== userIdToUpdate ) .value (); if (existingUser) { return res.status (400 ).json ({ field : "email" , message : "此邮箱已被其他用户占用 (来自服务器的验证)" , }); } } next (); }); server.use (router); server.listen (3001 , () => { console .log ("JSON Server with custom middleware is running on port 3001" ); });
接下来,修改 package.json,让 server 脚本运行这个新文件。
文件路径 : package.json (修改 scripts)
1 2 3 4 5 6 { "scripts" : { "server" : "node server.js" } , }
现在,重启您的 pnpm run server 进程 。我们的 Mock 后端已经具备了模拟“邮箱冲突”业务错误的能力。
第二步:创建 update 相关的 API 与 Mutation Hook
遵循“高内聚、低耦合”的原则,我们将所有与“更新用户”相关的逻辑,包括 useMutation 的配置,都封装到一个独立的自定义 Hook 中。
文件路径 : src/api/userApi.ts (添加新函数)
1 2 3 4 5 6 7 8 9 10 11 import { type UserSchema as User } from '@/schemas/userSchema' ;export const updateUser = async ( { id, ...data }: { id : number } & User ): Promise <User > => { const response = await axiosInstance.patch (`/users/${id} ` , data); return response.data ; };
文件路径 : src/hooks/useUserMutations.ts (新建文件夹和文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import { useMutation, useQueryClient } from "@tanstack/react-query" ;import { type UseFormSetError } from "react-hook-form" ;import { isAxiosError } from "axios" ;import { App as AntdApp } from "antd" ;import { updateUser } from "@/api/userApi" ;import { usersQueryOptions } from "@/queries/userQueries" ;import { type UserSchema } from "@/schemas/userSchema" ;export const useUpdateUser = ( setError : UseFormSetError <UserSchema >, onSuccess ?: () => void ) => { const queryClient = useQueryClient (); const { message } = AntdApp .useApp (); return useMutation ({ mutationFn : updateUser, onSuccess : () => { message.success ("用户资料更新成功!" ); queryClient.invalidateQueries ({ queryKey : ["users" ] }); onSuccess?.(); }, onError : (error ) => { if (isAxiosError (error) && error.response ?.status === 400 ) { const serverError = error.response .data as { field : "username" | "email" ; message : string ; }; if (serverError.field && serverError.message ) { setError (serverError.field , { type : "server" , message : serverError.message , }); return ; } message.error ("更新失败,请稍后重试。" ); } }, }); };
这个 useUpdateUser Hook 是一个完美的“胖 Hook”范例:它封装了 API 调用、成功处理、缓存失效和复杂的错误处理逻辑,对外只暴露简洁的接口。
第三步:在 EditUserPage 中集成 Mutation Hook
现在,我们的页面组件可以变得非常“瘦”,它只需调用我们创建的 Hook,而无需关心复杂的错误处理实现。
文件路径 : src/pages/EditUserPage.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 import { useEffect } from "react" ;import { useLoaderData, useNavigate } from "react-router-dom" ;import { useQuery } from "@tanstack/react-query" ;import { useForm, FormProvider } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { userSchema, type UserSchema } from "@/schemas/userSchema" ;import { userQueryOptions } from "@/queries/userQueries" ;import { Form , Button , Typography , Skeleton , Alert } from "antd" ;import { ControlledInput } from "@/components/form/ControlledInput" ;import { useUpdateUser } from "@/hooks/useUserMutations" ; export default function EditUserPage ( ) { const { userId } = useLoaderData () as { userId : number }; const navigate = useNavigate (); const { data : user, isLoading, isError } = useQuery (userQueryOptions (userId)); const methods = useForm<UserSchema >({ resolver : zodResolver (userSchema), defaultValues : { username : "" , email : "" , }, }); const { setError, handleSubmit } = methods; const updateUserMutation = useUpdateUser (setError, () => { navigate ("/users" ); }); useEffect (() => { if (user) { methods.reset (user); } }, [user, methods.reset ]); const onSubmit = (data : UserSchema ) => { updateUserMutation.mutate ({ ...data, id : userId }); }; if (isLoading) { return <Skeleton active paragraph ={{ rows: 4 }} /> ; } if (isError) { return <Alert message ="用户数据加载失败!" type ="error" showIcon /> ; } return ( <FormProvider {...methods}> <Form onFinish={methods.handleSubmit(onSubmit)} layout="vertical" className="max-w-md" > <Typography.Title level={2}> 编辑用户: {user?.username} </Typography.Title> <ControlledInput<UserSchema> name="username" label="用户名" /> <ControlledInput<UserSchema> name="email" label="邮箱" /> <Button type="primary" htmlType="submit" className="w-full mt-4" // 5. 将按钮的加载状态与 mutation 绑定 loading={updateUserMutation.isPending} > {updateUserMutation.isPending ? "保存中..." : "保存更改"} </Button> </Form> </FormProvider> ); }
第四步:验证
启动应用,确保 pnpm run dev 和 pnpm run server 都在运行。 进入 /users 列表,随便找一个用户(比如 test,邮箱 test@example.com)。 进入另一个用户(比如 admin)的编辑页。 将其邮箱修改为 test@example.com,然后点击“保存更改”。 观察现象 :页面不会跳转,按钮会停止加载,同时“邮箱”输入框下方会精准地出现红色的错误提示:“此邮箱已被其他用户占用 (来自服务器的验证)”。我们的目标达成了!
技术架构决策
2025-10-10 11:30
这个模式太强大了!我们的 ControlledInput 组件完全不知道错误是来自客户端的 Zod 还是来自服务器。它只是忠实地从 formState.errors 中读取并渲染错误。
架构师
正是如此。这就是“统一状态模型”的威力。setError API 就是那座关键的桥梁,它允许我们外部的异步逻辑(useMutation)能够安全地“注入”信息到 RHF 的内部状态模型中。
这样做,UI 组件保持了极高的纯粹性和复用性,而所有的“脏活累活”都被封装在了高内聚的 useUpdateUser Hook 里。
架构师
你已经完全掌握了企业级表单开发的精髓:“胖 Hooks,瘦组件”。UI
4.3 状态协同 III:Zod 异步验证与 queryClient 缓存 业务场景与痛点分析 回顾过去 : 在【第二章 2.3 节】中,我们首次学习了如何在 Zod 的 .refine() 方法中,使用原生 fetch API 来实现异步的“用户名唯一性”校验。
1 2 3 4 5 6 username : z.string ().min (3 ).refine (async (username) => { const response = await fetch (`http://localhost:3001/users?username=${username} ` ); const users = await response.json (); return users.length === 0 ; }, "该用户名已被占用" ),
痛点分析 : 这个实现虽然能工作,但在我们已经拥有 TanStack Query 这个强大“服务器状态引擎”的今天,它显得非常“原始”和低效:
无缓存 : 每次验证触发(例如,用户在输入框 blur 时),它都会 无条件地 发起一次新的网络请求。如果用户反复检查同一个用户名,就会造成大量不必要的 API 调用。请求无法去重 : 如果多个地方同时触发对同一个用户名的校验,它会发送多次重复的请求。状态相异 : 这次 fetch 游离于 TanStack Query 的管理体系之外,我们无法在 Devtools 中观察它,也无法享受后台自动刷新等所有高级特性。我们的目标 :将 Zod 的异步验证,无缝接入 TanStack Query 的缓存体系中。
架构核心:queryClient.fetchQuery 要实现这个目标,我们不能在 Zod 的 refine 函数(一个非 React 的纯函数环境)中使用 useQuery Hook。为此,TanStack Query 提供了它的“命令式”版本:queryClient.fetchQuery。
useQuery : 是一个 声明式 的 Hook ,用于在 React 组件中 订阅 缓存数据,并在数据变化时触发组件重渲染。fetchQuery : 是一个 命令式 的 方法 ,可以直接在 queryClient 实例上调用。它会返回一个 Promise,解析为查询结果。最关键的是,它在发起请求前,会首先检查缓存 :如果缓存中存在该查询的 新鲜 (fresh) 数据,它会立即返回数据,不会发起网络请求 。 如果数据 陈旧 (stale) 或 不存在 ,它才会发起网络请求,并将结果存入缓存,然后返回。 fetchQuery 正是为我们这种需要在 React 环境之外与 Query 缓存交互的场景量身打造的完美工具。
逐步实战:实现带缓存的“用户名唯一性”校验 第一步:API 与 Query 层准备
我们需要一个用于检查用户名是否可用的 API 和对应的 Query Options。
文件路径 : src/api/userApi.ts (添加新函数)
1 2 3 4 5 6 7 8 export const checkUsernameIsAvailable = async (username : string ): Promise <boolean > => { const { data } = await axiosInstance.get <User []>('/users' , { params : { username } }); return data.length === 0 ; };
文件路径 : src/queries/userQueries.ts (添加新选项)
1 2 3 4 5 6 7 8 9 10 11 12 13 import { checkUsernameIsAvailable } from '@/api/userApi' ;export const checkUsernameQueryOptions = (username : string ) => queryOptions ({ queryKey : ['users' , 'check' , username], queryFn : () => checkUsernameIsAvailable (username), staleTime : 1000 * 60 * 5 , retry : false , });
第二步:升级 Zod Schema
现在,我们用 queryClient.fetchQuery 来替换掉 userSchema.ts 中原始的 fetch 实现。
文件路径 : src/schemas/userSchema.ts (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import * as z from 'zod/v4' ;import { queryClient } from '@/lib/queryClient' ; import { checkUsernameQueryOptions } from '@/queries/userQueries' ; export const userSchema = z.object ({ username : z.string ().min (2 , "用户名至少需要 2 个字符" ) .refine (async (username) => { if (!username) return true ; try { const isAvailable = await queryClient.fetchQuery (checkUsernameQueryOptions (username)); return isAvailable; } catch (error) { return false ; } }, { message : '该用户名已被占用 (来自服务器)' , }), email : z.string ().email ("请输入有效的邮箱地址" ), }); export type UserSchema = z.infer <typeof userSchema>;
第三步:创建“注册”页面以验证效果
我们需要一个使用此 Schema 的新表单页面来测试我们的新功能。
文件路径 : src/pages/RegisterPage.tsx (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import { useForm, FormProvider } from 'react-hook-form' ;import { zodResolver } from '@hookform/resolvers/zod' ;import { userSchema, type UserSchema } from '@/schemas/userSchema' ;import { Form , Button , Typography } from 'antd' ;import { ControlledInput } from '@/components/form/ControlledInput' ;import { useNavigate } from 'react-router-dom' ;export default function RegisterPage ( ) { const navigate = useNavigate (); const methods = useForm<UserSchema >({ resolver : zodResolver (userSchema), mode : 'onBlur' , defaultValues : { username : '' , email : '' , }, }); const onSubmit = (data : UserSchema ) => { alert (`(模拟) 注册成功: \n${JSON .stringify(data, null , 2 )} ` ); navigate ('/users' ); }; return ( <FormProvider {...methods}> <Form onFinish={methods.handleSubmit(onSubmit)} layout="vertical" className="max-w-md"> <Typography.Title level={2}>新用户注册</Typography.Title> <ControlledInput<UserSchema> name="username" label="用户名 (admin, test 已被占用)" /> <ControlledInput<UserSchema> name="email" label="邮箱" /> <Button type="primary" htmlType="submit" className="w-full mt-4"> 注册 </Button> </Form> </FormProvider> ); }
第四步:添加路由并验证
最后,将新页面添加到路由中。
文件路径 : src/router/index.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 import RegisterPage from '@/pages/RegisterPage' ; const router = createBrowserRouter ([ { path : '/' , element : <App /> , children : [ { path : 'register' , element : <RegisterPage /> }, ], }, ]);
验证流程 :
启动应用,打开 React Query Devtools。 导航到 /register 页面。 在“用户名”输入框中输入 admin(假设它已存在于 db.json),然后点击输入框外部(触发 blur 事件)。观察 : Devtools 中出现一个新的查询 ['users', 'check', 'admin'],状态变为 fetching。片刻后,API 返回 false,输入框下方出现“该用户名已被占用”的错误提示。 清空输入框,再次输入 admin,然后再次 blur。观察 : 错误信息 瞬间出现 。查看 Devtools,没有任何新的网络请求 !状态指示器从 fresh (绿色) 直接提供了结果。这是因为 fetchQuery 命中了我们在 5 分钟 staleTime 内的缓存。 输入一个全新的用户名,如 newuser123,然后 blur。观察 : Devtools 中出现一个新的查询 ['users', 'check', 'newuser123'],API 请求发出并返回 true,输入框保持有效状态。
技术架构决策
2025-10-10 12:00
简直是魔法!第二次校验 admin 时,错误提示是瞬时的,体验极佳。fetchQuery 太强大了。
架构师
这不是魔法,是优秀的架构设计。fetchQuery 让你成功地将 表单验证逻辑 与 全局服务器状态缓存 连接了起来。你的 Zod Schema 不再是一个孤立的验证器,而是成为了整个数据管理体系的一部分。
所以,任何需要在非组件环境(如 Action、工具函数、或者这里的 Schema)与 TanStack Query 交互的场景,都应该首选 queryClient.fetchQuery?
架构师
完全正确。useQuery 用于在组件中“订阅”和“渲染”数据,而 fetchQuery 用于在任何地方“命令式地触发”和“获取”数据。你已经清晰地掌握了这两个核心工具的职责边界。
4.4 状态协同 IV:Zustand 与多步向导式表单 本节我们将引入一个新的“伙伴”—— Zustand ,一个轻量级的全局状态管理器。我们将学习如何将它与 React Hook Form 结合,构建出清晰、可维护、高性能的多步表单。
4.4.0 前置准备:在项目中集成 Zustand 这是我们首次在项目中正式引入 Zustand,必须先完成基础的安装和配置。
第一步:安装 Zustand
第二步:创建 Store 的组织结构 我们将遵循官方推荐的 “切片模式” (Slice Pattern) ,按照业务领域来组织我们的 Store。
文件路径 : src/stores/slices/ (新建 slices 文件夹)文件路径 : src/stores/useAppStore.ts (新建 stores 文件夹和文件)
1 2 3 4 import { create } from 'zustand' ;export const useAppStore = create (() => ({}));
4.4.1 架构选型对比 方案 实现方式 核心优势 核心劣势 单一 useForm 实例 所有字段定义在一个 巨型 Schema 中,用 useState 控制步骤渲染。 - 上手最快,状态集中。 - 无需引入外部状态管理库。 - 性能瓶颈 :单次输入可能触发 全局 重新验证。 - 代码臃肿,维护难度高,逻辑耦合。 多 useForm + 状态管理 (优选方案) 每一步拥有独立的 useForm。Zustand/Redux 仅用于 跨步骤 数据聚合和传递。 - 高性能 :局部验证,步骤间互不干扰。 - 高内聚 :模块化设计,职责清晰,易于维护。 - 引入外部依赖 (如 Zustand)。 - 需要设计 Store 数据结构。
4.4.2 实战:构建三步注册流程 我们将严格按照线性的、可执行的顺序,手把手实现一个包含“账户信息”、“个人资料”、“完成”三个步骤的注册流程。
第一步:【蓝图先行】为每个步骤创建独立的 Schema
在做任何事情之前,我们首先定义好每一步的数据结构和验证规则。这为后续所有开发工作提供了清晰的“类型蓝图”。
文件路径 : src/schemas/registrationSchemas.ts (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import * as z from 'zod/v4' ;export const step1Schema = z.object ({ username : z.string ().min (3 , '用户名至少需要3个字符' ), password : z.string ().min (6 , '密码至少需要6个字符' ), }); export type Step1Schema = z.infer <typeof step1Schema>;export const step2Schema = z.object ({ email : z.string ().email ('无效的邮箱地址' ), acceptTerms : z.literal (true , { error_map : () => ({ message : '您必须同意服务条款' }), }), }); export type Step2Schema = z.infer <typeof step2Schema>;
第二步:【状态核心】设计并创建 registrationSlice
现在,我们已经拥有了 Step1Schema 和 Step2Schema 类型,可以安全地在 Store 中使用它们了。
文件路径 : src/stores/slices/registrationSlice.ts (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import { type StateCreator } from 'zustand' ;import { type Step1Schema } from '@/schemas/registrationSchemas' ;import { type Step2Schema } from '@/schemas/registrationSchemas' ;export interface RegistrationSlice { currentStep : number ; step1Data : Step1Schema | null ; step2Data : Step2Schema | null ; nextStep : () => void ; prevStep : () => void ; setStep1Data : (data : Step1Schema ) => void ; setStep2Data : (data : Step2Schema ) => void ; resetRegistration : () => void ; } const initialState = { currentStep : 1 , step1Data : null , step2Data : null , }; export const createRegistrationSlice : StateCreator <RegistrationSlice > = (set ) => ({ ...initialState, nextStep : () => set ((state ) => ({ currentStep : state.currentStep + 1 })), prevStep : () => set ((state ) => ({ currentStep : state.currentStep - 1 })), setStep1Data : (data ) => set ({ step1Data : data }), setStep2Data : (data ) => set ({ step2Data : data }), resetRegistration : () => set (initialState), });
文件路径 : src/stores/useAppStore.ts (修改)
1 2 3 4 5 6 7 8 import { create } from 'zustand' ;import { createRegistrationSlice, type RegistrationSlice } from './slices/registrationSlice' ;type AppState = RegistrationSlice ;export const useAppStore = create<AppState >()((...a ) => ({ ...createRegistrationSlice (...a), }));
第三步:【UI 构建】创建独立的步骤组件
每个步骤都是一个高内聚的表单单元。
文件路径 : src/components/registration/Step1.tsx (新建文件夹和文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import { useForm, FormProvider } from 'react-hook-form' ;import { zodResolver } from '@hookform/resolvers/zod' ;import { step1Schema, type Step1Schema } from '@/schemas/registrationSchemas' ;import { useAppStore } from '@/stores/useAppStore' ;import { Form , Button , Typography } from 'antd' ;import { ControlledInput } from '@/components/form/ControlledInput' ;export default function Step1 ( ) { const { setStep1Data, nextStep, step1Data } = useAppStore (); const methods = useForm<Step1Schema >({ resolver : zodResolver (step1Schema), defaultValues : step1Data || { username : '' , password : '' }, }); const onSubmit = (data : Step1Schema ) => { setStep1Data (data); nextStep (); }; return ( <FormProvider {...methods}> <Form onFinish={methods.handleSubmit(onSubmit)} layout="vertical"> <Typography.Title level={3}>第一步:设置账户</Typography.Title> <ControlledInput<Step1Schema> name="username" label="用户名" /> <ControlledInput<Step1Schema> name="password" label="密码" type="password" /> <Button type="primary" htmlType="submit" className="mt-4">下一步</Button> </Form> </FormProvider> ); }
查看 Step2.tsx 完整代码 文件路径 : src/components/registration/Step2.tsx (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 import { useForm, FormProvider , Controller } from 'react-hook-form' ;import { zodResolver } from '@hookform/resolvers/zod' ;import { step2Schema, type Step2Schema } from '@/schemas/registrationSchemas' ;import { useAppStore } from '@/stores/useAppStore' ;import { Form , Button , Typography , Checkbox , Space } from 'antd' ;import { ControlledInput } from '@/components/form/ControlledInput' ;export default function Step2 ( ) { const { setStep2Data, nextStep, prevStep, step2Data } = useAppStore (); const methods = useForm<Step2Schema >({ resolver : zodResolver (step2Schema), defaultValues : step2Data || { email : '' , acceptTerms : false }, }); const onSubmit = (data : Step2Schema ) => { setStep2Data (data); nextStep (); }; return ( <FormProvider {...methods }> <Form onFinish ={methods.handleSubmit(onSubmit)} layout ="vertical" > <Typography.Title level ={3} > 第二步:个人资料</Typography.Title > <ControlledInput<Step2Schema > name="email" label="邮箱" /> <Controller control ={methods.control} name ="acceptTerms" render ={({ field , fieldState }) => ( <Form.Item validateStatus ={fieldState.error ? 'error ' : ''} help ={fieldState.error?.message} > <Checkbox checked ={field.value} onChange ={field.onChange} > 我同意服务条款 </Checkbox > </Form.Item > )} /> <Space className ="mt-4" > <Button onClick ={prevStep} > 上一步</Button > <Button type ="primary" htmlType ="submit" > 下一步</Button > </Space > </Form > </FormProvider > ); }
第四步:【真实提交】创建最终提交逻辑
我们创建一个真实的 useMutation Hook,将聚合后的数据 POST 到 json-server 的 /users 端点。
文件路径 : src/api/userApi.ts (添加 createUser 函数)
1 2 3 4 5 6 7 8 import { type UserSchema } from '@/schemas/userSchema' ;export const createUser = async (userData : UserSchema ): Promise <UserSchema > => { const response = await axiosInstance.post ('/users' , userData); return response.data ; };
文件路径 : src/hooks/useUserMutations.ts (添加 useSubmitRegistration Hook)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { createUser } from '@/api/userApi' ;import { App as AntdApp } from "antd" ;export const useSubmitRegistration = ( ) => { const queryClient = useQueryClient (); const { message } = AntdApp .useApp (); return useMutation ({ mutationFn : createUser, onSuccess : () => { message.success ('恭喜!新用户注册成功!' ); return queryClient.invalidateQueries ({ queryKey : ['users' ] }); }, onError : (error ) => { message.error (`注册失败: ${error.message} ` ); }, }); };
文件路径 : src/components/registration/Step3.tsx (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import { useAppStore } from '@/stores/useAppStore' ;import { Button , Descriptions , Result , Spin } from 'antd' ;import { useNavigate } from 'react-router-dom' ;import { useSubmitRegistration } from '@/hooks/useUserMutations' ; export default function Step3 ( ) { const { step1Data, step2Data, prevStep, resetRegistration } = useAppStore (); const navigate = useNavigate (); const { mutate, isPending } = useSubmitRegistration (); const handleFinalSubmit = ( ) => { if (step1Data && step2Data) { const finalData = { ...step1Data, ...step2Data }; mutate (finalData, { onSuccess : () => { resetRegistration (); navigate ('/users' ); } }); } }; if (!step1Data || !step2Data) { return <Result status ="warning" title ="部分步骤数据缺失,请返回上一步。" extra ={ <Button onClick ={prevStep} > 返回</Button > } /> ; } return ( <div > <Result status ="success" title ="即将完成注册!" subTitle ="请确认您的注册信息:" /> {isPending && <div className ="text-center" > <Spin tip ="正在提交..." /> </div > } <Descriptions bordered column ={1} className ="mb-6" > <Descriptions.Item label ="用户名" > {step1Data.username}</Descriptions.Item > <Descriptions.Item label ="邮箱" > {step2Data.email}</Descriptions.Item > </Descriptions > <div className ="flex justify-between" > <Button onClick ={prevStep} disabled ={isPending} > 上一步</Button > <Button type ="primary" onClick ={handleFinalSubmit} loading ={isPending} > 完成注册 </Button > </div > </div > ); }
现在我们集成到页面中,这个组件是“指挥官”,它根据 Zustand 的 currentStep 来决定显示哪个步骤。
文件路径 : src/pages/RegisterPage.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import { useAppStore } from "@/stores/useAppStore" ;import { Steps } from "antd" ;import Step1 from "@/components/registration/Step1" ;import Step2 from "@/components/registration/Step2" ;import Step3 from "@/components/registration/Step3" ;export default function RegisterPage ( ) { const { currentStep } = useAppStore (); const renderStep = ( ) => { switch (currentStep) { case 1 : return <Step1 /> ; case 2 : return <Step2 /> ; case 3 : return <Step3 /> ; default : return <Step1 /> ; } }; return ( <div className ="max-w-xl mx-auto" > <Steps current ={currentStep - 1 } items ={[ { title: "账户信息 " }, { title: "个人资料 " }, { title: "完成 " }, ]} > </Steps > {renderStep()} </div > ); }
至此,一个架构清晰、高度解耦、性能优异的多步表单就完成了。每个部分都各司其职,完美体现了我们所追求的企业级架构思想。
4.5 终极抽象:“表单即服务”的自定义 Hook 模式 4.5.1 架构原则:“胖 Hooks,瘦组件” 我们回顾一下在 4.1 和 4.2 节中创建的 EditUserPage 组件。它虽然功能完备,但却承担了过多的职责:
1 const { userId } = useLoaderData () as { userId : number };
它需要调用 useQuery 来获取用户数据,并处理加载状态。 1 const { data : user, isLoading, isError } = useQuery (userQueryOptions (userId));
1 2 3 4 5 6 const methods = useForm<UserSchema >({ resolver : zodResolver (userSchema), defaultValues : { username : "" , email : "" , },
它需要使用 useEffect 来将服务器数据同步到表单中。 1 2 3 4 5 useEffect (() => { if (user) { methods.reset (user); } }, [user, methods.reset ]);
它需要调用 useUpdateUser Hook 来处理数据提交。 1 2 3 4 5 const updateUserMutation = useUpdateUser (setError, () => { navigate ("/users" ); });
当一个组件承担了如此多的逻辑时,它就变成了一个“上帝组件”——臃肿、难以测试、且极难复用。如果现在产品经理要求:“我们需要在用户列表页弹出一个 Modal,也能快速编辑用户信息”,我们唯一的选择似乎只有复制粘贴大部分逻辑,这无疑是灾难的开始。
解决方案 :遵循 “胖 Hooks,瘦组件” (Fat Hooks, Thin Components) 的设计哲学。
这个原则的核心思想是:将一个功能相关的所有 非视图逻辑 ,从组件中彻底剥离,封装到一个单一的、高内聚的自定义 Hook 中。
胖 Hook (Fat Hook) : 这个 Hook 是功能的“大脑”。它负责数据获取 (useQuery)、表单状态管理 (useForm)、数据提交 (useMutation),以及所有成功/失败的副作用处理。它是一个自给自足的、可独立测试的逻辑单元。瘦组件 (Thin Component) : 组件的职责被极度简化,回归其本源——渲染 UI 。它只需调用这个“胖 Hook”,获取所有它需要的状态和事件处理器,然后将它们绑定到 TSX 上即可。它不关心数据从何而来,也不关心提交后会发生什么。现在,我们将 EditUserPage 的所有逻辑,全部提炼到一个名为 useUserProfileForm 的自定义 Hook 中。
第一步:创建自定义 Hook 文件
文件路径 : src/hooks/useUserProfileForm.ts (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 import { useEffect } from "react" ;import { useNavigate } from "react-router-dom" ;import { useQuery } from "@tanstack/react-query" ;import { useForm } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { userSchema, type UserSchema } from "@/schemas/userSchema" ;import { userQueryOptions } from "@/queries/userQueries" ;import { useUpdateUser } from "@/hooks/useUserMutations" ; interface UseUserProfileFormProps { userId : number ; } export default function useUserProfileForm ({ userId, }: UseUserProfileFormProps ) { const navigate = useNavigate (); const { data : user, isLoading } = useQuery (userQueryOptions (userId)); const methods = useForm<UserSchema >({ resolver : zodResolver (userSchema), defaultValues : { username : "" , email : "" , }, }); const { setError } = methods; const updateUserMutation = useUpdateUser (setError, () => { navigate ("/users" ); }); useEffect (() => { if (user) { methods.reset (user); } }, [user, methods.reset ]); const onSubmit = (data : UserSchema ) => { updateUserMutation.mutate ({ ...data, id : userId }); }; return { methods, handleSubmit : methods.handleSubmit (onSubmit), isLoading : isLoading, isSubmitting : methods.formState .isSubmitting || updateUserMutation.isPending , isError : updateUserMutation.isError , }; }
4.5.3 重构“瘦”组件 封装好“胖 Hook”后,我们的 EditUserPage 组件可以被前所未有地简化,回归其作为“视图层”的纯粹职责。
文件路径 : src/pages/EditUserPage.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import { useLoaderData } from "react-router-dom" ;import { FormProvider } from "react-hook-form" ;import useUserProfileForm from "@/hooks/useUserProfileForm" ; import { Form , Button , Typography , Skeleton , Alert } from "antd" ;import { ControlledInput } from "@/components/form/ControlledInput" ;import type { UserSchema } from "@/schemas/userSchema" ;export default function EditUserPage ( ) { const { userId } = useLoaderData () as { userId : number }; const { methods, handleSubmit, isLoading, isSubmitting, isError } = useUserProfileForm ({ userId }); if (isLoading) { return <Skeleton active paragraph ={{ rows: 4 }} /> ; } if (isError) { return <Alert message ="用户数据加载失败!" type ="error" showIcon /> ; } return ( <FormProvider {...methods}> <Form onFinish={handleSubmit} layout="vertical" className="max-w-md"> <Typography.Title level={2}>编辑用户 (Hook 模式)</Typography.Title> <ControlledInput<UserSchema> name="username" label="用户名" /> <ControlledInput<UserSchema> name="email" label="邮箱" /> <Button type="primary" htmlType="submit" className="w-full mt-4" loading={isSubmitting} // 提交状态也来自 Hook disabled={isSubmitting} > {isSubmitting ? "保存中..." : "保存更改"} </Button> </Form> </FormProvider> ); }
收益分析与总结
通过这次重构,我们获得了巨大的架构优势:
高度复用 : useUserProfileForm 这个 Hook 现在是一个“便携式”的编辑功能单元。任何需要编辑用户功能的组件(无论是页面还是弹窗),只需 import 并调用它即可,无需重复编写任何逻辑。极度简洁 : EditUserPage 组件的代码量大幅减少,其职责变得单一且清晰——它只关心“如何展示”,不关心“如何工作”。这使得新成员接手或未来修改样式时,变得异常简单。关注点分离 : UI 与逻辑被彻底解耦。UI 开发者可以专注于 EditUserPage.tsx,而业务逻辑开发者可以专注于 useUserProfileForm.ts,两者互不干扰。可测试性 : useUserProfileForm 是一个纯粹的 JavaScript 函数(尽管它内部调用了其他 Hooks)。我们可以使用 @testing-library/react-hooks 等工具,在不渲染任何真实 UI 的情况下,对它进行完整的单元测试和集成测试,确保其逻辑的健壮性。这,就是“表单即服务”模式的威力,也是我们在企业级项目中追求的终极架构形态。
本章核心速查总结 模式/API 核心职责 最佳应用场景 数据填充 (reset) 将从 useQuery 获取的异步数据,安全地“水合”到 useForm 实例中,并更新表单的“基准状态”。 编辑模式 :为表单提供初始的、从服务器获取的数据。服务端错误 (setError) 在 useMutation 的 onError 回调中,将后端返回的字段特定错误,手动注入到 RHF 的 formState.errors 中。 服务端验证 :实现与客户端验证无缝衔接的用户错误提示体验。缓存化验证 (fetchQuery) 在 Zod 的 .refine() 等非组件环境,命令式地与 TanStack Query 缓存交互,实现带缓存和请求去重的异步验证。 性能优化 :对“检查用户名/邮箱是否可用”等高频异步校验进行性能优化。多步表单 (Zustand) 每个步骤使用独立的 useForm,Zustand 作为“全局草稿纸”在步骤间传递和聚合数据。 向导式表单 :构建逻辑解耦、高性能的复杂注册、申请、结账等流程。终极抽象 (自定义 Hook) (胖 Hooks, 瘦组件) 将与一个完整表单功能相关的所有逻辑(数据获取、表单管理、提交、错误处理)封装成一个可复用的 Hook。企业级开发 :任何需要被复用或逻辑复杂的表单功能,都应被抽象为此模式。
第五章:生产力与用户体验:打磨企业级表单 摘要 : 至此,我们已经构建了表单的“引擎”与“传动系统”,实现了从数据填充到复杂提交的完整数据流。在本章,我们将作为“总装工程师”,为这座高性能机器装配上优雅的“车身”与智能的“驾驶舱”——即所有直接面向用户的体验优化。您将学习如何通过 无障碍(a11y) 集成,让表单服务于所有用户;如何通过 国际化(i18n) ,让验证信息跨越语言障碍;以及如何通过 焦点管理 和 防丢失保护 ,将用户体验的细节打磨到极致。本章的目标,是让您具备交付一个功能完整、体验极致、符合现代 Web 标准的专业级表单的能力。
5.1 无障碍 (a11y) 与焦点管理 一个功能强大的表单,如果用户在使用时感到困惑或遇到障碍,那它依然是失败的。本节,我们将专注于两个最能提升用户体验的细节:当表单校验失败时,智能地将用户引导至错误位置 ;以及,确保我们的表单符合 WAI-ARIA 无障碍标准 ,让所有用户都能无差别地使用。
5.1.1 痛点分析 想象一个包含多个分区的、很长的“创建项目”表单:
焦点丢失 : 用户填写完所有信息,点击“创建项目”。页面似乎没有反应,只是在某个不起眼的地方弹出了“提交失败”的提示。用户并不知道是表单顶部的“项目名称”未填写,他需要手动向上滚动、寻找那个标红的输入框,体验非常糟糕。可访问性缺失 : 对于依赖屏幕阅读器等辅助技术的视障用户,情况更糟。当校验失败时,屏幕阅读器无法得知哪个输入框是“无效”的。它只会一遍遍地读出输入框的标签,用户会彻底迷失,无法完成表单。5.1.2 核心武器:RHF 配置与 ARIA 属性 useForm 配置之 shouldFocusError: true : RHF 内置的“导航员”。当客户端验证失败时,它会自动将页面滚动并聚焦到第一个错误字段。setError 选项之 { shouldFocus: true } : 手动控制焦点的“精确制导”。当处理服务端返回的错误时,附加上这个选项,就能实现与客户端错误完全一致的自动聚焦体验。WAI-ARIA 属性之 aria-invalid : HTML 的标准无障碍属性。用于向辅助技术声明一个输入字段当前的值是否无效。5.1.3 逐步实战:打造一个体验优秀的“创建项目”表单 我们将从零开始,构建一个全新的、带体验优化的“创建项目”功能。
第一步:【后端升级】在 server.js 中实现真实的业务校验
我们必须在真实的后端层面实现业务逻辑。我们将扩展在 4.2 节创建的 server.js 文件,为其增加校验“项目名称”唯一性的功能。
文件路径 : server.js (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 const jsonServer = require ('json-server' );server.use (middlewares); server.use ((req, res, next ) => { const db = router.db ; if (req.method === 'PATCH' && req.path .startsWith ('/users/' )) { } if (req.method === 'POST' && req.path === '/projects' ) { const { projectName } = req.body ; if (projectName && projectName.toLowerCase () === 'test' ) { return res.status (400 ).json ({ field : 'projectName' , message : '项目名称 "Test" 已被系统保留,请更换 (来自服务器)' , }); } } next (); }); server.use (router); server.listen (3001 , () => { });
修改完成后,请务必重启您的 pnpm run server 进程 ,让新的后端逻辑生效。
第二步:创建 API、Schema 与 Query 层
文件路径 : db.json (添加 projects 数组)
1 2 3 4 { "users" : [ ] , "projects" : [ ] }
文件路径 : src/api/projectApi.ts (新建文件)
1 2 3 4 5 6 7 8 import axiosInstance from '@/utils/axios' ;import { type ProjectSchema } from '@/schemas/projectSchema' ;export const createProject = async (projectData : ProjectSchema ): Promise <ProjectSchema & { id : number }> => { const response = await axiosInstance.post ('/projects' , projectData); return response.data ; };
文件路径 : src/schemas/projectSchema.ts (新建文件)
1 2 3 4 5 6 7 8 import * as z from 'zod/v4' ;export const projectSchema = z.object ({ projectName : z.string ().min (5 , '项目名称至少需要5个字符' ), description : z.string ().optional (), }); export type ProjectSchema = z.infer <typeof projectSchema>;
第三步:创建集成了“焦点管理”的“胖 Hook”
文件路径 : src/hooks/useCreateProjectForm.ts (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 import { useForm } from "react-hook-form" ;import { zodResolver } from "@hookform/resolvers/zod" ;import { useMutation, useQueryClient } from "@tanstack/react-query" ;import { isAxiosError } from "axios" ;import { App as AntdApp } from "antd" ;import { projectSchema, type ProjectSchema } from "@/schemas/projectSchema" ;import { createProject } from "@/api/projectApi" ;export const useCreateProjectForm = ( ) => { const queryClient = useQueryClient (); const { message } = AntdApp .useApp (); const methods = useForm<ProjectSchema >({ resolver : zodResolver (projectSchema), shouldFocusError : true , defaultValues : { projectName : "" , description : "" , }, }); const { handleSubmit, setError, reset, formState } = methods; const createProjectMutation = useMutation ({ mutationFn : createProject, onSuccess : () => { message.success ("项目创建成功" ); queryClient.invalidateQueries ({ queryKey : ["projects" ] }); reset (); }, onError : (error ) => { if (isAxiosError (error) && error.response ?.status === 400 ) { const serverError = error.response .data as { field : "projectName" ; message : string ; }; setError ( serverError.field , { type : "error" , message : serverError.message , }, { shouldFocus : true , } ); return ; } message.error ("项目创建失败,请稍后重试。" ); }, }); const onSubmit = (data : ProjectSchema ) => { createProjectMutation.mutate (data); }; return { methods, handleSubmit : handleSubmit (onSubmit), isSubmitting : formState.isSubmitting || createProjectMutation.isPending , }; };
第四步:升级 ControlledInput 以支持 aria-invalid
这是一个一次性的、将惠及整个应用的架构级优化。
文件路径 : src/components/form/ControlledInput.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 export function ControlledInput <T extends FieldValues >({ name, label, ...rest }: ControlledInputProps <T>) { const { control } = useFormContext<T>(); return ( <Controller name ={name} control ={control} render ={({ field , fieldState }) => { const errorMessage = fieldState.error?.message; return ( <Form.Item label ={label} validateStatus ={errorMessage ? 'error ' : ''} help ={errorMessage} > {/* ✨ 核心 3: 添加 aria-invalid 属性 */} <Input {...field } {...rest } aria-invalid ={!!fieldState.error} /> </Form.Item > ); }} /> ); }
第五步:创建“瘦”的 UI 组件并添加路由
文件路径 : src/pages/CreateProjectPage.tsx (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import { FormProvider , Controller } from 'react-hook-form' ;import { useCreateProjectForm } from '@/hooks/useCreateProjectForm' ;import { Form , Button , Typography , Input as AntdInput } from 'antd' ;import { ControlledInput } from '@/components/form/ControlledInput' ;import { type ProjectSchema } from '@/schemas/projectSchema' ;const { TextArea } = AntdInput ;export default function CreateProjectPage ( ) { const { methods, handleSubmit, isSubmitting } = useCreateProjectForm (); return ( <FormProvider {...methods }> <Form onFinish ={handleSubmit} layout ="vertical" className ="max-w-xl" > <Typography.Title level ={2} > 创建一个新项目</Typography.Title > <ControlledInput<ProjectSchema > name="projectName" label="项目名称 (不允许为 'Test')" /> {/* 为了演示方便,我们直接使用 Controller 来包装 TextArea */} <Controller name ="description" control ={methods.control} render ={({ field , fieldState }) => ( <Form.Item label ="项目描述 (可选)" validateStatus ={fieldState.error ? 'error ' : ''} help ={fieldState.error?.message} > <TextArea {...field } rows ={4} /> </Form.Item > )} /> <Button type ="primary" htmlType ="submit" className ="w-full mt-6" loading ={isSubmitting} > {isSubmitting ? '创建中...' : '创建项目'} </Button > </Form > </FormProvider > ); }
文件路径 : src/router/index.tsx (添加路由)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import CreateProjectPage from '@/pages/CreateProjectPage' ;const router = createBrowserRouter ([ { path : '/' , element : <App /> , children : [ { path : 'projects/new' , element : <CreateProjectPage /> }, ], }, ]);
第六步:验证
客户端错误 : 访问 /projects/new,不填写任何内容直接点击“创建项目”。您会看到页面 自动滚动并聚焦 到“项目名称”输入框。服务端错误 : 在“项目名称”中输入 Test,然后点击“创建项目”。您会看到同样的 自动聚焦 效果,并且输入框下方显示来自 我们真实服务器 的错误信息。无障碍验证 : 使用浏览器开发者工具检查“项目名称”输入框。在出错时,您会看到 aria-invalid="true" 属性被正确添加。技术架构决策
2025-10-11 10:00
这个体验太棒了。客户端和服务端的错误,现在都有一致的、智能的聚焦行为。而且 aria-invalid 的集成如此简单。
架构师
这正是“高内聚、低耦合”架构的回报。焦点管理 的逻辑,我们将其内聚在了“胖 Hook”中,因为它是与表单的提交和验证状态紧密相关的业务逻辑。而 a11y 属性 的逻辑,我们将其内聚在了可复用的 ControlledInput 中,因为它是与 UI 展现相关的通用逻辑。
所以,当我们需要添加新功能时,应该先思考:“这个功能属于哪个‘职责领域’?” 然后将它放到最合适的模块里。
架构师
完全正确。不是把所有东西都塞进“胖 Hook”,也不是所有东西都塞进组件。让每个模块只做它最擅长的一件事,然后优雅地组合它们。这就是通往大型、可维护应用架构的康庄大道。
5.2 国际化 (i18n):让 Zod 开口说外语 5.2.1 痛点分析 我们刚刚构建的表单功能完善,但其验证逻辑与错误文案紧密耦合,这在需要支持多语言的应用中是不可接受的。
文件路径 : src/schemas/projectSchema.ts
1 2 3 4 5 6 import * as z from 'zod/v4' ;export const projectSchema = z.object ({ projectName : z.string ().min (5 , '项目名称至少需要5个字符' ), description : z.string ().optional (), });
硬编码的错误文案使得我们的 Schema 只能服务于单一语言的用户。我们需要一套机制,能够将验证逻辑与展示文案彻底分离。
5.2.2 架构核心:i18next + Zod v4 内置 Locales Zod v4 带来了强大的原生国际化支持,使其不再需要任何第三方适配库。我们的最佳实践是将其与 React 生态中最主流的 i18n 框架 i18next 相结合。
i18next : 作为应用的“语言切换引擎”,负责管理当前语言状态。Zod v4 Locales : Zod 官方提供的、开箱即用的多语言翻译文件,我们直接从 zod/locales 导入。z.config() + Event Listener : 我们将在应用启动时,使用 z.config() 为 Zod 设置初始语言环境。然后,通过监听 i18next 的 languageChanged 事件,在语言切换时动态地再次调用 z.config(),实现 Zod 语言环境的同步更新。5.2.3 逐步实战:实现 Zod 错误信息的多语言切换 我们将一步步地为我们的项目集成完整的国际化功能。
第一步:安装 i18n 相关依赖
在项目终端中,运行以下命令来添加 i18next 及其 React 适配库。
1 pnpm add i18next react-i18next
第二步:创建 i18n 配置文件
接下来,我们需要一个中心文件来配置 i18next 并建立与 Zod 的连接。
文件路径 : src/lib/i18n.ts (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import i18n from "i18next" ;import { initReactI18next } from "react-i18next" ;import * as z from "zod" ;import { en, zhCN } from "zod/locales" ;const zodLocales = { en : en, zh : zhCN, }; i18n.use (initReactI18next).init ({ resources : {}, lng : "zh" , fallbackLng : "en" , interpolation : { escapeValue : false , }, }); const currentLocale = zodLocales[i18n.language as keyof typeof zodLocales] || zhCN; z.config (currentLocale ()); i18n.on ("languageChanged" , (lng ) => { const zodLocale = zodLocales[lng as keyof typeof zodLocales] || en; z.config (zodLocale ()); console .log (`🌐 Zod 语言已同步切换至: ${lng} ` ); }); export default i18n;
第三步:在应用入口加载 i18n 配置
为了让上述配置在应用启动时生效,我们只需在 main.tsx 中导入它。
文件路径 : src/main.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import React from "react" ;import ReactDOM from "react-dom/client" ;import { RouterProvider } from "react-router-dom" ;import { ConfigProvider , App as AntdApp } from "antd" ;import zhCN from "antd/locale/zh_CN" ;import router from "@/router" ;import "./index.css" ;import { QueryClientProvider } from "@tanstack/react-query" ;import { queryClient } from "@/lib/queryClient" ;import { ReactQueryDevtools } from "@tanstack/react-query-devtools" ;import "./lib/i18n" ;ReactDOM .createRoot (document .getElementById ("root" )!).render ( <React.StrictMode > <QueryClientProvider client ={queryClient} > <ConfigProvider locale ={zhCN} > <AntdApp > <RouterProvider router ={router} /> </AntdApp > </ConfigProvider > <ReactQueryDevtools initialIsOpen ={false} /> </QueryClientProvider > </React.StrictMode > );
第四步:更新 Schema 以移除硬编码文案
现在全局 i18n 机制已经建立,我们可以清理 Schema,让它回归验证逻辑的纯粹性。
文件路径 : src/schemas/projectSchema.ts (修改)
1 2 3 4 5 6 7 8 9 import * as z from 'zod/v4' ;export const projectSchema = z.object ({ projectName : z.string ().min (5 ), description : z.string ().optional (), }); export type ProjectSchema = z.infer <typeof projectSchema>;
第五步:创建语言切换 UI 组件
为了让用户能动态切换语言,我们来创建一个 LanguageSwitcher 组件。
文件路径 : src/components/LanguageSwitcher.tsx (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { useTranslation } from 'react-i18next' ;import { Radio , RadioChangeEvent , Tooltip } from 'antd' ;import { GlobalOutlined } from '@ant-design/icons' ;export const LanguageSwitcher = ( ) => { const { i18n } = useTranslation (); const handleLangChange = (e : RadioChangeEvent ) => { const lang = e.target .value ; i18n.changeLanguage (lang); }; return ( <div style ={{ position: 'fixed ', top: 16 , right: 24 , zIndex: 999 }}> <Tooltip title ="切换语言 / Switch Language" > <Radio.Group value ={i18n.language} onChange ={handleLangChange} buttonStyle ="solid" > <Radio.Button value ="zh" > <GlobalOutlined /> 中文</Radio.Button > <Radio.Button value ="en" > <GlobalOutlined /> EN</Radio.Button > </Radio.Group > </Tooltip > </div > ); };
第六步:集成组件并验证
最后,我们将 LanguageSwitcher 添加到主布局中,来测试完整的国际化流程。
文件路径 : src/App.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { Layout } from 'antd' ;import { Outlet } from 'react-router-dom' ;import { LanguageSwitcher } from './components/LanguageSwitcher' ;const { Header , Content } = Layout ;export default function App ( ) { return ( <Layout > <Header className ="flex items-center" > <div className ="demo-logo" /> </Header > <LanguageSwitcher /> <Content className ="p-6" > <div className ="p-6 bg-white rounded-md" > <Outlet /> </div > </Content > </Layout > ); }
验证流程 :
启动应用,访问 /projects/new 页面。 确保右上角的语言切换器在“中文”位置。 不填写任何内容,直接点击“创建项目”。观察 : “项目名称”输入框下方显示中文 错误信息:“字符串最少应包含 5 个字符”。 点击右上角的语言切换器,切换到“EN”。 再次点击“创建项目”按钮。观察 : 错误信息 自动、动态地 变为英文 :“String must contain at least 5 character(s)”。 5.3 终极守护:使用 isDirty 防止意外离开 5.3.1 痛点分析 想象一个真实场景:用户正在我们精心打造的 CreateProjectPage 表单上,花费了数分钟填写复杂的项目名称和详尽的项目描述。就在他即将点击“创建”按钮之前,不小心按下了浏览器的后退按钮,或者点击了导航栏的其他链接。
结果是灾难性的 :页面立即跳转,所有未保存的输入都将丢失,没有任何提示。这种糟糕的体验会极大地打击用户的使用信心,对于企业级应用来说是不可接受的。
我们需要一个“安全网”,在用户尝试离开一个有未保存更改的页面时,及时给予提醒,并让他做出最终确认。
formState.isDirty : 这是 React Hook Form 提供的一个极其有用的布尔状态。
当表单的当前值与 defaultValues (或最后一次 reset 后的值) 完全相同时,isDirty 为 false。 一旦用户修改了任何一个字段,isDirty 就会 立即变为 true 。 当表单被成功提交并 reset 后,它会再次变回 false。isDirty 是我们判断“是否有未保存的更改”的最准确、最可靠的信号源。 useBlocker (from react-router-dom) : 这是 React Router v6.4+ 提供的官方 Hook,用于在用户尝试离开当前路由时进行拦截。它是现代 React 应用中处理导航拦截的最佳实践。
我们的策略 :创建一个可复用的 useUnsavedChangesWarning Hook。它将接收 isDirty 状态作为输入,并在内部使用 useBlocker 来决定是否需要弹出确认框来“阻止”用户离开。
5.3.3 逐步实战:构建“离开前确认”功能 第一步:创建 useUnsavedChangesWarning Hook
这个 Hook 将是所有“防丢失”逻辑的“大脑”,它高内聚地封装了拦截、弹窗、确认和取消的所有行为。
文件路径 : src/hooks/useUnsavedChangesWarning.ts (新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import { useEffect } from 'react' ;import { useBlocker } from 'react-router-dom' ;import { Modal } from 'antd' ;export const useUnsavedChangesWarning = (isDirty : boolean ) => { const blocker = useBlocker (isDirty); useEffect (() => { if (blocker.state === 'blocked' ) { Modal .confirm ({ title : '您有未保存的更改' , content : '您确定要离开此页面吗?所有未保存的输入都将丢失。' , okText : '确认离开' , cancelText : '留在本页' , onOk : () => { blocker.proceed ?.(); }, onCancel : () => { blocker.reset ?.(); }, }); } }, [blocker]); };
第二步:增强 useCreateProjectForm Hook
为了让我们的页面组件能够知道表单是否“脏”,我们需要让“胖 Hook” useCreateProjectForm 将 isDirty 状态暴露出来。
文件路径 : src/hooks/useCreateProjectForm.ts (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 export const useCreateProjectForm = ( ) => { const methods = useForm<ProjectSchema >({ }); const { handleSubmit, setError, reset, formState : { isDirty } } = methods; const createProjectMutation = useMutation ({ }); const onSubmit = (data : ProjectSchema ) => { }; return { methods, handleSubmit : handleSubmit (onSubmit), isSubmitting : createProjectMutation.isPending , isDirty, }; };
第三步:在“瘦”组件中集成
现在,为我们的 CreateProjectPage 页面装上这个强大的“安全气囊”,只需要两行代码。这完美地体现了“胖 Hooks,瘦组件”模式的威力。
文件路径 : src/pages/CreateProjectPage.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { FormProvider , Controller } from 'react-hook-form' ;import { useCreateProjectForm } from '@/hooks/useCreateProjectForm' ;import { useUnsavedChangesWarning } from '@/hooks/useUnsavedChangesWarning' ;import { Form , Button , Typography , Input as AntdInput } from 'antd' ;export default function CreateProjectPage ( ) { const { methods, handleSubmit, isSubmitting, isDirty } = useCreateProjectForm (); useUnsavedChangesWarning (isDirty); return ( <FormProvider {...methods }> <Form onFinish ={handleSubmit} layout ="vertical" className ="max-w-xl" > {/* ... (表单 UI 保持不变) */} </Form > </FormProvider > ); }
第四步:验证
启动应用,访问 /projects/new 页面。 测试场景一 (表单干净时) : 立即点击导航栏的其他链接(例如,我们之前创建的 /users 页面链接)。页面应该会 正常跳转 ,没有任何提示。返回 /projects/new 页面。 测试场景二 (表单变脏时) : 在“项目名称”输入框中输入任意字符。此刻 isDirty 变为 true。再次点击导航栏的 /users 链接。观察 : 页面跳转被 阻止 ,并弹出一个 Ant Design 的确认对话框,提示“您有未保存的更改”。 点击对话框中的 “留在本页” 。观察 : 对话框关闭,您仍然停留在 /projects/new 页面,输入的内容保持不变。 再次点击 /users 链接,在弹出的对话框中点击 “确认离开” 。观察 : 对话框关闭,页面成功跳转到 /users。 测试场景三 (提交后) : 返回 /projects/new,填写表单并成功提交。由于我们的 Hook 在成功后会自动调用 reset(),isDirty 会变回 false。此时再点击其他链接,页面会 正常跳转 ,不再弹出提示。