react-hook-form实践

139 阅读9分钟

1、背景

表单的场景在我们的IT生涯中总是会遇到那么一次,这一次尤其的复杂,单表单内表单元素多,表单元素类型多,校验复杂,例如联动校验,要求我们在输入后立即校验,校验的错误信息直接提示在元素下方,此外在离开当前表单前还需要做是否保存改动的弹框提示等,除了创建表单,也需要编辑表单,以及只做表单内容展示,当这些需求点叠加后,就是考验我们前期设计能力的时候,再者更复杂的,步骤条表单,每个步骤的表单都包含前面的内容,此时我们要考虑的更多,每个步骤之间的强校验,每个步骤的校验状态更新,保存草稿,最后一步提交前的全量表单校验等,如果设计不当,就容易出现各种问题,以及后续维护难等一系列问题。

目前针对复杂表单的场景,国内使用阿里系的ant-design的form组件可以应对大部分场景,他们内部基于阿里开源的formily,本人没有使用过,只是了解到它的学习难度还是有的,底层基于Proxy代理去控制更新和提供性能,在这里不做探讨,而如果脱离阿里系,例如本人使用MUI作为UI组件库,选用了react-hook-form作为表单库,首先它在github上fork和star的数量都很高,其次有自己单独的官网,文档齐全,论坛等比较活跃,但是国内用的人不算多,而且它比较轻量,支持受控和非受控两种模式,支持搭配多种Schema Validation,例如yup、zod、joi等,写法上也相对简单易懂。

2、能解决什么问题?

它能解决什么问题?这是我们都关心的,常见的表单场景它都能支持,我自己项目的场景是步骤条表单,输入后立即触发校验,校验信息在表单元素底部显示,有联动校验,常见的表单类型例如输入框、chexkbox group、radio group、table的增删查改行(最大支持700行)、Datepicker、Autocomplete等,此外还有离开表单的是否保存当前改动的弹框提示,除了完成以上的功能外,还要考虑性能问题,例如更新表单值是否出现卡顿的现象,是否出现重复render问题,那可以告诉大家,它都可以处理到这些问题,如果只是单表单复杂度会更低一些。

3、核心原理

首先大家关心的性能指标,提交一个链接给大家参考,也是官方出的performance link,这里提到react-hook-form的性能还是不错的 而大家关心的局部刷新,优化render,核心都是基于使用Proxy,内部自己实现了发布订阅机制,官方都是推荐使用非受控的方式来处理表单,但是现代化的前端框架中,大家基本会使用UI组件库,而这些框架大部分是基于受控的,以及如果有联动的逻辑,非受控都不太好处理,对于受控模式,react-hook-form也是提供了Controller Component来处理,内部也是做了大量的优化避免重复render

4、项目实践和踩坑

一、watch和useWatch的区别

  • useWatch 会订阅表单数据的变化,且它初始返回的值一定是来自 useForm 里的 defaultValuedefaultValues

  • useWatchwatch 的差别主要体现在更新触发的位置

    • watch 是在当前组件内同步访问最新值,会立即触发 render(如果你使用了它的返回值)。
    • useWatch 是一个订阅,React 会根据它的调用顺序先建立订阅,然后监听后续变化,这也意味着
  • useWatch 的执行顺序非常关键,如果你在订阅建立前就更新了表单值,那个更新可能不会被捕捉到,因为当时订阅还没生效。

推荐监听改动更新UI使用useWatch, watch是个函数 useWactch是hook,订阅更新的颗粒度更细 用 watch 有时候会触发更多重新渲染,而用 useWatch 订阅更细粒度,性能更优

二、监听dirty的变化

当手动触发其他字段的setvalue,要手动触发dirty的比对,否则就不会放进dirtyField去,,注意下面的shouldDirty字段的配置

setValue(`${FORMEnum.DateTime}.planStartDtm`, dueDtm, { shouldDirty: true });

三、关于订阅formState的困惑

formState是包含了整个表单状态的值,比如dirty相关,error相关等,它是使用Proxy来包裹的,你必须「先访问某个字段」,React Hook Form 才会对这个字段的变化进行监听(订阅)并触发组件更新,这里要注意:
const { control, setValue, formState } = methods; 这一句只是解构了methods,formState是一个完整的对象存在,它不会触发render,但是下面这种方式:

const { control, setValue, formState} = methods;  
const { dirtyFields, isValid, touchedFields } = formState;

此时解构了formState,那么就会订阅解构的这三个值的变化,会导致render,所以如果用不到相关的状态请不要解构。 对应需要用useeffect去订阅formState的变化,官方推荐要把完整的formState放进依赖数组,如下:

useEffect(() => {
  if (formState.errors.firstName) {
    // do the your logic here
  }
}, [formState]) // ✅
// ❌ [formState.errors] will not trigger the useEffect

这里有个有意思的地方,如果在useEffect里不去获取formSate里的值,也不会触发这个useEffect,归根结底还是只有访问formState某个字段,才会触发对这个字段订阅,就算你只是解构了formSate,但是实际没用到,也会触发更新。

四、table列表要使用useFieldArray

table列表的增删查改一定要使用配套的方法,setValue更新table内的字段会出现无效的error,例如新增行使用append,更新行update,删除行remove等,useFieldArray内部使用uuid作为行的唯一key,我们可以不用关心唯一key的生成,但是唯一key默认使用了id为字段名,如果和业务的命名有冲突,可以换一个keyname,不过这个属性后续版本会移除,最好是和后端商量下唯一键的命名,此外当保存列表到后端也有注意点,通常存到后端后会自动返回新的唯一id,不会使用前端生成的这个唯一id,此时就要拿回后端返回列表替换掉前端这一份,存在的问题就是唯一key变了,会导致整个列表重新render一次,这个场景都很难避免,否则强行进行映射,逻辑也会更复杂,出BUG的概率也会增加

五、步骤条表单的校验问题

步骤条表单的场景操作就是填写完当前步骤表单再按下一步按钮去到下一张表单,在最后一个表单里进行提交,那每一次用户只会见到当前表单,每次按下一步的时候会校验当前表单并自动存一次草稿到后端,而最后提交的时候还是需要对所有步骤表单进行一次校验,这也是防止某几个表单直接有关联,中途改了某一张后,直接跳到最后一张进行提交,如下图的框架,步骤条上都会显示每一步骤的校验结果,用户也可以点击步骤条上的步骤进行跳步,当然只有当前表单校验过了才能往前跳步,而往后面的步骤则只是校验一次无需强制不能跳转,这样也是提升体验,这种场景下就存在必须要在提交做一次全局校验,react-hook-form只校验存在dom里的表单元素,步骤条表单只显示当前步骤,这就导致启用它自带的校验无法进行所有表单的校验,你可能会说把其他步骤用css进行隐藏不就行了吗,dom也会存在,这是可行的,但是到最后一步的时候底层显示了所有表单的dom,一定会存在卡顿的问题,而且也不安全,查询了资料,这种情况我暂时未找到解决方案,最后是为每一步骤写了单独的校验方法,在最后提交的时候执行一次所有步骤的自定义校验方法,这种虽然和自带的校验重复的问题以及同步改动的问题,但是以功能完整性为主

image.png

六、如何避免不必要的render

1、如果需要监听字段变化,请使用useWatch

2、如果需要监听dirtyFields请务必不要放在父组件的顶层,它会导致整个子组件的刷新,可以封装到一个单独的组件监听去做逻辑,和子组件放在同一层级,例如:

function FormDirtyWatcher() {
  const { control } = useFormContext();
  const { dirtyFields } = useFormState({ control });

  useEffect(() => {
    if (Object.keys(dirtyFields).length > 0) {
      // 这里可以触发父组件或全局状态,提示有脏数据
      console.log('表单被修改了');
    }
  }, [dirtyFields]);

  console.log('---re-FormDirtyWatcher isDirty', dirtyFields);

  return null; // 这个组件不渲染任何 UI,只做监听和副作用
}

3、如果使用FormProvider进行传递methods,要尤其注意了,如果你放在父组件去使用useForm,那么如果你的父组件内还有其他状态变化就会导致组件刷新进而导致useForm重新调用,这时候methods的引用就会发生变化,那么所有用到useFormContext的组件都会重新render,建议是再封装一层,把useForm单独放在这个一层组件内,保持引用稳定,然后把业务的父组件包括在这个一层里面,如此业务父组件有状态变化也不会导致useForm的重新创建,如下图,MainPage才是业务的父组件

function DashBorad(props: ChangeInfoProps) {
  const methods = useForm<FormProps>({
    defaultValues: {
      id: '',
      [FORMEnum.ChangeInfo]: {},
    },
    mode: 'onChange',
    shouldFocusError: false,
  });
  console.log('---re-render DashBorad');
  return (
    <FormProvider {...methods}>
      <MainPage />
    </FormProvider>
  );
}