React Hook Form 中 isValid 与errors 状态不一致问题源码级解析

145 阅读3分钟

背景

最近开发的项目中,使用 react hook form 搭配 yup 做表单的校验,但是经常发生 formState.isValidformState.errors 状态不一致的问题。

例子:

  1. 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
        })
    })
})

  1. 使用
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} />
        })}
    </>

}

image.png

  1. 期望:用户修改商品数量时触发校验,当总量 === 0时:

    • isValid: false
    • errors: { items: { message: 'xxx', type: 'test' } }
  2. 实际:

    • isValid正确变为 false
    • errors 却是 {} [空对象]

image.png

为什么会出现这样的问题呢?带着以下两个疑问,翻了 hook form 源码,终于找到了答案~

  1. 为什么会出现 isValiderrors 状态不一致的问题
  2. 为什么明明出错了,但是 errors 是空的

源码分析

由于触发校验的 actiononChange,因此需要去 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)
}

流程分解说明

  1. 全量校验执行:每次字段变更都会执行完整 schema 校验,产生错误对象errors, 以当前例子:errors: {items: {message: 'xxx'}}

  2. 取出错误信息:通过schemaErrorLookup尝试从 errors 中匹配当前字段的错误信息,以当前例子为例,取出的错误信息是 --> error: {name:items.0.quantity, error: undefined}

  3. 赋值 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 方法,应该就能理解上面的两个问题了。

  1. 为什么 formState.isValidformState.errors 状态不一致

因为 formState.isValid 反映全局校验结果,只要存在任何错误即为false。是根据 schema 校验出来的最原始 errors 是否为空来判断的。

但是 formState.errors 仅包含与当前操作字段关联的错误信息,其他错误不会主动更新。

  1. 为什么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);
    } 
}