React-和-TypeScript3-学习手册-四-

65 阅读35分钟

React 和 TypeScript3 学习手册(四)

原文:zh.annas-archive.org/md5/9ec979022a994e15697a4059ac32f487

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:处理表单

表单在我们构建的应用程序中非常常见。在本章中,我们将学习如何在 React 和 TypeScript 中使用受控组件构建表单。作为学习练习,我们将为我们在其他章节中一直在开发的 React 商店构建一个联系我们表单。

我们很快会发现,在创建表单时涉及大量样板代码,因此我们将研究构建通用表单组件以减少样板代码。客户端验证对我们构建的表单的用户体验至关重要,因此我们还将深入讨论这个主题。

最后,表单提交是一个关键考虑因素。我们将介绍如何处理提交错误,以及成功情况。

在本章中,我们将讨论以下主题:

  • 使用受控组件创建表单

  • 使用通用组件减少样板代码

  • 验证表单

  • 表单提交

技术要求

我们将在本章中使用以下技术:

  • Node.jsnpm:TypeScript 和 React 依赖于这些。可以从以下链接安装它们:nodejs.org/en/download/。如果您已经安装了这些,请确保npm至少是 5.2 版本。

  • Visual Studio Code:我们需要一个编辑器来编写 React 和 TypeScript

代码,可以从code.visualstudio.com/安装。我们还需要 TSLint 扩展(由 egamma 提供)和 Prettier 扩展(由 Estben Petersen 提供)。

为了从上一章节恢复代码,可以在github.com/carlrip/LearnReact17WithTypeScript上下载LearnReact17WithTypeScript存储库。然后可以在 Visual Studio Code 中打开相关文件夹,然后在终端中输入npm install来进行恢复。本章中的所有代码片段都可以在github.com/carlrip/LearnReact17WithTypeScript/tree/master/07-WorkingWithForms上找到。

使用受控组件创建表单

表单是大多数应用程序的常见部分。在 React 中,创建表单的标准方式是使用所谓的受控组件。受控组件的值与 React 中的状态同步。当我们实现了我们的第一个受控组件时,这将更有意义。

我们将扩展我们一直在构建的 React 商店,以包括一个联系我们表单。这将使用受控组件来实现。

添加联系我们页面

在我们开始处理表单之前,我们需要一个页面来承载表单。该页面将是一个容器组件,我们的表单将是一个展示组件。我们还需要创建一个导航选项,可以带我们到我们的新页面。

在开始实现我们的表单之前,我们将写下以下代码:

  1. 如果还没有,打开在 Visual Studio Code 中的 React 商店项目。在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;

这个组件最终将包含状态,因此我们创建了一个基于类的组件。目前,它只是简单地呈现一个带有一些说明的标题。最终,它将引用我们的表单。

  1. 现在让我们将这个页面添加到可用的路由中。打开Routes.tsx,并导入我们的页面:
import ContactUsPage from "./ContactUsPage";
  1. 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>
  1. 现在打开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>
  1. 通过在终端中输入以下内容,在开发服务器中运行项目:
npm start

你应该看到一个新的导航选项,可以带我们到我们的新页面:

现在我们有了新页面,我们准备在表单中实现我们的第一个受控输入。我们将在下一节中完成这个任务。

创建受控输入

在这一部分,我们将开始创建包含我们第一个受控输入的表单:

  1. 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;

这是一个函数组件,用于呈现一个包含用户姓名标签和输入框的表单。

  1. 我们引用了一些 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类将包装表单中的每个字段,显示标签在输入框上方,并具有良好的间距。

  1. 现在让我们从我们的页面引用我们的表单。转到ContactUsPage.tsx并导入我们的组件:
import ContactUs from "./ContactUs";
  1. 然后我们可以在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>

如果我们查看正在运行的应用程序并转到联系我们页面,我们将看到我们的名字字段被呈现:

我们可以将我们的名字输入到这个字段中,但目前什么也不会发生。我们希望输入的名字存储在ContactUsPage容器组件的状态中。这是因为ContactUsPage最终将管理表单提交。

  1. 让我们为ContactUsPage添加一个状态类型:
interface IState {
 name: string;
 email: string;
 reason: string;
 notes: string;
}

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

除了人的名字,我们还将捕获他们的电子邮件地址,联系商店的原因以及任何其他附加说明。

  1. 让我们还在构造函数中初始化状态:
public constructor(props: {}) {
  super(props);
  this.state = {
    email: "",
    name: "",
    notes: "",
    reason: ""
  };
}
  1. 现在,我们需要将ContactUsPage中的名字值传递到ContactUs组件中。这将允许我们在输入框中显示该值。我们可以通过首先在ContactUs组件中创建 props 来实现这一点:
interface IProps {
 name: string;
 email: string;
 reason: string;
 notes: string;
}

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

我们已为我们最终要捕获的所有数据创建了 props。

  1. 现在,我们可以将名字输入值绑定到name属性:
<div className="form-group">
  <label htmlFor="name">Your name</label>
  <input type="text" id="name" value={props.name} />
</div>
  1. 现在,我们可以从ContactUsPage的状态中传递这些:
<ContactUs 
  name={this.state.name} 
 email={this.state.email} 
 reason={this.state.reason} 
 notes={this.state.notes} 
/>

让我们去运行的应用程序并转到我们的联系我们页面。尝试在名字输入框中输入一些内容。

似乎什么都没有发生……有什么东西阻止我们输入值。

我们刚刚将输入值设置为一些 React 状态,因此 React 现在控制着输入的值。这就是为什么我们似乎不再能够输入的原因。

我们正在创建我们的第一个受控输入。但是,如果用户无法输入任何内容,受控输入就没有多大用处。那么,我们如何使输入框再次可编辑呢?

答案是我们需要监听输入值的更改,并相应地更新状态。然后 React 将从状态中呈现新的输入值。

  1. 让我们通过onChange属性监听输入的更改:
<input type="text" id="name" value={props.name} onChange={handleNameChange} />
  1. 让我们也创建我们刚刚引用的处理程序:
const ContactUs: React.SFC<IProps> = props => {
  const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
 props.onNameChange(e.currentTarget.value);
 };
  return ( ... );
};

请注意,我们已经使用了通用的React.ChangeEvent命令,其类型为我们正在处理的元素(HTMLInputElement)。

事件参数中的currentTarget属性为我们提供了事件处理程序所附加到的元素的引用。其中的value属性为我们提供了输入的最新值。

  1. 处理程序引用了一个我们尚未定义的onNameChange函数属性。因此,让我们将其添加到我们的接口中,以及其他字段的类似属性:
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;
}
  1. 现在我们可以将这些 props 从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}
/>
  1. 让我们在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 });
};

如果我们现在去运行应用程序中的联系我们页面,并输入一些内容到姓名中,这次输入会按预期的方式行为。

  1. 让我们在ContactUsrender方法中添加电子邮件、原因和备注字段:
<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 在select标签中添加了value属性,并在幕后管理option标签上的selected属性。这使我们能够一致地在我们的代码中管理inputtextareaselected

  1. 现在让我们为这些字段创建更改处理程序,调用我们之前创建的函数 props:
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);
};

这完成了我们基本的联系我们表单,使用各种受控表单元素。我们还没有实现任何验证或提交表单。我们将在本章后面处理这些问题。

我们已经注意到为每个字段获取字段更改到状态的类似代码。在下一节中,我们将开始开发一个通用表单组件,并切换到使用它来处理我们的联系我们表单。

使用通用组件减少样板代码

通用表单组件将有助于减少实现表单所需的代码量。在本节中,我们将对我们在上一节中为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>

在这个例子中,有两个通用的复合组件:FormField。以下是一些关键点:

  • Form组件是复合组件的容器,管理状态和交互。

  • 我们在Form组件的defaultValues属性中传递字段的默认值。

  • Field组件为每个字段渲染标签和编辑器。

  • 每个字段都有一个name属性,它将确定状态中存储字段值的属性名称。

  • 每个字段都有一个label属性,用于指定每个字段标签中显示的文本。

  • 使用type属性指定特定的字段编辑器。默认编辑器是基于文本的input

  • 如果编辑器类型是Select,那么我们可以使用options属性指定出现在其中的选项。

渲染新的ContactUs组件的 JSX 比原始版本要短得多,而且可能更容易阅读。状态管理和事件处理程序被隐藏在Form组件中并封装起来。

创建一个基本的表单组件

是时候开始处理我们的通用Form组件了:

  1. 让我们首先在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是一个基于类的组件,因为它需要管理状态。我们将 props 接口命名为IFormProps,因为以后我们将需要一个字段 props 的接口。

  1. 让我们在IFormProps接口中添加一个defaultValues属性。这将保存表单中每个字段的默认值:
export interface IValues {
 [key: string]: any;
}

interface IFormProps {
  defaultValues: IValues;
}

我们使用了一个名为IValues的额外接口来表示默认值类型。这是一个可索引的键/值类型,具有string类型的键和any类型的值。键将是字段名称,值将是字段值。

因此,defaultValues属性的值可以是这样的:

{ name: "", email: "", reason: "Support", notes: "" }
  1. 现在让我们继续处理Form中的状态。我们将在状态属性values中存储字段值:
interface IState {
  values: IValues;
}

请注意,这与defaultValues属性的类型相同,即IValues

  1. 现在我们将在构造函数中使用默认值初始化状态:
constructor(props: IFormProps) {
  super(props);
  this.state = {
    values: props.defaultValues
  };
}
  1. 在本节中,我们要做的最后一件事是开始实现Form组件中的render方法:
public render() {
 return (
 <form className="form" noValidate={true}>
 {this.props.children}
 </form>
 );
}

我们在form标签中渲染子组件,使用了我们在上一章中使用的神奇的children属性。

这很好地引出了Field组件,我们将在下一节中实现它。

添加一个基本的 Field 组件

Field组件需要渲染标签和编辑器。它将位于Form组件内部的静态属性Field中。消费者可以使用Form.Field来引用此组件:

  1. 让我们首先在Form.tsx中为字段 props 创建一个接口,就在IFormProps上面:
interface IFieldProps {
  name: string;
  label: string;
  type?: "Text" | "Email" | "Select" | "TextArea";
  options?: string[];
}
  • name属性是字段的名称。

  • label属性是要在字段标签中显示的文本。

  • type属性是要显示的编辑器类型。我们已经为此属性使用了联合类型,包含了我们将要支持的可用类型。请注意,我们已将其定义为可选属性,因此稍后需要为此定义一个默认值。

  • options属性仅适用于Select编辑器类型,也是可选的。这定义了要在下拉列表中显示的选项列表,是一个string数组。

  1. 现在,让我们在Form中为Field组件添加一个骨架静态Field属性:
public static Field: React.SFC<IFieldProps> = props => {
  return ();
};
  1. 在我们忘记之前,让我们为字段type属性添加默认值。我们将其定义如下,在Form类的外部和下方:
Form.Field.defaultProps = {
  type: "Text"
};

因此,默认的type将是基于文本的输入。

  1. 现在,让我们尝试渲染字段:
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>
  );
}
  • 我们首先从 props 对象中解构namelabeltypeoptions

  • 该字段被包裹在一个div容器中,使用form-group类在index.css中已经实现的方式在垂直方向上间隔字段。

  • 然后,在div容器内部渲染labellabelhtmlFor属性引用inputid

这是一个很好的开始,但并非所有不同的字段编辑器都是输入。实际上,这只适用于TextEmail类型。

  1. 因此,让我们稍微调整一下,并在输入周围包裹一个条件表达式:
<label htmlFor={name}>{label}</label>
{(type === "Text" || type === "Email") && (
  <input type={type.toLowerCase()} id={name} />
)}
  1. 接下来,让我们通过添加高亮的 JSX 来处理TextArea类型:
{(type === "Text" || type === "Email") ... }

{type === "TextArea" && (
 <textarea id={name} />
)}
  1. 现在,我们可以渲染我们将要支持的最终编辑器,如下所示:
{type === "TextArea" ... } {type === "Select" && (
  <select>
    {options &&
      options.map(option => (
        <option key={option} value={option}>
          {option}
        </option>
      ))}
  </select>
)} 

我们渲染一个select标签,其中包含使用options数组属性中的map函数指定的选项。请注意,我们为每个选项分配一个唯一的key属性,以便在检测到选项的任何更改时保持 React 的正常运行。

现在,我们已经有了基本的FormField组件,这很棒。但是,实现仍然相当无用,因为我们尚未在状态中管理字段值。让我们在下一节中解决这个问题。

与 React 上下文共享状态

字段值的状态存储在Form组件中。但是,这些值是通过Field组件渲染和更改的。Field组件无法访问Form中的状态,因为状态存在于Form实例中,而Field没有。

这与我们在上一章中实现的复合Tabs组件非常相似。我们使用 React 上下文在Tabs复合组件之间共享状态。

在本节中,我们将使用相同的方法来处理Forms组件。

  1. 让我们首先在Form.tsx中为表单上下文创建一个接口:
interface IFormContext {
  values: IValues;
}

上下文只包含与我们状态中的IValues相同类型的值。

  1. 现在让我们在IFormContext下方使用React.createContext创建上下文组件:
const FormContext = React.createContext<IFormContext>({
  values: {}
});

通过将初始上下文值设置为空文字值,我们使 TypeScript 编译器满意。

  1. Formrender方法中,创建包含状态中的值的上下文值:
public render() {
  const context: IFormContext = {
 values: this.state.values
 };
  return ( ... )
}
  1. render方法的 JSX 中的form标签周围包装上下文提供程序:
<FormContext.Provider value={context}>
  <form ... >
    ...
  </form>
</FormContext.Provider>
  1. 现在我们可以在Field SFC 中使用上下文:
<FormContext.Consumer>
 {context => (
    <div className="form-group">
    </div>
 )}
</FormContext.Consumer>
  1. 既然我们可以访问上下文了,让我们在所有三个编辑器中从中呈现值:
<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 编译器现在对我们的FormField组件满意了。因此,我们可以开始对新的ContactUs实现进行工作。

然而,用户现在还不能输入任何内容到我们的表单中,因为我们还没有处理更改并将新值传递给状态。现在我们需要实现更改处理程序。

  1. 让我们首先在Form类中创建一个setValue方法:
private setValue = (fieldName: string, value: any) => {
  const newValues = { ...this.state.values, [fieldName]: value };
  this.setState({ values: newValues });
};

这个方法的关键点如下:

  • 该方法接受字段名称和新值作为参数。

  • 使用一个名为newValues的新对象创建values对象的新状态,该对象展开了状态中的旧值,然后添加了新的字段名称和值。

  • 然后在状态中设置新值。

  1. 然后我们在表单上下文中创建对该方法的引用,以便Field组件可以访问它。让我们首先将其添加到表单上下文接口中:
interface IFormContext {
  values: IValues;
  setValue?: (fieldName: string, value: any) => void;
}

我们将属性设置为可选,以便在创建表单上下文组件时使 TypeScript 编译器满意。

  1. 然后我们可以在创建上下文值时在Form中创建对setValue方法的引用:
const context: IFormContext = {
  setValue: this.setValue,
  values: this.state.values
};
  1. 现在我们可以从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 事件处理程序参数。我们将所有不同的编辑器的 change 处理程序类型联合起来,这样我们就可以在一个函数中处理所有的变化。

  • 处理程序的第二个参数是表单上下文。

  • 我们需要一个条件语句来检查setValue方法是否不是undefined,以使 TypeScript 编译器满意。

  • 然后我们可以使用字段名称和新值调用setValue方法。

  1. 然后我们可以在input标签中引用这个 change handler,如下所示:
<input 
  type={type.toLowerCase()} 
  id={name} 
  value={context.values[name]}
  onChange={e => handleChange(e, context)} 
/>

请注意,我们使用了一个 lambda 函数,这样我们就可以将上下文值传递给handleChange

  1. 我们也可以在textarea标签中这样做:
<textarea 
  id={name} 
  value={context.values[name]} 
  onChange={e => handleChange(e, context)} 
/>
  1. 我们也可以在select标签中这样做:
<select 
 value={context.values[name]}
 onChange={e => handleChange(e, context)} 
>
 ...
</select>

因此,我们的FormField组件现在很好地协同工作,渲染字段并管理它们的值。在下一节中,我们将通过实现一个新的ContactUs组件来尝试我们的通用组件。

实现我们的新 ContactUs 组件

在本节中,我们将使用我们的FormField组件实现一个新的ContactUs组件:

  1. 让我们首先从ContactUs.tsx中删除 props 接口。

  2. ContactUs SFC 中的内容将与原始版本非常不同。让我们首先删除内容,使其看起来如下:

const ContactUs: React.SFC = () => {
  return ();
};
  1. 让我们将我们的Form组件导入到ContactUs.tsx中:
import { Form } from "./Form";
  1. 现在我们可以引用Form组件,传递一些默认值:
return (
  <Form
 defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
 >
 </Form>
);
  1. 让我们添加name字段:
<Form
  defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
>
  <Form.Field name="name" label="Your name" />
</Form>

请注意,我们没有传递type属性,因为这将默认为基于文本的输入,这正是我们需要的。

  1. 现在让我们添加emailreasonnotes字段:
<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>
  1. ContactUsPage现在会简单得多。它不会包含任何状态,因为现在状态是在Form组件中管理的。我们也不需要向ContactUs组件传递任何 props:
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>
    );
  }
}

如果我们转到运行中的应用程序并转到联系我们页面,它会按照要求呈现并接受我们输入的值。

我们的通用表单组件正在良好地进展,并且我们已经使用它来实现了ContactUs组件,正如我们所希望的那样。在下一节中,我们将通过添加验证进一步改进我们的通用组件。

验证表单

在表单中包含验证可以提高用户体验,让他们立即得到关于输入信息是否有效的反馈。在本节中,我们将为我们的Form组件添加验证,然后在我们的ContactUs组件中使用它。

我们将在ContactUs组件中实现的验证规则是这些:

  • 名称和电子邮件字段应填写

  • 名称字段应至少为两个字符

当字段编辑器失去焦点时,我们将执行验证规则。

在下一节中,我们将向Form组件添加一个属性,允许消费者指定验证规则。

向表单添加验证规则属性

让我们考虑如何指定验证规则给表单。我们需要能够为一个字段指定一个或多个规则。一些规则可能有参数,比如最小长度。如果我们能够像下面的示例那样指定规则就好了:

<Form
  ...
  validationRules={{
 email: { validator: required },
 name: [{ validator: required }, { validator: minLength, arg: 3 }]
 }}
>
  ...
</Form>

让我们尝试在Form组件上实现validationRules属性:

  1. 首先在Form.tsx中为Validator函数定义一个类型:
export type Validator = (
  fieldName: string,
  values: IValues,
  args?: any
) => string;

Validator函数将接受字段名称、整个表单的值和特定于函数的可选参数。将返回包含验证错误消息的字符串。如果字段有效,则返回空字符串。

  1. 让我们使用此类型创建一个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实现中使用。该函数检查字段值是否为undefinednull或空字符串,如果是,则返回必须填写此字段的验证错误消息。

如果字段值不是undefinednull或空字符串,则返回空字符串以指示该值有效。

  1. 同样,让我们为检查字段输入是否超过最小长度创建一个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`
    : "";

该函数检查字段值的长度是否小于长度参数,如果是,则返回验证错误消息。否则,返回空字符串以指示该值有效。

  1. 现在,让我们通过一个属性向Form组件传递验证规则的能力:
interface IValidation {
 validator: Validator;
 arg?: any;
}

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

interface IFormProps {
  defaultValues: IValues;
  validationRules: IValidationProp;
}
  • validationRules属性是一个可索引的键/值类型,其中键是字段名称,值是一个或多个IValidation类型的验证规则。

  • 验证规则包含Validator类型的验证函数和传递到验证函数的参数。

  1. 有了新的validationRules属性,让我们将其添加到ContactUs组件中。首先导入验证函数:
import { Form, minLength, required } from "./Form";
  1. 现在,让我们将验证规则添加到ContactUs组件的 JSX 中:
<Form
  defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
  validationRules={{
 email: { validator: required },
 name: [{ validator: required }, { validator: minLength, arg: 2 }]
 }}
>
  ...
</Form>

现在,如果名称和电子邮件已填写,并且名称至少为两个字符长,我们的表单就是有效的。

这就是validationRulesprop 的完成。在下一节中,我们将跟踪验证错误消息,以准备在页面上呈现它们。

跟踪验证错误消息

当用户完成表单并字段变为有效或无效时,我们需要在状态中跟踪验证错误消息。稍后,我们将能够将错误消息呈现到屏幕上。

Form组件负责管理所有表单状态,因此我们将错误消息状态添加到其中,如下所示:

  1. 让我们将验证错误消息状态添加到表单状态接口中:
interface IErrors {
 [key: string]: string[];
}

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

errors状态是可索引的键/值类型,其中键是字段名称,值是验证错误消息的数组。

  1. 让我们在构造函数中初始化errors状态:
constructor(props: IFormProps) {
  super(props);
  const errors: IErrors = {};
 Object.keys(props.defaultValues).forEach(fieldName => {
 errors[fieldName] = [];
 });
  this.state = {
    errors,
    values: props.defaultValues
  };
}

defaultValuesprop 包含其键中的所有字段名称。我们遍历defaultValues键,将适当的errors键设置为空数组。因此,当Form组件初始化时,没有任何字段包含任何验证错误消息,这正是我们想要的。

  1. Field组件最终将呈现验证错误消息,因此我们需要将这些添加到表单上下文中。让我们从将这些添加到表单上下文接口开始:
interface IFormContext {
 errors: IErrors;  values: IValues;
  setValue?: (fieldName: string, value: any) => void;
}
  1. 让我们在创建上下文时将errors空文字作为默认值添加。这是为了让 TypeScript 编译器满意:
const FormContext = React.createContext<IFormContext>({
  errors: {},
  values: {}
});
  1. 现在,我们可以在上下文值中包含错误:
public render() {
  const context: IFormContext = {
    errors: this.state.errors,
    setValue: this.setValue,
    values: this.state.values
  };
  return (
    ...
  );
}

现在,验证错误在表单状态中,也在表单上下文中,以便Field组件可以访问。在下一节中,我们将创建一个方法来调用验证规则。

调用验证规则

到目前为止,我们可以定义验证规则,并且有状态来跟踪验证错误消息,但是还没有调用规则。这就是我们将在本节中实现的内容:

  1. 我们需要在Form组件中创建一个方法,该方法将验证字段,调用指定的验证器函数。让我们创建一个名为validate的方法,该方法接受字段名称和其值。该方法将返回一个验证错误消息数组:
private validate = (
  fieldName: string,
  value: any
): string[] => {

};
  1. 让我们获取字段的验证规则并初始化一个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;
}
  1. 规则可以是一个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;
  1. 现在让我们处理有多个验证规则时的代码分支。我们可以在规则数组上使用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;
  1. 我们需要在validate方法中实现的最后一部分代码是设置新的errors表单状态:
if (Array.isArray(rules)) {
 ...
} else {
 ...
}
const newErrors = { ...this.state.errors, [fieldName]: errors };
this.setState({ errors: newErrors });
return errors;

我们将旧的错误状态扩展到一个新对象中,然后为字段添加新的错误。

  1. Field组件需要调用这个validate方法。我们将在表单上下文中添加对这个方法的引用。让我们先将它添加到IFormContext接口中:
interface IFormContext {
  values: IValues;
  errors: IErrors;
  setValue?: (fieldName: string, value: any) => void;
  validate?: (fieldName: string, value: any) => void;
}
  1. 现在我们可以在Formrender方法中将其添加到上下文值中:
public render() {
  const context: IFormContext = {
    errors: this.state.errors,
    setValue: this.setValue,
    validate: this.validate,
    values: this.state.values
  };
  return (
    ...
  );
}

我们的表单验证进展顺利,现在我们有一个可以调用的方法来调用字段的所有规则。然而,这个方法还没有被从任何地方调用,因为用户填写表单。我们将在下一节中做这件事。

从字段触发验证规则执行

当用户填写表单时,我们希望在字段失去焦点时触发验证规则。我们将在本节中实现这一点:

  1. 让我们创建一个函数,来处理三种不同编辑器的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 模糊事件处理程序参数。我们将所有不同的处理程序类型联合起来,这样我们就可以在一个函数中处理所有的模糊事件。

  • 处理程序的第二个参数是表单上下文。

  • 我们需要一个条件语句来检查validate方法是否不是undefined,以使 TypeScript 编译器满意。

  • 然后我们可以使用字段名称和需要验证的新值调用validate方法。

  1. 现在我们可以在文本和电子邮件编辑器的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属性设置为调用我们的handleBlur函数的 lambda 表达式,同时传入模糊参数和上下文值。

  1. 现在让我们在另外两个编辑器中引用这个处理程序:
{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组件中渲染验证错误消息:

  1. 让我们在form-groupdiv容器底部显示所有错误,使用我们已经实现的form-error CSS 类的span
<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

  1. 我们已经引用了一个 CSS form-error类,所以让我们把它添加到index.css中:
.form-error {
  font-size: 13px;
  color: red;
  margin: 3px auto 0px 0px;
}

现在是时候尝试联系我们页面了。如果我们的应用程序没有启动,请使用npm start启动它,然后转到联系我们页面。如果我们通过名称和电子邮件字段进行切换,将触发必填验证规则,并显示错误消息:

这正是我们想要的。如果我们回到名称字段,尝试在切换之前只输入一个字符,那么最小长度验证错误会触发,正如我们所期望的那样:

我们的通用表单组件现在几乎完成了。我们的最后任务是提交表单,我们将在下一节中完成。

表单提交

提交表单是表单实现的最后一部分。Form组件的消费者将处理实际的提交,这可能会导致调用 Web API。我们的Form组件在表单提交时将简单地调用消费者代码中的一个函数。

在表单中添加一个提交按钮

在这一节中,我们将向我们的Form组件添加一个提交按钮:

  1. 让我们在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>
  1. 使用以下 CSS 样式为按钮添加样式在index.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;
}

我们现在在表单上有一个黑色的提交按钮,当禁用时是灰色的。

添加一个 onSubmit 表单 prop

在我们的Form组件中,我们需要一个新的 prop,允许消费者指定要调用的submit函数。我们将在这一节中完成这个任务:

  1. 让我们首先在Form props 接口中创建一个名为onSubmit的新 prop 函数:
export interface ISubmitResult {
 success: boolean;
 errors?: IErrors;
}

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

该函数将接受字段值,并异步返回提交是否成功,以及在服务器上发生的任何验证错误。

  1. 我们将跟踪表单是否正在提交或者在 Form 状态中成功提交的情况。
interface IState {
  values: IValues;
  errors: IErrors;
  submitting: boolean;
 submitted: boolean;
}
  1. 让我们在构造函数中初始化这些状态值:
constructor(props: IFormProps) {
  ...
  this.state = {
    errors,
    submitted: false,
 submitting: false,
    values: props.defaultValues
  };
}
  1. 如果表单正在提交或已成功提交,我们现在可以禁用提交按钮:
<button
  type="submit"
  disabled={this.state.submitting || this.state.submitted}
>
  Submit
</button>
  1. 让我们在 form 标签中引用一个提交处理程序:
<form className="form" noValidate={true} onSubmit={this.handleSubmit}>
  ...
</form>
  1. 现在我们可以开始实现我们刚刚引用的提交处理程序:
private handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();

};

我们在提交事件参数中调用 preventDefault 来阻止浏览器自动发布表单。

  1. 在开始表单提交过程之前,我们需要确保所有字段都是有效的。让我们引用并创建一个执行此操作的 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 函数。状态会更新为最新的验证错误,并返回字段中是否有任何错误。

  1. 让我们现在实现剩下的提交处理程序:
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 函数 prop。在下一节中,我们将在我们的联系我们页面中使用它。

使用 onSubmit 表单 prop

在这一节中,我们将在 ContactUs 组件中使用 onSubmit 表单 prop。ContactUs 组件不会管理提交,它只会委托给 ContactUsPage 组件来处理提交:

  1. 让我们首先导入 ISubmitResultIValues,并在 ContactUs 组件中为 onSubmit 函数创建一个 props 接口:
import { Form, ISubmitResult, IValues, minLength, required } from "./Form";

interface IProps {
 onSubmit: (values: IValues) => Promise<ISubmitResult>;
} const ContactUs: React.SFC<IProps> = props => { ... }
  1. 创建一个 handleSubmit 函数来调用 onSubmit prop:
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,并在 onSubmit 调用前加上 await

  1. 在 JSX 中将此提交处理程序绑定到表单的 onSubmit prop 中:
return (
  <Form ... onSubmit={handleSubmit}>
    ...
  </Form>
);
  1. 现在让我们转到 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 字段的验证错误。

  1. 让我们创建刚刚引用的 wait 函数:
const wait = (ms: number): Promise<void> => {
 return new Promise(resolve => setTimeout(resolve, ms));
};
  1. 现在让我们将handleSubmit方法与ContactUsonSubmit属性连接起来:
<ContactUs onSubmit={this.handleSubmit} />
  1. 我们已经引用了IValuesISubmitResult,所以让我们导入它们:
import { ISubmitResult, IValues } from "./Form";

如果我们转到正在运行的应用程序中的联系我们页面,填写表单并单击提交按钮,我们会收到有关电子邮件字段存在问题的通知,这是我们所期望的:

  1. 让我们将ContactUsPage中的提交处理程序更改为返回成功的结果:
private handleSubmit = async (values: IValues): Promise<ISubmitResult> => {
  await wait(1000); // simulate asynchronous web API call
 return {
 success: true
 };
};

现在,如果我们再次转到正在运行的应用程序中的联系我们页面,填写表单并单击提交按钮,提交将顺利进行,并且提交按钮将被禁用:

因此,我们的联系我们页面已经完成,还有我们的通用FormField组件。

总结

在本章中,我们讨论了受控组件,这是 React 处理表单数据输入的推荐方法。通过受控组件,我们让 React 通过组件状态控制输入值。

我们研究了构建通用的FormField组件,这些组件包含状态和更改处理程序,因此我们不需要为应用程序中每个表单中的每个字段实现单独的状态和更改处理程序。

然后,我们创建了一些标准验证函数,并在通用Form组件中添加了添加验证规则的能力,并在Field组件中自动呈现验证错误。

最后,我们添加了在使用通用Form组件时处理表单提交的能力。我们的联系我们页面已更改为使用通用的FormField组件。

我们的通用组件只处理非常简单的表单。毫不奇怪,已经有相当多的成熟表单库在外面。一个受欢迎的选择是 Formik,它在某些方面类似于我们刚刚构建的内容,但功能更加强大。

如果您正在构建包含大量表单的应用程序,构建一个通用表单或使用 Formik 等已建立的库来加快开发过程是非常值得的。

问题

通过尝试以下实现来检查关于 React 和 TypeScript 中表单的所有信息是否已经掌握:

  1. 扩展我们的通用Field组件,使用原生数字输入包括一个数字编辑器。

  2. 在联系我们表单上实现一个紧急性字段,以指示回复的紧急程度。该字段应为数字。

  3. 在通用的Form组件中实现一个新的验证器函数,用于验证一个数字是否落在另外两个数字之间。

  4. 在紧急字段上实施验证规则,以确保输入是 1 到 10 之间的数字。

  5. 我们的验证在用户点击字段而不输入任何内容时触发。当字段失去焦点时如何触发验证,但只有在字段已经被更改时?

进一步阅读

以下链接是关于 React 中表单的进一步信息的良好来源:

第八章:React Redux

到目前为止,在本书中,我们已经在 React 组件内部管理了状态。当状态需要在不同组件之间共享时,我们还使用了 React 上下文。这种方法对许多应用程序都很有效。React Redux 帮助我们强大地处理复杂的状态场景。当用户交互导致状态发生多个变化时,它会发挥作用,也许其中一些是有条件的,特别是当交互导致 web 服务调用时。当应用程序中存在大量共享状态时,它也非常有用。

在本章中,我们将继续构建我们的 React 商店,添加 React Redux 来帮助我们管理状态交互。最终,我们将在商店的页眉中添加一个购物篮摘要组件,通知用户他们的购物篮中有多少件商品。Redux 将帮助我们在商品添加到购物篮时更新此组件。

在本章的最后一节中,我们将探讨一种类似于 Redux 的方法,用于在组件内部管理复杂状态。这是在 Redux 存储中管理状态和仅在组件内部使用setStateuseState之间的中间地带。

在本章中,我们将学习以下主题:

  • 原则和关键概念

  • 安装 Redux

  • 创建 reducers

  • 创建动作

  • 创建存储

  • 将我们的 React 应用连接到存储

  • 使用 useReducer 管理状态

技术要求

在本章中,我们将使用以下技术:

为了从上一章恢复代码,可以下载github.com/carlrip/LearnReact17WithTypeScript上的LearnReact17WithTypeScript存储库。然后可以在 Visual Studio Code 中打开相关文件夹,然后在终端中输入npm install进行恢复。本章中的所有代码片段都可以在github.com/carlrip/LearnReact17WithTypeScript/tree/master/08-ReactRedux%EF%BB%BF上找到。

原则和关键概念

在本节中,我们将首先介绍 Redux 中的三个原则,然后深入探讨核心概念。

原则

让我们来看看 Redux 的三个原则:

  • 唯一数据源:这意味着整个应用程序状态存储在一个对象中。在真实的应用程序中,该对象可能包含复杂的嵌套对象树。

  • 状态是只读的:这意味着状态不能直接更改。这有点像说我们不能直接更改组件内的状态。在 Redux 中,更改状态的唯一方法是分派所谓的动作。

  • 更改是通过纯函数进行的:负责更改状态的函数称为 reducers。

在接下来的章节中,我们将更深入地了解动作和 reducers,以及管理它们的东西,即所谓的store

关键概念

应用程序的整个状态存储在所谓的store中。状态存储在一个 JavaScript 对象中,如下所示:

{
  products: [{ id: 1, name: "Table", ...}, {...}, ...],
  productsLoading: false,
  currentProduct: { id: 2, xname: "Chair", ... },
  basket: [{ product: { id: 2, xname: "Chair" }, quantity: 1 }],
};

在这个例子中,单个对象包含以下内容:

  • 产品数组

  • 产品是否正在从 Web API 中获取

  • 用户正在查看的当前产品

  • 用户购物篮中的物品

状态不包含任何函数、设置器或任何获取器。它是一个简单的 JavaScript 对象。存储还协调 Redux 中的所有移动部分。这包括通过 reducers 推送动作来更新状态。

因此,要更新存储中的状态,首先需要分派一个action。动作是另一个简单的 JavaScript 对象,如下所示:

{
  type: "PRODUCTS/LOADING"
}

type属性确定需要执行的操作类型。这是操作的一个重要且必需的部分。如果操作对象中没有type,reducer 将不知道如何更改状态。在前面的示例中,操作除了type属性之外没有包含任何其他内容。这是因为 reducer 不需要其他信息来为此类型的操作更改状态。

以下示例是另一个操作:

{
  type: "PRODUCTS/GETSINGLE",
  product: { id: 1, name: "Table", ...}
}

这次,在操作中包含了一个product属性的额外信息。reducer 需要这些额外信息来为此类型的操作更改状态。

因此,reducer 是实际更改状态的纯函数。

纯函数对于给定的一组参数总是返回相同的结果。因此,这些函数不依赖于函数范围之外的任何状态,而这些状态没有传递到函数中。纯函数也不会改变函数范围之外的任何状态。

以下是 reducer 的一个示例:

export const productsReducer = (state = initialProductState, action) => {
  switch (action.type) {
    case "PRODUCTS/LOADING": {
      return {
        ...state,
        productsLoading: true
      };
    }
    case "PRODUCTS/GETSINGLE": {
      return {
        ...state,
        currentProduct: action.product,
        productsLoading: false
      };
    }
    default:
  }
  return state || initialProductState;
};

以下是关于 reducer 的一些内容:

  • reducer 接受当前状态和正在执行的操作这两个参数。

  • 当首次调用 reducer 时,状态参数默认为初始状态对象

  • 在操作类型上使用 switch 语句,并为其每个分支创建一个新的状态对象

  • 为了创建新状态,我们将当前状态扩展到一个新对象中,然后用已更改的属性覆盖它

  • 新状态是从 reducer 返回的

您会注意到我们刚刚看到的操作和 reducer 没有 TypeScript 类型。显然,在我们在接下来的章节中实现这些时,我们将包含必要的类型。

因此,现在我们已经开始了解 Redux 是什么,是时候在我们的 React 商店中实践这一点了。

安装 Redux

在我们可以使用 Redux 之前,我们需要安装它以及 TypeScript 类型。我们还将安装一个名为redux-thunk的额外库,这是为了实现异步操作而需要的:

  1. 如果我们还没有的话,让我们从上一章结束的地方在 Visual Studio Code 中打开我们的 React 商店项目。因此,让我们在终端中通过npm安装核心 Redux 库:
npm install redux

请注意,核心 Redux 库中包含了 TypeScript 类型。因此,不需要额外安装这些类型。

  1. 让我们现在安装 Redux 的 React 特定部分。这些部分允许我们将 React 组件连接到 Redux 存储。让我们通过npm安装这些部分:
npm install react-redux
  1. 让我们也安装react-redux的 TypeScript 类型:
npm install --save-dev @types/react-redux
  1. 让我们也安装redux-thunk
npm install redux-thunk
  1. 最后,我们可以安装redux-thunk的 TypeScript 类型:
npm install --save-dev @types/redux-thunk

现在所有 Redux 部分都已安装,我们可以在下一节中将 Redux 添加到我们一直在开发的 React 商店中。

创建操作

我们将扩展我们在之前章节中构建的 React 商店,并添加 Redux 来管理Products页面上的状态。在本节中,我们将创建操作来开始将产品添加到页面的过程。将有一个操作来获取产品。将有另一个操作来改变一些新的加载状态,最终我们将把它与我们项目中已经拥有的withLoading HOC 联系起来。

在我们开始编写 Redux 操作之前,让我们在ProductsData.ts中创建一个虚拟 API 来获取产品:

export const getProducts = async (): Promise<IProduct[]> => {
  await wait(1000);
  return products;
};

因此,该函数在返回产品之前会异步等待一秒钟。

我们需要通过创建一些类型来开始实现我们的操作。我们将在下一步完成这个步骤。

创建状态和操作类型

现在是时候开始使用 Redux 来增强我们的 React 商店了。我们将首先为 Redux 存储创建一些状态和操作类型:

  1. 让我们在src文件夹中创建一个名为ProductsTypes.ts的新文件,并在顶部添加以下导入语句:
import { IProduct } from "./ProductsData";
  1. 让我们为我们将要实现的两种不同操作类型创建一个枚举:
export enum ProductsActionTypes {
  GETALL = "PRODUCTS/GETALL",
  LOADING = "PRODUCTS/LOADING"
}

Redux 不规定操作类型字符串的格式。因此,操作类型字符串的格式是我们的选择。但是,我们需要确保这些字符串在存储中的操作类型中是唯一的。因此,我们在字符串中包含了两个信息:

  • 操作涉及的存储区域。在我们的情况下,这是PRODUCTS

  • 该区域内的特定操作。在我们的情况下,我们有GETALL用于获取所有产品,LOADING用于指示产品正在被获取。

我们可以选择PRODUCTS-GETALLGet All Products。我们只需要确保字符串是唯一的。我们使用枚举来在实现操作和减速器时给我们良好的 IntelliSense。

  1. 现在我们可以为这两个操作创建接口:
export interface IProductsGetAllAction {
  type: ProductsActionTypes.GETALL,
  products: IProduct[]
}

export interface IProductsLoadingAction {
  type: ProductsActionTypes.LOADING
}

IProductsGetAllAction接口用于在需要获取产品时分派的动作。IProductsLoadingAction接口用于导致减速器改变加载状态的动作。

  1. 让我们将动作类型与联合类型结合在一起:
export type ProductsActions =
  | IProductsGetAllAction
  | IProductsLoadingAction

这将是传递给减速器的动作参数的类型。

  1. 最后,让我们在存储中为这个状态区域创建一个接口:
export interface IProductsState {
  readonly products: IProduct[];
  readonly productsLoading: boolean;
}

因此,我们的状态将包含一个产品数组,以及产品是否正在加载。

请注意,属性前缀带有readonly关键字。这将帮助我们避免直接更改状态。

现在我们已经为动作和状态准备好了类型,我们可以在下一节中创建一些动作。

创建动作

在这一节中,我们将创建两个动作,用于获取产品并指示产品正在加载。

  1. 让我们从创建一个带有以下导入语句的ProductsActions.ts文件开始:
import { ActionCreator, AnyAction, Dispatch } from "redux";

这些是我们在实现动作时将要使用的 Redux 中的一些类型。

  1. 我们的动作之一将是异步的。因此,让我们从redux-thunk中导入一个类型,以便在实现此动作时准备好:
import { ThunkAction } from "redux-thunk";
  1. 让我们添加另一个导入语句,以便我们可以使用我们的虚假 API:
import { getProducts as getProductsFromAPI } from "./ProductsData";

我们将 API 函数重命名为getProductsFromAPI,以避免名称冲突,因为我们将稍后创建一个名为getProducts的动作。

  1. 让我们还导入上一节中创建的类型:
import { IProductsGetAllAction, IProductsLoadingAction, IProductsState, ProductsActionTypes } from "./ProductsTypes";
  1. 我们现在要创建一个称为动作创建者的东西。动作创建者就像它的名字一样:它是一个创建并返回动作的函数!让我们为创建产品加载动作创建一个动作创建者:
const loading: ActionCreator<IProductsLoadingAction> = () => {
  return {
    type: ProductsActionTypes.LOADING
  }
};
  • 我们使用包含适当动作接口的泛型ActionCreator类型来定义函数签名

  • 该函数简单地返回所需的动作对象

我们可以使用隐式返回语句更简洁地编写这个函数,如下所示:

const loading: ActionCreator<IProductsLoadingAction> = () => ({
  type: ProductsActionTypes.LOADING
});

在实现动作创建者时,我们将使用这种更短的语法。

  1. 让我们继续实现获取产品的动作创建者。这更复杂,所以让我们从函数签名开始:
export const getProducts: ActionCreator<ThunkAction<Promise<AnyAction>, IProductsState, null, IProductsGetAllAction>> = () => {};

我们再次使用泛型ActionCreator类型,但这次它包含的不仅仅是最终返回的动作接口。这是因为这个特定的动作是异步的。

我们在ActionCreator中使用ThunkAction进行异步操作,这是一个具有四个参数的泛型类型:

  • 第一个参数是返回类型,理想情况下应该是Promise<IProductsGetAllAction>。但是,TypeScript 编译器很难解析这一点,因此我们选择了稍微宽松一些的Promise<AnyAction>类型。

  • 第二个参数是动作所关注的状态接口。

  • 第三个参数是传递给动作创建者的参数类型,在我们的情况下是null,因为没有参数。

  • 最后一个参数是动作的类型。

我们导出此动作创建者,因为最终将从ProductsPage组件中调用它。

  1. 异步动作需要返回一个最终会分派我们的动作的异步函数:
export const getProducts: ActionCreator<ThunkAction<Promise<AnyAction>, IProductsState, null, IProductsGetAllAction>> = () => {
  return async (dispatch: Dispatch) => {

 };
};

因此,该函数的第一件事是返回另一个函数,使用async关键字标记为异步。内部函数将调度程序从存储中作为参数。

  1. 让我们实现内部函数:
return async (dispatch: Dispatch) => {
  dispatch(loading());
 const products = await getProductsFromAPI();
 return dispatch({
 products,
 type: ProductsActionTypes.GETALL
 });
};
  • 我们首先要做的是分派另一个动作,以便加载状态最终由 reducer 相应地更改

  • 下一步是从虚拟 API 异步获取产品

  • 最后一步是分派所需的动作

现在我们已经创建了一些动作,我们将在下一节中创建一个 reducer。

创建 reducer

Reducer 是一个负责为给定动作创建新状态的函数。因此,该函数接受当前状态的动作,并返回新状态。在本节中,我们将为产品创建两个动作的 reducer。

  1. 让我们从创建一个名为ProductsReducer.ts的文件开始,其中包含以下导入语句:
import { Reducer } from "redux";
import { IProductsState, ProductsActions, ProductsActionTypes } from "./ProductsTypes";

我们从 Redux 中导入Reducer类型以及我们之前创建的动作和状态的类型。

  1. 接下来,我们需要定义初始状态是什么:
const initialProductState: IProductsState = {
  products: [],
  productsLoading: false
};

因此,我们将产品设置为空数组,并将产品加载状态设置为false

  1. 我们现在可以开始创建 reducer 函数:
export const productsReducer: Reducer<IProductsState, ProductsActions> = (
  state = initialProductState,
  action
) => {
 switch (action.type) {
  // TODO - change the state
 }
 return state;
};
  • 我们使用 Redux 的Reducer泛型类型对函数进行了类型化,传入了我们的状态和动作类型。这为我们提供了很好的类型安全性。

  • 该函数根据 Redux 所需的状态和动作参数。

  • 状态默认为我们在上一步中设置的初始状态对象。

  • 在函数的最后,如果动作类型在 switch 语句中没有被识别,我们将返回默认状态。

  1. 让我们继续实现我们的 reducer:
switch (action.type) {
  case ProductsActionTypes.LOADING: {
 return {
 ...state,
 productsLoading: true
 };
 }
 case ProductsActionTypes.GETALL: {
 return {
 ...state,
 products: action.products,
 productsLoading: false
 };
 }
}

我们为每个 action 实现了一个 switch 分支。两个分支都遵循相同的模式,通过返回一个新的状态对象,其中包含旧状态并合并了适当的属性。

这是我们的第一个 reducer 完成。在下一节中,我们将创建我们的 store。

创建一个 store

在本节中,我们将创建一个 store,用于保存我们的状态并管理动作和 reducer:

  1. 让我们首先创建一个名为Store.tsx的新文件,并使用以下导入语句从 Redux 中获取我们需要的部分:
import { applyMiddleware, combineReducers, createStore, Store } from "redux";
  • createStore是一个我们最终将用来创建我们的 store 的函数

  • 我们需要applyMiddleware函数,因为我们需要使用 Redux Thunk 中间件来管理我们的异步动作

  • combineReducers函数是一个我们可以用来合并我们的 reducers 的函数

  • Store是一个我们可以用于 store 的 TypeScript 类型

  1. 让我们导入redux-thunk
import thunk from "redux-thunk";
  1. 最后,让我们导入我们的 reducer 和状态类型:
import { productsReducer } from "./ProductsReducer";
import { IProductsState } from "./ProductsTypes";
  1. store 的一个关键部分是状态。因此,让我们为此定义一个接口:
export interface IApplicationState {
  products: IProductsState;
}

此时,接口只包含了我们的产品状态。

  1. 现在让我们把我们的 reducer 放到 Redux 的combineReducer函数中:
const rootReducer = combineReducers<IApplicationState>({
  products: productsReducer
});
  1. 有了状态和根 reducer 定义,我们可以创建我们的 store。实际上,我们要创建一个创建 store 的函数:
export default function configureStore(): Store<IApplicationState> {
  const store = createStore(rootReducer, undefined, applyMiddleware(thunk));
  return store;
}
  • 创建我们的 store 的函数被称为configureStore,并返回具有特定 store 状态的通用Store类型。

  • 该函数使用 Redux 的createStore函数来创建并返回 store。我们传入我们的 reducer 以及 Redux Thunk 中间件。我们将undefined作为初始状态,因为我们的 reducer 会处理初始状态。

我们已经在我们的 store 上取得了很好的进展。在下一节中,我们将开始连接我们的 React 商店到我们的 store。

将我们的 React 应用连接到 store。

在本节中,我们将连接Products页面到我们的 store。第一步是添加 React Redux 的Provider组件,我们将在下一节中完成。

添加 store Provider 组件

Provider组件可以将 store 传递给其下的任何级别的组件。因此,在本节中,我们将在组件层次结构的顶部添加Provider,以便所有我们的组件都可以访问它:

  1. 让我们打开我们现有的index.tsx并从 React Redux 中导入Provider组件:
import { Provider} from "react-redux";
  1. 让我们还从 React Redux 中导入Store类型:
import { Store } from "redux";
  1. 我们需要从我们的商店中导入以下内容:
import configureStore from "./Store";
import { IApplicationState } from "./Store";
  1. 然后我们将在导入语句之后创建一个小的函数组件:
interface IProps {
  store: Store<IApplicationState>;
}
const Root: React.SFC<IProps> = props => {
  return ();
};

这个Root组件将成为我们的新根元素。它将我们的商店作为一个 prop。

  1. 因此,我们需要在我们的新根组件中包含旧的根元素Routes
const Root: React.SFC<IProps> = props => {
  return (
    <Routes />
  );
};
  1. 这个组件还需要添加另一件事,那就是来自 React Redux 的Provider组件:
return (
  <Provider store={props.store}>
    <Routes />
  </Provider>
);

我们已经将Provider放在了组件树的顶部,并将我们的商店传递给它。

  1. 完成我们的新根组件后,让我们更改我们的根渲染函数:
const store = configureStore();
ReactDOM.render(<Root store={store} />, document.getElementById(
  "root"
) as HTMLElement);

我们首先使用我们的configureStore函数创建商店,然后将其传递给我们的Root组件。

因此,这是将我们的组件连接到商店的第一步。在下一节中,我们将完成对ProductPage组件的连接。

将组件连接到商店

我们即将看到我们增强的商店在行动中。在本节中,我们将连接我们的商店到几个组件。

将 ProductsPage 连接到商店

我们要连接到商店的第一个组件将是ProductsPage组件。

让我们打开ProductsPage.tsx并开始重构它:

  1. 首先,让我们从 React Redux 中导入connect函数:
import { connect } from "react-redux";

我们将在本节末尾使用connect函数将ProductsPage组件连接到商店。

  1. 让我们从我们的商店中导入存储状态类型和getProducts动作创建者:
import { IApplicationState } from "./Store";
import { getProducts } from "./ProductsActions";
  1. ProductPage组件现在不会包含任何状态,因为这将保存在 Redux 存储中。因此,让我们首先删除状态接口、静态getDerivedStateFromProps方法,以及构造函数。ProductsPage组件现在应该具有以下形状:
class ProductsPage extends React.Component<RouteComponentProps> {
  public async componentDidMount() { ... }
  public render() { ... }
}
  1. 现在,数据将通过 props 从商店中获取。因此,让我们重构我们的 props 接口:
interface IProps extends RouteComponentProps {
 getProducts: typeof getProducts;
 loading: boolean;
 products: IProduct[];
}

class ProductsPage extends React.Component<IProps> { ... }

因此,我们将从商店传递以下数据到我们的组件:

  • getProducts动作创建者

  • 一个名为loading的标志,指示产品是否正在被获取

  • 产品数组

  1. 因此,让我们调整componentDidMount生命周期方法,以调用getProducts动作创建者来开始获取产品的过程:
public componentDidMount() {
  this.props.getProducts();
}
  1. 我们不再直接引用ProductsData.ts中的products数组。因此,让我们从输入语句中删除它,使其如下所示:
import { IProduct } from "./ProductsData";
  1. 我们仍然看不到我们以前使用的search状态。现在我们将在render方法开始时获取它,而不是在状态中存储它:
public render() {
  const searchParams = new URLSearchParams(this.props.location.search);
 const search = searchParams.get("search") || "";
  return ( ... );
}
  1. 让我们留在render方法中,替换旧的state引用:
<ul className="product-list">
  {this.props.products.map(product => {
    if (!search || (search && product.name.toLowerCase().indexOf(search.toLowerCase()) > -1)
    ) { ... }
  })}
</ul>
  1. 在类下面,但在导出语句之前,让我们创建一个函数,将来自存储的状态映射到组件属性:
const mapStateToProps = (store: IApplicationState) => {
  return {
    loading: store.products.productsLoading,
    products: store.products.products
  };
};

因此,我们正在获取产品是否正在加载以及从存储中获取这些产品并将它们传递给我们的 props。

  1. 我们还需要映射到另一个 prop,那就是getProducts函数 prop。让我们创建另一个函数,将这个操作从存储映射到组件中的函数 prop:
const mapDispatchToProps = (dispatch: any) => {
  return {
    getProducts: () => dispatch(getProducts())
  };
};
  1. 在文件底部还有一项工作要做。这是在导出之前,将 React Redux 的connect HOC 包装在我们的ProductsPage组件周围:
export default connect(
 mapStateToProps,
 mapDispatchToProps
)(ProductsPage);

connect HOC 将组件连接到我们的存储,这是由组件树中更高级别的Provider组件提供给我们的。connect HOC 还调用映射函数,将存储中的状态和操作创建者映射到组件属性中。

  1. 现在终于是时候尝试我们增强的页面了。让我们通过终端启动开发服务器和应用程序:
npm start

我们应该发现页面的行为与以前完全相同。唯一的区别是现在状态是在我们的 Redux 存储中管理的。

在下一节中,我们将通过添加我们项目中已经拥有的加载旋转器来增强我们的产品页面。

将 ProductsPage 连接到加载存储状态。

在本节中,我们将向产品页面添加一个加载旋转器。在此之前,我们将把产品列表提取到自己的组件中。然后我们可以将withLoader HOC 添加到提取的组件中:

  1. 让我们为提取的组件创建一个名为ProductsList.tsx的新文件,并导入以下内容:
import * as React from "react";
import { Link } from "react-router-dom";
import { IProduct } from "./ProductsData";
import withLoader from "./withLoader";
  1. 该组件将接受产品数组和搜索字符串的 props:
interface IProps {
  products?: IProduct[];
  search: string;
}
  1. 我们将称该组件为ProductList,它将是一个 SFC。让我们开始创建组件:
const ProductsList: React.SFC<IProps> = props => {
  const search = props.search;
  return ();
};
  1. 现在我们可以将ProductsPage组件 JSX 中的ul标签移动到我们新的ProductList组件的返回语句中:
return (
  <ul className="product-list">
    {props.products &&
      props.products.map(product => {
        if (
          !search ||
          (search &&
            product.name.toLowerCase().indexOf(search.toLowerCase()) 
            > -1)
        ) {
          return (
            <li key={product.id} className="product-list-item">
              <Link to={`/products/${product.id}`}>{product.name}
              </Link>
            </li>
          );
        } else {
          return null;
        }
      })}
  </ul>
);

请注意,在移动 JSX 后,我们会删除对this的引用。

  1. 完成ProductList组件后,让我们将其导出并使用我们的withLoaderHOC 包装:
export default withLoader(ProductsList);
  1. 让我们更改ProductPage.tsx中的返回语句以引用提取的组件:
return (
  <div className="page-container">
    <p>
      Welcome to React Shop where you can get all your tools for ReactJS!
    </p>
    <ProductsList
 search={search}
 products={this.props.products}
 loading={this.props.loading}
 />
  </div>
);
  1. 我们不要忘记引入已引用的ProductsList组件:
import ProductsList from "./ProductsList";
  1. 最后,我们可以在ProductsPage.tsx中删除导入的Link组件,因为它不再被引用。

如果我们转到正在运行的应用程序并浏览到产品页面,我们现在应该看到产品加载时的加载旋转器:

因此,我们的产品页面现在已经很好地连接到了 Redux 存储。在下一节中,我们将把产品页面连接到存储。

将产品状态和操作添加到存储

ProductPage组件连接到我们的存储首先需要在我们的存储中进行一些工作。我们需要额外的状态来存储当前产品,以及它是否已添加到购物篮中。我们还需要额外的操作和减速器代码来获取产品并将其添加到购物篮中:

  1. 首先,在ProductsTypes.ts中为当前产品添加额外的状态:
export interface IProductsState {
  readonly currentProduct: IProduct | null;
  ...
}
  1. 当我们在ProductTypes.ts中时,让我们添加获取产品的操作类型:
export enum ProductsActionTypes {
  GETALL = "PRODUCTS/GETALL",
  GETSINGLE = "PRODUCTS/GETSINGLE",
  LOADING = "PRODUCTS/LOADING"
}
  1. 让我们还为获取产品添加操作类型:
export interface IProductsGetSingleAction {
  type: ProductsActionTypes.GETSINGLE;
  product: IProduct;
}
  1. 然后,我们可以将此操作类型添加到我们的联合操作类型中:
export type ProductsActions = IProductsGetAllAction| IProductsGetSingleAction | IProductsLoadingAction;
  1. 让我们继续在ProductsActions.ts中创建新的操作创建者。首先,让我们导入我们的虚假 API 以获取产品:
import { getProduct as getProductFromAPI, getProducts as getProductsFromAPI} from "./ProductsData";
  1. 然后我们可以导入我们需要实现的操作创建者的类型:
import { IProductsGetAllAction, IProductsGetSingleAction, IProductsLoadingAction, IProductsState, ProductsActionTypes } from "./productsTypes";
  1. 让我们实现获取产品的操作创建者:
export const getProduct: ActionCreator<ThunkAction<Promise<any>, IProductsState, null, IProductsGetSingleAction>> = (id: number) => {
  return async (dispatch: Dispatch) => {
    dispatch(loading());
    const product = await getProductFromAPI(id);
    dispatch({
      product,
      type: ProductsActionTypes.GETSINGLE
    });
  };
};

这与getProducts操作创建者非常相似。结构上唯一的区别是操作创建者接受产品 ID 的参数。

  1. 现在转到ProductsReducer.ts中的减速器。首先在初始状态中将当前产品设置为 null:
const initialProductState: IProductsState = {
  currentProduct: null,
  ...
};
  1. productReducer函数中,让我们为我们的新操作类型在 switch 语句中添加一个分支:
switch (action.type) {
  ...
  case ProductsActionTypes.GETSINGLE: {
 return {
 ...state,
 currentProduct: action.product,
 productsLoading: false
 };
 }
}

我们将旧状态扩展到一个新对象中,覆盖当前项目,并将加载状态设置为false

因此,这是产品页面在 Redux 存储中需要的一些状态管理。但是,我们还没有在我们的存储中管理购物篮。我们将在下一节中完成这一点。

将购物篮状态和操作添加到存储中

在这一部分,我们将为我们的购物篮添加状态管理。我们将在我们的存储中创建一个新的部分。

  1. 首先,让我们创建一个名为BasketTypes.ts的类型的新文件,内容如下:
import { IProduct } from "./ProductsData";

export enum BasketActionTypes {
  ADD = "BASKET/ADD"
}

export interface IBasketState {
  readonly products: IProduct[];
}

export interface IBasketAdd {
  type: BasketActionTypes.ADD;
  product: IProduct;
}

export type BasketActions = IBasketAdd;
  • 我们的购物篮中只有一个状态,那就是购物篮中产品的数组。

  • 同样也只有一个动作。这是将产品添加到购物篮中。

  1. 让我们创建一个名为BasketActions.ts的文件,内容如下:
import { BasketActionTypes, IBasketAdd } from "./BasketTypes";
import { IProduct } from "./ProductsData";

export const addToBasket = (product: IProduct): IBasketAdd => ({
  product,
  type: BasketActionTypes.ADD
});

这是用于添加到购物篮的动作创建者。该函数接受一个产品,并在具有适当动作类型的动作中返回它。

  1. 现在到了减速器。让我们创建一个名为BasketReducer.ts的文件,其中包含以下导入语句:
import { Reducer } from "redux";
import { BasketActions, BasketActionTypes, IBasketState } from "./BasketTypes";
  1. 让我们为初始购物篮状态创建一个对象:
const initialBasketState: IBasketState = {
  products: []
};
  1. 现在让我们创建减速器:
export const basketReducer: Reducer<IBasketState, BasketActions> = (state = initialBasketState, action) => {
  switch (action.type) {
    case BasketActionTypes.ADD: {
      return {
        ...state,
        products: state.products.concat(action.product)
      };
    }
  }
  return state || initialBasketState;
};

这遵循与productsReducer相同的模式。

一个有趣的地方要注意的是,我们如何优雅地将product添加到products数组中,而不会改变原始数组。我们使用 JavaScript 的concat函数,它通过将原始数组与传入的参数合并来创建一个新数组。这是在减速器中使用的一个很好的函数,其中状态变化涉及向数组添加项目。

  1. 现在让我们打开Store.ts并导入购物篮的新减速器和状态:
import { basketReducer } from "./BasketReducer";
import { IBasketState } from "./BasketTypes";
  1. 让我们将购物篮状态添加到存储中:
export interface IApplicationState {
 basket: IBasketState;
  products: IProductsState;
}
  1. 现在我们有两个减速器。因此,让我们将购物篮减速器添加到combineReducers函数调用中:
export const rootReducer = combineReducers<IApplicationState>({
  basket: basketReducer,
  products: productsReducer
});

现在我们已经调整了我们的存储,我们可以将我们的ProductPage组件连接到它。

将 ProductPage 连接到存储

在这一部分,我们将把ProductPage组件连接到我们的存储中:

  1. 首先将以下内容导入到ProductPage.tsx中:
import { connect } from "react-redux";
import { addToBasket } from "./BasketActions";
import { getProduct } from "./ProductsActions";
import { IApplicationState } from "./Store";
  1. 现在我们要引用存储的getProduct,而不是来自ProductsData.ts的产品。因此,让我们从此导入中删除它,使其看起来像以下内容:
import { IProduct } from "./ProductsData";
  1. 接下来,让我们将状态移入属性:
interface IProps extends RouteComponentProps<{ id: string }> {
  addToBasket: typeof addToBasket;
  getProduct: typeof getProduct;
  loading: boolean;
  product?: IProduct;
  added: boolean;
}

class ProductPage extends React.Component<IProps> { ... }

因此,在此移动之后,应该删除IState接口和Props类型。

  1. 我们可以移除构造函数,因为我们现在不需要初始化任何状态。这一切都在存储中完成。

  2. 让我们将componentDidMount生命周期方法更改为调用获取产品的动作创建者:

public componentDidMount() {
  if (this.props.match.params.id) {
    const id: number = parseInt(this.props.match.params.id, 10);
    this.props.getProduct(id);
  }
}

请注意,我们还移除了async关键字,因为该方法不再是异步的。

  1. 继续进行render函数,让我们将对状态的引用替换为对属性的引用:
public render() {
  const product = this.props.product;
  return (
    <div className="page-container">
      <Prompt when={!this.props.added} message={this.navAwayMessage}
      />
      {product || this.props.loading ? (
        <Product
          loading={this.props.loading}
          product={product}
          inBasket={this.props.added}
          onAddToBasket={this.handleAddClick}
        />
      ) : (
        <p>Product not found!</p>
      )}
    </div>
  );
}
  1. 现在让我们来看点击处理程序,并重构它以调用添加到购物篮的动作创建者:
private handleAddClick = () => {
  if (this.props.product) {
    this.props.addToBasket(this.props.product);
  }
};
  1. 现在进行连接过程的最后几个步骤。让我们实现将存储中的动作创建者映射到组件属性的函数:
const mapDispatchToProps = (dispatch: any) => {
  return {
    addToBasket: (product: IProduct) => dispatch(addToBasket(product)),
    getProduct: (id: number) => dispatch(getProduct(id))
  };
};
  1. 将状态映射到组件 prop 有点复杂。让我们从简单的映射开始:
const mapStateToProps = (store: IApplicationState) => {
  return {
    basketProducts: store.basket.products,
    loading: store.products.productsLoading,
    product: store.products.currentProduct || undefined
  };
};

请注意,我们将 null 的currentProduct映射到undefined

  1. 我们需要映射的剩余 prop 是added。我们需要检查商店中的当前产品是否在购物篮状态中,以设置这个boolean值。我们可以使用产品数组中的some函数来实现这一点:
const mapStateToProps = (store: IApplicationState) => {
  return {
    added: store.basket.products.some(p => store.products.currentProduct ? p.id === store.products.currentProduct.id : false),
    ...
  };
};
  1. 最后一步是使用 React Redux 中的connect HOC 将ProductPage组件连接到商店:
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(ProductPage);

现在我们可以进入运行的应用程序,访问产品页面,并将其添加到购物篮中。点击“添加到购物篮”按钮后,该按钮应该消失。如果我们浏览到另一个产品,然后回到我们已经添加到购物篮中的产品,那么“添加到购物篮”按钮就不应该出现。

所以,现在我们的产品和产品页面都连接到了 Redux 商店。在下一节中,我们将创建一个购物篮摘要组件并将其连接到商店。

创建并连接 BasketSummary 到商店

在本节中,我们将创建一个名为BasketSummary的新组件。这将显示购物篮中的物品数量,并位于我们商店的右上角。以下截图显示了购物篮摘要将在屏幕右上角的样子:

  1. 让我们创建一个名为BasketSummary.tsx的文件,内容如下:
import * as React from "react";

interface IProps {
  count: number;
}

const BasketSummary: React.SFC<IProps> = props => {
  return <div className="basket-summary">{props.count}</div>;
};

export default BasketSummary;

这是一个简单的组件,它以一个 prop 的形式接收购物篮中产品的数量,并在一个带有basket-summary CSS 类的div中显示这个值。

  1. 让我们在index.css中添加我们刚刚引用的 CSS 类:
.basket-summary {
  display: inline-block;
  margin-left: 10px;
  padding: 5px 10px;
  border: white solid 2px;
}
  1. 我们将把我们的购物篮摘要添加到我们的页眉组件中。所以,让我们把它导入到Header.tsx中:
import BasketSummary from "./BasketSummary";
  1. 让我们也从 React Redux 中导入connect函数:
import { connect } from "react-redux";
  1. 让我们也导入我们商店的状态类型:
import { IApplicationState } from "./Store";
  1. Header组件添加一个购物篮中产品数量的 prop:
interface IProps extends RouteComponentProps {
 basketCount: number;
}

class Header extends React.Component<IProps, IState> { 
   public constructor(props: IProps) { ... }
   ...
}

我们将在这个组件中保持搜索状态为本地。

  1. 现在让我们将BasketSummary组件添加到Header组件的 JSX 中:
<header className="header">
  <div className="search-container">
    <input ... />
    <BasketSummary count={this.props.basketCount} />
  </div>
  ...
</header>
  1. 下一步是将商店购物篮中的产品数量映射到basketCount prop:
const mapStateToProps = (store: IApplicationState) => {
  return {
    basketCount: store.basket.products.length
  };
};
  1. 最后,我们可以将Header组件连接到商店:
export default connect(mapStateToProps)(withRouter(Header));

现在Header组件正在使用BasketSummary组件,并且也连接到商店,我们应该能够在运行的应用程序中添加产品到购物篮并看到购物篮摘要增加。

这样,这一部分关于将组件连接到商店的内容就完成了。我们已经将一些不同的组件连接到了商店,所以希望这个过程现在很清晰。

在下一节中,我们将探讨一种类似 Redux 的方法来管理组件内的状态。

使用 useReducer 管理状态

Redux 非常适合管理应用程序中的复杂状态。但是,如果我们要管理的状态只存在于单个组件中,那么它可能会有点重。显然,我们可以使用setState(对于类组件)或useState(对于函数组件)来管理这些情况。但是,如果状态很复杂怎么办?可能会有很多状态片段,状态交互可能涉及很多步骤,其中一些是异步的。在本节中,我们将探讨使用 React 中的useReducer函数来管理这些情况的方法。我们的示例将是人为的和简单的,但它将让我们了解这种方法。

我们将在我们的 React 商店的产品页面上添加一个喜欢按钮。用户可以多次喜欢一个产品。Product组件将跟踪喜欢的数量以及最后一次喜欢的日期和时间:

  1. 我们将首先打开Product.tsx并在Product组件之前创建一个接口,用于我们的状态,包含喜欢的数量和最后一次喜欢的日期:
interface ILikeState {
  likes: number;
  lastLike: Date | null;
}
  1. 我们将创建一个变量来保存初始状态,也在Product之外:
const initialLikeState: ILikeState = {
  likes: 0,
  lastLike: null
};
  1. 现在让我们为这个动作创建一个类型:
enum LikeActionTypes {
  LIKE = "LIKE"
}

interface ILikeAction {
  type: LikeActionTypes.LIKE;
  now: Date;
}
  1. 我们还将创建一个包含所有动作类型的联合类型。在我们的示例中,我们只有一个动作类型,但让我们这样做以了解一个可扩展的方法:
type LikeActions = ILikeAction;
  1. Product组件内部,让我们在 React 中调用useReducer函数来获取我们的状态和dispatch函数:
const [state, dispatch]: [
    ILikeState,
    (action: ILikeAction) => void
  ] = React.useReducer(reducer, initialLikeState);

让我们来分解一下:

  • 我们传递给useReducer一个名为reducer的函数(我们还没有创建)。

  • 我们还将我们的初始状态传递给useReducer

  • useReducer返回一个包含两个元素的数组。第一个元素是当前状态,第二个是一个dispatch函数来调用一个动作。

  1. 让我们重构这一行并解构状态,以便我们可以直接引用状态的片段:
const [{ likes, lastLike }, dispatch]: [
    ILikeState,
    (action: ILikeAction) => void
  ] = React.useReducer(reducer, initialLikeState);
  1. Product组件的 JSX 底部,让我们添加 JSX 来渲染我们有多少个喜欢和一个按钮来添加喜欢:
{!props.inBasket && (
  <button onClick={handleAddClick}>Add to basket</button>
)}
<div className="like-container">
 {likes > 0 && (
 <div>{`I like this x ${likes}, last at ${lastLike}`}</div>
 )}
 <button onClick={handleLikeClick}>
 {likes > 0 ? "Like again" : "Like"}
 </button>
</div>
  1. 让我们将刚刚引用的like-container CSS 类添加到index.css中:
.like-container {
  margin-top: 20px;
}

.like-container button {
  margin-top: 5px;
}
  1. 让我们也在 Like 按钮上实现点击处理程序:
const handleLikeClick = () => {
  dispatch({ type: LikeActionTypes.LIKE, now: new Date() });
};
  1. 我们的最后任务是在Product组件之外实现 reducer 函数,在LikeActions类型的下面:
const reducer = (state: ILikeState = initialLikeState, action: LikeActions) => {
 switch (action.type) {
 case LikeActionTypes.LIKE:
 return { ...state, likes: state.likes + 1, lastLike: action.now };
 }
 return state;
};

如果我们尝试这样做,我们将在导航到产品页面后最初看到一个 Like 按钮。如果我们点击它,按钮文本会变成 Like,上面会出现一段文字,指示有多少个赞和上次点赞的时间。

这个实现感觉非常类似于在 Redux 存储中实现操作和 reducers,但这都是在一个组件内部。对于我们刚刚经历过的例子来说,这有点过度,但在我们需要管理更多状态片段的情况下可能会很有用。

总结

我们在本章开始时介绍了 Redux,学习了其原则和关键概念。我们了解到状态存储在一个单一对象中,并在分发 action 时通过称为 reducers 的纯函数进行更改。

我们在我们的 React 商店中创建了自己的 store 来将理论付诸实践。以下是我们在实现中学到的一些关键点:

  • 为 action 类型创建枚举在引用它们时给我们提供了很好的智能感知。

  • 使用接口定义 actions 可以提供很好的类型安全性,并允许我们创建一个 reducer 可以用于处理的 actions 的联合类型。

  • 在状态接口中使用只读属性可以帮助我们避免直接改变状态。

  • 同步 action 创建者只是简单地返回所需的 action 对象。

  • 异步 action 创建者返回一个最终返回 action 对象的函数。

  • Reducer 包含了它处理的每种 action 类型的逻辑分支,通过将旧状态扩展到一个新对象中,然后用更改后的属性覆盖它来创建新状态。

  • Redux 的createStore函数创建了实际的 store。我们将所有的 reducer 合并在一起,还有 Redux Thunk 中间件来管理异步操作。

然后我们将一些组件连接到了 store。以下是这个过程中的关键点:

  • 来自 React Redux 的Provider组件需要位于所有想要使用 store 的组件之上。它接收一个包含 store 的 prop。

  • 然后,来自 React Redux 的connect高阶组件将单独的组件连接到 store。它接收两个参数,可以用于将状态和 action 创建者映射到组件 props。

在我们的 React 应用程序中实现 Redux 时,有很多要理解的细节。因为 Redux 强制我们将逻辑分解成易于理解和维护的单独部分,所以在状态管理复杂的情况下,Redux 会发挥作用。

我们学到,我们可以利用 React 的useReducer函数在单个组件中使用类似 Redux 的方法。当状态复杂且仅存在于单个组件中时,可以使用这种方法。

Redux 动作经常要做的一个任务是与 REST API 交互。在下一章中,我们将学习如何在基于类和基于函数的组件中与 REST API 交互。我们还将了解一个我们用来调用 REST API 的本地函数,以及一个流行的开源库。

问题

在结束本章之前,让我们用一些问题来测试我们的知识:

  1. action 对象中的type属性是必需的吗?这个属性需要被称为 type 吗?我们可以称其为其他名称吗?

  2. action 对象可以包含多少个属性?

  3. 什么是 action creator?

  4. 为什么我们在 React 商店应用程序中的 Redux 存储中需要 Redux Thunk?

  5. 除了 Redux Thunk,我们还能用其他东西吗?

  6. 在我们刚刚实现的basketReducer中,为什么我们不直接使用push函数将项目添加到购物篮状态中?也就是说,高亮显示的行有什么问题?

export const basketReducer: Reducer<IBasketState, BasketActions> = (
  state = initialBasketState,
  action
) => {
  switch (action.type) {
    case BasketActionTypes.ADD: {
      state.products.push(action.product);
    }
  }
  return state || initialBasketState;
};

进一步阅读

以下链接是关于 React Redux 的更多信息的好资源: