三、React Bootstrap 个人联系人管理器

在本章中,我们将学习如何使用 React 构建一个个人联系人管理器,React 是一个用小组件构建用户界面(ui)的库。 通过学习 React,您将获得使用当前最流行的库之一的能力,并开始理解如何以及何时使用绑定的强大功能来简化代码。

探索 React 将帮助我们理解如何为客户端编写现代应用,并研究其需求。

为了帮助我们开发应用,本章将涵盖以下主题:

  • 创建一个模拟布局来检查布局
  • 创建 React 应用
  • 使用tslint分析和格式化代码
  • 添加引导支持
  • 在 React 中使用 tsx 组件
  • React 中的App组件
  • 显示我们的个人细节 UI
  • 使用绑定简化我们的更新
  • 创建验证器并将其应用于验证
  • 在 React 组件中应用验证
  • 创建并发送数据到 IndexedDB 数据库

技术要求

由于我们使用 IndexedDB 数据库来存储数据,因此需要使用 Chrome(版本 11 或更高版本)或 Firefox(版本 4 或更高版本)等现代网络浏览器。 完成的项目可从https://github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/chapter03下载。 下载项目后,您必须使用npm install安装软件包要求。

了解项目概况

我们将使用 React 建立一个个人联系人管理器数据库。 数据使用标准的 IndexedDB 数据库本地存储在客户机上。 当我们完成时,我们的应用看起来如下所示:

您应该能够在大约两个小时内完成本章中的步骤,与 GitHub 存储库中的代码一起工作。

开始使用组件

本章依赖于 Node.js,可以在https://nodejs.org/上获得。 随着本章的进展,我们将安装以下组件:

  • @types/bootstrap(4.1.2 及以上版本)
  • @types/reactstrap(6.4.3 及以上版本)
  • bootstrap(4.1.3 及以上版本)
  • react(16.6.3 及以上版本)
  • react-dom(16.6.3 及以上版本)
  • react-script-ts(3.1.0 及以上版本)
  • reactstrap(6.5.0 及以上版本)
  • create-react-app(2.1.2 及以上版本)

创建一个支持 TypeScript 的 React 引导项目

正如我们在第 2 章、用 TypeScript 创建 Markdown 编辑器中所讨论的,从收集我们将要编写的应用的需求开始是一个好主意。 以下是本章的要求:

  • 用户将能够创建或编辑一个人的新细节
  • 这些详细信息将保存到客户端数据库中
  • 用户将能够加载所有人的列表
  • 用户可以删除某人的个人信息
  • 个人详细信息将包括姓名、地址(由两行地址、城镇、县和邮政编码组成)、电话号码和出生日期
  • 个人资料将被保存到数据库中
  • 名字至少是一个字符,姓氏至少是两个字符
  • 地址行 1,镇和县将至少 5 个字符
  • 邮政编码将符合大多数邮政编码的美国标准
  • 电话号码将符合标准的美国电话格式
  • 用户可以通过单击按钮来清除详细信息

创建模拟布局

一旦我们有了我们的需求,通常最好是起草一些我们认为应用布局应该是什么样子的草图。 我们想要做的是创建一个布局,显示我们正在使用一个草图格式的网页浏览器布局。 我们之所以想让它看起来像草图,是因为我们与客户的互动方式。 我们希望他们对我们的应用的粗略布局有一个大致的概念,而不是纠缠于细节,比如特定按钮的确切宽度。

特别有用的是能够使用工具,如https://ninjamock.com/创建我们的界面的线框图。 这些草图可以在线与客户或其他可以直接添加评论的团队成员共享。 下面的示意图展示了我们想要的界面完成后的样子:

创建应用

在开始编写代码之前,我们需要安装 React。 虽然可以手动创建 React 所需的基础架构,但大多数人使用create-react-app命令来创建 React 应用。 我们不会有任何不同的做法,所以我们也将使用create-react-app命令。 React 默认不使用 TypeScript,所以我们要在创建应用时使用的命令中添加一点额外的内容,以提供我们所需的所有 TypeScript 容量。 我们使用create-react-app,给它我们的应用的名字和一个额外的scripts-version参数,它在 TypeScript 中为我们钩子:

npx create-react-app chapter03 --scripts-version=react-scripts-ts

If you have installed Node.js packages before, you may think that there is a mistake in the preceding command and that we should be using npm to install create-react-app. However, we are using npx in place of npm because npx is an enhanced version of the Node Package Manager (NPM). With npx, we missed out the need to run npm install create-react-app to install the create-react-app package before manually running create-react-app to start the process. The use of npx does help to speed up our development workflow.

一旦我们的应用被创建,我们打开Chapter03目录并运行以下命令:

npm start

假设我们有一个默认的浏览器设置,它应该被打开到http://localhost:3000,这是这个应用的默认网页。 这将提供一个恰巧包含默认 React 示例的标准网页。 我们现在要做的是编辑public/index.html文件,并为它设置标题。 我们将把标题设为Advanced TypeScript - Personal Contacts Manager。 虽然这个文件的内容看起来很稀疏,但它们包含了我们在 HTML 方面需要的所有内容,即一个名为rootdiv元素。 这是我们的 React 代码将挂起的钩子,我们将在后面讨论。 我们可以实时编辑我们的应用,这样我们所做的任何更改都会被编译并自动返回到浏览器:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="theme-color" content="#000000">
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
    <title>Advanced TypeScript - Personal Contacts Manager</title>
  </head>
  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>
  </body>
</html>

使用 tslint 格式化代码

一旦我们创建了应用,我们就要使用一个叫做tslint的工具,它通过查找潜在的问题来分析我们的代码。 注意,在创建应用时,自动添加了对该功能的支持。 运行的tslint版本应用了一组非常严格的规则,我们根据这些规则检查代码。 我在代码库中使用了完整的tslint规则集; 然而,如果你想放松一点规则,你只需要改变tslint.json文件,像以下内容:

{
  "extends": [],
  "defaultSeverity" : "warning",
  "linterOptions": {
    "exclude": [
      "config/**/*.js",
      "node_modules/**/*.ts",
      "coverage/lcov-report/*.js"
    ]
  }
}

添加引导支持

对于我们的应用,我们需要做的一件事就是引入对 Bootstrap 的支持。 这不是 React 提供的开箱即用,所以我们需要使用其他包添加这个容量:

  1. 安装 Bootstrap 的方法如下:
npm install --save bootstrap
  1. 有了这些,我们现在就可以自由地使用 React-ready Bootstrap 组件了。 我们将使用reactstrap包,因为这个包以一种反应友好的方式针对 Bootstrap 4:
npm install --save reactstrap react react-dom
  1. reactstrap不是 TypeScript 组件,所以我们需要为它和引导安装DefinitelyTyped定义:
npm install --save @types/reactstrap
npm install --save @types/bootstrap
  1. 有了这些,我们现在可以添加 Bootstrap CSS 文件了。 为了做到这一点,我们将更新index.tsx文件,通过添加一个引用到我们本地安装的 Bootstrap CSS 文件,通过添加以下import到文件的最顶部:
import "bootstrap/dist/css/bootstrap.min.css";

Here, we are using the local Bootstrap file for convenience. As we discussed in Chapter 1, Advanced TypeScript Features, we want to change this to use a CDN source for the production version of this application.

  1. 为了整理,从src/index.tsx中删除以下行,然后从磁盘中删除匹配的.css文件:
import './index.css'

使用 tsx 组件进行反应

您现在可能会有一个问题,为什么索引文件有不同的扩展名? 也就是说,为什么是.tsx而不是.ts? 为了回答这些问题,我们必须稍微改变一下我们对这个扩展的印象,并谈谈为什么 React 使用.jsx文件而不是.js文件(.tsx版本相当于.jsx)。

这些 JSX 文件是被转译成 JavaScript 的 JavaScript 扩展。 如果你试图在 JavaScript 中运行,那么你会得到运行时错误,如果他们包含任何这些扩展。 在传统的 React 中,有一个转译阶段,它获取 JSX 文件,并通过将代码扩展为标准 JavaScript 将其转换为 JavaScript。 实际上,这是我们从 TypeScript 获得的编译阶段的一种形式。 使用 TypeScript React,我们会得到与 TSX 文件最终成为 JavaScript 文件相同的最终结果。

现在的问题是,为什么我们需要这些扩展? 为了回答这个问题,我们将分析index.tsx文件。 这是添加了 Bootstrap CSS 文件后的文件样子:

import "bootstrap/dist/css/bootstrap.min.css";
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from './App';

import registerServiceWorker from './registerServiceWorker';

ReactDOM.render(
  <App />,
  document.getElementById('root') as HTMLElement
);
registerServiceWorker();

import语句我们现在应该很熟悉了,registerServiceWorker是添加到代码中的行为,通过从缓存中提供资产,而不是一次又一次地重新加载它们,从而提供更快的生产应用。 React 的一个关键原则就是要尽可能快,这就是ReactDOM.render的作用所在。 如果我们阅读这段代码,事情就会变得清晰起来。 它所做的是在我们提供的 HTML 页面中寻找标记为根的元素——我们在index.html文件中看到了这一点。 我们在这里使用as HTMLElement语法的原因是我们想让 TypeScript 知道 this 是什么类型(这个参数要么是从一个元素派生出来的,要么是 null -是的,这确实意味着底层的 this 是一个联合类型)。

现在,我们需要一个特殊的扩展的原因是因为这段代码说<App />。 我们在这里所做的是将一段 XML 代码内联到语句中。 在这个特定的实例中,我们告诉我们的render方法渲染一个名为App的组件,该组件已在App.tsx文件中定义。

React 如何使用虚拟 DOM 来提高响应速度

我解释了为什么要使用render方法,所以现在是时候讨论 React 的秘密武器了,即虚拟文档对象模型(DOM)。 如果您已经开发了一段时间的 web 应用,那么您可能知道 DOM。 如果您从未遇到过这种情况,那么 DOM 是一个实体,它准确地描述了 web 页面的样子。 Web 浏览器在很大程度上依赖于 DOM,而且随着它多年来的有机发展,它可能变得相当笨拙。 浏览器制造商在尝试提高 DOM 的速度方面只能做这么多了。 如果他们希望能够提供旧网页,那么他们必须支持完整的 DOM。

虚拟 DOM 是标准 DOM 的一个轻量级副本。 它重量更轻的原因是它忽略了标准 DOM 的一个主要特性; 也就是说,它不需要呈现到屏幕上。 当 React 运行render方法时,它遍历每个.tsx(或 JavaScript 中的.jsx)文件,并在那里执行渲染代码。 然后将呈现的代码与上次运行的呈现的副本进行比较,以确定到底发生了什么变化。 只有那些改变了的元素才会在屏幕上更新。 这个比较阶段就是我们必须使用虚拟 DOM 的原因。 使用这种方法可以更快地确定哪些元素需要更新,并且只有那些被更改的元素需要更新。

React App 组件

我们已经谈到了 React 中组件的使用。 默认情况下,我们总是有一个App组件。 这是将在 HTML 中呈现给根元素的组件。 我们的分量是从React.Component推导出来的,所以App分量的开始看起来如下:

import * as React from 'react';
import './App.css';

export default class App extends React.Component {

}

当然,我们的组件需要一个众所周知的方法来触发组件的呈现。 如果你知道这个方法叫做render,你就不会感到惊讶了。 当我们使用引导显示 UI,我们想要呈现出一个组件,它与我们的Containerdiv。要做到这一点,我们将使用来自reactstrap``Container组件(和介绍的核心组件,我们将使用显示界面):

import * as React from 'react';
import './App.css';
import Container from 'reactstrap/lib/Container';
import PersonalDetails from './PersonalDetails';
export default class App extends React.Component {
  public render() {
    return (
      <Container>
        <PersonalDetails />
      </Container>
    );
  }
}

显示个人详细信息界面

我们将创建一个名为PersonalDetails的类。 这个类将在render方法中渲染出接口的核心。 同样,我们使用reactstrap来布局界面的各个部分。 在我们分解复杂的render方法之前,让我们看看它是什么样子的:

import * as React from 'react';
import Button from 'reactstrap/lib/Button';
import Col from 'reactstrap/lib/Col';
import Row from 'reactstrap/lib/Row';

export default class PersonalDetails extends React.Component {

  public render() {
    return (
      <Row>
        <Col lg="8">
          <Row>
            <Col><h4 className="mb-3">Personal details</h4></Col>
          </Row>
          <Row>
            <Col><label htmlFor="firstName">First name</label></Col>
            <Col><label htmlFor="lastName">Last name</label></Col>
          </Row>
          <Row>
            <Col>
              <input type="text" id="firstName" className="form-control" placeholder="First name" />
            </Col>
            <Col><input type="text" id="lastName" className="form-control" placeholder="Last name" /></Col>
          </Row>
... Code omitted for brevity
        <Col>
          <Col>
            <Row>
              <Col lg="6"><Button size="lg" color="success">Load</Button></Col>
              <Col lg="6"><Button size="lg" color="info">New Person</Button></Col>
            </Row>
          </Col>
        </Col>
      </Row>
    );
  }
}

如你所见,这个方法中有很多内容; 然而,它的绝大部分是用于复制行和列引导元素的重复代码。 例如,如果我们看一下postcodephoneNumber元素的布局,我们可以看到我们布局了两行,每行有两列。 在 Bootstrap 中,其中一个Col元素是 3 个大元素,另一个是 4 个大元素(我们将把它留给 Bootstrap 来考虑剩下的空列):

<Row>
  <Col lg="3"><label htmlFor="postcode">Postal/ZipCode</label></Col>
  <Col lg="4"><label htmlFor="phoneNumber">Phone number</label></Col>
</Row>
<Row>
  <Col lg="3"><input type="text" id="postcode" className="form-control" /></Col>
  <Col lg="4"><input type="text" id="phoneNumber" className="form-control" /></Col>
</Row>

看看标签和输入元素,我们可以看到有两个不熟悉的元素。 当然,标签中正确的键是for,我们应该在输入中使用class来引用 CSS 类。 我们使用替换键的原因是forclass是 JavaScript 关键字。 因为 React 允许我们在渲染中混合代码和标记语言,所以 React 必须使用不同的关键字。 这意味着我们用htmlFor代替for,用className代替class。 回到我们讨论虚拟 DOM 的时候,这给了我们一个主要的提示,即这些 HTML 元素是具有类似目的的副本,而不是元素本身。

通过绑定简化值的更新

许多现代框架的一个特性是使用绑定来消除手动更新输入或触发事件的需要。 使用绑定背后的思想是,框架在 UI 元素和代码(比如属性)之间建立连接,监视底层值的更改,然后在检测到更改时触发更新。 如果操作正确,这将消除编写代码的许多繁琐工作,更重要的是,有助于减少错误。

提供要绑定的状态

与 React 绑定背后的想法是,我们有一个需要绑定到的状态。 在创建想要显示在屏幕上的数据的情况下,状态可以像描述想要使用的属性的界面一样简单。 对于单个联系人,这将转换为如下状态:

export interface IPersonState {
  FirstName: string,
  LastName: string,
  Address1: string,
  Address2: StringOrNull,
  Town: string,
  County: string,
  PhoneNumber: string;
  Postcode: string,
  DateOfBirth: StringOrNull,
  PersonId : string
}

注意,为了方便起见,我们创建了一个名为StringOrNull的联合类型。 我们将把它放在一个名为Types.tsx的文件中,这样它看起来像这样:

export type StringOrNull = string | null;

我们现在要做的是告诉组件它将要使用的状态。 首先要做的是更新类定义,使它看起来像这样:

export default class PersonalDetails extends React.Component<IProps, IPersonState>

这遵循了属性从父组件传递到类而状态来自本地组件的约定。 这种分离的属性和状态对我们非常重要,因为它为我们提供了一个为父与组件(组件与父母沟通回),同时仍然能够管理数据和行为,我们的组件想要的状态。

这里,我们的属性是在一个名为IProps的接口中定义的。 现在我们已经告诉 React 我们的状态的形状在内部是什么,React 和 TypeScript 使用它来创建一个ReadOnly<IPersonState>属性。 因此,确保我们使用正确的状态是很重要的。 如果我们在状态中使用了错误的类型,TypeScript 会通知我们。

Note that there is a caveat to that preceding statement. If we have two interfaces of exactly the same shape, then TypeScript treats them as equivalent to each other. So, even though TypeScript is expecting IState, if we supply something called IMyOtherState that has exactly the same properties, then TypeScript will happily let us use that in its place. The question, of course, is why would we want to duplicate the interface in the first place? I cannot think of many cases where we would want to do that, so the idea of using the right state is accurate for almost all the cases we are ever likely to encounter.

我们的app.tsx文件将创建一个默认状态,并将其作为属性传递给我们的组件。 默认状态是当用户按 clear 键清除当前编辑的条目或按 New Person 键开始添加新人员时将应用的状态。 我们的IProps界面是这样的:

interface IProps {
  DefaultState : IPersonState
}

Something that may seem slightly confusing at first is a potential contradiction between my earlier statement the idea that the properties and state are different—with state being something that is local to the component and yet we are passing state down as part of the properties. I deliberately use state as part of the name to reinforce the fact that this represents the state. The values that we are passing in can be called anything at all. They do not have to represent any state; they could simply be functions that the component calls to trigger some response in the parent. Our component will receive this property and it will be its responsibility to convert any part that it needs into state.

在适当的位置,我们准备改变我们的App.tsx文件,以创建我们的默认状态,并将其传递到我们的PersonalDetails组件。 正如我们在下面的代码中看到的,接口的属性变成了<PersonalDetails ..行的参数。 在属性界面中添加的项越多,我们需要添加到这一行的参数就越多:

import * as React from 'react';
import Container from 'reactstrap/lib/Container';
import './App.css';
import PersonalDetails from './PersonalDetails';
import { IPersonState } from "./State";

export default class App extends React.Component {
  private defaultPerson : IPersonState = {
    Address1: "",
    Address2: null,
    County: "",
    DateOfBirth : new Date().toISOString().substring(0,10),
    FirstName: "",
    LastName: "",
    PersonId : "",
    PhoneNumber: "",
    Postcode: "",
    Town: ""
  }
  public render() {
    return (
      <Container>
        <PersonalDetails DefaultState={this.defaultPerson} />
      </Container>
    );
  }
}

Date handling with JavaScript can be off-putting when we want to hook the date into a date picker component. The date picker expects to receive the date in the format of YYYY-MM-DD. So, we use the new Date().toISOString().substring(0,10) syntax to get today's date, which includes a time component, and only retrieve the YYYY-MM-DD portion from this. Even though the date picker expects the date to be in this format, it does not say that this is the format that will be displayed on the screen. The format on your screen should respect the local settings of the user.

关于我们为支持传入属性所做的更改,有趣的是我们已经在这里看到了绑定的作用。 在我们设置Default={this.defaultPerson}render方法中,我们使用了绑定。 在这里使用{ },我们告诉 React 我们想绑定到什么东西,无论是属性还是事件。 在 React 中我们会遇到很多绑定。

现在我们要给PersonalDetails.tsx添加一个构造函数来支持从App.tsx传入的属性:

private defaultState: Readonly<IPersonState>;
constructor(props: IProps) {
  super(props);
  this.defaultState = props.DefaultState;
  this.state = props.DefaultState;
}

我们在这里做两件事。 首先,我们设置了一个默认状态,以便在需要时返回,这是从父节点接收到的; 其次,我们正在为这个页面设置状态。 我们不需要在代码中创建一个状态属性,因为这是由React.Component提供的。 这是学习如何将我们的财产从父母那里与国家联系起来的最后一部分。

Changes to state will not be reflected back in the parent props. If we wanted to explicitly set a value back in the parent component, this would require us to trigger a change to props.DefaultState. I advise against doing this directly if you can possibly avoid it.

正确的。 让我们设置名字和姓氏元素来处理来自我们状态的绑定。 这里的想法是,如果我们在代码中更新名字或姓氏的状态,这将在 UI 中自动更新。 那么,让我们根据需要更改条目:

<Row>
  <Col><input type="text" id="firstName" className="form-control" value={this.state.FirstName} placeholder="First name" /></Col>
  <Col><input type="text" id="lastName" className="form-control" value={this.state.LastName} placeholder="Last name" /></Col>
</Row>

现在,如果我们运行应用,就会有绑定到底层状态的条目。 然而,这段代码有一个问题。 如果我们尝试在任何一个文本框中输入,我们将看到什么都没有发生。 实际的文本条目将被拒绝。 这并不意味着我们做错了什么,而是说我们只掌握了整体的一部分。 我们需要理解的是 React 为我们提供了状态的只读版本。 如果我们想让 UI 更新我们的状态,我们必须明确地选择这一点,通过对变化作出反应,然后设置适当的状态。 首先,我们将编写一个事件处理程序来处理文本改变时的状态设置:

private updateBinding = (event: any) => {
  switch (event.target.id) {
    case `firstName`:
      this.setState({ FirstName: event.target.value });
      break;
    case `lastName`:
      this.setState({ LastName: event.target.value });
      break;
  }
}

有了这一点,我们现在可以使用onChange属性更新输入以触发更新。 同样,我们将使用 binding 将onChange事件匹配到作为结果触发的代码:

<Row>
  <Col>
    <input type="text" id="firstName" className="form-control" value={this.state.FirstName} onChange={this.updateBinding} placeholder="First name" />
  </Col>
  <Col><input type="text" id="lastName" className="form-control" value={this.state.LastName} onChange={this.updateBinding} placeholder="Last name" /></Col>
</Row>

从这段代码中,我们可以清楚地看到,this.state为我们提供了访问我们在组件中设置的底层状态的权限,我们需要使用this.setState更改它。 this.setState的语法看起来应该很熟悉,因为它匹配键到值,我们以前在 TypeScript 中遇到过很多次。 在这个阶段,我们现在可以更新其余的入口组件来支持这个双向绑定。 首先,我们扩展我们的updateBinding代码如下:

private updateBinding = (event: any) => {
  switch (event.target.id) {
    case `firstName`:
      this.setState({ FirstName: event.target.value });
      break;
    case `lastName`:
      this.setState({ LastName: event.target.value });
      break;
    case `addr1`:
      this.setState({ Address1: event.target.value });
      break;
    case `addr2`:
      this.setState({ Address2: event.target.value });
      break;
    case `town`:
      this.setState({ Town: event.target.value });
      break;
    case `county`:
      this.setState({ County: event.target.value });
      break;
    case `postcode`:
      this.setState({ Postcode: event.target.value });
      break;
    case `phoneNumber`:
      this.setState({ PhoneNumber: event.target.value });
      break;
    case `dateOfBirth`:
      this.setState({ DateOfBirth: event.target.value });
      break;
  }
}

我们不会在代码中转储所有需要对实际输入进行的更改。 我们只需要更新每个输入,使其值与适当的状态元素相匹配,然后在每种情况下添加相同的onChange处理程序。

As Address2 can be null, we are using the ! operator on our binding so that it looks slightly different: value={this.state.Address2!}.

验证用户输入和验证器的使用

在这个阶段,我们真的应该考虑验证来自用户的输入。 我们将在代码中引入两种类型的验证。 第一个是最小长度验证。 换句话说,我们将确保某些条目必须有最小数量的条目,才能认为它们是有效的。 第二种验证使用正则表达式进行验证。 这意味着它接受输入,并将其与一组规则进行比较,以查看是否有匹配; 如果你是正则表达式的新手,这些表达式看起来可能有点奇怪,所以我们将对它们进行分解,看看我们到底应用了哪些规则。

我们将把验证分为三个部分:

  1. 提供检查特性的类,例如应用正则表达式。 我们将调用这些验证器。
  2. 将验证项应用于状态的不同部分的类。 我们将这些类称为验证。
  3. 该组件将调用验证项并使用失败验证的详细信息更新 UI。 这将是一个名为FormValidation.tsx的新组件。

我们将从创建一个名为IValidator的接口开始。 这个接口将接受一个泛型参数,这样我们就可以将它应用到我们想要的任何东西上。 验证将告诉我们输入是否有效,它将有一个名为IsValid的方法,接受相关的输入,然后返回一个boolean值:

interface IValidator<T> {
  IsValid(input : T) : boolean;
}

我们要编写的第一个验证器检查字符串是否具有最小字符数,我们将通过构造函数设置最小字符数。 我们还将防止用户无法提供输入的情况,当输入为空时,从IsValid返回false:

export class MinLengthValidator implements IValidator<StringOrNull> {
  private minLength : number;
  constructor(minLength : number) {
    this.minLength = minLength;
  }
  public IsValid(input : StringOrNull) : boolean {
    if (!input) {
      return false;
    }
    return input.length >= this.minLength;
  }
}

我们要创建的另一个验证器稍微复杂一些。 这个验证器接受一个字符串,它使用该字符串创建一个称为正则表达式的东西。 正则表达式实际上是一种迷你语言,它提供了一组用于测试输入字符串的规则。 在本例中,形成正则表达式的规则被传递到构造函数中。 然后,构造函数将实例化 JavaScript 正则表达式引擎(RegExp)的一个实例。 与最小长度验证类似,我们确保在没有输入时返回false。 如果有输入,则返回正则表达式 test 的结果:

import { StringOrNull } from 'src/Types';

export class RegularExpressionValidator implements IValidator<StringOrNull> {
  private regex : RegExp;
  constructor(expression : string) {
    this.regex = new RegExp(expression);
  }
  public IsValid (input : StringOrNull) : boolean {
    if (!input) {
      return false;
    }
    return this.regex.test(input);
  } 
}

现在我们已经有了验证器,接下来我们将检查如何应用它们。 我们要做的第一件事就是定义一个接口,它形成了我们想要验证的契约。 我们的Validate方法将接受组件的IPersonState状态,从这个状态验证项,然后返回一个验证失败的数组:

export interface IValidation {
  Validate(state : IPersonState, errors : string[]) : void;
}

我决定将验证分为以下三个方面:

  1. 验证地址
  2. 验证这个名字
  3. 确认电话号码

验证地址

我们的地址验证将使用MinLengthValidatorRegularExpressionValidator验证器:

export class AddressValidation implements IValidation {
  private readonly minLengthValidator : MinLengthValidator = new MinLengthValidator(5);
  private readonly zipCodeValidator : RegularExpressionValidator 
    = new RegularExpressionValidator("^[0-9]{5}(?:-[0-9]{4})?$");
}

最小长度验证非常简单,但是如果您以前从未见过这种类型的语法,那么正则表达式可能会令人生畏。 在查看验证代码之前,我们将分析正则表达式的作用。

第一个字符,^,告诉我们验证将从字符串的最开始开始。 如果我们省略了这个字符,就意味着匹配可以出现在文本中的任何地方。 使用[0-9]告诉正则表达式引擎我们想要匹配一个数字。 严格地说,由于美国邮政编码以 5 个数字开始,我们需要告诉验证器我们想要匹配 5 个数字,我们通过告诉引擎我们想要的数量:[0-9]{5}。 如果我们只想匹配主要区域代码,比如 10023,我们几乎可以在这里结束表达式。 然而,邮政编码也有一个可选的四位数部分,由连字符与主要部分隔开。 因此,我们必须告诉正则表达式引擎,我们有一个想要应用的可选部件。

我们知道邮政编码的可选部分的格式是 4 位数字的连字符。 这意味着正则表达式的下一部分必须将测试视为一个测试。 这意味着我们不能先测试连字符,然后再单独测试数字; 我们要么有-1234 格式,要么什么都没有。 这告诉我们,我们想要将要测试的项目组合在一起。 在正则表达式中把东西组合在一起的方法是把表达式放在括号中。 所以,如果我们应用之前的逻辑,我们可能会认为这部分验证是(-[0-9]{4})。 作为第一遍,这非常接近我们想要的结果。 这里的规则是把它作为一个组,第一个字符必须是连字符,然后必须有四个数字。 对于这个表达式的这一部分,我们需要弄清楚两件事。 第一件事是,目前这项测试不是可选的。 换句话说,输入 10012-1234 有效,而 10012 不再有效。 第二个问题是,我们在表达式中创建了一个称为捕获组的东西,而我们并不需要它。

A capture group is a numbered group that represents the number of the match. This can be useful if we want to match the same text in a number of places in a document; however, as we only want one match, it is something we can avoid.

现在我们将用验证的可选部分来修复这两个问题。 我们要做的第一件事是移除捕获组。 这是通过使用一个?:操作符来完成的,该操作符告诉引擎这个组是一个非捕获组。 接下来我们要处理的是应用一个?操作符,该操作符表示我们希望这个匹配发生 0 次或只发生一次。 换句话说,我们把它变成了一个可选测试。 此时,我们可以成功地测试 10012 和 10012-1234,但我们还有一件事需要处理。 我们需要确保输入只匹配这个输入。 换句话说,我们不想让任何游离的角色出现在最后; 否则,用户就可以输入 10012-12345,引擎就会认为我们的输入是有效的。 我们需要做的是在表达式的末尾添加$操作符,该操作符表示表达式期望该行在该点结束。 此时,我们的正则表达式是^[0-9]{5}(?:-[0-9]{4})?$,它与我们期望应用于邮政编码的验证相匹配。

I have chosen to explicitly specify that a number is represented as [0-9] because it is a clear indicator for someone new to regular expressions that this represents a number between 0 and 9. There is an equivalent shorthand that can be used to represent a single digit, and that is to use \d in its place. With this, we can rewrite this rule to ^\d{5}(?:-\d{4})?$. The use of \d in this represents a single American Standard Code for Information Interchange (ASCII) digit.

回到我们的地址验证,实际的验证本身非常简单,因为我们花了时间编写了验证器,为我们完成了艰巨的工作。 我们只需要对地址、城镇和县的第一行应用最小长度验证器,然后将正则表达式验证器应用于邮政编码。 每个失败的验证项都被添加到错误列表中:

public Validate(state: IPersonState, errors: string[]): void {
  if (!this.minLengthValidator.IsValid(state.Address1)) {
    errors.push("Address line 1 must be greater than 5 characters");
  }
  if (!this.minLengthValidator.IsValid(state.Town)) {
    errors.push("Town must be greater than 5 characters");
  }
  if (!this.minLengthValidator.IsValid(state.County)) {
    errors.push("County must be greater than 5 characters");
  }
  if (!this.zipCodeValidator.IsValid(state.Postcode)) {
    errors.push("The postal/zip code is invalid");
  }
}

验证这个名字

名称验证是我们要编写的验证中最简单的部分。 这种验证假设我们的名字至少有一个字母,姓氏至少有两个字母:

export class PersonValidation implements IValidation {
  private readonly firstNameValidator : MinLengthValidator = new MinLengthValidator(1);
  private readonly lastNameValidator : MinLengthValidator = new MinLengthValidator(2);
  public Validate(state: IPersonState, errors: string[]): void {
    if (!this.firstNameValidator.IsValid(state.FirstName)) {
      errors.push("The first name is a minimum of 1 character");
    }
    if (!this.lastNameValidator.IsValid(state.FirstName)) {
      errors.push("The last name is a minimum of 2 characters");
    }
  }
}

确认电话号码

电话号码验证将分为两部分。 首先,我们验证是否有电话号码的条目。 然后,使用正则表达式进行验证,以确保它的格式是正确的。 在我们分析正则表达式之前,让我们看看这个验证类是什么样的:

export class PhoneValidation implements IValidation {

  private readonly regexValidator : RegularExpressionValidator = new RegularExpressionValidator(`^(?:\\((?:[0-9]{3})\\)|(?:[0-9]{3}))[-. ]?(?:[0-9]{3})[-. ]?(?:[0-9]{4})$`);
  private readonly minLengthValidator : MinLengthValidator = new MinLengthValidator(1);

  public Validate(state : IPersonState, errors : string[]) : void {
    if (!this.minLengthValidator.IsValid(state.PhoneNumber)) {
      errors.push("You must enter a phone number")
    } else if (!this.regexValidator.IsValid(state.PhoneNumber)) {
      errors.push("The phone number format is invalid");
    }
  }
}

正则表达式最初看起来比邮政编码验证更复杂; 然而,一旦我们分解它,我们会看到它有许多熟悉的元素。 它使用^从行开始捕获,$从右到尾捕获,?:创建非捕获组。 我们还看到,我们已经设置了数字匹配,例如[0-9]{3}来表示三个数字。 如果我们一节一节地将其分解,就会发现这确实是一个简单的验证。

我们的电话号码的第一部分要么采用(555)格式,要么使用 555 后跟连字符、句号或空格。 乍一看,(?:\\((?:[0-9]{3})\\)|(?:[0-9]{3}))[-. ]?是这个表达中最令人生畏的部分。 我们知道,第一部分要么是(555),要么是 555; 这意味着我们要么有这个表达,要么有这个表达。 我们已经看到,()对正则表达式引擎来说意味着一些特殊的东西,所以我们必须有一些可用的机制来说明我们正在查看实际的括号,而不是括号所代表的表达式。 这就是这个短语中\\部分的意思。

The use of \ in a regular expression escapes the next character so that it is treated literally, rather than as an expression that forms a rule that will be matched. Additionally, as TypeScript already treats \ as an escape character, we have to escape the escape character as well so that the expression engine sees the correct value. 

当我们希望正则表达式表示一个值必须是 this 或 that 时,我们对表达式进行分组,然后使用|将其分开。 看看这里的表达式,我们看到我们首先寻找的是(nnn)部分,如果它不匹配,我们转而寻找nnn部分。

我们还说过,这个值可以后跟连字符、句号或空格。 我们使用[-. ]来匹配列表中的单个字符。 为了使这个测试可选,我们把?放在最后。

有了这些知识,我们可以看到正则表达式的下一部分,(?:[0-9]{3})[-. ]?,正在寻找三个数字,可以选择后跟连字符、句号或空格。 最后一部分,(?:[0-9]{4}),说明这个数字必须以四位数字结尾。 我们现在知道,我们可以匹配(555)123-4567、123.456.7890 和(555)543 9876 等数字。

For our purpose, simple zip code and phone number validations such as these work perfectly. In larger-scale applications, we do not want to rely on these as validation. These only test the data that looks to be in a particular format; they don't actually check to see whether they belong to real addresses or phones. If we reached a stage with our application where we actually wanted to verify that these existed, we would have to hook up to the services that did these checks. 

在 React 组件中应用验证

在模拟布局中,我们希望验证显示在SaveClear按钮下方。 虽然我们可以在主组件中这样做,但我们要将验证分离到单独的验证组件中。 该组件将接收主组件的当前状态,当状态更改时应用验证,并返回是否可以保存数据。

类似于我们创建的PersonalDetails组件,我们将创建属性来传递给我们的组件:

interface IValidationProps {
  CurrentState : IPersonState;
  CanSave : (canSave : boolean) => void;
}

我们将在FormValidation.tsx中创建一个组件,它将应用我们刚刚创建的不同的IValidation类。 构造函数简单地将不同的验证器添加到一个数组中,稍后我们将对该数组进行迭代并应用验证:

export default class FormValidation extends React.Component<IValidationProps> {
  private failures : string[];
  private validation : IValidation[];

  constructor(props : IValidationProps) {
    super(props);
    this.validation = new Array<IValidation>();
    this.validation.push(new PersonValidation());
    this.validation.push(new AddressValidation());
    this.validation.push(new PhoneValidation());
  }

  private Validate() {
    this.failures = new Array<string>();
    this.validation.forEach(validation => {
      validation.Validate(this.props.CurrentState, this.failures);
    });

    this.props.CanSave(this.failures.length === 0);
  }
}

Validate方法中,在从属性调用CanSave方法之前,我们在forEach中应用每个验证片段。

在我们添加render方法之前,我们将重新访问PersonalDetails并添加FormValidation组件:

<Row><FormValidation CurrentState={this.state} CanSave={this.userCanSave} /></Row>

userCanSave方法如下:

private userCanSave = (hasErrors : boolean) => {
  this.canSave = hasErrors;
}

因此,每当验证被更新时,我们的Validate方法就会回调userCanSave,这是作为属性传入的。

要让验证运行,我们需要做的最后一件事是从render方法调用Validate方法。 我们这样做是因为每当父进程的状态改变时,渲染循环就会被调用。 当我们有一个验证失败的列表时,我们需要将它们作为元素添加到 DOM 中,以便将它们呈现回接口。 一个简单的方法是创建所有失败的映射,并提供一个迭代器作为函数,它将循环遍历每个失败,并将其作为行写入接口:

public render() {
  this.Validate();
  const errors = this.failures.map(function it(failure) {
    return (<Row key={failure}><Col><label>{failure}</label></Col></Row>);
  });
  return (<Col>{errors}</Col>)
}

此时,每当我们改变应用中的状态时,我们的验证就会自动触发,任何失败都会以label标签的形式写入浏览器中。

创建并发送数据到 IndexedDB 数据库

如果我们不能保存详细信息以备下次使用,那么在使用应用时将会产生非常糟糕的体验。 幸运的是,新的网络浏览器提供了对 IndexedDB 的支持,这是一个基于网络浏览器的数据库。 使用它作为我们的数据存储意味着当我们重新打开页面时,详细信息将是可用的。

当我们使用数据库时,我们需要记住两个不同的领域。 我们需要代码来构建数据库表,也需要代码来保存数据库中的记录。 在开始编写数据库表之前,我们将添加描述数据库外观的功能,这将用于构建数据库。

接下来,我们将创建一个流畅的界面,以添加ITable公开的信息:

export interface ITableBuilder {
  WithDatabase(databaseName : string) : ITableBuilder;
  WithVersion(version : number) : ITableBuilder;
  WithTableName(tableName : string) : ITableBuilder;
  WithPrimaryField(primaryField : string) : ITableBuilder;
  WithIndexName(indexName : string) : ITableBuilder;
}

流畅接口背后的想法是,它们允许我们将方法链接在一起,以便以更容易的方式阅读它们。 它们鼓励将方法操作放在一起的想法,因为所有操作都分组在一起,因此更容易读取发生在实例上的事情。 这个接口很流畅,因为方法返回ITableBuilder。 这些方法的实现使用return this;来允许操作链接在一起。

With fluent interfaces, not all methods need to be fluent. If you create a non-fluent method on an interface, that becomes the end of the call chain. This is sometimes used for classes that need to set some properties and then build an instance of a class that has those properties.

构建表的另一方面是能够从构建器获取值。 因为我们想保持我们流畅的接口纯粹处理添加细节,我们将编写一个单独的接口来检索这些值并构建我们的 IndexedDB 数据库:

export interface ITable {
  Database() : string;
  Version() : number;
  TableName() : string;
  IndexName() : string;
  Build(database : IDBDatabase) : void;
}

虽然这两个接口服务于不同的目的,并将被类以不同的方式使用,但它们都引用相同的底层代码。 当我们编写公开这些接口的类时,我们将在同一个类中实现这两个接口。 这样做的原因是,我们可以根据调用代码看到的接口来区分它们的行为。 我们的表构建类定义如下:

export class TableBuilder implements ITableBuilder, ITable {
}

当然,如果我们现在尝试构建它,它会失败,因为我们还没有实现我们的接口。 这个类的ITableBuilder部分代码如下:

private database : StringOrNull;
private tableName : StringOrNull;
private primaryField : StringOrNull;
private indexName : StringOrNull;
private version : number = 1;
public WithDatabase(databaseName : string) : ITableBuilder {
  this.database = databaseName;
  return this;
}
public WithVersion(versionNumber : number) : ITableBuilder {
  this.version = versionNumber;
  return this;
}
public WithTableName(tableName : string) : ITableBuilder {
  this.tableName = tableName;
  return this;
}
public WithPrimaryField(primaryField : string) : ITableBuild
  this.primaryField = primaryField;
  return this;
}
public WithIndexName(indexName : string) : ITableBuilder {
  this.indexName = indexName;
  return this;
}

在大多数情况下,这是简单的代码。 我们定义了许多成员变量来保存细节,每个方法负责填充单个值。 代码真正有趣的地方是return语句。 通过返回这个,我们可以将每个方法链接在一起。 在我们添加ITable支持之前,让我们通过创建一个类来添加个人详细信息表定义来探索如何使用这个流畅的接口:

export class PersonalDetailsTableBuilder {
  public Build() : TableBuilder {
    const tableBuilder : TableBuilder = new TableBuilder();
    tableBuilder
      .WithDatabase("packt-advanced-typescript-ch3")
      .WithTableName("People")
      .WithPrimaryField("PersonId")
      .WithIndexName("personId")
      .WithVersion(1);
    return tableBuilder;
  }
}

这段代码所做的是创建一个表构建器,将数据库名称设置为packt-advanced-typescript-ch3,并将People表添加到其中,将主字段设置为PersonId,并在其中创建一个索引personId

现在我们已经看到了流畅的接口,我们需要通过添加缺少的ITable方法来完成TableBuilder类:

public Database() : string {
  return this.database;
}

public Version() : number {
  return this.version;
}

public TableName() : string {
  return this.tableName;
}

public IndexName() : string {
  return this.indexName;
}

public Build(database : IDBDatabase) : void {
  const parameters : IDBObjectStoreParameters = { keyPath : this.primaryField };
  const objectStore = database.createObjectStore(this.tableName, parameters);
  objectStore!.createIndex(this.indexName, this.primaryField);
}

Build方法是这部分代码中最有趣的一个。 在这里,我们使用来自底层 IndexedDB 数据库的方法来物理创建表。 IDBDatabase是与实际的 IndexedDB 数据库的连接,我们将在开始编写核心数据库功能时检索它。 我们使用它来创建对象存储,我们将使用它来存储人员记录。 设置keyPath允许我们给对象存储一个我们想要搜索的字段,因此它将匹配一个字段的名称。 当我们添加索引时,我们可以告诉对象存储我们希望在哪些字段中进行搜索。

为我们的州添加活动记录支持

在我们查看实际的数据库代码之前,我们需要引入最后一块拼图——我们要存储的对象。 当我们使用状态时,我们一直在使用IPersonState来表示一个人的状态,就PersonalDetails组件而言,这就足够了。 在使用数据库时,我们希望扩展这个状态。 我们将引入一个新的IsActive参数,它将决定一个人是否显示在屏幕上。 我们不需要改变IPersonState的实现来添加这个功能; 我们将使用交集类型来处理这个。 我们要做的第一件事是添加一个有这个活动标志的类,然后创建交集类型:

export interface IRecordState {
  IsActive : boolean;
}

export class RecordState implements IRecordState {
  public IsActive: boolean;
}

export type PersonRecord = RecordState & IPersonState;

使用数据库

现在,我们已经能够构建表和希望保存到表中的状态表示,我们可以将注意力转向连接到数据库并实际操作其中的数据。 我们要做的第一件事是将我们的类定义为泛型类型,它可以与任何扩展我们刚刚实现的RecordState类的类型工作:

export class Database<T extends RecordState> {

}

我们需要指定在这个类中接受的类型的原因是,它中的大多数方法要么接受该类型的实例作为参数,要么返回该类型的实例,以便在调用代码中处理。

随着 IndexedDB 已经成为标准的客户端数据库,它已经成为可以直接从窗口对象访问的对象。 TypeScript 提供了强大的接口来支持数据库,所以它被公开为一个IDBFactory类型。 这对我们来说很重要,因为它使我们能够访问诸如打开数据库之类的操作。 实际上,这是我们的代码开始操作数据时必须从的路径。

当我们想要打开数据库时,我们给它一个名称和版本。 如果数据库名称不存在,或者我们试图打开一个更新的版本,那么应用代码需要升级数据库。 这就是TableBuilder代码发挥作用的地方。 我们已经指定了TableBuilder实现了一个ITable接口来提供读取值和构建底层数据库表的能力,我们将使用它(表实例被传递到构造函数中,我们很快就会看到)。

使用 IndexedDB 乍一看可能有点奇怪,因为它非常强调使用事件处理程序。 例如,当我们试图打开数据库时,如果代码决定需要升级,它会触发upgradeneeded事件,我们使用onupgradeneeded来处理该事件。 这种事件的使用允许我们的代码异步执行,因为执行不需要等待操作完成就会继续。 然后,当事件处理程序被触发时,它接管处理。 当我们向这个类添加数据方法时,我们会看到很多这样的情况。

记住这些信息,我们可以编写我们的OpenDatabase方法来使用Version方法的值打开数据库。 第一次执行这段代码时,我们需要编写数据库表。 即使这是一个新表,它也被视为一次升级,因此会触发upgradeneeded事件。 再次,我们可以看到在PersonalDetailsTableBuilder类中构建数据库的能力的好处,因为我们使数据库代码不知道如何构建表。 通过这样做,我们可以重用这个类,以便在需要时将其他类型写入数据库。 当数据库打开时,onsuccess处理程序将被触发,我们将设置一个实例级database成员,我们可以在以后使用:

private OpenDatabase(): void {
    const open = this.indexDb.open(this.table.Database(), this.table.Version());
    open.onupgradeneeded = (e: any) => {
        this.UpgradeDatabase(e.target.result);
    }
    open.onsuccess = (e: any) => {
        this.database = e.target.result;
    }
}

private UpgradeDatabase(database: IDBDatabase) {
    this.database = database;
    this.table.Build(this.database);
}

现在我们已经有能力构建和打开表,我们将编写一个构造函数,接受ITable实例,我们将使用它来构建表:

private readonly indexDb: IDBFactory;
private database: IDBDatabase | null = null;
private readonly table: ITable;

constructor(table: ITable) {
    this.indexDb = window.indexedDB;
    this.table = table;
    this.OpenDatabase();
}

在开始编写处理数据的代码之前,我们还要为这个类编写最后一个辅助方法。 为了将数据写入数据库,我们必须创建一个事务并从它检索对象存储的实例。 实际上,对象存储表示数据库中的一个表。 从本质上讲,如果我们想要读或写数据,就需要对象存储。 由于这很常见,我们创建了一个返回对象存储的GetObjectStore方法。 为了方便起见,我们将允许我们的事务将每个操作视为读或写,这是我们在调用事务时指定的:

private GetObjectStore(): IDBObjectStore | null {
    try {
        const transaction: IDBTransaction = this.database!.transaction(this.table.TableName(), "readwrite");
        const dbStore: IDBObjectStore = transaction.objectStore(this.table.TableName());
        return dbStore;
    } catch (Error) {
        return null;
    }
}

As we go through the code, you will see that I have chosen to name the methods Create, Read, Update, and Delete. It is fairly common to name the first two methods Load and Save; however, I chose these method names deliberately, because when working with data in databases, we often use the term CRUD operation, where CRUD refers to Create, Read, Update, and Delete. By adopting this naming convention, I hope that this solidifies this connection for you.

我们要添加的第一个(也是最简单的)方法将允许我们将记录保存到数据库中。 Create方法接收一个单独的记录,获取对象存储,并将记录添加到数据库中:

public Create(state: T): void {
    const dbStore = this.GetObjectStore();
    dbStore!.add(state);
}

当我最初为本章编写代码时,我编写了ReadWrite方法来使用回调方法。 回调方法背后的思想只是接受一个函数,当success事件处理程序被触发时,我们的方法可以回调。 当我们查看大量 IndexedDB 示例时,我们可以看到它们倾向于采用这种类型的约定。 在我们看最终版本之前,让我们看看Read方法最初是什么样子的:

public Read(callback: (value: T[]) => void) {
    const dbStore = this.GetObjectStore();
        const items : T[] = new Array<T>();
        const request: IDBRequest = dbStore!.openCursor();
        request.onsuccess = (e: any) => {
            const cursor: IDBCursorWithValue = e.target.result;
            if (cursor) {
                const result: T = cursor.value;
                if (result.IsActive) {
                    items.push(result);
                }
                cursor.continue();
            } else {
                // When cursor is null, that is the point that we want to 
                // return back to our calling code. 
                callback(items);
            }
    }
}

该方法通过获取对象存储并使用它来打开游标来打开。 游标为我们提供了读取记录并移动到下一个记录的能力; 因此,当游标打开时,成功事件被触发,这意味着我们进入onsuccess事件处理程序。 当这是异步发生时,Read方法就完成了,因此我们将依赖回调函数将实际值传递回调用它的类。 看起来相当奇怪的callback: (value: T[]) => void是实际的回调函数,我们将使用它将T项数组返回给调用代码。

success事件处理程序中,我们从事件中获得结果,这将是一个游标。 假设游标不为空,我们从游标中获取结果,如果记录的状态是活动的,则将记录添加到数组中; 这就是为什么我们将泛型约束应用到我们的类中,这样我们就可以访问IsActive属性。 然后我们在光标上调用continue,光标移动到下一条记录。 continue方法导致再次触发success,这意味着我们重新进入onsuccess处理程序,导致对下一个记录执行相同的代码。 当没有更多记录时,游标将为空,因此代码将用项数组回调调用代码。

我提到过这是这段代码的最初实现。 虽然回调函数很有用,但它们并没有真正利用 TypeScript 提供给我们的强大功能。 这忽略了在代码库中使用承诺的能力。 由于我们依赖于一个承诺,我们将在将所有记录返回给调用代码之前将它们收集在一起。 这意味着我们将对success处理程序内部的逻辑有一些小的结构差异:

public Read() : Promise<T[]> {
    return new Promise((response) => {
        const dbStore = this.GetObjectStore();
        const items : T[] = new Array<T>();
        const request: IDBRequest = dbStore!.openCursor();
        request.onsuccess = (e: any) => {
            const cursor: IDBCursorWithValue = e.target.result;
            if (cursor) {
                const result: T = cursor.value;
                if (result.IsActive) {
                    items.push(result);
                }
                cursor.continue();
            } else {
                // When cursor is null, that is the point that we want to 
                // return back to our calling code. 
                response(items);
            }
        }
    });
}

因为这将返回一个承诺,所以我们从方法签名中删除回调,并返回一个数组T的承诺。 我们必须注意的一件事是,用于存储结果的数组的作用域必须在success事件处理程序之外; 否则,每次点击onsuccess,我们都会重新分配它。 有趣的是,这段代码与回调版本非常相似。 我们所做的只是改变返回类型,同时从方法签名中删除回调。 我们承诺的响应部分代替回调。

In general, if our code accepts a callback, we can convert it to a promise by returning a promise with the callback moved from the method signature into the promise itself.

游标的逻辑与我们依赖游标检查来查看是否有值,如果有值,就将其推入数组。 当没有更多的记录时,我们调用承诺的响应,以便调用代码可以在承诺的then部分中使用它。 为了说明这一点,让我们检查一下PersonalDetails中的loadPeople代码:

private loadPeople = () => {
  this.people = new Array<PersonRecord>();
  this.dataLayer.Read().then(people => {
    this.people = people;
    this.setState(this.state);
  });
}

Read方法是 CRUD 操作中最复杂的部分。 我们要写的下一个方法是Update方法。 当记录更新后,我们希望重新加载列表中的记录,以便在屏幕上更新对姓名或姓氏的更改。 更新记录的对象存储操作是put。 如果它成功完成,它会引发 success 事件,这导致我们的代码调用 promise 的resolve属性。 当我们返回一个Promise<void>类型时,我们有能力使用async/await语法来调用这个:

public Update(state: T) : Promise<void> {
    return new Promise((resolve) =>
    {
        const dbStore = this.GetObjectStore();
        const innerRequest : IDBRequest = dbStore!.put(state);
        innerRequest.onsuccess = () => {
          resolve();
        } 
    });
}

我们最后的数据库方法是Delete方法。 Delete方法的语法与Update方法非常相似,唯一的区别是它只取索引,索引告诉它数据库中的delete行是哪一行:

public Delete(idx: number | string) : Promise<void> {
    return new Promise((resolve) =>
    {
        const dbStore = this.GetObjectStore();
        const innerRequest : IDBRequest = dbStore!.delete(idx.toString());
        innerRequest.onsuccess = () => {
          resolve();
        } 
    });
}

从 PersonalDetails 访问数据库

我们现在可以添加数据库支持到我们的PersonalDetails类。 我们要做的第一件事是更新成员变量和构造函数,以引入数据库支持,并存储我们想要显示的人的列表:

  1. 首先,我们增加成员:
private readonly dataLayer: Database<PersonRecord>;
private people: IPersonState[];
  1. 接下来,我们更新构造函数以连接到数据库,并使用PersonalDetailsTableBuilder创建TableBuilder:
const tableBuilder : PersonalDetailsTableBuilder = new PersonalDetailsTableBuilder();
this.dataLayer = new Database(tableBuilder.Build());
  1. 我们仍然需要做的一件事是在我们的render方法中添加显示用户的功能。 与使用map显示验证失败类似,我们将对people数组应用map:
let people = null;
if (this.people) {
  const copyThis = this;
  people = this.people.map(function it(p) {
  return (<Row key={p.PersonId}><Col lg="6"><label >{p.FirstName} {p.LastName}</label></Col>
  <Col lg="3">
    <Button value={p.PersonId} color="link" onClick={copyThis.setActive}>Edit</Button>
  </Col>
  <Col lg="3">
    <Button value={p.PersonId} color="link" onClick={copyThis.delete}>Delete</Button>
  </Col></Row>)
  }, this);
}
  1. 然后呈现出以下内容:
<Col>
  <Col>
  <Row>
    <Col>{people}</Col>
  </Row>
  <Row>
    <Col lg="6"><Button size="lg" color="success" onClick={this.loadPeople}>Load</Button></Col>
    <Col lg="6"><Button size="lg" color="info" onClick={this.clear}>New Person</Button></Col>
  </Row>
  </Col>
</Col>

Load 按钮是该类中调用loadPeople方法的多个位置之一。 当我们更新并删除记录时,我们将看到它的使用情况。

在处理数据库代码时,经常会遇到这样的情况:删除记录时不应该从数据库中物理地删除记录。 我们可能不想物理地删除它,因为另一条记录指向它,因此删除它将打破另一条记录。 或者,为了进行审计,我们可能不得不将它保留在适当的位置。 在这些情况下,通常会执行软删除(硬删除是指从数据库中删除记录)。 使用软删除,在记录上有一个标志,指示该记录是否处于活动状态。 IPersonState没有这个标志,PersonRecord有这个标志,因为它是IPersonStateRecordState的交集。 我们的delete方法将把IsActive改为false,并用该值更新数据库。 加载用户的代码已经理解它正在检索IsActivetrue的记录,所以这些删除的记录将在列表重新加载时消失。 这意味着,虽然我们在数据库代码中编写了 Delete 方法,但实际上并不会使用它。 它是作为一个方便的参考,你可能想要更改代码来执行硬删除,但这对我们的目的不是必要的。

Delete 按钮将触发删除操作。 由于这个列表中可能有许多项,我们不能假设用户在删除一个人之前会选择一个人,所以我们需要在试图删除他们之前从这个人的列表中找到那个人。 回头看看呈现人员的代码,我们可以看到人员的 ID 被传递给事件处理程序。 在编写事件处理程序之前,我们将编写从数据库中异步删除人的方法。 在这个方法中我们要做的第一件事是找到使用find数组方法的人:

private async DeletePerson(person : string) {
  const foundPerson = this.people.find((element : IPersonState) => {
    return element.PersonId === person;
  });
  if (!foundPerson) {
    return;
  }
}

假设我们从数组中找到了这个人,我们需要让这个人进入一个可以将IsActive设置为false的状态。 我们首先创建一个新的RecordState实例,如下所示:

  const personState : IRecordState = new RecordState();
  personState.IsActive = false;

我们有一个交集类型,PersonRecord,由人与记录状态的交集组成。 我们将分散foundPersonpersonState,形成PersonRecord型。 有了这个,我们将调用我们的Update数据库方法。 当更新完成时,我们要做的是重新加载人员列表并清除编辑器中当前的项目——以防它是我们刚刚删除的项目; 我们不希望用户能够恢复记录仅仅因为他们再次保存它与IsActive设置为true。 我们将使用事实,我们可以使用await的代码,作为一个承诺,等待记录已经被更新,然后我们继续处理:

  const state : PersonRecord = {...foundPerson, ...personState};
  await this.dataLayer.Update(state);
  this.loadPeople();
  this.clear();

clear方法简单地将状态更改回我们的默认状态。 这就是我们将它传递给这个组件的全部原因,这样我们就可以轻松地将值清除回默认状态:

private clear = () => {
  this.setState(this.defaultState);
}

使用我们的delete事件处理程序,完整的代码如下:

private delete = (event : any) => {
  const person : string = event.target.value;
  this.DeletePerson(person);
}

private async DeletePerson(person : string) {
  const foundPerson = this.people.find((element : IPersonState) => {
    return element.PersonId === person;
  });
  if (!foundPerson) {
    return;
  }
  const personState : IRecordState = new RecordState();
  personState.IsActive = false;
  const state : PersonRecord = {...foundPerson, ...personState};
  await this.dataLayer.Update(state);
  this.loadPeople();
  this.clear();
}

我们需要连接的最后一个数据库操作是从 Save 按钮触发的。 保存的结果取决于我们之前是否保存了记录,可以通过PersonId是否为空来识别。 在尝试保存记录之前,我们必须确定是否可以保存它。 这归结为检查验证是否说我们可以保存。 如果有未解决的验证失败,我们将警告用户,他们不能保存记录:

private savePerson = () => {
  if (!this.canSave) {
    alert(`Cannot save this record with missing or incorrect items`);
    return;
  }
}

类似于我们如何使用删除技术,我们将通过将状态与RecordState结合在一起来创建我们的PersonRecord类型。 这一次,我们将IsActive设置为true,以便将其作为实时记录:

const personState : IRecordState = new RecordState();
personState.IsActive = true;
const state : PersonRecord = {...this.state, ...personState};

当我们插入记录时,我们需要为它分配一个唯一的值PersonId。 为了简单起见,我们只将它与当前日期和时间一起使用。 当我们将人添加到数据库时,我们会重新加载人的列表,并从编辑器中清除当前记录,这样用户就不能仅仅通过再次点击 Save 来插入一个副本:

  if (state.PersonId === "") {
    state.PersonId = Date.now().toString();
    this.dataLayer.Create(state);
    this.loadPeople();
    this.clear();
  }

更新人员的代码利用了承诺的特性,以便在完成保存后立即更新人员列表。 在这种情况下,我们不需要清除当前记录,因为如果用户再次单击 Save,我们不可能创建新的记录,但我们将简单地更新当前记录:

  else {
    this.dataLayer.Update(state).then(rsn => this.loadPeople());
  }

完整的保存方法如下:

private savePerson = () => {
  if (!this.canSave) {
    alert(`Cannot save this record with missing or incorrect items`);
    return;
  }
  if (state.PersonId === "") {
    state.PersonId = Date.now().toString();
    this.dataLayer.Create(state);
    this.loadPeople();
    this.clear();
  }
  else {
    this.dataLayer.Update(state).then(rsn => this.loadPeople());
  }
}

我们还需要介绍最后一种方法。 您可能已经注意到,当我们单击 Edit 按钮时,我们无法在文本框中选择和显示用户。 逻辑指示按下按钮应该触发一个事件,将PersonId传递给事件处理程序,我们可以使用该事件处理程序从列表中查找相关人员; 在使用 Delete 按钮时,我们已经看到了这种类型的行为,所以我们对代码的选择部分有一个很好的概念。 一旦我们有了这个人,我们调用setState来更新状态,它会通过 binding 的力量来更新显示:

private setActive = (event : any) => {
  const person : string = event.target.value;
  const state = this.people.find((element : IPersonState) => {
    return element.PersonId === person;
  });
  if (state) {
    this.setState(state);
  }
}

现在我们已经有了用 React 构建联系人管理器所需的所有代码。 我们已经满足了本章开始时提出的要求,我们的显示看起来非常接近模拟布局。

增强

Create方法有一个潜在的问题,因为它假定它立即成功。 它不处理操作的success事件。 另外,还有一个问题是,add操作有一个complete事件,因为success事件可能在记录成功写入磁盘之前触发,如果事务失败,complete事件不会触发。 您可以转换Create方法,以便它使用 promise 并在success事件引发时恢复处理。 然后,更新组件的插入部分,以便在插入完成后重新加载。

删除将重置状态,即使用户没有编辑被删除的记录。 因此,增强删除代码,只在被编辑的记录与被删除的记录相同的情况下重置状态。

总结

本章向我们介绍了流行的 React 框架,并讨论了如何使用它和 TypeScript 一起构建一个现代的客户端应用来添加联系人信息。 在使用create-react-appreact-scripts-ts脚本版本创建基本实现之前,我们首先定义需求并创建应用的模拟布局。 为了以一种反应友好的方式利用 Bootstrap 4,我们在reactstrap包中添加了。

在讨论了 React 如何使用特殊的 JSX 和 TSX 格式来控制它的呈现方式之后,我们继续定制App组件,并添加我们自己的定制 TSX 组件。 通过这些组件,我们了解了传递属性和设置状态,然后使用它们创建双向绑定。 通过绑定,我们讨论了如何通过创建可重用的验证器来验证用户输入,然后将这些验证器应用于验证类。 作为验证的一部分,我们添加了两个正则表达式,我们分析它们以了解它们是如何构造的。

最后,我们研究了如何在 IndexedDB 数据库中保存个人信息。 第一部分是理解如何使用表构建器构建数据库和表,通过查看如何使用数据库补充了这一点。 我们讨论了如何将一个基于回调的方法转换为使用 promise API 来提供异步支持,以及数据软删除和硬删除之间的区别。

在下一章中,我们将继续使用 MongoDB、Express 和 Node.js(统称为 MEAN 栈)来构建一个照片库应用。

问题

  1. 是什么赋予 React 将视觉元素与render方法中的代码混合的能力?
  2. React 为什么使用classNamehtmlFor?
  3. 我们看到,可以使用正则表达式^(?:\\((?:[0-9]{3})\\)|(?:[0-9]{3}))[-. ]?(?:[0-9]{3})[-. ]?(?:[0-9]{4})$验证电话号码。 我们还讨论了表示单个数字的另一种方法。 我们如何将这个表达式转换成与另一种表示方式完全相同的结果?
  4. 为什么我们要创建与验证代码分开的验证器?
  5. 软删除和硬删除的区别是什么?

进一步的阅读