我们或许不需要 React 的 Form 组件
在上一篇小甜点 《我们或许不需要 classnames 这个库》
) 中, 我们 简单的使用了一些语法代替了 classnames 这个库
现在我们调整一下难度, 移除 React 中相对比较复杂的组件: Form 组件
在移除 Form 组件之前, 我们现需要进行一些思考, 为什么会有 Form 组件及Form组件和 React 状态管理的关系
注意, 接下来的内容非常容易让 React 开发人员感到不适, 并且极具争议性
何时不应该使用受控组件
Angular, Vue, 都有双向绑定, 而 React 官方文档也为一个 input 标签的双向绑定给了一个官方方案 - 受控组件:
https://reactjs.org/docs/forms.html#controlled-components
本文中提到的代码都可以直接粘贴至项目中进行验证.
// 以下是官方的受控组件例子:
class NameForm extends React.Component {
constructor(props) {
super(props);
this.state = {value: ''};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
this.setState({value: event.target.value});
}
handleSubmit(event) {
alert('A name was submitted: ' + this.state.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
Name:
<input type="text" value={this.state.value} onChange={this.handleChange} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
}
相信写过 React 项目的人都已经非常熟练, 受控组件就是: 把一个 input 的 value 和 onChange 关联到某一个状态中.
很长一段时间, 使用受控组件, 我们都会受到以下几个困惑:
- 针对较多表单内容的页面, 编写受控组件繁琐
- 跨组件的受控组件需要使用 onChange 等 props 击鼓传花, 层层传递, 这种情况下做表单联动就会变得麻烦
社区对以上的解决方案是提供一些表单组件, 比较常用的有:
- Ant Design Form 组件
- no-form 组件
- react-final-form 组件(有hooks版本)
包括我自己也编写过 Form 组件
它们解决了以下几个问题:
- 跨组件获取表单内容
- 表单联动
- 根据条件去执行或修改表单组件的某些行为, 如:
- 表单校验
- props属性控制
- ref获取函数并执行
其实这些表单都是基于 React 官方受控组件的封装, 其中 Antd Form 及 no-form 都是参考我们的先知
Dan Abramov 的理念:
单向数据流, 状态管理至顶而下;
这样可以确保整个架构数据的同步, 加强项目的稳定性;
它满足以下 4 个特点:
- 不阻断数据流
- 时刻准备渲染
- 没有单例组件
- 隔离本地状态
Dan Abramov 具体的文章在此处: 编写有弹性的组件
我一直极力推崇单向数据流的方案, 在之前的项目中一直以 redux + immutable 作为项目管理, 项目也一直稳定运行, 直到 React-Hooks 的方案出现(这是另外的话题).
单向数据流的特点是用计算时间换开发人员的时间, 我们举一个小例子说明:
如果当前组件树中有 100 个 组件, 其中50个组件被connect注入了状态, 那么当发起一个 dispatch 行为, 需要更新1个组件, 这50个组件会被更新, 我们需要使用 immutable 在 shouldComponentUpdate 中进行高效的判断, 以拦截另外49个不必要更新的组件.
单向数据流的好处是我们永远只需要维护最顶部的状态, 减少了系统的混乱程度.
缺点也是明显的: 我们需要额外的判断是否更新的开销
大部分 Form 表单获取数据的思路也是一个内聚的单向数据流, 每次 onChange 就修改 Form 中的 state, 子组件通过注册 context, 获取及更新相应的值. 这是满足 Dan Abramov 的设计理念的.
而 react-final-form 没有使用以上模式, 而是通过发布订阅, 把每个组件的更新加入订阅, 根据行为进行相应的更新, 按照以上的例子, 它们是如此运作:
如果当前组件树中有 100 个 组件, 其中50个组件被Form标记了, 那么当发起一个 input 行为, 需要更新1个组件, 会找到这一个组件, 在内部进行setState, 并把相应的值更新到 Form 中的 data 中.
这种设计有没有违背 React 的初衷呢? 我认为是没有的, 因为 Form 维护的内容是局部的, 而不是整体的, 我们只需要让整个 Form 不脱离数据流的管理即可.
通过 react-final-form 这个组件的例子我想明白了一件事情:
-
单向数据流是帮我们更容易的管理, 但是并不是表示非单向数据流状态就一定混乱, 就如 react-final-form 组件所管理的表单状态.
-
既然 react-final-form 可以这么设计, 我们为什么不能设计局部的, 脱离受控组件的范畴的表单?
好的, 可以进入正题了:
表单内部的组件可以脱离受控组件存在, 只需要让表单本身为受控组件
使用 form 标签代替 React Form 组件
我们用一个简单的例子实现最开始React官方的受控组件的示例代码:
class App extends React.Component {
formDatas = {};
handleOnChange = event => {
// 在input事件中, 我们将dom元素的值存储起来, 用于表单提交
this.formDatas[event.target.name] = event.target.value;
};
handleOnSubmit = event => {
console.log('formDatas: ', this.formDatas);
event.preventDefault();
};
render() {
return (
<form onChange={this.handleOnChange} onSubmit={this.handleOnSubmit}>
<input name="username" />
<input name="password" />
<button type="submit" />
</form>
);
}
}
这是最简单的获取值, 存储到一个对象中, 我们会一步步描述如何脱离受控组件进行值和状态管理, 但是为了后续的代码更加简洁, 我们使用 hooks 完成以上行为:
获取表单内容
function App() {
// 使用 useRef 来存储数据, 这样可以防止函数每次被重新执行时无法存储变量
const { current: formDatas } = React.useRef({});
// 使用 useCallback 来声明函数, 减少组件重绘时重新声明函数的开销
const handleOnChange = React.useCallback(event => {
// 在input事件中, 我们将dom元素的值存储起来, 用于表单提交
formDatas[event.target.name] = event.target.value;
}, []);
const handleOnSubmit = React.useCallback(event => {
// 提交表单
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
<input name="username" />
<input name="password" />
<button type="submit" />
</form>
);
}
接下来的代码都会在此基础上, 使用 hooks 语法编写
跨组件获取表单内容
我们不需要做任何处理, <form /> 标签原本就可以获取其内部的所有表单内容
// 子组件, form标签一样可以获取相应的输入
function PasswordInput(){
return <div>
<p>密码:</p>
<input name="password" />
</div>
}
function App() {
const { current: formDatas } = React.useRef({});
const handleOnChange = React.useCallback(event => {
formDatas[event.target.name] = event.target.value;
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
<input name="username" />
<PasswordInput />
<button type="submit" />
</form>
);
}
表单联动 \ 校验
现在我们在之前的基础上实现一个需求:
如果密码长度大于8, 将用户名和密码重置为默认值
我们通过 form, 将input的dom元素存储起来, 再在一些情况进行dom操作, 直接更新, 代码如下:
function App() {
const { current: formDatas } = React.useRef({});
const { current: formTargets } = React.useRef({});
const handleOnChange = React.useCallback(event => {
// 在input事件中, 我们将dom元素的值存储起来, 用于表单提交
formDatas[event.target.name] = event.target.value;
// 在input事件中, 我们将dom元素储存起来, 接下来根据条件修改value
formTargets[event.target.name] = event.target;
// 如果密码长度大于8, 将用户名和密码重置为默认值
if (formTargets.password && formDatas.password.length > 8) {
// 修改DOM元素的value, 更新视图
formTargets.password.value = formTargets.password.defaultValue;
// 如果存储过
if (formTargets.username) {
// 修改DOM元素的value, 更新视图
formTargets.username.value = formTargets.username.defaultValue;
}
}
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
<input defaultValue="hello" name="username" />
<input defaultValue="" name="password" />
<button type="submit" />
</form>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
如上述代码, 我们很简单的实现了表单的联动, 因为直接操作 DOM, 所以整个组件并没有重新执行 render, 这种更新方案的性能是极佳的(HTML的极限).
在写 React 的时候我们都非常忌讳
直接操作DOM, 这是因为, 如果我们操作了 DOM, 但是通过React对Node的Diff之后, 又进行更新, 可能会覆盖掉之前操作DOM的一些行为. 但是如果我们确保这些 DOM 并不是受控组件, 那么就不会发生以上情况.
它会有什么问题么? 当其他行为触发 React 重绘时, 这些标签内的值会被清空吗?
明显是不会的, 只要React的组件没有被销毁, 即便重绘, React也只是获取到 dom对象修改其属性:
function App() {
const { current: formDatas } = React.useRef({});
const { current: formTargets } = React.useRef({});
const [value, setValue] = React.useState(10);
// 我们这里每隔 500ms 自动更新, 并且重绘我们的输入框的字号
React.useEffect(() => {
setInterval(() => {
setValue(v => v + 1);
}, 300);
}, []);
const handleOnChange = React.useCallback(event => {
formDatas[event.target.name] = event.target.value;
formTargets[event.target.name] = event.target;
if (formTargets.password && formDatas.password.length > 8) {
formTargets.password.value = formTargets.password.defaultValue;
if (formTargets.username) {
formTargets.username.value = formTargets.username.defaultValue;
}
}
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
<p>{value}</p>
<input defaultValue="hello" name="username" />
{/* p 标签会一直被 setState 更新, 字号逐步增大, 我们输入的值并没有丢失 */}
<input defaultValue="" name="password" style={{ fontSize: value }} />
<button type="submit" />
</form>
);
}
但是, 如果标签被销毁了, 非受控组件的值就不会被保存
以下例子, input输入了值之后, 被消耗再被重绘, 此时之前input的值已经丢失了
function App() {
const { current: formDatas } = React.useRef({});
const { current: formTargets } = React.useRef({});
const [value, setValue] = React.useState(0);
React.useEffect(() => {
setInterval(() => {
setValue(v => v + 1);
}, 500);
}, []);
const handleOnChange = React.useCallback(event => {
formDatas[event.target.name] = event.target.value;
formTargets[event.target.name] = event.target;
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
{/* 如果 value 是 5 的整数倍, input 会被销毁, 已输入的值会丢失 */}
{value % 5 !== 0 && <input name="username" />}
{/* 我们可以使用 defaultValue 去读取历史的值, 让重绘时读取之前输入的值 */}
{value % 5 !== 0 && <input defaultValue={formDatas.password} name="password" />}
{/* 如果可能, 我们最好使用 display 代替条件渲染 */}
<input name="code" style={{ display: value % 5 !== 0 ? 'block' : 'none' }} />
<button type="submit" />
</form>
);
}
如代码中的注释所述:
- 如果 input 被销毁, 已输入的值会丢失
- 我们可以使用 defaultValue 去读取历史的值, 让重绘时读取之前输入的
- 如果可能, 我们最好使用 display 代替条件渲
好了, 我们在了解了直接操作DOM的优点和弊端之后, 我们继续实现表单常见的其他行为.
跨层级组件通信
根据条件执行某子组件的函数, 我们只需要获取该组件的ref即可, 但是如果涉及到多层级的组件, 这就会很麻烦.
传统 Form 组件会提供一个 FormItem, FormItem会获取 context, 从而提供跨多级组件的通信
而我们如何既然已经获取到dom了, 我们只需要在dom上捆绑事件, 就可以无痛的做到跨层级的通信. 这个行为完全违反我们平时编写React的思路和常规操作, 但是通过之前我们对 "标签销毁" 的理解, 通常可以使它在可控的范围内.
我们看看实现的代码案例:
// 此为子子组件
function SubInput() {
const ref = React.useRef();
React.useEffect(() => {
if (ref.current) {
// 在DOM元素上捆绑一个函数, 此函数可以执行此组件的上下文事件
ref.current.saved = name => {
console.log('do saved by: ', name);
};
}
}, [ref]);
return (
<div>
{/* 获取表单的DOM元素 */}
<input ref={ref} name="sub-input" />
</div>
);
}
// 此为子组件, 仅引用了子子组件
function Input() {
return (
<div>
<SubInput />
</div>
);
}
function App() {
const { current: formDatas } = React.useRef({});
const { current: formTargets } = React.useRef({});
const handleOnChange = React.useCallback(event => {
formDatas[event.target.name] = event.target.value;
formTargets[event.target.name] = event.target;
// 直接通过dom元素上的属性, 获取子子组件的事件
event.target.saved && event.target.saved(event.target.name);
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
{/* 我们应用了某个子子组件, 并且没用传递任何 props, 也没有捆绑任何 context, 没有获取ref */}
<Input />
</form>
);
}
根据此例子我们可以看到, 使用 html 的 form 标签,就可以完成我们绝大部分的 Form 组件的场景, 而且开发效率和执行效率都更高.
争议
通过操作DOM, 我们可以很天然解决一些 React 非常棘手才能解决的问题. 诚然这有点像在刀尖上跳舞, 但是此文中给出了一些会遇到的问题及解决方案.
我非常欢迎对此类问题的讨论, 有哪些还会遇到的问题, 如果能清晰的将其原理及原因描述并回复到此文, 那是对所有阅读者的帮助.
写在最后
请不要被教条约束, 试试挑战它.