陌上人如玉
公子世无双

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 手写表单库](../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 写法(这里最容易出错)

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

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

微信扫一扫

支付宝扫一扫