第十三章:2025 前端React表单开发新标准:React Hook Form v7 集成 Zod v4,搞定类型校验与错误处理

序章: 范式革命与工程基石——搭建现代化表单开发环境

摘要: 在本章中,我们将首先从 理论层面 出发,颠覆您对 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
// 一个典型的 useState 受控表单输入框
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
# 1. 使用 Vite 创建一个 React + TypeScript 项目
pnpm create vite react-hook-form-practice --template react-ts

# 2. 进入项目目录
cd react-hook-form-practice

# 3. 一次性安装所有核心依赖
# react-hook-form: 核心库
# zod: 验证与类型库
# @hookform/resolvers: 连接 RHF 与 Zod 的桥梁
# antd: UI 组件库
# react-router-dom: 路由库
pnpm add react-hook-form zod @hookform/resolvers antd react-router-dom

# 4. 安装必要的开发依赖
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 (清空后)

1
@import "tailwindcss";

文件路径: 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,

/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",

/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,

/* Path alias */
"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
# src/
├── api/ # (可选) API 请求函数
├── App.tsx # 应用根组件与布局
├── assets/ # 静态资源
├── components/ # 通用组件
│ └── form/ # 封装的表单原子组件 (如 Input, Select)
├── index.css # 全局样式
├── main.tsx # 应用入口文件
├── pages/ # 页面级组件
│ └── SimpleForm.tsx # 我们的第一个表单页面
├── router/ # 路由配置
│ └── index.tsx
└── schemas/ # Zod Schema 定义
└── 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
// 严格遵循官方文档,从 "/v4" 子路径导入 Zod 4
import * as z from "zod/v4";

// 1. 定义 Zod Schema
export const userSchema = z.object({
username: z.string().min(2, "用户名至少需要 2 个字符"),
// Zod 4 推荐使用顶层函数 z.email()
// 但为保持链式调用习惯,z.string().email() 依然可用
email: z.string().email("请输入有效的邮箱地址"),
});

// 2. 使用 z.infer 推导出 TypeScript 类型
export type UserSchema = z.infer<typeof userSchema>;

第二步:创建表单页面 (核心 API 整合)

有了验证模式,我们现在开始构建 React 组件。我们将逐步拆解 useForm 这个核心 Hook 返回的 API,理解它们各自的职责以及如何整合。

初始化表单核心:useForm

一切的起点是 useForm hook。它负责创建表单的管理实例,我们通过泛型传入 UserSchema 来启用完整的 TypeScript 类型支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/pages/SimpleForm.tsx (片段 1)

import { useForm, type SubmitHandler } from "react-hook-form";
import { userSchema, type UserSchema } from "@/schemas/userSchema";

export default function SimpleForm() {
  // 初始化 useForm,并传入 TypeScript 类型
  const {
    // ... 稍后我们将从这里解构出所需函数
  } = useForm<UserSchema>();

  // ... JSX
}

集成验证逻辑:resolver

为了让 RHF 使用 Zod 进行验证,我们需要配置 resolver 选项。zodResolver 是官方提供的适配器,用于将 Zod Schema 集成到 RHF 的验证流程中。

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/pages/SimpleForm.tsx (片段 2)

import { zodResolver } from "@hookform/resolvers/zod";
// ...其他 imports

export default function SimpleForm() {
  const {
    // ...
  } = useForm<UserSchema>({
    resolver: zodResolver(userSchema), // 👈 配置 Zod 解析器
  });
  // ...
}

字段注册: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
// src/pages/SimpleForm.tsx (片段 3)

export default function SimpleForm() {
  const {
    register, // 👈 解构出 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
// src/pages/SimpleForm.tsx (片段 4)

export default function SimpleForm() {
  const {
    register,
    handleSubmit, // 👈 解构出提交处理器
    formState: { errors }, // 👈 解构出错误状态对象
  } = useForm<UserSchema>({ /* ...配置 */ });

  // 定义一个仅在验证成功时才会执行的回调函数
  const onSubmit: SubmitHandler<UserSchema> = (data) => {
    alert(`提交成功: ${JSON.stringify(data)}`);
  };

  return (
// 👇 将我们的成功回调传递给 handleSubmit
    <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() {
// 1.使用useForm创建表单
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserSchema>({
resolver: zodResolver(userSchema), // 2.通过zodResolver将Zod Schema转换为React Hook Form的Resolver
});

// 3.定义onSubmit函数
const onSubmit: SubmitHandler<UserSchema> = (data) => {
alert(`提交成功: ${JSON.stringify(data)}`);
};

return (
// 4.创建原生HTML表单,通过绑定事件触发表单验证
<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 带来了“非受控组件的文艺复兴”。

性能剖析:一次按键引发的“渲染风暴”

为了直观感受性能差异,我们创建一个包含多个输入框的表单,并引入一个简单的日志组件来监控渲染行为。

痛点背景: 这是最符合 React 直觉的写法,但也是性能陷阱的开始。

文件路径: src/pages/ControlledForm.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
import { useState } from 'react';

// 一个简单的日志组件,它的渲染就代表着父组件的渲染
const RenderLogger = ({ componentName }: { componentName: string }) => {
console.log(`🌀 ${componentName} component is re-rendering...`);
return null;
};

export default function ControlledForm() {
const [values, setValues] = useState({
firstName: '',
lastName: '',
});

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValues({
...values,
[e.target.name]: e.target.value,
});
};

return (
<form className="space-y-4 max-w-md">
<RenderLogger componentName="ControlledForm" />
<h2 className="text-xl font-bold">Traditional Controlled Form</h2>
<div>
<label htmlFor="firstName-controlled" className="block mb-1">First Name</label>
<input
id="firstName-controlled"
name="firstName"
onChange={handleChange}
value={values.firstName}
className="block w-full p-2 border border-gray-300 rounded"
/>
</div>
<div>
<label htmlFor="lastName-controlled" className="block mb-1">Last Name</label>
<input
id="lastName-controlled"
name="lastName"
onChange={handleChange}
value={values.lastName}
className="block w-full p-2 border border-gray-300 rounded"
/>
</div>
</form>
);
}

运行分析: 打开浏览器控制台,当您在任何一个输入框中 每输入一个字符,都会看到 🌀 ControlledForm 组件正在重新渲染.... 的日志输出。这意味着整个 ControlledForm 组件都在进行重渲染,仅仅是为了更新一个输入框的值。这正是性能浪费的根源。

img

RHF 的解决方案: RHF 将状态的管理权从 React State 交还给了 DOM 自身,通过 ref 直接进行交互。

文件路径: src/pages/UncontrolledForm.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
import { useForm } from "react-hook-form";

const RenderLogger = ({ componentName }: { componentName: string }) => {
console.log(`🌀 ${componentName} 组件正在重新渲染...`);
return null;
};

export default function UncontrolledForm() {
const { register } = useForm();

return (
<form className="space-y-4 max-w-md">
<RenderLogger componentName="UncontrolledForm" />
<h2 className="text-xl font-bold">RHF 非受控组件</h2>
<div>
<label htmlFor="firstName-uncontrolled" className="block mb-1">
First Name
</label>
<input
id="firstName-uncontrolled"
{...register("firstName")}
className="block w-full p-2 border border-gray-300 rounded"
/>
</div>
<div>
<label htmlFor="lastName-uncontrolled" className="block mb-1">
Last Name
</label>
<input
id="lastName-uncontrolled"
{...register("lastName")}
className="block w-full p-2 border border-gray-300 rounded"
/>
</div>
</form>
);
}

运行分析: 同样打开控制台,现在无论您在哪个输入框里输入,控制台都 没有任何日志输出。UncontrolledForm 组件从始至终只渲染了一次。RHF 通过 register 函数,利用 ref 直接与 DOM 交互,完全绕过了导致性能问题的 setState 渲染周期

img

心智模型转译:从“数据驱动”到“所有权转移”

深入探讨
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() {
// 1.使用useForm创建表单
const {
control,
handleSubmit,
formState: { errors },
} = useForm<UserSchema>({
resolver: zodResolver(userSchema), // 2.通过zodResolver将Zod Schema转换为React Hook Form的Resolver
mode: "onChange", // 实时验证
defaultValues: {
username: "",
email: "",
},
});

// 3.定义onSubmit函数
const onSubmit: SubmitHandler<UserSchema> = (data) => {
alert(`提交成功: ${JSON.stringify(data)}`);
};

return (
// 使用 Antd 的 Form 组件,并通过 onFinish 绑定 handleSubmit
<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 组件,就完成了无缝集成。

![img](file:///C:\Users\Prorise\AppData\Local\PixPin\Temp\PixPin_2025-10-09_11-45-47.webp)


本节小结

  • 性能核心: 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';

// 使用 z.object() 定义一个对象 Schema
// 所有属性默认都是必须的
export const userSchema = z.object({
username: z.string().min(2, "用户名至少需要 2 个字符"),
email: z.string().email("请输入有效的邮箱地址"),
// 使用 .optional() 可以让一个字段变为可选
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
// ...接上文

// z.infer 会读取 userSchema 的结构和规则,并生成一个 TypeScript 类型
export type UserSchema = z.infer<typeof userSchema>;

/*
悬停在 UserSchema 类型上,你会看到 VSCode 提示它等价于:
type UserSchema = {
username: string;
email: string;
age?: number | undefined; // 因为我们用了 .optional()
};
*/

第三步:在应用中全局复用

现在,我们拥有了两个紧密关联的“产物”:

  • userSchema: 一个 运行时验证器,可以解析和验证数据。
  • UserSchema: 一个 编译时 TypeScript 类型,提供代码提示和静态检查。

它们共同构成了关于用户数据的 单一事实来源 ,我们可以在应用的任何地方安全、一致地使用它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 在 React Hook Form 中使用
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userSchema, type UserSchema } from '@/schemas/userSchema';

function UserForm() {
// 1. 将类型传递给 useForm 泛型,获得完整的类型提示
// 2. 将验证器传递给 zodResolver
const form = useForm<UserSchema>({
resolver: zodResolver(userSchema),
});
// ...
}

// 在 API 调用函数中使用
async function updateUser(data: UserSchema) {
// 函数参数的类型得到了保证
// ...
}

这种模式彻底消除了手动维护类型与验证规则同步的可能,极大地提升了项目的健壮性。


第二章: 核心实现与 UI 集成

摘要: 在本章中,我们将从理论转向实践,深入剖析 React Hook Form 的“大脑”——useForm Hook。我们将系统学习其核心配置项,解构 handleSubmit 的工作机制,并最终将 RHF 的强大能力与我们熟悉的 Ant Design 组件库完美结合。本章的目标是,让您具备独立构建一个功能完备、体验良好、且与主流 UI 库无缝集成的表单的能力。


2.1. useForm 深度解析:表单的“大脑”

在前面的章节中,我们已经多次与 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 或你指定的任意值。

整合:一个完整的 useForm 实例

现在,让我们将“输入”和“输出”结合起来,看一个集成了这些配置和 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() {
// `useForm` 同时接收配置(输入)并返回 API(输出)
const {
// --- 输出:我们从中解构出的核心 API ---

// API 1: 用于连接第三方 UI 组件的 `control` 对象
control,

// API 2: 负责处理验证和提交的 `handleSubmit` 函数
handleSubmit,

// API 3: 包含错误信息、是否修改等状态的 `formState`
formState: { errors, isDirty, isValid },

// API 4: 用于监听字段变化的 `watch` 函数
watch,

// API 5: 用于重置表单的 `reset` 函数
reset,

} = useForm<UserSchema>({
// --- 输入:我们传入的配置对象 ---

// 配置 1: 指定 Zod 作为验证器
resolver: zodResolver(userSchema),

// 配置 2: 设置验证在字段失去焦点时触发
mode: 'onBlur',

// 配置 3: 提供完整的默认值结构,以获得最佳的类型推断和重置行为
defaultValues: {
username: '',
email: '',
},
});

// ... 在下面的 JSX 中,我们就可以使用上面解构出的 control, handleSubmit, errors 等 API 了
}

通过这个结构,我们清晰地看到 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 的高阶函数,它像一个尽职尽责的“守门员”,在数据真正提交给后端之前,处理了所有验证的脏活累活。

它的工作流程非常清晰:

  1. 你提供一个处理业务逻辑的函数(例如 onValid)。
  2. handleSubmit 会返回一个 新函数,我们将这个新函数绑定到 <form> 的 onSubmit。
  3. 当用户点击提交,handleSubmit 会首先运行 Zod 验证。
  4. 只有在 验证通过 的情况下,它才会调用你的 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";
// ... 其他 imports

export default function SimpleAntdForm() {
  const { control, handleSubmit, formState: { errors } } = useForm<UserSchema>({
    // ... 配置
  });

  // 这个函数只关心一件事:当数据有效时,该做什么
  const onValid: SubmitHandler<UserSchema> = (data) => {
    alert(`提交成功: ${JSON.stringify(data)}`);
    // 在这里调用 API
  };

  // 可选的第二个回调,处理验证失败
  const onInvalid = () => {
    console.log("表单验证失败!");
  };

  return (
    // 👇 Ant Design 的 Form 使用 `onFinish` 属性来处理提交
    <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

第二步:实现 ControlledInput 组件

这个组件将作为 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
// src/components/form/ControlledInput.tsx
import {
Controller,
useFormContext,
type FieldValues,
type Path,
} from "react-hook-form";
import { Form, Input, type InputProps } from "antd";

// 使用泛型 T 来确保 name 属性的类型安全
type ControlledInputProps<T extends FieldValues> = {
name: Path<T>; // `Path<T>` 确保 name 是 T 类型中的一个合法 key
label: string;
} & InputProps; // 继承 antd Input 的所有原生属性,如 placeholder

export function ControlledInput<T extends FieldValues>({
name,
label,
...rest // 剩余的 props (如 placeholder) 将直接传递给 antd Input
}: ControlledInputProps<T>) {
// 从 FormProvider 获取 control
const { control } = useFormContext<T>();

return (
// Controller 是连接 RHF 内核与 UI 组件的核心
<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() {
// `useForm` 返回的方法和状态可以打包传递给 FormProvider
const methods = useForm<UserSchema>({
resolver: zodResolver(userSchema),
mode: "onChange",
defaultValues: { username: "", email: "" },
});

const onSubmit: SubmitHandler<UserSchema> = (data) => {
alert(`提交成功: ${JSON.stringify(data)}`);
};

return (
// 👇 用 FormProvider 包裹,使得 useFormContext 能在深层组件中被访问
<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 位"),
})
// 👇 在 object schema 的末尾链接 .refine()
.refine(
// 第一个参数是验证函数,它接收整个对象作为输入
(data) => data.password === data.confirmPassword,
// 第二个参数是配置对象,用于定义错误信息
{
message: "两次输入的密码不一致",
// `path` 指定这个错误应该显示在哪个字段下面
path: ["confirmPassword"],
}
);

export type RegisterSchema = z.infer<typeof registerSchema>;

通过这种方式,我们建立了一个依赖关系:confirmPassword 字段的最终有效性,取决于 password 字段的值。当验证失败时,错误信息会精准地附加到 confirmPassword 字段上。


2. 异步验证:与服务器对话

另一个常见的复杂场景是需要与后端通信才能完成的验证,最典型的就是“检查用户名或邮箱是否已被注册”。Zod 的 .refine() 同样支持异步函数,这让实现服务端验证变得异常简单。

与其在代码里模拟请求,不如我们来搭建一个真实的 Mock 后端服务。我们将使用在序章中安装的 json-server 来完成这个任务。

第一步:搭建 Mock API 服务器

  1. 创建数据文件: 在你的项目 根目录 (与 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" }
    ]
    }
  2. 添加启动脚本: 打开 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 的默认端口冲突。

  3. 启动服务: 打开 一个新的终端窗口 (不要关闭你正在运行 Vite 的终端),然后运行以下命令:

    1
    pnpm run server

    如果一切顺利,你会看到 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
/**
* 检查用户名是否可用
* @param username 要检查的用户名
* @returns 如果用户名可用,则返回 true;否则返回 false。
*/
export const checkUsernameAvailability = async (username: string): Promise<boolean> => {
try {
// json-server 支持通过查询参数进行过滤
const response = await fetch(`http://localhost:3001/users?username=${username}`);
const users = await response.json();

// 如果返回的数组长度为 0,说明该用户名未被注册,可用
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';
// 👇 导入我们封装的 API 函数
import { checkUsernameAvailability } from '@/api/userApi';

export const userSchema = z.object({
// 👇 在单个字段上使用 .refine() 并传入一个异步函数
username: z.string()
.min(3, "用户名至少 3 个字符")
.refine(async (username) => {
// 👇 等待真实 API 调用的结果
return await checkUsernameAvailability(username);
}, "该用户名已被占用"), // 如果异步函数返回 false,则显示此错误信息

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, "活动名称不能为空"),

// 场景 1: 将输入框的字符串 "25" 转换为数字 25
age: z.coerce.number({
error: '请输入有效的年龄'
}).int("年龄必须是整数").min(18, "参与者必须年满18岁"),

// 场景 2: 将 <input type="date"> 返回的 "2025-10-09" 字符串转换为 Date 对象
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", // 字符串,因为 input 输入的是字符串
eventDate: new Date().toISOString().split("T")[0], // string 格式
},
});

// 👇 注意这里的 `data` 参数,其类型是 EventSchema(Zod 转换后的类型)
// Zod 已经为我们处理好了类型转换
const onSubmit = async (data: any) => {
// zodResolver 已经验证并转换了数据
// data 的实际类型是 EventSchema
const validatedData = data as EventSchema;

console.log("表单提交的数据:", validatedData);

// 你可以验证 validatedData 中 age 和 eventDate 的类型
console.log("年龄的类型:", typeof validatedData.age); // "number"
console.log("活动日期的实例:", validatedData.eventDate instanceof Date); // true

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';

// 1. 先定义最小单元:单个成员的 Schema
const memberSchema = z.object({
name: z.string().min(1, '成员姓名不能为空'),
role: z.string().min(1, '成员角色不能为空'),
});

// 2. 再定义整个表单的 Schema,其中 'members' 是一个包含 memberSchema 对象的数组
export const teamSchema = z.object({
teamName: z.string().min(1, "团队名称不能为空"),
members: z.array(memberSchema).min(1, "至少需要一名团队成员"), // 👈 核心:定义一个数组字段
});

// 3. 依旧使用 z.infer 推导出 TypeScript 类型
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() {
// 1. 初始化 useForm,提供 resolver 和 defaultValues
const methods = useForm<TeamSchema>({
resolver: zodResolver(teamSchema),
defaultValues: {
teamName: "精英团队",
members: [{ name: "张三", role: "前端开发" }], // 👈 为动态数组提供初始值
},
});

// 2. 初始化 useFieldArray
const { fields, append, remove } = useFieldArray({
control: methods.control, // 👈 必须传入 control
name: "members", // 👈 指定要操作的数组字段名
});

const onSubmit = (data: TeamSchema) => {
alert(`提交成功: \n${JSON.stringify(data, null, 2)}`);
};

// ... 稍后填充 JSX
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
// ... 在 Form 组件内部 ...
<Typography.Title level={2}>动态团队管理</Typography.Title>
{/* ... 渲染 teamName 输入框 ... */}

{/* 👇 核心:遍历 `fields` 数组来渲染每个动态表单项 */}
{fields.map((field, index) => (
// 👇 关键:key 必须使用 field.id,而不是 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
// ... 在 fields.map 内部,为每个成员添加删除按钮 ...
<div key={field.id} className="flex items-center space-x-2 mb-4">
{/* ... input 字段 ... */}
<Button type="primary" danger onClick={() => remove(index)}>
删除
</Button>
</div>

// ... 在 fields.map 外部,添加一个用于增加成员的按钮 ...
<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 内部使用,从而优雅地处理复杂层级。

实战场景:构建一个问卷,每个问题下有多个可动态增删的选项。

image-20251010092327217

第一步:定义嵌套 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>();

// 👇 内层的 useFieldArray
const { fields, append, remove } = useFieldArray({
control,
// 关键点:name 必须通过模板字符串构造出指向嵌套数组的完整路径
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>({ /* ... resolver and defaultValues ... */ });

// 👇 外层的 useFieldArray,用于管理 'questions'
const { fields, append, remove } = useFieldArray({
control: methods.control,
name: 'questions',
});

// ... onSubmit function ...

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
// ... imports ...

export default function DynamicForm() {
const methods = useForm<TeamSchema>({ /* ... */ });

// 👇 在 useFieldArray 中解构出 move API
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 内部 ...
{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 的核心特性——正是因为它,我们才能轻松实现声明式的条件渲染。

实战场景:构建一个反馈表单,当用户在下拉框中选择“其他”时,动态显示一个“请输入具体原因”的输入框。

img

第一步:定义支持条件验证的 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 是可选的
otherReason: z.string().optional(),
})
.superRefine((data, ctx) => {
if (
data.feedbackType === "other" &&
(!data.otherReason || data.otherReason.length < 1)
) {
// 如果选择了 'other',但 otherReason 为空,则在 otherReason 字段上添加一个错误
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: "",
},
});

// 👇 关键:调用 watch 并传入字段名来订阅它的值
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()无 (命令式,一次性读取)在 事件回调 中获取当前值极低,无重渲染

实战场景:在一个复杂的表单中,提供一个“在控制台打印当前值”的调试按钮,我们不希望用户的任何输入都导致整个表单重绘。

image-20251010101142258

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 在你的表单组件中
const { getValues } = useForm();

// 一个不会触发父组件重渲染的事件处理器
const handleDebug = () => {
// 调用 getValues() 获取所有字段的当前值
const currentValues = getValues();
// 或者 getValues('fieldName') 来获取单个字段的值
console.log('Current form values:', currentValues);
alert('请打开控制台查看当前表单值');
};

return (
<Form>
{/* ... 大量输入框 ... */}
<Button onClick={handleDebug}>打印当前值到控制台</Button>
</Form>
)

在这个例子中,无论用户在输入框中如何操作,组件都不会因为调试功能而重渲染。只有当用户点击按钮时,getValues 才会执行一次数据读取操作。

3.2.3. 精准订阅:useFormState

我们已经知道 watch 会重渲染整个组件。但如果某个 UI 的更新,只依赖于表单的 元数据状态 (比如 isValid, isDirty),而不是具体某个字段的值呢?让整个大表单为了一个按钮的 disabled 状态而重渲染,显然也是不划算的。

useFormState 正是为此而生的性能优化利器。它允许你 精准地订阅你关心的表单状态,并将重渲染的范围隔离到最小。

实战场景:一个提交按钮,只有在表单被修改过 (isDirty) 并且所有字段都通过验证 (isValid) 时才可点击。我们希望只有这个按钮本身重渲染,而不是整个表单。

第一步:将依赖状态的 UI 隔离成子组件

这是实现性能优化的关键前提:将需要根据状态变化而更新的 UI(这里是提交按钮)封装成一个独立的子组件。

第二步:在子组件中使用 useFormState

文件路径: 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>; // 必须接收 control 来连接到父表单
}

export function SmartSubmitButton<T extends FieldValues>({ control }: SmartSubmitButtonProps<T>) {
// 👇 关键:精准订阅 isDirty 和 isValid 状态
const { isDirty, isValid } = useFormState({
control,
// 你可以在这里列出更多你想订阅的状态,如 errors, isSubmitting 等
});

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。你会观察到:

  1. 当你在输入框中输入内容时,isDirty 和 isValid 状态会变化。
  2. 只有 ✅ SmartSubmitButton is re-rendering... 的日志被打印。
  3. 主表单组件的 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。它由两个部分组成:

  1. <FormProvider>: 一个提供者(Provider)组件,我们将 useForm 返回的整个实例“广播”出去。
  2. 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() {
// ControlledInput 内部已经使用 useFormContext 获取 control,无需传递
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() {
// ControlledInput 内部已经使用 useFormContext 获取 control,无需传递
return (
<Card title="联系方式" className="mt-4">
<ControlledInput<ProfileSchema> name="contactInfo.email" label="邮箱" />
<ControlledInput<ProfileSchema>
name="contactInfo.phone"
label="电话(可选)"
/>
</Card>
);
}

第三步:在顶层组件中使用 FormProvider

现在,我们在主表单组件中调用 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() {
// 1. 在顶层调用 useForm,获取所有方法和状态
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 (
// 2. 👇 将 methods 实例通过 spread 传给 FormProvider
<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
# 安装 TanStack Query 核心库、开发工具,以及 axios 用于网络请求
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";

// 创建 QueryClient 实例并导出,供整个应用使用
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 在这里可以设置全局默认配置,例如 staleTime, gcTime 等
staleTime: 1000 * 60, // 1 分钟内数据被认为是新鲜的
},
},
});

第三步:在应用入口处提供 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";

// 👇 1. 导入 QueryClientProvider 和我们创建的 queryClient 实例
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
// 2. 导入 React Query 开发工具
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({
// 这里的 `user` 在首次渲染时是 `undefined`!
defaultValues: user,
});

这种方式注定会失败。因为 useQuery 是异步的,在组件首次渲染时,user 的值是 undefined。useForm 只会在其 首次初始化时 读取一次 defaultValues。后续当 user 数据从服务器返回时,useForm 不会再响应这个变化,导致表单永远是空的。

架构核心:“先挂载,后填充”的水合模式

要解决这个问题,我们必须遵循一个清晰的架构模式:

  1. 独立初始化: useForm 应使用静态的、结构完整的默认值(例如空字符串)进行 同步初始化。
  2. 异步获取: useQuery 负责独立地、异步地从服务器获取数据。
  3. 事后填充 (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', // 对应 json-server 的地址
});

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"; // 复用已有的 Zod 类型

export const getUserById = async (id: number): Promise<User> => {
const response = await axiosInstance.get(`/users/${id}`);
// 注意:json-server 的数据字段可能与我们的 schema 不完全匹配,需要转换
// 这里我们的 db.json 的字段是 { id, username, email },与 schema 一定要保持一致
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";

// 获取所有用户的 Query Options
export const usersQueryOptions = queryOptions({
queryKey: ["users"],
queryFn: getUsers,
});

// 根据 ID 获取单个用户的动态 Query Options
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
// ... 其他 imports
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,然后点击“编辑”来体验整个流程。

img


4.2 状态协同 II:useMutation 与服务端验证

业务场景与痛点分析

我们已经使用 Zod 实现了强大的 客户端验证(例如,邮箱格式是否正确)。但是,有很多验证逻辑 只能在服务器端执行。最典型的例子就是 唯一性校验:当用户修改邮箱时,只有后端数据库才知道这个新邮箱是否已经被其他用户注册了。

痛点: 当后端校验失败时,我们不能只给用户一个模糊的“提交失败”提示。一个优秀的用户体验,要求我们将后端返回的错误信息(例如“此邮箱已被占用”)精准地显示在对应的输入框下方,其展现形式应与 Zod 客户端验证错误完全一致,形成统一的反馈闭环。

架构核心:useMutation + setError API

要实现这个目标,我们需要搭建一座桥梁,连接 TanStack Query 的“变更层”和 React Hook Form 的“状态层”。

  1. useMutation: 我们将使用它来处理所有“写”操作(如 POST, PATCH, DELETE)。它的 onError 回调函数是捕获后端错误的完美时机。
  2. 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); // ✨ 添加 body parser 以正确解析 JSON 请求体

// 自定义中间件,用于在 PATCH /users/: id 时进行唯一性校验
server.use((req, res, next) => {
if (req.method === "PATCH" && req.path.startsWith("/users/")) {
const db = router.db; // 获取 lowdb 实例
const updatedUser = req.body;
const userIdToUpdate = parseInt(req.path.split("/")[2], 10);

// 添加空值检查
if (!updatedUser) {
return res.status(400).json({
message: "请求体不能为空",
});
}

// 检查提交的 email 是否已被其他用户占用
const existingUser = db
.get("users")
.find(
(user) => user.email === updatedUser.email && user.id !== userIdToUpdate
)
.value();

if (existingUser) {
// 如果 email 已存在,返回 400 错误和特定格式的 JSON
return res.status(400).json({
field: "email",
message: "此邮箱已被其他用户占用 (来自服务器的验证)",
});
}
}
// 如果没有冲突,或者不是我们关心的请求,则继续执行 json-server 的默认行为
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
// ... (保留 getUsers, getUserById)
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";

// 自定义 Hook 接收 RHF 的 setError 方法和成功回调作为参数
export const useUpdateUser = (
setError: UseFormSetError<UserSchema>,
onSuccess?: () => void // 添加可选的成功回调
) => {
const queryClient = useQueryClient();
const { message } = AntdApp.useApp();

return useMutation({
mutationFn: updateUser,
onSuccess: () => {
message.success("用户资料更新成功!");
// 让 ['users'] 相关的查询失效,自动刷新列表页和详情页
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 方法,将后端错误映射到 RHF 的表单状态中
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"; // 👈 1. 导入新创建的 Hook

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: "",
},
});

// 2. 从 RHF 实例中解构出 setError
const { setError, handleSubmit } = methods;

// 3. ✨ 调用自定义 Hook,注入 setError 和成功后的导航回调
const updateUserMutation = useUpdateUser(setError, () => {
navigate("/users"); // 只在成功时才导航
});

useEffect(() => {
if (user) {
methods.reset(user);
}
}, [user, methods.reset]);

const onSubmit = (data: UserSchema) => {
// 不再立即导航,而是等待 mutation 成功后由回调处理
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>
);
}

第四步:验证

  1. 启动应用,确保 pnpm run dev 和 pnpm run server 都在运行。
  2. 进入 /users 列表,随便找一个用户(比如 test,邮箱 test@example.com)。
  3. 进入另一个用户(比如 admin)的编辑页。
  4. 将其邮箱修改为 test@example.com,然后点击“保存更改”。
  5. 观察现象:页面不会跳转,按钮会停止加载,同时“邮箱”输入框下方会精准地出现红色的错误提示:“此邮箱已被其他用户占用 (来自服务器的验证)”。我们的目标达成了!

img

技术架构决策
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 这个强大“服务器状态引擎”的今天,它显得非常“原始”和低效:

  1. 无缓存: 每次验证触发(例如,用户在输入框 blur 时),它都会 无条件地 发起一次新的网络请求。如果用户反复检查同一个用户名,就会造成大量不必要的 API 调用。
  2. 请求无法去重: 如果多个地方同时触发对同一个用户名的校验,它会发送多次重复的请求。
  3. 状态相异: 这次 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
// ... (保留 getUsers, getUserById, updateUser)

export const checkUsernameIsAvailable = async (username: string): Promise<boolean> => {
// 我们在 db.json 中已经有用户数据,可以直接使用
const { data } = await axiosInstance.get<User[]>('/users', { params: { username } });
// 如果返回的数组长度为 0,说明用户名可用
return data.length === 0;
};

文件路径: src/queries/userQueries.ts (添加新选项)

1
2
3
4
5
6
7
8
9
10
11
12
13
// ... (保留 usersQueryOptions, userQueryOptions)
import { checkUsernameIsAvailable } from '@/api/userApi';

// 为用户名检查创建一个专属的、动态的 Query Options
export const checkUsernameQueryOptions = (username: string) => queryOptions({
// 查询键必须包含动态的 username,以区分不同的校验
queryKey: ['users', 'check', username],
queryFn: () => checkUsernameIsAvailable(username),
// 对于这种校验数据,我们可以设置一个更长的 staleTime,因为用户名是否被占用这个事实不会频繁变动
staleTime: 1000 * 60 * 5, // 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'; // 👈 1. 导入全局的 queryClient 实例
import { checkUsernameQueryOptions } from '@/queries/userQueries'; // 👈 2. 导入我们创建的 Query Options

export const userSchema = z.object({
username: z.string().min(2, "用户名至少需要 2 个字符")
// 👇 3. 升级 .refine 方法
.refine(async (username) => {
// 避免在用户还未输入时就触发校验
if (!username) return true;

try {
// 使用 fetchQuery 代替 fetch。它会自动处理缓存。
const isAvailable = await queryClient.fetchQuery(checkUsernameQueryOptions(username));
return isAvailable;
} catch (error) {
// 如果 API 请求失败,我们也认为验证不通过
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),
// ✨ 关键:将验证模式设置为 onBlur,以便在用户离开输入框时触发异步验证
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
// ... imports
import RegisterPage from '@/pages/RegisterPage'; // 👈 导入

const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
// ...
{ path: 'register', element: <RegisterPage /> }, // 👈 添加
],
},
]);

验证流程:

  1. 启动应用,打开 React Query Devtools。
  2. 导航到 /register 页面。
  3. 在“用户名”输入框中输入 admin(假设它已存在于 db.json),然后点击输入框外部(触发 blur 事件)。
    • 观察: Devtools 中出现一个新的查询 ['users', 'check', 'admin'],状态变为 fetching。片刻后,API 返回 false,输入框下方出现“该用户名已被占用”的错误提示。
  4. 清空输入框,再次输入 admin,然后再次 blur。
    • 观察: 错误信息 瞬间出现。查看 Devtools,没有任何新的网络请求!状态指示器从 fresh (绿色) 直接提供了结果。这是因为 fetchQuery 命中了我们在 5 分钟 staleTime 内的缓存。
  5. 输入一个全新的用户名,如 newuser123,然后 blur。
    • 观察: Devtools 中出现一个新的查询 ['users', 'check', 'newuser123'],API 请求发出并返回 true,输入框保持有效状态。

img

技术架构决策
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 结合,构建出清晰、可维护、高性能的多步表单。

img

4.4.0 前置准备:在项目中集成 Zustand

这是我们首次在项目中正式引入 Zustand,必须先完成基础的安装和配置。

第一步:安装 Zustand

1
pnpm add zustand

第二步:创建 Store 的组织结构
我们将遵循官方推荐的 “切片模式” (Slice Pattern),按照业务领域来组织我们的 Store。

文件路径: src/stores/slices/ (新建 slices 文件夹)
文件路径: src/stores/useAppStore.ts (新建 stores 文件夹和文件)

1
2
3
4
import { create } from 'zustand';

// 目前这是一个空的 Root Store,我们稍后会向其中添加“切片”
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';

// 第一步的 Schema
export const step1Schema = z.object({
username: z.string().min(3, '用户名至少需要3个字符'),
password: z.string().min(6, '密码至少需要6个字符'),
});
export type Step1Schema = z.infer<typeof step1Schema>;

// 第二步的 Schema
export const step2Schema = z.object({
email: z.string().email('无效的邮箱地址'),
// 我们复用之前 userSchema 中定义的 email 字段,但在真实项目中可以更细化
// 注意:这里我们使用了 z.literal 来强制 checkbox 必须为 true
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>
);
}

文件路径: 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
// ... (保留 getUsers, getUserById, updateUser, checkUsernameIsAvailable)
import { type UserSchema } from '@/schemas/userSchema';

// 用于最终提交的 API 函数
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
// ... (保留 useUpdateUser)
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'; // 👈 导入真实的 Hook

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: () => {
// 在 mutation 成功后执行清理和跳转
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 组件。它虽然功能完备,但却承担了过多的职责:

  • 它需要知道如何从 URL 中解析 userId。
1
const { userId } = useLoaderData() as { userId: number };
  • 它需要调用 useQuery 来获取用户数据,并处理加载状态。
1
const { data: user, isLoading, isError } = useQuery(userQueryOptions(userId));
  • 它需要调用 useForm 来管理表单状态。
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
// 调用自定义 Hook,注入 setError 和成功后的导航回调
const updateUserMutation = useUpdateUser(setError, () => {
navigate("/users"); // 只在成功时才导航
});

  • 最后,它还需要渲染所有的 UI。

当一个组件承担了如此多的逻辑时,它就变成了一个“上帝组件”——臃肿、难以测试、且极难复用。如果现在产品经理要求:“我们需要在用户列表页弹出一个 Modal,也能快速编辑用户信息”,我们唯一的选择似乎只有复制粘贴大部分逻辑,这无疑是灾难的开始。

解决方案:遵循 “胖 Hooks,瘦组件” (Fat Hooks, Thin Components) 的设计哲学。

这个原则的核心思想是:将一个功能相关的所有 非视图逻辑,从组件中彻底剥离,封装到一个单一的、高内聚的自定义 Hook 中。

  • 胖 Hook (Fat Hook): 这个 Hook 是功能的“大脑”。它负责数据获取 (useQuery)、表单状态管理 (useForm)、数据提交 (useMutation),以及所有成功/失败的副作用处理。它是一个自给自足的、可独立测试的逻辑单元。
  • 瘦组件 (Thin Component): 组件的职责被极度简化,回归其本源——渲染 UI。它只需调用这个“胖 Hook”,获取所有它需要的状态和事件处理器,然后将它们绑定到 TSX 上即可。它不关心数据从何而来,也不关心提交后会发生什么。

4.5.2 实战:封装 useUserProfileForm Hook

现在,我们将 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"; // 👈 1. 导入新创建的 Hook

// Hook 的输入:它需要知道正在编辑哪个用户
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) => {
// 不再立即导航,而是等待 mutation 成功后由回调处理
updateUserMutation.mutate({ ...data, id: userId });
};

// 5. 将所有 UI 需要的状态和方法,作为一个单一对象返回
return {
methods,
handleSubmit: methods.handleSubmit(onSubmit),
isLoading: isLoading, // 初始数据加载状态
// ✨ 结合 react-hook-form 的 isSubmitting 和 mutation 的 isPending
// formState.isSubmitting 会在 handleSubmit 调用后立即变为 true(包括验证阶段)
// isPending 是实际的网络请求状态
isSubmitting:
methods.formState.isSubmitting || updateUserMutation.isPending,
isError: updateUserMutation.isError, // mutation 错误状态
};
}

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"; // 👈 1. 导入终极 Hook

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 };

// 2. ✨ 一行代码,调用 Hook,获取所有需要的状态和方法
const { methods, handleSubmit, isLoading, isSubmitting, isError } =
useUserProfileForm({ userId });

// 3. 根据状态,纯粹地渲染 UI
if (isLoading) {
return <Skeleton active paragraph={{ rows: 4 }} />;
}

if (isError) {
return <Alert message="用户数据加载失败!" type="error" showIcon />;
}

return (
// `methods` 实例现在来自 Hook
<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>
);
}

收益分析与总结

通过这次重构,我们获得了巨大的架构优势:

  1. 高度复用: useUserProfileForm 这个 Hook 现在是一个“便携式”的编辑功能单元。任何需要编辑用户功能的组件(无论是页面还是弹窗),只需 import 并调用它即可,无需重复编写任何逻辑。
  2. 极度简洁: EditUserPage 组件的代码量大幅减少,其职责变得单一且清晰——它只关心“如何展示”,不关心“如何工作”。这使得新成员接手或未来修改样式时,变得异常简单。
  3. 关注点分离: UI 与逻辑被彻底解耦。UI 开发者可以专注于 EditUserPage.tsx,而业务逻辑开发者可以专注于 useUserProfileForm.ts,两者互不干扰。
  4. 可测试性: 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 属性

  1. useForm 配置之 shouldFocusError: true: RHF 内置的“导航员”。当客户端验证失败时,它会自动将页面滚动并聚焦到第一个错误字段。
  2. setError 选项之 { shouldFocus: true }: 手动控制焦点的“精确制导”。当处理服务端返回的错误时,附加上这个选项,就能实现与客户端错误完全一致的自动聚焦体验。
  3. 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, router, middlewares 的定义)

server.use(middlewares);

// 中间件逻辑
server.use((req, res, next) => {
const db = router.db;

// 保留之前对 PATCH /users/: id 的校验逻辑
if (req.method === 'PATCH' && req.path.startsWith('/users/')) {
// ... (已有的用户邮箱校验逻辑)
}

// 👇 新增:对 POST /projects 的校验逻辑
if (req.method === 'POST' && req.path === '/projects') {
const { projectName } = req.body;
if (projectName && projectName.toLowerCase() === 'test') {
// 如果项目名称是 'Test',则返回 400 业务错误
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';

// API 层的职责非常纯粹:只负责发送 HTTP 请求
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 = () => {
// 1.获取TankQuery - queryClient
const queryClient = useQueryClient();

// 2.引入antdesign的全局app对象以便获取信息弹窗
const { message } = AntdApp.useApp();

// 3.使用React-Hook-Form创建表单并解构出三个函数
const methods = useForm<ProjectSchema>({
resolver: zodResolver(projectSchema),
shouldFocusError: true, // ✨ 核心 1: 开启客户端验证失败时的自动聚焦
defaultValues: {
projectName: "",
description: "",
},
});

const { handleSubmit, setError, reset, formState } = methods;

// 4.使用tankquery的useMutation创建一个mutation
const createProjectMutation = useMutation({
mutationFn: createProject,
onSuccess: () => {
message.success("项目创建成功");
queryClient.invalidateQueries({ queryKey: ["projects"] });
reset();
},
onError: (error) => {
// 5.判断是否错误来自于后端
if (isAxiosError(error) && error.response?.status === 400) {
const serverError = error.response.data as {
field: "projectName";
message: string;
};
// ✨ 核心 2: 处理服务端验证失败时的自动聚焦
setError(
serverError.field,
{
type: "error",
message: serverError.message,
},
{
shouldFocus: true, // ✨ 核心 3: 处理服务端验证失败时的自动聚焦
}
);

return;
}
message.error("项目创建失败,请稍后重试。");
},
});

// 5.处理表单提交,调用mutation传入数据
const onSubmit = (data: ProjectSchema) => {
createProjectMutation.mutate(data);
};
// 6.暴露三个核心方法供组件消费
// 1 - 表单实例
// 2 - 表单提交方法
// 3 - 表单提交状态
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
// ... imports
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
// ... imports
import CreateProjectPage from '@/pages/CreateProjectPage';

// ...
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
// ...
{ path: 'projects/new', element: <CreateProjectPage /> }, // 👈 添加
],
},
]);

第六步:验证

  1. 客户端错误: 访问 /projects/new,不填写任何内容直接点击“创建项目”。您会看到页面 自动滚动并聚焦 到“项目名称”输入框。
  2. 服务端错误: 在“项目名称”中输入 Test,然后点击“创建项目”。您会看到同样的 自动聚焦 效果,并且输入框下方显示来自 我们真实服务器 的错误信息。
  3. 无障碍验证: 使用浏览器开发者工具检查“项目名称”输入框。在出错时,您会看到 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 相结合。

  1. i18next: 作为应用的“语言切换引擎”,负责管理当前语言状态。
  2. Zod v4 Locales: Zod 官方提供的、开箱即用的多语言翻译文件,我们直接从 zod/locales 导入。
  3. 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";
// 1. 从 Zod v4 内置的 locales 中导入我们需要的语言包
import { en, zhCN } from "zod/locales";

// 2. 创建一个从 i18next 语言代码到 Zod locale 对象的映射表
const zodLocales = {
en: en,
zh: zhCN,
};

// 3. 初始化 i18next
i18n.use(initReactI18next).init({
// resources 对象可以留空,因为 Zod 的翻译由其内置 locale 提供
resources: {},
lng: "zh", // 应用初始加载时的默认语言
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});

// 4. 根据 i18next 的当前语言,设置 Zod 的初始全局错误映射
const currentLocale =
zodLocales[i18n.language as keyof typeof zodLocales] || zhCN;
z.config(currentLocale());

// 5. 建立动态同步机制:监听 i18next 语言变化,同步更新 Zod 的语言
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";

// ✨ 只需导入 i18n 实例,它内部的配置逻辑就会自动执行
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({
// 👇 移除第二个参数中的硬编码字符串,让 i18n 机制接管
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>
);
}

验证流程:

  1. 启动应用,访问 /projects/new 页面。
  2. 确保右上角的语言切换器在“中文”位置。
  3. 不填写任何内容,直接点击“创建项目”。
    • 观察: “项目名称”输入框下方显示中文错误信息:“字符串最少应包含 5 个字符”。
  4. 点击右上角的语言切换器,切换到“EN”。
  5. 再次点击“创建项目”按钮。
    • 观察: 错误信息 自动、动态地 变为英文:“String must contain at least 5 character(s)”。

5.3 终极守护:使用 isDirty 防止意外离开

5.3.1 痛点分析

想象一个真实场景:用户正在我们精心打造的 CreateProjectPage 表单上,花费了数分钟填写复杂的项目名称和详尽的项目描述。就在他即将点击“创建”按钮之前,不小心按下了浏览器的后退按钮,或者点击了导航栏的其他链接。

结果是灾难性的:页面立即跳转,所有未保存的输入都将丢失,没有任何提示。这种糟糕的体验会极大地打击用户的使用信心,对于企业级应用来说是不可接受的。

我们需要一个“安全网”,在用户尝试离开一个有未保存更改的页面时,及时给予提醒,并让他做出最终确认。

5.3.2 架构核心:formState.isDirty + React Router useBlocker

  1. formState.isDirty: 这是 React Hook Form 提供的一个极其有用的布尔状态。

    • 当表单的当前值与 defaultValues (或最后一次 reset 后的值) 完全相同时,isDirty 为 false。
    • 一旦用户修改了任何一个字段,isDirty 就会 立即变为 true。
    • 当表单被成功提交并 reset 后,它会再次变回 false。
      isDirty 是我们判断“是否有未保存的更改”的最准确、最可靠的信号源。
  2. 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';

// 这个 Hook 接收一个布尔值,代表是否有未保存的更改
export const useUnsavedChangesWarning = (isDirty: boolean) => {
// useBlocker 是 React Router 的 Hook,当 isDirty 为 true 时,它会阻止导航
const blocker = useBlocker(isDirty);

useEffect(() => {
// 当 blocker 的状态变为 'blocked' 时,意味着用户尝试离开
if (blocker.state === 'blocked') {
Modal.confirm({
title: '您有未保存的更改',
content: '您确定要离开此页面吗?所有未保存的输入都将丢失。',
okText: '确认离开',
cancelText: '留在本页',
// 如果用户点击“确认离开”
onOk: () => {
// 调用 blocker.proceed() 来放行,完成跳转
blocker.proceed?.();
},
// 如果用户点击“留在本页”
onCancel: () => {
// 调用 blocker.reset() 来取消阻止,停留在当前页面
blocker.reset?.();
},
});
}
}, [blocker]); // 依赖 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
// ... (保留其他 imports)

export const useCreateProjectForm = () => {
// ... (保留 queryClient, message 的获取)

const methods = useForm<ProjectSchema>({ /* ... */ });
// 👇 1. 从 methods.formState 中解构出 isDirty
const { handleSubmit, setError, reset, formState: { isDirty } } = methods;

const createProjectMutation = useMutation({ /* ... */ });

const onSubmit = (data: ProjectSchema) => { /* ... */ };

return {
methods,
handleSubmit: handleSubmit(onSubmit),
isSubmitting: createProjectMutation.isPending,
isDirty, // 👈 2. 将 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';
// 👇 1. 导入我们新创建的警告 Hook
import { useUnsavedChangesWarning } from '@/hooks/useUnsavedChangesWarning';

import { Form, Button, Typography, Input as AntdInput } from 'antd';
// ... (其他 imports)

export default function CreateProjectPage() {
// 👇 2. 从主 Hook 中解构出 isDirty 状态
const { methods, handleSubmit, isSubmitting, isDirty } = useCreateProjectForm();

// 👇 3. ✨ 一行代码,启用防丢失功能!
useUnsavedChangesWarning(isDirty);

return (
<FormProvider {...methods}>
<Form onFinish={handleSubmit} layout="vertical" className="max-w-xl">
{/* ... (表单 UI 保持不变) */}
</Form>
</FormProvider>
);
}

第四步:验证

  1. 启动应用,访问 /projects/new 页面。
  2. 测试场景一 (表单干净时): 立即点击导航栏的其他链接(例如,我们之前创建的 /users 页面链接)。页面应该会 正常跳转,没有任何提示。
  3. 返回 /projects/new 页面。
  4. 测试场景二 (表单变脏时): 在“项目名称”输入框中输入任意字符。此刻 isDirty 变为 true。
  5. 再次点击导航栏的 /users 链接。
    • 观察: 页面跳转被 阻止,并弹出一个 Ant Design 的确认对话框,提示“您有未保存的更改”。
  6. 点击对话框中的 “留在本页”。
    • 观察: 对话框关闭,您仍然停留在 /projects/new 页面,输入的内容保持不变。
  7. 再次点击 /users 链接,在弹出的对话框中点击 “确认离开”。
    • 观察: 对话框关闭,页面成功跳转到 /users。
  8. 测试场景三 (提交后): 返回 /projects/new,填写表单并成功提交。由于我们的 Hook 在成功后会自动调用 reset(),isDirty 会变回 false。此时再点击其他链接,页面会 正常跳转,不再弹出提示。