
一句话:react-hook-form(RHF)快的根本原因是「非受控(值留在 DOM)+ 按字段订阅 +
formState用 Proxy 做「按需订阅」」——所以它的核心能力不是「收集值」,而是「用register/Controller统一非受控与受控、用resolver接 zod/yup、用useFieldArray处理动态数组」。
📌 本篇是「用」的视角;「自己实现一个表单库」的机制视角见 [
11.14 手写表单库](../11-手写实现/11.14-手写表单库.md)。
一、它为什么快(先理解这个,后面才不会用错)
⭐⭐⭐ RHF 的三个性能设计(缺一个都不会快):
① 【非受控为主】
`register('email')` 返回 `{ name, onChange, onBlur, ref }` ——
值是**浏览器在维护**,RHF 只在「校验/提交/按需读值」时去 DOM 拿。
✅ 结果:**打字不引起 React 重渲染**(对比:`useState` 受控表单每个字符都渲染)。
② 【按字段订阅】
内部用「订阅者表」,每个订阅者声明自己关心哪些字段 →
改 A 字段时,只有订阅了 A 的组件重渲染。
③ 【`formState` 是 Proxy(按需订阅)】⭐ 最精妙的一点
`formState` 是一个 **Proxy**:你**读了哪个属性**,RHF 才订阅对应的变化。
· 你只读 `isDirty` → 只有「dirty 变化」才重渲染你的组件
· 你读 `errors` → 只有「错误变化」才重渲染
⭐ 所以你「不读 `isValid`」的组件,不会因为 `isValid` 变化而重渲染。
⚠️ 反过来说:**读得越多,重渲染越频繁**——
`const { ...formState } = useForm()`(展开整个 formState)是**最常见的性能杀手**。
// ❌ 反面教材:展开整个 formState(订阅了一切)
const { formState } = useForm();
const isDirty = formState.isDirty; // 其实只想读这一个
// ✅ 正确:只解构你真正需要的
const { formState: { isDirty, isSubmitting } } = useForm();
// ⭐ 连「解构」这个动作都会触发订阅 —— 所以「用不到的不要解构」
// ❌ 另一个常见性能杀手:用 `watch` 读值(会让「整个组件」重渲染)
const value = watch('keyword'); // 每次 keyword 变化 → 当前组件重渲染
// ✅ 用 `useWatch`(独立订阅,只让「读它的那个组件」重渲染)
function ResultCount({ control }: { control: Control }) {
const keyword = useWatch({ control, name: 'keyword' }); // ⭐ 只有这个组件重渲染
return {keyword.length} 字;
}
二、核心 API 全貌
const {
register, // ⭐ 注册非受控输入(返回 name/onChange/onBlur/ref)
handleSubmit, // 包装提交(先校验),返回 (e) => void
control, // 给 useWatch / useFieldArray / Controller 用
formState, // ⭐ Proxy:按需订阅(errors/isDirty/isValid/isSubmitting/touchedFields/dirtyFields/isSubmitSuccessful/submitCount/isLoading)
watch, // ⭐ 读值(会让「当前组件」重渲染 → 少用)
getValues, // 读值(不订阅、不重渲染)⭐ 事件处理里读值用这个
setValue, // 写值(含 options: { shouldValidate, shouldDirty, shouldTouch })
setError, // 手动设错误(服务端错误映射)
clearErrors, // 清错误
trigger, // 手动触发校验(返回 boolean)⭐ 多步表单「下一步」用它
reset, // 重置(可传新值 / keepErrors / keepDirty / keepValues)
resetField, // 重置单个字段
unregister, // 注销字段(含 keepValue)
setFocus, // 聚焦某个字段
} = useForm({
defaultValues, // ⭐ 强烈建议总是传(决定「非受控初值 + isDirty 基准」)
mode: 'onSubmit', // 校验时机:onSubmit | onBlur | onChange | onTouched | all
reValidateMode: 'onChange', // 首次校验通过后的「重新校验」时机
criteriaMode: 'firstError', // 'all' 会收集「同一字段的所有错误」(配 zod 时有意义)
shouldFocusError: true, // 提交失败后自动聚焦第一个错误字段
shouldUnregister: false, // ⭐ 卸载时「是否清除值」(默认 false = 保留值)
resolver: zodResolver(schema), // ⭐ 接 zod/yup/valibot
disabled: false, // 整体禁用(会跳过校验)
});
⭐⭐ `shouldUnregister` 是个「必须做选择」的选项(两种语义都对):
· `false`(默认):字段卸载后**值保留**
✅ 适合「多步表单」「条件显示的字段」——
上一步填的值,即使那一步的 DOM 卸载了,提交时仍然带着。
· `true`:字段卸载后**值被清除**
✅ 适合「真正的条件字段」——用户选了「不要发票」,
发票信息的 DOM 消失时也应该「不在提交数据里」。
⚠️ 用错的后果很隐蔽:
· 期望「不要了」用 `false` → 提交里带着「用户看不到了的旧数据」
· 期望「保留」用 `true` → 多步表单回退时数据丢了
三、register:非受控输入的全部细节
// ✅ register 的完整选项(内置校验规则)
v !== 'admin@x.com' || '该邮箱不可用',
// ⭐ 跨字段校验:值里拿不到别的字段,但可以用 getValues
unique: async (v) => {
const taken = await api.checkEmail(v);
return taken ? '该邮箱已被注册' : true;
},
},
// ⭐ 值转换(这是「非受控」的关键能力)
setValueAs: (v) => v.trim(), // 字符串处理
valueAsNumber: true, // 直接转数字(会与 setValueAs 冲突,二选一)
valueAsDate: true,
deps: ['confirmEmail'], // ⭐ 声明「这个字段校验依赖谁」→ 依赖变化时重校验
disabled: false,
shouldUnregister: false,
})}
/>
// ⭐ 不同表单控件的 register 写法(这里最容易出错)
{/* 值 = boolean */}
{/* 同 name 的 radio 一组 */}
{/* 值是 FileList */}
{/* ⭐ 多个同名 checkbox → 值是数组 */}
⭐⭐⭐ 「多个同名 checkbox 得到数组」是一个容易踩坑的特性:
→ 勾选 a 和 b 时,`values.tags === ['a', 'b']`
⚠️ 但 `defaultValues.tags` 必须是**数组**(`[]`),否则类型与运行时不符
⚠️ 而且「这个字段的 DOM 卸载一个」(比如条件渲染掉一个选项)会导致
数组内容变化(RHF 会重新计算)——用 `shouldUnregister` 相关行为要小心
⭐ 更推荐的做法:用 `Controller` + 自己的 CheckboxGroup 组件
(可控、可测、类型清晰)
四、Controller:接入受控组件
// ✅ 当第三方组件「只能用受控」(如 antd 的 Select/DatePicker、React Select)
import { Controller } from 'react-hook-form';
(
<>
{fieldState.error && {fieldState.error.message}}
{/* ⭐ fieldState 是「该字段专属的订阅」→ 只有它变化才重渲染这个 render */}
>
)}
/>
⭐⭐ `Controller` 的两个「为什么」:
① 【为什么需要它?】
因为 `register` 靠「`name` + 原生事件」工作,
而第三方组件(Select/DatePicker/富文本)**不产生原生 input[value] 语义**,
必须由你「把值传给它、把它的 onChange 接回来」→ 这就是受控。
⭐ `Controller` 就是「把受控组件接入 RHF 内部状态」的桥。
② 【`fieldState` vs `formState`(读哪个很重要)】
· `fieldState`(`error`/`isDirty`/`isTouched`/`invalid`)→ **只订阅这一个字段**
· `formState` → 订阅你读的那些「表单级」属性
⭐ 在 `render` 里**优先用 `fieldState`**(粒度更细、重渲染更少)。
⚠️ 还有一条:`Controller` 的 `render` 是一个「内联函数」——
每次父组件渲染它都会重建,但 RHF 内部做了处理(不会因此丢状态)。
不过如果 `render` 里做了重活,仍要注意「父组件渲染会带上它」。
五、useFieldArray:动态数组
const { fields, append, prepend, insert, remove, move, swap, update, replace } =
useFieldArray({ control, name: 'addresses' });
// ⭐ 渲染时用 field.id 当 key(RHF 生成的稳定 id,而不是 index)
{fields.map((field, index) => (
{/* ⭐⭐ 关键:不要用 index */}
))}
⭐⭐ `useFieldArray` 的三个要点:
① 【`fields` 里的每个元素有 `id`】→ 用它当 `key`
⭐ 用 `index` 当 key 的后果:删除中间项时「后面所有项前移」,
React 复用 DOM 节点 → 「输入框内容与它绑定的字段错位」。
(`useFieldArray` 提供的 `id` 就是为了解决这个:删中间项时后面的 id 不变。)
② 【`fields` 里**没有**值(只有 id 和 key)】
⭐ 要读值必须用 `useWatch({ control, name: 'addresses' })` 或 `watch`。
这是有意的设计——避免「fields 变化就重渲染整个列表」。
③ 【「整组替换」用 `replace`,不要用「循环 remove + append」】
replace([...]) // ✅ 一次更新(一次重渲染)
fields.forEach((_, i) => remove(i)); fields.forEach((v) => append(v)); // ❌ N 次更新
⭐⭐⭐ 「数组字段」的经典事故(与 11.14 的坑呼应):
{fields.map((field, index) => (
// ❌ key 用 index
))}
场景:3 个输入框分别是 A、B、C,用户删掉第一个(A)。
React 看到「key 0、1、2」还是存在(只是内容变了)→
⭐ 它复用 DOM 节点,把 B 的内容写进原 A 的输入框、C 写进原 B 的。
→ 如果输入框里有「未受控的额外状态」(如光标位置、IME 组合态、
或第三方组件的内部 state),就会错位。
✅ 用 `field.id` 当 key → 删掉 A 时「B、C 的 DOM 节点与 id 都保留」,
只有 A 被移除(⭐ 这就是「稳定标识」的价值)。
六、完整实现:多步表单 + 数组 + zod + 服务端错误(230 行)
// ============ 1. schema(zod):一份 schema 同时做「类型」与「校验」 ============
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const addressSchema = z.object({
city: z.string().min(1, '请填写城市'),
zip: z.string().regex(/^\d{6}$/, '邮编为 6 位数字'),
});
const schema = z
.object({
// 第 1 步:账号
email: z.string().min(1, '邮箱必填').email('邮箱格式不正确'),
password: z.string().min(8, '密码至少 8 位'),
confirm: z.string(),
// 第 2 步:资料
name: z.string().min(1, '请填写姓名'),
age: z.coerce.number({ invalid_type_error: '请输入年龄' }).min(18, '需年满 18 岁'),
tags: z.array(z.object({ value: z.string().min(1, '标签不能为空') })).min(1, '至少一个标签'),
// 第 3 步:地址
addresses: z.array(addressSchema).min(1, '至少一个地址'),
agree: z.literal(true, { errorMap: () => ({ message: '请先同意条款' }) }),
})
// ⭐ 跨字段校验(zod 的 refine)
.refine((v) => v.password === v.confirm, {
path: ['confirm'], // ⭐ 把错误挂到 confirm 字段上
message: '两次输入的密码不一致',
});
type FormValues = z.infer; // ⭐ 类型从 schema 推导(单一事实源)
const STEPS = [
{ title: '账号', fields: ['email', 'password', 'confirm'] },
{ title: '资料', fields: ['name', 'age', 'tags'] },
{ title: '地址', fields: ['addresses', 'agree'] },
] as const;
// ============ 2. 表单主体(分步 + 跨步校验 + 服务端错误映射) ============
export function SignupWizard() {
const [step, setStep] = useState(0);
const form = useForm({
resolver: zodResolver(schema),
mode: 'onBlur',
// ⭐ 总是给 defaultValues(决定初值与 isDirty 基准)
defaultValues: {
email: '', password: '', confirm: '', name: '', age: 18,
tags: [{ value: '' }],
addresses: [{ city: '', zip: '' }],
agree: false as true,
},
shouldUnregister: false, // ⭐ 多步表单必须「保留」上一步的值
});
const { control, handleSubmit, trigger, formState, setError, getValues, watch, reset } = form;
// ⭐ 只订阅真正需要的 formState 字段
const { isSubmitting, errors, submitCount } = formState;
/** ⭐「下一步」:只校验「当前步的字段」 */
const goNext = async () => {
const fields = STEPS[step]!.fields as unknown as (keyof FormValues)[];
// ⭐ trigger 只校验指定字段(返回 boolean)
const ok = await trigger(fields, { shouldFocus: true });
if (ok) setStep((s) => Math.min(s + 1, STEPS.length - 1));
};
/** ⭐ 提交:先整体校验,再调用接口,最后处理服务端错误 */
const onSubmit = handleSubmit(async (values) => {
try {
await api.signup(values);
} catch (err) {
// ⭐ 服务端把错误塞进同一套 errors 体系
const fieldErrors = (err as { fieldErrors?: Record })?.fieldErrors;
if (fieldErrors) {
Object.entries(fieldErrors).forEach(([name, message]) => {
setError(name as keyof FormValues, { type: 'server', message });
});
// ⭐ 把用户「送回」有错的那一步(体验关键)
const firstErrorField = Object.keys(fieldErrors)[0]!;
const errorStep = STEPS.findIndex((s) =>
(s.fields as readonly string[]).includes(firstErrorField.split('.')[0]!)
);
if (errorStep >= 0) setStep(errorStep);
} else {
setError('root.server', { message: (err as Error).message }); // 表单级错误
}
}
});
return (
);
}
// ============ 3. 三个子组件:Field / TagArray / AddressArray ============
function Field({ name, label, type = 'text', register, control, error }: {
name: string;
label: string;
type?: string;
register?: ReturnType['register'];
control?: Control;
error?: string;
}) {
return (
);
}
function TagArray({ control }: { control: Control }) {
const { fields, append, remove } = useFieldArray({ control, name: 'tags' });
return (
);
}
function AddressArray({ control, register, errors }: {
control: Control;
register: ReturnType['register'];
errors?: FieldErrors['addresses'];
}) {
const { fields, append, remove, move } = useFieldArray({ control, name: 'addresses' });
// ⭐ useWatch 读「数组长度」做展示(不订阅具体值 → 打字不重渲染)
const count = useWatch({ control, name: 'addresses' }).length;
return (
);
}
import { useFieldArray, Controller, useWatch, type Control, type FieldErrors } from 'react-hook-form';
⭐⭐⭐ 这个实现覆盖了「真实项目里表单的六个难题」:
① 【分步表单 + 跨步校验】
· 「下一步」只校验当前步:`trigger(STEPS[step].fields)`
· ⭐ `shouldUnregister: false` 保证「上一步的值不会因 DOM 卸载而丢」
· 提交失败时把用户送回「有错的那一步」(体验关键)
② 【`zod` 作为「类型 + 校验」的单一事实源】
`z.infer` → 类型与校验永远一致(改 schema 就改类型)
⭐ `refine` 做跨字段校验(`confirm === password`),并用 `path` 把错误挂到具体字段
③ 【动态数组(标签与地址)】
`useFieldArray` + **`field.id` 当 key**(避免「删中间项导致错位」)
④ 【服务端错误映射】
`setError('field', { type: 'server', message })` → 与客户端错误统一展示
⭐ 并跳到有错的那一步
⑤ 【受控/非受控混合】
原生输入用 `register`;需要自定义渲染的用 `Controller`
⑥ 【性能】
· 只解构需要的 `formState` 字段(Proxy 按需订阅)
· 展示「数组长度」用 `useWatch`(而不是 `watch`,避免整体重渲染)
七、性能要点(五条实战判据)
⭐⭐⭐ 用 RHF 时「什么时候会慢」——五条判据:
① 【展开整个 `formState`】→ 订阅一切
❌ const { formState } = useForm(); const { isValid, isDirty, errors, ... } = formState;
✅ 只解构用到的:`const { formState: { isSubmitting } } = useForm();`
② 【用 `watch` 读值(而不是 `useWatch`)】
❌ const keyword = watch('keyword'); // 当前组件每次变化都重渲染
✅ 把「读值的部分」抽到独立子组件,用 `useWatch`(⭐ 只有它重渲染)
③ 【`watch()` 读「整个表单」(不带参数)】
❌ const all = watch(); // 任何字段变化 → 整个组件重渲染
✅ 用 `getValues()`(不订阅)在事件处理里读
④ 【resolver 里做了重活】(如每次都 `zod.parse` 整个大对象)
⭐ 大表单(100+ 字段)时解析成本显著
✅ 用 `mode: 'onBlur'`(而不是 `onChange`)减少校验次数;
或把「独立字段」用字段级 `validate` 而不是整体 resolver
⑤ 【`Controller` 的 `render` 里读 `formState`】(粒度变粗)
❌ render={({ formState }) => }
✅ render={({ field, fieldState }) => }
| 想做的事 | 用哪个 | 为什么 |
|---|---|---|
| 输入值(不关心重渲染) | register |
非受控,零重渲染 |
| 在事件里读值 | getValues('x') |
不订阅、不重渲染 |
| 在渲染里展示某字段值 | useWatch |
独立订阅,只让该组件重渲染 |
| 在渲染里读表单级状态 | 解构 formState 的具体字段 |
Proxy 按需订阅 |
| 单个字段的错误/脏值 | Controller 的 fieldState |
粒度最细 |
| 手动改值 | setValue(+ shouldValidate) |
不影响其它订阅者 |
八、本篇特有的坑
// ① 不传 defaultValues(导致 isDirty 语义混乱、受控切换警告)
useForm({ resolver }); // ⚠️ 强烈不建议
useForm({ defaultValues: {...}, resolver }); // ✅
// ② 展开整个 formState(订阅一切)
const { formState } = useForm();
const { isValid, isDirty, errors } = formState; // ⚠️ 解构 = 订阅
// ✅ 只取需要的
// ③ 用 `watch` 代替 `useWatch`(整组件重渲染)
// ✅ useWatch + 独立子组件
// ④ 数组字段用 `index` 当 key(删中间项时输入框错位)
{fields.map((f, i) => )} // ❌
// ✅ key={f.id}
// ⑤ 以为 `fields` 里有值(其实是空的,只有 id)
fields.map((f) => {f.name}) // ❌ undefined
// ✅ 用 useWatch 或 defaultValue
// ⑥ 用「循环 remove + append」替换整个数组(N 次重渲染 + 状态错乱)
// ✅ `replace([...])`
// ⑦ `shouldUnregister: true` 用在了多步表单上(回退时数据丢了)
// ✅ 多步表单/条件显示用 `false`(默认)
// ⑧ `valueAsNumber` 与 `setValueAs` 同时用(后者会覆盖前者)
// ✅ 二选一
// ⑨ `type="number"` 但没开 `valueAsNumber`(拿到的是字符串)
// ⚠️ "18" 与 18 在 `z.number()` 校验下不同(可用 `z.coerce.number()` 兜底)
// ✅ `register('age', { valueAsNumber: true })` 或 zod 的 coerce
// ⑩ 多个同名 checkbox 期望「布尔」但得到数组
// ⭐ 同名且有 value → 值是数组(这是对的)
// ⚠️ 但单选 checkbox 要去掉 `value`(或统一用 Controller)
// ⑪ `setError` 后用户改了值,错误没清除
// ⚠️ 下次校验才清(如果 `mode: 'onSubmit'` 则一直显示)
// ✅ `setError(..., { shouldFocus: true })` + 在 onChange 里 `clearErrors('x')`
// 或提高 `reValidateMode`
// ⑫ `errors` 的嵌套路径读法写错
errors.addresses[0].city // ⚠️ 可能 undefined
errors?.addresses?.[0]?.city?.message // ✅ 全程可选链
// ⑬ `handleSubmit` 的「校验失败」分支没有反馈(用户按了没反应)
// ✅ 用 `formState.submitCount` 或「提交失败时聚焦第一个错误」(默认 `shouldFocusError: true`)
// ⑭ 「异步校验」的结果覆盖了「更晚的输入」(竞态)
// ⭐ zod 的 `superRefine` 里做异步请求也有同样问题
// ✅ 在异步校验里对比「当前值是否还是发起时的值」,或用 RHF 的 validate(它内部有处理)
// ⑮ `reset()` 没有重置「外部状态」(如 UI 的展开/收缩)
// ✅ `reset()` 只重置表单值;UI 状态要自己重置
// ⑯ `formState.isValid` 在 `mode: 'onSubmit'` 下初始就是 true(还没校验过)
// ⚠️ 用它控制「提交按钮禁用」会「一进页面按钮就是亮的」
// ✅ 用 `isValid` 时要配 `mode: 'onChange'`/`onTouched`,或用 `submitCount`
// ⑰ `trigger()` 不传字段 → 校验全部(大表单会明显卡)
// ✅ 分步表单只校验当前步
// ⑱ 提交中「重复点击」(重复下单)
// ⚠️ `isSubmitting` 只保证「RHF 内部不会并发调用 onValid」,但按钮仍可点
// ✅ `
// ⑪ 的完整处理(错误随输入清除)
clearErrors('email'), // ⭐ 用户一改就清除服务端错误
})}
/>
// ⭐ 更优雅的做法:用 `setError` 时带 `type: 'server'`,
// 在 onChange 里只清 `type === 'server'` 的错误
// ⑯ 的完整处理(提交按钮的禁用条件)
const { formState: { isValid, isDirty, submitCount } } = useForm({ mode: 'onTouched' });
const canSubmit = isValid && isDirty;
// ⭐ 注意:`isValid` 只有在「至少校验过一次」后才有意义
// 配 `mode: 'onTouched'` 可以让它「用户碰过就开始算」
九、面试延伸
- 「react-hook-form 为什么性能好?」
三个设计:① 非受控为主——register 把值交给浏览器维护,RHF 只在「校验/提交/按需读值」时读 DOM,所以打字不引起 React 重渲染;② 按字段订阅——内部维护订阅者表,改 A 字段时只有订阅 A 的组件重渲染;③ ⭐ formState 是 Proxy——你读哪个属性,它才订阅哪个变化(只读 isDirty 就不会因 errors 变化重渲染)。这也带来最重要的一条实践纪律:不要展开整个 formState(const { ...formState } = useForm() 等于订阅一切),只解构真正需要的字段。
- 「
watch和useWatch有什么区别?」
watch('x') 是在当前组件里订阅——x 一变,当前组件就重渲染(如果当前组件是「整个表单」,那就等于整表单重渲染)。useWatch({ control, name: 'x' }) 是独立订阅——它把「读值」变成一个只作用于「使用它的那个组件」的订阅,所以通常做法是「把需要展示值的部分抽成一个小子组件,在里面用 useWatch」。⭐ 另外「在事件处理里读值」应该用 getValues()(不订阅、不重渲染),watch()(不带参数、读整个表单)是最差的选择。
- 「
register和Controller该用哪个?」
判据是「这个控件是不是原生 input/select/textarea 语义」:① 是(原生输入)→ 用 register(非受控,性能最好);② 不是(antd Select/DatePicker、React Select、富文本、自研组件)→ 用 Controller(受控,把 field.value/field.onChange 接上去)。注意两点:Controller 的 render 里优先用 fieldState(只订阅该字段,比 formState 粒度细);Controller 的 rules 与 register 的第二参是同一套校验规则。
- 「
useFieldArray的key为什么不能是index?」
因为「删除中间项」时,用 index 当 key 会让 React 复用后面所有 DOM 节点——于是「B 的内容被写进原 A 的输入框、C 写进原 B 的」。如果输入框里有未受控的额外状态(光标位置、IME 组合态、第三方组件的内部 state),就会错位。useFieldArray 的 fields[i].id 是稳定标识:删掉 A 时,B、C 的 id 与 DOM 节点都保留,只有 A 被移除。⭐ 另外要注意:fields 里没有值(只有 id/key),要读值得用 useWatch 或 watch——这是有意设计,避免「改一个值就重渲染整个列表」。
- 「
shouldUnregister该怎么选?」
两种语义都合法,取决于「字段卸载后值该不该保留」:① false(默认)——保留值,⭐ 适合「多步表单」与「条件显示的字段」(上一步的 DOM 卸载了,但提交时值还在);② true——清除值,适合「真正的条件字段」(用户选了「不要发票」,发票信息的 DOM 消失时,值也应该不在提交数据里)。用错的后果很隐蔽:该 false 的用了 true → 多步表单回退时数据丢失;该 true 的用了 false → 提交里带着「用户已经看不到的旧数据」。
- 「表单校验方案怎么组织?」
三层,越靠后越灵活:① 内置规则(required/minLength/pattern/valueAsNumber)——简单场景最快;② validate(同步或异步函数)——单字段的复杂规则(如「用户名不可用」),⭐ 并可用 deps 声明「这个字段校验依赖谁」从而「依赖变化时重新校验」;③ ⭐ resolver(zod/yup/valibot)——把「校验」与「类型」合并成单一事实源(z.infer<typeof schema> 直接得到表单类型),并用 refine/superRefine 做跨字段与异步校验。实践建议:中小表单用「resolver 一把梭」,超大表单(100+ 字段)考虑「字段级 validate + 只对当前步校验」,避免每次输入都解析整个大对象。
- 「多步表单(Wizard)要注意什么?」
四个要点:① shouldUnregister: false(否则回退步骤时数据会丢);② 「下一步」只校验当前步——用 trigger(当前步的字段),⭐ 不要 trigger()(全量校验,大表单会卡);③ 跨字段校验要放在 schema 的 refine 里并指定 path,否则错误会挂在「表单级」而用户找不到;④ ⭐ 提交失败后把用户送回「有错的那一步」(否则「提交失败但看不到错误在哪」)。额外提醒:reset() 只会重置表单值,「当前在第几步」这类 UI 状态要自己重置。
- 「服务端返回字段错误怎么处理?」
用 setError(name, { type: 'server', message }) ——它把服务端错误塞进同一套 errors 体系,于是「客户端校验错误」与「服务端错误」的展示逻辑完全一致。三个配套细节:① 表单级错误(如网络失败)用 setError('root.server', ...)(root 是约定的「表单级」路径),渲染时读 errors.root;② ⭐ 把用户送回有错的那一步/滚动到第一个错误字段(体验关键);③ 错误要能被清除——在字段的 onChange 里 clearErrors(name)(或只清 type === 'server' 的),否则用户改了值错误还挂着。
一句话速记
RHF 快的三个原因是「非受控(值在 DOM)+ 按字段订阅 +
formState是 Proxy(读哪个才订阅哪个)」,⭐ 最重要的纪律是「不要展开整个formState」与「展示值用useWatch(抽成子组件)而不是watch,事件里读值用getValues」;原生输入用register(第二参是校验规则,valueAsNumber/setValueAs二选一,同名 checkbox 有value时值是数组),第三方受控组件用Controller(render里优先用粒度更细的fieldState);useFieldArray的key必须用field.id而不是index(否则删中间项时输入框错位),且fields里没有值(要读值用useWatch),整组替换用replace;shouldUnregister:多步表单用false(保値)、真正的条件字段用true(清值);校验分三层「内置规则 →validate(可用deps声明依赖)→resolver(zod 的z.infer让类型与校验同源,用refine做跨字段)」;多步表单要「shouldUnregister: false+trigger(当前步字段)+ 提交失败送回出错步骤」;服务端错误用setError(表单级用root.xxx)并记得在 onChange 清除。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!