金数据技术博客 · №5
学习 React-hook-form
一、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 😜
-
what-are-react-controlled-components-and-uncontrolled-components
-
Controlled and uncontrolled form inputs in React don't have to be complicated
查阅了许多文档,大部分文档认为应该优先考虑受控模式,主要举例说明受控之于非受控更灵活,更符合 react 单向数据流。这里还有一张对比图:
图中列举的一些非受控组件无法覆盖的场景,实际上 ref 配合组件 onChange 是可以做到的,例如字段的实时校验,我们完全可以在字段上挂上监听函数,在其值发生改变的时候进行字段校验,然后通过 ref 控制组件的内部状态。
说了这么多内容,其实问题的关键在于 React 理念,也就是我们常说的单向数据流 ViewModel 🧐 。所以理所应当我们开发的程序也应该是如此。
受控和非受控是站在组件状态(值)的存储位置来看的,本质上受控与非受控的表达能力是相同的,从某种层面上看可以互相实现。
二、React-hook-form
React-hook-form 是一个基于非受控组件实现的表单库。
非受控表单就不是使用 state 单向数据流来控制表单,它是通过 ref 来直接拿到表单组件,从而可以直接拿到表单的值,不需要对表单的值进行状态维护,这就使得非受控表单可以减少很多不必要的渲染。
非受控表单也存在着它的问题,在动态校验、动态修改(联动)方面不是很方便,于是诞生了一个以非受控思想为基础的表单库 react-hook-form,它可以帮助我们解决这些问题。还有一点好处就是基于非受控的组件,完全拥抱原生 HTML 标准。
使用 React 和 React-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 方法内部都干了什么 🤔 ?
- 定义表单状态,例如
isDirty、isValidating、dirtyFields等。源码位置
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>,
});- 创建表单控制器属性及方法。源码位置
if (_formControl.current) {
_formControl.current.control._options = props;
} else {
_formControl.current = {
...createFormControl(props),
formState,
};
}这里的 createFormControl 方法是 react-hook-form 的核心,表单整体的状态以及表单提供对外的 API,如handleSubmit、setValue、getValues 校验等,以及字段的注册、订阅方法等等都会这里预先处理。
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,
};- 观察表单整体的状态变化
接着我们继续回到 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。
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);
},
};
};Controller和useController
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 内部实现很有意思 🤪 。
- 字段值得获取。
useController 直接使用订阅的方式获取字段的值。源码位置
const value = useWatch({
control,
name,
defaultValue: get(
control._formValues,
name,
get(control._defaultValues, name, props.defaultValue),
),
exact: !isArrayField,
}) as UnpackNestedValue<FieldPathValue<TFieldValues, TName>>;- 字段通过
register注册。
useController 内部还是使用 register 来注册字段,这就是为什么 createFormControl 会返回一个 control 属性并且其中包含了 register 方法。源码位置
useController hook 是为了字段的分布式注册。而不局限于 useForm 的 register。
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 都是基于字段级别的渲染:
-
字段分布式注册,每一个字段的状态由组件自己负责,并不需要数据回流。
-
表单的内部状态(
isDirty、touched、isSubmitting等)统一用过 Proxy 包装, 在初次渲染的时候利用 Proxy 记录用户对于各个状态的订阅情况,不订阅的话变化将被忽略,不引发重新渲染。 -
对错误进行浅层比较,例如上一轮渲染已经展示了错误信息,如果这一轮渲染错误信息不变的话,则不重新渲染.
-
虽然
watch默认会触发全局渲染,不过useWatch可以做到不触发全局渲染的情况下通知某个字段的更新,本质上是发布订阅机制。
3. 动态校验以及联动
为了支持动态校验,react-hook-form 在进行表单注册的时候还会将 onChange、onBlur 等事件挂载到表单组件上,保证对与用户输入、修改行为的监听,从而可以对表单校验、表单值监听等进行触发。
非受控表单除了动态校验的问题,还存在联动实现的问题。由于 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 和若干操作数组的方法例如(append、insert)。
需要注意的是 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,
});我们发现 fields 是 useFieldArray 自己内部维护的一个状态。同时 useFieldArray 内部也订阅了一个 control._subjects.array,可以在全局监听变化或修改。
接着我们继续往下看,append 方法,append 方法会帮我们动态添加字段。并且我们发现它还会调用 control._updateFieldArray 也就是会更新全局的表单数据。prepend、remove 等 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 中的 fields 和 form 中的数据一致。
接着我们对数据进行操作就发现二者是有区别的,也就是我们之前所说的。通过动态注册字段的方式无法修改 fields 中的数据内容。
接着我们通过 useFieldArray 提供的 API 就可以修改 fields 以及 form 中的数据。但是会造成组件 Array 级别的渲染,性能下降。
使用建议:
- 如果数据需要用户操作,那么建议使用
control的方式注册字段,并且使用useWatch来监听字段变化,而不是从fields中获得。 - 如果数据只是用作纯粹的展示使用,那么建议使用
fields中的数据即可。 - 如果在
useFieldArray的 API 更新数据的时候遇到问题,建议使用setValue来操作。(😕 需要具体分析) - 添加数据时需要确保数据结构的完整性,不然会造成数据初始化错乱。(例如:删除一条数据,然后追加一条数据)
到此我们就简单的了解了一下 React-hook-form。







