第十三章: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>
  );
}

提交处理与错误反馈:handleSubmiterrors

  • 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 组件的“翻译官”定位

痛点背景: 并非所有组件都能用 registerregister 的高性能源于它通过 ref 直接操作原生 DOM。然而,许多第三方 UI 库(如 Ant Design)的组件(如 Input, Select)是完全受控的,它们不暴露原生 ref 接口,而是通过 valueonChange 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 组件(尤其是第三方受控组件)的核心。在后续使用 ControlleruseFieldArray 等高级 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 的 refinetransform 等高级 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 中使用了异步 refinezodResolver 会自动处理这一切,它会智能地等待你的 API 请求完成后,再决定表单的最终有效性。我们作为开发者,无需进行任何额外配置,体验如丝般顺滑。


3. 数据转换:在验证前后“净化”数据

HTML 表单的一个“天坑”是,无论你输入的是数字还是日期,<input> 元素的值永远是字符串。但在我们的应用逻辑或数据库中,需要的却是 numberDate 类型。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)。

第三章: 高级模式与动态交互

摘要: 在前两章中,我们已经完全掌握了构建一个健壮、高性能的“静态”表单所需的所有核心技能。然而,真实世界的表单是“活”的:用户可以动态增删条目,表单项之间会相互影响,复杂的布局也要求我们更优雅地管理状态。本章,我们将直面这些动态与交互的挑战,深入 useFieldArraywatchuseFormContext 等高级 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

一切的起点是 useFormuseFieldArray。我们首先在组件中初始化这两个核心 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.iduseFieldArray 为每个动态项生成的唯一且稳定的标识符。即使你删除了一个项,其他项的 id 依然保持不变。React 就能精准地知道哪个组件被移除了,而哪个组件只是位置变了,从而正确地保留组件内部的状态。

架构师

完全正确!永远、永远使用 field.id 作为动态列表的 key。这是使用 useFieldArray 的第一条铁律。

实现增删操作

现在,我们利用 useFieldArray 返回的 appendremove 函数来赋予表单“生命”。

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

前置知识:使用 FormProvideruseFormContext 告别 Prop Drilling

在我们开始构建嵌套表单之前,必须先掌握一个解决组件通信的关键模式。在之前的示例中,useFormuseFieldArray 都在同一个组件中被调用,因此 control 对象可以被直接访问。

但现在,我们要将“选项列表”拆分成一个独立的子组件 QuestionOptions。这就带来了一个问题:我们如何将顶层 NestedDynamicForm 组件中由 useForm 创建的 control 对象,传递给深层嵌套的 QuestionOptions 组件?

最直接的方法是“属性钻探”(Prop Drilling):NestedDynamicForm -> Card -> QuestionOptions,一层层地将 control 作为 prop 传递下去。但这会让代码变得冗长且难以维护。

React Hook Form 为此提供了完美的解决方案:FormProvideruseFormContext

  • 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 还提供了 moveswap 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. 当你在输入框中输入内容时,isDirtyisValid 状态会变化。
  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 实例(尤其是 controlregister 对象)如何高效、优雅地传递给那些深埋在组件树底层的输入框组件 ?

手动一层层地通过 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>
);
}

通过这个模式,我们彻底消除了属性钻探。PersonalInfoSectionContactInfoSection 变得完全独立和解耦,它们可以被放置在组件树的任何(被 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 的值是 undefineduseForm 只会在其 首次初始化时 读取一次 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.errorsUI 自动显示错误

逐步实战:集成服务端验证错误

第一步:升级 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 devpnpm 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

现在,我们已经拥有了 Step1SchemaStep2Schema 类型,可以安全地在 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,将聚合后的数据 POSTjson-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)useMutationonError 回调中,将后端返回的字段特定错误,手动注入到 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 设置初始语言环境。然后,通过监听 i18nextlanguageChanged 事件,在语言切换时动态地再次调用 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 后的值) 完全相同时,isDirtyfalse
    • 一旦用户修改了任何一个字段,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” useCreateProjectFormisDirty 状态暴露出来。

文件路径: 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。此时再点击其他链接,页面会 正常跳转,不再弹出提示。