图片来源网络,侵删
是的,我冒着“涉嫌开车”以及“zz正确”的风险取了这么个标题 (狗头)
一会儿再瞎扯,马上进入主题!
在我的这篇文章(目前202赞,250收藏,没看过的快去看看)中
FreewheelLee:以React表单库Formik为例谈优秀的三方库应该是什么样的zhuanlan.zhihu.com
我提到了受控组件和非受控组件,引起了一点争议。
简单解释一下:
受控组件
受控组件,我称之为白盒组件 —— 是React官方推崇的一种表单的实现方式。特点是表单的值和输入回调都受我们的代码直接控制
如:
import React, {useState} from 'react';
const ControlledForm = () => {
const [name, setName] = useState('');
const handleChange = (e) => {
setName(e.target.value);
}
const handleSubmit = (e) => {
e && e.preventDefault();
const payload = {
name: name
};
//发送http请求或其他操作
alert(JSON.stringify(payload, null, 4))
}
return (
<div>
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={name} onChange={handleChange}/>
</label>
<input type="submit" value="Submit"/>
</form>
</div>
);
};
export default ControlledForm;
可以看到 Input 元素的value 和 onChange 回调都是我们的代码,所以我们理论上可以控制其中的流程,于是我称之为白盒。
非受控组件
非受控组件呢?我称之为黑盒组件,特点是表单相关的值、回调都交给浏览器的原生代码处理,通过组件的引用获取当前表单状态。
如:
import React, {useRef} from 'react';
const UnControlledForm = () => {
const ref = useRef();
const handleSubmit = (e) => {
e && e.preventDefault();
const payload = {
name: ref.current.value,
}
//发送http请求或其他操作
alert(JSON.stringify(payload, null, 4))
}
return (
<div>
<form onSubmit={handleSubmit} >
<label>
Name:
<input type="text" ref={ref}/>
</label>
<input type="submit" value="Submit"/>
</form>
</div>
);
};
export default UnControlledForm;
可以看到 input 元素中加了个 ref 作为引用,但是 input 的值如何变化,当前值是什么都不受我们的代码直接控制。唯一推荐我们的代码做的 —— 就是通过 ref 引用间接获取当前 input 的值。
思考:为什么不推荐使用 ref 去更改当前 input 的值 ?
答:因为这导致了 input 的值 有多个来源,既可能是用户通过浏览器更改的,也可能是ref更改的 —— 违反了 single source of truth 原则,容易产生bug,或降低代码可读性 (后文会继续阐释这一条)
表单库
上面阐述的受控与非受控思想也深入到了表单库中,我个人推荐的 Formik 就是基于受控组件的思想,而 react-hook-form 则是基于非受控组件。
为什么我更青睐受控表单库
因为更灵活且更一致!
我工作中的项目需要写很多表单,有的表单可能就像上面的例子一样简单,有的表单相当复杂 —— 这里的复杂不仅仅是表单项多那么简单。
在复杂表单中,我发现很难使用非受控表单实现,相反,使用受控表单就能游刃有余处理各种逻辑。
以下我举三类例子:
B项的值跟随 A项变化
在某个表单中,当用户改变 A 选项框的值,B 选项框的当前值也需要变化。
举个简单的例子:假如 A 项代表的是国家,B 代表的是城市
- 用户在 A 输入了中国,在 B 中输入了 北京
- 用户更改了 A, 输入了日本 —— B 此时肯定不能继续显示北京,产品经理要求默认值是 东京(即当前国家的首都)
如果采用的是非受控表单库,实现起来就很麻烦:
- 非受控表单库(如 react-hook-form )的引用对象通常是整个 form 而不是 单一的输入组件,且基于非受控思想一般不直接参与对表单值的篡改 —— 可以参考 react-hook-form 的示例代码 Get Started
- 如果使用入侵性代码,强行更改表单项的值就会带来违反single source of truth 原则的问题
- 代码可读性会显著下降
如果采用的是受控表单库Formik,实现就简单很多:
import React from 'react';
import {useFormik} from 'formik';
const ControlledForm2 = () => {
const {values, errors, handleSubmit, handleChange, setFieldValue} = useFormik({
initialValues: {
country: 'China',
city: 'Beijing'
},
onSubmit: (values) => {
alert(JSON.stringify(values, null, 4))
}
});
const onSubmit = e=>{
e.preventDefault();
handleSubmit();
}
return (
<div>
<form onSubmit={onSubmit}>
<label>
Country:
<input type="text" name="country" value={values.country} onChange={(e)=>{
handleChange(e);
const country = e.target.value;
if (country === 'Japan') {
// 方案一:
setFieldValue('city', 'Tokyo');
// 方案二:
//const mockEvent = { // 模拟一个 change 事件,让 city 更改
// target: {
// name: 'city',
// value: 'Tokyo',
// }
//}
//handleChange(mockEvent);
}
}}/>
</label>
<br/>
<label>
City:
<input type="text" name="city" value={values.city} onChange={handleChange}/>
</label>
<br/>
<input type="submit" value="Submit"/>
</form>
</div>
);
};
export default ControlledForm2;
唯一添加的代码就是 country 的 onChange 回调。
有两种方案,第一种是 使用 useFormik() 返回的 setFieldValue 方法;
第二种是 模拟一个 change 事件,调用 handleChange 让 city 更改。
方案一和二底层实现其实是一样的,handleChange 从 event 中提取出 name 和 value 然后调用 setFieldValue —— 最终都能修改 values 中 city 的值。
值得注意的是:我们没有打破 single source of truth,代码流程保持了一致性 —— 更改表单项的值永远是通过handleChange/setFieldValue 来做的;所有 input 显示的值都来自于 values
敏感词禁止
假设我们在做一个评论框,当用户输入敏感词(如粗话、涉嫌黄赌毒),我们要实时替换掉敏感词。
例如我们禁止输入 sex 这个单词
使用受控表单库 Formik 实现的一种方案的核心代码如下
if (values.comment.includes("sex")) {
values.comment = values.comment.replace("sex", "***"); // 替换掉敏感词
}
return (
<div>
<form onSubmit={onSubmit}>
<label>
City:
<input type="text" name="city" value={values.city} onChange={handleChange}/>
</label>
<br/>
<label>
Comment:
<input type="text" name="comment" value={values.comment} onChange={handleChange}/>
</label>
<input type="submit" value="Submit"/>
</form>
</div>
);
这个需求其实也可以使用上面提到的 setFieldValue 或者 handleChange 模拟事件 来实现,有兴趣的读者可以自己试试看。
容我再强调一遍,上面的代码仍然没有打破 single source of truth,代码流程保持了一致性 —— 更改表单项的值永远是通过handleChange/setFieldValue来做的;所有 input 显示的值都来自于 values
特殊场景下自定义表单验证错误消息
表单库中验证功能肯定不能少,但是大多数表单库实现的是静态的验证规则。
如,用户名需要符合某个正则表达式,年龄不能低于18岁等等。
无论是受控还是非受控表单,静态的验证规则实现起来相对容易。而实现动态的验证规则和错误信息在 Formik 中也一样轻松(感谢评论区一位读者的提醒, errors错误信息的设计 react-hook-form也有类似之处)。
众所周知,我国结婚的法定年龄是男22岁女20岁,产品经理希望你能根据这条规则做年龄验证,且输出不同的错误信息提示
(提示文本纯属搞笑,男拳女拳别打我)
使用受控表单库 Formik 实现的一种方案的核心代码如下
const {values, errors, handleSubmit, handleChange} = useFormik({
initialValues: {
age: 30,
sex: 'male'
},
onSubmit: (values) => {
alert(JSON.stringify(values, null, 4))
}
});
if (values.sex === 'male' && values.age < 22) {
errors.age = "毛长齐了吗?存款多少?代码能一次通过编译不出错了吗?就想结婚!?"
} else if (values.sex === 'female' && values.age < 20) {
errors.age = "小姑娘的花样年华可别一时大意被猪拱了,过几年成熟点再考虑吧!"
} else {
errors.age = "";
}
return (
<div>
<form onSubmit={onSubmit}>
<label>
性别:
<select name="sex" value={values.sex} onChange={handleChange}>
<option value="male">男</option>
<option value="female">女</option>
</select>
</label>
<br/>
<label>
年龄:
<input type="number" name="age" value={values.age} onChange={handleChange}/>
</label>
<br/>
<br/>
{errors.age && <span style={{color: 'red'}}>{errors.age}</span>}
<br/>
<br/>
<input type="submit" value="Submit"/>
</form>
</div>
);
在 Formik 中,errors 是用于保存表单验证错误信息的(Formik 有内置的表单验证功能,本文没展示)。
同样是因为基于受控组件的思想,和 values 一样 errors也暴露在我们的代码中,我们可以根据自己的需求篡改、自定义错误信息,因此上面这个需求对于Formik 而言十分轻松。
总结
上面三个例子,分别展示了受控表单库 Formik 允许我们从三个方面控制我们的表单逻辑
- handleChange/setFieldValue —— 通过setFieldValue 或 handleChange模拟事件,可以同时修改多个input的值
- values —— 通过拦截、篡改 values ,直接影响表单显示的值
- errors —— 通过拦截、篡改 errors ,方便自定义表单验证消息
而且,这个表单库在运作中始终保持 single source of truth —— view 的数据始终来自 values 和 errors,表单值的变化始终由 handleChange 发起
正是因为这种灵活性和一致性让我更喜欢白盒的受控表单 —— 简言之,我更喜欢白一点的(狗头)
踩过的坑
其实 useFormik() 还返回了 setFieldError 函数,可以用来设置 errors。 这个设计类似于 setFieldValue。
但是setFieldError 和 setFieldValue 都会触发重新渲染,所以使用不当会造成无限循环渲染。
拓展与思考
- single source of truth 的思想其实在 React 以相关生态的设计与开发中是很常见的,比如 React 的单数据流向、Redux 的 action-dispatch-reducer 的流程设计等。
相反的是,假如你发现组件中某个状态既可能因为 props 的改变而变化,也会因为 state 的改变而变化,那么这个组件通常就是不合理的。你应该再抽象出一层组件,让内层的组件的 props 稳定下来 或者 让内部组件的状态只受内部 state 的影响 或 只受 props 的影响。 - 封装的程度
个人认为在复杂表单的场景下,表单库的封装不应该太过分,适当地暴露出更多实现细节可以让业务实现更轻松。
类似的,当自己写一个工具库时,封装内部实现时不要一味追逐黑盒,应多思考使用场景给调用者提供足够的灵活性 —— 设计从白盒开始往黑盒慢慢前进可能是个不错的选择。
相关文章
FreewheelLee:以React表单库Formik为例谈优秀的三方库应该是什么样的zhuanlan.zhihu.com
参考链接:
受控组件: https://zh-hans.reactjs.org/docs/forms.html#controlled-components
非受控组件:https://zh-hans.reactjs.org/docs/uncontrolled-components.html
Formik: https://formik.org/docs/overview
react-hook-form: react-hook-form.com