表单是应用程序中最重要的部分之一.通过表单,我们可以从用户那里获得丰富的输入.我们从基础一开始做起,这里不使用任何UI框架,用react自身来实现对Form表单的控制.
1.基础按钮
import React from 'react';
const content = document.createElement('div');
document.body.appendChild(content);
module.exports = class extends React.Component {
static displayName = "01-basic-button";
onGreatClick = (evt) => {
console.log('The user clicked button-1: great', evt);
};
onAmazingClick = (evt) => {
console.log('The user clicked button-2: amazing', evt);
};
render() {
return (
<div>
<h1>What do you think of React?</h1>
<button
name='button-1'
value='great'
onClick={this.onGreatClick}
>
Great
</button>
<button
name='button-2'
value='amazing'
onClick={this.onAmazingClick}
>
Amazing
</button>
</div>
);
}
};
通过点击就可以把不同的按钮输出在控制台,这里简单,咱们继续优化.
2.事件和事件处理程序
刚才我们给两个按钮注册了两个不同的事件,其实他俩的功能是一样的,所以可以把这部分优化一下,代码如下:
import React from 'react';
const content = document.createElement('div');
document.body.appendChild(content);
module.exports = class extends React.Component {
static displayName = "02-basic-button";
onButtonClick = (evt) => {
const btn = evt.target;
console.log(`The user clicked ${btn.name}: ${btn.value}`);
};
render() {
return (
<div>
<h1>What do you think of React?</h1>
<button
name='button-1'
value='great'
onClick={this.onButtonClick}
>
Great
</button>
<button
name='button-2'
value='amazing'
onClick={this.onButtonClick}
>
Amazing
</button>
</div>
);
}
};
可以看出两个按钮使用了一个点击事件,并输出不同的点击内容,这里主要是利用了事件对象和事件处理程序,即使我们添加100个按钮,也不需要对程序进行任何修改.
3.文本输入(表单经典用法)
需求是这样的:一个文本框,右边是一个体检按钮,用户输入后点击提交,会把输入的内容展示在控制台中.
3.1使用refs访问用户输入
ref可以获取到最真实的DOM值,这点在React还是Vue中都是一样的,我们来写一下这段代码:
onFormSubmit = (evt) => {
evt.preventDefault();
console.log(this.refs.name.value);
};
render() {
return (
<div>
<h1>Sign Up Sheet</h1>
<form onSubmit={this.onFormSubmit}>
<input
placeholder='Name'
ref='name'
/>
<input type='submit' />
</form>
</div>
);
}
然后在控制台可以看到你输入的内容,这里也不难理解. evt.preventDefault()方法是防止浏览器会默认提交表单的操作.
3.1.1 使用用户的输入
我使用的都是ES6组件类,因为可以通过定义state属性来设置state对象的初始值.
static displayName = "04-basic-input";
state = { names: [] }; // <-- 初始状态
接下来创建一个新的div容器包含一个h3标题和名字列表.列表由一个ul父元素组成,每个名字是一个li元素,看代码:
render() {
return (
<div>
<h1>Sign Up Sheet</h1>
<form onSubmit={this.onFormSubmit}>
<input
placeholder='Name'
ref='name'
/>
<input type='submit' />
</form>
<div>
<h3>Names</h3>
<ul>
{ this.state.names.map((name, i) => <li key={i}>{name}</li>) }
</ul>
</div>
</div>
);
}
现在render方法已经更新,我们想要往name数组里添加新的元素,但是注意不要使用this.state.name(push)这样的写法操作,为啥呢?因为React依赖于this.setState()方法来修改state对象,它被执行后会触发一个新的render方法.
正确的做法应该这样做,我们一起来捋一下,这里有区别于Vue:
(1)创建一个复制了当前names数组的新变量;
(2)把新名字添加到数组中;
(3)在调用this.setState()方法时使用该变量.
注:用户输入完后,我们记得把表单清空,这样一个常规操作,要记得.
onFormSubmit = (evt) => {
const name = this.refs.name.value;
const names = [ ...this.state.names, name ];
this.setState({ names: names });
this.refs.name.value = '';
evt.preventDefault();
};
这个时候,就把用户新输入的名字添加进去了,我们最后总体总结一下缩写表单的执行流程:
(1)用户输入名字后,点击提交按钮;
(2)onFormSubmit函数被调用了;
(3)使用this.refs.name访问文本字段的值(也就是一个名字);
(4)该名字会被添加到state中的names列表里;
(5)清空文本字段,为后续更多的操作做准备;
(6)render()函数被调用,并显示更新后的名字列表.
我应该说明的很清楚了,这里我们使用refs来访问用户的输入.但是不够好,为啥呢?因为我们开发React应该只专注于改变state,并依赖React的能力来操作DOM.说白了就是我们的文本字段是非受控组件,我们需要的是受控组件.所以我们大可不必获取最真实的DOM值来操作,我们的代码还有大的优化空间,一起来看下面的内容.
3.2使用state访问用户输入
其实也就是使用受控组件把非受控的input组件转换过来,我们需要做3件事,来捋一下:
(1)用户输入字段;
(2)使用change事件来调用onChange处理程序;
(3)在state中使用 evt.target.value 来更新input元素的值;
(4)调用render()函数并使用state中的新值来更新input元素的值.代码如下:
render() {
return (
<div>
<h1>Sign Up Sheet</h1>
<form onSubmit={this.onFormSubmit}>
<input
placeholder='Name'
+ value={this.state.name}
+ onChange={this.onNameChange}
/>
<input type='submit' />
</form>
<div>
<h3>Names</h3>
<ul>
{ this.state.names.map((name, i) => <li key={i}>{name}</li>) }
</ul>
</div>
</div>
);
}
唯一的区别就是我们删除了input的ref属性,并替换成了value和onChange属性.
state = {
name: '',//我们提供一个初始的默认值
names: [],//这里还是刚才的用法,用来放置用户新输入的值
};
onNameChange = (evt) => {
this.setState({ name: evt.target.value });
};
到这一步,又会碰到和刚才refs一样的问题,就是往新数组names新添加名字的问题,千万不能用push方法,这里不多说了,看代码吧:
onFormSubmit = (evt) => {
const names = [ ...this.state.names, this.state.name ];//定义变量把新值加进来
this.setState({ names: names, name: '' });//更新这个最新的值,更新后清空输入的值
evt.preventDefault();
};
这里我应该说的很清楚了吧,这里实现的逻辑和刚才使用refs是一样的,实现的效果也是一样的:
3.3使用多个字段
我们一直实现的都是单个表单,但是实际中我们不可能总是一个输入表单,那碰到多个我们又该如何处理呢?我们现在假如说又碰到了需要将用户的email地址输入后进行提交的操作,如果我们还是像刚才那样去监理响应的state和onChange处理程序,想一下这样我们的代码会跟冗余,对吗?想一下该怎么操作才好.
上面的input操作都是直接在state对象上直接有一个专用属性,我们当然可以在这块载来一个email属性.我们可以稍微优化一下,避免都直接添加在state对象上,我们添加一个fields对象把表单所有字段的值都放在里边,这样是不是好一点.
state = {
fields: {
name: '',
email: ''
},
people: []
};
现在是这样的:
你觉得我们会为Email这个输入框再单独的设置事件处理程序吗?当然是不需要的.我们可以只创建一个方法来接收所有输入的修改事件.看代码:
render() {
return (
<div>
<h1>Sign Up Sheet</h1>
<form onSubmit={this.onFormSubmit}>
<input
placeholder="Name"
+ name="name"
value={this.state.fields.name}
+ onChange={this.onInputChange}
/>
<input
placeholder="Email"
+ name="email"
value={this.state.fields.email}
+ onChange={this.onInputChange}
/>
<input type="submit" />
</form>
<div>
<h3>People</h3>
<ul>
{this.state.people.map(({name, email}, i) => (
<li key={i}>
{name} ({email})
</li>
))}
</ul>
</div>
</div>
);
}
我们把这个事件处理程序的代码写一下,然后捋一下这块的实现逻辑:
onInputChange = evt => {
const fields = Object.assign({}, this.state.fields);//获取了对state.fields对象的本地引用
fields[evt.target.name] = evt.target.value;//获取到state.fields中的哪个属性需要更新
this.setState({fields});//调用setState()方法
};
我们来捋一下实现的思路,核心思路其实和上面的onNameChange()所做的类似,但是由两方面的区别:
(1)我们更新的是嵌套在state对象中的值(例如:更新state.fields.name而不是state.name);
(2)我们使用evt.target.name来获取到state.fields中的哪个属性需要更新.
为了正确的更新状态,我们首先获取了对state.fields对象的本地引用;
然后使用事件中的信息(evt.target.name和evt.target.value)来更新本地应用;
最后使用修改后的本地应用来调用setState()方法.
到这我说的很清楚了吧,相关的说明以及注释都写好了,小伙伴们应该问题不大.
我们继续,this.state.fields会始终与input字段中的文本保持同步,但我们需要修改onFormSubmit()方法,才可以将信息放入已注册的用户列表中.下面是更新后的onFormSubmit()方法:
onFormSubmit = evt => {
const people = [...this.state.people, this.state.fields];
this.setState({
people,
fields: {
name: '',
email: ''
}
});
evt.preventDefault();
};
我们再一起捋一下这段代码的逻辑,其实道理和刚才是一样的,思路是这样:
(1)我们首先获得已注册的人员列表(this.state.people)的本地引用;然后将this.state.fields对象,也就是当前输入的新的name和email字段中的对象添加到people队列中;
(2) 通过this.setState()方法更新信息列表,同时将state.fields中的表单值清空.
好处:可以很容易的添加更对字段,实际上也只是render()方法需要修改.对于添加一个新字段,我们只需要再添加一个input字段,并修改列表中的渲染方式来显示新字段.
但是这时我们的表单还缺少一个关键点:验证(也就是校验)
3.4验证
验证可以在单个字段的级别上进行,也可以在整个表单上进行,验证对于表单是非常重要的.
对于我们写的表单来说,需要验证的有:
(1)确保有name和email字段;
(2)确保电子邮件是有效的地址.
3.5在应用程序中添加验证
我们的验证思路是这样的,一起来看一下:
(1)在state中添加一个对象用来存储验证错误(如果存在验证错误的话);
(2)修改render()方法,因此它会显示验证错误信息(如果存在验证错误的话),并在每个字段旁边显示红色文本;
(3)onFormSubmit()方法将调用新的validate()方法来获取fieldErrors对象,如果有错误,就会把它们添加到状态中,以便于它们可以在render()中显示,并提前返回而不会把'person'字段添加到state.people列表中.
首先需要修改初始的state对象,剩下的就是把我们总结的思路变成代码:
state = {
fields: {
name: '',
email: ''
},
fieldErrors: {},//存储错误对象
people: []
};
更新后的render()方法是:
render() {
return (
<div>
<h1>Sign Up Sheet</h1>
<form onSubmit={this.onFormSubmit}>
<input
placeholder="Name"
name="name"
value={this.state.fields.name}
onChange={this.onInputChange}
/>
+ <span style={{color: 'red'}}>{this.state.fieldErrors.name}</span>
<br />
<input
placeholder="Email"
name="email"
value={this.state.fields.email}
onChange={this.onInputChange}
/>
+ <span style={{color: 'red'}}>{this.state.fieldErrors.email}</span>
<br />
<input type="submit" />
</form>
<div>
<h3>People</h3>
<ul>
{this.state.people.map(({name, email}, i) => (
<li key={i}>
{name} ({email})
</li>
))}
</ul>
</div>
</div>
);
}
这里唯一的区别就是增加了两个span元素,每个字段都有一个.每个span都将在state.fieldErrors中的相应位置查找错误信息.如找到错误,就会在字段旁边以红色文本显示.接下来我们说明如何将这些错误信息写入state.
在用户提交表单后,我们会检查输入的有效性.所以做验证做合适的位置是在onFormSubmit()方法中,因为点击提交它会获取到表单值.但我们要为该方法创建一个独立的函数来调用,所以我们创建了纯函数,也就是validate()方法,看下代码:
validate = person => {
const errors = {};
if (!person.name) errors.name = 'Name Required';
if (!person.email) errors.email = 'Email Required';
if (person.email && !isEmail(person.email)) errors.email = 'Invalid Email';
return errors;
};
这一段的代码不难理解,如果什么也不输入直接点击体检,上面的两行红色错误信息就会显示,如果名字输入了而email地址是错误的,那么显示Invalid Email.
之后我们需要更新onFormSubmit()方法来使用一下这个validate()方法,并对返回的error对象进行更新:
onFormSubmit = evt => {
const people = [...this.state.people];
const person = this.state.fields;
const fieldErrors = this.validate(person);
this.setState({fieldErrors});
evt.preventDefault();
if (Object.keys(fieldErrors).length) return;
this.setState({
people: people.concat(person),
fields: {
name: '',
email: ''
}
});
};
我们来捋一下这段代码的逻辑:
(1)提交的时候使用了validate()方法,从this.state.fields中获取到了当前字段的值,用一个变量person来接收,并把person作为一个参数提供给validate.如果没错,那么返回空对象;如果有错,返回一个对象.其中的键对应每个字段名(也就是errors.name 等),值对应每个错误信息(也就是'Name Required'等).这两种情况下,我们都需要更新state.fieldErrors对象,以便render()方法可以根据需要显示或隐藏信息;
(2)如果验证错误对象存在任何键( if (Object.keys(fieldErrors).length) > 0),那么我们就知道有错误存在.如果没有错误,直接return即可,逻辑与前面的类似,也就是往people中添加新信息,添加后再把表单清空.如果有错误的话,错误就会提前返回,阻止表单的提交,看下效果图:
成功后的显示效果:
有错误的提交:
可以看出我们的表单验证规则已经生效,至此我们完成最基本的一个验证.其实这里稍显复杂,为啥这么说呢?一方面我们没有用任何UI框架,如果用ant Design的话,会简单的多;另一个就是我们的代码还有优化的空间,可以想一下,当我们的应用程序具有多个不同验证要求的字段时,我们该怎么做?
3.6创建Field组件
我们可以看到,当前我们的表单组件不但负责在整个表单上进行验证,同样也为每个字段运行单独的验证规则.这样想一下:如果每个字段只负责它自己的输入上标识验证错误,而父表单只负责在表单级别标识错误,这么做是最好的,理由是:
(1)以这种方式创建的电子邮件字段可以在用户输入时实时检查其输入的格式;
(2)字段可以包含其验证的错误信息,而父表单不用跟踪它.
基于此,我们得创建一个独立的新的Field组件,并使用它来代替表单中的input元素,这个新组件能够将常规的input元素与逻辑验证及错误信息结合起来.
我们还得思考,需要提供哪些信息给这个新组件,并期望他能输出什么样的结果?这些提供给新组件的信息也就是输入将会成为这个组件的props,输出将会被用作传递给事件处理程序的参数.
因为Field组件会包含一个input子元素,所以我们需要提供相同的基准信息以便将其传递下去.如果我们想要Field组件在他的input子元素上使用特定的placeholder属性来渲染,那么在表单的render()方法中创建Field组件时,也必须将placeholder作为一个属性传递.
另外需要提供的两个属性是name和value,name属性允许我们在组件间共享一个事件处理程序,value属性允许父表单预填充Field组件并让它保持最新.
另外,这个新的Field组件将负责自己的验证.所以需要为它提供其包含的数据的特定规则.例如:它是email的Field组件,那么我们要为它提供验证函数作为它的validate属性.在组件内部,它将运行此函数来确定输入是否是有效的email.
最后,需要为onChange事件提供一个事件处理程序.我们提供的onChange属性函数会在每次Field组件的输入发生变化时调用,并会使用我们定义的一个事件参数来调用它.这个事件参数包含3个属性:Field组件的名称,输入的当前值,及当前的验证错误信息(如果验证错误存在的话).
我们最后总结一下新的Field组件需要的这几个属性:
(1)placeholder:它会直接传递给input子元素.与标签类似,他告诉用户Field组件需要什么数据;
(2)name:这与input元素提供的name属性原因相同,即在事件处理程序中使用他来确定存储输入数据和验证错误信息的位置;
(3)value:父表单可以使用它来初始化Field组件,或者使用它的新值来更新Field组件.类似于input的value属性;
(4)validate:运行时返回验证错误(如果验证有错误的话)的函数;
(5)onChange:当Field组件发生变化时需要运行的事件处理程序.会接收一个事件对象作为参数.
说了这么多,然后就可以在Field组件上设置propTypes了:
static propTypes = {
placeholder: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.string,
validate: PropTypes.func,
onChange: PropTypes.func.isRequired
};
接下来可以考虑Field组件需要跟踪的state对象.其只需要两个数据,即当前value和error的属性值.
state = {
value: this.props.value,
error: false
};
一个主要的区别就是Field组件有一个父级,而这个父级有时会更新Field组件的value属性.这需要一个新的生命周期方法(getDerivedStateFromProps())来接收新值并更新状态:
getDerivedStateFromProps(nextProps) {
return {value: nextProps.value}
}
Field组件的render()要简单,它只包括一个input元素和响应的span来保存错误信息:
render() {
return (
<div>
<input
placeholder={this.props.placeholder}
value={this.state.value}
onChange={this.onChange}
/>
<span style={{color: 'red'}}>{this.state.error}</span>
</div>
);
}
对于input元素来说,placeholder属性的值将从父级传递进来并且可以从this.props.placeholder获得.综上所述,input元素的值和span中的错误信息都将存储在state中.它的值来自于this.state.value,错误信息来自于this.state.error.
最后设置一个onChange事件处理程序,它负责接收用户输入,验证,更新状态,以及调用父级的事件处理程序.这个方法就是this.onChange.
onChange = evt => {
const name = this.props.name;
const value = evt.target.value;
const error = this.props.validate ? this.props.validate(value) : false;
this.setState({value, error});
this.props.onChange({name, value, error});
};
我们再来捋一下这段代码的逻辑:
(1)evt.target.value属性为我们提供了input元素的当前文本内容,有了这些我们就可知道它是否通过了验证;
(2)如果为Field组件的validate属性提供了验证函数,我们就会在此使用它,如果没有提供,我们就不需要验证输入并将error设置为false.一旦有了value和error,就可以更新state,从而使他们都出现在render()方法中.
当父组件使用Field组件时,它会将自己的事件处理程序作为onChange属性传入.我们调用这个函数,以便将信息传递给父组件.在this.onChange()中,此函数是this.props.onChange(),我们调用它使用了三种信息:Field组件中的name,value和error属性.
可以认为是onChange属性在事件处理程序链中负责携带信息.表单包含了Field组件,而Field组件又包含了一个input元素.事件在input元素上发生,信息会首先传递到Field组件,最后会传递到表单.这个时候,Field组件已经可以用于替代应用程序中的input和error消息组合.看下效果:
可以看出,不用点击提交,根据输入的信息表单会自动校验出是否输入正确.
3.7使用新的Field组件
现在我们可以明显感受到Field组件可以取代render()方法中的input元素和error消息的span元素.目前,Field组件可以处理字段级别的验证,但在表单级别的验证呢?
其实我们需要两方面的验证:字段级别的验证和表单级别的验证.所以我们的代码还需要继续优化,是这样的,这里需要添加另一个好用的功能,也就是在表单验证通过(或失败)时实时启用(或禁用)表单的提交按钮.这是一个很好的反馈,可以改进表单的用户体验,我们尝试写一下代码:
下面是更新后的render()方法:
render() {
return (
<div>
<h1>Sign Up Sheet</h1>
<form onSubmit={this.onFormSubmit}>
<Field
placeholder="Name"
name="name"
value={this.state.fields.name}
onChange={this.onInputChange}
validate={val => (val ? false : 'Name Required')}
/>
<br />
<Field
placeholder="Email"
name="email"
value={this.state.fields.email}
onChange={this.onInputChange}
validate={val => (isEmail(val) ? false : 'Invalid Email')}
/>
<br />
<input type="submit" disabled={this.validate()} />
</form>
<div>
<h3>People</h3>
<ul>
{this.state.people.map(({name, email}, i) => (
<li key={i}>
{name} ({email})
</li>
))}
</ul>
</div>
</div>
);
}
按照惯例,我们一起来看一下这段代码的逻辑:
(1)最直观的就是Field组件代替了input元素,所有的props也与input相同,只是这次多了一个validate属性;
(2)在Field组件的onChange()方法中,我们使用了提供给Field组件的validate属性.它的目的是将用户提供的输入作为参数,并给出一个相应的返回值.如果输入无效,validate就会返回一个错误消息.否则,它返回false;
(3)对于name子段,validate属性只是检查一个真值.只要框中有字符,验证就会通过,返回将返回'Name Required'的错误消息;
(4)对于email子段,我们使用validate模块导入的isEmail()函数.该函数如果返回true,我们就知道这个地址是允许的,验证就会通过;如果返回false,就会返回'Invalid Email'消息;
(4)我们仍然保留了onChange属性,仍然被设置成this.onInputChange函数,但是此时我们需要更新一下onInputChange()函数.
(5)我们也根据条件来禁用提交按钮.所以将disabled的属性值设置为this.validate()的返回值.这是因为如果验证错误,则this.validate()会返回一个真值,如果表单无效,则按钮会被禁用.
接下来我们把两个Field组件的onChange属性都设置成this.onInputChange.看代码:
onInputChange = ({name, value, error}) => {
const fields = Object.assign({}, this.state.fields);
const fieldErrors = Object.assign({}, this.state.fieldErrors);
fields[name] = value;
fieldErrors[name] = error;
this.setState({fields, fieldErrors});
};
我们之前使用的是evt.target.name或evt.target.value,现在是直接从参数对象中获取name和value属性的值,此外我们还获取到了每个字段的验证错误error.一旦有了name,value和error属性的值,我们就可以更新state中的两个对象,也就是之前的state.fields和一个新的state.fieldErrors对象.
我们再来总结一下到目前为止的代码逻辑:
(1)首先,用户会在Field组件上输入;
(2)调用Field组件的onInputChange()事件处理程序;
(3)onInputChange()会更新state;
(4)之后表单会再次被渲染,并且向Field组件传递了更新后的value属性的值.
(5)接着使用新的value属性的值调用Field组件中的getDerivedStateFromProps()方法,并返回新状态;
(6)最后,再次调用Field.render()方法,且文本文字显示相应的输入和验证错误(如果有的话).
此时,表单中的state和外观是同步的,接下来需要修改提交事件的方式,看代码:
onFormSubmit = evt => {
const people = this.state.people;
const person = this.state.fields;
evt.preventDefault();
if (this.validate()) return;
this.setState({
people: people.concat(person),
fields: {
name: '',
email: ''
}
});
};
为了检查验证错误,我们需要调用this.validate()函数,如果有错误,该函数会在新人员添加到列表之前就返回,看代码:
validate = () => {
const person = this.state.fields;
const fieldErrors = this.state.fieldErrors;
const errMessages = Object.keys(fieldErrors).filter(k => fieldErrors[k]);
if (!person.name) return true;
if (!person.email) return true;
if (errMessages.length) return true;
return false;
};
我们来捋一下这段代码的逻辑:
(1)validate()要检验这个表单的两个字段都不能为空;不能有任何字段级别的验证错误;
(2)验证表单的两个字段都不能为空,需要访问this.state.fields,并确保state.fields.name和state.fields.email的值都为真.它们会由onInputChange()函数来保持数据更新.因此它始终匹配文本字段中的内容.如果缺少name和email属性的值,则会返回true,表示存在验证错误;
(3)对于不能有任何字段级别的验证错误,我们来看this.state.fieldErrors,onInputChange()函数会在次对象上设置字段级别的验证错误信息,我们使用Object.keys和Array.filter来获取所有存在的错误消息的数组.如果存在任何字段级别的验证问题,那么数组中会有相应的错误消息,因此它的长度不为0且为真值.如果是这种情况,我们也返回true表示验证错误存在.
综上,validate()可以在任何时候调用它来检查数据在表单级别是否有效.我们在onFormSubmit()函数中使用它来防止向列表中添加无效数据,并在render()函数中使用它来禁用提交按钮,从而为UI提供了良好的反馈.来看下效果吧:
有错误表单就会自动显示出来,同时按钮也不能提交.如果没有错误的话,就直接添加了,下面的效果图就是:
接下来我们准备再把我们写的提升一个档次,也就是我们将探讨如果允许用户从分层次的异步选项中进行选择,这里由于篇幅已经很长了,我会另外再写一篇发布,小伙伴们,加油!1