啥是表单,他能干啥?
表单对我们来说是个熟悉的不能再熟悉的概念了,常见于各种信息输入的地方,通过表单用户可以提交数据、修改数据或者实现其他更复杂的交互。今天我们在这里聊聊开发中使用的各种 Form 方案是如何演进的。按照维基百科上的定义来说:网页表单(英语:WebForms)的作用是可以将用户输入的数据,发送到服务器进行处理。而百度百科则将表单分为了表单标签,表单域和表单按钮,作用是用于将数据传送到服务器上的CGI脚本或者取消输入,还可以用表单按钮来控制其他定义了处理脚本的处理工作。
笔者对表单作用的理解就是:表单通过 UI 视图向用户提供了和服务器进行数据交互的能力,同时在其中提供了校验,错误处理以及不同类型数据展示的能力。在中后台项目中,表单已经成为了重要的交互形式,用户通过填入数据,通过表单转化为JSON数据结构,并和服务端产生交互。
走出原生,迈向 React
虽然我们标题是 React 表单方案,但是我们还是先从 HTML 原生表单出发。首先 HTML 中的 Form 标签会根据每一个表单元素的 name 取得对应的用户输入, Form 会自动处理 submit 事件 (submit 事件通常由 type=submit 的 input 或者 button 的元素触发)。Form.novalidate 属性表示提交表单时不需要验证表单,如果没有声明该属性 (则表单需要通过验证)。默认的 method 是 GET, 默认的 action 是当前的 url。event.target.elements 会返回所有表单元素。
接下来我们再看看 React 对表单的实现。其实某种意义来说,本篇后面介绍的其他表单方案也都属于对 React 方案的封装和拓展。
React 在表单部分这样开头,也就引出了第一种实现方式:受控组件;当然众所周知也有第二种实现方式:非受控组件。受控组件与非受控组件最大的区别就是:对内部状态的维护与否。
受控组件并不维护自己的内部状态,外部提供 props 和回调函数,受控组件通过 props 被控制起来,更新通过 onChange 通知给父组件。下面是一个官方示例:
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('提交的名字: ' + this.state.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
名字:
<input type="text" value={this.state.value} onChange={this.handleChange} />
</label>
<input type="submit" value="提交" />
</form>
);
}
}
非受控组件,则把表单数据将交由 DOM 节点来处理,通过 Ref 来操作 DOM 获取表单的值再加以操作。同样的例子再使用非受控的方式实现:
class NameForm extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.input = React.createRef();
}
handleSubmit(event) {
alert('A name was submitted: ' + this.input.current.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
Name:
<input type="text" ref={this.input} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
}
从官方态度来看会更推崇受控组件(非受控放在了高级指引里)。开发者对他们的对比也非常多,下面是一张广为流传的对比图,不过这张图也不算完全准确的,比如你可能觉得非受控组件通过绑定事件也可以做到即时表单验证等特性,笔者来看受控和非受控更多是一种设计规范,主要看父组件对子组件的值是否有控制权,有控制权就是受控组件反之则是非受控。
骂完前辈,它成了官方推荐
但是官方方案更多来说是简陋的,在验证、访问字段以及处理表单提交这些方面没有提供能力,所以官方也在受控组件章节的最后推荐了第三方方案 Formik。
作为官方钦点的表单方案,Formik 它在文档的开始首先阐明了 Formik 主要的作用:帮开发者获取表单状态,处理验证、错误消息和处理表单提交。然后又指出了前辈 Redux-Form 的缺点,缺点可以总结为: 表单状态本质上是短暂的和本地的,因此在 Redux(或任何类型的 Flux 库)中跟踪它是不必要的;而且一个不使用 Redux 的项目因为使用 Redux-Form 引入了 Redux 也是不必要的;而且 Redux-Form 在每个表单项变化时都会调用你的整个顶级 Redux reducer。随着 Redux 应用程序的增长,性能将会越来越差。
Redux-Form 流程如下图所示。Redux-Form 使用 formReducer 捕获应用的操作来通知如何更新 Redux store。当用户操作 input,Focus action 被分发到 Action Creators,Action Creators 创建的 Redux actions 已经绑定了 Dispatcher,接下来 formReducer 更新了相应的状态切片(state slice),最后 state 被传递回文本输入框。
但是因为所有的操作都会触发 Redux 的更新过程,这样太重了(且在 Redux-Form V6 之前的版本是全量更新,性能堪忧)。Formik 基于以上缺点,抛弃了 Redux ,选择自己维护表单状态。首先看一个 Formik 的官方示例:
// Render Prop
import React from 'react';
import { Formik, Form, Field, ErrorMessage } from 'formik';
const Basic = () => (
<div>
<h1>Any place in your app!</h1>
<Formik
initialValues={{ email: '', password: '' }}
validate={values => {
const errors = {};
if (!values.email) {
errors.email = 'Required';
} else if (
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)
) {
errors.email = 'Invalid email address';
}
return errors;
}}
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
alert(JSON.stringify(values, null, 2));
setSubmitting(false);
}, 400);
}}
>
{({ isSubmitting }) => (
<Form>
<Field type="email" name="email" />
<ErrorMessage name="email" component="div" />
<Field type="password" name="password" />
<ErrorMessage name="password" component="div" />
<button type="submit" disabled={isSubmitting}>
Submit
</button>
</Form>
)}
</Formik>
</div>
);
export default Basic;
Formik 主要设计思维就是帮助开发者减少模版代码,Formik 通过配备一些额外的组件:<Form />、<Field />和 <ErrorMessage /> 来跟踪值或者处理错误信息。这些组件使用 React Context 来获取父级 <Formik /> 的状态和方法。
Formik 对提交表单也做了处理,调用源码中 794 行的 handleSubmit(e) 或者 729 行的 submitForm(在 Formik 中这两个方法都是以属性的方式提供的)都会触发提交表单。当调用其中一个方法时,Formik 首先 touch 所有字段,然后把 isSubmitting 设置为 true。然后进行校验把 isValidating 设置为 true 并异步运行所有字段级的校验和 validationSchema,并深度合并执行结果判断是否存在错误。如果存在错误,取消提交,把isValidating 设置为 false,设置错误信息,并把 isSubmitting 设置为 false。如果不存在错误则设置 isValidating 为 false, 执行提交。
Formik 还使用了 FastField 做了性能优化,但其实你看过 FastField.tsx 源码 75 行后会发现其实它就是简单地对表单中几个关键的状态 values,errors,touched 结合 props 的长度和 isSubmitting 等等关键字段通过 shouldComponentUpdate 进行了浅比较,这样对一些复杂的场景比如联动变换是没有效果的。
综上所述,Formik 从 Redux 中摆脱了出来,此外还让开发者可以少写一些模板代码,并在一定程度上做了性能优化,所以被官方推荐也不意外。但是从代码架构来说并没有支持到更细的更新粒度,还是有很大的改进空间。
而 Redux-Form 的作者后来的新作品 React-final-form,在性能上又重新找回了场子,现在 Redux-Form 的文档也在最开始的地方提示了开发者优先使用 React-final-form,唯一还值得使用 Redux-Form 的场景是:如果你需要将表单数据与 Redux 紧密耦合,特别是如果需要订阅它但是不打算从应用的部分修改它。不过在研究 React-final-form 之前我们先看看来自蚂蚁的 Antd 是怎么做的。
Antd 3.x——摸索中前进
首先我们从 Antd 3.x 开始看起,还是先看下官方示例:
import { Form, Icon, Input, Button } from 'antd';
function hasErrors(fieldsError) {
return Object.keys(fieldsError).some(field => fieldsError[field]);
}
class HorizontalLoginForm extends React.Component {
componentDidMount() {
// To disable submit button at the beginning.
this.props.form.validateFields();
}
handleSubmit = e => {
e.preventDefault();
this.props.form.validateFields((err, values) => {
if (!err) {
console.log('Received values of form: ', values);
}
});
};
render() {
const { getFieldDecorator, getFieldsError, getFieldError, isFieldTouched } = this.props.form;
// Only show error after a field is touched.
const usernameError = isFieldTouched('username') && getFieldError('username');
const passwordError = isFieldTouched('password') && getFieldError('password');
return (
<Form layout="inline" onSubmit={this.handleSubmit}>
<Form.Item validateStatus={usernameError ? 'error' : ''} help={usernameError || ''}>
{getFieldDecorator('username', {
rules: [{ required: true, message: 'Please input your username!' }],
})(
<Input
prefix={<Icon type="user" style={{ color: 'rgba(0,0,0,.25)' }} />}
placeholder="Username"
/>,
)}
</Form.Item>
<Form.Item validateStatus={passwordError ? 'error' : ''} help={passwordError || ''}>
{getFieldDecorator('password', {
rules: [{ required: true, message: 'Please input your Password!' }],
})(
<Input
prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />}
type="password"
placeholder="Password"
/>,
)}
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" disabled={hasErrors(getFieldsError())}>
Log in
</Button>
</Form.Item>
</Form>
);
}
}
const WrappedHorizontalLoginForm = Form.create({ name: 'horizontal_login' })(HorizontalLoginForm);
ReactDOM.render(<WrappedHorizontalLoginForm />, mountNode);
其实看完这段代码会产生一些疑问,比如:为什么用 Form.create() 包裹表单以及为什么用 getFieldDecorator 对数据进行包裹。而解决这些问题,我们就必须要关注下 Antd 3.x Form 的底层依赖 rc-form。
首先来看看 Form.create() 干了什么。组件 Form 的代码里有一个静态方法 create,调用 create 时会返回 rc-form 里面的 createDomForm。这个方法把 mixin 里面的一些方法作为参数(mixin 如下图所示内置 getFieldDecorator 等属性和方法),传入 createBaseForm 方法通过高阶组件 decorate 产生一个新容器。
然后复制被包裹组件的静态属性到新组件中。执行生命周期事件,主要是通过 getInitialState 初始化默认的field。render 函数返回原始组件(已经被注入了 Form 组件的属性)。返回的新组件被 argumentContainer 包裹,argumentContainer 使用了 hoist-non-react-statics 这个库的 hoistStatics 方法,使用这个方法是为了把传入组件的一些静态方法结合到新组件上。这样 Form 的初始化完成了,rc-form 创建了一个对应该组件实例的 fieldStore,用来存放当前 Form的各种状态。
然后是负责数据管理的 getFieldDecorator,当用户使用 getFieldDecorator 方法传递了 key、初始值、校验规则这些参数后,它会通过 getFieldProps 创建表单信息返回一个克隆的 input 返回到 fieldsStore(源码 225 行),并绑定默认 onChange 事件。
如果需要验证,会触发 onCollectValidate,通过 validateFieldsInternal 保存结果到 fieldsStore,最后返回双向数据绑定的表单组件。
双向绑定的表单项当触发 onChange 或者 onBlur 时,调用对应的 onCollect 方法(比如验证就会使用 onCollectValidate),内部调用 onCollectCommon 来触发对应的 onChange,并获取事件中更新后的值,生成新的 field,然后 setFields 调用 forceUpdate 设置更新值。数据回流,最终用户看到新的值。
rc-form 看起来接管了大量细节,减少了开发者负担。但是 setFields 调用 forceUpdate 设置更新值的作法会带来整个 Form 的全局渲染,这样性能会有很大问题,而这一点在 Antd 4.x 得到了解决,也会在「下」篇进行介绍。