解绑UI,先看看好用的React表单校验库
表单校验对于前端来说无疑是个繁琐的事情,好在后台管理组件库例如Antd、Element帮我们解决了这些麻烦。可是对于C端说,UI设计师总会为网站定制个性的表单页面,这不得不需要我们切图搞起来。既然结构层和表示层需要我们亲自动手,而表单校验的行为层逻辑总是相似的,有没有一款好用又方便、又接地气又高逼格的库让我们解放双手呢?唉,还真有,那就是今天推荐的React-Hook-Form,Github上Star有30多k。看它的官方文档真是享受,因为它让我感觉原来组件还能这样封装,逻辑还能这样处理,格局打开了。有种醍醐灌顶的感觉,妈妈,原来我又会了。
基本用法
我们来看下它的基本用法
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 "只允许输入英文字母";
},
})
当然,一些常用的min
、max
、minLength
等校验属性都是可以直接使用。
你可能会好奇<input {...register("name")} />
中register到底返回了哪些字段给到表单项,我们应该大概猜到无非就是onChange
、value
之类的属性,啀,还不完全定对。它返回的主要字段有name
、onChange
、onBlur
、ref
,没有value
?没有!React Hook Form
注重减少重渲染以达到高性能的目的,采用非受控组件的方式。通过ref拿到input
、select
、textarea
等原生组件节点来设置value值。
由于不需要特意使用useState
来实时存储表单数据,因此我们输入框输入等操作时,并不会影响组件重新渲染。
formState监听表单状态
useForm
还返回了formState
字段,里面有校验错误信息、是否在校验、是否提交等等属性。
const {
formState: {
errors, isDirty, isValidating, isSubmitted
// ...
},
} = useForm();
这些属性被开发者使用且改变时,才会触发组件渲染,不使用时不会造成重渲染。什么意思呢?我们使用errors
字段来看下区别。
没有使用errors字段,不会触发重渲染
使用errors字段,当errors变化时会自动触发重渲染,获取最新的errors数据
只需要依据我们是否解构使用来判断,是否需要监听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")
返回的一些字段name
、onChange
、onBlur
、ref
等会挂载到表单组件上,那如果我们本身需要拿到表单组件的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
校验函数,为了让输入改变和提交表单时校验规则一致,所以在onChange
和handleSubmit
回调函数中都添加了校验。
看起来是麻烦了点,如果一定要受控组件并且不一定能提供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>
);
}
是不是很方便,传入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到了不少技巧,收获满满的💖,也希望对你有用。
转载自:https://juejin.cn/post/7154274703392276488