背景
最近开发的项目中,使用 react hook form 搭配 yup 做表单的校验,但是经常发生 formState.isValid 和 formState.errors 状态不一致的问题。
例子:
- schema 这个 schema 的逻辑很简单,每个 item 都有 quantity 字段,主要就是判断 items 的总数加起来是否大于 0。
interface IItem {
quantity:number
}
const schema = yup.object().shape({
items: yup.lazy(() => {
return yup.array().test({
message: 'xxx',
test: (items:IItem[]) => items.reduce((acc,cur) => acc + cur.quantity,0) > 0 // 商品总数量加起来 > 0
})
})
})
- 使用
const Home = () => {
const form = useForm({
resolver: YupResover(schema)
})
console.log('isValid, errors', isValid, errors)
return <>
{items.map((item, index) => {
return <TextField name={`items.${index}`.quantity} control={control} />
})}
</>
}
-
期望:用户修改商品数量时触发校验,当总量 === 0时:
isValid: falseerrors: { items: { message: 'xxx', type: 'test' } }
-
实际:
isValid正确变为falseerrors却是{}[空对象]
为什么会出现这样的问题呢?带着以下两个疑问,翻了 hook form 源码,终于找到了答案~
- 为什么会出现
isValid和errors状态不一致的问题 - 为什么明明出错了,但是
errors是空的
源码分析
由于触发校验的 action 是 onChange,因此需要去 hookform 的源码中找到 OnChange 的实现。
// src/logic/createFormControl.ts
const onChange: ChangeHandler = async (event) => {
//...
if (_options.resolver) {
// 1. 调用 useForm 定义时传入的 schema
const { errors } = await _executeSchema([name]);
// 2. 提取当前 name 对应的 error
const errorLookupResult = schemaErrorLookup(
errors,
_fields,
previousErrorLookupResult.name || name,
);
// 3. 进行赋值
error = errorLookupResult.error;
name = errorLookupResult.name;
// 4. 设定 isValid
isValid = isEmptyObject(errors);
}
// 5. 判断是否要根据 error 进行渲染
shouldRenderByError(name, isValid, error, fieldState)
}
流程分解说明
-
全量校验执行:每次字段变更都会执行完整 schema 校验,产生错误对象
errors, 以当前例子:errors: {items: {message: 'xxx'}} -
取出错误信息:通过
schemaErrorLookup尝试从 errors 中匹配当前字段的错误信息,以当前例子为例,取出的错误信息是 -->error: {name:items.0.quantity, error: undefined} -
赋值 isValid: 看
errors是否是个空对象,此时为false
这里的 errors、error、isValid, 和我们 formState.errors/isValid 的关系是什么呢?往下看 shouldRenderByError,一切问题就迎刃而解了。
async shouldRenderByError(
name: InternalFieldName,
isValid: boolean,
error?: FieldError, // name 现在的错误
) => {
const previousFieldError = get(_formState.errors, name); // 获取之前的 error
const shouldUpdateValid =
_proxyFormState.isValid && _formState.isValid !== isValid; // 判断之前的 isValid 和现在的 isValid 是否一致,如果不一致,设置更新 isValid 的标志位为 true
if (){}
else {
error
? set(_formState.errors, name, error) // 如果 error 有值,那么更新 errors 对象
: unset(_formState.errors, name);
}
if (
(error ? !deepEqual(previousFieldError, error) : previousFieldError) ||
shouldUpdateValid
) {
const updatedFormState = {
...fieldState,
...(shouldUpdateValid ? { isValid } : {}), // 更新 isValid
errors: _formState.errors,
name,
}
// 更新 formState
_formState = {
..._formState,
...updatedFormState,
};
// 触发 UI 的更新
_subjects.state.next(updatedFormState);
}
}
看完 shouldRenderByError 方法,应该就能理解上面的两个问题了。
- 为什么
formState.isValid和formState.errors状态不一致
因为 formState.isValid 反映全局校验结果,只要存在任何错误即为false。是根据 schema 校验出来的最原始 errors 是否为空来判断的。
但是 formState.errors 仅包含与当前操作字段关联的错误信息,其他错误不会主动更新。
- 为什么
formState.errors为空
当校验错误位于父级字段(如数组级items错误)时,触发校验的字段路径(如items.0.quantity)无法与父级错误路径items精确匹配,导致错误信息无法注入errors对象。
解决办法
不确定我的解决办法是否是 best practice,根据 hookform 的源码分析,我在 quantity 的输入框的 onChange 事件中,手动去 trigger('items'), 让其内部去针对 item 进行 errors 的设值。
const executeSchemaAndUpdateState = async (names?: InternalFieldName[]) => {
const { errors } = await _executeSchema();
if (names) {
for (const name of names) {
const error = get(errors, name);
// 根据 name 设置 error
error
? set(_formState.errors, name, error)
: unset(_formState.errors, name);
}
} else {
_formState.errors = errors as FieldErrors<TFieldValues>;
}
return errors;
};
const trigger: UseFormTrigger<TFieldValues> = async (name, options = {}) => {
if (_options.resolver) {
// 校验
const errors = await executeSchemaAndUpdateState(
isUndefined(name) ? name : fieldNames,
);
isValid = isEmptyObject(errors);
}
}