金数据技术博客 · №5

学习 React-hook-form

· silverWolf818 · frontend / React

一、React 中的表单

首选我们来看 React 官方如何实践表单处理。直接打开官方文档

官方给出了两种不同的表单方案,基于受控组件以及基于非受控组件的表单实现,当然前者会比较常见一些。所有的第三方表单都可以认为是这两种方案的延伸及封装。例如:

1. 受控组件

在 HTML 中,表单元素(如 <input><textarea><select>)通常自己维护 state,并根据用户输入进行更新。而在 React 中,可变状态(mutable state)通常保存在组件的 state 属性中,并且只能通过使用 setState() 来更新。

我们可以把两者结合起来,使 React 的 state 成为“唯一数据源”。渲染表单的 React 组件还控制着用户输入过程中表单发生的操作。被 React 以这种方式控制取值的表单输入元素就叫做“受控组件”。

简单来说就是指由父组件完全控制该组件的状态及回调函数,子组件的状态变更需要通过回调函数通知到父组件,由父组件完成状态变更后再将新值传回子组件。

表现在代码上就是形如之类的表单组件同时接收 value 以及 onChange 这两个 props 来实现受控。来看一个官方的实例

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};

    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(event) {
    this.setState({value: event.target.value});
  }

  handleSubmit(event) {
    alert('提交的名字: ' + this.state.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          名字:
          <input type="text" value={this.state.value} onChange={this.handleChange} />
        </label>
        <input type="submit" value="提交" />
      </form>
    );
  }
}

2. 非受控组件

刚说到受控组件所有的状态都由外界接管,非受控组件则恰恰相反,它将状态存储在自身内部,我们可以借用 React 中的 ref 来访问它。同样还是来看一个官方的实例

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.input = React.createRef();
  }

  handleSubmit(event) {
    alert('A name was submitted: ' + this.input.current.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" ref={this.input} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

3. 如何选择

关于受控 vs 非受控的选择这个问题 🤔 ,我们就交给 google 帮助我们回答 controlled vs uncontrolled in react 😜

查阅了许多文档,大部分文档认为应该优先考虑受控模式,主要举例说明受控之于非受控更灵活,更符合 react 单向数据流。这里还有一张对比图:

图中列举的一些非受控组件无法覆盖的场景,实际上 ref 配合组件 onChange 是可以做到的,例如字段的实时校验,我们完全可以在字段上挂上监听函数,在其值发生改变的时候进行字段校验,然后通过 ref 控制组件的内部状态。

说了这么多内容,其实问题的关键在于 React 理念,也就是我们常说的单向数据流 ViewModel 🧐 。所以理所应当我们开发的程序也应该是如此。

受控和非受控是站在组件状态(值)的存储位置来看的,本质上受控与非受控的表达能力是相同的,从某种层面上看可以互相实现。

二、React-hook-form

React-hook-form 是一个基于非受控组件实现的表单库。

非受控表单就不是使用 state 单向数据流来控制表单,它是通过 ref 来直接拿到表单组件,从而可以直接拿到表单的值,不需要对表单的值进行状态维护,这就使得非受控表单可以减少很多不必要的渲染

非受控表单也存在着它的问题,在动态校验、动态修改(联动)方面不是很方便,于是诞生了一个以非受控思想为基础的表单库 react-hook-form,它可以帮助我们解决这些问题。还有一点好处就是基于非受控的组件,完全拥抱原生 HTML 标准。

使用 ReactReact-hook-form简单示例

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

export default function App() {
  const { register, handleSubmit, watch, formState: { errors } } = useForm();
  const onSubmit = 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>
  );
}

1. 核心原理

我们看到 react-hook-form 在使用时必须先调用 useForm 方法,该方法返回了一系列表单相关的 API。

useForm 方法内部都干了什么 🤔 ?

  1. 定义表单状态,例如 isDirtyisValidatingdirtyFields 等。源码位置
const [formState, updateFormState] = React.useState<FormState<TFieldValues>>({
  isDirty: false,
  isValidating: false,
  dirtyFields: {} as FieldNamesMarkedBoolean<TFieldValues>,
  isSubmitted: false,
  submitCount: 0,
  touchedFields: {} as FieldNamesMarkedBoolean<TFieldValues>,
  isSubmitting: false,
  isSubmitSuccessful: false,
  isValid: false,
  errors: {} as FieldErrors<TFieldValues>,
});
  1. 创建表单控制器属性及方法。源码位置
if (_formControl.current) {
  _formControl.current.control._options = props;
} else {
  _formControl.current = {
    ...createFormControl(props),
    formState,
  };
}

这里的 createFormControl 方法是 react-hook-form 的核心,表单整体的状态以及表单提供对外的 API,如handleSubmitsetValuegetValues 校验等,以及字段的注册、订阅方法等等都会这里预先处理。

createFormControl 方法定义的内如下,这里只是一部分。

这部分中有一个很重要的内容就是 _subjects 这内部属性,它会创建一个发布订阅的实例,表单字段可以通过该实例进行字段间的通信,达到字段关联的目的。源码位置

let _formState: FormState<TFieldValues> = {
  isDirty: false,
  isValidating: false,
  dirtyFields: {} as FieldNamesMarkedBoolean<TFieldValues>,
  isSubmitted: false,
  submitCount: 0,
  touchedFields: {} as FieldNamesMarkedBoolean<TFieldValues>,
  isSubmitting: false,
  isSubmitSuccessful: false,
  isValid: false,
  errors: {} as FieldErrors<TFieldValues>,
};
let _fields = {};
let _defaultValues = _options.defaultValues || {};
let _formValues = _options.shouldUnregister
  ? {}
  : cloneObject(_defaultValues);
let _stateFlags = {
  action: false,
  mount: false,
  watch: false,
};
let _names: Names = {
  mount: new Set(),
  unMount: new Set(),
  array: new Set(),
  watch: new Set(),
} as Names;
let delayErrorCallback: DelayCallback;
let timer = 0;
let validateFields: Record<InternalFieldName, number> = {};
const _proxyFormState = {
  isDirty: false,
  dirtyFields: false,
  touchedFields: false,
  isValidating: false,
  isValid: false,
  errors: false,
};
const _subjects: Subjects<TFieldValues> = {
  watch: createSubject(),
  array: createSubject(),
  state: createSubject(),
};

createFormControl 方法返回的属性及其方法(_标识的属性和方法属于 react-hook-form 内部方法不对外暴露),就是 useForm 对用户暴露的 API。

return {
  control: {
    register,
    unregister,
    _executeSchema,
    _getWatch,
    _getDirty,
    _updateValid,
    _removeUnmounted,
    _updateFieldArray,
    _getFieldArray,
    _subjects,
    _proxyFormState,
    get _fields() {
      return _fields;
    },
    set _fields(value) {
      _fields = value;
    },
    get _formValues() {
      return _formValues;
    },
    set _formValues(value) {
      _formValues = value;
    },
    get _stateFlags() {
      return _stateFlags;
    },
    set _stateFlags(value) {
      _stateFlags = value;
    },
    get _defaultValues() {
      return _defaultValues;
    },
    set _defaultValues(value) {
      _defaultValues = value;
    },
    get _names() {
      return _names;
    },
    set _names(value) {
      _names = value;
    },
    get _formState() {
      return _formState;
    },
    set _formState(value) {
      _formState = value;
    },
    get _options() {
      return _options;
    },
    set _options(value) {
      _options = {
        ..._options,
        ...value,
      };
    },
  },
  trigger,
  register,
  handleSubmit,
  watch,
  setValue,
  getValues,
  reset,
  resetField,
  clearErrors,
  unregister,
  setError,
  setFocus,
};
  1. 观察表单整体的状态变化

接着我们继续回到 useForm 方法,react-hook-form 还会观察表单的变化,来更新表单的状态(例如:校验,是否修改等)。

useSubscribe({
  subject: control._subjects.state,
  callback: (value) => {
    if (shouldRenderFormState(value, control._proxyFormState, true)) {
      control._formState = {
        ...control._formState,
        ...value,
      };

      updateFormState({ ...control._formState });
    }
  },
});

注册字段目前 react-hook-form 提供 3 中 API。

  1. register

register 方法提供对外非受控控件 ref 属性及其更改方法。源码位置

const register: UseFormRegister<TFieldValues> = (name, options = {}) => {
    let field = get(_fields, name);

    //...

    return {
      name,
      onChange,
      onBlur: onChange,
      ref: (ref: HTMLInputElement | null): void => {
        field = get(_fields, name, {});

          if (field._f) {
            field._f.mount = false;
          }

          (_options.shouldUnregister || options.shouldUnregister) &&
            !(isNameInFieldArray(_names.array, name) && _stateFlags.action) &&
            _names.unMount.add(name);
      },
    };
  };
  1. ControlleruseController

Controller 是以 React 组件的形式进行注册,其核心使用的是 useController源码位置

const Controller = <
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
  props: ControllerProps<TFieldValues, TName>,
) => props.render(useController<TFieldValues, TName>(props));

export { Controller };

这里重点介绍 useController 这个钩子方法。** 在实际项目中会更倾向采用 useController 方式注册字段 **

useController 内部实现很有意思 🤪 。

  1. 字段值得获取。

useController 直接使用订阅的方式获取字段的值。源码位置

const value = useWatch({
  control,
  name,
  defaultValue: get(
    control._formValues,
    name,
    get(control._defaultValues, name, props.defaultValue),
  ),
  exact: !isArrayField,
}) as UnpackNestedValue<FieldPathValue<TFieldValues, TName>>;
  1. 字段通过 register 注册。

useController 内部还是使用 register 来注册字段,这就是为什么 createFormControl 会返回一个 control 属性并且其中包含了 register 方法。源码位置

useController hook 是为了字段的分布式注册。而不局限于 useFormregister

const registerProps = control.register(name, {
    ...props.rules,
    value,
});

react-hook-form 表单的提交相对简单,在内部通过 _formValues 来收集分布式注册的字段的值,然后进行提交。源码位置

const handleSubmit: UseFormHandleSubmit<TFieldValues> =
(onValid, onInvalid) => async (e) => {
  let fieldValues: any = _options.shouldUnregister
    ? cloneObject(_formValues)
    : { ..._formValues };

  _subjects.state.next({
    isSubmitting: true,
  });

  // ....
};

2. 表单交互

当我们进行表单交互 react-hook-form 中又做了什么呢? 🤔

createFormControl 中的 onChange 方法就是来相应表单交互的变化。源码位置

const onChange: ChangeHandler = async (event) => {
  const target = event.target;
  let name = target.name;
  const field: Field = get(_fields, name);

  if (field) {
    let error;
    let isValid;
    const fieldValue = target.type
      ? getFieldValue(field._f)
      : getEventValue(event);
    
    set(_formValues, name, fieldValue);
	
    //...
    
    const fieldState = updateTouchAndDirty(
      name,
      fieldValue,
      isBlurEvent,
      false,
    );

    const shouldRender = !isEmptyObject(fieldState) || watched;
    
    validateFields[name] = validateFields[name] ? +1 : 1;
    
    field._f.deps && trigger(field._f.deps as FieldPath<TFieldValues>[]);

    shouldRenderByError(false, name, isValid, error, fieldState);
  }
};

这里首先会获取当前值变化的字段信息,然后还会判断该字段是否被 watch ,还有对字段进行 validateFields 操作。

这里总结一下 react-hook-form 基本流程:

表单初始化

表单交互

react-hook-form 都是基于字段级别的渲染:

  1. 字段分布式注册,每一个字段的状态由组件自己负责,并不需要数据回流。

  2. 表单的内部状态(isDirtytouchedisSubmitting 等)统一用过 Proxy 包装, 在初次渲染的时候利用 Proxy 记录用户对于各个状态的订阅情况,不订阅的话变化将被忽略,不引发重新渲染。

  3. 对错误进行浅层比较,例如上一轮渲染已经展示了错误信息,如果这一轮渲染错误信息不变的话,则不重新渲染.

  4. 虽然 watch 默认会触发全局渲染,不过 useWatch 可以做到不触发全局渲染的情况下通知某个字段的更新,本质上是发布订阅机制。

3. 动态校验以及联动

为了支持动态校验,react-hook-form 在进行表单注册的时候还会将 onChangeonBlur 等事件挂载到表单组件上,保证对与用户输入、修改行为的监听,从而可以对表单校验、表单值监听等进行触发。

非受控表单除了动态校验的问题,还存在联动实现的问题。由于 react-hook-form 不会将表单的值维护在 React 的 state 中,用户输入不会触发其他组件的更新,因此 react-hook-form 提供了 watch 和性能更好的 useWatch

watch 官方示例:

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

function App() {
  const { register, watch, formState: { errors }, handleSubmit } = useForm();
  const watchShowAge = watch("showAge", false); // you can supply default value as second argument
  const watchAllFields = watch(); // when pass nothing as argument, you are watching everything
  const watchFields = watch(["showAge", "number"]); // you can also target specific fields by their names

  // Callback version of watch.  It's your responsibility to unsubscribe when done.
  React.useEffect(() => {
    const subscription = watch((value, { name, type }) => console.log(value, name, type));
    return () => subscription.unsubscribe();
  }, [watch]);

  const onSubmit = data => console.log(data);

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input type="checkbox" {...register("showAge")} />
        
        {/* based on yes selection to display Age Input*/}
        {watchShowAge && <input type="number" {...register("age", { min: 50 })} />}
        
        <input type="submit" />
      </form>
    </>
  );
}

useWatch 官方示例:

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

function FirstNameWatched({ control }) {
  const firstName = useWatch({
    control,
    name: "firstName", // without supply name will watch the entire form, or ['firstName', 'lastName'] to watch both
    defaultValue: "default" // default value before the render
  });

  return <div>Watch: {firstName}</div>; // only re-render at the component level, when firstName changes
}

function App() {
  const { register, control, handleSubmit } = useForm();
  
  const onSubmit = (data) => {
    console.log(data)
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("firstName")} />
      <input {...register("lastName")} />
      <input type="submit" />

      <FirstNameWatched control={control} />
    </form>
  );
}

两者是存在新能差异的,useWatch 可以认为是把更新移到了更局部的位置,所以性能上会更有优势。下图:

三、动态数组字段

在复杂的表单应用中,还需要动态增减数组字段。react-hook-form 提供了一个 useFieldArray 的API,来帮助我们实现。

我们来看一个官方示例:代码地址

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

function App() {
  const { register, control, handleSubmit, reset, trigger, setError } = useForm({
    // defaultValues: {}; you can populate the fields by this attribute 
  });
  const { fields, append } = useFieldArray({
    control,
    name: "test"
  });
  
  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <ul>
        {fields.map((item, index) => (
          <li key={item.id}>
            <input {...register(`test.${index}.firstName`)} />
            <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>
  );
}

useFieldArray 使用也很简单,该返回一个 fields 和若干操作数组的方法例如(appendinsert)。

需要注意的是 useFieldArray 返回的 fields 是一个不受控制的字段这点很重要。这意味着它不会存储在全局的 _formValues 中,所以在使用 useFieldArray 遍历动态字段的时候,还需要我们自己对需要操作的数据进行一次注册操作。

useFieldArray 中注册动态字段:

 <Controller
	render={({ field }) => <input {...field} />}
	name={`test.${index}.lastName`}
	control={control}
/>

但是这样就会有一个问题,动态注册的字段,数据变化并不会反应到 fields 中。这是为什么呢? 🤔

我们直接阅读源码:源码位置

const methods = useFormContext();
const {
  control = methods.control,
  name,
  keyName = 'id' as TKeyName,
  shouldUnregister,
} = props;
const [fields, setFields] = React.useState<
  Partial<FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>>[]
>(mapIds(control._getFieldArray(name), keyName));
const _fieldIds = React.useRef(fields);
const _name = React.useRef(name);
const _actioned = React.useRef(false);

_name.current = name;
_fieldIds.current = fields;
control._names.array.add(name);

useSubscribe({
  callback: ({ values, name: fieldArrayName }) => {
    if (fieldArrayName === _name.current || !fieldArrayName) {
      setFields(mapIds(get(values, _name.current), keyName));
    }
  },
  subject: control._subjects.array,
});

我们发现 fieldsuseFieldArray 自己内部维护的一个状态。同时 useFieldArray 内部也订阅了一个 control._subjects.array,可以在全局监听变化或修改。

接着我们继续往下看,append 方法,append 方法会帮我们动态添加字段。并且我们发现它还会调用 control._updateFieldArray 也就是会更新全局的表单数据。prependremove 等 API 也是如此。

const append = (
    value:
      | Partial<FieldArray<TFieldValues, TFieldArrayName>>
      | Partial<FieldArray<TFieldValues, TFieldArrayName>>[],
    options?: FieldArrayMethodProps,
  ) => {
    const appendValue = convertToArrayPayload(value);
    const updatedFieldArrayValuesWithKey = appendAt(
      mapCurrentIds(control._getFieldArray(name), _fieldIds, keyName),
      mapIds(appendValue, keyName),
    );
    setFields(updatedFieldArrayValuesWithKey);
    control._updateFieldArray(
      name,
      appendAt,
      {
        argA: fillEmptyArray(value),
      },
      updateValues(updatedFieldArrayValuesWithKey),
    );

    control._names.focus = getFocusFieldName(
      name,
      updatedFieldArrayValuesWithKey.length - appendValue.length,
      options,
    );
  };

这里我们可以得出结论就是,想要更新 fields 就需要调用 useFieldArray 提供的 API 方法或者通过全局 setValue 方式修改,通过动态注册字段的方式无法修改 fields 中的数据内容。至于为什么是这样的一种实现,官方的解释是 ** 提供更好的用户体验和表单性能。**

useFieldArray 的使用还有诸多使用细节,建议大家查看官方文档,这里简单编写一个示例:

function App() {
  const { register, control, handleSubmit, reset, watch } = useForm({
    defaultValues: {
      test: [{ firstName: "Bill", lastName: "Luo" }]
    }
  });
  const {
    fields,
    append,
    prepend,
    remove,
    swap,
    move,
    insert,
    replace
  } = useFieldArray({
    control,
    name: "test"
  });

  const onSubmit = (data) => console.log("data", data);

  const formValues = useWatch({ name: "test", control });

  renderCount++;

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit)}>
        <h1>Field Array </h1>
        <p>The following demo allow you to delete, append, prepend items</p>
        <span className="counter">Render Count: {renderCount}</span>
        <ul>
          {fields.map((item, index) => {
            return (
              <li key={item.id}>
                <input {...register(`test.${index}.firstName`)} />

                <Controller
                  render={({ field }) => <input {...field} />}
                  name={`test.${index}.lastName`}
                  control={control}
                />
                <button type="button" onClick={() => remove(index)}>
                  Delete
                </button>
              </li>
            );
          })}
        </ul>
        <section>
          <button
            type="button"
            onClick={() => {
              append({ firstName: "appendBill", lastName: "appendLuo" });
            }}
          >
            append
          </button>
        </section>
      </form>
    </>
  );
}

表单初始化数据 useFieldArray 中的 fieldsform 中的数据一致。

接着我们对数据进行操作就发现二者是有区别的,也就是我们之前所说的。通过动态注册字段的方式无法修改 fields 中的数据内容。

image

接着我们通过 useFieldArray 提供的 API 就可以修改 fields 以及 form 中的数据。但是会造成组件 Array 级别的渲染,性能下降。

使用建议:

  1. 如果数据需要用户操作,那么建议使用 control 的方式注册字段,并且使用 useWatch 来监听字段变化,而不是从 fields 中获得。
  2. 如果数据只是用作纯粹的展示使用,那么建议使用 fields 中的数据即可。
  3. 如果在 useFieldArray 的 API 更新数据的时候遇到问题,建议使用 setValue 来操作。(😕 需要具体分析)
  4. 添加数据时需要确保数据结构的完整性,不然会造成数据初始化错乱。(例如:删除一条数据,然后追加一条数据)

到此我们就简单的了解了一下 React-hook-form