陌上人如玉
公子世无双

1.13 表单进阶:react-hook-form

1.13 表单进阶:react-hook-form

一句话:react-hook-form(RHF)快的根本原因是「非受控(值留在 DOM)+ 按字段订阅 + formState 用 Proxy 做「按需订阅」」——所以它的核心能力不是「收集值」,而是「用 register/Controller 统一非受控与受控、用 resolver 接 zod/yup、用 useFieldArray 处理动态数组」。

📌 本篇是「用」的视角;「自己实现一个表单库」的机制视角见 11.14 手写表单库。

一、它为什么快(先理解这个,后面才不会用错)

⭐⭐⭐ 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<FormValues> }) {
  const keyword = useWatch({ control, name: 'keyword' });    // ⭐ 只有这个组件重渲染
  return <span>{keyword.length} 字</span>;
}

二、核心 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<FormValues>({
  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 的完整选项(内置校验规则)
<input
  {...register('email', {
    required: '邮箱必填',                          // 或 true
    minLength: { value: 5, message: '至少 5 位' },
    maxLength: { value: 100, message: '最多 100 位' },
    pattern: { value: /@/, message: '格式不正确' },
    validate: {
      notAdmin: (v) => 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 写法(这里最容易出错)
<textarea {...register('bio')} />
<select {...register('city')}><option value="sh">上海</option></select>
<input type="checkbox" {...register('agree')} />                 {/* 值 = boolean */}
<input type="radio" value="male" {...register('gender')} />      {/* 同 name 的 radio 一组 */}
<input type="number" {...register('age', { valueAsNumber: true })} />
<input type="file" {...register('avatar')} />                    {/* 值是 FileList */}
<input type="checkbox" value="a" {...register('tags')} />        {/* ⭐ 多个同名 checkbox → 值是数组 */}
<input type="checkbox" value="b" {...register('tags')} />
⭐⭐⭐ 「多个同名 checkbox 得到数组」是一个容易踩坑的特性:

   <input type="checkbox" value="a" {...register('tags')} />
   <input type="checkbox" value="b" {...register('tags')} />

   → 勾选 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';

<Controller
  name="birthday"
  control={control}
  rules={{ required: '请选择生日' }}
  // ⭐ render 的 field 里有 value/onChange/onBlur/name/ref
  render={({ field, fieldState, formState }) => (
    <>
      <DatePicker
        value={field.value}
        onChange={field.onChange}             // ⭐ 直接传(DatePicker 会传 Date 对象)
        onBlur={field.onBlur}
        ref={field.ref}
        disabled={formState.isSubmitting}
      />
      {fieldState.error && <span role="alert">{fieldState.error.message}</span>}
      {/* ⭐ 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) => (
  <div key={field.id}>                        {/* ⭐⭐ 关键:不要用 index */}
    <input {...register(`addresses.${index}.city` as const)} />
    <input {...register(`addresses.${index}.zip` as const)} />
    <button type="button" onClick={() => remove(index)}>删除</button>
    <button type="button" onClick={() => move(index, index - 1)} disabled={index === 0}>上移</button>
  </div>
))}
<button type="button" onClick={() => append({ city: '', zip: '' })}>添加地址</button>
⭐⭐ `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) => (
     <input key={index} {...register(`items.${index}.name`)} />    // ❌ 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<typeof schema>;   // ⭐ 类型从 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<FormValues>({
    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<string, string> })?.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 (
    <form onSubmit={onSubmit} noValidate style={{ maxWidth: 480 }}>
      {/* 步骤指示 */}
      <ol style={{ display: 'flex', gap: 8, listStyle: 'none', padding: 0 }}>
        {STEPS.map((s, i) => (
          <li key={s.title} style={{ fontWeight: i === step ? 700 : 400 }}>
            {i + 1}. {s.title}
          </li>
        ))}
      </ol>

      {/* ⭐ 条件渲染:未激活的步骤「不渲染 DOM」,但因为 shouldUnregister: false → 值保留 */}
      {step === 0 && (
        <>
          <Field name="email" label="邮箱" control={control} register={form.register} error={errors.email?.message} />
          <Field name="password" label="密码" type="password" register={form.register} error={errors.password?.message} />
          <Field name="confirm" label="确认密码" type="password" register={form.register} error={errors.confirm?.message} />
        </>
      )}

      {step === 1 && (
        <>
          <Field name="name" label="姓名" register={form.register} error={errors.name?.message} />
          <Field name="age" label="年龄" type="number" register={form.register} error={errors.age?.message} />
          <TagArray control={control} />                     {/* ⭐ useFieldArray 示例 */}
        </>
      )}

      {step === 2 && (
        <>
          <AddressArray control={control} register={form.register} errors={errors.addresses} />
          <label style={{ display: 'flex', gap: 8, marginTop: 12 }}>
            <input type="checkbox" {...form.register('agree')} /> 我已同意条款
          </label>
          {errors.agree && <p role="alert" style={{ color: '#c00' }}>{errors.agree.message}</p>}
        </>
      )}

      {/* 表单级错误(如网络失败) */}
      {errors.root?.server && <p role="alert" style={{ color: '#c00' }}>{errors.root.server.message}</p>}

      <div style={{ display: 'flex', gap: 8, marginTop: 20 }}>
        <button type="button" disabled={step === 0} onClick={() => setStep((s) => s - 1)}>上一步</button>
        {step < STEPS.length - 1
          ? <button type="button" onClick={goNext}>下一步</button>
          : <button type="submit" disabled={isSubmitting}>{isSubmitting ? '提交中…' : '提交'}</button>}
        <button type="button" onClick={() => { reset(); setStep(0); }}>重置</button>
      </div>

      {submitCount > 0 && <p style={{ color: '#888', fontSize: 12 }}>已尝试提交 {submitCount} 次</p>}
    </form>
  );
}
// ============ 3. 三个子组件:Field / TagArray / AddressArray ============
function Field({ name, label, type = 'text', register, control, error }: {
  name: string;
  label: string;
  type?: string;
  register?: ReturnType<typeof useForm>['register'];
  control?: Control<FormValues>;
  error?: string;
}) {
  return (
    <label style={{ display: 'grid', gap: 4, marginBottom: 12 }}>
      <span>{label}</span>
      {register
        // ⭐ 非受控(大多数原生输入)
        ? <input type={type} {...register(name as keyof FormValues)} aria-invalid={!!error} />
        // ⭐ 受控(第三方组件)用 Controller
        : <Controller name={name as keyof FormValues} control={control!}
            render={({ field }) => <input type={type} {...field} value={String(field.value ?? '')} />} />}
      {error && <span role="alert" style={{ color: '#c00', fontSize: 12 }}>{error}</span>}
    </label>
  );
}

function TagArray({ control }: { control: Control<FormValues> }) {
  const { fields, append, remove } = useFieldArray({ control, name: 'tags' });
  return (
    <fieldset style={{ marginBottom: 12 }}>
      <legend>标签(至少一个)</legend>
      {fields.map((field, index) => (
        // ⭐⭐ key 用 field.id(不是 index)
        <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
          <Controller
            name={`tags.${index}.value`}
            control={control}
            rules={{ required: '标签不能为空' }}
            render={({ field: f, fieldState }) => (
              <>
                <input {...f} placeholder="标签名" style={{ flex: 1 }} />
                {fieldState.error && <span role="alert" style={{ color: '#c00', fontSize: 12 }}>{fieldState.error.message}</span>}
              </>
            )}
          />
          <button type="button" onClick={() => remove(index)}>删除</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ value: '' })}>添加标签</button>
    </fieldset>
  );
}

function AddressArray({ control, register, errors }: {
  control: Control<FormValues>;
  register: ReturnType<typeof useForm>['register'];
  errors?: FieldErrors<FormValues>['addresses'];
}) {
  const { fields, append, remove, move } = useFieldArray({ control, name: 'addresses' });

  // ⭐ useWatch 读「数组长度」做展示(不订阅具体值 → 打字不重渲染)
  const count = useWatch({ control, name: 'addresses' }).length;

  return (
    <fieldset>
      <legend>地址({count} 个)</legend>
      {fields.map((field, index) => (
        <div key={field.id} style={{ display: 'grid', gap: 4, marginBottom: 12, padding: 8, border: '1px solid #eee' }}>
          <input {...register(`addresses.${index}.city`)} placeholder="城市" />
          {errors?.[index]?.city && <span role="alert" style={{ color: '#c00', fontSize: 12 }}>{errors[index]!.city!.message}</span>}
          <input {...register(`addresses.${index}.zip`)} placeholder="邮编(6 位)" />
          {errors?.[index]?.zip && <span role="alert" style={{ color: '#c00', fontSize: 12 }}>{errors[index]!.zip!.message}</span>}
          <div style={{ display: 'flex', gap: 8 }}>
            <button type="button" onClick={() => remove(index)} disabled={fields.length === 1}>删除</button>
            <button type="button" onClick={() => move(index, index - 1)} disabled={index === 0}>上移</button>
          </div>
        </div>
      ))}
      <button type="button" onClick={() => append({ city: '', zip: '' })}>添加地址</button>
    </fieldset>
  );
}

import { useFieldArray, Controller, useWatch, type Control, type FieldErrors } from 'react-hook-form';
⭐⭐⭐ 这个实现覆盖了「真实项目里表单的六个难题」:

① 【分步表单 + 跨步校验】
   · 「下一步」只校验当前步:`trigger(STEPS[step].fields)`
   · ⭐ `shouldUnregister: false` 保证「上一步的值不会因 DOM 卸载而丢」
   · 提交失败时把用户送回「有错的那一步」(体验关键)

② 【`zod` 作为「类型 + 校验」的单一事实源】
   `z.infer<typeof schema>` → 类型与校验永远一致(改 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 }) => <Input disabled={formState.isSubmitting} />}
   ✅ render={({ field, fieldState }) => <Input {...field} error={fieldState.error} />}
想做的事 用哪个 为什么
输入值(不关心重渲染) 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) => <input key={i} {...register(`items.${i}.name`)} />)}   // ❌
// ✅ key={f.id}

// ⑤ 以为 `fields` 里有值(其实是空的,只有 id)
fields.map((f) => <span>{f.name}</span>)                 // ❌ 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 期望「布尔」但得到数组
<input type="checkbox" value="a" {...register('tags')} />
<input type="checkbox" value="b" {...register('tags')} />
// ⭐ 同名且有 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」,但按钮仍可点
// ✅ `<button disabled={isSubmitting}>`

// ⑲ 在 `Controller` 里直接改 `field.value`(不通过 onChange)
field.value = 'x';                                       // ❌ 不触发更新
// ✅ `field.onChange('x')` 或 `setValue`

// ⑳ 忘记「文件上传」的值是 `FileList`(不能直接 JSON 序列化)
// ✅ 提交前转成 `FormData` 或 `Array.from(files)`
// ⑪ 的完整处理(错误随输入清除)
<input
  {...register('email', {
    onChange: () => clearErrors('email'),        // ⭐ 用户一改就清除服务端错误
  })}
/>
// ⭐ 更优雅的做法:用 `setError` 时带 `type: 'server'`,
//    在 onChange 里只清 `type === 'server'` 的错误
// ⑯ 的完整处理(提交按钮的禁用条件)
const { formState: { isValid, isDirty, submitCount } } = useForm({ mode: 'onTouched' });
const canSubmit = isValid && isDirty;
// ⭐ 注意:`isValid` 只有在「至少校验过一次」后才有意义
//    配 `mode: 'onTouched'` 可以让它「用户碰过就开始算」
<button type="submit" disabled={!canSubmit || isSubmitting}>提交</button>

九、面试延伸

  1. 「react-hook-form 为什么性能好?」
    三个设计:① 非受控为主——register 把值交给浏览器维护,RHF 只在「校验/提交/按需读值」时读 DOM,所以打字不引起 React 重渲染;② 按字段订阅——内部维护订阅者表,改 A 字段时只有订阅 A 的组件重渲染;③ ⭐ formState 是 Proxy——你读哪个属性,它才订阅哪个变化(只读 isDirty 就不会因 errors 变化重渲染)。这也带来最重要的一条实践纪律:不要展开整个 formState(const { ...formState } = useForm() 等于订阅一切),只解构真正需要的字段。

  2. 「watch 和 useWatch 有什么区别?」
    watch('x') 是在当前组件里订阅——x 一变,当前组件就重渲染(如果当前组件是「整个表单」,那就等于整表单重渲染)。useWatch({ control, name: 'x' }) 是独立订阅——它把「读值」变成一个只作用于「使用它的那个组件」的订阅,所以通常做法是「把需要展示值的部分抽成一个小子组件,在里面用 useWatch」。⭐ 另外「在事件处理里读值」应该用 getValues()(不订阅、不重渲染),watch()(不带参数、读整个表单)是最差的选择。

  3. 「register 和 Controller 该用哪个?」
    判据是「这个控件是不是原生 input/select/textarea 语义」:① 是(原生输入)→ 用 register(非受控,性能最好);② 不是(antd Select/DatePicker、React Select、富文本、自研组件)→ 用 Controller(受控,把 field.value/field.onChange 接上去)。注意两点:Controller 的 render 里优先用 fieldState(只订阅该字段,比 formState 粒度细);Controller 的 rules 与 register 的第二参是同一套校验规则。

  4. 「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——这是有意设计,避免「改一个值就重渲染整个列表」。

  5. 「shouldUnregister 该怎么选?」
    两种语义都合法,取决于「字段卸载后值该不该保留」:① false(默认)——保留值,⭐ 适合「多步表单」与「条件显示的字段」(上一步的 DOM 卸载了,但提交时值还在);② true——清除值,适合「真正的条件字段」(用户选了「不要发票」,发票信息的 DOM 消失时,值也应该不在提交数据里)。用错的后果很隐蔽:该 false 的用了 true → 多步表单回退时数据丢失;该 true 的用了 false → 提交里带着「用户已经看不到的旧数据」。

  6. 「表单校验方案怎么组织?」
    三层,越靠后越灵活:① 内置规则(required/minLength/pattern/valueAsNumber)——简单场景最快;② validate(同步或异步函数)——单字段的复杂规则(如「用户名不可用」),⭐ 并可用 deps 声明「这个字段校验依赖谁」从而「依赖变化时重新校验」;③ ⭐ resolver(zod/yup/valibot)——把「校验」与「类型」合并成单一事实源(z.infer<typeof schema> 直接得到表单类型),并用 refine/superRefine 做跨字段与异步校验。实践建议:中小表单用「resolver 一把梭」,超大表单(100+ 字段)考虑「字段级 validate + 只对当前步校验」,避免每次输入都解析整个大对象。

  7. 「多步表单(Wizard)要注意什么?」
    四个要点:① shouldUnregister: false(否则回退步骤时数据会丢);② 「下一步」只校验当前步——用 trigger(当前步的字段),⭐ 不要 trigger()(全量校验,大表单会卡);③ 跨字段校验要放在 schema 的 refine 里并指定 path,否则错误会挂在「表单级」而用户找不到;④ ⭐ 提交失败后把用户送回「有错的那一步」(否则「提交失败但看不到错误在哪」)。额外提醒:reset() 只会重置表单值,「当前在第几步」这类 UI 状态要自己重置。

  8. 「服务端返回字段错误怎么处理?」
    用 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 清除。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.13 表单进阶:react-hook-form

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫