表单在我们构建的应用程序中非常常见。在本章中,我们将学习如何使用 React 和 TypeScript 中的受控组件构建表单。我们将为 React shop 创建一个联系我们表单,我们在其他章节中一直在进行学习练习。
我们很快就会发现,在创建表单时涉及到大量的样板代码,因此我们将考虑构建一个通用表单组件来减少样板代码。客户端验证对于我们构建的表单的用户体验至关重要,因此我们还将深入讨论这个主题。
最后,表单提交是一个重要的考虑因素。我们将介绍如何处理提交错误以及成功。
在本章中,我们将讨论以下主题:
- 使用受控组件创建窗体
- 使用通用组件减少样板代码
- 验证表单
- 提交表格
在本章中,我们将使用以下技术:
-
Node.js和
npm
:TypeScript 和 React 依赖于这些。从以下链接安装它们:https://nodejs.org/en/download/ 。如果您已经安装了这些,请确保npm
至少是 5.2 版。 -
Visual Studio 代码:我们需要一个编辑器来编写 React 和 TypeScript 代码,可以从安装 https://code.visualstudio.com/ 。我们还需要 TSLint 扩展(由 egamma 提供)和 Pretter 扩展(由 Estben Petersen 提供)。
-
React shop:我们将从第 6 章、组件模式中完成的 React shop 项目开始。这可在 GitHub 的上获得 https://github.com/carlrip/LearnReact17WithTypeScript/tree/master/06-ComponentPatterns 。
In order to restore code from a previous chapter, the LearnReact17WithTypeScript
repository at https://github.com/carlrip/LearnReact17WithTypeScript can be downloaded. The relevant folder can then be opened in Visual Studio Code and then npm install
can be entered in the terminal to do the restore. All the code snippets in this chapter can be found online at https://github.com/carlrip/LearnReact17WithTypeScript/tree/master/07-WorkingWithForms.
表单是大多数应用程序的常见部分。在 React 中,创建表单的标准方法是使用所谓的受控组件。受控组件的值与 React 中的状态同步。当我们实现第一个受控组件时,这将更有意义。
我们将扩展我们一直在构建的 React 商店,以包括一个联系我们表单。这将使用受控组件实现。
在开始处理表单之前,我们需要一个页面来承载表单。页面将是一个容器组件,我们的表单将是一个表示组件。我们还需要创建一个导航选项,将我们带到新页面。
在开始实现表单之前,我们将编写以下代码:
- 如果还没有,请在 Visual Studio 代码中打开 React shop 项目。在
src
文件夹中创建一个名为ContactUsPage.tsx
的新文件,包含以下代码:
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;
这个组件最终将包含 state,因此,我们创建了一个基于类的组件。这只是呈现了一个标题和一些指示。最终,它将引用我们的表单。
- 现在,让我们将此页面添加到可用路由。打开
Routes.tsx
,导入我们的页面:
import ContactUsPage from "./ContactUsPage";
- 在
Routes
组件的render
方法中,我们现在可以在admin
路由的正上方向页面添加新路由:
<Switch>
<Redirect exact={true} from="/" to="/products" />
<Route path="/products/:id" component={ProductPage} />
<Route exact={true} path="/products" component={ProductsPage} />
<Route path="/contactus" component={ContactUsPage} />
<Route path="/admin">
...
</Route>
<Route path="/login" component={LoginPage} />
<Route component={NotFoundPage} />
</Switch>
- 现在打开
Header.tsx
,其中包含所有导航选项。让我们在新页面的管理链接上方添加一个NavLink
:
<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>
- 通过在终端中输入以下内容,在开发服务器中运行项目:
npm start
您应该会看到一个新的导航选项,将我们带到新页面:
现在我们有了新的页面,我们准备在表单中实现第一个受控输入。我们将在下一节中进行此操作。
在本节中,我们将开始创建包含第一个受控输入的表单:
- 在包含以下代码的
src
文件夹中创建一个名为ContactUs.tsx
的新文件:
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;
这是一个函数组件,用于呈现包含标签和用户名输入的表单。
- 我们已经引用了一些 CSS 类,所以让我们将它们添加到
index.css
的底部:
.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;
font-size: 16px;
padding: 5px;
border: lightgray solid 1px;
border-radius: 5px;
}
form-group
类将表单中的每个字段包装起来,在输入上方以很好的间距显示标签。
- 现在让我们从页面中引用我们的表单。转到
ContactUsPage.tsx
并导入我们的组件:
import ContactUs from "./ContactUs";
- 然后我们可以在
div
容器底部的render
方法中引用我们的组件:
<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>
如果我们查看 running 应用程序并转到“联系我们”页面,我们将看到呈现的名称字段:
我们可以在这个字段中输入我们的名字,但什么也不会发生。我们希望输入的名称存储在ContactUsPage
容器组件状态中。这是因为ContactUsPage
最终将管理表单提交。
- 让我们为
ContactUsPage
添加一个状态类型:
interface IState {
name: string;
email: string;
reason: string;
notes: string;
}
class ContactUsPage extends React.Component<{}, IState> { ... }
除了此人的姓名,我们还将捕获他们的电子邮件地址、联系商店的原因以及任何其他注释。
- 我们还要初始化构造函数中的状态:
public constructor(props: {}) {
super(props);
this.state = {
email: "",
name: "",
notes: "",
reason: ""
};
}
- 我们现在需要将名称值从
ContactUsPage
中的状态获取到ContactUs
组件中。这将允许我们在输入中显示值。我们可以先在ContactUs
组件中创建道具:
interface IProps {
name: string;
email: string;
reason: string;
notes: string;
}
const ContactUs: React.SFC<IProps> = props => { ... }
我们已经为最终将以表单形式捕获的所有数据创建了道具。
- 现在,我们可以将名称输入值绑定到
name
属性:
<div className="form-group">
<label htmlFor="name">Your name</label>
<input type="text" id="name" value={props.name} />
</div>
- 我们现在可以通过
ContactUsPage
中的状态传递这些信息:
<ContactUs
name={this.state.name}
email={this.state.email}
reason={this.state.reason}
notes={this.state.notes}
/>
让我们转到 running 应用程序并转到我们的联系我们页面。尝试在名称输入中键入一些内容。
似乎什么都没发生。。。有些东西阻止我们输入值。
我们刚刚将输入值设置为某个 React 状态,因此 React 现在控制输入值。这就是为什么我们似乎不再能够输入它。
我们正在创建第一个受控输入。然而,如果用户不能在受控输入中输入任何内容,那么受控输入就没有多大用处。那么,我们如何使我们的输入再次可编辑?
答案是我们需要监听对输入值的更改,并相应地更新状态。React 然后将呈现来自状态的新输入值。
- 让我们听一下通过
onChange
道具对输入的更改:
<input type="text" id="name" value={props.name} onChange={handleNameChange} />
- 让我们创建刚才引用的处理程序:
const ContactUs: React.SFC<IProps> = props => {
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
props.onNameChange(e.currentTarget.value);
};
return ( ... );
};
请注意,我们已经将泛型React.ChangeEvent
命令与正在处理的元素类型(HTMLInputElement
一起使用。
事件参数中的currentTarget
属性为我们提供了事件处理程序所附加到的元素的引用。其中的value
属性为我们提供了输入的最新值。
- 处理程序引用了一个我们尚未定义的
onNameChange
函数 prop。因此,让我们将此添加到我们的界面,以及其他字段的类似道具:
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
传递到ContactUs
:
<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}
/>
- 让我们创建刚才在
ContactUsPage
中引用的更改处理程序,该处理程序设置相关状态:
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 });
};
如果我们现在转到 running 应用程序中的 Contact Us 页面,并在名称中输入一些内容,那么这次输入的行为与预期一致。
- 让我们在
ContactUs
的render
方法中添加电子邮件、原因和注释字段:
<form className="form" noValidate={true} onSubmit={handleSubmit}>
<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" id="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">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>
对于每个字段,我们在一个div
容器中呈现一个label
和适当的编辑器,并使用一个form-group
类来很好地分隔字段。
所有编辑器都引用处理程序来处理对其值的更改。所有编辑器也都有相应的ContactUs
道具设置的值。因此,所有字段编辑器都有受控组件。
让我们仔细看看select
编辑。我们使用value
属性在select
标记中设置值。然而,这在原生的select
标记中并不存在。通常,我们必须在select
标记中的相关option
标记中包含selected
属性:
<select id="reason">
<option value="Marketing">Marketing</option>
<option value="Support" selected>Support</option>
<option value="Feedback">Feedback</option>
<option value="Jobs">Jobs</option>
<option value="Other">Other</option>
</select>
React 将value
道具添加到select
标签,并在幕后为我们管理option
标签上的selected
属性。这使我们能够在代码中一致地管理input
、textarea
和selected
。
- 现在,让我们为这些字段创建更改处理程序,这些字段调用我们先前创建的函数道具:
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);
};
这就完成了使用各种受控表单元素的基本联系我们表单。我们尚未实施任何验证或提交表单。我们将在本章后面部分讨论这些问题。
我们已经注意到,每个字段都有许多类似的代码,用于将字段更改为状态。在下一节中,我们将开始研究一个通用表单组件,并切换到将其用于我们的 Contact Us 表单。
通用表单组件将有助于减少实现表单所需的代码量。我们将在本节中完成这项工作,重构我们在上一节中为ContactUs
组件所做的工作。
让我们考虑一下,理想情况下,我们将如何使用通用组件来生产新版本的ContactUs
组件。它可能类似于以下 JSX:
<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>
在本例中,有两种通用化合物成分:Form
和Field
。以下是一些要点:
Form
成分是化合物的容器,管理状态和相互作用。- 我们为
Form
组件上的defaultValues
属性中的字段传递默认值。 Field
组件为每个字段呈现标签和编辑器。- 每个字段都有一个
name
属性,该属性将确定字段值存储状态下的属性名称。 - 每个字段都有一个
label
道具,指定要在每个字段标签中显示的文本。 - 使用
type
属性指定特定字段编辑器。默认编辑器是基于文本的input
。 - 如果编辑器类型为
Select
,那么我们可以使用options
道具指定此编辑器中出现的选项。
呈现新ContactUs
组件的 JSX 比原始版本要短得多,而且可以说更易于阅读。状态管理和事件处理程序被隐藏并封装在Form
组件中。
是时候开始我们的通用Form
组件了:
- 我们先在
src
文件夹中创建一个名为Form.tsx
的新文件,其中包含以下内容:
import * as React from "react";
interface IFormProps {}
interface IState {}
export class Form extends React.Component<IFormProps, IState> {
constructor(props: IFormProps) {}
public render() {}
}
Form
是一个基于类的组件,因为它需要管理状态。我们将道具接口命名为IFormProps
,因为稍后我们需要一个用于野外道具的接口。
- 让我们向
IFormProps
界面添加一个defaultValues
道具。这将保存表单中每个字段的默认值:
export interface IValues {
[key: string]: any;
}
interface IFormProps {
defaultValues: IValues;
}
对于默认值类型,我们使用一个名为IValues
的附加接口。这是一种可转位键/值类型,具有string
类型键和any
类型值。键将是字段名,值将是字段值。
因此,defaultValues
道具的值可以是:
{ name: "", email: "", reason: "Support", notes: "" }
- 现在让我们转到
Form
中的状态。我们将把字段值存储在名为values
的状态属性中:
interface IState {
values: IValues;
}
请注意,这与defaultValues
道具的类型相同,即IValues
。
- 现在,我们将使用构造函数中的默认值初始化状态:
constructor(props: IFormProps) {
super(props);
this.state = {
values: props.defaultValues
};
}
- 本节中我们要做的最后一点是开始在
Form
组件中实现render
方法:
public render() {
return (
<form className="form" noValidate={true}>
{this.props.children}
</form>
);
}
我们使用上一章中使用的神奇的children
道具,在form
标记中呈现子组件。
这很好地将我们引向Field
组件,我们将在下一节中实现它。
Field
组件需要呈现标签和编辑器。它将生活在一个名为Field
的静态属性中,位于Form
组件中。消费者可以使用Form.Field
引用此组件:
- 让我们先为
IFormProps
上方Form.tsx
中的场地道具创建一个界面:
interface IFieldProps {
name: string;
label: string;
type?: "Text" | "Email" | "Select" | "TextArea";
options?: string[];
}
name
道具是字段的名称。label
道具是要在字段标签中显示的文本。type
道具是要显示的编辑器类型。我们已经为这个道具使用了一个联合类型,其中包含我们将要支持的可用类型。请注意,我们已经将其定义为可选道具,因此稍后需要为其定义一个默认值。options
道具仅适用于Select
编辑器类型,也是可选的。这定义了要在string
数组的下拉列表中显示的选项列表。
- 现在,让我们在
Form
中为Field
组件添加一个骨架静态Field
属性:
public static Field: React.SFC<IFieldProps> = props => {
return ();
};
- 在我们忘记之前,让我们为字段
type
道具添加默认值。我们对Form
类外部和下方的定义如下:
Form.Field.defaultProps = {
type: "Text"
};
因此,默认的type
将是基于文本的输入。
- 现在,让我们尝试渲染该字段:
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>
);
}
-
我们首先从道具对象中解构
name
、label
、type
和options
。 -
该字段被包装在一个
div
容器中,该容器使用我们在index.css
中已经实现的form-group
类垂直分隔字段。 -
然后在
div
容器内的input
之前呈现label
,标签的htmlFor
属性引用input
的id
。
这是一个良好的开端,但并非所有不同的字段编辑器都是输入。事实上,这只适用于类型Text
和Email
。
- 因此,让我们稍微调整一下,并在输入周围包装一个条件表达式:
<label htmlFor={name}>{label}</label>
{(type === "Text" || type === "Email") && (
<input type={type.toLowerCase()} id={name} />
)}
- 接下来,我们通过添加突出显示的 JSX 来处理
TextArea
类型:
{(type === "Text" || type === "Email") ... }
{type === "TextArea" && (
<textarea id={name} />
)}
- 现在,我们可以呈现我们将要支持的最终编辑器,如下所示:
{type === "TextArea" ... } {type === "Select" && (
<select>
{options &&
options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)}
我们呈现一个select
标记,包含使用options
数组属性中的map
函数指定的选项。请注意,我们为每个选项提供了一个独特的key
属性,以便在检测到选项的任何更改时保持 React 愉快。
我们现在有了基本的Form
和Field
组件,这很好。但是,该实现仍然非常无用,因为我们还没有管理处于状态的字段值。让我们在下一节讨论这个问题。
字段值的状态存在于Form
组件中。但是,这些值是通过Field
组件呈现和更改的。Field
组件无法访问Form
中的状态,因为该状态存在于Form
实例中,Field
不存在。
这与我们在上一章中实现的复合Tabs
组件非常相似。我们使用 React 上下文在Tabs
化合物中的组分之间共享状态。
在本节中,我们将对我们的Forms
组件使用相同的方法:
- 让我们首先在
Form.tsx
中为表单上下文创建一个接口:
interface IFormContext {
values: IValues;
}
上下文只包含与我们的状态具有相同类型IValues
的值。
- 现在让我们使用
React.createContext
在IFormContext
下方创建上下文组件:
const FormContext = React.createContext<IFormContext>({
values: {}
});
通过将初始上下文值设置为空文本值,我们可以让 TypeScript 编译器满意。
- 在
Form
中的render
方法中,创建包含状态值的上下文值:
public render() {
const context: IFormContext = {
values: this.state.values
};
return ( ... )
}
- 将上下文提供程序包装在
render
方法的 JSX 中的form
标记周围:
<FormContext.Provider value={context}>
<form ... >
...
</form>
</FormContext.Provider>
- 我们现在可以使用
Field
SFC 中的上下文:
<FormContext.Consumer>
{context => (
<div className="form-group">
</div>
)}
</FormContext.Consumer>
- 现在我们可以访问上下文,让我们在所有三个编辑器中呈现上下文中的值:
<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>
TypeScript 编译器现在对我们的Form
和Field
组件很满意。因此,我们可以开始新的ContactUs
实现。
但是,用户还不能在表单中输入任何内容,因为我们不处理更改并向 state 传递新值。我们现在需要实现更改处理程序。
- 让我们先在
Form
类中创建一个setValue
方法:
private setValue = (fieldName: string, value: any) => {
const newValues = { ...this.state.values, [fieldName]: value };
this.setState({ values: newValues });
};
以下是该方法的要点:
- 此方法接受字段名和新值作为参数。
values
对象的新状态是使用一个名为newValues
的新对象创建的,它传播状态中的旧值,然后添加新字段名和值。- 然后在状态中设置新值。
- 然后,我们在表单上下文中创建对该方法的引用,以便
Field
组件可以访问它。让我们首先将其添加到表单上下文接口:
interface IFormContext {
values: IValues;
setValue?: (fieldName: string, value: any) => void;
}
我们将该属性设置为可选,以便在创建表单上下文组件时使 TypeScript 编译器满意。
- 当创建上下文值时,我们可以在
Form
中创建对setValue
方法的引用:
const context: IFormContext = {
setValue: this.setValue,
values: this.state.values
};
- 我们现在可以从
Field
组件调用此方法。在Field
中,在分解props
对象之后,让我们创建一个调用setValue
方法的变更处理程序:
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
是 React change 事件处理程序参数。我们为不同的编辑器合并了所有不同的更改处理程序类型,以便可以在单个函数中处理所有更改。 - 处理程序的第二个参数是表单上下文。
- 我们需要一个条件语句来检查
setValue
方法不是undefined
,以使 TypeScript 编译器满意。 - 然后,我们可以使用字段名和新值调用
setValue
方法。
- 然后我们可以在
input
标记中引用此更改处理程序,如下所示:
<input
type={type.toLowerCase()}
id={name}
value={context.values[name]}
onChange={e => handleChange(e, context)}
/>
请注意,我们使用 lamda 函数,以便可以将上下文值传递给handleChange
。
- 我们可以在
textarea
标签中执行相同的操作:
<textarea
id={name}
value={context.values[name]}
onChange={e => handleChange(e, context)}
/>
- 我们也可以在
select
标签中这样做:
<select
value={context.values[name]}
onChange={e => handleChange(e, context)}
>
...
</select>
因此,我们的Form
和Field
组件现在可以很好地协同工作,呈现字段并管理它们的值。在下一节中,我们将通过实现一个新的ContactUs
组件来尝试我们的通用组件。
在本节中,我们将使用我们的Form
和Field
组件实现一个新的ContactUs
组件:
- 让我们从移除
ContactUs.tsx
中的道具界面开始。 ContactUs
证监会内的内容将与原始版本大不相同。让我们从删除内容开始,如下所示:
const ContactUs: React.SFC = () => {
return ();
};
- 让我们将我们的
Form
组件导入ContactUs.tsx
:
import { Form } from "./Form";
- 我们现在可以引用
Form
组件,传递一些默认值:
return (
<Form
defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
>
</Form>
);
- 让我们添加
name
字段:
<Form
defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
>
<Form.Field name="name" label="Your name" />
</Form>
注意我们没有传递type
属性,因为这将默认为基于文本的输入,这正是我们所需要的。
- 现在我们添加
email
、reason
和notes
字段:
<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
现在要简单得多。它不会包含任何状态,因为它现在在Form
组件中进行管理。我们也不需要向ContactUs
组件传递任何道具:
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>
);
}
}
如果我们转到 running 应用程序并转到 Contact Us 页面,它会根据需要呈现并接受我们输入的值。
我们的通用表单组件进展顺利,我们使用它实现了我们所希望的ContactUs
组件。在下一节中,我们将通过添加验证进一步改进通用组件。
在表单上包含验证可以改善用户体验,方法是立即向用户反馈输入的信息是否有效。在本节中,我们将向Form
组件添加验证,然后在ContactUs
组件中使用它。
我们将在ContactUs
组件中实施的验证规则如下:
- 应填充名称和电子邮件字段
- 名称字段应至少包含两个字符
我们将在字段编辑器失去焦点时执行验证规则。
在下一节中,我们将向Form
组件添加一个道具,允许使用者指定验证规则。
让我们考虑一下如何为表单指定验证规则。我们需要能够为一个字段指定一个或多个规则。某些规则可能有一个参数,例如最小长度。如果我们可以指定规则就好了,如下面的示例所示:
<Form
...
validationRules={{
email: { validator: required },
name: [{ validator: required }, { validator: minLength, arg: 3 }]
}}
>
...
</Form>
让我们试着在Form
组件上实现validationRules
道具:
- 首先在
Form.tsx
中定义Validator
函数的类型:
export type Validator = (
fieldName: string,
values: IValues,
args?: any
) => string;
Validator
函数将接受字段名、整个表单的值以及特定于该函数的可选参数。将返回包含验证错误消息的字符串。如果字段有效,将返回一个空白字符串。
- 让我们使用此类型创建一个
Validator
函数,以检查Validator
类型下名为required
的字段是否已填充:
export const required: Validator = (
fieldName: string,
values: IValues,
args?: any
): string =>
values[fieldName] === undefined ||
values[fieldName] === null ||
values[fieldName] === ""
? "This must be populated"
: "";
我们导出该函数,以便在以后的ContactUs
实现中使用。该函数检查字段值是undefined
、null
还是空字符串,如果是,则返回此必须填充的验证错误消息。
如果字段值不是undefined
、null
或空字符串,则返回空字符串以指示该值有效。
- 类似地,让我们创建一个
Validator
函数,用于检查字段输入是否超过最小长度:
export const minLength: Validator = (
fieldName: string,
values: IValues,
length: number
): string =>
values[fieldName] && values[fieldName].length < length
? `This must be at least ${length} characters`
: "";
该函数检查字段值的长度是否小于 length 参数,如果小于,则返回验证错误消息。否则,将返回一个空字符串以指示该值有效。
- 现在,让我们添加通过道具向
Form
组件传递验证规则的功能:
interface IValidation {
validator: Validator;
arg?: any;
}
interface IValidationProp {
[key: string]: IValidation | IValidation[];
}
interface IFormProps {
defaultValues: IValues;
validationRules: IValidationProp;
}
validationRules
道具为可索引键/值类型,键为字段名,值为IValidation
类型的一条或多条验证规则。- 验证规则包含类型为
Validator
的验证函数和要传递到验证函数的参数。
- 有了新的
validationRules
道具,让我们将其添加到ContactUs
组件中。首先导入验证程序函数:
import { Form, minLength, required } from "./Form";
- 现在,让我们将验证规则添加到
ContactUs
组件 JSX 中:
<Form
defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
validationRules={{
email: { validator: required },
name: [{ validator: required }, { validator: minLength, arg: 2 }]
}}
>
...
</Form>
现在,如果填写了姓名和电子邮件,并且姓名至少有两个字符长,则我们的表单是有效的。
那是道具完成了。在下一节中,我们将跟踪验证错误消息,以便在页面上呈现它们。
我们需要在用户完成表单和字段变为有效或无效时跟踪状态中的验证错误消息。稍后,我们将能够将错误消息呈现到屏幕上。
Form
组件负责管理所有表单状态,因此我们将在其中添加错误消息状态,如下所示:
- 让我们将验证错误消息状态添加到表单状态界面:
interface IErrors {
[key: string]: string[];
}
interface IState {
values: IValues;
errors: IErrors;
}
errors
状态为可索引键/值类型,其中键为字段名,值为验证错误消息数组。
- 让我们初始化构造函数中的
errors
状态:
constructor(props: IFormProps) {
super(props);
const errors: IErrors = {};
Object.keys(props.defaultValues).forEach(fieldName => {
errors[fieldName] = [];
});
this.state = {
errors,
values: props.defaultValues
};
}
defaultValues
属性包含其键中的所有字段名。我们遍历defaultValues
键,将适当的errors
键设置为空数组。因此,当Form
组件初始化时,所有字段都不包含任何验证错误消息,这正是我们想要的。
Field
组件最终将呈现验证错误消息,因此我们需要将这些消息添加到表单上下文中。让我们首先将这些添加到表单上下文界面:
interface IFormContext {
errors: IErrors; values: IValues;
setValue?: (fieldName: string, value: any) => void;
}
- 在创建上下文时,让我们添加一个
errors
空文本作为默认值。这是为了让 TypeScript 编译器满意:
const FormContext = React.createContext<IFormContext>({
errors: {},
values: {}
});
- 我们现在可以在上下文值中包含错误:
public render() {
const context: IFormContext = {
errors: this.state.errors,
setValue: this.setValue,
values: this.state.values
};
return (
...
);
}
现在,验证错误处于表单状态,也处于Field
组件要访问的表单上下文中。在下一节中,我们将创建一个将调用验证规则的方法。
到目前为止,我们可以定义验证规则,并拥有跟踪验证错误消息的状态,但还没有任何东西调用这些规则。这是我们将在本节中实施的内容:
- 我们需要在
Form
组件中创建一个方法来验证一个字段,调用指定的验证器函数。让我们创建一个名为validate
的方法,它接受字段名及其值。该方法将返回一组验证错误消息:
private validate = (
fieldName: string,
value: any
): string[] => {
};
- 让我们获取字段的验证规则并初始化一个
errors
数组。我们将在执行验证程序时收集errors
数组中的所有错误。我们还将在所有验证器执行完毕后返回errors
数组:
private validate = (
fieldName: string,
value: any
): string[] => {
const rules = this.props.validationRules[fieldName];
const errors: string[] = [];
// TODO - execute all the validators
return errors;
}
- 规则可以是一个
IValidation
数组,也可以只是一个IValidation
。如果我们只有一个验证规则,那么让我们检查一下并调用validator
函数:
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;
- 现在让我们来讨论当存在多个验证规则时的代码分支。我们可以使用规则数组上的
forEach
函数来迭代规则并执行validator
函数:
if (Array.isArray(rules)) {
rules.forEach(rule => {
const error = rule.validator(
fieldName,
this.state.values,
rule.arg
);
if (error) {
errors.push(error);
}
});
} else {
...
}
return errors;
- 我们需要在
validate
方法中实现的最后一位代码是设置新的errors
表单状态:
if (Array.isArray(rules)) {
...
} else {
...
}
const newErrors = { ...this.state.errors, [fieldName]: errors };
this.setState({ errors: newErrors });
return errors;
我们将旧错误状态扩展到新对象中,然后为字段添加新错误。
Field
组件需要调用此validate
方法。我们将在表单上下文中添加对该方法的引用。我们先把它添加到IFormContext
界面:
interface IFormContext {
values: IValues;
errors: IErrors;
setValue?: (fieldName: string, value: any) => void;
validate?: (fieldName: string, value: any) => void;
}
- 现在我们可以将其添加到
Form
中render
方法的上下文值中:
public render() {
const context: IFormContext = {
errors: this.state.errors,
setValue: this.setValue,
validate: this.validate,
values: this.state.values
};
return (
...
);
}
我们的表单验证进展顺利,现在我们有了一个可以调用的方法来调用字段的所有规则。但是,在用户填写表单时,还没有从任何地方调用此方法。我们将在下一节中这样做。
当用户填写表单时,我们希望在字段失去焦点时触发验证规则。我们将在本节中实现这一点:
- 让我们创建一个函数来处理三个不同编辑器的
blur
事件:
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 模糊事件类型为
FocusEvent<T>
,其中T
是正在处理的元素的类型。 - 处理程序的第一个参数
e
是 React blur 事件处理程序参数。我们为不同的编辑器合并了所有不同的处理程序类型,因此我们可以在单个函数中处理所有模糊事件。 - 处理程序的第二个参数是表单上下文。
- 我们需要一个条件语句来检查
validate
方法不是undefined
,以使 TypeScript 编译器满意。 - 然后,我们可以使用需要验证的字段名和新值调用
validate
方法。
- 我们现在可以在文本和电子邮件编辑器的
Field
JSX 中引用此处理程序:
{(type === "Text" || type === "Email") && (
<input
type={type.toLowerCase()}
id={name}
value={context.values[name]}
onChange={e => handleChange(e, context)}
onBlur={e => handleBlur(e, context)}
/>
)}
我们将onBlur
属性设置为一个 lamda 表达式,该表达式调用我们的handleBlur
函数,传递 blur 参数和上下文值。
- 现在让我们在其他两个编辑器中引用处理程序:
{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>
)}
我们的字段在失去焦点时正在执行验证规则。在尝试“联系我们”页面之前,还有一项任务要做,我们将在下一节中完成。
在本节中,我们将在Field
组件中呈现验证错误消息:
- 让我们用我们已经实现的
form-error
CSS 类显示span
中的所有错误。我们在form-group
的div
容器底部显示:
<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>
因此,我们首先检查字段名是否有错误,然后使用errors
数组中的map
函数为每个错误呈现一个span
。
- 我们已经引用了一个 CSS
form-error
类,所以让我们将其添加到index.css
中:
.form-error {
font-size: 13px;
color: red;
margin: 3px auto 0px 0px;
}
是时候尝试一下“联系我们”页面了。如果我们的应用程序未启动,请使用npm start
启动,然后转到“联系我们”页面。如果我们在“名称”和“电子邮件”字段中使用 tab 键,将显示所需的验证规则触发器和错误消息:
这正是我们想要的。如果我们返回到 name 字段,在切换到 Tab 之前尝试只输入一个字符,就会触发最小长度验证错误,正如我们所预期的:
我们的通用表单组件现在几乎完成了。我们的最后一项任务是提交表单,我们将在下一节中完成。
提交表单是表单实现的最后一部分。Form
组件的使用者将处理实际提交,这可能导致调用 web API。提交表单时,我们的Form
组件只需调用消费者代码中的函数。
在本节中,我们将在Form
组件中添加一个提交按钮:
- 让我们在
Form
JSX 中添加一个提交按钮,包裹在form-group
内的div
容器中:
<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>
- 在
index.css
中使用以下 CSS 设置按钮样式:
.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;
}
我们现在在表单上有一个黑色的 submit 按钮,禁用时为灰色。
在我们的Form
组件中,我们需要一个新的道具,允许使用者指定要调用的submit
函数。我们将在本节中执行此操作:
- 我们先在
Form
道具界面中创建一个名为onSubmit
的新道具函数:
export interface ISubmitResult {
success: boolean;
errors?: IErrors;
}
interface IFormProps {
defaultValues: IValues;
validationRules: IValidationProp;
onSubmit: (values: IValues) => Promise<ISubmitResult>;
}
该函数将接受字段值并异步返回提交是否成功,以及服务器上发生的任何验证错误。
- 我们将跟踪表单是否处于
Form
状态。我们还将跟踪表单是否已在Form
状态下成功提交:
interface IState {
values: IValues;
errors: IErrors;
submitting: boolean;
submitted: boolean;
}
- 让我们在构造函数中初始化这些状态值:
constructor(props: IFormProps) {
...
this.state = {
errors,
submitted: false,
submitting: false,
values: props.defaultValues
};
}
- 如果表单正在提交或已成功提交,我们现在可以禁用提交按钮:
<button
type="submit"
disabled={this.state.submitting || this.state.submitted}
>
Submit
</button>
- 让我们在
form
标记中引用一个提交处理程序:
<form className="form" noValidate={true} onSubmit={this.handleSubmit}>
...
</form>
- 现在,我们可以开始实现刚才引用的提交处理程序:
private handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
我们在 submit 事件参数中调用preventDefault
,以停止浏览器自动发布表单。
- 在开始表单提交过程之前,我们需要确保所有字段都有效。让我们引用并创建一个
validateForm
函数,它可以执行以下操作:
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()) {
}
};
validateForm
函数遍历字段,调用已经实现的validate
函数。状态更新为最新的验证错误,我们返回任何字段中是否存在任何错误。
- 现在让我们实现提交处理程序的其余部分:
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
});
}
};
如果表单有效,我们首先将submitting
状态设置为true
。然后我们异步调用onSubmit
prop 函数。当onSubmit
prop 函数完成后,我们将函数中的任何验证错误设置为状态,以及提交是否成功。我们还将提交过程已完成这一事实设置为状态。
现在,我们的Form
组件有一个onSubmit
函数。在下一节中,我们将在“联系我们”页面中使用它。
在本节中,我们将使用ContactUs
组件中的onSubmit
表单道具。ContactUs
组件不会管理提交,它只会委托ContactUsPage
组件处理提交:
- 我们先导入
ISubmitResult
和IValues
,在ContactUs
组件中为onSubmit
功能创建一个道具界面:
import { Form, ISubmitResult, IValues, minLength, required } from "./Form";
interface IProps {
onSubmit: (values: IValues) => Promise<ISubmitResult>;
} const ContactUs: React.SFC<IProps> = props => { ... }
- 创建一个调用
onSubmit
属性的handleSubmit
函数:
const ContactUs: React.SFC<IProps> = props => {
const handleSubmit = async (values: IValues): Promise<ISubmitResult> => {
const result = await props.onSubmit(values);
return result;
};
return ( ... );
};
onSubmit
prop 是异步的,所以我们需要用async
作为函数的前缀,用await
作为onSubmit
调用的前缀。
- 在 JSX 中以
onSubmit
prop 的形式绑定此提交处理程序:
return (
<Form ... onSubmit={handleSubmit}>
...
</Form>
);
- 现在让我们转到
ContactUsPage
组件。让我们从创建提交处理程序开始:
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
};
};
实际上,这可能会调用 web API。在我们的示例中,我们异步等待一秒钟,并返回带有email
字段的验证错误。
- 让我们创建刚才引用的
wait
函数:
const wait = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
};
- 现在让我们将
handleSubmit
方法连接到ContactUs``onSubmit
道具:
<ContactUs onSubmit={this.handleSubmit} />
- 我们已经引用了
IValues
和ISubmitResult
,所以让我们导入这些:
import { ISubmitResult, IValues } from "./Form";
如果我们进入 running 应用程序中的“联系我们”页面,填写表格,然后单击“提交”按钮,我们会被告知电子邮件字段存在问题,正如我们所预期的:
- 让我们更改
ContactUsPage
中的提交处理程序,以返回一个成功的结果:
private handleSubmit = async (values: IValues): Promise<ISubmitResult> => {
await wait(1000); // simulate asynchronous web API call
return {
success: true
};
};
现在,如果我们再次进入 running 应用程序中的“联系我们”页面,填写表单,然后单击“提交”按钮,则提交将完成,并且“提交”按钮将被禁用:
这就是我们完整的联系我们页面,以及我们的通用Form
和Field
组件。
在本章中,我们讨论了控制组件,这是 React 推荐的处理表单数据输入的方法。对于受控组件,我们可以通过组件状态来反应控制输入值。
我们着眼于构建包含状态和更改处理程序的通用Form
和Field
组件,这样我们就不需要为应用程序中的每个表单中的每个字段实现单独的状态和更改处理程序。
然后,我们创建了一些标准验证函数,并添加了在通用Form
组件中添加验证规则的功能,以及在Field
组件中自动呈现验证错误的功能。
最后,我们添加了在使用通用Form
组件时处理表单提交的功能。我们的联系我们页面更改为使用通用Form
和Field
组件。
我们的通用组件只处理非常简单的表单。毫不奇怪,已经有相当数量的成熟表单库出现在野外。一个流行的选择是 Formik,它在某些方面与我们刚刚构建的类似,但功能更强大。
如果您正在构建一个包含大量表单的应用程序,那么您可以像我们刚才所做的那样构建一个通用表单,或者使用一个已建立的库(如 Formik)来加快开发过程。
通过尝试以下实现,检查 React 和 TypeScript 中有关表单的所有信息是否都已被卡住:
-
使用本机数字输入扩展我们的通用
Field
组件以包括数字编辑器。 -
在“联系我们”表单上设置一个紧急字段,以指示响应的紧急程度。该字段应为数字。
-
在泛型
Form
组件中实现一个新的验证器函数,用于验证一个数字是否介于其他两个数字之间。 -
在“紧急程度”字段上实施验证规则,以确保输入是介于 1 和 10 之间的数字。
-
当用户在没有键入任何内容的情况下单击某个字段时,就会触发我们的验证。当一个字段失去焦点,但仅当它被更改时,我们如何触发验证?
以下链接是 React 中表单的详细信息来源:
- React 文档中有一个关于表格的章节,位于https://reactjs.org/docs/forms.html 。
- Formik 图书馆非常值得一看。这可以在找到 https://github.com/jaredpalmer/formik 。