第七章 表单

  • creating a form with controlled components
  • Reducing boilerplate code with generic components
  • Validating forms
  • Form submission

Creating a form with controlled components

表单是大部分应用的常见内容。在React中,创建表单的标准方式是使用被称为 controlled component 的组件。

Adding a Contact Us page

在src目录添加一个新的文件ContactUsPage.tsx,内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import * as React from "react";

class ContactUsPage extends React.Component {
public render() {
return (
<div className="page-container">
<h1>Contact Us</h1>
<p>
If you enter your details we'll get back to you as soon as we can.
</p>
</div>
);
}
}

export default ContactUsPage;

该组件需要包含状态,目前首先创建了header相关信息。接下来,导入该组件到页面中,打开Routes.tsx

1
import ContactUsPage from "./ContactUsPage";

Routes组件的render方法中,添加新的路由,

1
2
3
4
5
6
7
8
9
10
11
<Switch>
<Redirect exact={true} from="/" to="/products" />
<Route path="/products/:id" component="{ProductPage} />
<Route exact={true} path="/products" compoent={ProductsPage} />
<Route path="contactus" component={ContactUsPage} />
<Route path="/admin">
...
</Route>
<Route path="/login" compoent={LoginPage} />
<Route component={NotFoundPage} />
</Switch>

打开Header.tsx,添加新的导航信息,

1
2
3
4
5
6
7
8
9
10
11
<nav>
<NavLink to="/products" className="header-link" activeClassName="header-link-active">
Products
</NavLink>
<NavLink to="/contactus" className="header-link" activeClassName="header-link-active">
Contact Us
</NavLink>
<NavLink to="/admin" className="header-link" activeClassName="header-link-active">
Admin
</NavLink>
</nav>

现在,页面已经创建了,下面创建表单输入框。

Creating controlled inputs

在src目录下创建一个新文件ContactUs.tsx,包含下面内容,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import * as React from "react";

const ContactUs: React.SFC = () => {
return (
<form className="form" noValidate={true}>
<div className="form-group">
<label htmlFor="name">Your name</label>
<input type="text" id="name" />
</div>
</form>
);
};

export default ContactUs;

这是一个功能组件,渲染一个表单包含label和用户名的输入框。

现在需要添加对应的css样式,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.form {
width: 300px;
margin: 0px auto 0px auto;
}

.form-group {
display: flex;
flex-direction: column;
margin-bottom: 20px;
}

.form-group label {
align-self: flex-start;
font-size: 16px;
margin-bottom: 3px;
}

.form-group input, select, textarea {
font-family: Arial, Helvetica, sans-serif;
font-size: 16px;
padding: 5px;
border: lightgray solid 1px;
border-radius: 5px;
}

现在在我们页面ContactUsPage.tsx添加并渲染表单,

1
import ContactUs from "./ContactUs";

render方法中添加,

1
2
3
4
5
<div className="page-container">
<h1>Contact Us</h1>
<p>If you enter your details we'll get back to you as soon as we can.</p>
<ContactUs />
</div>

表单已经创建好了,但需要创建一个状态类型到ContactUsPage页面中,

1
2
3
4
5
6
7
8
interface IState {
name: string;
email: string;
reason: string;
notes: string;
}

class ContactUsPage extends React.Component<{}, IState> { ... }

在构造器中初始化状态,

1
2
3
4
5
6
7
8
9
public constructor(props: {}) {
super(props);
this.state = {
email: "",
name: "",
notes: "",
reason: ""
};
}

我们需要将ContactUsPage中的状态传递到ContactUs组件中。在ContactUs组件中,

1
2
3
4
5
6
7
8
interface IProps {
name: string;
email: string;
reason: string;
notes: string;
}

const ContactUs: React.SFC<IProps> = props => { ... }

将表单名name绑定到name属性中,

1
2
3
4
<div className="form-group">
<label htmlFor="name">Your name</label>
<input type="text" id="name" value={props.name} />
</div>

将表单状态传递给ContactUsPage

1
2
3
4
5
6
<ContactUs
name={this.state.name}
emial={this.state.email}
reason={this.state.reason}
notes={this.state.notes}
/>

添加事件监听,

1
<input type="text" id="name" value={props.name} onChange={handleNameChange} />

创建对应的handler,

1
2
3
4
5
6
const ContactUs: React.SFC<IProps> = props => {
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
props.onNameChange(e.currentTarget.value);
};
return ( ... );
};

这是使用到了React.ChangeEvent。我们需要添加onNameChange函数到IProps中,

1
2
3
4
5
6
7
8
9
10
interface IProps {
name: string;
onNameChange: (name: string) => void;
email: string;
onEmailChange: (email: string) =>void;
reason: string;
onReasonChange: (reason: string) => void;
notes: string;
onNotesChange: (notes: string) => void;
}

现在需要将来自ContactUsPage的Props传递到ContactUs中,

1
2
3
4
5
6
7
8
9
10
<ContactUs
name={this.state.name}
onNameChange={this.handleNameChange}
email={this.state.email}
onEmailChange={this.handleEmailChange}
reason={this.state.reason}
onReasonChange={this.handleReasonChange}
notes={this.state.notes}
onNotesChange={this.handleNotesChange}
}

接下来创建对应的handlers方法,

1
2
3
4
5
6
7
8
9
10
11
12
private handleNameChange = (name: string => {
this.setState({ name });
};
private handleEmailChange = (email: string) => {
this.setState({ email });
};
private handleReasonChange = (reason: string) => {
this.setState({ reason });
};
private handleNotesChange = (notes: string) => {
this.setState({ notes });
};

接下来在ContactUs中补充其它表单内容,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<form className="form" noValidate={true}>
<div className="form-group">
<label htmlFor="name">Your name</label>
<input
type="text"
id="name"
value={props.name}
onChange={handleNameChange}
/>
</div>

<div className="form-group">
<label htmlFor="email">Your email address</label>
<input type="email" value={props.email} onChange={handleEmailChange} />
</div>

<div className="form-group">
<label htmlFor="reason">Reason you need to contact us</label>
<select id="reason" value={props.reason} onChange={handleReasonChange}>
<option value="Marketing">Marketing</option>
<option value="Support" selected={true}>Support</option>
<option value="Feedback">Feedback</option>
<option value="Jobs">Jobs</option>
<option value="Other">Other</option>
</select>
</div>

<div className="form-group">
<label htmlFor="notes">Additional notes</label>
<textarea id="notes" value={props.notes} onChange={handleNotesChange} />
</div>
</form>

现在创建这些handler的函数属性

1
2
3
4
5
6
7
8
9
10
11
12
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>)
=> {
props.onEmailChange(e.currentTarget.value);
};
const handleReasonChange = (e:
React.ChangeEvent<HTMLSelectElement>) => {
props.onReasonChange(e.currentTarget.value);
};
const handleNotesChange = (e:
React.ChangeEvent<HTMLTextAreaElement>) => {
props.onNotesChange(e.currentTarget.value);
};

Reducing boilerplate code with generic components

通用表单组件将有利于减少表单代码的重复实现。我们重构上面的ContactUs组件来实现generic form components。

假设我们希望,理想情况下消费组件ContactUs内容的generic component组件的形式如下,

1
2
3
4
5
6
7
8
9
10
<Form
defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
>
<Form.Field name="name" label="Your name" />
<Form.Field name="email" label="Your email address" type="Email" />
<Form.Field name="reason" label="Reason you need to contact us"
type="Select" options={["Marketing", "Support", "Feedback", "Jobs",
"Other"]} />
<Form.Field name="notes" label="Additional notes" type="TextArea" />
</Form>

在这个示例中,有两个通用合成组件:FormField。它们有某些特点,

  • Form组件是用于合成、管理状态和交互的。
  • Fomr组件使用defaultValues属性来传递默认值。
  • Field组件渲染label和每个字段的一个编辑器。
  • 每个字段包含一个name属性,并被存储在对应的state属性名内。
  • 每个字段有一个label属性用于展示每个字段的标签。
  • 特殊字段用type属性标识。默认的属性为文本类型input
  • 如果编辑器类型是Select,我们可以通过options属性指定。

新版本的ContactUs组件相比原来的更简短、更易用。状态的管理和事件的处理被隐藏和封装在Form组件内。

Creating a basic form component

下面开始构建我们的通用Form组件;

  1. src文件夹下创建一个新的文件Form.tsx,包含下面内容:
1
2
3
4
5
6
7
8
9
10
import * as React from "react";

interface IFormProps {}

interface IState {}

export class Form extends React.Component<IFormProps, IState> {
constructor(props: IFormProps) {}
public render() {}
}

Form是一个基类组件,因为它需要管理状态。我们将属性接口命名为IFormProps,因为之后我们将需要一个字段属性的接口。

  1. 添加一个defaultValues属性到IFormProps接口中,它为每个字段提供默认值,
1
2
3
4
5
6
7
export interface IValues {
[key: string]: any;
}

interface IFormProps {
defaultValues: IValues;
}

我们使用一个额外的接口IValues,它是一个索引的key/value类型[key: string]: any,key是字段名,value是字段值。

因此,defaultValues属性可以是,

1
{ name: "", email: "", reason: "Support", notes: "" }
  1. 对于表单的state,需要存储这个接口类型,
1
2
3
interface IState {
values: IValues;
}
  1. 接下来需要在构造方法中初始化组件的状态,
1
2
3
4
5
6
constructor(props: IFormProps) {
super(props);
this.state = {
values: props.defaultValues
};
}
  1. 最后一步,实现render方法,
1
2
3
4
5
6
7
public render() {
return (
<form className="form" noValidate={true}>
{this.props.children}
</form>
);
}

我们在form标签中渲染子组件,使用了children属性。

接下来,我们需要实现我们的Field组件。

Adding a basic Field component

Field组件需要渲染一个标签(label)和一个编辑框(editor)。

  1. 首先在Form.tsx中创建一个接口属性,
1
2
3
4
5
6
interface IFieldProps {
name: string;
label: string;
type?: "Text" | "Email" | "Select" | "TextArea";
options?: string[];
}
  • name表示字段名
  • label是展示标签
  • type输入类型,可选
  • options,仅作用于当typeSelect时,可选
  1. 现在添加Field属性字段的骨架,
1
2
3
public static Field: React.SFC<IFieldProps> = props => {
return ();
};
  1. 另外,首先添加type字段的默认属性,
1
2
3
Form.Field.defaultProps = {
type: "Text"
};

这样,默认的type是一个文本类型,

  1. 现在,渲染它的内容,
1
2
3
4
5
6
7
8
9
public static Field: React.SFC<IFieldProps> = props => {
const { name, label, type, options } = props;
return (
<div className="form-group">
<label htmlFor={name}>{label}</label>
<input type={type.toLowerCase()} id={name} />
</div>
);
}

这虽然是好的开头,但是,仅使用TextEmail类型。

  1. 因此,需要添加合适的条件进行渲染,
1
2
3
4
5
6
7
8
9
10
11
12
{type === "TextArea" ... }

{type === "Select" && (
<select>
{options &&
options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)}

Sharing state with React context

Form组件内的字段值状态,需要在Field组件内共享,即可以通过Field组件访问和修改。

  1. 首先在Form.tsx创建一个接口,
1
2
3
interface IFormContext {
values: IValues;
}
  1. IFormContext下使用React.createContext创建一个上下文创建(context component),
1
2
3
const FormContext = React.createContext<IFormContext>({
values: {}
});
  1. Formrender方法中,创建包含上下文的值,
1
2
3
4
5
6
public render() {
const context: IFormContext = {
values: this.state.values
};
return ( ... )
}
  1. 包装表单标签,
1
2
3
4
5
<FormContext.Provider value={context}>
<form ... >
...
</form>
</FormContext.Provider>
  1. Field上下文进行消费,
1
2
3
4
5
6
<FormContext.Consumer>
{context => (
<div className="form-group">
</div>
)}
</FormContext.Consumer>
  1. 现在可以访问这些上下文了,下面补充剩余的输入框,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<div className="form-group">
<label htmlFor={name}>{label}</label>
{(type === "Text" || type === "Email") && (
<input type={type.toLowerCase() id={name} value={context.values[name]} />
)}

{type === "TextArea" && (
<textarea id={name} value={context.values[name]} />
)}

{type === "Select" && (
<select value={context.values[name]}>
...
</select>
)}
</div>

目前还没有添加事件用于更新上下文的状态,需要实现相应的事件处理机制。

  1. Form类中创建一个setValue方法,
1
2
3
4
private setValue = (fieldName: string, value: any) => {
const newValues = { ...this.state.values, [fieldName]: value };
this.setState({ values: newValues });
};

该方法包含有:

  • 该方法接收fieldName和value作为参数。
  • 状态被更新为newValues,旧的值被更新,没有则添加。
  • 新值被更新了。
  1. 接下来在Field组件中创建该方法的一个上下文属性,以实现访问,
1
2
3
4
interface IFormContext {
values: IValues;
setValue?: (fieldName: string, value: any) => void;
}
  1. 对应地,在Form组件也创建一个上下文属性,
1
2
3
4
const context: IFormContext = {
setValue: this.setValue,
values: this.state.values
};
  1. 现在可以在Field组件中访问该方法了。在Field中,即在解构(destucture)对象props后面,创建对应的事件Hnadler,
1
2
3
4
5
6
7
8
9
10
11
12
13
const { name, label, type, options } = props;

const handleChange = (
e:
| React.ChangeEvent<HTMLInputElement>
| React.ChangeEvent<HTMLTextAreaElement>
| React.ChangeEvent<HTMLSelectElement>,
context: IFormContext
) => {
if (context.setValue) {
context.setValue(props.name, e.currentTarget.value);
}
};

该方法有几个关键的地方:

  • TypeScript的事件改变类型是ChangeEvent<T>,其中T是被处理的元素。
  • 该方法的第一个参数e,对应事件类型,组合(union)了所有不同的输入框事件,方便对事件进行统一处理。
  • 该方法的第二个参数是表单上线文。
  • 方法体内加入了条件语句,以确保编译顺利。
  • 调用setValue方法更新或添加新值。
  1. 现在可以为input输入框添加这个事件处理,
1
2
3
4
5
6
<input 
type={type.toLowerCase() }
id={name}
value={context.values[name] }
onChange={e => handleChange(e, context) }
/>
  1. 对于textarea标签,
1
2
3
4
5
<textarea
id={name}
value={context.values[name]}
onChange={e => handleChange(e, context) }
/>
  1. 对于select标签,
1
2
3
4
5
6
<select
value={context.values[name] }
onChange={e => handleChange(e, context) }
>
...
</select>

现在,我们的FormField组件可以组合在一起工作了。

Implementing our new ContactUs component

接下来,我们使用FormField重新实现我们的ContactUs组件。

  1. 首先删除ContactUs.tsx中的props,

  2. 重新定义ContactUs的SFC,

1
2
3
const ContactUs: React.SFC = () => {
return ();
};
  1. ContactUs.tsx中导入Form组件,
1
import { Form } from "./Form";
  1. 现在引用Form组件,带上默认值,
1
2
3
4
 return (
<Form defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
</Form>
);
  1. 添加name输入框,
1
2
3
<Form defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
<Form.Field name="name" label="Your name" />
</Form>

注意这个没有写type属性,则默认使用text填充,

  1. 下面补充emailreasonnotes字段,
1
2
3
4
5
6
7
8
9
10
11
<Form defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
<Form.Field name="name" label="Your name" />
<Form.Field name="email" label="Your email address" type="Email" />
<Form.Field
name="reason"
label="Reason you need to contact us"
type="Select"
options={["Marketing", "Support", "Feedback", "Jobs", "Other"]}
/>
<Form.Field name="notes" label="Additional notes" type="TextArea" />
</Form>

接下来的ContactUsPage就变得简单了。它不需要包含任何状态(state),因为状态的维护已经交由Form组件管理。我们也不需要传递任何属性(props)到ContactUs组件中,

1
2
3
4
5
6
7
8
9
10
11
12
13
class ContactUsPage extends React.Component<{}, {}> {
public render() {
return (
<div className="page-container">
<h1>Contact Us</h1>
<p>
If you enter your details we'll get back to you as soon as we can.
</p>
<ContactUs />
</div>
);
}
}

目前为止这个通用组件变得易用并减少我们的重复代码,但还需添加表单验证的实现。

Validating forms

为了提升用户体验,需要在表单中实现校验功能。

ContactUs组件中我们需要实现的校验规则是:

  • name和email应该被填充
  • name字段不少于2个字符

Adding a validatio rules prop to form

首先思考如何在表单中指定校验规则。我们需要为一个字段指定一个或多个规则。某些规则可能会有参数,例如最小长度。以如下形式,

1
2
3
4
5
6
7
8
9
<Form
...
validationRules={{
email: { validator: required },
name: [{ validator: required }, { validator: minLength, arg: 3 }]
}}
>
...
</Form>

首先在Form组件实现一个validationRules属性,

  1. Form.tsx中定义一个Validator函数:
1
2
3
4
5
export type Validator= (
fieldName: string,
values: IValues,
args?: any
) => string;

一个Validator函数包含字段名、值、以及一个可选参数,并返回string的字符串消息。如果输入内容合法,则返回空字符串。

  1. 下面使用该类型创建一个required函数,
1
2
3
4
5
6
7
8
9
10
export const required: Validator = (
fieldName: string,
values: IValues,
args?: any
): string =>
values[fieldName] === undefined ||
values[fieldName] === null ||
values[fieldName] === ""
? "This must be populated"
: "";

这里需要将这个函数对外暴露使用。该函数会检测字段值是undefinednull还是空字符串,如果是则返回This must be populated的错误信息。

  1. 类似地,创建一个长度判断的函数,
1
2
3
4
5
6
7
export const minLength: Validator = (
fieldName: string,
values: IValues,
length: number
): string =>
values[fieldName] && values[fieldName].length < length ? `This must be at least ${length} characters`
: "";
  1. 接下来需要创建传递这些规则的props,
1
2
3
4
5
6
7
8
9
10
11
12
13
interface IValidation {
validator: Validator;
arg?: any;
}

interface IValidationProp {
[key: string]: IValidation | IValidation[];
}

interface IFormProps {
defaultValues: IValues;
validationRules: IValidationProp;
}
  • validationRules是一个索引key/value类型,其中key是字段名,value是一个或多个验证规则。
  • 一个校验规则包含函数类型和一个参数。
  1. 有了validationRules后,在ContactUs中添加,
1
import { Form, minLenght, requied } from "./Forma";
  1. 现在,添加校验规则到ContactUs组件中,
1
2
3
4
5
6
7
8
<Form defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
valiationRules={{
email: { validator: required },
name: [{ validator: required }, { validator: minLength, arg: 2 }]
}}
>
...
</Form>

Tracking validation error messages

有必要跟踪用户的不合法输入信息,提供友好的用户体验。

Form组件的职责用于管理表单状态,因此将错误信息添加到state中,

  1. 定义错误信息类型,添加到IState中,
1
2
3
4
5
6
7
8
interface IErrors {
[key: string]: string[];
}

interface IState {
values: IValues;
errors: IErrors;
}

其中errors是一个key/value键值对,key为字段名,value为一组错误消息。

  1. 在构造器中初始化errors的状态,
1
2
3
4
5
6
7
8
9
10
11
constructor(props: IFormProps) {
super(props);
const errors: IErrors = {};
Object.keys(props.defaultValues).forEach(fieldName => {
errors[fieldName] = [];
});
this.state = {
errors,
values: props.defaultValues
};
}

默认的defaultValues包含了所有字段名。当Form组件初始化,所有字段的错误信息为空。

  1. Field组件最终被用于渲染校验的错误信息,因此需要将这些信息添加到表单上下文。
1
2
3
4
5
interface IFormContext {
errors: IErrors;
values: IValues;
setValue?: (fieldName: string, value: any) => void;
}
  1. 添加一个空白的error字面量作为默认值。
1
2
3
4
const FormContext = React.createContext<IFormContext>({
errors: {},
values: {}
});
  1. 现在加入到context中,
1
2
3
4
5
6
7
8
9
10
public render() {
const context: IFormContext = {
errors: this.state.errors,
setValue: this.setValue,
values: this.state.values
};
return (
...
);
}

现在,校验错误信息被设置在state中,并且可以被Field组件访问。接下来要创建一个方法来调用这些校验规则。

Invoking validation rules

前面定义了校验规则,并且将校验信息关联到state中。但这些规则还没被调用。接下来我们要实现:

  1. 我们需要在Form组件内创建一个方法,使用这些规则来校验字段。我们创建一个validate方法,它接收字段名和它的值。该方法会返回一个error message的数组信息,
1
2
3
4
5
private validate = (
fieldName: string,
value: any
): string[] => {
};
  1. 方法内,需要获取校验规则,并初始化返回信息errors。我们会收集校验的错误信息并存储在errors中。
1
2
3
4
5
6
7
8
9
10
private validate = (
fieldName: string,
value: any
): string[] => {
const rules = this.props.validationRules[fieldName];
const errors: string[] = [];

// TODO - execute all the validators
return errors;
}
  1. 上下文获取的规则可能是一个IValidation数组,也可能是一个IValidation对象。
1
2
3
4
5
6
7
8
9
10
11
12
const errors: string[] = [];
if (Array.isArray(rules)) {
// TODO - execute all the validators in the array of rules
} else {
if (rules) {
const error = rules.validator(fieldName, this.state.values, rules.arg);
if (error) {
errors.push(error);
}
}
}
return errors;
  1. 有多个校验规则时,我们可以使用forEach函数迭代执行,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (Array.isArray(rules)) {
rules.forEach(rule => {
const error = rule.validator(
fieldNmae,
this.state.values,
rule.arg
);
if (error) {
errors.push(error);
}
});
} else {
...
}
return errors;
  1. 剩下的代码部分是,将校验的结果存储到表单状态errors中。
1
2
3
4
5
6
7
8
if (Array.isArray(rules)) {
...
} else {
...
}
const newErrors = { ...this.state.errors, [fieldName]: errors };
this.setState({ errors: newErrors });
return errors;
  1. 表单Field组件需要调用到这个validate方法。首先添加到IFormContext接口,
1
2
3
4
5
6
interface IFormContext {
values: IValues;
errors: IErrors;
setValue?: (fieldName: string, value: any) => void;
validate?: (fieldName: string, value: any) => void;
}
  1. 现在将它添加到Form渲染内容中,
1
2
3
4
5
6
7
8
9
10
11
public render() {
const context: IFormContext = {
errors: this.state.errors,
setValue: this.setValue,
validate: this.validate,
values: this.state.values
};
return (
...
);
}

表单的校验和方法的调用已经完成了。但没有事件触发这个动作,

TRiggering validation rule execution from field

当用户输入表单内容后,我们希望校验规则在blur时触发,

  1. 首先创建一个函数处理这些输入框触发的blur事件,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const handleChange = (
...
};

const handleBlur = (
e:
| React.FocusEvent<HTMLInputElement>
| React.FocusEvent<HTMLTextAreaElement>
| React.FocusEvent<HTMLSelectElement>,
context: IFormContext
) => {
if (context.validate) {
context.validate(props.name, e.currentTarget.value);
}
};

return ( ... )
  • TypeScript的blur事件是FocusEvent<T>,其中T是被处理的元素。
  • 参数e作为事件对象。
  • 第二个参数是表单上下文。
  • 需要使用条件语句判断validate方法是否定义。
  • 方法体内调用valdiate方法。
  1. 将事件引入,
1
2
3
4
5
6
7
8
9
{(type === "Text" || type === "Email") && (
<input
type={type.toLowerCase()}
id={name}
value={context.values[name]}
onChange={e => handleChange(e, context)}
onBlur={e => handleBlur(e, context)}
/>
)}
  1. 类似地,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{type === "TextArea" && (
<textarea
id={name}
value={context.values[name]}
onChange={e => handleChange(e, context)}
onBlur={e => handleBlur(e, context)}
/>
)}
{type === "Select" && (
<select
value={context.values[name]}
onChange={e => handleChange(e, context)}
onBlur={e => handleBlur(e, context)}
>
...
</select>
)}

输入框字段会在失去焦点时触发校验动作。

Rendering validation error messages

在此之前,需要将错误信息展示或者隐藏。

  1. 添加form-error样式控制,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div className="form-group">
<label htmlFor={name}>{label}</label>
{(type === "Text" || type === "Email") && (
...
)}
{type === "TextArea" && (
...
)}
{type === "Select" && (
...
)}
{context.errors[name] &&
context.errors[name].length > 0 &&
context.errors[name].map(error => (
<span key={error} className="form-error">
{error}
</span>
))}
</div>

首先检测有错误的字段,再将错误信息渲染出来。

  1. 下面是css样式,
1
2
3
4
5
.form-error {
font-size: 13px;
color: red;
margin: 3px auto 0px 0px;
}

Form submission

表单触发提交动作时,同样也需要进行校验。

  1. 首先添加提交按钮,在Form组件中添加,
1
2
3
4
5
6
7
8
<FormContext.Provider value={context}>
<form className="form" noValidate={true}>
{this.props.children}
<div className="form-group">
<button type="submit">Submit</button>
</div>
</form>
</FormContext.Provider>
  1. 给按钮添加样式,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.form-group button {
font-size: 16px;
padding: 8px 5px;
width: 80px;
border: black solid 1px;
border-radius: 5px;
background-color: black;
color: white;
}
.form-group button:disabled {
border: gray solid 1px;
background-color: gray;
cursor: not-allowed;
}

Adding a onSubmit form prop

在我们的Form组件中,需要一个新的属性来消费表单的提交动作。

  1. Form组件中创建一个prop函数,
1
2
3
4
5
6
7
8
9
10
export interface ISubmitResult {
success: boolean;
errors?: IErrors;
}

interface IFormProps {
defaultValues: IValues;
validationRules: IValidationProp;
onSubmit: (values: IValues) => Promise<ISubmitResult>;
}

onSubmit函数会接收filed的值,并异步返回提交的信息。

  1. 另外需要添加状态记录表单的提交动作,
1
2
3
4
5
6
interface IState {
values: IValues;
errors: IErrors;
submitting: boolean;
submitted: boolean;
}
  1. 另外需要在构造器初始化,
1
2
3
4
5
6
7
8
9
constructor(props: IFormProps) {
...
this.state = {
errors,
submitted: false,
submitting: false,
values: props.defaultValues
};
}
  1. 表单提交后按钮不可用,
1
2
3
4
5
<button type="submit"
disabled={this.state.submitting || this.state.submitted}
>
Submit
</button>
  1. 在表单中添加事件控制,
1
2
3
<form className="form" noValidate={true} onSubmit={this.handleSubmit}>
...
</form>
  1. 下面模拟这个提交动作,
1
2
3
private handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};

这里使用了preventDefault避免浏览器自动提交。

  1. 接下来就是重点,表单验证!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private validateForm(): boolean {
const errors: IErrors = {};
let haveError: boolean = false;
Object.keys(this.props.defaultValues).map(fieldName => {
errors[fieldName] = this.validate(
fieldName,
this.state.values[fieldName]
);
if (errors[fieldName].length > 0) {
haveError = true;
}
});
this.setState({ errors });
return !haveError;
}

private handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (this.validateForm()) {
}
};

表单的状态会更新到最新的校验错误信息,

  1. 实现剩余的代码,
1
2
3
4
5
6
7
8
9
10
11
12
private handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { 
e.preventDefault();
if (this.validateForm()) {
this.setState({ submitting: true });
const result = await this.props.onSubmit(this.state.values);
this.setState({
errors: result.errors || {},
submitted: result.success,
submitting: false
})
}
}

Consuming the onSubmit form prop

在本小节,将实现如何消费表单的提交内容。

  1. 首先在ContactUs组件中导入ISubmitResultIValues,用于处理提交的内容,
1
2
3
4
5
6
import { Form, ISubmitResult, IValues, minLength, required } from "./Form";

interface IProps {
onSubmit: (values: IValues) => Promise<ISubmitResult>;
}
const ContactUs: React.SFC<IProps> = props => { ... }
  1. 创建一个handleSubmit函数,它将会调用onSubmit属性,
1
2
3
4
5
6
7
const ContactUs: React.SFC<IProps> = props => {
const handleSubmit = async (values: IValues): Promise<ISubmitResult> => {
const result = await props.onSubmit(values);
return result;
};
return ( ... );
};

onSubmit属性是异步的,因此需要函数前缀带async以及onSubmit前面带await

  1. 绑定这个属性,
1
2
3
4
5
return (
<Form ... onSubmit={handleSubmit}>
...
</Form>
);
  1. 现在移步到ContactUsPage组件,创建提交处理,
1
2
3
4
5
6
7
8
9
private handleSubmit = async (values: IValues): Promise<ISubmitResult> => {
await wait(1000); // simulate asynchronous web API call
return {
errors: {
email: ["Some is wrong with this"]
},
success: false
};
};
  1. 接着创建wait函数,
1
2
3
const wait = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
};
  1. ContactUs组件中加上,
1
<ContactUs onSubmit={this.handleSubmit} />
  1. 导入暴露的属性,
1
import { ISubmitResult, IValues } from "./Form";

Summary

本章讨论了控制组件,通过实现自定义表单组件描述。我们构建了一个通用型的FormField组件,并实现了状态控制、事件处理、表单提交等操作。

Questions

问题练习:

  1. 扩展Field组件内容,包含number属性。
  2. 实现一个的输入框,该输入框响应紧急的程度,用数字表示。
  3. 实现一个新的校验函数,检测输入的数字是否在区间范围内。
  4. 合并实现2和3的功能。
  5. 为这个输入框添加事件。