解绑UI,先看看好用的React表单校验库

1,452 阅读3分钟

表单校验对于前端来说无疑是个繁琐的事情,好在后台管理组件库例如Antd、Element帮我们解决了这些麻烦。可是对于C端说,UI设计师总会为网站定制个性的表单页面,这不得不需要我们切图搞起来。既然结构层和表示层需要我们亲自动手,而表单校验的行为层逻辑总是相似的,有没有一款好用又方便、又接地气又高逼格的库让我们解放双手呢?唉,还真有,那就是今天推荐的React-Hook-Form,Github上Star有30多k。看它的官方文档真是享受,因为它让我感觉原来组件还能这样封装,逻辑还能这样处理,格局打开了。有种醍醐灌顶的感觉,妈妈,原来我又会了。

格局打开2.jpg

基本用法

我们来看下它的基本用法

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

function Demo() {
  const { register, handleSubmit, formState: { errors } } = useForm();
  const onSubmit = handleSubmit((data) => console.log(data))
  return (
    <div className="Demo">
      <form onSubmit={onSubmit}>
        <input {...register("name")} />
        <input {...register("desc", { required: "请输入描述" })} />
        {errors.desc && <span>{errors.desc.message}</span>}
        <input type="submit" />
      </form>
    </div>
  );
}

export default Demo;

非常方便,只需要将使用register('name')注册一个name的表单项,并将返回值注入到input框中。如果input框是必填项,register方法提供第二个参数,register("desc", { required: "请输入描述" })。我们可以从formState.errors字段获取校验的错误信息进行显示。接着handleSubmit是一个高阶函数,可传入回调函数。handleSubmit(data => console.log(data))返回onSubmit函数,当我们触发onSubmit时可在回调函数中获取表单数据。

register还可以自定义校验规则,例如

register("name", {
  validate: (value) => {
    if (/^[A-Za-z]+$/i.test(value)) {
      return true;
    }
    return "只允许输入英文字母";
  },
})

当然,一些常用的minmaxminLength等校验属性都是可以直接使用

你可能会好奇<input {...register("name")} />中register到底返回了哪些字段给到表单项,我们应该大概猜到无非就是onChangevalue之类的属性,啀,还不完全定对。它返回的主要字段有nameonChangeonBlurref,没有value?没有!React Hook Form 注重减少重渲染以达到高性能的目的,采用非受控组件的方式。通过ref拿到inputselecttextarea等原生组件节点来设置value值。

由于不需要特意使用useState来实时存储表单数据,因此我们输入框输入等操作时,并不会影响组件重新渲染。

demo.gif

formState监听表单状态

useForm还返回了formState字段,里面有校验错误信息、是否在校验、是否提交等等属性

const {
  formState: { 
    errors, isDirty, isValidating, isSubmitted
    // ...
  },
} = useForm();

这些属性被开发者使用且改变时,才会触发组件渲染,不使用时不会造成重渲染。什么意思呢?我们使用errors字段来看下区别。

没有使用errors字段,不会触发重渲染 none_errors.gif

使用errors字段,当errors变化时会自动触发重渲染,获取最新的errors数据 errors.gif

只需要依据我们是否解构使用来判断,是否需要监听errors变化。第一眼看上去是不是很神奇,很有灵性,不需要开发者操心,就可以避免一些不必要的性能消耗。那它是怎么做到的呢?仔细想想我们怎么监听是否使用了某个字段,当然就是我们老生常谈的Object.defineProperty或者Proxy。还真是,源码传送门

大概的思路就是

const initialFormState = {
  isDirty: false,
  isValidating: false,
  errors: {},
  // ...
}

function useForm() {
  const [formState, updateFormState] = useState({ ...initialFormState })
  
  const _formControl = React.useRef({
    control: {
      _formState: { ...initialFormState },
      _proxyFormState: {}
    }
  });
  
  // ...
  
  // 对formState进行代理
  _formControl.current.formState = getProxyFormState(
    formState, 
    _formControl.current.control
  );
  
  return _formControl.current;
}

function getProxyFormState(formState, control) {
  const result = {};
  for (const key in formState) {
    Object.defineProperty(result, key, {
      get: () => {
        // 只要开发者解构使用了某个字段,即触发了get方法,则设置该字段代理状态为true
        control._proxyFormState[key] = true;
        return formState[key];
      },
    });
  }
  return result
}

useForm只用了一个useState,一般不会去更新state的状态,而是用useRef创建的_formControl.control._formState来存储最新值,这样保证不会触发组件更新。

例如errors字段有变动了,才会更新useState的值

// errors有变化时,且_proxyFormState.errors === true
if (_formControl.control._proxyFormState.errors) {
  // 更新useState中的值,触发重渲染
  updateFormState({ ...control._formState });
}

register返回的ref

解决了我们的好奇,接着往下讲。前面说到register("name")返回的一些字段nameonChangeonBlurref等会挂载到表单组件上,那如果我们本身需要拿到表单组件的ref,或者监听事件怎么办?

function Demo() {
  const inputRef = useRef(null);
  const { register, handleSubmit } = useForm();

  const { ref, onBlur, ...rest } = register("name", {
    required: "请输入名称",
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input
        onBlur={(e) => {
          onBlur(e);
          // 处理blur事件
          console.log("blur");
        }}
        ref={(e) => {
          ref(e);
          // 拿到输入框ref
          inputRef.current = e;
        }}
        {...rest}
      />
      <input type="submit" />
    </form>
  );
}

那你要说register上述方式不得劲啊,有时候自己封装的表单组件没有提供ref,或者就是不想通过ref来绑定。那也是可以手动setValue

const CustomInput = ({ name, label, value, onChange }) => (
  <>
    <label htmlFor={name}>{label}</label>
    <input name={name} value={value} onChange={onChange} />
  </>
);

function Demo() {
  const {
    register,
    handleSubmit,
    setValue,
    setError,
    watch,
    formState: { errors },
  } = useForm({ defaultValues: { name: '' } });

  const onValidateName = (value) => {
    if (!value) {
      setError("name", { message: "请输入" });
    } else if (!/^[A-Z]/.test(value)) {
      setError("name", { message: "首字符必须为大写字母" });
    }
  };

  useEffect(() => {
    register("name");
  }, []);

  return (
    <form
      onSubmit={handleSubmit((data) => {
        // 手动添加触发onSubmit时校验
        if (!onValidateName(data.name)) {
          return;
        }
        console.log(`提交:${JSON.stringify(data)}`);
      })}
    >
      <CustomInput
        label="名称"
        name="name"
        value={watch("name")}
        onChange={(value) => {
          // 手动添加触发onChange时校验
          onValidateName(value);
          setValue("name", value);
        }}
      />
      {errors.name && <span>{errors.name.message}</span>}
      <input type="submit" />
    </form>
  );
}

可以看到,如果我们需要受控组件的方式可以使用value={watch("name")}传递给组件(当然不一定需要)。

如果需要输入操作时能够触发校验规则,只能够手动添加了,上面我们封装了onValidateName校验函数,为了让输入改变提交表单时校验规则一致,所以在onChangehandleSubmit回调函数中都添加了校验。

看起来是麻烦了点,如果一定要受控组件并且不一定能提供ref,这个库也为我们考虑了这种情况,提供了Controller组件给我们,这样就简洁一点了。

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

const CustomInput = ({ name, label, value, onChange }) => (
  <>
    <label htmlFor={name}>{label}</label>
    <input name="name" value={value} onChange={onChange} />
  </>
);

function Demo() {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm({
    defaultValues: {
      name: "",
    },
  });
  return (
    <form
      onSubmit={handleSubmit((data) => {
        console.log(`提交:${JSON.stringify(data)}`);
      })}
    >
      <Controller
        render={({ field }) => <CustomInput label="名称" {...field} />}
        name="name"
        control={control}
        rules={{
          required: {
            value: true,
            message: "请输入",
          },
          pattern: {
            value: /^[A-Z]/,
            message: "首字符必须为大写字母",
          },
        }}
      />
      {errors.name && <span>{errors.name.message}</span>}
      <input type="submit" />
    </form>
  );
}

还是回归到最初,如果我们能提供ref,还是尽量提供吧。非受控组件至少能减少渲染次数。例如使用forwardRef

const CustomInput = forwardRef(({ name, label, onChange, onBlur }, ref) => (
  <>
    <label htmlFor={name}>{label}</label>
  	<input name={name} onChange={onChange} onBlur={onBlur} ref={ref} />
  </>
));

表单联动

前面说到React-hook-form使用的是非受控组件方案,那如果我们需要实时获取监听最新的表单值呢?可以如下

function Demo() {
  const { watch } = useForm();
  // 监听单个值
  const name = watch('name');
  
  // 监听多个值
  const [name2, desc] = watch(['name', 'desc'])
  
  // 监听所有值
  useEffect(() => {
    const { unsubscribe } = watch((values) => {
      console.log("values", values);
    });
    return () => unsubscribe();
  }, []);
  
  return (
    <form>
      <input {...register("name")} />
      <input {...register("desc", { required: "请输入描述" })} />
    </form>
  );
}

这里使用了观察者模式,只要我们对需要观察的字段值改变了,才会触发组件渲染。

那么使用watch,我们可以很容易做到表单联动

export default function Demo() {
  const { register, watch, handleSubmit } = useForm({
    shouldUnregister: true,
  });

  const [data, setData] = useState({});

  return (
    <div className="App">
      <form onSubmit={handleSubmit(setData)}>
        <div>
          <label htmlFor="name">名称:</label>
          <input {...register("name")} />
        </div>

        <div>
          <label htmlFor="more">更多:</label>
          <input type="checkbox" {...register("more")} />
        </div>

        {watch("more") && (
          <div>
            <label>年龄:</label>
            <input type="number" {...register("age")} />
          </div>
        )}

        <input type="submit" />
      </form>
      <div>提交数据:{JSON.stringify(data)}</div>
    </div>
  );
}

watch.gif

是不是很方便,传入useForm({ shouldUnregister: true });,就可以自动取消注册不需要的表单项,比如上面的年龄。Reat-hook-form又是咋做到自动取消注册不要的表单项呢,还是从ref上。

首先询问下<input ref={e => console.log(e)} />一般从挂载到注销会打印几次?一般两次,第一次打印input的dom节点,另一次打印null

所以React-hook-form也是判断null时取消注册的,下面也描述下简单做法

const _names = {
  unMount: new Set()
}
ref(ref) {
  if (ref) {
    register(name, options);
  } else {
    options.shouldUnregister && unMount.add(name);
  }
}

然后在useEffect中取消注册


useEffect({
  const _removeUnmounted = () => {
    for (const name of _names.unMount) {
      unregister(name)
    }
    _names.unMount = new Set();
  }
  _removeUnmounted()
})

我们现在了解了基本原理,那前面说不提供ref的组件咋么办,shouldUnregister是不会起作用,那只能手动移除了

export default function Demo() {
  const { register, watch, handleSubmit, unregister, setValue } = useForm();

  const [data, setData] = useState({});

  const more = watch("more");

  useEffect(() => {
    if (more) {
      register("age");
    } else {
      unregister("age");
    }
  }, [more]);

  return (
    <div className="App">
      <form onSubmit={handleSubmit(setData)}>
        <div>
          <label htmlFor="name">名称:</label>
          <input {...register("name")} />
        </div>

        <div>
          <label htmlFor="more">更多:</label>
          <input type="checkbox" {...register("more")} />
        </div>

        {more && (
          <CustomInput
            label="年龄"
            name="age"
            onChange={(value) => setValue("age", value)}
          />
        )}

        <input type="submit" />
      </form>
      <div>提交数据:{JSON.stringify(data)}</div>
    </div>
  );
}

或者使用上面说的库提供的Controller组件

基于React-hook-form封装表单组件

最后,假设我们开发好了我们的表单组件,再结合React-hook-form校验库使用,就可以完成我们网站专属的表单页啦。如果我们的表单组件在网站或项目中多个地方用到,也许我们可以再进一层封装。如下使用是不是简介很多。

function Demo() {
  const [data, setData] = useState({});

  return (
    <div className="App">
      <Form onFinish={setData}>
        <FormItem label="名称" name="name" rule={{ required: "请输入名称" }}>
          <CustomInput />
        </FormItem>
        <FormItem label="性别" name="gender" rule={{ required: "请选择性别" }}>
          <CustomSelect options={["", "", "其他"]} />
        </FormItem>
      </Form>
      <div>提交数据:{JSON.stringify(data)}</div>
    </div>
  );
}

我们现在来动手简单实现一个。首先是我们自定义开发的表单组件,例如输入框、选择框等。

const CustomInput = React.forwardRef(({ size = "middle", ...rest }, ref) => (
  <input {...rest} className={`my-input my-input-${size}`} ref={ref} />
));

const CustomSelect = React.forwardRef(
  ({ size = "middle", options, placeholder = "请选择", ...rest }, ref) => (
    <select {...rest} className={`my-select my-select-${size}`} ref={ref}>
      <option value="">{placeholder}</option>
      {options.map((value) => (
        <option key={value} value={value}>
          {value}
        </option>
      ))}
    </select>
  )
);

紧接着我们封装Form容器组件

const Form = ({ children, defaultValues, onFinish }) => {
  const {
    handleSubmit,
    register,
    formState: { errors },
  } = useForm({ defaultValues });

  return (
    <form onSubmit={handleSubmit(onFinish)}>
      {React.Children.map(children, (child) =>
        child.props.name
          ? React.cloneElement(child, {
              ...child.props,
              register,
              error: errors[child.props.name],
              key: child.props.name,
            })
          : child
      )}
      <input type="submit" />
    </form>
  );
};

一般Form中的children就是FormItem组件,我们对其props补充了register方法和error。

然后我们再来封装下FormItem组件

const FormItem = ({ children, name, label, register, rule, error }) => {
  // 简单处理:判断FormItem 只能传入一个child
  const child = React.Children.only(children);

  return (
    <div>
      <label htmlFor={name}>{label}</label>
      {React.cloneElement(child, {
        ...child.props,
        ...register(name, rule),
        name,
      })}
      {error && <span>{error.message}</span>}
    </div>
  );
};

FormItem组件的children一般就是输入框、选择框等,我们调用register方法将返回的ref、onChange等属性再补充到输入框、选择框等表单组件上。

至此,我们自行封装的表单组件库demo版就完成啦。那其实我们还有很多容错判断、更多功能还没有处理,可以慢慢添加。例如我们需要有重置表单的功能

function Demo() {
  const [data, setData] = useState({});
  const formRef = useRef();

  return (
    <div className="App">
      <Form onFinish={setData} ref={formRef}>
        <FormItem label="名称" name="name" rule={{ required: "请输入名称" }}>
          <CustomInput />
        </FormItem>
      </Form>
      <div onClick={() => formRef.current.reset()}>
        重置
      </div>
    </div>
  );
}

那么我们的Form组件就需要把useForm返回的方法等暴露出去

const Form = React.forwardRef(({ children, defaultValues, onFinish }, ref) => {
  const form = useForm({ defaultValues });
  const {
    handleSubmit,
    register,
    formState: { errors },
  } = form;

  React.useImperativeHandle(ref, () => form);

  return (
    <form onSubmit={handleSubmit(onFinish)}>
      {React.Children.map(children, (child) =>
        child.props.name
          ? React.cloneElement(child, {
              ...child.props,
              register,
              error: errors[child.props.name],
              key: child.props.name,
            })
          : child
      )}
      <input type="submit" />
    </form>
  );
});

好啦太多需要补充了,就不一一述说。

最后

通过学习使用React-hook-form,给开发节省不少时间,也get到了不少技巧,收获满满的💖,也希望对你有用。