React Hook Form 设计思路浅析

3,558 阅读13分钟

背景

先说下大背景,笔者终极目标是为了提高前端中后台类项目的开发效率,于是先后研究了 开箱即用的工具 - BI低代码漫谈 系列。后者还在进行当中,现在处于 Form 表单状态管理工具调研 这个子主题下。

目前这个子主题的目的是从业内主流的表单状态管理框架中学习一些思想,获取一些灵感,来让表单开发尽可能的配置化,从而提高开发效率。更具体的原因以及优缺点分析,请阅读《Form 表单状态管理工具调研》中的背景部分。

react-hook-form(以下简称 RHF) 作为目前业内风头最盛的框架绝对值得深入研究。废话不多说,开始吧。

介绍

react-hook-form 的 官网 给出的定义:

Performant, flexible and extensible forms with easy-to-use validation.

谷歌翻译:具有易于使用的验证的高性能、灵活和可扩展的表单。

再来看下 TSX 的 Demo。

import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";

type Inputs = {
  example: string,
  exampleRequired: string,
};

export default function App() {
  const { register, handleSubmit, watch, formState: { errors } } = useForm<Inputs>();
  const onSubmit: SubmitHandler<Inputs> = data => console.log(data);

  console.log(watch("example")) // watch input value by passing the name of it

  return (
    /* "handleSubmit" will validate your inputs before invoking "onSubmit" */
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* register your input into the hook by invoking the "register" function */}
      <input defaultValue="test" {...register("example")} />
      
      {/* include validation with required or other standard HTML validation rules */}
      <input {...register("exampleRequired", { required: true })} />
      {/* errors will return when field validation fails  */}
      {errors.exampleRequired && <span>This field is required</span>}
      
      <input type="submit" />
    </form>
  );
}

从 Demo 我们可以看出,useForm 是 RHF 的核心 hook,其返回的值会被使用到不同的地方。纵观 RHF 的文档,一共只有 6 个 hooks,可以说是非常简洁了,它们分别是:useFormuseControlleruseFormContextuseWatchuseFormStateuseFieldArray,我们分别来看一下。

API 解析

useForm & useFormContext

useFormContext 的功能和用法几乎跟 useForm 一样。从名字也能看出来,useFormContext 是提供上下文的,盲猜也知道是方便深度嵌套组件的场景使用,只要最外层用 FormProvider 包裹注入数据即可,用法详见文档 。功能就只介绍 useForm 了,反正它俩没太大区别。

useForm 是 RHF 最主要的 hook,所以需要重点分析一下,其它 hooks 多少都与其有关系。篇幅有限,我们挑重点的说,useForm 的入参这里就不细说了,可以直接看文档,很好理解,不需要太多解释,我们重点来看几个返回值:register、formState、watch、handleSubmit、setValue、control

register(重要)

register 的类 TS 声明如下:

register: (name: string, RegisterOptions?) => ({ onChange, onBlur, name, ref })

再结合 Demo 中的代码来看

<input defaultValue="test" {...register("example")} /> 
<input {...register("exampleRequired", { required: true })} />

可见 register 就是给表单输入组件用的,入参 name必填且唯一的,其实就是字段标识,第二个参数主要是一些 validate 校验规则的配置,详情可看文档,这里暂不展开,我们重点来看返回值。

其实看到这里挺让笔者惊讶的,竟然这么简单?只有 4 个返回值?甚至连 value 都没有?这块其实挺引人深思的。onChange 和 onBlur 就不说了,很好理解,肯定是在原始的回调事件之前加入了 RHF 自己的校验、监听等功能。令笔者感到困惑的是竟然没有 value。不管从 register 的入参还是后面的 setValue 方法来看,这些组件显然都是受控组件,是不可能没有 value 的。就算可以根据 name 在内存中维护数据的全部变化,但是表单组件是需要回显的啊。所以答案就很明显了,RHF 是用 ref 实现的 value 属性的功能。于是笔者做了个小实现,把 Demo 中的 input 封装了一下(没有使用 React.forwardRef,也就是无法正确的将 ref 传给 input):

const InputDemo = (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />;

用其替换原 input 并使用的时候,果然失灵了。表现是:输入框还可以正常输入,可以正常回显,但是 onSubmit 中的 data 相应字段变成了 undefined。

这意味着:所有想要接入 RHF 的组件,必须能够正确处理 ref 属性(即必须用 React.forwardRef 正确处理),否则无法正常工作

划重点:重要的事情再强调一遍,想接入 RHF 的组件,不一定需要接收 value 属性,但必须接收 ref 属性。这点的影响很大,会影响到组件的封装,请务必!务必!务必!注意(后文有反转)。

经过进一步测试,笔者初步得出结论:RHF 对于 value 的读写都是根据 ref 实现的;另外,即使 ref 绑定在了组件的最外层,RHF 也会自动深入遍历出第一个组件类表单去处理,这还挺智能的。这段文字看不懂没关系,不影响整个文章的理解。

formState

该对象包含了很多信息,比如 isSubmitted、isSubmitSuccessful、isSubmitting、isValidating 等表示当前状态的字段,相当于表单状态生命周期的穷举,更多内容请看文档。参考它的设计,可以对表单生命周期有一定的了解。

最关键的是它还有一个 errors 对象,这个对象对于表单校验来说至关重要,而且几乎每次写表单都会用到。这里就不做扩展了,关键是学习 formState 对于表单生命周期的状态穷举。

watch

刚看到这个 watch 的时候,笔者以为跟 Vue 当中的 watch 一样。仔细研究后,发现其实它更像 Vue 中的 computed。也就是 watch(name) 的返回结果是一个响应式的值,只要 watch 的 name 对应的值变了,返回值也会变化。结合文档中的例子更容易理解:

  <input type="checkbox" {...register("showAge")} />
  {watch("showAge", false) && (
    <input type="number" {...register("age", { min: 50 })} />
  )}

showAge 的默认值为 false,当控制 showAge 的 checkbox 值变化时,会根据其值显示或隐藏 age 的输入框。这是一个比较常见的联动需求,也就是说 watch 就是用来解决字段联动的,这个思路的确值得参考。

但是笔者有个疑问,看起来这个 watch 应该可以独立作为一个 hook 暴露出来,而不需要作为 useForm 的返回值才对,这个设计的必要性暂时还没有参透(后文有解答)。

handleSubmit

handleSubmit 的 TS 声明为:

(
  (data: Object, e?: Event) => void, 
  (errors: Object, e?: Event) => void,
) => Function

// usage
<form onSubmit={handleSubmit(onSubmit, onError)}>
// code
</form>

入参是函数,返回值也是函数,明显是个高阶函数。其作用就是在触发 onSubmit 之前先执行校验逻辑。与 watch 一样,笔者还是觉得它可以单独作为一个 hook 存在,这块还是先挖个坑吧,看后面能不能填上。

setValue

setValue、setFocus、getValues、getFieldState 甚至 trigger,都可以算作一类:操作真实组件。没有太多需要解释或扩展的,只是借此再次强调一下上文提到的 ref 属性的重要性,也算是再次强调一下这个设计。

control(核心、重要)

这个只读对象很特别,它非常关键,主要是作为 useControlleruseWatchuseFormStateuseFieldArray 以及 Controller 组件的一个必要参数。文档中的定义为:

This object contains methods for registering components into React Hook Form.

打印出来会有很多 _ 开头的属性和方法,的确包含了已经注册了的组件的全部信息和方法。如果想更好的理解这个属性,还是得看它如何使用,我们后文接着说(此时笔者还没有意识到问题的严重性)。

useWatch & useFormState

为什么先说这两个?为什么放在一块说?其实理由很直接,就是因为它们刚刚在 useForm 的返回值中出现过,而且笔者在上文写 watch 的时候也提出过为什么不能单独成 hook 的疑问,这里就直接解答了。这两个 hooks 都接收 namecontrol 作为必要入参,当然还有其他入参,不过没那么重要就不展开说了,还是主要分析一下设计思路。说到这,上面留的疑问就有答案了:

watch 可以单独抽成一个 useWatch hook,但是必须依赖 control,也就是要有全部已注册组件的信息和附带方法

这点似乎不难理解,毕竟当前 watch 的字段不可避免的会受到其他字段的影响,useFormState 也同理,但是这里又出现了一个疑问:

从 Demo 的例子当中我们看到,字段或组件的注册,并不是在初始化 useForm 的时候就完成的,而是之后通过 register 方法的调用才完成的注册。

  <input {...register("name", { required: true, maxLength: 50 })} />
  <input type="checkbox" {...register("showAge")} />

所以笔者大胆推测,register 方法中包含了往 control 对象当中写入数据的逻辑,这样才能使 control 对象拥有全部注册组件的信息。

为了验证猜想,通过查看源代码发现,useForm 返回的 register 其实是 createFormControl 函数的一个返回值,在 useForm 源码 中根本找不到 register 的关键字,而且这个函数的确有向一个对象当中 set 属性的逻辑。

关系的梳理似乎又清晰了一点,实际上 control 才是整个 RHF 的核心,useForm 返回的那些对象和方法,实际上全都是 control 暴露的,useForm 只能算是个壳子(惊不惊喜,意不意外)。

最后,useFormState 文档下还有一个 ErrorMessage 组件,可以简单视为一个专门处理 errors 对象的组件。个人认为实际使用能用到它的机会很少,毕竟已经有了完整的 errors 信息,想渲染什么组件自己写就好了,也不需要专门用 ErrorMessage 组件承载,所以就不多介绍了。

useController

前面为了解释清楚 control 对象,已经铺垫了那么多了,我们最后深入的再看一下 useController,进一步加深一下理解。

useController 的类 TS 声明及 Demo:

// defination
type useController = (props?: UseControllerProps) => { field: object, fieldState: object, formState: object }

// Demo
import React from "react";
import { TextField } from "@material-ui/core";
import { useController, useForm } from "react-hook-form";

function Input({ control, name }) {
  const {
    field: { onChange, onBlur, name, value, ref },
    fieldState: { invalid, isTouched, isDirty },
    formState: { touchedFields, dirtyFields }
  } = useController({
    name,
    control,
    rules: { required: true },
    defaultValue: "",
  });

  return (
    <TextField 
      onChange={onChange} // send value to hook form 
      onBlur={onBlur} // notify when input is touched/blur
      value={value} // input value
      name={name} // send down the input name
      inputRef={ref} // send input ref, so we can focus on input when error appear
    />
  );
}

从 Demo 我们可以看出,useController 的作用,就是让我们可以把一个普通表单输入组件,封装成 RHF 可用的组件

另外,注意 field 字段,它的返回值比 register 的结果多了一个 value,其他完全一样。还记得上文划的第一个重点,强调了 ref 的重要性,这里为我们提供了 value。(反转来了)经过笔者测试,value + onChange 的组合仍然是可以满足全部功能的,为了保证文章的结构,测试代码和结果放在了最后的附录。所以,在传入了 control 的前提下,要封装的组件就不需要接收 ref 了。请注意,虽然不需要接收 ref 了,但是换成了接收 control,其实也差不太多。不过后者给了我们封装那些没有被正确处理 ref 的组件的能力,也许更实用一些。

文档中 useController 的下面还有个 Controller 组件,实际上就是 useController 组件化的表现,二者功能完全相同,只是用法不一样而已,二选一即可。个人还是推荐 hook 的方式,可以让逻辑和表现更好的分离。不过有的时候在循环或递归渲染的时候 Controller 组件会更好用一些。

useFieldArray(重要)

表单当中处理数组类的值通常都是个难点,RHF 的解决方案是 useFieldArray,我们来看一下它的用法和设计思路,直接看 Demo:

import React from "react";
import { useForm, useFieldArray, Controller } from "react-hook-form";

export default function App() {
  const { register, control, handleSubmit, reset, trigger, setError } = useForm({
    // defaultValues: {}; you can populate the fields by this attribute 
  });
  // Notice 1
  const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
    control,
    name: "test"
  });
  
  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <ul>
        {fields.map((item, index) => (
          <li key={item.id}>
            {/* Notice 2 */}
            <input {...register(`test.${index}.firstName`)} />
            {/* Notice 3 */}
            <Controller
              render={({ field }) => <input {...field} />}
              name={`test.${index}.lastName`}
              control={control}
            />
            <button type="button" onClick={() => remove(index)}>Delete</button>
          </li>
        ))}
      </ul>
      <button
        type="button"
        onClick={() => append({ firstName: "bill", lastName: "luo" })}
      >
        append
      </button>
      <input type="submit" />
    </form>
  );
}

先看 Notice 1 备注,这部分是 useFieldArray 的核心,入参是 controlname,很好理解,返回值就很精彩了,看名字也能看出是各种操作数组的方法,还有一个 fields 数组。

插播一下 Notice 2 和 3,实际上它们功能完全一样,都是实现一个 RHF 可用的 input 组件,只是分别用了 registerController 组件 两种方式而已,这里也算是回顾一下上面的内容,加深一下理解,在理解 useFieldArray 时只需要关注任意一个即可。

另外,笔者特意打印了一下 item.id ,大概是这样 452a0242-d04f-4c68-965c-73eb8acf920d,应该只是跟 UI 有关,这么复杂的数据应该不会放到 data 当中。

该例当中只用了 append 和 remove 方法,用法也很直接,不需要过多解释,其它方法可以详见文档。这个设计真的很巧妙,把数组的生成和操作数组的方法集成到一个 hook 里,可以很好的解决它们之间的关系,可以说是举重若轻。事后诸葛亮来看,这个设计不复杂,这也就是其巧妙所在,想到了就很简单,想不到就会异常复杂。纵观全篇,这块对于笔者来说,启发是最大的。

总结

所有 API 分析下来,我们可以看出,RHF 的核心就是这个 control 对象,所有的 API 都要依赖这个对象,useForm 也只是在它外面套了一个壳子。

如果想要封装一个配置化的 Form 组件,实际上是可以将 control 对象的流转在组件内部消化的,Form 的任何表单输入子组件只需要暴露出 valueonChangeonBlur 这 3 个 API 即可(统一用 useController 实现的话,ref 可选,name、validate 相关的由 FormItem 组件承载)。

Form 组件需要对外暴露的 API 可以参考 useForm 的入参,其实不算复杂。当然这些只是逻辑相关的 API,UI 相关的 API 实在太多太杂了,而且更应该参考 UI 组件库,而不是 RHF 这种状态处理工具。所以,我们将范围限定在状态管理 API 设计之后,今天的内容就可以收尾了。后续还会有 formikfinal-form 的解析,最后会综合三者,给出一套可配置化表单组件的标准 API,当然只是状态管理相关的 API,UI 部分后续再说,敬请期待。

“问题不可能在产生问题的意识水平上得到解决。”

"Problems cannot be solved at the same level of awareness that created them."——Albert Einstein

附录

control 属性功能测试

测试代码:

import React from "react";
import { useForm, SubmitHandler, useController } from "react-hook-form";

type Inputs = {
  controlTest: string;
};

// @ts-ignore
function Input({ control, name }) {
  const {
    field: { onChange, onBlur, name: _name, value, ref },
  } = useController({
    name,
    control,
    rules: { required: true },
    defaultValue: "",
  });

  return (
    <input
      onChange={onChange} // send value to hook form
      onBlur={onBlur} // notify when input is touched/blur
      value={value} // input value
      name={_name} // send down the input name
      ref={ref}
    />
  );
}

export default function App() {
  const { handleSubmit, control, setValue } = useForm<Inputs>();
  const onSubmit: SubmitHandler<Inputs> = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <button onClick={() => setValue("controlTest", "xxxx")}>setValue</button>
      <Input control={control} name="controlTest" />
      <input type="submit" />
    </form>
  );
}

测试结果:

输入回显输入值进 datasetValue 回显setValue 进 data
没 value,没 ref,没 onChangeXX
有 value,没 ref,没 onChangeXX
没 value,有 ref,没 onChangeXX
没 value,没 ref,有 onChangeX
有 value,有 ref,没 onChangeXX
有 value,没 ref,有 onChange
没 value,有 ref,有 onChangeX
有 value,有 ref,有 onChange