TypeScript-高级编程项目-全-

TypeScript 高级编程项目(全)

原文:zh.annas-archive.org/md5/412B7599C0C63C063566D3F1FFD02ABF

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这是一本关于 TypeScript 的书;但是,你从标题中已经知道了。但它不仅仅是一本关于 TypeScript 的书。这是一本关于如何使用 TypeScript 超越基本示例的书。这意味着这是一本关于比你可能已经在 TypeScript 世界初探时所涉及的那些稍微困难一点的主题的书。

因此,我们可以重新表述开头的句子为“这是一本关于 TypeScript 以及一些有趣和酷的方式,你可以用 TypeScript 与比我以前使用过的更高级的技术一起使用的书”。

首先,我要说的是,这本书不是关于如何在 Angular、React、Vue 或 ASP.NET Core 中编程的书。这些都是值得拥有自己独立书籍的大主题(事实上,在每一章的结尾,我会尽力指引你去其他资源,帮助你更深入地学习这些技术,而不仅仅是本书中简短的章节)。相反,对于 Angular 和 React,我试图将每章引入的新功能限制在不超过五个新概念。在使用诸如 Bootstrap 这样的技术时,我们将使用最合适的库,比如在 React 中使用reactstrap。我们这样做是因为这些库已经被设计用于与相关的用户界面(UI)框架一起使用。

当我们为这本书进行初步研究时,一个经常出现的问题是,“现在什么最热门?人们正在使用什么新的、令人兴奋的技术?”这本书旨在介绍其中一些技术,包括 GraphQL、微服务和机器学习。同样,这本书无法教授有关相关技术的一切。它所做的是提供对技术的介绍,并展示我们如何利用 TypeScript 的强大功能来使我们在开发时更加轻松。

当我们阅读本书时,我们会发现我倾向于非常重视面向对象编程(OOP)。我们将会构建很多类。这样做有很多原因,但这种关注的最大原因是,在早期章节中,我们将编写可以在后续章节中使用的代码。我也希望编写的代码可以直接放入你自己的代码库中。使用 TypeScript,基于类的开发使得这一切变得更加简单。这也给了我们讨论可以应用的技术的机会,使得代码更简单,即使在使用更高级的技术时,我们也会涵盖一些原则,比如类具有单一职责(称为单一职责模式),以及基于模式的开发,我们将已知的软件工程模式应用于复杂问题,使解决方案变得简单。

除了 TypeScript,我们还将在大多数章节中使用 Bootstrap 进行用户界面设计。在关于 Angular 的几章中,我们会考虑使用 Angular Material 来布局界面,因为 Material 和 Angular 是相辅相成的,如果你最终开发商业 Angular 应用程序,那么你很可能会使用 Material。

第一章向我们介绍了一些我们可能以前没有使用过的功能,比如 rest 和 spread,所以我们将在那里更深入地介绍它们。在后面的章节中,我们将以一种自然的方式使用这些功能,而不是打断代码的流程来特别指出某个项目,我们将倾向于只是以一种变得自然的方式使用这些功能。另一方面,随着我们在书中的进展,我们会发现前几章的功能通常会再次被提及,这样我们就不会只是做一次某件事然后就忘记它。

这本书是为谁写的

本书适用于至少对 TypeScript 基础知识感到舒适的人。如果您知道如何使用 TypeScript 编译器 tsc 来构建配置文件和编译代码,以及 TypeScript 中的类型安全性、函数和类等基础知识,那么您应该能够从本书中获得一些收获。

如果您对 TypeScript 有更高级的理解,那么您可能会对以前未使用过的技术有兴趣。

本书涵盖的内容

第一章,“高级 TypeScript 功能”,向我们介绍了我们以前可能没有遇到过的 TypeScript 功能,例如使用联合和交集类型,创建自己的类型声明,以及使用装饰器来启用面向方面的编程,等等。通过本章,我们将熟悉各种 TypeScript 技术,这些技术将成为我们作为专业程序员每天使用的基础。

第二章,“使用 TypeScript 创建 Markdown 编辑器”,是我们编写第一个实际项目的地方 - 一个简单的 Markdown 编辑器。我们将创建一个简单的解析器,将其连接到网页中的文本块,并使用它来识别用户何时键入 Markdown 标记,并在预览区域中反映这一点。在编写此代码时,我们将看到如何使用 TypeScript 设计模式来构建更健壮的解决方案。

第三章,“使用 React Bootstrap 创建个人联系人管理器”,让我们使用流行的 React 库构建个人联系人管理器。在编写应用程序时,我们将看到 React 如何使用特殊的 TSX 文件将 TypeScript 和 HTML 混合在一起以生成用户组件。我们还将看到如何在 React 中使用绑定和状态来在用户更改值时自动更新数据模型。这里的最终目标是创建一个允许我们使用浏览器自己的 IndexedDB 数据库输入,保存和检索信息的 UI,并查看如何将验证应用于组件以确保输入有效。

第四章,“MEAN 堆栈 - 构建照片库”,是我们第一次遇到 MEAN 堆栈。MEAN 堆栈描述了一组协作技术,用于构建在客户端和服务器上运行的应用程序。我们使用此堆栈编写一个使用 Angular 作为 UI 的照片库应用程序,其中使用 MongoDB 存储用户上传的图像。在创建应用程序时,我们将利用 Angular 的强大功能来创建服务和组件。同时,我们将看到如何使用 Angular Material 创建具有吸引力的 UI。

第五章,“使用 GraphQL 和 Apollo 创建 Angular ToDo 应用”,向我们介绍了一个观念,即我们不仅需要使用 REST 来在客户端和服务器之间进行通信。目前热门话题之一是使用 GraphQL 创建应用程序,该应用程序可以使用 GraphQL 服务器和客户端从多个点消耗和更新数据。我们在本章中编写的 Angular 应用程序将为用户管理待办事项列表,并进一步演示 Angular 功能,例如使用模板在只读和可编辑功能之间切换,以及查看 Angular 提供的用于验证用户输入的功能。

第六章,使用 Socket.IO 构建聊天室应用程序,进一步探讨了我们不需要依赖 REST 通信的想法。我们将看看如何在 Angular 中建立长时间运行的客户端/服务器应用程序,在这种应用程序中,客户端和服务器之间的连接似乎被保持永久打开,以便消息可以来回传递。利用 Socket.IO 的强大功能,我们将编写一个聊天室应用程序。为了进一步增强我们的代码,我们将使用外部身份验证提供程序来帮助我们专业地保护我们的应用程序,以避免存储密码的明文等尴尬的身份验证失败。

第七章,使用 Firebase 进行基于云的 Angular 地图,我们不得不忽视基于云的服务的增长已经变得不可能。在这个我们最后的 Angular 应用程序中,我们将使用两个独立的基于云的服务。我们将使用的第一个是 Bing 地图,它将向我们展示如何注册第三方基于云的地图服务,并将其集成到我们的应用程序中。我们将讨论此服务的规模对成本的影响。我们将显示一个地图,用户可以保存兴趣点,数据将存储在使用 Google 的 Firebase 云平台的独立基于云的数据库中。

第八章,使用 React 和微服务构建 CRM,在我们对 React 和 MEAN 堆栈的经验基础上,介绍了使用等效的基于 React 的堆栈。当我们第一次遇到 MEAN 时,我们使用 REST 与单个应用程序端点进行通信。在这个应用程序中,我们将与多个微服务进行通信,以创建一个简化的基于 React 的 CRM 系统。我们将讨论什么是微服务,以及何时我们想要使用它们,以及如何使用 Swagger 设计和记录 REST API。本章的主要收获是,我们介绍 Docker,以展示如何在其自己的容器中运行我们的服务;容器目前是开发人员在开发应用程序时最喜欢的话题之一,因为它们简化了应用程序的部署,并且使用起来并不那么困难。

第九章,使用 Vue.js 和 TensorFlow.js 进行图像识别,向我们介绍了如何使用我们的网络浏览器来托管使用 TensorFlow.js 的机器学习。我们将使用流行的 Vue.js 框架编写一个应用程序,使用预训练的图像模型来识别图像。我们将扩展此功能,以了解如何创建姿势检测应用程序,以识别您所处的姿势,并可以扩展到使用网络摄像头跟踪您的姿势,用于体育教练的目的。

第十章,构建 ASP.NET Core 音乐库,对我们来说是一个重大的转变。到目前为止,我们已经写了许多应用程序,其中 TypeScript 代表了我们用来构建 UI 的主要编程语言。使用 ASP.NET Core,我们将编写一个音乐库应用程序,我们可以输入艺术家的名称,并使用免费的 Discogs 音乐 API 搜索其音乐的详细信息。我们将使用 C#和 TypeScript 的组合来运行对 Discog 的查询,并构建我们的 UI。

要充分利用本书

  • 您应该具备基本的 TypeScript 知识,以便使用本书中的内容。了解 HTML 和网页将会很有用。

  • 在下载代码时,如果使用npm等软件包管理器,您需要知道如何恢复软件包,因为我们没有将它们包含在存储库中。要恢复它们,您可以在与package.json相同的目录中使用npm install,这将恢复软件包。

  • 在最后一章中,您不必显式下载缺少的软件包。在构建项目时,Visual Studio 将恢复这些软件包。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support并注册,以便文件直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可以在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781789133042_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:"以下的tsconfig.json文件被使用"。

代码块设置如下:

{
  "compilerOptions": {
    "target": "ES2015", 
    "module": "commonjs", 
    "sourceMap": true, 
    "outDir": "./script", 
  }
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

{
  "compilerOptions": {
    "target": "ES2015", 
    "module": "commonjs", 
    "sourceMap": true, 
    "outDir": "./script", 
  }
}

任何命令行输入或输出都将按照以下方式编写:

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

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:"通常,Angular 用于创建单页应用程序SPA),在这种情况下,客户端的小部分会被更新,而不是在导航事件发生时重新加载整个页面。"

警告或重要说明会显示为这样。提示和技巧会显示为这样。

第一章:高级 TypeScript 功能

在本章中,我们将研究 TypeScript 的一些方面,超越了语言的基础知识。当适当使用时,这些功能提供了一种清晰直观的方式来使用 TypeScript,并将帮助您编写专业水平的代码。我们在这里涵盖的一些内容可能对您来说并不新鲜,但我包括它们是为了确保我们在后面的章节中有一个共同的知识基础,以及为什么我们将使用这些功能的理解。我们还将介绍为什么我们需要这些技术;仅仅知道如何应用某些东西是不够的,我们还需要知道在什么情况下应该使用它们以及在这样做时需要考虑什么。本章的重点不是创建一个枯燥的、详尽的功能列表,而是要介绍我们在本书的其余部分需要的信息。这些都是我们在日常开发中一遍又一遍应用的实用技术。

由于这是一本关于 Web 开发的书,我们还将创建许多 UI,因此我们将看看如何使用流行的 Bootstrap 框架创建吸引人的界面。

本章将涵盖以下主题:

  • 使用联合类型的不同类型

  • 使用交集类型组合类型

  • 使用类型别名简化类型声明

  • 使用 REST 属性解构对象

  • 使用 REST 处理可变数量的参数

  • 使用装饰器进行面向方面的编程 (AOP)

  • 使用混合类型组合类型

  • 使用相同的代码和不同的类型,并使用泛型

  • 使用映射映射值

  • 使用承诺和 async/await 创建异步代码

  • 使用 Bootstrap 创建 UI

技术要求

为了完成本章,您需要安装 Node.js。您可以从nodejs.org/en/下载并安装 Node.js。

您还需要安装 TypeScript 编译器。有两种方法可以通过 Node.js 使用Node Package Manager (NPM)来完成这个任务。如果您希望所有应用程序都使用相同版本的 TypeScript,并且确信它们在更新时都能运行在相同的版本上,请使用以下命令:

npm install -g typescript

如果您希望 TypeScript 的版本局限于特定项目,请在项目文件夹中输入以下内容:

npm install typescript --save-dev

对于代码编辑器,您可以使用任何合适的编辑器,甚至是基本的文本编辑器。在本书中,我将使用 Visual Studio Code,这是一个免费的跨平台集成开发环境 (IDE),可在code.visualstudio.com/上获得。

所有代码都可以在 GitHub 上找到github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter01

使用 tsconfig 构建未来的 TypeScript

随着 TypeScript 的流行,它受益于快速发展的开源架构。原始实现背后的设计目标意味着它已经成为开发人员的热门选择,无论是对于新手 JavaScript 开发者还是经验丰富的专业人士。这种流行意味着该语言迅速获得了新功能,有些功能简单直接,而其他功能则面向那些在 JavaScript 生态系统的前沿工作的开发人员。本章旨在介绍 TypeScript 引入的功能,以匹配当前或即将到来的 ECMAScript 实现,这些功能您可能之前没有遇到过。

随着我们在本章的进展,我会不时地指出需要较新 ECMAScript 标准的功能。在某些情况下,TypeScript 已经提供了一个与较早版本的 ECMAScript 兼容的功能的 poly-filled 实现。在其他情况下,我们编译的版本将具有一个功能,该功能无法在某一点之后进行回填,因此值得使用更更新的设置。

虽然可以完全使用命令行编译 TypeScript,但我更喜欢使用tsconfig.json。您可以手动创建此文件,也可以使用以下命令从命令行让 TypeScript 为您创建它:

tsc --init

如果您想复制我的设置,这些是我默认设置的设置。当我们需要更新引用时,我会指出需要添加的条目:

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "commonjs",
    "lib": [ "ES2015", "dom" ],
    "sourceMap": true,
    "outDir": "./script", 
    "strict": true, 
    "strictNullChecks": true, 
    "strictFunctionTypes": true, 
    "noImplicitThis": true, 
    "alwaysStrict": true, 
    "noImplicitReturns": true, 
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true,
    "experimentalDecorators": true, 
  }
}

介绍高级 TypeScript 功能

随着每个版本的发布,TypeScript 不断迈出重要的步伐,增加了功能和能力,这些功能和能力是建立在语言基础之上的,这些语言基础是在 1 版本中引入的。从那时起,JavaScript 已经发展,TypeScript 已经添加了一些功能,以便针对新兴标准,提供对旧版 JavaScript 的实现,或者在针对更新的 ECMA 标准时调用本地实现。在本章中,我们将看一些这些功能,这些功能将贯穿本书的整个内容。

使用联合类型与不同类型

我们要看的第一个功能是我最喜欢的功能之一,即使用联合类型的能力。当函数期望单个参数是一种类型或另一种类型时,就会使用这些类型。例如,假设我们有一个验证例程,需要检查值是否在特定范围内,这个验证可以从文本框中接收string值,也可以从计算中接收number值。由于解决这个问题的每种技术都有很多共同之处,我们将从一个简单的类开始,这个类允许我们指定形成我们范围的最小值和最大值,并且有一个实际执行验证的函数,如下所示:

class RangeValidationBase {
     constructor(private start : number, private end : number) { }
     protected RangeCheck(value : number) : boolean {
         return value >= this.start && value <= this.end;
     }
     protected GetNumber(value : string) : number {
        return new Number(value).valueOf();
     }
 }

如果您以前没有见过那样的constructor,那就相当于编写以下内容:

 private start : number = 0;
 private end : number = 0;
 constructor(start : number, end : number) {
     this.start = start;
     this.end = end;
 }

如果您需要检查参数或以某种方式操纵它们,您应该使用参数的扩展格式。如果您只是将值分配给私有字段,那么第一种格式是一种非常优雅的方式,可以节省代码的混乱。

有几种方法可以解决确保我们只使用stringnumber进行验证的问题。我们可以通过提供两个接受相关类型的单独方法来解决这个问题,如下所示:

class SeparateTypeRangeValidation extends RangeValidationBase {
     IsInRangeString(value : string) : boolean {
         return this.RangeCheck(this.GetNumber(value));
     }
     IsInRangeNumber(value : number) : boolean {
         return this.RangeCheck(value);
     }
 }

虽然这种技术可以工作,但它并不是非常优雅,而且它肯定没有充分利用 TypeScript 的强大功能。我们可以使用的第二种技术是允许我们传入值而不加以限制,如下所示:

class AnyRangeValidation extends RangeValidationBase {
     IsInRange(value : any) : boolean {
         if (typeof value === "number") {
             return this.RangeCheck(value);
         } else if (typeof value === "string") {
             return this.RangeCheck(this.GetNumber(value));
         }
         return false;
     }
 }

这绝对是对我们原始实现的改进,因为我们已经确定了函数的一个签名,这意味着调用代码更加一致。不幸的是,我们仍然可以将无效类型传递给方法,因此,如果我们传递boolean,这段代码将成功编译,但在运行时会失败。

如果我们想要限制我们的验证只接受字符串或数字,那么我们可以使用联合类型。它与上一个实现并没有太大的不同,但它确实给了我们编译时类型安全性,这正是我们想要的,如下所示:

class UnionRangeValidation extends RangeValidationBase {
     IsInRange(value : string | number) : boolean {
         if (typeof value === "number") {
             return this.RangeCheck(value);
         }
         return this.RangeCheck(this.GetNumber(value));
     }
 }

标识类型约束为联合的签名是函数名称中的type | type。这告诉编译器(和我们)这种方法的有效类型是什么。因为我们已经限制了输入为numberstring,所以一旦我们排除了类型不是number,我们就不需要检查typeof来查看它是否是string,所以我们甚至进一步简化了代码。

我们可以在联合语句中链接尽可能多的类型。实际上没有实际限制,但我们必须确保联合列表中的每种类型都需要相应的typeof检查,如果我们要正确处理它。类型的顺序也不重要,所以number | stringstring | number是相同的。但要记住的是,如果函数将许多类型组合在一起,那么它可能做得太多了,应该查看代码,看看是否可以将其分解成更小的部分。

我们可以进一步使用联合类型。在 TypeScript 中,我们有两种特殊类型,nullundefined。除非我们使用-strictNullChecks选项编译我们的代码,或者如果我们在tsconfig.json文件中将其设置为strictNullChecks = true,否则这些类型可以分配给任何东西。我喜欢设置这个值,这样我的代码只处理应该处理的空值情况,这是防止副作用潜入的好方法,只是因为一个函数接收了一个空值。如果我们想允许null(或undefined),我们只需要将它们添加为联合类型。

使用交集类型组合类型

有时,对我们来说很重要的是,我们有能力处理一种情况,即我们可以将多种类型合并在一起,并将它们视为一种类型。交集类型是正在合并的每种类型中都可用的所有属性的类型。我们可以通过以下简单的示例看到交集的样子。首先,我们将为GridMargin创建类,如下所示:

class Grid {
     Width : number = 0;
     Height : number = 0;
 }
 class Margin {
     Left : number = 0;
     Top : number = 0;
 }

我们要创建的是一个交集,最终会得到Grid属性的WidthHeight,以及MarginLeftTop。为此,我们将创建一个函数,该函数接受GridMargin,并返回一个包含所有这些属性的类型,如下所示:

function ConsolidatedGrid(grid : Grid, margin : Margin) : Grid & Margin {
     let consolidatedGrid = <Grid & Margin>{};
     consolidatedGrid.Width = grid.Width;
     consolidatedGrid.Height = grid.Height;
     consolidatedGrid.Left = margin.Left;
     consolidatedGrid.Top = margin.Top;
     return consolidatedGrid;
 }

请注意,当我们在本章后面查看对象扩展时,我们将回到这个函数,看看如何消除大量属性的样板复制。

使这项工作的魔法是我们如何定义consolidatedGrid。我们使用&来连接我们想要使用的类型,以创建我们的交集。因为我们想要将GridMargin合并在一起,所以我们使用<Grid & Margin>来告诉编译器我们的类型将是什么样子。我们可以看到,我们不必明确命名这种类型;编译器足够聪明,可以为我们处理这个问题。

如果我们在两种类型中都有相同的属性,会发生什么?TypeScript 是否会阻止我们混合这些类型?只要属性是相同类型,TypeScript 就可以完全允许我们使用相同的属性名称。为了看到这一点,我们将扩展我们的Margin类,以包括WidthHeight属性,如下所示:

class Margin {
     Left : number = 0;
     Top : number = 0;
     Width : number = 10;
     Height : number = 20;
 }

我们如何处理这些额外的属性取决于我们想要做什么。在我们的示例中,我们将MarginWidthHeight添加到GridWidthHeight中。这样,我们的函数看起来像这样:

function ConsolidatedGrid(grid : Grid, margin : Margin) : Grid & Margin {
     let consolidatedGrid = <Grid & Margin>{};
     consolidatedGrid.Width = grid.Width + margin.Width;
     consolidatedGrid.Height = grid.Height + margin.Height;
     consolidatedGrid.Left = margin.Left;
     consolidatedGrid.Top = margin.Top;
     return consolidatedGrid;
 }

然而,如果我们想要尝试并重用相同的属性名称,但这些属性的类型不同,如果这些类型有限制,我们可能会遇到问题。为了看到这种影响,我们将扩展我们的GridMargin类以包括Weight。我们的Grid类中的Weight是一个数字,而我们的Margin类中的Weight是一个字符串,如下所示:

class Grid {
     Width : number = 0;
     Height : number = 0;
     Weight : number = 0;
 }
 class Margin {
     Left : number = 0;
     Top : number = 0;
     Width : number = 10;
     Height : number = 20;
     Weight : string = "1";
 }

我们将尝试在我们的ConsolidatedGrid函数中将Weight类型相加:

consolidatedGrid.Weight = grid.Weight + new          
    Number(margin.Weight).valueOf();

此时,TypeScript 会对这行代码进行以下错误提示:

error TS2322: Type 'number' is not assignable to type 'number & string'.
   Type 'number' is not assignable to type 'string'.

虽然有解决这个问题的方法,比如在Grid中使用联合类型来解析输入的Weight,但通常不值得那么麻烦。如果类型不同,这通常是属性行为不同的一个很好的指示,所以我们真的应该考虑给它取一个不同的名字。

虽然我们在这里的示例中使用类,但值得指出的是,交集不仅限于类。交集也适用于接口、泛型和原始类型。

在处理交集时,还有一些其他规则需要考虑。如果我们有相同的属性名称,但只有一个属性是可选的,那么最终的属性将是必需的。我们将在GridMargin类中引入一个padding属性,并在Margin中将Padding设为可选,如下所示:

class Grid {
     Width : number = 0;
     Height : number = 0;
     Padding : number;
 }
 class Margin {
     Left : number = 0;
     Top : number = 0;
     Width : number = 10;
     Height : number = 20;
     Padding?: number;
 }

因为我们提供了一个强制的Padding变量,我们不能改变我们的交集,如下所示:

consolidatedGrid.Padding = margin.Padding;

由于不能保证边距填充会被分配,编译器会尽力阻止我们。为了解决这个问题,我们将改变我们的代码,如果设置了margin填充,则应用margin填充,如果没有,则回退到grid填充。为了做到这一点,我们将做一个简单的修复:

consolidatedGrid.Padding = margin.Padding ? margin.Padding : grid.Padding;

这种看起来奇怪的语法被称为三元运算符。这是一种简写的方式,相当于写成以下形式——如果margin.Padding有值,则让consolidatedGrid.Padding等于该值;否则,让它等于grid.Padding。这本可以写成 if/else 语句,但是,由于这是 TypeScript 和 JavaScript 等语言中的常见范例,值得熟悉。

使用类型别名简化类型声明

与交集类型和联合类型相辅相成的是类型别名。TypeScript 允许我们创建一个方便的别名,而不是在代码中引用string | number | null,这个别名会被编译器展开成相关的代码。

假设我们想创建一个代表string | number联合类型的类型别名,那么我们可以创建一个如下所示的别名:

type StringOrNumber = string | number;

如果我们重新审视我们的范围验证示例,我们可以更改函数的签名以使用这个别名,如下所示:

class UnionRangeValidationWithTypeAlias extends RangeValidationBase {
     IsInRange(value : StringOrNumber) : boolean {
         if (typeof value === "number") {
             return this.RangeCheck(value);
         }
         return this.RangeCheck(this.GetNumber(value));
     }
 }

在这段代码中需要注意的重要事情是,我们并没有真正创建任何新类型。类型别名只是一个语法技巧,我们可以用它来使我们的代码更易读,更重要的是,帮助我们创建更一致的代码,尤其是在大型团队中工作时。

我们还可以将类型别名与类型结合起来创建更复杂的类型别名。如果我们想要为之前的类型别名添加null支持,我们可以添加这个类型:

type NullableStringOrNumber = StringOrNumber | null;

由于编译器仍然看到了底层类型并使用它,我们可以使用以下语法来调用我们的IsInRange方法:

let total : string | number = 10;
if (new UnionRangeValidationWithTypeAlias(0,100).IsInRange(total)) {
    console.log(`This value is in range`);
}

显然,这样做不会给我们带来非常一致的代码,所以我们可以将string | number改为StringOrNumber

使用对象展开分配属性

交集类型部分的ConsolidatedGrid示例中,我们分别将每个属性分配给了我们的交集。根据我们试图实现的效果,我们还可以用另一种方式用更少的代码创建我们的<Grid & Margin>交集类型。使用展开运算符,我们可以自动从一个或多个输入类型中复制属性的浅层副本。

首先,让我们看看如何重写之前的例子,以便自动填充边距信息:

function ConsolidatedGrid(grid : Grid, margin : Margin) : Grid  & Margin {
    let consolidatedGrid = <Grid & Margin>{...margin};
    consolidatedGrid.Width += grid.Width;
    consolidatedGrid.Height += grid.Height;
    consolidatedGrid.Padding = margin.Padding ? margin.Padding : 
    grid.Padding;
    return consolidatedGrid;
}

当我们实例化我们的consolidatedGrid函数时,这段代码会复制margin的属性并填充它们。三个点(...)告诉编译器将其视为展开操作。由于我们已经填充了WidthHeight,我们使用+=来简单地添加网格中的元素。

如果我们想要同时应用gridmargin的值呢?为了做到这一点,我们可以将我们的实例化更改为如下所示:

let consolidatedGrid = <Grid & Margin>{…grid, ...margin};

这将Grid的值填充到grid的值中,然后将Margin的值填充到margin的值中。这告诉我们两件事。第一,扩展操作将适当的属性映射到适当的属性。第二,这告诉我们它执行的顺序很重要。由于margingrid都具有相同的属性,grid设置的值将被margin设置的值覆盖。为了设置属性,以便我们在WidthHeight中看到grid的值,我们必须颠倒这行的顺序。当然,实际上,我们可以看到效果如下:

let consolidatedGrid = <Grid & Margin>{...margin, …grid };

在这个阶段,我们应该真正看一下 TypeScript 从中产生的 JavaScript。当我们使用 ES5 编译它时,代码看起来像这样:

var __assign = (this && this.__assign) || function () {
    __assign = Object.assign || function(t) {
        for (var s, i = 1, n = arguments.length; i < n; i++) {
            s = arguments[i];
            for (var p in s) if (Object.prototype.hasOwnProperty.call(s,
            p))
                t[p] = s[p];
        }
        return t;
    };
    return __assign.apply(this, arguments);
};
function ConsolidatedGrid(grid, margin) {
    var consolidatedGrid = __assign({}, margin, grid);
    consolidatedGrid.Width += grid.Width;
    consolidatedGrid.Height += grid.Height;
    consolidatedGrid.Padding = margin.Padding ? margin.Padding : 
    grid.Padding;
    return consolidatedGrid;
}

然而,如果我们使用 ES2015 或更高版本编译代码,__assign函数将被移除,我们的ConsolidatedGrid JavaScript 看起来如下:

function ConsolidatedGrid(grid, margin) {
    let consolidatedGrid = Object.assign({}, margin, grid);
    consolidatedGrid.Width += grid.Width;
    consolidatedGrid.Height += grid.Height;
    consolidatedGrid.Padding = margin.Padding ? margin.Padding : 
    grid.Padding;
    return consolidatedGrid;
}

我们在这里看到的是,TypeScript 努力确保它可以生成无论我们针对的 ECMAScript 版本是哪个都能工作的代码。我们不必担心该功能是否可用;我们把这个问题留给 TypeScript 来填补空白。

使用 REST 属性解构对象

在构建对象时,我们使用扩展运算符,我们也可以使用 REST 属性解构对象。解构简单地意味着我们要把一个复杂的东西分解成更简单的东西。换句话说,解构发生在我们将数组或对象的属性中的元素分配给单独的变量时。虽然我们一直能够将复杂的对象和数组分解为更简单的类型,但 TypeScript 提供了一种干净而优雅的方式,使用 REST 参数来分解这些类型,可以解构对象和数组。

为了理解 REST 属性是什么,我们首先需要了解如何解构对象或数组。我们将从解构以下对象文字开始,如下所示:

let guitar = { manufacturer: 'Ibanez', type : 'Jem 777', strings : 6 };

我们可以通过以下方式解构这个对象:

const manufacturer = guitar.manufacturer;
const type = guitar.type;
const strings = guitar.strings;

虽然这样可以工作,但不够优雅,而且有很多重复。幸运的是,TypeScript 采用了 JavaScript 的语法,用于像这样简单的解构,提供了一个更整洁的语法:

let {manufacturer, type, strings} = guitar;

从功能上讲,这导致与原始实现相同的单独项目。单个属性的名称必须与我们解构的对象中的属性的名称匹配——这就是语言知道哪个变量与对象上的哪个属性匹配的方式。如果我们因某种原因需要更改属性的名称,我们使用以下语法:

let {manufacturer : maker, type, strings} = guitar;

对象上的 REST 运算符的想法是,当你获取可变数量的项目时,它适用于对象,因此我们将这个对象解构为制造商,其他字段将被捆绑到 REST 变量中,如下所示:

let { manufacturer, ...details } = guitar;

REST 运算符必须出现在赋值列表的末尾;如果我们在它之后添加任何属性,TypeScript 编译器会抱怨。

在这个语句之后,details现在包含了类型和字符串值。有趣的地方在于我们看一下生成的 JavaScript。在前面的例子中,解构的形式在 JavaScript 中是相同的。在 JavaScript 中没有 REST 属性的等价物(至少在 ES2018 之前的版本中没有),因此 TypeScript 为我们生成了代码,让我们以一种一致的方式解构更复杂的类型:

// Compiled as ES5
var manufacturer = guitar.manufacturer, details = __rest(guitar, ["manufacturer"]);
var __rest = (this && this.__rest) || function (s, e) {
    var t = {};
    for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && 
    e.indexOf(p) < 0)
        t[p] = s[p];
    if (s != null && typeof Object.getOwnPropertySymbols === "function")
        for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length;
        i++) if (e.indexOf(p[i]) < 0)
            t[p[i]] = s[p[i]];
    return t;
};

数组解构与对象解构类似。语法与对象版本几乎相同;不同之处在于它使用[]来解构,而对象版本使用{},以及变量的顺序是基于数组中项目的位置。

解构数组的原始方法依赖于将变量与数组中特定索引处的项目关联起来:

const instruments = [ 'Guitar', 'Violin', 'Oboe', 'Drums' ];
const gtr = instruments[0];
const violin = instruments[1];
const oboe = instruments[2];
const drums = instruments[3];

使用数组解构,我们可以将此语法更改为更简洁的形式,如下所示:

let [ gtr, violin, oboe, drums ] = instruments;

知道 TypeScript 团队擅长为我们提供一致和逻辑的体验,应该不会让人感到意外,我们也可以使用类似的语法将 REST 属性应用于数组:

let [gtr, ...instrumentslice] = instruments;

再次强调,没有直接的 JavaScript 等价物,但编译后的 TypeScript 显示 JavaScript 确实提供了基本原理,TypeScript 设计者能够优雅地使用array.slice进行整合。

// Compiled as ES5
var gtr = instruments[0], instrumentslice = instruments.slice(1);

使用 REST 处理可变数量的参数

关于 REST 我们需要看的最后一件事是函数具有 REST 参数的概念。这些与 REST 属性不同,但语法非常相似,我们应该很容易掌握。REST 参数解决的问题是处理传递给函数的可变数量的参数。在函数中识别 REST 参数的方法是它前面有省略号,并且它被定义为数组。

在这个例子中,我们将记录一个标题,然后是可变数量的instruments

function PrintInstruments(log : string, ...instruments : string[]) : void {
    console.log(log);
    instruments.forEach(instrument => {
        console.log(instrument);
    });
}
PrintInstruments('Music Shop Inventory', 'Guitar', 'Drums', 'Clarinet', 'Clavinova');

由于 REST 参数是一个数组,这使我们可以直接从中执行forEach等操作。重要的是,REST 参数与 JavaScript 函数内的 arguments 对象不同,因为它们从参数列表中未命名的值开始,而 arguments 对象包含所有参数的列表。

由于 ES5 中没有 REST 参数,TypeScript 会提供必要的工作来提供模拟 REST 参数的 JavaScript。首先,我们将看到编译为 ES5 时的情况,如下所示:

function PrintInstruments(log) {
    var instruments = [];
    // As our rest parameter starts at the 1st position in the list of 
    // arguments,
    // our index starts at 1.
    for (var _i = 1; _i < arguments.length; _i++) {
        instruments[_i - 1] = arguments[_i];
    }
    console.log(log);
    instruments.forEach(function (instrument) {
        console.log(instrument);
    });
}

当我们查看从 ES2015 编译生成的 JavaScript 时(您需要在tsconfig.json文件中将目标更改为 ES2015),我们看到它看起来与我们的 TypeScript 代码完全相同:

function PrintInstruments(log, ...instruments) {
    console.log(log);
    instruments.forEach(instrument => {
        console.log(instrument);
    });
}

在这一点上,我无法再强调查看生成的 JavaScript 有多么重要。TypeScript 非常擅长隐藏复杂性,但我们确实应该熟悉生成的内容。我发现这是了解底层发生了什么的好方法,尽可能使用不同版本的 ECMAScript 标准进行编译,并查看生成的代码。

使用装饰器的 AOP

在 TypeScript 中我最喜欢的功能之一是使用装饰器。装饰器作为一项实验性功能被引入,它们是我们可以使用的代码片段,用于修改单个类的行为,而无需更改类的内部实现。通过这个概念,我们可以调整现有类的行为,而无需对其进行子类化。

如果您从 Java 或 C#等语言转到 TypeScript,您可能会注意到装饰器看起来很像一种称为 AOP 的技术。AOP 技术提供给我们的是通过跨越代码并将其分离到不同位置来提取重复代码的能力。这意味着我们不必在实现中散布大量基本代码,但这些代码在运行应用程序中必须存在。

解释装饰器的最简单方法是从一个例子开始。假设我们有一个类,只有特定角色的用户才能访问某些方法,如下所示:

interface IDecoratorExample {
    AnyoneCanRun(args:string) : void;
    AdminOnly(args:string) : void;
}
class NoRoleCheck implements IDecoratorExample {
    AnyoneCanRun(args: string): void {
        console.log(args);
    }   
    AdminOnly(args: string): void {
        console.log(args);
    }
}

现在,我们将创建一个具有adminuser角色的用户,这意味着在这个类中调用两种方法都没有问题:

let currentUser = {user: "peter", roles : [{role:"user"}, {role:"admin"}] };
function TestDecoratorExample(decoratorMethod : IDecoratorExample) {
    console.log(`Current user ${currentUser.user}`);
    decoratorMethod.AnyoneCanRun(`Running as user`);
    decoratorMethod.AdminOnly(`Running as admin`);       
}
TestDecoratorExample(new NoRoleCheck());

这给我们我们期望的输出,如下所示:

Current user Peter
Running as user
Running as admin

如果我们创建一个只有user角色的用户,我们期望他们不应该能够运行只有管理员才能运行的代码。由于我们的代码没有角色检查,无论用户分配了什么角色,AdminOnly方法都将被运行。修复这段代码的一种方法是添加代码来检查权限,然后将其添加到每个方法中。

首先,我们将创建一个简单的函数来检查当前用户是否属于特定角色:

function IsInRole(role : string) : boolean {
    return currentUser.roles.some(r => r.role === role);
}

重新审视我们现有的实现,我们将改变我们的函数来调用这个检查,并确定user是否被允许运行该方法:

AnyoneCanRun(args: string): void {
    if (!IsInRole("user")) {
        console.log(`${currentUser.user} is not in the user role`);
        return;
    };
    console.log(args);
}   
AdminOnly(args: string): void {
    if (!IsInRole("admin")) {
        console.log(`${currentUser.user} is not in the admin role`);
    };
    console.log(args);
}

当我们看这段代码时,我们可以看到这里有很多重复的代码。更糟糕的是,虽然我们有重复的代码,但在这个实现中有一个 bug。在AdminOnly代码中,在IsInRole块内没有返回语句,所以代码仍然会运行AdminOnly代码,但它会告诉我们用户不在admin角色中,然后无论如何输出消息。这突显了重复代码的一个问题:很容易引入微妙(或不那么微妙)的 bug 而不自知。最后,我们违反了良好的面向对象OO)开发实践的基本原则之一。我们的类和方法正在做它们不应该做的事情;代码应该只做一件事,所以检查角色不属于那里。在第二章,使用 TypeScript 创建 Markdown 编辑器,当我们更深入地探讨面向对象开发思维方式时,我们将更深入地讨论这个问题。

让我们看看如何使用方法装饰器来消除样板代码并解决单一职责问题。

在编写代码之前,我们需要确保 TypeScript 知道我们将使用装饰器,这是一个实验性的 ES5 功能。我们可以通过在命令行中运行以下命令来做到这一点:

tsc --target ES5 --experimentalDecorators

或者,我们可以在我们的tsconfig文件中设置这一点:

"compilerOptions": {
        "target": "ES5",
// other parameters….
        "experimentalDecorators": true
    }

启用了装饰器构建功能后,我们现在可以编写我们的第一个装饰器,以确保用户属于admin角色:

function Admin(target: any, propertyKey : string | symbol, descriptor : PropertyDescriptor) {
        let originalMethod = descriptor.value;
        descriptor.value = function() {
            if (IsInRole(`admin`)) {
                originalMethod.apply(this, arguments);
                return;
            }
            console.log(`${currentUser.user} is not in the admin role`);
        }
        return descriptor;
    }

每当我们看到一个函数定义看起来类似于这样的,我们知道我们正在看一个方法装饰器。TypeScript 期望按照这个顺序精确地使用这些参数:

function …(target: any, propertyKey : string | symbol, descriptor : PropertyDescriptor)

第一个参数用于引用我们正在应用的元素。第二个参数是元素的名称,最后一个参数是我们要应用装饰器的方法的描述符;这允许我们改变方法的行为。我们必须有一个具有这个签名的函数作为我们的装饰器。

let originalMethod = descriptor.value;
descriptor.value = function() {
    ...
}
return descriptor;

装饰器方法的内部并不像它们看起来那么可怕。我们所做的是从描述符中复制原始方法,然后用我们自己的自定义实现替换该方法。这个包装的实现被返回,并且在我们遇到它时将被执行的代码:

if (IsInRole(`admin`)) {
    originalMethod.apply(this, arguments);
    return;
}
console.log(`${currentUser.user} is not in the admin role`);

在我们的包装实现中,我们正在执行相同的角色检查。如果检查通过,我们应用原始方法。通过使用这样的技术,我们已经添加了一些东西,可以以一致的方式避免调用我们的方法,如果不需要的话。

为了应用这个,我们在我们的装饰器工厂函数名字前面使用@,就在我们的类的方法之前。当我们添加我们的装饰器时,我们必须避免在它和方法之间加上分号,如下所示:

class DecoratedExampleMethodDecoration implements IDecoratorExample {
    AnyoneCanRun(args:string) : void {
        console.log(args);
    }
    @Admin
    AdminOnly(args:string) : void {
        console.log(args);
    }
}

虽然这段代码对于AdminOnly代码来说是有效的,但它并不特别灵活。随着我们添加更多的角色,我们将不得不添加越来越多几乎相同的函数。如果我们能有一种方法来创建一个通用函数,我们可以用它来返回一个接受设置我们想要允许的角色的参数的装饰器。幸运的是,我们可以使用一种叫做装饰器工厂的东西来做到这一点。

简而言之,TypeScript 装饰器工厂是一个可以接收参数并使用这些参数返回实际装饰器的函数。我们的代码只需要进行一些微小的调整,就可以得到一个可以指定我们想要保护的角色的工作工厂:

function Role(role : string) {
    return function(target: any, propertyKey : string | symbol, descriptor 
    : PropertyDescriptor) {
        let originalMethod = descriptor.value;
        descriptor.value = function() {
            if (IsInRole(role)) {
                originalMethod.apply(this, arguments);
                return;
            }
            console.log(`${currentUser.user} is not in the ${role} role`);
        }
        return descriptor;
    }
}

这里唯一的真正区别是我们有一个返回装饰器的函数,这个函数不再有名字,工厂函数参数被用在我们的装饰器内部。现在我们可以改变我们的类来使用这个工厂:

class DecoratedExampleMethodDecoration implements IDecoratorExample {
    @Role("user") // Note, no semi-colon
    AnyoneCanRun(args:string) : void {
        console.log(args);
    }
    @Role("admin")
    AdminOnly(args:string) : void {
        console.log(args);
    }
}

通过这种改变,当我们调用我们的方法时,只有管理员才能访问AdminOnly方法,而任何用户都可以调用AnyoneCanRun。一个重要的副作用是,我们的装饰器只适用于类内部。我们不能在独立的函数上使用它。

我们之所以称这种技术为装饰器,是因为它遵循了一种叫做装饰器模式的东西。这种模式认识到一种用于向单个对象添加行为而不影响同一类的其他对象并且不必创建子类的技术。模式只是对软件工程中常见问题的正式化解决方案,因此这些名称作为描述功能上发生的事情的有用缩写。也许不会讦知道还有一种工厂模式。当我们阅读本书时,我们将遇到其他模式的例子,因此当我们到达末尾时,我们将能够自如地使用它们。

我们也可以将装饰器应用到类中的其他项目上。例如,如果我们想要防止未经授权的用户甚至实例化我们的类,我们可以定义一个类装饰器。类装饰器被添加到类定义中,并期望接收构造函数作为函数。这是我们从工厂创建的构造函数装饰器的样子:

function Role(role : string) {
    return function(constructor : Function) {
        if (!IsInRole (role)) {
            throw new Error(`The user is not authorized to access this class`);
        }
    }
}

当我们应用这个时,我们遵循相同的格式,使用@前缀,所以当代码尝试为非管理员用户创建这个类的新实例时,应用程序会抛出错误,阻止这个类被创建:

@Role ("admin")
class RestrictedClass {
    constructor() {
        console.log(`Inside the constructor`);
    }
    Validate() {
        console.log(`Validating`);
    }
}

我们可以看到我们没有在类内声明任何装饰器。我们应该总是将它们创建为顶级函数,因为它们的用法不适合装饰一个类,所以我们不会看到诸如@MyClass.Role("admin");这样的语法。

除了构造函数和方法的装饰,我们还可以装饰属性、访问器等等。我们不会在这里详细介绍,但它们将在本书的后面出现。我们还将看看如何将装饰器链接在一起,以便我们有以下的语法:

@Role ("admin")
@Log(“Creating RestrictedClass”)
class RestrictedClass {
    constructor() {
        console.log(`Inside the constructor`);
    }
    Validate() {
        console.log(`Validating`);
    }
}

使用混合类型进行组合

当我们首次接触经典的面向对象理论时,我们会遇到类可以被继承的概念。这里的想法是我们可以从通用类创建更加专业化的类。其中一个更受欢迎的例子是我们有一个包含有关车辆基本细节的车辆类。我们从vehicle类继承,创建一个car类。然后我们从car类继承,创建一个sports car类。这里每一层继承都添加了在我们继承的类中不存在的特性。

总的来说,这对我们来说是一个简单的概念,但是当我们想要将两个或更多看似无关的事物结合起来编写我们的代码时会发生什么呢?让我们来看一个简单的例子。

数据库应用程序中常见的一件事是存储记录是否已被删除,而不实际删除记录,并记录记录上次更新的时间。乍一看,似乎我们希望在个人数据实体中跟踪这些信息。但我们可能不是将这些信息添加到每个数据实体中,而是创建一个包含这些信息的基类,然后从中继承:

class ActiveRecord {
    Deleted = false;
}
class Person extends ActiveRecord {
    constructor(firstName : string, lastName : string) {
        this.FirstName = firstName;
        this.LastName = lastName;
    }

    FirstName : string;
    LastName : string;
} 

这种方法的第一个问题是,它混合了有关记录状态的详细信息和实际记录本身。随着我们在接下来的几章中进一步深入 OO 设计,我们将不断强调这样混合物的想法并不是一个好主意,因为我们正在创建必须执行多个任务的类,这可能会使它们不够健壮。这种方法的另一个问题是,如果我们想要添加记录更新日期,我们要么必须将更新日期添加到ActiveRecord中,这意味着每个扩展ActiveRecord的类也将获得更新日期,要么我们必须创建一个新类,添加更新日期并将其添加到我们的层次结构链中,这意味着我们不能有没有删除字段的更新字段。

尽管继承确实有其用武之地,但近年来,将对象组合在一起以创建新对象的想法日益突出。这种方法的理念是我们构建不依赖于继承链的离散元素。如果我们重新审视我们的人员实现,我们将使用一种称为混合物的功能来构建相同的功能。

我们需要做的第一件事是定义一个类型,它将作为我们混合物的合适构造函数。我们可以给这种类型取任何名字,但在 TypeScript 中,围绕混合物演变出来的约定是使用以下类型:

type Constructor<T ={}> = new(...args: any[]) => T;

这种类型定义为我们提供了一些可以扩展以创建我们专门的混合物的东西。这种奇怪的语法有效地表示,给定任何特定类型,将使用任何适当的参数创建一个新实例。

这是我们的记录状态实现:

function RecordStatus<T extends Constructor>(base : T) {
    return class extends base {
        Deleted : boolean = false;
    }
}

RecordStatus函数通过返回一个扩展构造函数实现的新类来扩展Constructor类型。在这里,我们添加了我们的Deleted标志。

将这两种类型合并或混合在一起,我们只需执行以下操作:

const ActivePerson = RecordStatus(Person);

这已经创建了我们可以使用来创建具有RecordStatus属性的Person对象的东西。它实际上还没有实例化任何对象。为了做到这一点,我们以与任何其他类型相同的方式实例化信息:

let activePerson = new ActivePerson("Peter", "O'Hanlon");
activePerson.Deleted = true;

现在,我们还想添加有关记录上次更新时间的详细信息。我们创建另一个混合物,如下所示:

function Timestamp<T extends Constructor>(base : T) {
 return class extends base {
   Updated : Date = new Date();
 }
}

要将此添加到ActivePerson,我们更改定义以包括Timestamp。无论我们首先放置哪个混合物,无论是Timestamp还是RecordStatus

const  ActivePerson  =  RecordStatus(Timestamp(Person));

除了属性,我们还可以向我们的混合物添加构造函数和方法。我们将把我们的RecordStatus函数更改为在记录被删除时记录日志。为此,我们将把我们的Deleted属性转换为一个 getter 方法,并添加一个新的方法来执行删除:

function RecordStatus<T extends Constructor>(base : T) {
    return class extends base {
        private deleted : boolean = false;
        get Deleted() : boolean {
            return this.deleted;
        }
        Delete() : void {
            this.deleted = true;
            console.log(`The record has been marked as deleted.`);
        }
    }
}

关于使用这种混合技术的警告。它们是一种很好的技术,可以整洁地做一些非常有用的事情,但除非我们放宽参数限制到任意,否则我们不能将它们作为参数传递。这意味着我们不能使用这样的代码:

function DeletePerson(person : ActivePerson) {
     person.Delete();
}

如果我们查看 TypeScript 文档中有关混合物的部分,我们会发现语法看起来非常不同。与处理这种方法的所有固有限制相比,我们将坚持这里的方法,这是我在basarat.gitbooks.io/typescript/docs/types/mixins.html首次接触到的。

使用相同的代码和不同的类型以及使用泛型

当我们在 TypeScript 中首次开始开发类时,很常见的是我们反复编写相同的代码,只是改变我们依赖的类型。例如,如果我们想存储整数队列,我们可能会写以下类:

class QueueOfInt {
    private queue : number[]= [];

    public Push(value : number) : void {
        this.queue.push(value);
    }

    public Pop() : number | undefined {
        return this.queue.shift();
    }
}

调用这段代码就像这样简单:

const intQueue : QueueOfInt = new QueueOfInt();
intQueue.Push(10);
intQueue.Push(35);
console.log(intQueue.Pop()); // Prints 10
console.log(intQueue.Pop()); // Prints 35

后来,我们决定还需要创建一个字符串队列,所以我们也添加了相应的代码:

class QueueOfString {
    private queue : string[]= [];

    public Push(value : string) : void {
        this.queue.push(value);
    }

    public Pop() : string | undefined {
        return this.queue.shift();
    }
}

很容易看出,我们添加的这些代码越多,我们的工作就变得越繁琐,错误也就越多。假设我们忘记在其中一个实现中放置了 shift 操作。shift 操作允许我们从数组中删除第一个元素并返回它,这给了我们队列的核心行为(队列按照先进先出(或FIFO)的原则运行)。如果我们忘记了 shift 操作,我们实际上实现了一个堆栈操作(后进先出(或LIFO))。这可能导致代码中出现微妙且危险的错误。

通过泛型,TypeScript 为我们提供了创建所谓的泛型的能力,这是一种使用占位符来表示正在使用的类型的类型。调用泛型的代码负责确定它们接受的类型。我们可以通过在类名后面的<>内或在方法名后面出现的泛型来识别泛型。如果我们重写我们的队列以使用泛型,我们将看到这意味着什么:

class Queue<T> {
    private queue : T[]= [];

    public Push(value : T) : void {
        this.queue.push(value);
    }

    public Pop() : T | undefined {
        return this.queue.shift();
    }
}

让我们来分解一下:

class Queue<T> {
}

在这里,我们创建了一个名为Queue的类,它接受任何类型。<T>语法告诉 TypeScript,每当它在这个类内部看到T时,它指的是传递进来的类型:

private queue : T[]= [];

这是泛型类型首次出现的实例。编译器将使用泛型类型来创建数组,而不是将数组固定为特定类型:

public Push(value : T) : void {
    this.queue.push(value);
}

public Pop() : T | undefined {
    return this.queue.shift();
}

再次,我们用泛型替换了代码中的具体类型。请注意,TypeScript 很乐意在Pop方法中使用undefined关键字。

改变我们使用代码的方式,我们现在可以告诉我们的Queue对象我们想要应用的类型:

const queue : Queue<number> = new Queue<number>();
const stringQueue : Queue<string> = new Queue<string>();
queue.Push(10);
queue.Push(35);
console.log(queue.Pop());
console.log(queue.Pop());
stringQueue.Push(`Hello`);
stringQueue.Push(`Generics`);
console.log(stringQueue.Pop());
console.log(stringQueue.Pop());

特别有帮助的是,TypeScript 在引用的任何地方都强制执行我们分配的类型,因此,如果我们尝试向我们的queue变量添加一个字符串,TypeScript 将无法编译这个代码。

尽管 TypeScript 尽力保护我们,但我们必须记住它会转换为 JavaScript。这意味着它无法保护我们的代码免受滥用,因此,尽管 TypeScript 强制执行我们分配的类型,如果我们编写了调用我们泛型类型的外部 JavaScript,就没有任何东西可以阻止添加不受支持的值。泛型仅在编译时强制执行,因此,如果我们的代码将被外部调用,我们应该采取措施防止代码中出现不兼容的类型。

我们不仅限于在泛型列表中只有一个类型。只要它们具有唯一的名称,泛型允许我们在定义中指定任意数量的类型,如下所示:

function KeyValuePair<TKey, TValue>(key : TKey, value : TValue)

敏锐的读者会注意到我们已经遇到了泛型。当我们创建一个 mixin 时,我们在我们的Constructor类型中使用了泛型。

如果我们想从我们的泛型中调用特定的方法会发生什么?由于 TypeScript 希望知道类型的底层实现,它对我们可以做什么非常严格。这意味着以下代码是不可接受的:

interface IStream {
    ReadStream() : Int8Array; // Array of bytes
}
class Data<T> {
    ReadStream(stream : T) {
        let output = stream.ReadStream();
        console.log(output.byteLength);
    }
}

由于 TypeScript 无法猜测我们想在这里使用IStream接口,如果我们尝试编译这段代码,它会报错。幸运的是,我们可以使用泛型约束告诉 TypeScript 我们有一个特定的类型要在这里使用:

class Data<T extends IStream> {
    ReadStream(stream : T) {
        let output = stream.ReadStream();
        console.log(output.byteLength);
    }
}

<T extends IStream>部分告诉 TypeScript,我们将使用基于我们的IStream接口的任何类。

虽然我们可以将泛型限制为类型,但通常我们会希望将泛型限制为接口。这使我们在约束中使用的类具有很大的灵活性,并且不会强加我们只能使用从特定基类继承的类的限制。

要看到这个动作,我们将创建两个实现IStream的类:

class WebStream implements IStream {
    ReadStream(): Int8Array {
        let array : Int8Array = new Int8Array(8);
        for (let index : number = 0; index < array.length; index++){
            array[index] = index + 3; 
        }
        return array;
    }
}
class DiskStream implements IStream {
    ReadStream(): Int8Array {
        let array : Int8Array = new Int8Array(20); 
        for (let index : number = 0; index < array.length; index++){
            array[index] = index + 3;
        }
        return array;
    }
}

这些现在可以用作我们的通用Data实现中的类型约束:

const webStream = new Data<WebStream>();
const diskStream = new Data<DiskStream>();

我们刚刚告诉webStreamdiskStream它们将可以访问我们的类。要使用它们,我们仍然必须传递一个实例,如下所示:

webStream.ReadStream(new WebStream());
diskStream.ReadStream(new DiskStream());

虽然我们在类级别声明了我们的泛型及其约束,但我们不必这样做。如果需要,我们可以在方法级别声明更精细的泛型。不过,在这种情况下,如果我们想要在代码中的多个地方引用该泛型类型,将其作为类级别泛型是有意义的。如果我们只想在一个或两个方法中应用特定的泛型,我们可以将我们的类签名更改为这样:

class Data {
    ReadStream<T extends IStream>(stream : T) {
        let output = stream.ReadStream();
        console.log(output.byteLength);
    }
}

使用地图映射值

经常出现的情况是需要使用一个容易查找的键存储多个项目。例如,假设我们有一个按流派分类的音乐收藏:

enum Genre {
    Rock,
    CountryAndWestern,
    Classical,
    Pop,
    HeavyMetal
}

对于这些流派中的每一个,我们将存储一些艺术家或作曲家的详细信息。我们可以采取的一种方法是创建一个代表每个流派的类。虽然我们可以这样做,但这将是对我们编码时间的浪费。我们解决这个问题的方式是使用一种叫做map的东西。地图是一个接受两种类型的通用类:用于地图的键的类型和存储在其中的对象的类型。

键是一个唯一的值,用于允许我们存储值或快速查找事物-这使得地图成为快速查找值的良好选择。我们可以将任何类型作为键,值可以是绝对任何东西。对于我们的音乐收藏,我们将创建一个使用流派作为键和字符串数组表示作曲家或艺术家的地图的类:

class MusicCollection {
    private readonly collection : Map<Genre, string[]>;
    constructor() {
        this.collection = new Map<Genre, string[]>();
    }
}

为了填充地图,我们调用set方法,如下所示:

public Add(genre : Genre, artist : string[]) : void {
    this.collection.set(genre, artist);
}

从地图中检索值就像调用Get与相关的键一样简单:

public Get(genre : Genre) : string[] | undefined {
    return this.collection.get(genre);
}

我们必须在这里添加undefined关键字到返回值,因为地图条目可能不存在。如果我们忘记考虑 undefined 的可能性,TypeScript 会友好地提醒我们。再一次,TypeScript 努力为我们的代码提供强大的安全保障。

我们现在可以填充我们的集合,如下所示:

let collection = new MusicCollection();
collection.Add(Genre.Classical, [`Debussy`, `Bach`, `Elgar`, `Beethoven`]);
collection.Add(Genre.CountryAndWestern, [`Dolly Parton`, `Toby Keith`, `Willie Nelson`]);
collection.Add(Genre.HeavyMetal, [`Tygers of Pan Tang`, `Saxon`, `Doro`]);
collection.Add(Genre.Pop, [`Michael Jackson`, `Abba`, `The Spice Girls`]);
collection.Add(Genre.Rock, [`Deep Purple`, `Led Zeppelin`, `The Dixie Dregs`]);

如果我们想添加一个单独的艺术家,我们的代码会变得稍微复杂。使用 set,我们要么在地图中添加一个新条目,要么用新条目替换先前的条目。由于情况如此,我们确实需要检查是否已经添加了特定的键。为此,我们调用has方法。如果我们还没有添加流派,我们将使用空数组调用 set。最后,我们将使用 get 从地图中获取数组,以便我们可以推入我们的值:

public AddArtist(genre: Genre, artist : string) : void {
    if (!this.collection.has(genre)) {
        this.collection.set(genre, []);
    }
    let artists = this.collection.get(genre);
    if (artists) {
        artists.push(artist);
    }
}

我们要对我们的代码做的另一件事是改变Add方法。现在,该实现会覆盖对特定流派的先前调用Add,这意味着调用AddArtist然后Add最终会覆盖我们单独添加的艺术家与Add调用中的艺术家:

collection.AddArtist(Genre.HeavyMetal, `Iron Maiden`);
// At this point, HeavyMetal just contains Iron Maiden
collection.Add(Genre.HeavyMetal, [`Tygers of Pan Tang`, `Saxon`, `Doro`]);
// Now HeavyMetal just contains Tygers of Pan Tang, Saxon and Doro

为了修复Add方法,只需简单地迭代我们的艺术家并调用AddArtist方法,如下所示:

public Add(genre : Genre, artist : string[]) : void {
    for (let individual of artist) {
        this.AddArtist(genre, individual);
    }
}

现在,当我们完成填充HeavyMetal流派时,我们的艺术家包括Iron MaidenTygers of Pan TangSaxonDoro

使用承诺和异步/等待创建异步代码

我们经常需要编写以异步方式行为的代码。这意味着我们需要启动一个任务并将其在后台运行,同时我们做其他事情。一个例子是当我们调用一个可能需要一段时间才能返回的 web 服务时。很长一段时间以来,在 JavaScript 中的标准方式是使用回调。这种方法的一个大问题是,我们需要的回调越多,我们的代码就变得越复杂,潜在的错误也就越多。这就是 promise 出现的地方。

Promise 告诉我们某事将以异步方式发生;在异步操作完成后,我们可以选择继续处理并处理 promise 的结果,或者捕获任何被异常抛出的异常。

以下是一个演示这一点的示例:

function ExpensiveWebCall(time : number) : Promise<void> {
    return new Promise((resolve, reject) => setTimeout(resolve, time));
}
class MyWebService {
    CallExpensiveWebOperation() : void {
        ExpensiveWebCall(4000).then(()=> console.log(`Finished web 
        service`))
            .catch(()=> console.log(`Expensive web call failure`));
    }
}

当我们写一个 promise 时,我们可以选择接受两个参数——一个resolve函数和一个reject函数,可以调用它们来触发错误处理。Promise 为我们提供了两个函数来处理这些值,所以then()将在成功完成操作时触发,另一个catch函数处理reject函数。

现在,我们将运行这段代码来看看它的效果:

console.log(`calling service`);
new MyWebService().CallExpensiveWebOperation();
console.log(`Processing continues until the web service returns`);

当我们运行这段代码时,我们得到以下输出:

calling service
Processing continues until the web service returns
Finished web service

处理继续直到 web 服务返回完成 web 服务之间,有四秒的延迟,这是我们预期的,因为应用程序在执行处理控制台日志时正在等待 promise 返回。这向我们展示的是,这段代码在这里是异步行为,因为它在执行处理控制台日志时并没有等待 web 服务调用返回。

我们可能会觉得这段代码有点冗长,而且散布Promise<void>并不是让其他人理解我们的代码是异步的最直观的方式。TypeScript 提供了一个语法等效的方法,使得我们的代码异步的地方更加明显。通过使用asyncawait关键字,我们可以轻松地将之前的示例变得更加优雅:

function ExpensiveWebCall(time : number) {
    return  new Promise((resolve, reject) => setTimeout(resolve, time));
}
class MyWebService {
    async CallExpensiveWebOperation() {
        await ExpensiveWebCall(4000);
        console.log(`Finished web service`);
    }
}

async关键字告诉我们,我们的函数正在返回Promise。它还告诉编译器我们希望以不同的方式处理这个函数。在async函数中找到await时,应用程序将在那一点暂停该函数,直到被等待的操作返回。在那一点,处理继续,模仿我们在Promisethen()函数中看到的行为。

为了捕获async/await中的错误,我们真的应该将函数内部的代码包装在 try...catch 块中。当错误被catch()函数明确捕获时,async/await没有处理错误的等效方式,所以我们需要处理问题:

class MyWebService {
    async CallExpensiveWebOperation() {
        try {
            await ExpensiveWebCall(4000);
            console.log(`Finished web service`); 
        } catch (error) {
            console.log(`Caught ${error}`);
        }
    }
}

无论你选择采取哪种方法都是个人选择。使用async/await只是意味着它包装了Promise方法,因此不同技术的运行时行为完全相同。不过我建议的是,一旦你在应用程序中决定了一种方法,就要保持一致。不要混合风格,因为这会让任何审查你的应用程序的人感到困难。

使用 Bootstrap 创建 UI。

在接下来的章节中,我们将在浏览器中做很多工作。创建一个吸引人的 UI 可能是一件困难的事情,特别是在一个我们可能还要针对不同布局模式的移动设备的时代。为了让事情对我们自己更容易些,我们将相当依赖 Bootstrap。Bootstrap 被设计为一个移动设备优先的 UI 框架,可以平稳地扩展到 PC 浏览器。在本节中,我们将布置包含标准 Bootstrap 元素的基本模板,然后看看如何使用诸如 Bootstrap 网格系统等功能来布置一个简单的页面。

我们将从 Bootstrap 的起始模板开始(getbootstrap.com/docs/4.1/getting-started/introduction/#starter-template)。使用这个特定的模板,我们避免了下载和安装各种 CSS 样式表和 JavaScript 文件的需要;相反,我们依赖于众所周知的内容交付网络CDN)来为我们获取这些文件。

在可能的情况下,我建议使用 CDN 来获取外部 JavaScript 和 CSS 文件。这提供了许多好处,包括不需要自己维护这些文件,并在浏览器在其他地方遇到这个 CDN 文件时获得浏览器缓存的好处。

起始模板如下所示:

<!doctype html>
<html lang="en">
   <head>
      <!-- Required meta tags -->
      <meta name="viewport" content="width=device-width, initial-scale=1, 
      shrink-to-fit=no">
      <link rel="stylesheet"href="https://stackpath.bootstrapcdn.com/bootstrap
      /4.1.3/css/bootstrap.min.css" integrity="sha384-
      MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
      crossorigin="anonymous">
      <title>
         <
         <Template Bootstrap>
         >
      </title>
   </head>
   <body>
      <!-- 
         Content goes here...
         Start with the container.
         -->
      <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" 
         integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" 
         crossorigin="anonymous"></script>
      <script 
         src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" 
         integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" 
         crossorigin="anonymous"></script>
      <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" 
         integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" 
         crossorigin="anonymous"></script>
   </body>
</html>

布局内容的起点是容器。这是在前面的内容部分。以下代码显示了div部分:

<div class="container">

</div>

container类给了我们熟悉的 Twitter 外观,每个屏幕尺寸都有固定的大小。如果我们需要填满整个窗口,我们可以将其更改为container-fluid

在容器内部,Bootstrap 尝试以网格模式布置项目。Bootstrap 操作一个系统,屏幕的每一行可以表示为最多 12 个离散的列。默认情况下,这些列均匀分布在页面上,因此我们可以通过选择适当数量的列来创建复杂的布局。幸运的是,Bootstrap 提供了一套广泛的预定义样式,帮助我们为不同类型的设备创建布局,无论是 PC、手机还是平板电脑。这些样式都遵循相同的命名约定.col-<<size-identifier>>-<<number-of-columns>>

类型 超小设备 小设备 中等设备 大设备
尺寸 手机 < 768px 平板 >= 768px 桌面 >= 992px 桌面 >= 1200px
前缀 .col-xs- .col-sm- .col-md- .col-lg-

列数的工作方式是,每行理想情况下应该加起来为 12 列。因此,如果我们想要一行由三列、然后六列,最后又是三列的内容,我们会在容器内定义我们的行如下:

<div class="row">
  <div class="col-sm-3">Hello</div>
  <div class="col-sm-6">Hello</div>
  <div class="col-sm-3">Hello</div>
</div>

这种样式定义了在小设备上的显示方式。可以覆盖大设备的样式。例如,如果我们希望大设备使用五列、两列和五列,我们可以应用这种样式:

<div class="row">
  <div class="col-sm-3 col-lg-5">Hello</div>
  <div class="col-sm-6 col-lg-2">Hello</div>
  <div class="col-sm-3 col-lg-5">Hello</div>
</div>

这就是响应式布局系统的美妙之处。它允许我们生成适合我们设备的内容。

让我们看看如何向我们的页面添加一些内容。我们将在第一列中添加jumbotron,在第二列中添加一些文本,并在第三列中添加一个按钮:

<div class="row">
  <div class="col-md-3">
    <div class="jumbotron">
      <h2>
        Hello, world!
      </h2>
      <p>
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus 
        eget mi odio. Praesent a neque sed purus sodales interdum. In augue sapien, 
        molestie id lacus eleifend...
      </p>
      <p>
        <a class="btn btn-primary btn-large" href="#">Learn more</a>
      </p>
    </div>
  </div>
  <div class="col-md-6">
    <h2>
      Heading
    </h2>
    <p>
      Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus 
      eget mi odio. Praesent a neque sed purus sodales interdum. In augue sapien, 
      molestie id lacus eleifend...
    </p>
    <p>
      <a class="btn" href="#">View details</a>
    </p>
  </div>
  <div class="col-md-3">
    <button type="button" class="btn btn-primary btn-lg btn-block active">
      Button
    </button>
  </div>
</div>

同样,我们使用 CSS 样式来控制我们的显示样式。通过给div部分添加jumbotron样式,Bootstrap 立即为我们应用了该样式。我们通过选择将其设置为主按钮(btn-primary)等来精确控制我们的按钮的外观。

jumbotron通常横跨所有列的宽度。我们将其放在一个三列的div中,只是为了让我们看到宽度和样式是由网格布局系统控制的,jumbotron并没有一些特殊属性强制它横跨页面。

当我想要快速原型设计布局时,我总是遵循两个阶段的过程。第一步是在纸上画出我想要 UI 看起来的样子。我可以使用线框工具来做到这一点,但我喜欢能够快速画出东西的能力。一旦我大致知道我想要的布局是什么样子,我就会使用 Layoutit!(www.layoutit.com/)这样的工具将想法放到屏幕上;这也给了我导出布局的选项,这样我就可以手工进一步完善它。

总结

在本章中,我们看了 TypeScript 的一些特性,这些特性帮助我们构建未来的 TypeScript 代码。我们看了如何设置适当的 ES 级别来模拟或使用现代 ECMAScript 特性。我们看了如何使用联合和交集类型,以及如何创建类型别名。然后我们研究了对象扩展和 REST 属性,然后我们涵盖了装饰器的 AOP。我们还介绍了如何创建和使用映射类型,以及如何使用泛型和 promises。

为了准备本书其余部分中将要制作的 UI,我们简要介绍了使用 Bootstrap 来布局 UI,并介绍了 Bootstrap 网格布局系统的基础知识。

在下一章中,我们将使用一个简单的 Bootstrap 网页构建一个简单的 Markdown 编辑器,连接到我们的 TypeScript。我们将看到设计模式和单一职责类等技术如何帮助我们创建健壮的专业代码。

问题

  1. 我们编写了一个应用程序,允许用户将华氏度转换为摄氏度,以及将摄氏度转换为华氏度。计算是在以下类中执行的:
class FahrenheitToCelsius {
    Convert(temperature : number) : number {
        return (temperature - 32) * 5 / 9;
    }
}

class CelsiusToFahrenheit {
    Convert(temperature : number) : number {
        return (temperature * 9/5) + 32;
    }
}

我们想要编写一个方法,该方法接受一个温度和这些类型的实例之一,然后执行相关的计算。我们将使用什么技术来编写这个方法?

  1. 我们已经编写了以下类:
class Command {
    public constructor(public Name : string = "", public Action : Function = new Function()){}
}

我们想在另一个类中使用这个功能,我们将在其中添加多个命令。Name命令将作为键,我们可以在代码中稍后查找Command。我们将使用什么来提供这种键值功能,以及如何向其中添加记录?

  1. 我们如何自动记录我们在问题 2中添加的命令的条目,而不在我们的Add方法中添加任何代码?

  2. 我们创建了一个 Bootstrap 网页,我们想要显示一个包含六个中等大小列的行。我们该如何做?

第二章:使用 TypeScript 创建一个 Markdown 编辑器

在互联网上处理内容时很难避免遇到 markdown。Markdown 是一种使用纯文本创建内容的简化方式,可以轻松转换为简单的 HTML。在本章中,我们将调查创建一个解析器所需的步骤,该解析器将把标记格式的子集转换为 HTML 内容。我们将自动将相关标签转换为前三个标题级别、水平规则和段落。

在本章结束时,我们将学习如何创建一个简单的 Bootstrap 网页,并引用从我们的 TypeScript 生成的 JavaScript,以及如何连接到一个简单的事件处理程序。我们还将学习如何使用简单的设计模式创建类,以及如何设计具有单一职责的类,这些技术将成为我们作为专业开发人员的有用技能。

本章将涵盖以下主题:

  • 创建一个覆盖 Bootstrap 样式的 Bootstrap 页面

  • 选择我们在 markdown 中要使用的标签

  • 定义需求

  • 将我们的 markdown 标记类型映射到 HTML 标记类型

  • 将我们转换的 markdown 存储在自定义类中

  • 使用访问者模式更新我们的文档

  • 使用责任链模式应用标签

  • 将其连接回我们的 HTML

技术要求

本章的代码可以从github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter02下载。

了解项目概述

现在我们已经掌握了本书中将要涵盖的一些概念,我们将开始将它们付诸实践,创建一个项目,该项目在用户输入到文本区域时解析一个非常简单的 markdown 格式,并在其旁边显示生成的网页。与完整的 markdown 解析器不同,我们将集中于格式化前三个标题类型、水平规则和段落。标记受限于通过换行符分解行并查看行的开头。然后确定特定标签是否存在,如果不存在,则假定当前行是一个段落。我们选择这种实现的原因是因为它是一个可以立即掌握的简单任务。虽然简单,但它提供了足够的深度,以表明我们将处理需要我们认真考虑如何构建应用程序的主题。

用户界面UI)使用 Bootstrap,我们将看看如何连接到更改事件处理程序以及如何获取和更新当前网页的 HTML 内容。这是我们完成后项目的样子:

现在我们有了概述,我们可以继续开始创建 HTML 项目。

开始一个简单的 HTML 项目

这个项目是一个简单的 HTML 和 TypeScript 文件组合。创建一个目录来保存 HTML 和 TypeScript 文件。我们的 JavaScript 将驻留在此目录下的脚本文件夹中。使用以下tsconfig.json文件:

{
  "compilerOptions": {
    "target": "ES2015", 
    "module": "commonjs", 
    "sourceMap": true, 
    "outDir": "./script", 
    "strict": true, 
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true, 
    "experimentalDecorators": true,
  }
}

编写一个简单的 markdown 解析器

当我在考虑本章我们将要处理的项目时,我心中有一个明确的目标。在编写这段代码的同时,我们将尝试诸如模式和良好的面向对象OO)实践,比如类具有单一职责。如果我们能从一开始就应用这些技术,我们很快就会养成使用它们的习惯,这将转化为有用的开发技能。

作为专业开发人员,在编写任何代码之前,我们应该收集我们将使用的要求,并确保我们对我们的应用程序将要做什么没有任何假设。我们可能认为我们知道我们想要我们的应用程序做什么,但是如果我们列出我们的要求,我们将确保我们理解我们应该交付的一切,并且我们将得到一个方便的清单,以便在完成它们时勾选功能。

所以,这是我的清单:

  • 我们将创建一个解析 markdown 的应用程序

  • 用户将在文本区域中输入

  • 每当文本区域发生变化时,我们将重新解析整个文档

  • 我们将根据用户按下Enter键的位置来分解文档

  • 开头的字符将决定该行是否是 markdown

  • 输入#后跟一个空格将被替换为 H1 标题

  • 输入##后跟一个空格将被替换为 H2 标题

  • 输入###后跟一个空格将被替换为 H3 标题

  • 输入---将被替换为水平线

  • 如果该行不以 markdown 开头,则该行将被视为段落

  • 生成的 HTML 将显示在一个标签中

  • 如果 markdown 文本区域中的内容为空,则标签将包含一个空段落

  • 布局将在 Bootstrap 中完成,内容将拉伸到 100%的高度

考虑到这些要求,我们对我们将要交付的内容有一个很好的想法,所以我们要开始创建我们的 UI。

构建我们的 Bootstrap UI

在第一章中,高级 TypeScript 功能,我们看了使用 Bootstrap 创建 UI 的基础知识。我们将采用相同的基本页面,并通过一些小调整来调整它以满足我们的需求。我们的起点是这个页面,通过将容器设置为使用container-fluid,并在两侧设置col-lg-6,将界面分成两个相等的部分:

<div class="container-fluid">
  <div class="row">
    <div class="col-lg-6">
    </div>
    <div class="col-lg-6">
    </div>
  </div>
</div>

当我们将文本区域和标签组件添加到我们的表单中时,我们发现在此行中呈现它们不会自动将它们扩展到填满屏幕的高度。我们需要做一些调整。首先,我们需要手动设置htmlbody标签的样式以填充可用空间。为此,我们在头部添加以下内容:

<style>
  html, body { 
    height: 100%;
  }
</style>

有了这个,我们可以利用 Bootstrap 4 中的一个新功能,即将h-100应用于这些类,以填充 100%的空间。我们还将利用这个机会添加文本区域和标签,并为它们添加我们可以从我们的 TypeScript 代码中查找的 ID:

<div class="container-fluid h-100">
  <div class="row h-100">
    <div class="col-lg-6">
      <textarea class="form-control h-100" id="markdown"></textarea>
    </div>
    <div class="col-lg-6 h-100">
      <label class="h-100" id="markdown-output"></label>
    </div>
  </div>
</div>

在完成页面之前,我们将开始编写我们可以在应用程序中使用的 TypeScript 代码。添加一个名为MarkdownParser.ts的文件来保存我们的 TypeScript 代码,并将以下代码添加到其中:

class HtmlHandler {
    public TextChangeHandler(id : string, output : string) : void {
        let markdown = <HTMLTextAreaElement>document.getElementById(id);
        let markdownOutput = <HTMLLabelElement>document.getElementById(output);
        if (markdown !== null) {
            markdown.onkeyup = (e) => {
                if (markdown.value) {
                    markdownOutput.innerHTML = markdown.value;
                }
                else 
                   markdownOutput.innerHTML = "<p></p>";
            }
        }
    }
}

我们创建了这个类,以便我们可以根据它们的 ID 获取文本区域和标签。一旦我们有了这些,我们将连接到文本区域,按键事件,并将按键值写回标签。请注意,即使在这一点上我们不在网页上,TypeScript 也会隐式地给我们访问标准网页行为的权限。这使我们能够根据我们先前输入的 ID 检索文本区域和标签,并将它们转换为适当的类型。有了这个,我们就能够做一些事情,比如订阅事件或访问元素的innerHTML

为了简单起见,我们将在本章中使用MarkdownParser.ts文件中的所有 TypeScript。通常情况下,我们会将类分开放在它们自己的文件中,但是这种单文件结构应该更容易在我们逐步进行代码审查时进行复习。在未来的章节中,我们将摆脱单一文件,因为那些项目要复杂得多。

一旦我们有了这些接口元素,我们就可以连接到 keyup 事件。当事件触发时,我们查看文本区域中是否有任何文本,并使用内容(如果存在)或空段落(如果不存在)设置标签的 HTML。我们编写这段代码的原因是因为我们希望使用它来确保我们正确地链接生成的 JavaScript 和网页。

我们使用 keyup 事件而不是 keydown 或 keypress 事件,因为在 keypress 事件完成之前,键不会添加到文本区域中。

现在我们可以重新访问我们的网页,并添加缺失的部分,以便在文本区域更改时更新我们的标签。在</body>标记之前,添加以下内容以引用 TypeScript 生成的 JavaScript 文件,以创建我们的HtmlHandler类的实例,并将markdownmarkdown-output元素连接在一起:

<script src="script/MarkdownParser.js">
</script>
<script>
  new HtmlHandler().TextChangeHandler("markdown", "markdown-output");
</script>

快速回顾一下,这是目前 HTML 文件的样子:

<!doctype html>
<html lang="en">
 <head>
 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
 <style>
 html, body { 
 height: 100%; 
 }
 </style>
 <title>Advanced TypeScript - Chapter 2</title>
 </head>
 <body>
 <div class="container-fluid h-100">
 <div class="row h-100">
 <div class="col-lg-6">
 <textarea class="form-control h-100" id="markdown"></textarea>
 </div>
 <div class="col-lg-6 h-100">
 <label class="h-100" id="markdown-output"></label>
 </div>
 </div>
 </div>
 <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
 <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
 <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>

 <script src="script/MarkdownParser.js">
 </script>
 <script>
 new HtmlHandler().TextChangeHandler("markdown", "markdown-output");
 </script>
 </body>
</html>

如果我们在这一点运行我们的应用程序,在文本区域中输入将自动更新标签。以下屏幕截图显示了我们的应用程序在操作时的样子:

现在我们知道我们可以自动更新我们的网页,我们不需要对其进行任何更改。我们即将编写的所有代码将完全在 TypeScript 文件中完成。回到我们的需求列表,我们已经做了足够的工作来满足最后三个需求。

将我们的 markdown 标记类型映射到 HTML 标记类型

在我们的需求中,我们列出了我们的解析器将处理的标记的主列表。为了识别这些标记,我们将添加一个包含我们向用户提供的标记的枚举:

enum TagType {
    Paragraph,
    Header1,
    Header2,
    Header3,
    HorizontalRule
}

根据我们的需求,我们还知道我们需要在这些标记和它们的等效开放和关闭 HTML 标记之间进行转换。我们将要做的是将tagType映射到等效的 HTML 标记。为此,我们将创建一个专门负责处理此映射的类。以下代码显示了这一点:

class TagTypeToHtml {
    private readonly tagType : Map<TagType, string> = new Map<TagType, string>();
    constructor() {
        this.tagType.set(TagType.Header1, "h1");
        this.tagType.set(TagType.Header2, "h2");
        this.tagType.set(TagType.Header3, "h3");
        this.tagType.set(TagType.Paragraph, "p");
        this.tagType.set(TagType.HorizontalRule, "hr")
    }
}

首先,在类型上使用readonly可能看起来令人困惑。这个关键字的意思是,在类被实例化之后,tagType不能在类的其他地方重新创建。这意味着我们可以在构造函数中设置我们的映射,知道我们不会在以后调用this.tagType = new Map<TagType, string>();

我们还需要一种方法来从这个类中检索开放和关闭标签。我们将首先创建一个方法来从tagType获取开放标签,如下所示:

public OpeningTag(tagType : TagType) : string {
    let tag = this.tagType.get(tagType);
    if (tag !== null) {
        return `<${tag}>`;
    }
    return `<p>`;
}

这个方法非常简单。它首先尝试从映射中获取tagType。根据我们目前的代码,我们将始终在映射中有一个条目,但是我们将来可能会扩展枚举并忘记将标记添加到标记列表中。这就是为什么我们要检查标记是否存在;如果存在,我们返回用<>括起来的标记。如果标记不存在,我们返回一个段落标记作为默认值。

现在,让我们看一下ClosingTag

public ClosingTag(tagType : TagType) : string {
    let tag = this.tagType.get(tagType);
    if (tag !== null) {
        return `</${tag}>`;
    }
    return `</p>`;
}

看到这两种方法,我们可以看到它们几乎是相同的。当我们考虑创建 HTML 标记的问题时,我们意识到开放和关闭标记之间唯一的区别是关闭标记中有一个/。有了这个想法,我们可以改变代码,使用一个辅助方法,接受标记是否以<</开头:

private GetTag(tagType : TagType, openingTagPattern : string) : string {
    let tag = this.tagType.get(tagType);
    if (tag !== null) {
        return `${openingTagPattern}${tag}>`;
    }
    return `${openingTagPattern}p>`;
}

我们所要做的就是添加方法来检索开放和关闭标签:

public OpeningTag(tagType : TagType) : string {
    return this.GetTag(tagType, `<`);
}

public ClosingTag(tagType : TagType) : string {
    return this.GetTag(tagType, `</`);
}

将所有这些内容汇总起来,我们的TagTypeToHtml类的代码现在看起来像这样:

class TagTypeToHtml {
    private readonly tagType : Map<TagType, string> = new Map<TagType, string>();
    constructor() {
        this.tagType.set(TagType.Header1, "h1");
        this.tagType.set(TagType.Header2, "h2");
        this.tagType.set(TagType.Header3, "h3");
        this.tagType.set(TagType.Paragraph, "p");
        this.tagType.set(TagType.HorizontalRule, "hr")
    }

    public OpeningTag(tagType : TagType) : string {
        return this.GetTag(tagType, `<`);
    }

    public ClosingTag(tagType : TagType) : string {
        return this.GetTag(tagType, `</`);
    }

    private GetTag(tagType : TagType, openingTagPattern : string) : string {
        let tag = this.tagType.get(tagType);
        if (tag !== null) {
            return `${openingTagPattern}${tag}>`;
        }
        return `${openingTagPattern}p>`;
    }
}

TagTypeToHtml类的单一责任是将tagType映射到 HTML 标签。在本章中,我们将一直回到的一个问题是,我们希望类具有单一责任。在面向对象理论中,这被称为SOLID(单一责任原则、开闭原则、里氏替换原则、接口隔离原则、依赖倒置原则)设计原则之一。这个首字母缩略词指的是一组互补的开发技术,用于创建更健壮的代码。

这个方便的首字母缩略词指导我们如何构建类和最重要的部分,在我看来,就是单一责任原则,它规定一个类应该只做一件事。虽然我肯定建议阅读这个主题(随着我们的进展,我们将涉及其他方面),但在我看来,SOLID 设计最重要的部分是类只负责一件事;其他一切都源自这个原则。只做一件事的类通常更容易测试,也更容易理解。这并不意味着它们只能有一个方法。它们可以有很多方法,只要它们都与类的目的相关。因为这一点非常重要,所以我们将在整本书中一再涉及这个主题。

使用 Markdown 文档表示我们转换后的 Markdown

在解析内容的同时,我们需要一种机制来实际存储在解析过程中创建的文本。我们可以直接使用全局字符串并直接更新它,但如果我们决定以后异步添加内容,那将会变得很麻烦。不使用字符串的主要原因又回到了单一责任原则。如果我们使用简单的字符串,那么每个添加到文本的代码片段最终都要以正确的方式写入字符串,这意味着它们会将读取的 Markdown 与写入 HTML 输出混合在一起。当我们这样讨论时,显然我们需要另一种方式来输出 HTML 内容。

对我们来说,这意味着我们需要编写能够接受多个字符串以形成内容的代码(这些字符串可能包括我们的 HTML 标签,因此我们不希望只接受单个字符串)。我们还需要一种在构建完成后获取文档的方法。我们将首先定义一个接口,它将作为消费代码实现的契约。特别感兴趣的是,我们将允许我们的代码在Add方法中接受任意数量的项目,因此我们将在这里使用 REST 参数。

interface IMarkdownDocument {
    Add(...content : string[]) : void;
    Get() : string;
}

有了这个接口,我们可以创建我们的MarkdownDocument类如下:

class MarkdownDocument implements IMarkdownDocument {
    private content : string = "";
    Add(...content: string[]): void {
        content.forEach(element => {
            this.content += element;
        });
    } 
    Get(): string {
        return this.content;
    }
}

这个类非常简单。对于传递给我们的Add方法的每个内容片段,我们都将其添加到一个名为content的成员变量中。由于这被声明为私有,我们的Get方法返回相同的变量。这就是为什么我喜欢有单一责任的类——在这种情况下,它们只是更新内容;它们往往比做很多不同事情的复杂类更清晰、更容易理解。最重要的是,我们可以随心所欲地在内部保持我们的内容更新,因为我们已经将如何维护文档的细节隐藏在了消费代码之外。

由于我们将逐行解析文档,我们将使用一个类来表示我们正在处理的当前行:

class ParseElement {
    CurrentLine : string = "";
}

我们的类非常简单。同样,我们决定不使用简单的字符串在我们的代码库中传递,因为这个类清晰地表明了我们的意图——我们要解析当前行。如果我们只是使用一个字符串来表示行,当我们想要使用这行时,很容易传递错误的内容。

使用访问者更新 Markdown 文档

在第一章中,高级 TypeScript 特性,我们简要涉及了模式。简而言之,软件开发过程中的模式是特定问题的一般解决方案。这意味着我们使用模式的名称来向他人传达我们正在使用特定和成熟的代码示例来解决问题。例如,如果我们告诉另一个开发人员我们正在使用中介者模式来解决问题,只要另一个开发人员了解模式,他们就会对我们将如何构建我们的代码有一个很好的想法。

当我规划这段代码时,我早早地做出了一个有意识的决定,即我们将在我们的代码中使用一种称为访问者模式的东西。在我们看看我们将要创建的代码之前,我们将看一下这种模式是什么,以及为什么我们要使用它。

理解访问者模式

访问者模式是所谓的行为模式。行为模式这个术语只是一组关于类和对象如何通信的模式的分类。访问者模式给我们的是能够将算法与算法作用的对象分离开来的能力。这听起来比实际情况复杂得多。

我们使用访问者模式的动机之一是,我们想对通用的ParseElement类应用不同的操作,这取决于底层的 markdown 是什么,最终导致我们构建MarkdownDocument类。这里的想法是,如果用户输入的内容是我们在 HTML 中表示为段落的内容,我们希望为其添加不同的标签,例如,当内容表示水平规则时。访问者模式的约定是我们有两个接口,IVisitorIVisitable。在最基本的情况下,这些接口看起来像这样:

interface IVisitor {
    Visit(......);
}
interface IVisitable {
    Accept(IVisitor, .....);
}

这些接口的背后思想是对象将是可访问的,因此当它需要执行相关操作时,它接受访问者以便访问对象。

将访问者模式应用到我们的代码中

现在我们知道了访问者模式是什么,让我们看看我们将如何将其应用到我们的代码中:

  1. 首先,我们将创建IVisitorIVisitable接口如下:
interface IVisitor {
    Visit(token : ParseElement, markdownDocument : IMarkdownDocument) : void;
}
interface IVisitable {
    Accept(visitor : IVisitor, token : ParseElement, markdownDocument : IMarkdownDocument) : void;
}
  1. 当我们的代码达到调用Visit的点时,我们将使用TagTypeToHtml类将相关的开放 HTML 标签、文本行,以及匹配的闭合 HTML 标签添加到我们的MarkdownDocument中。由于这对于我们的每种标签类型都是通用的,我们可以实现一个封装这种行为的基类,如下所示:
abstract class VisitorBase implements IVisitor {
    constructor (private readonly tagType : TagType, private readonly TagTypeToHtml : TagTypeToHtml) {}
    Visit(token: ParseElement, markdownDocument: IMarkdownDocument): void {
        markdownDocument.Add(this.TagTypeToHtml.OpeningTag(this.tagType), token.CurrentLine, 
            this.TagTypeToHtml.ClosingTag(this.tagType));
    }
}
  1. 接下来,我们需要添加具体的访问者实现。这就像创建以下类一样简单:
class Header1Visitor extends VisitorBase {
    constructor() {
        super(TagType.Header1, new TagTypeToHtml());
    }
}
class Header2Visitor extends VisitorBase {
    constructor() {
        super(TagType.Header2, new TagTypeToHtml());
    }
}
class Header3Visitor extends VisitorBase {
    constructor() {
        super(TagType.Header3, new TagTypeToHtml());
    }
}
class ParagraphVisitor extends VisitorBase {
    constructor() {
        super(TagType.Paragraph, new TagTypeToHtml());
    }
}
class HorizontalRuleVisitor extends VisitorBase {
    constructor() {
        super(TagType.HorizontalRule, new TagTypeToHtml());
    }
}

起初,这段代码可能看起来有些多余,但它有其目的。例如,如果我们看Header1Visitor,我们有一个类,它的单一责任是获取当前行并将其添加到我们的 markdown 文档中,用 H1 标签包裹起来。我们可以在代码中散布许多负责检查行是否以#开头的类,然后在添加 H1 标签和当前行之前删除#。然而,这样会使代码更难测试,更容易出错,特别是如果我们想要改变行为。此外,我们添加的标签越多,这段代码就会变得越脆弱。

访问者模式代码的另一面是IVisitable的实现。对于我们当前的代码,我们知道每当调用Accept时,我们都希望访问相关的访问者。对我们的代码来说,这意味着我们可以有一个单一的可访问类来实现我们的IVisitable接口。以下是示例代码:

class Visitable implements IVisitable {
    Accept(visitor: IVisitor, token: ParseElement, markdownDocument: IMarkdownDocument): void {
        visitor.Visit(token, markdownDocument);
    }
}

对于这个例子,我们已经放置了最简单的访问者模式实现。访问者模式有许多变体,所以我们选择了一种尊重模式设计哲学的实现,而不是盲目地坚持它。这就是模式的美妙之处——虽然它们指导我们如何做某事,但我们不应该觉得必须盲目地遵循特定的实现,如果稍微修改它可以满足我们的需求。

使用责任链模式决定应用哪些标签

现在我们有了将简单行转换为 HTML 编码行的方法,我们需要一种方法来决定应该应用哪些标签。从一开始,我就知道我们将应用另一种模式,这种模式非常适合提出问题:“我应该处理这个标签吗?”如果不应该,那么我将把这个问题转发出去,让其他东西决定是否应该处理这个标签。

我们将使用另一种行为模式来处理这个问题——责任链模式。这种模式让我们通过创建一个接受链中下一个类的类,以及一个处理请求的方法,来将一系列类链接在一起。根据请求处理程序的内部逻辑,它可能将处理传递给链中的下一个类。

如果我们从基类开始,我们可以看到这种模式给了我们什么,以及我们将如何使用它:

abstract class Handler<T> {
    protected next : Handler<T> | null = null;
    public SetNext(next : Handler<T>) : void {
        this.next = next;
    }
    public HandleRequest(request : T) : void {
        if (!this.CanHandle(request)) {
            if (this.next !== null) {
                this.next.HandleRequest(request);
            }
            return;
        }
    }
    protected abstract CanHandle(request : T) : boolean;
}

我们链中的下一个类是使用SetNext设置的。HandleRequest通过调用我们的抽象CanHandle方法来查看当前类是否能够处理请求。如果它无法处理请求,并且this.next不是null(注意这里使用了联合类型),我们将请求转发到下一个类。这样重复进行,直到我们可以处理请求或this.nextnull

现在我们可以添加我们的Handler类的具体实现。首先,我们将添加我们的构造函数和成员变量,如下所示:

class ParseChainHandler extends Handler<ParseElement> {
    private readonly visitable : IVisitable = new Visitable();
    constructor(private readonly document : IMarkdownDocument, 
        private readonly tagType : string, 
        private readonly visitor : IVisitor) {
        super();
    }
}

我们的构造函数接受 markdown 文档的实例;表示我们的tagTypestring,例如,#;;如果我们得到匹配的标签,相关的访问者将访问该类。在看看CanHandle的代码之前,我们需要稍微绕个弯,介绍一个将帮助我们解析当前行并查看标签是否出现在开头的类。

我们将创建一个纯粹用于解析字符串的类,并查看它是否以相关的 markdown 标签开头。我们的Parse方法的特殊之处在于我们返回了一个元组。我们可以将元组视为一个固定大小的数组,在数组的不同位置可以有不同类型。在我们的情况下,我们将返回一个boolean类型和一个string类型。boolean类型表示标签是否被找到,string类型将返回不带标签的文本开头;例如,如果string# Hello,标签是#,我们希望返回Hello。检查标签的代码非常简单;它只是查看文本是否以标签开头。如果是,我们将元组的boolean部分设置为true,并使用substr获取我们文本的其余部分。考虑以下代码:

class LineParser {
    public Parse(value : string, tag : string) : [boolean, string] {
        let output : [boolean, string] = [false, ""];
        output[1] = value;
        if (value === "") {
            return output;
        }
        let split = value.startsWith(`${tag}`);
        if (split) {
            output[0] = true;
            output[1] = value.substr(tag.length);
        }
        return output;
    }
}

现在我们有了LineParser类,我们可以在我们的CanHandle方法中应用它:

protected CanHandle(request: ParseElement): boolean {
    let split = new LineParser().Parse(request.CurrentLine, this.tagType);
    if (split[0]){
        request.CurrentLine = split[1];
        this.visitable.Accept(this.visitor, request, this.document);
    }
    return split[0];
}

在这里,我们使用我们的解析器构建一个元组,第一个参数说明标签是否存在,第二个参数包含不带标签的文本(如果标签存在)。如果我们的字符串中存在 markdown 标签,我们调用我们的Visitable实现的Accept方法。

严格来说,我们本可以直接调用 this.visitor.Visit(request, this.document);,但是,这会让我们对如何访问这个类有更多的了解,而我不希望如此。通过使用“接受”方法,如果我们的访问者更复杂,我们就避免了不得不重新访问这个方法的情况。

现在我们的ParseChainHandler看起来是这样的:

class ParseChainHandler extends Handler<ParseElement> {
    private readonly visitable : IVisitable = new Visitable();
    protected CanHandle(request: ParseElement): boolean {
        let split = new LineParser().Parse(request.CurrentLine, this.tagType);
        if (split[0]){
            request.CurrentLine = split[1];
            this.visitable.Accept(this.visitor, request, this.document);
        }
        return split[0];
    }
    constructor(private readonly document : IMarkdownDocument, 
        private readonly tagType : string, 
        private readonly visitor : IVisitor) {
        super();
    }
}

我们有一个特殊情况需要处理。我们知道段落没有与之关联的标签——如果在链的其余部分没有匹配项,那么默认情况下是一个段落。这意味着我们需要一个稍微不同的处理程序来处理段落,如下所示:

class ParagraphHandler extends Handler<ParseElement> {
    private readonly visitable : IVisitable = new Visitable();
    private readonly visitor : IVisitor = new ParagraphVisitor()
    protected CanHandle(request: ParseElement): boolean {
        this.visitable.Accept(this.visitor, request, this.document);
        return true;
    }
    constructor(private readonly document : IMarkdownDocument) {
        super();
    }
}

有了这个基础设施,我们现在可以为适当的标签创建具体的处理程序,如下所示:

class Header1ChainHandler extends ParseChainHandler {
    constructor(document : IMarkdownDocument) {
        super(document, "# ", new Header1Visitor());
    }
}

class Header2ChainHandler extends ParseChainHandler {
    constructor(document : IMarkdownDocument) {
        super(document, "## ", new Header2Visitor());
    }
}

class Header3ChainHandler extends ParseChainHandler {
    constructor(document : IMarkdownDocument) {
        super(document, "### ", new Header3Visitor());
    }
}

class HorizontalRuleHandler extends ParseChainHandler {
    constructor(document : IMarkdownDocument) {
        super(document, "---", new HorizontalRuleVisitor());
    }
}

现在,我们已经从标签,例如---,到适当的访问者有了一条路径。我们现在将我们的责任链模式与访问者模式联系起来。我们还有最后一件事要做:设置链。为此,让我们使用一个单独的类来构建我们的链:

class ChainOfResponsibilityFactory {
    Build(document : IMarkdownDocument) : ParseChainHandler {
        let header1 : Header1ChainHandler = new Header1ChainHandler(document);
        let header2 : Header2ChainHandler = new Header2ChainHandler(document);
        let header3 : Header3ChainHandler = new Header3ChainHandler(document);
        let horizontalRule : HorizontalRuleHandler = new HorizontalRuleHandler(document);
        let paragraph : ParagraphHandler = new ParagraphHandler(document);

        header1.SetNext(header2);
        header2.SetNext(header3);
        header3.SetNext(horizontalRule);
        horizontalRule.SetNext(paragraph);

        return header1;
    }
}

这个看似简单的方法为我们做了很多事情。前几个语句为我们初始化了责任链处理程序;首先是标题,然后是水平线,最后是段落处理程序。记住这只是我们需要在这里做的一部分,然后我们遍历标题和水平线,并设置链中的下一个项目。标题 1 将调用转发到标题 2,标题 2 转发到标题 3,依此类推。我们之所以在段落处理程序之后不设置任何进一步的链接项,是因为那是我们想要处理的最后一种情况。如果用户没有输入header1header2header3horizontalRule,那么我们将把它视为段落。

将所有内容整合在一起

我们要编写的最后一个类用于接收用户输入的文本并将其拆分为单独的行,并创建我们的ParseElement、责任链处理程序和MarkdownDocument实例。然后,每一行都被转发到Header1ChainHandler来开始处理该行。最后,我们从文档中获取文本并返回它,以便我们可以在标签中显示它:

class Markdown {
    public ToHtml(text : string) : string {
        let document : IMarkdownDocument = new MarkdownDocument();
        let header1 : Header1ChainHandler = new ChainOfResponsibilityFactory().Build(document);
        let lines : string[] = text.split(`\n`);
        for (let index = 0; index < lines.length; index++) {
            let parseElement : ParseElement = new ParseElement();
            parseElement.CurrentLine = lines[index];
            header1.HandleRequest(parseElement);
        }
        return document.Get();
    }
}

现在我们可以生成我们的 HTML 内容,还有一件事要做。我们将重新访问HtmlHandler方法,并更改它,以便调用我们的ToHtml markdown 方法。同时,我们还将解决原始实现中的一个问题,即刷新页面会导致我们的内容丢失,直到我们按下一个键。为了解决这个问题,我们将添加一个window.onload事件处理程序:

class HtmlHandler {
 private markdownChange : Markdown = new Markdown;
    public TextChangeHandler(id : string, output : string) : void {
        let markdown = <HTMLTextAreaElement>document.getElementById(id);
        let markdownOutput = <HTMLLabelElement>document.getElementById(output);

        if (markdown !== null) {
            markdown.onkeyup = (e) => {
                this.RenderHtmlContent(markdown, markdownOutput);
            }
            window.onload = (e) => {
                this.RenderHtmlContent(markdown, markdownOutput);
            }
        }
    }

    private RenderHtmlContent(markdown: HTMLTextAreaElement, markdownOutput: HTMLLabelElement) {
        if (markdown.value) {
            markdownOutput.innerHTML = this.markdownChange.ToHtml(markdown.value);
        }
        else
            markdownOutput.innerHTML = "<p></p>";
    }
}

现在,当我们运行我们的应用程序时,即使刷新页面,它也会显示渲染后的 HTML 内容。我们已经成功地创建了一个简单的 Markdown 编辑器,满足了我们在需求收集阶段制定的要点。

我无法再次强调需求收集阶段有多么重要。往往,糟糕的需求会导致我们不得不对应用程序的行为进行假设。这些假设可能导致交付给用户不想要的应用程序。如果你发现自己在做假设,请回去问问用户他们到底想要什么。在构建代码时,我们参考了我们的需求,以确保我们正在构建确切的东西。

关于需求的最后一点——它们会变化。在编写应用程序时,需求通常会发生变化或被删除。当它们发生变化时,我们确保更新了需求,不做任何假设,并检查已经产生的工作,以确保它符合更新后的需求。这是我们作为专业人士所做的。

总结

在本章中,我们构建了一个应用程序,根据用户在文本区域中输入的内容做出响应,并使用转换后的文本更新标签。这些文本的转换由各自负责的类处理。我们专注于创建只做一件事情的类的原因是为了从一开始就学习如何使用行业最佳实践,使我们的代码更清晰,更不容易出错,因为一个设计良好的只做一件事情的类比做很多不同事情的类更不容易出问题。

我们引入了访问者和责任链模式,以便看到如何将文本处理分离为决定一行是否包含 Markdown 并添加适当的 HTML 编码文本。我们开始引入模式,因为模式在许多不同的软件开发问题中都会出现。它们不仅提供了如何解决问题的清晰细节;它们还提供了一种清晰的语言,因此如果有人说一段代码需要特定的模式,其他开发人员就不会对该代码需要做什么产生歧义。

在下一章中,我们将使用 React.js 来构建我们的第一个应用程序,用于构建个人联系人管理器。

问题

  1. 该应用程序目前只对用户使用键盘更改内容做出反应。用户也可能使用上下文菜单粘贴文本。增强HtmlHandler方法以处理用户粘贴文本。

  2. 我们添加了对 H1 到 H3 的支持。HTML 还支持 H4、H5 和 H6。添加对这些标签的支持。

  3. CanHandle代码中,我们正在调用Visitable代码。更改基本的Handler类,以便调用Accept方法。

进一步阅读

有关使用设计模式的更多信息,我建议阅读 Vilic Vane 撰写的书籍TypeScript Design Patternswww.packtpub.com/application-development/typescript-design-patterns),由 Packt 出版。

第三章:一个 React Bootstrap 个人联系人管理器

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

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

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

  • 创建一个模拟布局来检查我们的布局

  • 创建我们的 React 应用程序

  • 使用tslint分析和格式化代码

  • 添加 Bootstrap 支持

  • 在 React 中使用 tsx 组件

  • React 中的App组件

  • 展示我们的个人详细信息 UI

  • 使用绑定简化我们的更新

  • 创建验证器并将它们应用为验证

  • 在 React 组件中应用验证

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

技术要求

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

了解项目概述

我们将使用 React 构建一个个人联系人管理器数据库。数据存储在客户端上,使用标准的 IndexedDB 数据库。完成后,我们的应用程序将如下所示:

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

开始使用组件

本章依赖于 Node.js,可在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 Bootstrap 项目

正如我们在第二章中讨论的使用 TypeScript 创建 Markdown 编辑器,最好的方法是首先收集我们将要编写的应用程序的需求。以下是本章的要求:

  • 用户将能够创建一个人的新详细信息或编辑它们

  • 这些详细信息将保存到客户端数据库

  • 用户将能够加载所有人的列表

  • 用户将能够删除一个人的个人详细信息

  • 个人详细信息将包括名字和姓氏、地址(由两个地址行、城镇、县和邮政编码组成)、电话号码和出生日期。

  • 个人详细信息将保存到数据库中

  • 名字至少为一个字符,姓氏至少为两个字符

  • 地址行 1、城镇和县至少为五个字符

  • 邮政编码将符合大多数邮政编码的美国标准

  • 电话号码将符合标准的美国电话格式

  • 用户可以通过点击按钮清除详细信息

创建我们的模拟布局

一旦我们有了我们的要求,通常最好草拟一些我们认为应用程序布局应该是什么样的草图。我们想做的是创建一个布局,显示我们正在使用网页浏览器布局的草图格式。我们希望它看起来像是草绘的,因为我们与客户互动的方式。我们希望他们能够了解我们应用程序的大致布局,而不会陷入诸如特定按钮有多宽等细节中。

特别有用的是使用诸如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

如果您以前安装过 Node.js 包,您可能会认为在前面的命令中有一个错误,并且我们应该使用npm来安装create-react-app。但是,我们使用npx代替npm,因为npxNode Package ManagerNPM)的增强版本。使用npx,我们省去了运行npm install create-react-app来安装create-react-app包,然后手动运行create-react-app来启动进程的步骤。使用npx确实有助于加快我们的开发工作流程。

创建完我们的应用程序后,我们打开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 支持

我们的应用程序需要做的一件事是引入对 Bootstrap 的支持。这不是 React 默认提供的功能,因此我们需要使用其他包添加这个功能:

  1. 安装 Bootstrap 如下:
npm install --save bootstrap
  1. 有了这个,我们现在可以自由地使用一个 React-ready 的 Bootstrap 组件。我们将使用reactstrap包,因为这个包以 React 友好的方式针对 Bootstrap 4:
npm install --save reactstrap react react-dom
  1. reactstrap不是一个 TypeScript 组件,所以我们需要安装这个和 Bootstrap 的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";

在这里,我们使用本地的 Bootstrap 文件是为了方便。正如我们在第一章中讨论的高级 TypeScript 特性,我们希望将其更改为在生产版本中使用 CDN 源。

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

React 使用 tsx 组件

你现在可能会问一个问题,为什么索引文件有不同的扩展名?也就是说,为什么是.tsx而不是.ts?要回答这些问题,我们必须稍微改变我们对扩展的心智形象,并谈谈为什么 React 使用.jsx文件而不是.js.tsx版本是.jsx的 TypeScript 等价物)。

这些 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 知道这是什么类型(这个参数要么派生自一个元素,要么为空——是的,这意味着底层是一个联合类型)。

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

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

我略过了为什么使用render方法,现在是时候解释一下 React 的秘密武器,也就是虚拟文档对象模型DOM)。如果你已经开发了一段时间的 Web 应用程序,你可能已经了解了 DOM。如果你从未遇到过这个,DOM 是一个描述网页将会是什么样子的实体。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。由于我们正在使用 Bootstrap 来显示我们的 UI,我们希望呈现一个与我们的Container div 相关的组件。为此,我们将使用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>
    );
  }
}

正如您所看到的,这个方法中有很多事情要做;然而,其中绝大部分是重复的代码,用于复制行和列的 Bootstrap 元素。例如,如果我们看一下postcodephoneNumber元素的布局,我们会发现我们正在布置两行,每行有两个显式的列。在 Bootstrap 术语中,其中一个Col元素是三个大尺寸,另一个是四个大尺寸(我们将留给 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 会通知我们这一点。

请注意,前面的陈述有一个警告。如果我们有两个完全相同形状的接口,那么 TypeScript 会将它们视为等价的。因此,即使 TypeScript 期望IState,如果我们提供了一个名为IMyOtherState的东西,它具有完全相同的属性,那么 TypeScript 也会乐意让我们使用它。当然,问题是,为什么我们要首先复制接口呢?我想不出很多情况下我们会这样做,所以使用正确的状态的想法几乎适用于我们可能遇到的所有情况。

我们的app.tsx文件将会为状态创建一个默认值,并将其作为属性传递给我们的组件。默认状态是当用户按下清除按钮清除当前编辑的条目,或者按下新建人员按钮开始添加新人员时将会应用的状态。我们的IProps接口看起来是这样的:

interface IProps {
  DefaultState : IPersonState
}

一开始可能会有些令人困惑的是,我之前的陈述和属性和状态是不同的这个想法之间存在潜在的矛盾——状态是组件本地的东西,但我们将状态作为属性的一部分传递下去。我故意在名称中使用状态的一部分来强调这一点。我们传递的值可以是任何东西。它们不必代表任何状态;它们可以只是组件调用以触发父级响应的函数。我们的组件将接收这个属性,并且它将负责将其需要的任何部分转换为状态。

有了这个,我们就可以准备改变我们的App.tsx文件,创建我们的默认状态,并将其传递给我们的PersonalDetails组件。正如我们在下面的代码中所看到的,IProps接口中的属性成为了<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>
    );
  }
}

当我们想要将日期挂接到日期选择器组件时,使用 JavaScript 处理日期可能会让人望而却步。日期选择器期望以 YYYY-MM-DD 的格式接收日期。因此,我们使用new Date().toISOString().substring(0,10)语法来获取今天的日期,其中包括一个时间组件,并且只从中检索 YYYY-MM-DD 部分。尽管日期选择器期望日期以这种格式呈现,但它并没有规定这是屏幕上显示的格式。屏幕上的格式应该遵守用户的本地设置。

有趣的是,我们对支持传递属性所做的更改已经在这里看到了绑定的作用。在render方法中,我们设置Default={this.defaultPerson}时,我们正在使用绑定。在这里使用{},我们告诉 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为我们提供的。这是学习如何将我们的属性从父级绑定到状态的最后一部分。

对状态的更改不会反映在父级 props 中。如果我们想要明确地将一个值设置回父组件,这将要求我们触发对props.DefaultState的更改。如果可能的话,我建议不要直接这样做。

好的。让我们设置我们的名字和姓氏元素,使其与我们状态的绑定一起工作。这里的想法是,如果我们在代码中更新名字或姓氏的状态,这将自动在我们的 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属性更新我们的输入以触发此更新。同样,我们将使用绑定将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处理程序。

由于Address2可能为空,我们在绑定上使用!运算符,使其看起来略有不同:value={this.state.Address2!}

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

在这个阶段,我们真的应该考虑验证用户的输入。我们将在我们的代码中引入两种类型的验证。第一种是最小长度验证。换句话说,我们将确保一些条目在被视为有效之前必须具有最少数量的条目。第二种验证类型使用称为正则表达式的东西来验证它。这意味着它接受输入并将其与一组规则进行比较,以查看是否有匹配;如果您对正则表达式不熟悉,这些表达式可能看起来有点奇怪,因此我们将对它们进行分解,以确切了解我们正在应用的规则。

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

  1. 提供检查功能的类,比如应用正则表达式。我们将称这些为验证器。

  2. 将验证项目应用到状态的不同部分的类。我们将称这些类为验证。

  3. 将调用验证项目并使用失败验证的详细信息更新 UI 的组件。这将是一个名为FormValidation.tsx的新组件。

我们将首先创建一个名为IValidator的接口。这个接口将接受一个通用参数,以便我们可以将它应用到几乎任何我们想要的东西上。由于验证将告诉我们输入是否有效,它将有一个名为IsValid的单一方法,该方法接受相关输入,然后返回一个boolean值:

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

我们要编写的第一个验证器是检查字符串是否具有最小数量的字符,我们将通过构造函数设置。我们还将防范用户未提供输入的情况,通过在输入为 null 时从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。如果有输入,我们返回我们正则表达式测试的结果:

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]告诉正则表达式引擎我们要匹配一个数字。严格来说,由于美国邮政编码以五个数字开头,我们需要告诉验证器我们要匹配五个数字,我们通过告诉引擎我们需要多少个来做到这一点:[0-9]{5}。如果我们只想匹配主要区号,比如 10023,我们几乎可以在这里结束我们的表达式。然而,邮政编码还有一个可选的四位数字部分,它与主要部分由一个连字符分隔。因此,我们必须告诉正则表达式引擎我们有一个可选的部分要应用。

我们知道邮政编码可选部分的格式是一个连字符和四位数字。这意味着正则表达式的下一部分必须将测试视为一个测试。这意味着我们不能测试连字符,然后分别测试数字;我们要么有-1234 格式,要么什么都没有。这告诉我们我们想要将要测试的项目分组。在正则表达式中将事物分组的方法是将表达式放在括号内。因此,如果我们应用之前的逻辑,我们可能会认为验证的这部分是 (-[0-9]{4})。首次尝试,这与我们想要的非常接近。这里的规则是将其视为一个组,其中第一个字符必须是连字符,然后必须有四个数字。这个表达式的一部分有两件事情需要解决。第一件事是目前这个测试是不可选的。换句话说,输入 10012-1234 是有效的,而 10012 不再有效。第二个问题是我们在表达式中创建了一个捕获组,而我们并不需要。

捕获组是一个编号组,代表匹配的次数。如果我们想在文档的多个地方匹配相同的文本,这可能很有用;然而,由于我们只想要一个匹配,这是可以避免的。

我们现在将解决验证的可选部分的两个问题。我们要做的第一件事是删除捕获组。这是通过使用 ?: 运算符来完成的,告诉引擎这个组是一个非捕获组。接下来我们要处理的是应用 ? 运算符,表示我们希望此匹配发生零次或一次。换句话说,我们已经将其设置为可选测试。此时,我们可以成功测试 10012 和 10012-1234,但我们还有一件事需要处理。我们需要确保输入只匹配此输入。换句话说,我们不希望在结尾允许任何杂乱的字符;否则,用户可以输入 10012-12345,引擎会认为我们有一个有效的输入。我们需要做的是在表达式的结尾添加 $ 运算符,表示表达式在那一点处期望行的结束。此时,我们的正则表达式是 ^[0-9]{5}(?:-[0-9]{4})?$,它匹配我们期望应用于邮政编码的验证。

我选择明确指定数字表示为 [0-9],因为这对于新接触正则表达式的人来说是一个清晰的指示,表示 0 到 9 之间的数字。有一个等效的速记可以用来表示单个数字,那就是使用 \d 代替。有了这个,我们可以将这个规则重写为 ^\d{5}(?:-\d{4})?$。在这里使用 \d 代表一个美国信息交换标准代码ASCII)数字。

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

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;这意味着我们要么测试这个表达式,要么测试这个表达式。我们已经看到()对正则表达式引擎来说意味着特殊的东西,所以我们必须有一些机制可用来表明我们正在看实际的括号,而不是括号代表的表达式。这就是表达式中\\的意思。

在正则表达式中使用\来转义下一个字符,使其被当作字面量处理,而不是作为一个规则形成表达式来匹配。另外,由于 TypeScript 已经将\视为转义字符,我们必须对转义字符进行转义,以便表达式引擎看到正确的值。

当我们想要一个正则表达式表示一个值必须是这样或那样时,我们将表达式分组,然后使用|来分隔它。看看我们的表达式,我们首先看到我们首先寻找(nnn)部分,如果没有匹配,我们会转而寻找nnn部分。

我们还说这个值可以后面跟着一个连字符、句号或空格。我们使用[-. ]来匹配列表中的单个字符。为了使这个测试是可选的,我们在末尾加上?

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

对于我们的目的,像这样的简单邮政编码和电话号码验证非常完美。在大型应用程序中,我们不希望依赖这些验证。这些只是测试看起来是否符合特定格式的数据;它们实际上并不检查它们是否属于真实地址或电话。如果我们的应用程序达到了一个阶段,我们实际上想要验证这些是否存在,我们将不得不连接到执行这些检查的服务。

在 React 组件中应用验证

在我们的模拟布局中,我们确定我们希望我们的验证出现在保存清除按钮下方。虽然我们可以在主组件内部完成这个操作,但我们将把我们的验证分离到一个单独的验证组件中。该组件将接收我们主组件的当前状态,在状态改变时应用验证,并返回我们是否可以保存我们的数据。

与我们创建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方法之前,对每个验证部分都进行验证。

在我们添加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 数据库

如果我们不能保存细节以便下次回到应用程序时使用,那将会是非常糟糕的体验。幸运的是,较新的 Web 浏览器提供了对一种称为 IndexedDB 的东西的支持,这是一个基于 Web 浏览器的数据库。使用这个作为我们的数据存储意味着当我们重新打开页面时,这些细节将可用。

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

接下来,我们将创建一个流畅的接口来添加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;来允许将操作链接在一起。

使用流畅的接口,不是所有的方法都需要是流畅的。如果你在接口上创建一个非流畅的方法,那就成为了调用链的终点。这有时用于需要设置一些属性然后构建具有这些属性的类的实例的类。

构建表的另一方面是从构建器获取值的能力。由于我们希望保持我们的流畅接口纯粹处理添加细节,我们将编写一个单独的接口来检索这些值并构建我们的 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语句。通过返回this,我们有能力将每个方法链接在一起。在我们添加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 成为标准的客户端数据库,它已经成为可以直接从 window 对象访问的内容。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;
    }
}

当我们阅读代码时,您会看到我选择将方法命名为CreateReadUpdateDelete。通常将前两个方法命名为LoadSave是相当常见的;然而,我故意选择了这些方法名,因为在与数据库中的数据工作时,我们经常使用CRUD 操作这个术语,其中CRUD指的是CreateReadUpdateDelete。通过采用这种命名约定,我希望这能够巩固这种联系。

我们要添加的第一个(也是最简单的)方法将允许我们将记录保存到数据库中。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时都会重新分配它。这段代码有趣的地方在于它与回调版本有多么相似。我们所做的只是改变返回类型,同时从方法签名中删除回调。我们承诺的响应部分充当回调的位置。

一般来说,如果我们的代码接受回调,我们可以通过返回一个将回调从方法签名中移动到承诺本身的承诺来将其转换为承诺。

我们的光标逻辑与我们依赖光标检查的逻辑相同,以查看我们是否有一个值,如果有,我们就将其推送到我们的数组上。当没有更多记录时,我们调用承诺上的响应,以便调用代码可以在承诺的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。如果成功完成,它会触发成功事件,这会导致我们的代码调用承诺上的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方法非常相似——唯一的真正区别是它只接受索引,告诉它在数据库中要“删除”哪一行:

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显示验证失败的方式,我们将map应用于people数组:
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的记录,因此这些已删除的记录将在重新加载列表时消失。这意味着,虽然我们在数据库代码中编写了一个删除方法,但我们实际上不会使用它。它作为一个方便的参考,您可能希望更改代码以执行硬删除,但这对我们的目的并不是必要的。

删除按钮将触发删除操作。由于此列表中可能有多个项目,并且我们不能假设用户在删除之前会选择一个人,因此我们需要在尝试删除之前从人员列表中找到该人。回顾渲染人员的代码,我们可以看到人员的 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来恢复记录。我们将利用我们可以在写成promise的代码上使用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();
}

我们需要连接的最后一个数据库操作是从保存按钮触发的。保存的操作取决于我们之前是否保存了记录,这可以通过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分配一个唯一值。为简单起见,我们将使用当前日期和时间。当我们将人员添加到数据库时,我们重新加载人员列表,并从编辑器中清除当前记录,以便用户不能通过再次点击“保存”来插入重复记录:

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

更新人员的代码利用了 promise 的特性,以便在保存完成后立即更新人员列表。在这种情况下,我们不需要清除当前记录,因为如果用户再次点击“保存”,我们不可能创建一个新记录,而只是更新当前记录:

  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());
  }
}

我们还需要涵盖一个最后的方法。您可能已经注意到,当我们点击“编辑”按钮时,我们没有办法选择并在文本框中显示用户。逻辑推断,按下按钮应该触发一个事件,将PersonId传递给事件处理程序,我们可以使用它从列表中找到相关的人;当使用删除按钮时,我们已经看到了这种行为类型,因此我们对代码的选择部分有了一个很好的想法。一旦我们有了这个人,我们调用setState来更新状态,这将通过绑定的力量更新显示:

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脚本版本。为了以 React 友好的方式利用 Bootstrap 4,我们添加了reactstrap包。

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

最后,我们研究了如何将个人信息保存在 IndexedDB 数据库中。这一部分首先是了解如何使用表构建器构建数据库和表,然后是如何操作数据库。我们学习了如何将基于回调的方法转换为使用 promises API 以提供异步支持,以及软删除和硬删除数据之间的区别。

在下一章中,我们将继续使用 Angular 与 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. 软删除和硬删除之间有什么区别?

进一步阅读

第四章:MEAN 堆栈 - 构建照片库

现在,几乎不可能编写 Node.js 应用程序而不听说 MEAN 堆栈。MEAN 是用来描述一组常用技术的缩写,这些技术用于客户端和服务器端构建具有持久服务器端存储的 Web 应用程序。构成MEAN堆栈的技术有MongoDBExpress(有时被称为Express.js)、AngularNode.js

我们准备在前几章中学到的知识的基础上构建一个使用 MEAN 堆栈的照片库应用程序。与以前的章节不同的是,在本章中我们不会使用 Bootstrap,而是更喜欢使用 Angular Material。

本章将涵盖以下主题:

  • MEAN 堆栈的组件

  • 创建我们的应用程序

  • 使用 Angular Material 创建 UI

  • 使用 Material 添加我们的导航

  • 创建文件上传组件

  • 使用服务来读取文件

  • 将 Express 支持引入我们的应用程序

  • 提供 Express 路由支持

  • 引入 MongoDB

  • 显示图片

  • 使用 RxJS 来观察图片

  • 使用HttpClient传输数据

技术要求

完成的项目可以从github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter04下载。

下载项目后,您将需要使用npm install安装软件包要求。

MEAN 堆栈

当我们使用 MEAN 堆栈这个术语时,我们指的是一组单独的 JavaScript 技术,它们一起创建跨客户端和服务器端的 Web 应用程序。MEAN 是核心技术的缩写:

  • MongoDB:这是一种称为文档数据库的东西,用于以 JSON 形式存储数据。文档数据库与关系数据库不同,因此如果您来自诸如 SQL Server 或 Oracle 之类的技术,可能需要一点时间来适应文档数据库的工作方式。

  • Express:这是一个在 Node.js 之上的后端 Web 应用程序框架。在堆栈中使用 Express 的想法是简化 Node.js 在服务器端提供的功能。虽然 Node.js 可以做 Express 所做的一切,但编写代码来执行诸如添加 cookie 或路由 Web 请求等操作的复杂性意味着 Express 的简化可以通过减少开发时间来帮助我们。

  • Angular:Angular 是一个客户端框架,用于运行应用程序的客户端部分。通常,Angular 用于创建单页应用程序SPA),在这种应用程序中,客户端的小部分会被更新,而不必在导航事件发生时重新加载整个页面。

  • Node.js:Node.js 是应用程序的服务器端运行环境。我们可以将其视为 Web 服务器。

以下图表显示了 MEAN 堆栈的组件在我们的应用程序架构中的位置。用户看到的应用程序部分,有时被称为前端,在这个图表中是客户端。我们应用程序的其余部分通常被称为后端,在图表中是 Web 服务器和数据库:

在使用 React 替代 Angular 时有一个等效的。它被称为 MERN 堆栈。

项目概述

在本章中,我们将要构建的项目将使我们了解如何编写服务器端应用程序,并向我们介绍流行的 Angular 框架。我们将构建一个图片库应用程序,用户可以上传图片并将它们保存在服务器端数据库中,以便以后再次查看。

只要你在 GitHub 存储库中与代码一起工作,这一章应该需要大约三个小时才能完成。完成的应用程序将如下所示:

本章不打算成为 MEAN 栈所有方面的全面教程。到本章结束时,我们只会开始涉及这些不同部分提供的一小部分内容。由于我们在这里介绍了许多主题,我们将更多地关注这些主题,而不是 TypeScript 的高级特性,因为这可能导致信息过载,但我们仍将涵盖通用约束和流畅代码等特性,尽管我们不会明确提到它们。在这一点上,我们应该足够熟悉它们,以便在遇到它们时能够识别它们。

入门

就像上一章一样,本章将使用可在 nodejs.org 上获得的 Node.js。我们还将使用以下组件:

  • Angular 命令行界面CLI)(我使用的版本是 7.2.2)

  • cors(版本 2.8.5 或更高)

  • body-parser(版本 1.18.3 或更高)

  • express(版本 4.16.4 或更高)

  • mongoose(版本 5.4.8 或更高)

  • @types/cors(版本 2.8.4 或更高)

  • @types/body-parser(版本 1.17.0 或更高)

  • @types/express(版本 4.16.0 或更高)

  • @types/mongodb(版本 3.1.19 或更高)

  • @types/mongoose(版本 5.3.11 或更高)

我们还将使用 MongoDB。Community Edition 可以在 www.mongodb.com/download-center/community 下载。

MongoDB 还配备了一个 GUI,使查看、查询和编辑 MongoDB 数据库变得更加容易。MongoDB Community Edition 可以从 www.mongodb.com/download-center/compass 下载。

使用 MEAN 栈创建 Angular 照片库

就像在之前的章节中一样,我们将从定义我们应用程序的需求开始:

  • 用户必须能够选择要传输到服务器的图片

  • 用户将能够为图片提供额外的元数据,如描述

  • 上传的图片将与元数据一起保存在数据库中

  • 用户将能够自动查看上传的图片

理解 Angular

Angular 是作为一个平台创建客户端应用程序的,使用 HTML 和 TypeScript 的组合。最初,Angular 是用 JavaScript 编写的(当时被称为 Angular.js),但它经历了完全的重写,使用 TypeScript,并重新命名为 Angular。Angular 本身的架构围绕着一系列模块,我们可以将其引入我们的应用程序或自己编写,其中可以包含我们可以用来构建客户端代码的服务和组件。

最初,Angular 的一个关键驱动因素是完全重新加载网页是一种浪费的做法。因此,许多网站都在提供相同的导航、标题、页脚、侧边栏等,每次用户导航到新页面时重新加载这些项目都是一种浪费,因为它们实际上并没有改变。Angular 帮助推广了一种被称为 SPAs 的架构,其中只有需要更改的页面的小部分才会实际更改。这减少了网页处理的流量量,因此,当正确完成时,客户端应用的响应性会增加。

以下截图显示了典型的 SPA 格式。页面的绝大部分是静态的,因此不需要重新发送,但中间的垃圾邮件部分将是动态的——只有那部分需要更新。这就是 SPAs 的美妙之处:

这并不意味着我们不能在 Angular 中创建多页面应用程序。这只是意味着,除非我们真正需要创建多页面应用程序,否则 Angular SPA 应用程序是我们应该编写 Angular 应用程序的方式。

现在我们已经了解了 Angular 的内容,我们可以继续使用 Angular 来编写我们的客户端。

创建我们的应用程序

除非您最近安装了 Angular,否则需要使用npm进行安装。我们要安装的部分是 Angular CLI。这为我们提供了从命令提示符中运行所需的一切,包括生成应用程序、添加组件、脚手架应用程序等等:

npm install -g @angular/cli

由于我们将开发客户端和服务器端代码,将代码放在一起会很有帮助;因此,我们将在一个共同的目录下创建ClientServer文件夹。任何 Angular 命令都将在Client文件夹中运行。在客户端和服务器端之间共享代码是相当常见的,因此这种安排是保持应用程序在一起并简化共享的简单方法。

使用ng new命令轻松创建一个带有 Angular 的应用程序,该命令在添加 Angular CLI 时已经添加到我们的系统中。我们将指定命令行参数来选择 SCSS 生成我们的 CSS,以及选择我们要为创建的任何组件指定的前缀:

ng new Chapter04 --style scss --prefix atp

我选择遵循的命名约定反映了书名,因此我们使用atp来反映Advanced TypeScript Projects。虽然在本章中我们不会大量使用 CSS,但我更倾向于使用 SCSS 作为我的 CSS 预处理器,而不是使用原始 CSS,因为它具有丰富的语法,可以使用诸如样式混合等内容,这意味着这是我默认使用的样式引擎。我们选择使用atp前缀的原因是为了使我们的组件选择器唯一。假设我们有一个组件想要称为 label;显然,这将与内置的 HTML label 冲突。为了避免冲突,我们的组件选择器将是atp label。由于 HTML 控件从不使用连字符,我们保证不会与现有的控件选择器发生冲突

我们将接受安装默认值,因此在提示是否添加 Angular 路由支持时,只需按Enter。安装完成后,我们将启动我们的 Angular 服务器,它还会监视文件是否更改并实时重建应用程序。通常,在执行此部分之前,我会安装所有所需的组件,但是看到 Angular 给我们提供的起点以及查看实时更改的能力是非常有用的:

ng serve --open

与 React 不同,打开我们的应用程序的默认网址是http://localhost:4200。当浏览器打开时,它会显示默认的 Angular 示例页面。显然,我们将从中删除很多内容,但在短期内,我们将保持此页面不变,同时开始添加一些我们需要的基础设施。

Angular 为我们创建了许多文件,因此值得确定我们将与之最多一起使用的文件以及它们的作用。

App.Module.ts

在开发大型 Angular 应用程序的过程中,特别是如果我们只是众多团队中开发同一整体应用程序的一部分,将它们分解为模块是很常见的。我们可以将此文件视为我们进入组合模块的入口点。对于我们的目的,我们对@NgModule覆盖的模块定义中的两个部分感兴趣。

第一部分是declarations部分,告诉 Angular 我们开发了哪些组件。对于我们的应用程序,我们将开发三个组件,它们属于这里——AppComponent(默认添加),FileuploadComponentPageBodyComponent。幸运的是,当我们使用 Angular CLI 生成组件时,它们的声明会自动添加到此部分中。

我们感兴趣的另一部分是imports部分。这告诉我们需要导入到我们的应用程序中的外部模块。我们不能只是在我们的应用程序中引用外部模块的功能;我们实际上必须告诉 Angular 我们将使用该功能所来自的模块。这意味着当我们部署应用程序时,Angular 非常擅长最小化我们的依赖关系,因为它只会部署我们已经说过我们在使用的模块。

当我们阅读本章时,我们将在这一部分添加项目,以启用诸如 Angular Material 支持之类的功能。

使用 Angular Material 来构建我们的 UI

我们的应用程序的前端将使用一个叫做 Angular Material 的东西,而不是依赖于 Bootstrap。我们将研究 Material,因为它在 Angular 应用程序中被广泛使用;因此,如果你要商业化地开发 Angular,有很大的机会你会在职业生涯中的某个时候使用它。

Angular Material 是由 Angular 团队构建的,旨在将 Material Design 组件带到 Angular。它们的理念是,它们能够无缝地融入到 Angular 开发过程中,以至于使用它们感觉和使用标准 HTML 组件没有什么不同。这些设计组件远远超出了我们可以用单个标准控件做的事情,因此我们可以轻松地使用它们来构建复杂的导航布局,等等。

Material 组件将行为和视觉外观结合在一起,这样,我们可以直接使用它们来轻松创建专业外观的应用程序,而我们自己的工作量很小。在某种程度上,Material 可以被认为是一种类似于使用 Bootstrap 的体验。在本章中,我们将集中使用 Material 而不是 Bootstrap。

几段文字前,我们轻率地提到 Angular Material 将 Material Design 组件带到了 Angular。在我们了解 Material Design 是什么之前,这是一个很大程度上的循环陈述。如果我们在谷歌上搜索这个词,我们会得到很多文章告诉我们 Material Design 是谷歌的设计语言。

当然,如果我们进行 Android 开发,这个术语会经常出现,因为 Android 和 Material 基本上是相互关联的。Material 的理念是,如果我们能以一致的方式呈现界面元素,那么对我们的用户来说是最有利的。因此,如果我们采用 Material,我们的应用程序将对于习惯于诸如 Gmail 之类的应用程序的用户来说是熟悉的。

然而,“设计语言”这个术语太模糊了。对我们来说它实际上意味着什么?为什么它有自己的花哨术语?就像我们自己的语言被分解和结构化成单词和标点符号一样,我们可以将视觉元素分解成结构,比如颜色和深度。举个例子,语言告诉我们颜色的含义,所以如果我们在应用程序的一个屏幕上看到一个按钮是一个颜色,那么在应用程序的其他屏幕上它应该有相同的基本用法;我们不会在一个对话框上用绿色按钮表示“确定”,然后在另一个对话框上表示“取消”。

安装 Angular Material 是一个简单的过程。我们运行以下命令来添加对 Angular Material、组件设计工具包CDK)、灵活的布局支持和动画支持的支持:

ng add @angular/material @angular/cdk @angular/animation @angular/flex-layout

在安装库的过程中,我们将被提示选择要使用的主题。主题最显著的方面是应用的颜色方案。

我们可以从以下主题中进行选择(主题的示例也已提供):

对于我们的应用程序,我们将使用 Indigo/Pink 主题。

我们还被提示是否要添加 HammerJS 支持。这个库提供了手势识别,这样我们的应用程序就可以响应诸如触摸或鼠标旋转等操作。最后,我们必须选择是否要为 Angular Material 设置浏览器动画。

CDK 是一个抽象,它说明了常见 Material 功能的工作原理,但并不说明它们的外观。如果没有安装 CDK,Material 库的许多功能就无法正常工作,因此确保它与@angular/material一起安装非常重要。

使用 Material 添加导航

我们会一遍又一遍地看到,我们需要做的许多事情来为我们的应用程序添加功能,都需要从app.module.ts中开始。Material 也不例外,所以我们首先添加以下import行:

import { LayoutModule } from '@angular/cdk/layout';
import { MatToolbarModule, MatButtonModule, MatSidenavModule, MatIconModule, MatListModule } from '@angular/material';

现在,这些模块对我们可用,我们需要在NgModuleimport部分中引用它们。在这一部分列出的任何模块都将在我们应用程序的模板中可用。例如,当我们添加侧边导航支持时,我们依赖于我们已经在这一部分中使MatSidenavModule可用:

imports: [
  ...
 LayoutModule,
 MatToolbarModule,
 MatButtonModule,
 MatSidenavModule,
 MatIconModule,
 MatListModule,
]

我们将设置我们的应用程序使用侧边导航(出现在屏幕侧边的导航条)。在结构上,我们需要添加三个元素来启用侧边导航:

  • mat-sidenav-container 用于承载侧边导航

  • mat-sidenav 用于显示侧边导航

  • mat-sidenav-content 以添加我们要显示的内容

首先,我们将在app.component.html页面中添加以下内容:

<mat-sidenav-container class="sidenav-container">
  <mat-sidenav #drawer class="sidenav" fixedInViewport="true" [opened]="false">
  </mat-sidenav>
  <mat-sidenav-content>
  </mat-sidenav-content>
</mat-sidenav-container>

mat-sidenav 行设置了我们将利用的一些行为。我们希望导航固定在视口中,并通过#drawer的使用给它设置了 drawer 的 ID。我们将很快使用这个 ID,当我们触发抽屉是打开还是关闭的切换时。

这一行可能最有趣的部分是[opened]="false"。这是我们在应用程序中遇到绑定的第一个点。这里的[]告诉我们,我们要绑定到一个特定的属性,这种情况下是opened,并将其设置为false。当我们在本章中逐步学习时,会发现 Angular 有丰富的绑定语法。

现在我们有了容器来容纳我们的导航,我们将添加侧边导航内容。我们将添加一个工具栏来容纳Menu文本和一个导航列表,允许用户导入图像。

<mat-toolbar>Menu</mat-toolbar>
<mat-nav-list>
  <a mat-list-item>Import Image</a>
</mat-nav-list>

在标准锚标签中使用mat-list-item只是告诉 Material 引擎,我们要在列表中放置锚点。实际上,这一部分是一个使用 Material 样式进行样式化的锚点无序列表。

现在,我们要添加切换导航的功能。我们这样做的方式是在导航内容区域添加一个工具栏。这个工具栏将承载一个按钮,触发侧边导航抽屉的打开。在mat-sidenav-content部分,添加以下内容:

<mat-toolbar color="primary">
  <button type="button" aria-label="Toggle sidenav" mat-icon-button (click)="drawer.toggle()">
    <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
  </button>
</mat-toolbar>

按钮在这里使用了另一个绑定的例子——在这种情况下,对click事件做出反应——以触发具有drawerID 的mat-sidenav项目上的toggle操作。我们不再使用[eventName]来绑定命令,而是使用(eventName)。在按钮内部,我们使用mat-icon来表示用于切换导航的图像。与 Material 设计代表一种常见的应用程序显示方式的理念一致,Angular Material 为我们提供了许多标准图标,如menu

我们使用的 Material 字体代表了某些单词,比如 home 和 menu,通过一种叫做连字的东西来表示特定的图像。这是一个标准的排版术语,意思是有一些众所周知的字母、数字和符号的组合可以被表示为图像。例如,如果我们有一个带有文本homemat-icon,这将被表示为一个 home 图标。

创建我们的第一个组件 - FileUpload 组件

我们导航栏上的导入图像链接实际上必须做一些事情,所以我们将编写一个将显示在对话框中的组件。由于我们将要上传一个文件,我们将称其为FileUpload,创建它就像运行以下 Angular CLI 命令一样简单:

ng generate component components/fileupload

如果我们愿意,我们可以缩短这些标准的 Angular 命令,所以我们可以使用ng g c代替ng generate component

这个命令为我们创建了四个文件:

  • fileupload.component.html:我们组件的 HTML 模板。

  • fileupload.component.scss:我们需要将其转换为组件的 CSS 的任何内容。

  • fileupload.component.spec.ts:现在,当我们想要对我们的 Angular 应用运行单元测试时,会使用spec.ts文件。适当地测试 Web 应用程序超出了本书的范围,因为这本书本身就是一本书。

  • fileupload.component.ts:组件的逻辑。

运行ng命令生成组件还会导致它被添加到app.module.ts中的declarations部分。

当我们打开fileupload.component.ts时,结构大致如下(忽略顶部的导入):

@Component({
  selector: 'atp-fileupload',
  templateUrl: './fileupload.component.html',
  styleUrls: ['./fileupload.component.scss']
})
export class FileuploadComponent implements OnInit {
  ngOnInit() {
  }
}

在这里,我们可以看到 Angular 充分利用了我们已经了解的 TypeScript 特性。在这种情况下,FileuploadComponent有一个Component装饰器,告诉 Angular 当我们想在 HTML 中使用FileuploadComponent实例时,我们使用atp-fileupload。由于我们使用了单独的 HTML 模板和样式,@Component装饰器的其他部分标识了这些元素的位置。我们可以直接在这个类中定义样式和模板,但一般来说,最好将它们分开到它们自己的文件中。

我们可以在这里看到我们的命名约定,在创建应用程序时指定了atp。使用有意义的东西是个好主意。在团队中工作时,您应该了解您的团队遵循的标准是什么,如果没有标准,您应该花时间商定如何在前期命名。

对话框的一个特性是它会向我们显示用户选择的图像的预览。我们将把读取图像的逻辑从组件中分离出来,以保持关注点的清晰分离。

使用服务预览文件

开发 UI 应用程序的一个挑战是,逻辑往往会渗入视图中,这是不应该出现的。我们知道视图将调用它,所以把一部分逻辑放在我们的ts视图文件中变得很方便,但它做的事情对客户端没有任何可见的影响。

例如,我们可能想要将一些 UI 中的值写回服务器。与视图相关的部分只有数据部分;实际写入服务器是完全不同的责任。如果我们有一个简单的方法来创建外部类,我们可以在需要的地方注入它们,这对我们是有用的,这样我们就不需要担心如何实例化它们。它们只是在我们需要它们时可用。幸运的是,Angular 的作者们看到了这一点,并为我们提供了服务。

一个service只是一个使用@Injectable装饰器的类,并在模块的declarations部分中有一个条目。除了这些要求,没有其他需要的东西,所以如果需要的话,我们可以轻松手工制作这个类。虽然我们可以这样做,但实际上没有真正的理由,因为 Angular 帮助我们使用以下命令生成service

ng generate service <<servicename>>

创建service时,实际上我们不必在名称后面添加service,因为这个命令会自动为我们添加。为了看到这是如何工作的,我们将创建一个service,它接受使用文件选择器选择的文件,然后读取它,以便可以在图像上传对话框和主屏幕上显示,或者传输到数据库中保存。我们从以下命令开始:

ng generate service Services/FilePreviewService.

我喜欢在Services子文件夹中生成我的services。将其放在文件名中会在Services文件夹中创建它。

ng generate service命令给我们提供了以下基本概述:

import { Injectable } from '@angular/core';
@Injectable({
 providedIn: 'root'
})
export class FilePreviewService {
}

读取文件可能是一个耗时的过程,所以我们知道我们希望这个操作是异步发生的。正如我们在前面的章节中讨论的,我们可以使用回调来做到这一点,但更好的方法是使用Promise。我们将以下方法调用添加到service中:

public async Preview(files: any): Promise<IPictureModel> {
}

因为这是我们要读取文件的时候,这是我们要创建模型的时候,我们将使用它来传递数据到我们的应用程序。我们将要使用的模型看起来像这样:

export interface IPictureModel {
 Image: string;
 Name: string;
 Description: string;
 Tags: string;
}
export class PictureModel implements IPictureModel {
 Image: string;
 Name: string;
 Description: string;
 Tags: string;
}

Image保存我们要读取的实际图像,Name是文件的名称。这就是为什么我们在这一点上填充这个模型;我们正在处理文件本身,所以这是我们拥有文件名的时候。DescriptionTags字符串将由图像上传组件添加。虽然我们可以在那时创建一个交集类型,但对于一个简单的模型来说,有一个单一的模型来保存它们就足够了。

我们已经说过我们使用Promise,这意味着我们需要从我们的Preview方法中return 一个适当的Promise

return await new Promise((resolve, reject) => {});

Promise内部,我们将创建我们模型的一个实例。作为良好的实践,我们将添加一些防御性代码,以确保我们有一个图像文件。如果文件不是图像文件,我们将拒绝它,这可以由调用代码优雅地处理:

if (files.length === 0) {
  return;
}
const file = files[0];
if (file.type.match(/image\/*/) === null) {
  reject(`The file is not an image file.`);
  return;
}
const imageModel: IPictureModel = new PictureModel();

当我们到达这一点时,我们知道我们有一个有效的文件,所以我们将使用文件名在模型中设置名称,并使用FileReader使用readAsDataURL读取图像。当读取完成时,将触发onload事件,允许我们将图像数据添加到我们的模型中。此时,我们可以解决我们的承诺:

const reader = new FileReader();
reader.onload = (evt) => {
  imageModel.Image = reader.result;
  resolve(imageModel);
};
reader.readAsDataURL(file);

在对话框中使用服务

现在我们有一个工作的preview服务,我们可以在我们的对话框中使用它。为了使用它,我们将把它传递到我们的构造函数中。由于服务是可注入的,我们可以让 Angular 负责为我们注入它,只要我们在构造函数中添加一个适当的引用。同时,我们还将在对话框本身中添加一个引用,以及一组将在相应 HTML 模板中使用的声明:

protected imageSource: IPictureModel | null;
protected message: any;
protected description: string;
protected tags: string;

constructor(
  private dialog: MatDialogRef<FileuploadComponent>,
  private preview: FilePreviewService) { }

允许 Angular 自动构建具有依赖关系的构造函数,而无需我们明确使用new实例化它们的技术称为依赖注入。这个花哨的术语简单地意味着我们告诉 Angular 我们的类需要什么,然后让 Angular 来构建那个类的对象。实际上,我们告诉 Angular 我们需要什么,而不用担心它将如何构建。构建类的行为可能导致非常复杂的内部层次结构,因为依赖注入引擎可能不得不构建我们的代码依赖的类。

有了这个参考,我们将创建一个方法来接受文件上传组件的文件选择并调用我们的Preview方法。catch用于适应我们在服务中的防御性编码,以及适应用户尝试上传非图像文件的情况。如果文件无效,对话框将显示一条消息通知用户:

public OnImageSelected(files: any): void {
  this.preview.Preview(files).then(r => {
    this.imageSource = r;
  }).catch(r => {
    this.message = r;
  });
}

对话框的代码部分的最后一件事是允许用户关闭对话框并将选定的值传回到调用代码。我们使用相关的本地值更新图像源描述和标签。close方法关闭当前对话框并将imageSource返回给调用代码:

public Save(): void {
  this.imageSource.Description = this.description;
  this.imageSource.Tags = this.tags;
  this.dialog.close(this.imageSource);
}

文件上传组件模板

我们组件的最后一部分工作是fileupload.component.html中的实际 HTML 模板。由于这将是一个 Material 对话框,我们将在这里使用许多 Material 标签。其中最简单的标签用于添加对话框标题,这是一个带有mat-dialog-title属性的标准标题标签。使用此属性的原因是将标题锚定在对话框顶部,以便如果有任何滚动,标题将保持固定在原位:

<h2 mat-dialog-title>Choose image</h2>

将标题锚定在顶部后,我们准备添加内容和操作按钮。首先,我们将使用mat-dialog-content标签添加内容:

<mat-dialog-content>
  ...
</mat-dialog-content>

我们内容中的第一个元素是如果组件代码中设置了消息,则将显示的消息。用于显示消息是否显示的测试使用另一个 Angular 绑定*ngIf。在这里,Angular 绑定引擎评估表达式,并在表达式为真时呈现出值。在这种情况下,它正在检查消息是否存在。也许不会让人惊讶的是,看起来有趣的{{}}代码也是一个绑定。这个用于写出被绑定的项目的文本,这种情况下是消息:

<h3 *ngIf="message">{{message}}</h3>

变化的下一部分是我最喜欢的应用程序的一部分。标准 HTML 文件组件没有 Material 版本,因此如果我们想显示一个现代外观的等效组件,我们必须将文件输入显示为隐藏组件,并欺骗它认为在用户按下 Material 按钮时已被激活。文件上传输入被赋予fileUploadID,并在按钮被点击时使用(click)="fileUpload.click()"触发。当用户选择某物时,更改事件触发我们几分钟前编写的OnImageSelected代码:

  <button class="mat-raised-button mat-accent" md-button (click)="fileUpload.click()">Upload</button>
  <input hidden #fileUpload type="file" accept="image/*" (change)="OnImageSelected(fileUpload.files)" />

添加图像预览就像添加一个绑定到成功读取图像时创建的预览图像的img标签一样简单:

<div>
  <img src="{{imageSource.Image}}" height="100" *ngIf="imageSource" />
</div>

最后,我们需要添加用于读取标签和描述的字段。我们将这些放在mat-form-field部分内。matInput告诉模板引擎应该放置什么样式以用于文本输入。最有趣的部分是使用[(ngModel)]="..."部分。这为我们应用了模型绑定,告诉绑定引擎从我们的底层 TypeScript 组件代码中使用哪个字段:

<mat-form-field>
  <input type="text" matInput placeholder="Add tags" [(ngModel)]="tags" />
</mat-form-field>
<mat-form-field>
  <input matInput placeholder="Description" [(ngModel)]="description" />
</mat-form-field>

如果您之前使用过早期版本的 Angular(6 版之前),您可能已经遇到formControlName作为绑定值的一种方式。在 Angular 6+中,尝试结合formControlNamengModel不再起作用。有关更多信息,请参见next.angular.io/api/forms/FormControlName#use-with-ngmodel

mat-form-field需要关联一些样式。在fileupload.component.scss文件中,我们添加.mat-form-field { display: block; }来对字段进行样式设置,使其显示在新行上。如果我们忽略这一点,输入字段将并排显示。

有一个对话框我们无法关闭,或者无法将值返回给调用代码是没有意义的。我们应该遵循这样的操作约定,将我们的保存和取消按钮放在mat-dialog-actions部分。取消按钮标记为mat-dialog-close,这样它就会为我们关闭对话框,而无需我们采取任何操作。保存按钮遵循我们现在应该熟悉的模式,当检测到按钮点击时,在我们的组件代码中调用Save方法:

<mat-dialog-actions>
  <button class="mat-raised-button mat-primary" (click)="Save()">Save</button>
  <button class="mat-raised-button" mat-dialog-close>Cancel</button>
</mat-dialog-actions>

我们已经到了需要考虑用户选择的图像将存储在何处以及将从何处检索的地步。在上一章中,我们使用了客户端数据库来存储我们的数据。从现在开始,我们将开始处理服务器端代码。我们的数据将存储在一个 MongoDB 数据库中,所以现在我们需要看看如何使用 Node.js 和 Express 来连接 MongoDB 数据库。

引入 Express 支持到我们的应用程序

当我们使用 Node.js 开发客户端/服务器应用程序时,如果我们能够使用一个允许我们开发服务器端部分的框架,尤其是如果它带有丰富的插件功能生态系统,覆盖诸如连接到数据库和处理本地文件系统等功能,那将会让我们的生活变得更加轻松。这就是 Express 发挥作用的地方;它是一个中间件框架,与 Node.js 完美地配合在一起。

由于我们将完全从头开始创建我们的服务器端代码,我们应该从创建基本的tsconfig.jsonpackage.json文件开始。为此,在Server文件夹中运行以下命令,这也将通过导入 Express 和 TypeScript Express 定义来添加 Express 支持:

tsc --init
npm init -y
npm install express @types/express parser @types/body-parser --save

在我们的tsconfig.json文件中有许多不必要的选项。我们只需要最基本的选项,所以我们将我们的配置设置为如下所示:

{
  "compilerOptions": {
    "target": "es2015",
    "module": "commonjs",
    "outDir": "./dist",
    "strict": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true
  },
}

我们的服务器端代码将以一个名为Server的类开始。这个类将import express

import express from "express";

为了创建一个 Express 应用程序的实例,我们将在构造函数中创建一个名为app的私有实例,并将其设置为express()。这样做的效果是为我们初始化 Express 框架。

构造函数还接受一个端口号,我们将在Start方法中告诉我们的应用程序监听这个端口。显然,我们需要响应 web 请求,所以当我们的应用程序从/接收到一个get请求时,我们将使用send来向网页发送一条消息作为响应。在我们的例子中,如果我们导航到http://localhost:3000/,这个方法接收到的网页 URL 是根目录,调用的函数返回Hello from the server给客户端。如果我们浏览的不是/,我们的服务器将会响应404

export class Server {
  constructor(private port : number = 3000, private app : any = express()) {
  }

  public Start() : void {
    this.OnStart();
    this.app.listen(this.port, () => console.log(`Express server running on port ${this.port}`));
  }

  protected OnStart() : void {
    this.app.get(`/`, (request : any, response : any) => res.send(`Hello from the server`));
  }
}

要启动我们的服务器,我们必须给它要提供内容的端口,并调用Start

new Server(3000).Start();

我们之所以从Server类开始,而不是遵循大多数 Node.js/Express 教程在互联网上看到的方法,是因为我们希望构建一些基础,以便在未来的章节中能够重复使用。这一章代表了这个类的起点,未来的章节将会在我们这里所做的基础上增强服务器的功能。

在当前状态下,服务器将无法处理来自 Angular 的任何传入请求。现在是时候开始增强服务器,以便它能够处理来自客户端的请求。当客户端发送其数据时,它将以 JSON 格式的请求传递过来。这意味着我们需要告诉服务器接收请求,并在我们看到的任何请求的主体中公开它。

当我们很快涵盖路由时,我们将看到一个例子,我们将完整地接收request.Body。我们必须意识到的一件事是,我们将从 Angular 接收大量请求;照片可能占用大量空间。默认情况下,body 解析器的限制为 100 KB,这不够大。我们将提高请求大小的限制为 100 MB,这应该足够处理我们想要放在图片库中的任何图像:

public Start(): void {
  this.app.use(bodyParser.json({ limit: `100mb` }));
  this.app.use(bodyParser.urlencoded({ limit: `100mb`, extended: true }));
  this.OnStart();
  this.app.listen(this.port, () => console.log(`Express server running on port ${this.port}`));
}

现在我们正在讨论从 Angular 传递过来的数据,我们需要考虑我们的应用程序是否接受这些请求。在我们讨论服务器如何根据请求执行哪些操作之前,我们需要解决一个叫做跨域请求共享CORS)的问题。

使用 CORS,我们允许已知的外部位置访问我们站点上的受限操作。由于 Angular 是从与我们的 Web 服务器不同的站点运行的(localhost:4200而不是localhost:3000),我们需要启用 CORS 支持以进行 post;否则,当我们从 Angular 发出请求时,我们将不返回任何内容。我们必须做的第一件事是将cors中间件添加到我们的 Node.js 服务器中:

npm install cors @types/cors --save

添加 CORS 支持就像告诉应用程序使用 CORS 一样简单:

public WithCorsSupport(): Server {
    this.app.use(cors());
    return this;
}

CORS 支持提供了许多我们不需要利用的微调。例如,它允许我们设置允许的请求方法类型,使用Access-Control-Allow-Methods

现在我们可以接受来自 Angular 的请求,我们需要建立机制将请求路由到适当的请求处理程序。

提供路由支持

每当请求进入我们的 Web 服务器时,我们都必须确定要发送的响应。我们正在构建的东西将响应 post 和接收请求,这类似于我们构建 REST API 的方式。将传入请求路由到响应的能力称为路由。我们的应用程序将处理三种类型的请求:

  • 在 URL 中带有add作为 URL 的POST请求(换句话说,当我们看到http://localhost:3000/add/时)。这将向数据库添加图像和相关详细信息。

  • 在 URL 中带有getGET请求(如http://localhost:3000/get/)。这获取所有保存的图片的 ID,并将这些 ID 的数组返回给调用者。

  • 在 URL 中带有/id/GET请求。这在 URL 中使用了一个额外的参数来获取要发送回客户端的单个图片的 ID。

我们返回 ID 数组的原因是单个图像可能很大。如果我们尝试一次返回所有图像,我们将减慢客户端显示图像的速度,因为它们可以在加载时显示。我们还可能违反我们传回的响应的大小限制。在处理大块数据时,值得看看如何最小化每个请求传输的内容。

每个请求的目的对应于我们要执行的唯一操作。这给了我们一个提示,我们应该能够将每个路由拆分为一个什么都不做的单个类。为了强制执行单个操作,我们定义了我们希望我们的路由类使用的接口:

export interface IRouter {
  AddRoute(route: any): void;
}

我们将添加一个辅助类,负责实例化每个路由器实例。该类开始得足够简单,创建一个IRouter数组,将路由实例添加到其中:

export class RoutingEngine {
  constructor(private routing: IRouter[] = new Array<IRouter>()) {
  }
}

我们使用的方法让实例添加变得有趣。我们要做的是接受一个通用类型作为参数,并实例化该类型。为此,我们必须利用 TypeScript 的一个特性,允许我们接受一个通用类型,并指定当对其调用new时,它返回该类型的实例。

由于我们在类型上指定了通用约束,我们只接受IRouter实现:

public Add<T1 extends IRouter>(routing: (new () => T1), route: any) {
  const routed = new routing();
  routed.AddRoute(route);
  this.routing.push(routed);
}

传递给该方法的路由来自 Express。 这是我们告诉我们的应用程序使用的路由器实例。

现在我们已经在路由支持中就位,我们需要编写与我们之前确定的路由请求对应的类。 我们要查看的第一个是接受add post 的类:

export class AddPictureRouter implements IRouter {
  public AddRoute(route: any): void {
    route.post('/add/', (request: Request, response: Response) => {

  }
}

这种方法通过声明当我们收到一个/add/ post 时,我们将接受请求,处理它,并发送响应回来来工作。 我们如何处理请求取决于我们,但无论路由何时确定我们在这里有匹配项,我们将执行此方法。 在此方法中,我们将创建图片的服务器端表示并将其保存到数据库中。

对于我们的应用程序,我们只引入了 Express 路由。 Angular 有自己的路由引擎,但就我们想要在我们的代码中放置的内容而言,我们不需要它。 在第五章中,使用 GraphQL 和 Apollo 的 Angular ToDo 应用程序,我们介绍了 Angular 路由。

介绍 MongoDB

使用 MongoDB 需要我们使用诸如流行的 Mongoose 包之类的东西。 安装 Mongoose 需要我们添加mongoose@types/mongoose包:

npm install mongoose @types/mongoose --save-dev

在我们对数据库进行任何操作之前,我们需要创建一个模式来表示我们要保存到数据库中的对象。 不幸的是,这就是当我们使用 MEAN 开发应用程序时事情可能变得有点乏味的地方。 虽然模式表面上代表了我们在 Angular 端创建的模型,但它不是相同的模型,因此我们必须再次输入它。

更重要的是,这意味着如果我们更改我们的 Angular 模型,我们必须重新生成我们的 MongoDB 模式以与更改相适应。

export const PictureSchema = new Schema({
  Image: String,
  Name: String,
  Description: String,
  Tags: String,
});

对于我们的应用程序,我们将保留数据库中的图像—在Image字段中—因为这简化了我们必须放置的基础设施。 在商业级应用程序中,我们将选择将实际图像存储到数据库之外,并且Image字段将指向图像的物理位置。 图像的位置必须对我们的 Web 应用程序可访问,并且必须有政策确保图像得到安全备份并且可以轻松恢复。

有了模式,我们想创建一个代表它的模型。 想象一下模型和模式之间的交互的一个好方法是,模式告诉我们我们的数据应该是什么样子。 模型告诉我们我们想要如何使用数据库来操作它:

export const Picture = mongoose.model('picture', PictureSchema);

现在我们已经准备好模型,我们需要建立与数据库的连接。 MongoDB 数据库的连接字符串有自己的协议,因此它以mongodb://模式开头。 对于我们的应用程序,我们将使 MongoDB 在与我们的服务器端代码相同的服务器上运行; 对于更大的应用程序,我们确实希望将它们分开,但现在,我们将在连接字符串中使用localhost:27017,因为 MongoDB 正在侦听端口27017

由于我们希望能够在 MongoDB 中托管许多数据库,因此告诉引擎要使用哪个数据库的机制将作为连接字符串的一部分提供数据库名称。 如果数据库不存在,它将被创建。 对于我们的应用程序,我们的数据库将被称为packt_atp_chapter_04

export class Mongo {
  constructor(private url : string = "mongodb://localhost:27017/packt_atp_chapter_04") {
  }

  public Connect(): void {
    mongoose.connect(this.url, (e:any) => {
      if (e) {
        console.log(`Unable to connect ` + e);
      } else {
        console.log(`Connected to the database`);
      }
    });
  } 
}

只要在我们尝试在数据库内部执行任何操作之前调用Connect,我们的数据库应该可供我们使用。 在内部,Connect使用我们的连接字符串调用mongoose.connect

回到我们的路由

有了可用的Picture模型,我们可以直接从我们的add路由内部填充它。请求体包含与我们的模式相同的参数,因此对我们来说映射是不可见的。当它被填充后,我们调用save方法。如果有错误,我们将把错误发送回客户端;否则,我们将把图片发送回客户端:

const picture = new Picture(request.body);
picture.save((err, picture) => {
  if (err) {
    response.send(err);
  }
  response.json(picture);
});

在生产应用程序中,我们实际上不希望将错误发送回客户端,因为这会暴露我们应用程序的内部工作。对于一个小型应用程序,仅用于我们自己使用,这不是一个问题,这是一种确定我们应用程序出了什么问题的有用方式,因为我们可以简单地在浏览器控制台窗口中查看错误。从专业角度来看,我建议对错误进行消毒,并发送一个标准的 HTTP 响应之一。

get请求的处理程序并不复杂。它以与add路由类似的方式开始:

export class GetPicturesRouter implements IRouter {
  public AddRoute(route: any): void {
    route.get('/get/', (request: Request, response: Response) => {

    });
  }
}

RequestResponse类型在我们的路由中来自 Express,因此它们应该作为类中的imports添加。

我们试图做的是获取用户上传的图片的唯一列表。在内部,每个模式都添加了一个_id字段,因此我们将使用Picture.distinct方法来获取这些 ID 的完整列表,然后将其发送回客户端代码:

Picture.distinct("_id", (err, picture) => {
  if (err) {
    response.send(err);
  }
  response.send(pic);
});

我们需要放置的最后一个路由是获取单个 ID 请求并从数据库中检索相关项目。使这个类比前面的类稍微复杂的是,我们需要稍微操纵模式以在将数据传输回客户端之前排除_id字段。

如果我们没有删除这个字段,我们的客户端将收到的数据将无法匹配它所期望的类型,因此它将无法自动填充一个实例。这将导致我们的客户端即使收到了数据,也不会显示这些数据,除非我们在客户端手动填充它:

export class FindByIdRouter implements IRouter {
  public AddRoute(route: any): void {
    route.get('/id/:id', (request: Request, response: Response) => {
    });
  }
}

带有:id的语法告诉我们,我们将在这里接收一个名为id的参数。请求公开了一个params对象,该对象将把此参数公开为id

我们知道我们收到的id参数是唯一的,因此我们可以使用Picture.findOne方法从数据库中检索匹配的条目。为了在发送回客户端的结果中排除_id字段,我们必须在参数中使用-_id来删除它:

Picture.findOne({ _id: request.params.id }, '-_id', (err, picture) => {
  if (err) {
    response.send(err);
  }
  response.json(picture);
});

此时,Server类需要额外的关注。我们已经创建了RoutingEngineMongo类,但在Server类中没有任何东西来连接它们。通过扩展构造函数来添加它们的实例,这很容易解决。我们还需要添加一个调用Startconnect到数据库。如果我们将我们的Server类更改为抽象类,并添加一个AddRouting方法,我们将阻止任何人直接实例化服务器。

我们的应用程序将需要从这个类派生,并使用RoutingEngine类添加他们自己的路由实现。这是将服务器分解为更小的离散单元并分离责任的第一步。Start方法中的一个重大变化是,一旦我们添加了我们的路由,我们告诉应用程序使用与我们的路由引擎相同的express.Router(),因此任何请求都会自动连接起来:

constructor(private port: number = 3000, private app: any = express(), private mongo: Mongo = new Mongo(), private routingEngine: RoutingEngine = new RoutingEngine()) {}

protected abstract AddRouting(routingEngine: RoutingEngine, router: any): void;

public Start() : void {
  ...
  this.mongo.connect();
  this.router = express.Router();
  this.AddRouting(this.routingEngine, this.router);
  this.app.use(this.router);
  this.OnStart();
  this.app.listen(this.port, () => console.log(`Express server running on port ${this.port}`));
}

有了这个设置,我们现在可以创建一个具体的类,该类扩展了我们的Server类,并添加了我们创建的路由。这是我们运行应用程序时将启动的类:

export class AdvancedTypeScriptProjectsChapter4 extends Server {
  protected AddRouting(routingEngine: RoutingEngine, router: any): void {
    routingEngine.Add(AddPictureRouter, router);
    routingEngine.Add(GetPicturesRouter, router);
    routingEngine.Add(FindByIdRouter, router);
  }
}

new AdvancedTypeScriptProjectsChapter4(3000).WithCorsSupport().Start();

不要忘记删除原始调用以启动new Server(3000).Start();服务器。

我们的服务器端代码已经完成。我们不打算为其添加更多功能,因此我们可以回到客户端代码。

显示图片

在我们辛苦编写了服务器端代码并让用户选择要上传的图片之后,我们需要一些东西来实际显示这些图片。我们将创建一个PageBody组件,将其显示并添加为主导航中的一个元素。同样,我们将让 Angular 来完成这项艰苦的工作,并为我们创建基础设施。

ng g c components/PageBody

创建了这个组件后,我们将按以下方式更新app.component.html,添加PageBody组件:

...
      <span>Advanced TypeScript</span>
    </mat-toolbar>
    <atp-page-body></atp-page-body>
  </mat-sidenav-content>
</mat-sidenav-container>

当我们安装 Material 支持时,我们添加的一个功能是 Flex 布局,它为 Angular 提供了灵活的布局支持。我们将通过在我们的应用程序中设置卡片的布局,最初以每行三个的方式布置,并在需要时换行,来利用这一点。在内部,布局引擎使用 Flexbox(一种灵活的盒子)来执行布局。

引擎可以根据需要调整宽度和高度,以充分利用屏幕空间。这种行为应该对您来说很熟悉,因为我们设置了 Bootstrap,它采用了 Flexbox。由于 Flexbox 默认尝试在一行上布置项目,因此我们将首先创建一个div标签,以改变其行为,使其在行之间包裹 1%的空间间隙:

<div fxLayout="row wrap" fxLayout.xs="column" fxLayoutWrap fxLayoutGap="1%" fxLayoutAlign="left">
</div>

布局容器就位后,我们现在需要设置卡片来容纳图片和相关细节。由于我们可能有动态数量的卡片,我们真的希望 Angular 有一种方法,允许我们有效地定义卡片作为模板,并在内部添加各个元素。使用mat-card添加卡片,并通过一点点的 Angular 魔法(好吧,又一点点的 Angular 绑定),我们可以对图片进行迭代:

<mat-card class="picture-card-layout" *ngFor="let picture of Pictures">
</mat-card>

这一部分的作用是使用ngFor设置我们的卡片,ngFor是一个 Angular 指令,它可以迭代底层数组,本例中是Pictures,并且对于创建我们卡片的主体中可以使用的变量非常有效。通过这个,我们将添加一个绑定到picture.Name的卡片标题,以及一个将源绑定到picture.Image的图像。最后,我们将在段落中显示picture.Description

<mat-card-title fxLayout.gt-xs="row" fxLayout.xs="column">
  <span fxFlex="80%">{{picture.Name}}</span>
</mat-card-title>
<img mat-card-image [src]="picture.Image" />
<p>{{picture.Description}}</p>

为了完整起见,我们已经为我们的picture-card-layout添加了一些样式:

.picture-card-layout {
  width: 25%;
  margin-top: 2%;
  margin-bottom: 2%;
}

看看我们的卡片样式在实际中是什么样子:

这就是我们页面主体的 HTML,但是我们需要在其背后的 TypeScript 中放置代码,以实际开始提供我们的卡片将绑定到的一些数据。特别是,我们必须提供我们将要填充的Pictures数组:

export class PageBodyComponent implements OnInit {
  Pictures: Array<IPictureModel>;
  constructor(private addImage: AddImageService, private loadImage: LoadImageService, 
    private transfer: TransferDataService) {
    this.Pictures = new Array<IPictureModel>();
  }

  ngOnInit() {
  }
}

我们在这里有许多我们尚未遇到的服务。我们将首先看一下我们的应用程序如何知道IPictureModel的实例何时可用。

使用 RxJS 来观察图片

如果我们无法在页面主体中显示这些图片,那么通过对话框选择图片或在加载过程中从服务器获取图片的应用程序就没有意义。由于我们的应用程序具有彼此松散相关的功能,我们不希望引入事件作为控制这些功能发生的机制,因为这会在诸如页面主体组件和加载服务之间引入紧密耦合。

我们需要的是位于处理交互代码(例如加载数据)和页面主体之间的服务,并在有趣的事情发生时从一侧传递通知到另一侧。Angular 提供的执行此操作的机制称为JavaScript 的响应式扩展RxJS)。

响应式扩展是观察者模式的一种实现(又是那个模式词)。这是一个简单的模式,你会很容易理解,并且你可能已经使用它一段时间了,可能甚至没有意识到。观察者模式的想法是,我们有一个类,其中有一个叫做Subject的类型。在内部,这个Subject类型维护一个依赖项列表,当需要时,通知这些依赖项需要做出反应,可能传递它们需要做出反应的状态。

这可能会让你模糊地想起这正是事件所做的事情,那么为什么我们要关注这个模式呢?你的理解是正确的——事件只是观察者模式的一个非常专业的形式,但它们有一些弱点,而 RxJS 等东西是设计来克服这些弱点的。假设我们有一个实时股票交易应用程序,每秒都有成千上万的股票行情到达我们的客户端。显然,我们不希望我们的客户端处理所有这些股票行情,因此我们必须编写代码在我们的事件处理程序内部开始过滤通知。这是我们必须编写的大量代码,可能会在不同的事件中重复。当我们使用事件时,类之间还必须有紧密的关系,因此一个类必须了解另一个类,以便连接到一个事件。

随着我们的应用程序变得越来越庞大和复杂,可能会有很多距离在带入股票行情的类和显示它的类之间。因此,我们最终会构建一个复杂的事件层次结构,其中A 类监听B 类上的事件,当B 类引发该事件时,它必须重新引发它,以便C 类可以对其做出反应。我们的代码内部分布得越多,我们就越不希望鼓励这种紧密耦合。

使用 RxJS 等库,我们通过远离事件来解决这些问题(以及更多)。使用 RxJS,我们可以制定复杂的订阅机制,例如限制我们做出反应的通知数量或仅选择订阅满足特定条件的数据和更改。随着新组件在运行时添加,它们可以查询可观察类以查看已经可用的值,以便使用已经接收到的数据预填充屏幕。这些功能超出了我们在这个应用程序中所需的,但是由于我们将在未来的章节中使用它们,因此我们需要意识到它们对我们是可用的。

我们的应用程序有两件事需要做出反应:

  • 当页面加载时,图像将从服务器加载,因此我们需要对加载的每个图像做出反应。

  • 当用户从对话框中选择图像后,在用户选择保存后对话框关闭,我们需要触发对数据库的保存,并在页面上显示图像

也许不会让人惊讶的是,我们将创建服务来满足这两个要求。因为它们在内部做的事情是一样的,唯一的区别是订阅者需要在做出反应后做什么。我们首先创建一个简单的基类,这些服务将从中派生:

export class ContextServiceBase {
}

我们在这个类中的起点是定义我们的可观察对象将使用的Subject。正如我们所指出的,RxJS 中有不同的Subject专业化。由于我们只希望我们的Subject通知其他类最新的值,我们将使用BehaviorSubject并将当前值设置为null

private source = new BehaviorSubject(null);

我们不会将Subject暴露给外部类;相反,我们将使用此主题创建一个新的可观察对象。我们这样做是为了,如果我们愿意,我们可以自定义订阅逻辑——限制问题就是我们可能想这样做的一个例子:

context: this.source.asObservable();

我们称这种属性为上下文属性,因为它将携带变化的上下文。

有了这个设置,外部类现在可以访问可观察源,因此每当我们通知它们需要做出反应时,它们可以。由于我们要执行的操作基于用户添加IPictureModel或数据加载添加一个,我们将调用触发可观察add链的方法。我们的add方法将接收我们要发送到订阅代码的模型实例:

public add(image: IPictureModel) : void {
  this.source.next(image);
} 

我们确定需要两个服务来处理接收IPictureModel的不同方式。第一个服务称为AddImageService,正如我们所期望的那样,可以通过使用 Angular 为我们生成:

ng generate service services/AddImage

由于我们已经编写了我们的可观察逻辑,因此我们的服务看起来就像这样:

export class AddImageService extends ContextServiceBase {
}

我们的第二个服务称为LoadImageService

ng generate service services/LoadImage

同样,这个类将扩展ContextServiceBase

export class LoadImageService extends ContextServiceBase {
}

此时,你可能会想知道为什么我们有两个看起来做同样事情的服务。理论上,我们可以让它们都做完全相同的事情。我选择实现两个版本的原因是因为我们想要做的一件事是在通过AddImageService触发通知时显示图像并触发保存。假设我们在页面加载时也使用AddImageService。如果我们这样做,那么每当页面加载时,它也会触发保存,这样我们最终会复制图像。现在,我们可以引入过滤器来防止重复发生,但我选择使用两个单独的类来保持事情简单,因为这是我们第一次接触 RxJS。在接下来的章节中,我们将看到如何进行更复杂的订阅。

数据传输

我们已经涵盖了客户端/服务器交互的一侧。现在是时候处理另一侧了——实际调用我们服务器暴露的路由的代码。毫不奇怪,我们添加了一个负责这种通信的服务。我们从创建服务的代码开始:

ng g service services/TransferData

我们的服务将利用三样东西。它将依赖于的第一件事是一个HttpClient实例来管理getpost操作。我们还引入了我们刚刚创建的AddImageServiceLoadImageService类:

export class TransferDataService {
  constructor(private client: HttpClient, private addImage: AddImageService, 
    private loadImage: LoadImageService) {
  }
}

我们的服务器和客户端之间的第一个接触点是当用户从对话框中选择图像时我们将要使用的代码。一旦他们点击保存,我们将引发一系列操作,导致数据保存在服务器中。我们将设置我们的 HTTP 头部以将内容类型设置为 JSON:

private SubscribeToAddImageContextChanges() {
  const httpOptions = {
    headers: new HttpHeaders({
      'Content-Type': 'application/json',
    })
  };
}

回想一下我们的 RxJS 类,我们知道我们有两个可用的单独订阅。我们想在这里使用的是当AddImageService被推送出时做出反应的那个,因此我们将把这个订阅添加到SubscribeToAddImageContextChanges中:

this.addImage.context.subscribe(message => {
});

当我们在这个订阅中收到消息时,我们将把它发送到服务器,这将最终保存数据到数据库中:

if (message === null) {
  return;
}
this.client.post<IPictureModel>('http://localhost:3000/add/', message, httpOptions)
  .subscribe(callback => { });

发布的格式是传递端点地址,这与我们之前编写的服务器端代码很好地联系在一起,以及消息和任何 HTTP 选项。因为我们的消息内容在语义上与在服务器端接收的模型相同,所以它将自动在那一侧被解码。由于我们可以从服务器接收内容,我们有一个订阅可以用来解码从我们的 Express 代码库返回的消息。当我们将这些代码放在一起时,我们得到了这样的结果:

private SubscribeToAddImageContextChanges() {
  const httpOptions = {
    headers: new HttpHeaders({
      'Content-Type': 'application/json',
    })
  };
  this.addImage.context.subscribe(message => {
    if (message === null) {
      return;
    }
    this.client.post<IPictureModel>('http://localhost:3000/add/', message, httpOptions)
      .subscribe(callback => {
    });
  });
}

我们传输服务的另一侧负责从服务器获取图像。正如你可能还记得的,我们将在两个阶段接收数据。第一阶段是我们将接收一个与我们可用的所有图片匹配的 ID 数组。为了获取这个数组,我们在HttpClient上调用get,告诉它我们将获取一个字符串数组,指向/get/端点:

private LoadImagesWithSubscription() {
  const httpOptions = {
    headers: new HttpHeaders({
      'Content-Type': 'application/text',
    })
  };
  this.client.get<string[]>('http://localhost:3000/get/', httpOptions).subscribe(pic => {
  });
}

现在我们有了字符串数组,我们需要遍历每个元素并再次调用get,这次添加/id/...来告诉服务器我们感兴趣的是哪一个。当数据返回时,我们调用LoadImageService上的add方法,传入IPictureModel。这与我们的页面主体有关,我们很快就会看到:

pic.forEach(img => {
  this.client.get<IPictureModel>('http://localhost:3000/id/' + img).subscribe(pic1 => {
    if (pic1 !== null) {
      this.loadImage.add(pic1);
    }
  });
});

最后,我们将添加一个Initialize方法,我们将用它来初始化服务:

public Initialize(): void {
  this.SubscribeToAddImageContextChanges();
  this.LoadImagesWithSubscription();
}

回到页面主体组件

现在我们已经编写了LoadImageServiceAddImageServiceTransferDataService,我们可以在PageBodyComponent的初始化代码中使用它们,在ngOnInit中调用,这是在组件初始化时调用的。我们需要做的第一件事是调用TransferDataService中的Initialize函数:

ngOnInit() {
  this.transfer.Initialize();

}

为了完成这个组件,并实际填充Pictures数组,我们需要连接到我们的两个 RxJS 服务的上下文:

this.addImage.context.subscribe(message => {
  if (!message) {
    return;
  }
  this.Pictures.push(message);
});
this.loadImage.context.subscribe(message => {
  if (!message) {
    return;
  }
  this.Pictures.push(message);
});

通过显示对话框来结束

到目前为止,您可能已经注意到,我们实际上还没有放置任何代码来显示对话框或在用户关闭对话框时触发AddImageService。为了做到这一点,我们将在app.component.ts中添加代码,并对相关的 HTML 进行微小调整。

添加一个接受 Material 对话框和AddImageService的构造函数:

constructor(private dialog: MatDialog, private addImage: AddImageService) {
}

我们需要添加一个公共方法,我们的 HTML 模板将绑定到它。我们将称之为ImportImage

public ImportImage(): void {
}

与我们的 HTML 模板相关的更改是在app.component.html中的菜单列表项上添加对ImportImage的调用,通过(click)事件绑定对click事件做出响应。再次看到 Angular 绑定发挥作用:

<a mat-list-item (click)="ImportImage()">Import image</a>

我们将配置我们的对话框以特定的方式行为。我们不希望用户能够通过按下Esc键来自动关闭它。我们希望它自动聚焦并且宽度为 500 像素:

const config = new MatDialogConfig();
config.disableClose = true;
config.autoFocus = true;
config.width = '500px';

现在,我们可以使用这个配置来显示我们的对话框:

this.dialogRef = this.dialog.open(FileuploadComponent, config);

我们希望能够识别对话框何时关闭,并自动调用我们的添加图像服务——我们的add方法——这将通知传输数据服务必须将数据发送到客户端,并且还将通知页面主体有一个新图像要显示:

this.dialogRef.afterClosed().subscribe(r => {
  if (r) {
    this.addImage.add(r);
  }
});

这是我们放置的最后一段代码。我们的客户端代码现在已经整齐地分离了服务和组件,这些服务和组件与我们的 Material 对话框协作。我们的对话框在使用时看起来像这样:

我们已经将我们的对话框连接到我们的 Angular 代码中。我们有一个完全可用的应用程序,可以用来将图像保存到我们的数据库中。

总结

在本章中,使用 MEAN 堆栈,我们开发了一个应用程序,允许用户从其磁盘加载图像,添加有关图像的信息,并将数据从客户端传输到服务器。我们编写了创建一个服务器的代码,该服务器可以响应传入的请求,还可以将数据保存到数据库并从数据库中检索数据。我们发现了如何使用 Material Design,并使用 Angular Material 布局我们的屏幕,以及导航元素。

在下一章中,我们将扩展我们的 Angular 知识,并创建一个使用 GraphQL 来可视化其数据的 ToDo 应用程序。

问题

  1. 当我们说我们正在使用 MEAN 堆栈开发应用程序时,堆栈的主要组件是什么?

  2. 为什么在创建 Angular 客户端时我们提供了前缀?

  3. 我们如何启动 Angular 应用程序?

  4. 当我们说 Material 是一种设计语言时,我们是什么意思?

  5. 我们如何告诉 Angular 创建一个服务?

  6. 什么是 Express 路由?

  7. RxJS 实现了哪种模式?

  8. CORS 是什么,为什么我们需要它?

进一步阅读

第五章:带有 GraphQL 和 Apollo 的 Angular ToDo 应用程序

从客户端到服务器之间有许多不同的通信数据的方式。在本章中,我们将看看如何使用 GraphQL 从服务器中提取数据,然后从 Angular 客户端发送和改变数据。我们还将看看如何利用 GraphQL 中的计算值。在上一章的内容基础上,我们将再次使用 Angular Material 作为用户界面,以了解如何使用 Angular 路由来提供不同的内容。

本章将涵盖以下主题:

  • 理解 GraphQL 与 REST 的关系

  • 创建可重用的数据库类

  • 数据预填和使用单例

  • 创建 GraphQL 模式

  • 使用type-graphql设置 GraphQL 类型

  • 使用查询和变更创建 GraphQL 解析器

  • 将 Apollo Server 用作我们的应用程序服务器

  • 创建 GraphQL Angular 客户端应用程序

  • 为客户端添加 Apollo 支持

  • 在 Angular 中使用路由

  • 使用 Angular 验证控制输入

  • 从客户端向服务器发送 GraphQL 变更

  • 从客户端向服务器发送 GraphQL 查询

  • 在只读和可编辑模板之间切换

技术要求

完成的项目可以从github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter05下载。

下载项目后,您将需要使用npm install命令安装软件包要求。

理解 GraphQL 与 REST 的关系

基于网络技术的一大优点是解决常见问题的多种方式。在 REST 中,我们使用了一种简单但强大的方式从客户端到服务器进行通信;然而,这并不是我们可以做到的唯一方式。REST 解决了一系列问题,但也引入了新问题,新技术已经出现来解决这些问题。需要解决的三个问题如下:

  • 为了构建复杂的信息,我们可能需要对 REST 服务器进行多次调用。例如,对于购物应用程序,我们可能使用一个 REST 调用来获取一个人的姓名,另一个 REST 调用来获取他们的地址,还需要第三个调用来获取他们的购物篮详情。

  • 随着时间的推移,我们可能会经历多个版本的 REST API。让我们的客户端跟踪版本可能会有限制,这意味着在 API 的最开始,我们还必须定义我们的版本体验将会是什么样子。除非我们的 API 遵循相同的版本标准,否则这可能会导致混乱的代码。

  • 这些 REST 调用最终可能会带来比我们实际需要的信息更多。因此,当我们进行这些详细的调用时,我们实际上只需要 20 或 30 个字段中的三四个信息。

关于 REST 的一件事是要理解它实际上并不是一种技术。理解 REST 的一个好方法是,它更像是一个约定的架构标准,可以使用几乎任何传输机制作为通信手段。好吧,澄清一下,虽然我说它是一个标准,但实际上很少有人真正遵循 REST 的原始概念,这意味着我们也需要理解开发人员的意图。例如,当我们通过 REST 发出更新时,我们是使用PUT HTTP动词还是POST动词?如果我们想要使用第三方 API,了解这个细节是至关重要的。

GraphQL 最初由 Facebook 开发,但现在由 GraphQL 基金会维护(foundation.graphql.org/),GraphQL 是解决这些问题的好机制。与纯 REST 不同,GraphQL 只是一个带有工具支持的查询语言。GraphQL 围绕着我们的代码与字段交互的想法,只要有获取这些字段的定义,我们就可以编写任意复杂的查询,以一次获取多个位置的数据,或者对数据进行变异以更新它。一个设计良好的 GraphQL 系统可以处理版本需求以及可以根据需要添加和弃用的字段。

使用 GraphQL,我们只能通过查询检索我们需要的信息。这样可以避免我们在客户端级别过度订阅信息。同样,我们的查询可以为我们从多个位置拼接结果,这样我们就不必执行多次往返。我们从客户端发送查询,让我们的 GraphQL 服务器检索相关的数据项。我们也不必担心客户端代码中的 REST 端点。我们只需与我们的 GraphQL 服务器通信,让查询处理数据。

在本章中,我们将看看如何使用 Apollo GraphQL 引擎(www.apollographql.com/)和非常有用的TypeGraphQL库(typegraphql.ml/),它提供了一种方便的方式来从 TypeScript 中针对 GraphQL。使用 Apollo,我们有一个完整的前后端基础设施来完全管理我们的 GraphQL 行为。除了提供客户端库外,我们还可以在服务器上使用 Apollo,以及用于 iOS 和 Android 应用程序。

请注意,GraphQL 并不打算完全取代 RESTful 服务。有许多情况下,我们希望 REST 和 GraphQL 并存。例如,我们可能有一个与我们的 GraphQL 实现通信并为我们缓存信息的 REST 服务。但是,在本章中,我们将专注于纯粹创建 GraphQL 实现。

项目概述

在本章中,我们的项目将向我们介绍如何编写 GraphQL 应用程序,包括服务器端和客户端。我们还将开始研究 TypeScript 3 中引入的功能,以创建一个 ToDo 应用程序。我们将扩展上一章的 Angular 概念,引入客户端路由,这将允许我们显示不同的内容并有效地在页面之间导航。我们还将介绍 Angular 验证。

与 GitHub 代码一起工作,本章的任务应该需要大约四个小时才能完成。

完成后,应用程序应如下所示:

开始项目

就像上一章一样,本章将使用 Node.js(可从nodejs.org获取)。我们还将使用以下组件:

  • Angular CLI(我使用的版本是 7.2.2)

  • express(版本 4.16.4 或更高)

  • mongoose(版本 5.4.8 或更高)

  • @types/cors(版本 2.8.4 或更高)

  • @types/body-parser(版本 1.17.0 或更高)

  • @types/express(版本 4.16.0 或更高)

  • @types/mongodb(版本 3.1.19 或更高)

  • @types/mongoose(版本 5.3.11 或更高)

  • type-graphql(版本 0.16.0 或更高)

  • @types/graphql(版本 14.0.7 或更高)

  • apollo-server(版本 2.4.0 或更高)

  • apollo-server-express(版本 2.4.0 或更高)

  • guid-typescript(版本 1.0.9 或更高)

  • reflect-metadata(版本 0.1.13 或更高)

  • graphql(版本 14.1.1 或更高)

  • apollo-angular(版本 1.5.0 或更高)

  • apollo-angular-link-http(版本 1.5.0 或更高)

  • apollo-cache-inmemory(版本 1.4.3 或更高)

  • apollo-client(版本 2.4.13 或更高)

  • graphql(版本 14.1.1 或更高)

  • graphql-tag(版本 2.10.1 或更高版本)

除了使用 MongoDB,我们还将使用 Apollo 来提供 GraphQL 数据。

使用 GraphQL 和 Angular 创建 ToDo 应用程序

像现在一样,我们将从定义需求开始:

  • 用户必须能够添加由标题、描述和任务到期日期组成的 ToDo 任务

  • 验证将确保这些项目始终设置,并且到期日期不能早于今天

  • 用户将能够查看所有任务的列表

  • 用户将能够删除任务

  • 用户将能够查看过期任务(过期任务是指尚未完成且到期日期已过的任务)

  • 用户将能够编辑任务

  • 使用 GraphQL 传输数据到服务器,或从服务器传输数据

  • 传输的数据将保存到 MongoDB 数据库中

创建我们的应用程序

对于我们的 ToDo 应用程序,我们将从服务器实现开始。与上一章一样,我们将创建一个单独的客户端和服务器文件夹结构,将 Node.js 代码添加到服务器代码中。

我们将开始创建一个 GraphQL 服务器与数据库代码的旅程。我们客户端的所有数据都将来自数据库,因此我们需要将我们需要的一切放在适当的位置。与上一章一样,我们将安装我们需要与 MongoDB 一起工作的mongoose软件包:

npm install mongoose @types/mongoose --save-dev

在选择安装软件包的命令时要记住的一点是与--save--save-dev的使用有关。这两者都用于安装软件包,但它们之间有一个实际的区别,以及我们根据它们期望应用程序部署的方式。当我们使用--save时,我们声明这个软件包必须下载才能使应用程序运行,即使我们在另一台计算机上安装应用程序。如果我们打算将应用程序部署到已经全局安装了正确版本软件包的计算机上,这可能是浪费的。另一种情况是使用--save-dev将软件包下载并安装为所谓的开发依赖。换句话说,该软件包是为开发人员本地安装的。

有了这个基础,我们将开始编写我们在上一章中介绍的Mongo类的变体。我们不会重用该实现的原因是因为我们将开始引入特定于 TypeScript 3 的功能,然后再添加通用数据库框架。

我们类的重大变化是,我们将改变mongoose.connect方法的签名。其中一个变化告诉 Mongoose 使用新格式的 URL 解析器,但另一个变化与我们用作回调的事件的签名相关联:

public Connect(): void {
  mongoose.connect(this.url, {useNewUrlParser: true}, (e:unknown) => {
    if (e) {
      console.log(`Unable to connect ` + e);
    } else {
      console.log(`Connected to the database`);
    }
  });
}

从上一章,我们应该记住我们的回调的签名是e:any。现在,我们将其更改为使用e:unknown。这是一种新类型——在 TypeScript 3 中引入的——它允许我们添加额外的类型安全性。在很大程度上,我们可以将unknown类型视为类似于any,因为我们可以将任何类型分配给它。但是,我们不能在没有类型断言的情况下将其分配给另一种类型。我们将开始在整个代码中将any类型移动到unknown

到目前为止,我们一直在使用许多接口来提供类型的形状。我们也可以将相同的技术应用于 Mongo 模式,以便我们可以描述我们的 ToDo 模式的形状作为标准的 TypeScript 接口,然后将其映射到模式。我们的接口将是直接的:

export interface ITodoSchema extends mongoose.Document {
  Id: string,
  Title: string,
  Description: string,
  DueDate: Date,
  CreationDate: Date,
  Completed: boolean,
}

我们将创建一个mongoose模式,它将映射到数据库中。一个模式简单地说明了将使用 MongoDB 期望的类型存储的信息。例如,我们的ITodoSchemaId公开为string,但这不是 MongoDB 期望的类型;相反,它期望看到String。知道这一点,从ITodoSchemaTodoSchema的映射就很简单了,如下所示:

export const TodoSchema = new Schema({
  Id: String,
  Title: String,
  Description: String,
  DueDate: Date,
  CreationDate: Date,
  Completed: Boolean,
});

现在我们有了一个可以用来查询、更新等的模式模型。当然,Mongo 并不限制我们只使用一个模式。如果我们想使用更多,没有什么能阻止我们这样做。

关于我们的模式将包含什么的说明——TitleDescription字段相当直接,它们包含了有关我们待办事项的详细信息。DueDate简单地告诉我们项目何时到期,CreationDate告诉我们我们创建这条记录的时间。我们有一个Completed标志,用户将触发它来表示他们何时完成了任务。

有趣的字段是Id字段。这个字段不同于 Mongo 的Id字段,后者仍然是内部生成的。模式Id字段被分配了一个叫做全局唯一标识符GUID)的东西,它是一个唯一的字符串标识符。我们希望 UI 添加这个字段的原因是因为我们将在数据库查询中使用它作为一个已知的字段,并且我们希望客户端在执行任何往返之前知道Id的值。当我们涉及 Angular 方面时,我们将看到这个字段是如何被填充的。

我们需要创建一个数据库模型,将我们的ITodoSchemamongoose.Document实例映射到我们的TodoSchema。当使用mongoose.model时,这是一项直接的任务:

export const TodoModel = mongoose.model<ITodoSchema>('todo', TodoSchema, 'todoitems', false);

当我们创建我们的mongoose.model时,大小写非常重要。除了mongoose.model,我们还有mongoose.Model可用,我们需要用new语句来实例化。

我们现在有了一个相对通用的数据库类。然而,我们有一个约束——我们期望我们的模式有一个Id字段。这个约束纯粹是为了让我们专注于我们演示应用程序的逻辑。

我们要做的第一件事是创建一个接受mongoose.Document作为类型的通用基类。毫无疑问,我们最终将针对这个类型使用的是ITodoSchema。构造函数将接受一个我们可以用于各种数据库操作的模型。同样,我们已经创建了我们将用作TodoModel的模型:

export abstract class DataAccessBase<T extends mongoose.Document> {
  private model: Model;
  constructor(model: Model) {
    this.model = model;
  }
}

我们对这个类的具体实现非常简单:

export class TodoDataAccess extends DataAccessBase<ITodoSchema> {
  constructor() {
    super(TodoModel);
  }
}

我们现在要开始向DataAccessBase添加功能。我们将从一个获取与我们的模式匹配的所有记录的方法开始。在这个阶段,我们应该对 promises 感到满意,所以我们应该自然地返回一个Promise类型。在这种情况下,Promise类型将是一个T数组,我们知道它映射到ITodoSchema

在内部,我们调用我们的模型的find方法来检索所有记录,一旦查找完成,我们回调结果:

GetAll(): Promise<T[]> {
  return new Promise<T[]>((callback, error) => {
    this.model.find((err: unknown, result: T[]) => {
      if (err) {
        error(err);
      }
      if (result) {
       callback(result);
      }
    });
 });
}

添加记录同样简单。唯一的真正区别是我们调用model.create方法并返回一个boolean值来指示我们成功了:

Add(item: T): Promise<boolean> {
  return new Promise<boolean>((callback, error) => {
    this.model.create(item, (err: unknown, result: T) => {
      if (err) {
        error(err);
      }
      callback(!result);
    });
  });
}

除了检索所有记录,我们还可以选择检索单个记录。这与GetAll方法之间的主要区别在于find方法使用了搜索条件:

Get(id: string): Promise<T> {
  return new Promise<T>((callback, error) =>{
    this.model.find({'Id': id}, (err: unknown, result: T) => {
      if (err) {
        error(err);
      }
      callback(result);
    });
  });
}

最后,我们有了删除或更新记录的能力。它们在写法上非常相似:

Remove(id: string): Promise<void> {
  return new Promise<void>((callback, error) => {
    this.model.deleteOne({'Id': id}, (err: unknown) => {
      if (err) {
        error(err);
      }
      callback();
    });
  });
}
Update(id: string, item: T): Promise<boolean> {
  return new Promise<boolean>((callback, error) => {
    this.model.updateOne({'Id': id}, item, (err: unknown)=>{
      if (err) {
        error(err);
      }
      callback(true);
    });
  })
}

有了实际的数据库代码,我们现在可以转向访问数据库。我们要考虑的一件事是,随着时间的推移,我们可能会有大量的待办事项积累起来,如果我们每次需要时都尝试从数据库中读取它们,随着我们添加更多的待办事项,系统会变得越来越慢。为此,我们将创建一个基本的缓存机制,在服务器启动过程中数据库加载完成后立即填充。

由于缓存将被预先填充,我们希望在 GraphQL 和服务器中使用我们类的相同实例,因此我们将创建一个称为单例的东西。单例只是另一种说法,即我们在内存中只有一个类的实例,并且每个类将使用相同的实例。为了防止其他类能够创建自己的实例,我们将利用一些技巧。

我们要做的第一件事是创建一个带有私有构造函数的类。私有构造函数意味着我们只能在类内部实例化我们的类:

export class Prefill {
  private constructor() {}
}

这可能看起来有些反直觉,我们只能从类本身创建类。毕竟,如果我们不能实例化类,我们怎么访问任何成员呢?这个技巧是添加一个字段来保存对类实例的引用,然后提供一个公共静态属性来访问该实例。公共属性将负责实例化类,如果它尚不可用,我们将始终能够访问类的实例:

private static prefill: Prefill;
public static get Instance(): Prefill {
  return this.prefill || (this.prefill = new this());
}

现在我们有了访问我们将要编写的方法的方法,让我们开始创建一个方法来填充可用项目的列表。由于这可能是一个长时间运行的操作,我们将使其异步:

private items: TodoItems[] = new Array<TodoItem>();
public async Populate(): Promise<void> {
  try
  {
    const schema = await this.dataAccess.GetAll();
    this.items = new Array<TodoItem>();
    schema.forEach(item => {
      const todoItem: TodoItem = new TodoItem();
      todoItem.Id = item.Id;
      todoItem.Completed = item.Completed;
      todoItem.CreationDate = item.CreationDate;
      todoItem.DueDate = item.DueDate;
      todoItem.Description = item.Description;
      todoItem.Title = item.Title;
      this.items.push(todoItem);
    });
  } catch(error) {
    console.log(`Unfortunately, we couldn't retrieve all records ${error}`);
  }
}

这种方法通过调用GetAll来从我们的 MongoDB 数据库中检索所有记录。一旦我们有了记录,我们将遍历它们并创建它们的副本推入我们的数组。

TodoItem类是一个特殊的类,我们将使用它来将类型映射到 GraphQL。当我们开始编写我们的 GraphQL 服务器功能时,我们将很快看到这个类。

填充项目数组很好,但如果没有办法在代码的其他地方访问这些项目,这个类将没有太大帮助。幸运的是,访问这些元素就像添加一个Items属性一样简单:

get Items(): TodoItem[] {
  return this.items;
}

创建我们的 GraphQL 模式

有了我们的数据库代码,我们现在准备转向编写我们的 GraphQL 服务器。在编写本章示例代码时,我最早做出的决定之一是尽可能简化编写代码的过程。如果我们查看 Facebook 发布的参考示例,我们会发现代码可能会非常冗长:

import {
  graphql,
  GraphQLSchema,
  GraphQLObjectType,
  GraphQLString
} from 'graphql';

var schema = new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'RootQueryType',
    fields: {
      hello: {
        type: GraphQLString,
        resolve() {
          return 'world';
        }
      }
    }
  })
});

这个例子来自github.com/graphql/graphql-js。我们可以看到我们对特殊类型有很多依赖,这些类型不能一对一地映射到 TypeScript 类型。

由于我们希望使我们的代码更符合 TypeScript 的要求,我们将使用type-graphql。我们将通过npm安装它,以及graphql类型定义和reflect-metadata

npm install type-graphql @types/graphql reflect-metadata --save

在这个阶段,我们还应该设置我们的tsconfig文件如下:

{
  "compileOnSave": false,
  "compilerOptions": {
    "target": "es2016", 
    "module": "commonjs",
    "lib": ["es2016", "esnext.asynciterable", "dom"],
    "outDir": "./dist",
    "noImplicitAny": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
  }
}

在这个tsconfig文件中值得一提的主要是type-graphql使用的功能只能在 ES7 中找到,所以我们需要在 lib(ES7 映射到 ES2016)中使用 ES2016。

设置我们的 GraphQL 类型

正如我们刚才看到的,设置 GraphQL 类型可能有点复杂。借助type-graphql和一些方便的装饰器,我们将创建一个表示单个项目的模式。

我们现在不需要担心创建一个表示多个项目的类型。我们的项目将包括以下字段:

  • Id(默认为空字符串)

  • Title

  • Description(我们暂时将其设置为可空值。当我们创建 UI 时,我们将添加验证,以确保我们始终提供描述。)

  • 任务到期的日期(同样,这是可空的)

  • 任务创建时

  • 任务创建后的天数(当我们查询数据时,这将自动计算)

  • 任务是否已完成

如果我们仔细观察,我们会发现这里的字段与我们在 MongoDB 模式中定义的字段非常相似。这是因为我们将从数据库中填充我们的 GraphQL 类型,以及直接从这些类型更新数据库。

和我们现在习惯的一样,我们将从一个简单的类开始:

export class TodoItem {
}

我提到我们将在这个类中使用装饰器。我们将用@ObjectType装饰类定义,这使我们能够创建复杂类型。作为优秀的开发者,我们还将提供描述,以便我们类型的使用者了解它代表什么。现在,我们的类定义如下:

@ObjectType({description: "A single to do"})
export class TodoItem {
}

我们将一步一步地向我们的类型添加字段。首先,我们将添加Id字段,它与数据库中的Id字段匹配:

@Field(type=>ID)
Id: string="";

同样,我们为这个字段提供了一个装饰器,它将告诉type-graphql如何将我们的类转换为 GraphQL 类型。通过应用type=>ID,我们使用了特殊的 GraphQL ID类型。这种类型是一个映射到唯一值的字符串。毕竟,它是一个标识字段,惯例规定标识字段必须是唯一的。

接下来我们将添加三个可空字段——DescriptionDueDateCreationDate字段。实际上,我们并不打算允许这些字段的空值,因为当我们在本章后面开始添加 Angular 验证时,我们会看到,但对于我们来说,重要的是要看到我们如何为我们创建的任何未来的 GraphQL 类型添加可空类型:

@Field({ nullable: true, description: "The description of the item." })
Description?: string;
@Field({ nullable: true, description: "The due date for the item" })
DueDate?: Date;
@Field({ nullable: true, description: "The date the item was created" })
CreationDate: Date;

我们还有一些更简单的字段要提供:

@Field()
Title: string;
@Field(type => Int)
DaysCreated: number;
@Field()
Completed: boolean;

我们的TodoItem现在看起来像这样,它代表了构成我们查询类型的模式的全部内容:

@ObjectType({ description: "A single to do" })
export class TodoItem {
  constructor() {
    this.Completed = false;
  }
  @Field(type=>ID)
  Id: string = "";
  @Field()
  Title: string;
  @Field({ nullable: true, description: "The description of the item." })
  Description?: string;
  @Field({ nullable: true, description: "The due date for the item" })
  DueDate?: Date;
  @Field({ nullable: true, description: "The date the item was created" })
  CreationDate: Date;
  @Field(type => Int)
  DaysCreated: number;
  @Field()
  Completed: boolean;
}

除了有一个用于查询的类之外,我们还需要一个类来表示我们将用于变异状态的数据,以及用于更新数据库的数据。

当我们改变状态时,我们正在改变它。我们希望这些更改能够在服务器重新启动时持久化,因此它们将更新数据库和我们将在运行时缓存的状态。

我们将用于 mutation 的类看起来与我们的TodoItem类非常相似。主要区别在于我们使用@InputType代替@ObjectType,并且该类实现了TodoItem的通用Partial类型。另一个区别是这个类没有DaysCreated字段,因为这将由我们的查询计算,所以我们不必添加任何值来保存它:

@InputType()
export class TodoItemInput implements Partial<TodoItem> {
  @Field()
  Id: string;
  @Field({description: "The item title"})
  Title: string = "";
  @Field({ nullable: true, description: "The item description" })
  Description?: string = "";
  @Field({ nullable: true, description: "The item due date" })
  DueDate?: Date;
  @Field()
  CreationDate: Date;
  @Field()
  Completed: boolean = false;
}

如果你不知道Partial的作用,它只是使TodoItem的所有属性变成可选的。这样我们就可以将我们的新 mutation 类与旧类联系起来,而不必提供每个属性。

创建我们的 GraphQL 解析器

TodoItemTodoItemInput类的目的是为我们提供描述字段、类型和参数的模式。虽然它们是我们 GraphQL 拼图的重要部分,但我们缺少一个部分——执行函数来针对我们的 GraphQL 服务器。

我们需要一种方法来解析我们类型的字段。在 GraphQL 中,解析器代表一个单一字段。它获取我们需要的数据,有效地向 GraphQL 服务器提供了详细的指令,告诉它如何将查询转换为数据项(我们可以将这视为我们为变异数据和查询数据分别使用不同的逻辑的原因之一)。由此,我们可以得出字段和解析器之间存在一对一的映射。

使用type-graphql,我们可以轻松创建复杂的解析器关系和操作。我们将从定义我们的类开始。

@Resolver装饰器告诉我们,这个类的行为与 REST 类型的控制器类相同:

@Resolver(()=>TodoItem)
export class TodoItemResolver implements ResolverInterface<TodoItem>{
}

严格来说,ResolverInterface对于我们的类并不是必需的,但是当我们向DaysCreated字段添加字段解析器时,我们将使用它作为一个安全网。这个字段将返回今天的日期和任务创建日期之间的差异。由于我们正在创建一个字段解析器,ResolverInterface检查我们的字段是否具有对象类型的@Root装饰器作为参数,并且返回类型是正确的类型。

我们的DaysCreated字段解析器装饰有@FieldResolver,看起来像这样:

private readonly milliSecondsPerDay = 1000 * 60 * 60 * 24;
@FieldResolver()
DaysCreated(@Root() TodoItem: TodoItem): number {
  const value = this.GetDateDifference(...[new Date(), TodoItem.CreationDate]);
  if (value === 0) {
    return 0;
  }
  return Math.round(value / this.milliSecondsPerDay);
}
private GetDateDifference(...args: [Date, Date]): number {
  return Math.round(args[0].valueOf() - args[1].valueOf());
}

虽然这些方法看起来复杂,但实际上它们非常简单。我们的DaysCreated方法接收当前的TodoItem,并使用GetDateDifference计算出今天和CreationDate值之间的差异。

我们的type-graphql解析器还可以定义我们想要执行的查询和变异。对我们来说,定义一种检索所有待办事项的方法将非常有用。我们将创建一个使用@Query装饰的方法,以标识这将是一个查询操作。由于我们的查询有可能返回多个项目,我们告诉解析器返回类型是TodoItem类型的数组。就像我们之前创建Prefill类的辛苦工作一样,我们的方法就是这么简单:

@Query(() => [TodoItem], { description: "Get all the TodoItems" })
async TodoItems(): Promise<TodoItem[]> {
  return await Prefill.Instance.Items;
}

我们想要允许用户执行的操作之一是只查询逾期的记录。我们可以利用与上次查询类似的逻辑,但我们将筛选那些未完成的记录,这些记录已经超过了他们的截止日期:

@Query(() => [TodoItem], { description: "Get items past their due date" })
async OverdueTodoItems(): Promise<TodoItem[]> {
  const localCollection = new Array<TodoItem>();
  const testDate = new Date();
  await Prefill.Instance.Items.forEach(x => {
    if (x.DueDate < testDate && !x.Completed) {
      localCollection.push(x);
    }
  });
  return localCollection;
}

严格来说,对于像这样塑造数据的操作,我通常会将过滤逻辑委托给数据层,以便它只返回适当的记录。在这种情况下,我决定在解析器中进行过滤,以便我们可以看到相同的数据源可以以我们需要的任何方式进行塑造。毕竟,我们可能已经从一个不允许我们以适当方式塑造它的源中检索到这些数据。

我必须强调的一点是,在尝试执行任何查询或变异之前,我们必须导入 reflect-metadata。这是因为在使用装饰器时依赖反射。没有 reflect-metadata,我们将无法使用装饰器,因为它们在内部使用反射。

拥有查询数据的能力是很好的,但解析器还应该能够对数据执行变异。为此,我们将添加解析器来添加、更新和删除新的待办事项,以及在用户决定任务完成时设置Completed标志。我们将从Add方法开始。

由于这是一个变异,type-graphql提供了@Mutation装饰器。我们的方法将接受一个TodoItemInput参数。这是通过匹配的@Arg装饰器传递的。我们需要提供这个显式的@Arg是因为 GraphQL 期望变异有参数作为参数。通过使用@Arg,我们为它们提供了所需的上下文。在提供变异的同时,我们期望我们也将提供一个返回类型,因此正确地映射变异和方法的实际返回类型非常重要:

@Mutation(() => TodoItem)
async Add(@Arg("TodoItem") todoItemInput: TodoItemInput): Promise<TodoItem> {
}

我们变异方法的一个特点是,除了更新Prefill项目,我们还将更新数据库,这意味着我们必须将我们方法中的输入转换为我们的ITodoSchema类型。

为了帮助我们,我们将使用以下简单的方法:

private CreateTodoSchema<T extends TodoItem | TodoItemInput>(todoItem: T): ITodoSchema {
  return <ITodoSchema>{
    Id: todoItem.Id,
    CreationDate: todoItem.CreationDate,
    DueDate: todoItem.DueDate,
    Description: todoItem.Description,
    Title: todoItem.Title,
    Completed: false
  };
}

我们接受TodoItemTodoItemInput,因为我们将使用相同的方法来创建一个可接受我们数据库层的记录。由于该记录的来源可以是从Prefill项目中找到特定记录,也可以是从我们的 UI 传递过来,我们需要确保我们可以处理这两种情况。

我们的Add方法的第一部分涉及创建一个将存储在我们的Prefill集合中的TodoItem项目。一旦我们将项目添加到集合中,我们将把记录添加到数据库中。我们的完整的Add方法看起来像这样:

@Mutation(() => TodoItem)
async Add(@Arg("TodoItem") todoItemInput: TodoItemInput): Promise<TodoItem> {
  const todoItem = <TodoItem> {
    Id : todoItemInput.Id,
    CreationDate : todoItemInput.CreationDate,
    DueDate : todoItemInput.DueDate,
    Description : todoItemInput.Description,
    Title : todoItemInput.Title,
    Completed : todoItemInput.Completed
  };
  todoItem.Completed = false;
  await Prefill.Instance.Items.push(todoItem);
  await this.dataAccess.Add(this.CreateTodoSchema(todoItem));
  return todoItem;
}

现在我们知道如何添加记录,我们可以把注意力转向使用变异来更新记录。我们已经有了大部分的代码基础,所以更新变得更加简单。Update方法首先通过检索已经缓存的条目来开始,通过搜索具有匹配的Id的项目。如果我们找到这条记录,我们在更新与相关的TitleDescriptionDueDate之前更新匹配的数据库记录:

@Mutation(() => Boolean!)
async Update(@Arg("TodoItem") todoItemInput: TodoItemInput): Promise<boolean> {
  const item: TodoItem = await Prefill.Instance.Items.find(x => x.Id === todoItemInput.Id);
  if (!item) return false;
  item.Title = todoItemInput.Title;
  item.Description = todoItemInput.Description;
  item.DueDate = todoItemInput.DueDate;
  this.dataAccess.Update(item.Id, this.CreateTodoSchema(item));
  return true;
}

删除记录并不比Update方法复杂。为了删除记录,我们只需要提供Id值,因此我们的方法签名从输入一个复杂类型变为输入一个简单类型——在这种情况下是一个字符串。我们通过缓存条目来查找与Id匹配的记录的索引,找到后,我们使用 splice 方法删除缓存条目。当我们在数组上使用 splice 时,我们实际上是在说删除从相关索引开始的条目,并删除我们选择的条目数。因此,要删除1条记录,我们将1作为该方法的第二个参数提供。我们需要确保我们的数据库是一致的,所以我们也删除数据库条目:

@Mutation(() => Boolean!)
async Remove(@Arg("Id") id: string): Promise<boolean> {
  const index = Prefill.Instance.Items.findIndex(x => x.Id === id);
  if (index < 0) {
    return false;
  }
  Prefill.Instance.Items.splice(index, 1);
  await this.dataAccess.Remove(id);
  return true;
}

我们感兴趣的最终变异是将Completed标志设置为true的变异。这个方法在很大程度上是RemoveUpdate方法的组合,因为它遵循相同的逻辑来识别记录并更新它。然而,像Remove方法一样,它只需要Id作为输入参数。由于我们只打算更新Completed字段,这是我们在这个方法中要处理的唯一字段:

@Mutation(() => Boolean!)
async Complete(@Arg("Id") id: string) : Promise<boolean> {
  const item: TodoItem = await Prefill.Instance.Items.find(x => x.Id === id);
  if (!item) return false;
  item.Completed = true;
  await this.dataAccess.Update(item.Id, this.CreateTodoSchema(item));
  return true;
}

我们本可以选择重用Update方法,并从客户端代码将Completed设置为 true,但这将使用更复杂的调用来实现一个更简单的目标。通过使用单独的方法,我们确保我们有一个只做一件事情的代码。这使我们遵循我们感兴趣的单一责任原则。

有了我们的解析器和模式,我们现在可以把注意力转向添加代码来实际提供我们的 GraphQL 服务器。

使用 Apollo Server 作为我们的服务器

我们将为这个项目创建一个新的服务器实现,而不是重用上一章的任何服务器基础设施。Apollo 提供了自己的服务器实现(称为 Apollo Server),我们将在这里使用它来代替 Express。和往常一样,我们将首先引入必要的类型,然后创建我们的类定义。在构造函数中,我们将引入对我们的Mongo数据库类的引用。

Apollo Server 是 Apollo GraphQL 策略的一部分,用于提供开箱即用的 GraphQL 支持。服务器可以独立运行,也可以与 Express 等服务器框架一起工作,用于提供自描述的 GraphQL 数据。我们要使用 Apollo Server 的原因是因为它内置了与 GraphQL 模式一起工作的支持。如果我们试图自己添加这种支持,我们最终会重做我们从 Apollo Server 中免费获得的内容。

首先,我们要导入我们的类型:

npm install apollo-server apollo-server-express --save

然后,我们将编写我们的server类:

export class MyApp {
  constructor(private mongo: Mongo = new Mongo()) { }
}

我们的服务器将公开一个Start方法,负责连接到数据库并启动我们的 Apollo 服务器:

public async Start(): Promise<void> {
  this.mongo.Connect();

  await Prefill.Instance.Populate();

  const server = new ApolloServer({ schema, playground: true });
  await server.listen(3000);
}

当我们创建 Apollo Server 实例时,我们指示要使用GraphQLSchema,但我们没有定义关于该模式的任何内容。我们使用buildSchema函数,它接受一系列选项并使用它们来引入 Apollo Server 将使用的模式。resolvers接受一个 GraphQL 解析器数组,因此我们将TodoItemResolver作为我们要使用的解析器提供。当然,这里的含义是我们可以使用多个解析器。

validate标志表示我们是否要验证传递给解析器参数的对象。由于我们使用简单的对象和类型,我们将其设置为false

我喜欢做的一件事是使用emitSchemaFile来验证我创建的 GQL。这使用路径操作来构建一个完全合格的路径名。在这种情况下,我们将解析到dist文件夹,我们将在那里输出apolloschema.gql文件:

const schema: GraphQLSchema = await buildSchema({
  resolvers: [TodoItemResolver],
  validate: false,
  emitSchemaFile: path.resolve(__dirname, 'apolloschema.gql')
});

现在我们已经完成了服务器端的编码,我们可以添加new MyApp().Start();来启动和运行我们的应用程序。当我们构建和运行我们的服务器端时,它将在http://localhost:3000上启动一个启用 Apollo 的 GraphQL 服务器的实例。我们还有一个小小的惊喜,与我们提供给 Apollo Server 选项的最后一个参数有关,即playground: true。游乐场是一个可视化编辑区域,让我们运行graphql查询并查看它们带来的结果。

我建议在生产代码中关闭游乐场。然而,对于测试目的,它是一个无价的辅助工具,可以尝试查询。

为了检查我们是否正确连接了所有内容,请尝试在查询窗口中输入 GraphQL 查询。在输入查询时,请记住,只因为它与 JavaScript 对象有表面上的相似之处,并不意味着需要使用单独的条目。以下是一个开始的示例查询。此查询使用我们在TodoItemResolver中创建的TodoItems查询:

query {
  TodoItems {
    Id
    Title
    Description
    Completed
    DaysCreated
  }
}

GraphQL Angular 客户端

就像我们在上一章中所做的那样,我们将创建一个使用 Angular Material 作为其 UI 的 Angular 客户端。同样,我们将使用ng new命令创建一个新的应用程序,并将前缀设置为atp。由于我们想要为我们的应用程序添加路由支持,我们将在命令行中添加额外的--routing参数。我们这样做是因为它会向app.module.ts添加必要的AppRoutingModule条目,并为我们创建app-routing.module.ts路由文件:

ng new Chapter05 --style scss --prefix atp --routing true

在上一章中,即使我们使用了 Material,我们也没有利用它的路由。在我们回到本书的其余部分使用 Bootstrap 之前,我们将再次使用 Material 一次,因此我们需要为我们的应用程序添加 Material 支持(不要忘记在提示时接受添加对浏览器动画的支持):

ng add @angular/material @angular/cdk @angular/animation @angular/flex-layout

在这个阶段,我们的app.module.ts文件应该是这样的:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

我们需要将 Material 模块导入到我们的imports数组中:

HttpClientModule,
HttpLinkModule,
BrowserAnimationsModule,
MatToolbarModule,
MatButtonModule,
MatSidenavModule,
MatIconModule,
MatListModule,
FlexLayoutModule,
HttpClientModule,
MatInputModule,
MatCardModule,
MatNativeDateModule,
MatDatepickerModule,

我们将MatNativeDateModuleMatDatepickerModule一起添加,因为 Material 日期选择器的构建方式。它不提供关于日期实现方式的任何硬性假设,因此我们需要导入适当的日期表示。虽然我们可以编写自己的日期处理模块实现,但通过引入MatNativeDateModule,我们将取得真正的成功。如果我们没有这样做,我们将得到一个运行时错误,告诉我们未找到 DateAdapter 的提供程序

添加客户端 Apollo 支持

在我们开始创建用户界面之前,我们将设置 Apollo 集成的客户端部分。虽然我们可以使用npm安装 Apollo 的所有单独部分,但我们将再次使用ng的强大功能:

ng add apollo-client

回到AppModule,我们将设置 Apollo 与服务器进行交互。AppModule的构造函数是我们注入 Apollo 创建与服务器连接的完美位置。我们的构造函数一开始看起来像这样:

constructor(httpLink: HttpLink, apollo: Apollo) {
}

我们连接到服务器的方式是通过apollo.create命令。这个命令接受许多选项,但我们只关注其中的三个。我们需要一个链接,用于建立与服务器的连接;一个缓存,如果我们想要缓存我们的交互结果;以及一个覆盖默认 Apollo 选项的选项,我们在这里设置了观察查询始终从网络获取。如果我们不从网络获取,就会遇到缓存数据变得陈旧直到刷新的问题:

apollo.create({
  link: httpLink.create({ uri: 'http://localhost:3000' }),
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      // To get the data on each get, set the fetchPolicy
      fetchPolicy: 'network-only'
    }
  }
});

不要忘记,注入组件需要我们将相关模块添加到@NgModule模块的imports部分。在这种情况下,如果我们想要能够在其他地方自动使用这些,我们需要添加HttpLinkModuleApolloModule

这是我们需要放置的所有代码,以便我们的客户端与工作的 Apollo 服务器进行通信。当然,在生产系统中,我们会从其他地方获取服务器的地址并使用它,而不是硬编码的 localhost。但对于我们的示例,这就是我们需要的。现在我们可以继续添加屏幕和使用路由导航到它们的任务。

添加路由支持

我们为应用程序设置的要求是我们将有三个主要屏幕。我们的主屏幕将显示所有待办任务,包括它们是否已完成。第二个将显示过期任务,最后一个将让我们的用户添加新任务。每个都将创建为单独的组件。现在,我们将添加它们的虚拟实现,这将允许我们设置我们的路由:

ng g c components/AddTask
ng g c components/Alltasks
ng g c components/OverdueTasks

我们的路由是从app-routing.module.ts文件配置和控制的。在这里,我们将定义一组规则,我们期望 Angular 遵循。

在我们开始添加路由之前,我们实际上应该弄清楚这里的路由术语是什么意思。想到路由的简单方法是想到 URL。路由对应于 URL,或者说,对应于 URL 的基地址之外的部分。由于我们的页面将在localhost:4000上运行,我们的完整 URL 是http://localhost:4000/。现在,如果我们希望我们的AllTasks组件映射到http://localhost:4000/all,我们会认为路由是all

现在我们知道了路由是什么,我们需要将这三个组件映射到它们自己的路由。我们首先通过定义一个路由数组开始:

const routes: Routes = [
];

我们通过在模块定义中提供路由与我们的路由模块相关联,如下所示:

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

我们希望将AllTasks组件映射到all,因此我们将其添加为路由数组中的一个元素:

{
  path: 'all',
  component: AlltasksComponent
},

此时,当我们启动我们的 Angular 应用程序时,如果我们在http://localhost:4000/all中输入,我们可以显示all任务页面。虽然这相当令人印象深刻,但如果我们没有默认的站点着陆页的概念,这将会让用户感到恼火。我们的用户通常会期望他们可以进入站点而不必知道我们任何页面名称的细节,并且他们应该能够从那里导航,因为我们将引导他们到适当的页面。幸运的是,我们可以非常容易地实现这一点。我们将添加另一个包含空路径的路由。当我们遇到空路径时,我们将重定向用户到all页面:

{
  path: '',
  redirectTo: 'all',
  pathMatch: 'full'
},

现在,当用户导航到http://localhost:4000/时,他们将被重定向以查看我们所有未完成的任务。

我们还有两个组件,我们希望用户能够导航到:我们的AddTask页面和我们的OverdueTasks页面。同样,我们将添加支持通过新路由导航到这些页面。一旦我们添加了这些路由,我们就可以关闭这个文件,因为我们已经添加了所有我们需要的核心路由支持:

{
  path: 'add',
  component: AddTaskComponent
},
{
  path: 'overdue',
  component: OverduetasksComponent
}

路由用户界面

将路由支持添加到我们应用程序的最后一部分是设置app-component.html的内容。在这里,我们将添加一个工具栏,其中包含指向我们页面的链接以及显示页面组件本身的位置。工具栏只包含三个导航列表项。每个链接的有趣部分是routerLink,它将我们的链接与之前添加的地址联系起来。实际上,这部分的作用是告诉代码,当我们链接到该路由时,我们希望内容呈现在特殊的router-outlet标记中,这只是实际组件内容的占位符。

<mat-toolbar color="primary">
  <mat-nav-list><a mat-list-item routerLink="all">All tasks</a></mat-nav-list>
  <mat-nav-list><a mat-list-item routerLink="overdue">Overdue tasks</a></mat-nav-list>
  <mat-nav-list><a mat-list-item routerLink="add">Add task</a></mat-nav-list>
</mat-toolbar> 
<div>
  <router-outlet></router-outlet>
</div>

现在,当我们运行我们的应用程序时,单击不同的链接将显示相应的页面,尽管它们实际上几乎没有任何内容。

向页面组件添加内容

现在我们已经整理好了我们的路由,我们准备开始为我们的页面添加一些功能。除了添加内容,我们还将通过使用 Angular 验证来为我们的应用程序添加一些修饰,以便为用户提供即时反馈。我们要开始的组件是AddTask组件。如果没有添加任务的能力,我们将无法显示任何任务,所以让我们给自己一个机会开始添加一些待办任务。

在我开始添加用户界面元素之前,我喜欢确保我尽可能多地放置组件背后的逻辑。一旦这一点到位,实际添加用户界面就变得简单。在某些情况下,这意味着我甚至在考虑如何显示特定的显示部分或使用什么控件来显示它之前,我已经决定了 UI 约束。考虑到这一点,我们知道我们的待办事项中的一项是DueDate。如果我们思考一下,我们意识到创建一个已经过期的截止日期的任务是没有意义的。因此,我们将设置任务的最早截止日期为今天的日期。这将用作我们选择日期的任何控件的约束。

EarliestDate: Date;
ngOnInit() {
 this.EarliestDate = new Date();
}

我们将从用户那里捕获三件事,以便创建我们的待办任务。我们需要捕获标题、描述和任务到期日期。这告诉我们我们需要三个项目作为我们的模型。

Title: string;
Description?: string;
DueDate: Date;

这就是我们在添加任务组件的模型方面所需要的一切,但我们缺少实际将任何内容保存到我们的 GraphQL 服务器的能力。在我们开始与服务器通信之前,我们需要在我们的组件中引入 Apollo 的支持。这只需要在我们的构造函数中添加一个引用即可。

constructor(private apollo: Apollo) { }

我们要执行的操作必须与我们的解析器期望的相匹配。这意味着类型必须完全匹配,我们的 GraphQL 必须格式良好。由于我们要执行的任务是添加操作,我们将调用用于添加数据的方法Add

Add(): void {
}

添加操作将触发服务器上我们创建的解析器上的Add变异。我们知道这接受一个TodoItemInput实例,因此我们需要将我们的客户端模型转换为TodoItemInput实例,如下所示。

const todo: ITodoItemInput = new TodoItemInput();
todo.Completed = false;
todo.Id = Guid.create.toString();
todo.CreationDate = new Date();
todo.Title = this.Title;
todo.Description = this.Description;
todo.DueDate = this.DueDate;

在前面的片段中有一点对我们来说是陌生的,即Guid.create.toString()调用。这个命令负责创建一个称为全局唯一标识符GUID)的唯一标识符。GUID 是一个 128 位的数字,以字符串和数字格式外部表示,通常看起来像这样——a14abe8b-3d9b-4b14-9a66-62ad595d4582。由于 GUID 在数学上保证唯一性,而不是必须调用中央存储库来获取唯一值,它们很快就能生成。通过使用 GUID,我们为我们的待办事项赋予了一个唯一的值。如果需要的话,我们可以在服务器上完成这个操作,但我选择在客户端生成整个消息。

为了使用 GUID,我们将使用guid-typescript组件:

npm install --save guid-typescript

现在我们可以将代码放在适当的位置,将数据传输到 GraphQL 服务器。正如我之前提到的,我们将使用Add变异,这告诉我们我们将在我们的apollo客户端上调用mutate

this.apollo.mutate({
 ... logic goes here
})

变异是一种特殊的字符串,由gql覆盖。如果我们能看到这段代码的全部内容,我们将能够立即分解它:

this.apollo.mutate({
  mutation: gql`
    mutation Add($input: TodoItemInput!) {
      Add(TodoItem: $input) {
        Title
      }
    }
  `, variables: {
    input: todo
  }
}).subscribe();

我们已经知道我们将要调用一个变异,所以我们的mutate方法接受一个变异作为MutationOption

我们可以向MutationOption提供的参数之一是FetchPolicy,我们可以用它来覆盖我们在之前创建 Apollo 链接时设置的默认选项。

变异使用gql来创建特殊格式的查询。我们的查询分为两部分:告诉我们查询是什么的字符串文本和我们需要应用的任何变量。变量部分创建一个映射到我们之前创建的TodoItemInput的输入变量。这在我们的gql字符串内表示为$,因此任何变量名必须在查询中有一个匹配的$variable。当变异完成时,我们告诉它我们想要标题。实际上我们不必带回任何值,但是在我之前调试时,我发现使用标题来检查我们是否从服务器得到了响应是有用的。

我们正在使用 ```ts backtick because this lets us spread our input over multiple lines.

The mutate method is triggered from the call to subscribe. If we fail to supply this, our mutation will not run. As a convenience, I also added a Reset method so that we can clear values away from the UI when the user finishes. I did this so that the user would be able to immediately enter new values:


private Reset(): void {

this.Title = ``;

this.Description = ``;

this.DueDate = null;

}

```ts

That is the logic inside our component taken care of. What we need to do now is add the HTML that will be displayed in the component. Before we add any elements to our component, we want to display the card that will contain our display. This will be centered vertically and horizontally in the display. This is not something that comes naturally to Material, so we have to supply our own local styling. We have a couple of other styles that we are going to set as well, to fix the size of the text area and the width of the card, and to set how we display form fields to make sure each one appears on its own line.

Initially, we will set up a style to center the card. The card will be displayed inside a `div` tag, so we will apply the styling to the `div` tag, which will center the card inside it:

.centerDiv{

height: 100vh;

display: flex;

justify-content: center;

align-items: center;

}


Now, we can style the Material card and form fields:

.mat-card {

width: 400px;

}

.mat-form-field {

display: block;

}


Finally, we are going to set the height of the `textarea` tag that the user will use to enter their description to 100 pixels:

textarea {

height: 100px;

resize: vertical;

}


Getting back to our display, we are going to set up the container for our card so that it is centered:

.... 内容在这里


We have reached a point where we want to start leveraging the power of Angular to control the validation of the user input. In order to start treating user input as though it's all related, we are going to put the input parts of our display inside an HTML form:

.... 表单内容在这里。


We need to break this form statement down a bit. We will start by working out what `#f="ngForm"` actually does. This statement assigns the `ngForm` component to a variable called `f`. When we use `ngForm`, we are referring to the component inside `FormsModule` (make sure that it's registered inside the `app.module` imports section). The reason that we do this is because this assignment means that we have access to properties of the component itself. The use of `ngForm` means that we are working with the top-level form group so that we can do things such as track whether or not the form is valid.

We can see this inside `ngSubmit`, where we are subscribing to the event that tells us that the user has triggered the form submission, which results in the validation being checked; when the data is valid, this results in triggering the `Add` method. With this in place, we don't have to directly call `Add` when the Save button is clicked because the submit event will take care of this for us.

There is a short-circuit logic in play with `ngSubmit`. In other words, if the form is not valid, then we won't call the `Add` method.

We are now ready to add the card itself. This lives entirely inside our form. The title section is placed inside a `mat-card-title` section and our buttons are situated inside the `mat-card-actions` section, which aligns the buttons at the bottom of the card. As we just covered, we aren't supplying a click event handler to our Save button because the form submission will take care of this:

Add ToDo

.... 内容在这里。


We are ready to start adding the fields so that we can tie them back to the fields in our underlying model. We will start with the title as the description field largely follows this format as well. We will add the field and its related validation display in first, and then we will break down what is happening:

<input type="text" matInput placeholder="Title" [(ngModel)]="Title" name="title" #title="ngModel" required />

您必须添加一个标题。


The first part of our input element is largely self-explanatory. We created it as a text field and used `matInput` to hook the standard input so that it can be used inside `mat-form-field`. With this, we can set the placeholder text to something appropriate.

I opted to use `[(ngModel)]` instead of `[ngModel]` because of the way binding works. With `[ngModel]`, we get one-way binding so that it changes flow from the underlying property through to the UI element that displays it. Since we are going to be allowing the input to change the values, we need a form of binding that allows us to send information back from the template to the component. In this case, we are sending the value back to the `Title` property in the element.

The `name` property must be set. If it is not set, Angular throws internal warnings and our binding will not work properly. What we do here is set the name and then use `#` with the value set in the name to tie it to `ngModel`. So, if we had `name="wibbly"`, we would have `#wibbly="ngModel"` as well.

Since this field is required, we simply need to supply the `required` attribute, and our form validation will start working here.

Now that we have the input element hooked up to validation, we need some way of displaying any errors. This is where the next `div` statement comes in. The opening `div` statement basically reads as *if the title is invalid (because it is required and has not been set, for instance), and it has either had a value changed in it or we have touched the field by setting focus to it at some point, then we need to display internal content using the alert and alert-danger attributes*.

As our validation failure might just be one of several different failures, we need to tell the user what the problem actually was. The inner `div` statement displays the appropriate text because it is scoped to a particular error. So, when we see `title.errors.required`, our template will display the You must add a title*.* text when no value has been entered.

We aren't going to look at the description field because it largely follows the same format. I would recommend looking at the Git code to see how that is formatted.

We still have to add the `DueDate` field to our component. We are going to use the Angular date picker module to add this. Effectively, the date picker is made up of three parts.

We have an input field that the user can type directly into. This input field is going to have a `min` property set on it that binds the earliest date the user can select to the `EarliestDate` field we created in the code behind the component. Just like we did in the title field, we will set this field to required so that it will be validated by Angular, and we will apply `#datepicker="ngModel"` so that we can associate the `ngModel` component with this input field by setting the name with it:

<input matInput [min]="EarliestDate" [matDatepicker]="picker" name="datepicker" placeholder="Due date"

datepicker="ngModel" required [(ngModel)]="DueDate">


The way that we associate the input field is by using `[matDatepicker]="picker"`. As part of our form field, we have added a `mat-datepicker` component. We use `#picker` to name this component `picker`, which ties back to the `matDatepicker` binding in our input field:

<mat-datepicker #picker>


The final part that we need to add is the toggle that the user can press to show the calendar part on the page. This is added using `mat-datepicker-toggle`. We tell it what date picker we are applying the calendar to by using `[for]="picker"`:

<mat-datepicker-toggle matSuffix [for]="picker">


Right now, our form field looks like this:

<input matInput [min]="EarliestDate" [matDatepicker]="picker" name="datepicker" placeholder="Due date"

datepicker="ngModel" required [(ngModel)]="DueDate">

<mat-datepicker-toggle matSuffix [for]="picker">

<mat-datepicker #picker>


All that we are missing now is the validation. Since we have already defined that the earliest date we can choose is today, we don't need to add any validation to that. We have no maximum date to worry about, so all we need to do is check that the user has chosen a date:

您必须选择一个截止日期。


So, we have reached the point where we can add tasks to our todo list and they will be saved to the database, but that isn't much use to us if we can't actually view them. We are now going to turn our attention to the `AllTasksComponent` and `OverdueTasksComponent` components.

Our `AllTasksComponent` and `OverdueTasksComponent` components are going to display the same information. All that differs between the two is the GQL call that is made. Because they have the same display, we are going to add a new component that will display the todo information. `AllTasksComponent` and `OverdueTasksComponent` will both use this component:

ng g c components/Todo-Card


Just like in our add task component, `TodoCardComponent` is going to start off with an `EarliestDate` field and the Apollo client being imported:

EarliestDate: Date;

constructor(private apollo: Apollo) {

this.EarliestDate = new Date();

}


We have reached the point where we need to consider what this component is actually going to be doing. It will receive a single `ITodoItem` as input from either `AllTasksComponent` or `OverdueTasksComponent`, so we will need a means for the containing component to be able to pass this information in. We will also need a means to notify the containing component of when the todo item has been deleted so that it can be removed from the tasks being displayed (we will just do this on the client side rather than triggering a requery via GraphQL). Our UI will add a Save button when the user is editing the record, so we are going to need some way to track that the user is in the edit section.

With those requirements for the component, we can add in the necessary code to support this. First, we are going to address the ability to pass in a value to our component as an input parameter. In other words, we are going to add a field that can be seen and has values set on it by using data binding by the containers. Fortunately, Angular makes this a very simple task. By marking a field with `@Input`, we expose it for data binding:

@Input() Todo: ITodoItem;


That takes care of the input, but how do we let the container know when something has happened? When we delete a task, we want to raise an event as output from our component. Again, Angular makes this simple by using `@Output` to expose something; in this case, we are going to expose `EventEmitter`. When we expose this to our containers, they can subscribe to the event and react when we emit the event. When we create `EventEmitter`, we are going to create it to pass the `Id` of our task back, so we need `EventEmitter` to be a string event:

@Output() deleted: EventEmitter = new EventEmitter();


With this code in place, we can update our `AllTasksComponent` and `OverdueTasksComponent` templates that will hook up to our component:

<atp-todo-card

*ngFor="let todo of todos"

[Todo]="todo"

(deleted)="resubscribe($event)">


Before we finish adding the logic to `TodoCardComponent`, let's get back to `AllTasksComponent` and `OverdueTasksComponent`. Internally, these are both very similar, so we will concentrate on the logic in `OverdueTasksComponent`.

It shouldn't come as a shock now that these components will accept an Apollo client in the constructor. As we saw from `ngFor` previously, our component will also expose an array of `ITodoItem` called `todos`, which will be populated by our query:

todos: ITodoItem[] = new Array();

constructor(private apollo: Apollo) { }


You may notice, from looking at the code in the repository, that we have not added this code into our component. Instead, we are using a base class called `SubscriptionBase` that provides us with a `Subscribe` method and a resubscribe event.

Our `Subscribe` method is generic accepts either `OverdueTodoItemQuery` or `TodoItemQuery` as the type, along with a `gql` query, and returns an observable that we can subscribe to in order to pull out the underlying data. The reason we have added the base class goes back to the fact that `AllTasksComponent` and `OverdueTasksComponent` are just about identical, so it makes sense to reuse as much code as possible. The name that is sometimes given to this philosophy is **Don't Repeat Yourself** (**DRY**):

protected Subscribe<T extends OverdueTodoItemQuery | TodoItemQuery>(gqlQuery: unknown): Observable<ApolloQueryResult> {

}


All this method does is create a query using `gql` and set `fetch-policy` to `no-cache` to force the query to read from the network rather than relying on the cache set in `app-module`. This is just another way of controlling whether or not we read from the in-memory cache:

return this.apollo.query({

query: gqlQuery,

fetch-policy: 'no-cache'

});


We extend from a choice of two interfaces because they both expose the same items but with different names. So, `OverdueTodoItemQuery` exposes `OverdueTodoItems` and `TodoItemQuery` exposes `TodoItems`. The reason that we have to do this, rather than using just one interface, is because the field must match the name of the query. This is because Apollo client uses this to automatically map results back.

The `resubscribe` method is called after the user clicks the delete button in the interface (we will get to building up the UI template shortly). We saw that our `resubscribe` method was wired up to the event and that it would receive the event as a string, which would contain the `Id` of the task we want to delete. Again, all we are going to do to delete the record is find the one with the matching `Id`, and then splice the todos list to remove it:

resubscribe = (event: string) => {

const index = this.todos.findIndex(x => x.Id === event);

this.todos.splice(index, 1);

}


Going back to `OverdueTasksComponent`, all we need to do is call `subscribe`, passing in our `gql` query and subscribing to the return data. When the data comes back, we are going to populate our todos array, which will be displayed in the UI:

ngOnInit() {

this.Subscribe(gql`query ItemsQuery {

OverdueTodoItems {

Id,

标题,

Description,

DaysCreated,

DueDate,

Completed

}

}).subscribe(todo => {

this.todos = new Array();

todo.data.OverdueTodoItems.forEach(x => {

this.todos.push(x);

});

});

}


A note on our subscription—as we are creating a new list of items to display, we need to clear `this.todos` before we start pushing the whole list back into it.

With `AllTasksComponent` and `OverdueTasksComponent` complete, we can turn our attention back to `TodoCardComponent`. Before we finish off adding the component logic, we really need to take a look at the way the template is created. A large part of the logic is similar to the add task UI logic, so we aren't going to worry about how to hook up to a form or add a validation. The things I want to concentrate on here relate to the fact that the task component will display differently when the user is in edit mode, as opposed to a read-only or label-based version. Let's start by looking at the title. When the task is in read-only mode, we are just going to display the title in `span`, like this:

{{Todo.Title}}


When we are editing the task, we want to show input elements and validation, as follows:

<input type="text" name="Title" matInput placeholder="Title" [(ngModel)]="Todo.Title" #title="ngModel"

required />

You must add a title.


We do this by using a neat trick of Angular. Behind the scenes, we are maintaining an `InEdit` flag. When that is false, we want to display the span. If it is true, we want to display a template in its place that contains our input logic. To do this, we start off by wrapping our span inside a `div` tag. This has an `ngIf` statement that is bound to `InEdit`. The `ngIf` statement contains an `else` clause that picks up the template with the matching name and displays this in its place:

{{Todo.Title}}

<ng-template #editTitle>

<input type="text" name="Title" matInput placeholder="Title" [(ngModel)]="Todo.Title" #title="ngModel"

required />

You must add a title.


Other fields are displayed in a similar way. There is one more point of interest in the way we display the read-only fields. `DueDate` needs to be formatted in order to be displayed as a meaningful date rather than as the raw date/time that is saved in the database. We use `|` to pipe `DueDate` into a special date formatter that controls how the date is displayed. For instance, March 21, 2018 would be displayed as `Due: Mar 21st, 2019` using the following date pipe:

Due: {{Todo.DueDate | date}}


Please take the time to review the rest of `todo-card.component.html`. Swapping templates is heavily done, so it is a good way to review how to make the same UI serve two purposes.

In the component itself, we have three operations left to look at. The first one that we will cover is the `Delete` method, which is triggered when the user presses the delete button on the component. This is a simple method that calls the `Remove` mutation, passing the `Id` across to be removed. When the item has been removed from the server, we call `emit` on our `deleted` event. This event passes the `Id` back to the containing component, which results in this item being removed from the UI:

Delete() {

this.apollo.mutate({

mutation: gql`

mutation Remove($Id: String!) {

Remove(Id: $Id)

}

`, variables: {

Id: this.Todo.Id

}

}).subscribe();

this.deleted.emit(this.Todo.Id);

}


The `Complete` method is just as simple. When the user clicks the `Complete` link, we call the `Complete` query, which passes across the current `Id` as the matching variable. As we could be in edit mode at this point, we call `this.Edit(false)` to switch back to read-only mode:

Complete() {

this.apollo.mutate({

mutation: gql`

mutation Complete($input: String!) {

Complete(Id: $input)

}

`, variables: {

input: this.Todo.Id

}

}).subscribe();

this.Edit(false);

this.Todo.Completed = true;

}


The `Save` method is very similar to the `Add` method in the add task component. Again, we need to switch back from edit mode when this mutation finishes:

Save() {

const todo: ITodoItemInput = new TodoItemInput();

todo.Completed = false;

todo.CreationDate = new Date();

todo.Title = this.Todo.Title;

todo.Description = this.Todo.Description;

todo.DueDate = this.Todo.DueDate;

todo.Id = this.Todo.Id;

this.apollo.mutate({

mutation: gql`

mutation Update($input: TodoItemInput!) {

Update(TodoItem: $input)

}

`, variables: {

input: todo

}

}).subscribe();

this.Edit(false);

}


到目前为止,我们拥有一个完全功能的基于客户端和服务器的 GraphQL 系统。

# Summary

在本章中,我们通过将其视为检索和更新数据的 REST 服务的替代品,研究了 GraphQL 带来的好处。我们调查了将 Apollo 设置为服务器端 GraphQL 引擎,并将 Apollo 添加到 Angular 客户端以与服务器交互,以及查看专业的 GQL 查询语言。为了充分利用 TypeScript 的强大功能,我们引入了`type-graphql`包,以简化 GraphQL 模式和解析器的创建。

从上一章的经验中,我们看到了如何开始构建可重用的 MongoDB 数据访问层;虽然还有一些工作要做,但我们已经很好地开始了,留下了一些空间来消除应用程序约束,比如需要使用`Id`来查找记录。

本章还向我们介绍了 Angular 路由,以便根据用户选择的路由提供不同的视图。我们继续使用 Material,以便了解如何将此逻辑应用于我们在第四章中介绍的导航内容,*MEAN Stack - 构建照片库*。我们还看了如何通过查看 Angular 提供的验证内容以及如何将其与内联模板一起使用,以便向用户提供有关任何问题的一致反馈,以防止用户输入错误。

在下一章中,我们将通过使用 Socket.IO 来保持客户端和服务器之间的开放连接,来看另一种与服务器通信的方式。我们将构建一个 Angular 聊天应用程序,该应用程序将自动将对话转发到应用程序的所有开放连接。作为额外的奖励,我们将看到如何在 Angular 中集成 Bootstrap 以替代 Material,并仍然使用诸如路由之类的功能。我们还将介绍大多数专业应用程序依赖的功能:用户认证。

# 问题

1.  GraphQL 是否打算完全取代 REST 客户端?

1.  在 GraphQL 中,突变有什么作用?我们期望看到什么类型的操作?

1.  我们如何将参数传递给 Angular 中的子组件?

1.  模式和解析器之间有什么区别?

1.  我们如何创建一个单例?

完整的函数不会从过期项目页面中删除已完成的任务。增强代码以在用户点击完成后从页面中删除该项目。

# 进一步阅读

+   为了更深入地探讨 GraphQL 的奥秘,我推荐 Brian Kimokoti 的优秀著作《入门 GraphQL》([`www.packtpub.com/in/application-development/beginning-graphql`](https://www.packtpub.com/in/application-development/beginning-graphql))。

+   要在 React 中查看 GraphQL 的使用,Sebastian Grebe 写了《使用 GraphQL 和 React 进行全栈 Web 开发实践》([`www.packtpub.com/in/web-development/hands-full-stack-web-development-graphql-and-react`](https://www.packtpub.com/in/web-development/hands-full-stack-web-development-graphql-and-react))。


# 第六章:使用 Socket.IO 构建聊天室应用程序

在本章中,我们将介绍如何使用 Socket.IO 构建一个 Angular 聊天室应用程序,以便探索在客户端和服务器之间发送消息的能力,而无需建立 REST API 或通过使用 GraphQL 查询。我们将使用的技术涉及从客户端到服务器的建立长时间运行的连接,使通信变得像传递消息一样简单。

在本章中,我们将涵盖以下主题:

+   使用 Socket.IO 进行长时间运行的客户端/服务器通信

+   创建一个 Socket.IO 服务器

+   创建一个 Angular 客户端并添加 Socket.IO 支持

+   使用装饰器添加客户端日志记录

+   在我们的客户端使用 Bootstrap

+   添加 Bootstrap 导航

+   注册 Auth0 以对我们的客户端进行身份验证

+   为我们的客户端添加 Auth0 支持

+   添加安全的 Angular 路由

+   在我们的客户端和服务器上连接到 Socket.IO 消息

+   使用 Socket.IO 命名空间来分隔消息

+   添加房间支持

+   接收和发送消息

# 技术要求

完成的项目可以从[`github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter06`](https://github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter06)下载。

下载项目后,您将需要使用`npm install`命令安装软件包要求。

# 使用 Socket.IO 进行长时间运行的客户端/服务器通信

到目前为止,我们已经涵盖了各种方式在客户端和服务器之间进行来回通信,但它们都有一个共同点——它们都是对某种形式的交互做出反应,以触发数据传输。无论我们点击了链接还是按下了按钮,都有一些用户输入触发了双方之间的来回交流。

然而,有些情况下,我们希望保持客户端和服务器之间的通信线路永久打开,以便在数据可用时立即推送数据。例如,如果我们在玩在线游戏,我们不希望必须按下按钮才能更新屏幕上其他玩家的状态。我们需要的是一种能够为我们维护连接并允许我们无障碍传递消息的技术。

多年来,已经出现了许多旨在解决这一问题的技术。其中一些技术,如 Flash 套接字,因依赖专有系统而不受青睐。总称为**推送技术**,并出现了一种称为**WebSocket**的标准,并变得普遍,所有主要浏览器都支持它。值得知道的是,WebSocket 与 HTTP 并存作为一种合作协议。

这里有一个关于 WebSocket 的趣闻。虽然 HTTP 使用 HTTP 或 HTTPS 来标识协议,但 WebSocket 的规范定义了**WS**或**WSS**(WebSocket Secure 的缩写)作为协议标识符。

在 Node 世界中,Socket.IO 已成为启用 WebSocket 通信的*事实*标准。我们将使用它来构建一个保持所有连接用户聊天室应用程序。

# 项目概述

*经典*基于套接字的应用程序是创建一个聊天室。这几乎是套接字应用程序的*Hello World*。聊天室之所以如此有用,是因为它允许我们探索诸如向其他用户发送消息、对来自其他用户的消息做出反应以及使用房间来分隔消息发送位置等技术。

在过去的几章中,Material Design 在其开发中起到了很大作用,所以现在是我们返回到 Bootstrap 4 并看看如何在 Angular 应用程序中使用它来布局界面的合适时机。我们还将在客户端和服务器上使用 Socket.IO 来实现双向通信。在以前的章节中缺少的是对用户进行身份验证的能力。在本章中,我们将通过注册使用 Auth0([`auth0.com/`](https://auth0.com/))来引入身份验证支持。

与 GitHub 代码一起工作,完成本章大约需要两个小时。完成后,应用程序应如下所示:

![](https://gitee.com/OpenDocCN/freelearn-js-zh/raw/master/docs/adv-ts-prog-pj/img/dc5c7471-d8bc-47a2-be5f-b0db66be0c1b.png)

现在我们知道我们想要构建什么类型的应用程序,以及我们希望它看起来像什么,我们准备开始构建我们的应用程序。在下一节中,我们将看看如何使用 Auth0 为我们的应用程序添加外部身份验证。

# 使用 Socket.IO 和 Angular 入门

大多数要求,如 Node.js 和 Mongoose,与前几章相同,因此我们不再列出额外的组件。在本章中,我们将指出我们需要的任何新组件。总是可以在 GitHub 的代码中找到我们使用的内容。

作为本章的一部分,我们将使用 Auth0([`auth0.com`](https://auth0.com))来对我们的用户进行身份验证。Auth0 是身份验证的最受欢迎的选择之一,因为它负责所有基础设施。我们只需要提供一个安全的登录和信息存储机制。我们使用 Auth0 的想法是利用他们的 API 来验证使用我们的应用程序的人的身份,通过使用**开放认证**(**OAuth**)框架,这使我们能够根据这种身份验证自动显示或隐藏我们应用程序的部分访问权限。使用 OAuth 及其后继者 OAuth 2,我们使用了一个标准的授权协议,允许经过身份验证的用户访问我们应用程序的功能,而无需注册我们的网站并提供登录信息。

最初,本章将使用护照提供身份验证支持,但考虑到最近来自 Facebook 等公司的备受关注的安全问题,我决定我们将使用 Auth0 来处理和管理我们的身份验证。在身份验证方面,我发现最好确保在安全性方面使用最好的技术。

在我们编写任何代码之前,我们将注册到 Auth0 并创建我们需要的单页 Web 应用程序基础设施。首先点击“注册”按钮,这将重定向您到以下 URL:[`auth0.com/signup?&signUpData=%7B%22category%22%3A%22button%22%7D`](https://auth0.com/signup?&signUpData=%7B%22category%22%3A%22button%22%7D)。我选择使用我的 GitHub 帐户注册,但您可以选择任何可用的选项。

Auth0 为我们提供了各种付费高级服务以及免费版本。我们只需要基本功能,所以免费版本非常适合我们的需求。

注册后,您需要按“创建应用程序”按钮,这将弹出“创建应用程序”对话框。给它一个名称,并选择“单页 Web 应用程序”,然后单击“创建”按钮创建 Auth0 应用程序:

![](https://gitee.com/OpenDocCN/freelearn-js-zh/raw/master/docs/adv-ts-prog-pj/img/9f145e06-448b-4ac8-803f-133153f590d8.png)

如果您单击“设置”选项卡,您应该会看到类似以下内容的东西:

![](https://gitee.com/OpenDocCN/freelearn-js-zh/raw/master/docs/adv-ts-prog-pj/img/1d372384-f3ed-4778-ad9e-73ba49ea0f4f.png)

有回调 URL、允许的 Web 起源、注销 URL、CORS 等选项可用。

Auth0 的全部范围超出了本书的范围,但我建议阅读提供的文档,并根据您创建的任何应用程序适当地设置这些设置。

安全提示:在本书中,我提供有关客户端 ID 或类似唯一标识符的详细信息,这纯粹是为了说明代码。任何实时 ID 都将被停用以确保安全。我建议您采用类似的良好做法,不要在 GitHub 等公共位置提交实时标识符或密码。

# 使用 Socket.IO、Angular 和 Auth0 创建聊天室应用程序

在开始开发之前,我们应该弄清楚我们想要构建什么。由于聊天室是一个足够常见的应用程序,我们很容易想出一套标准的要求,这将帮助我们练习 Socket.IO 的不同方面。我们要构建的应用程序的要求如下:

+   用户将能够发送消息,让所有用户在通用聊天页面上看到

+   用户将能够登录应用程序,此时将有一个安全页面可用

+   已登录用户将能够发送消息,只有其他已登录用户才能看到

+   连接时,旧消息将被检索并显示给用户

# 创建我们的应用程序

到目前为止,创建一个节点应用程序应该是驾轻就熟的,所以我们不会再覆盖如何做了。我们将使用的`tsconfig`文件如下:

```ts
{
  "compileOnSave": true,
  "compilerOptions": {
    "incremental": true,
    "target": "es5",
    "module": "commonjs",
    "outDir": "./dist",
    "removeComments": true,
    "strict": true,
    "esModuleInterop": true,
    "inlineSourceMap": true,
    "experimentalDecorators": true,
  }
}

设置中的增量标志是 TypeScript 3.4 引入的一个新功能,它允许我们执行增量构建。这个功能的作用是在代码编译时构建一个称为项目图的东西。下次编译代码时,项目图将用于识别未更改的代码,这意味着它不需要重新构建。在更大的应用程序中,这可以节省大量编译时间。

我们将把消息保存到数据库中,所以毫无疑问,我们将从数据库连接代码开始。这一次,我们要做的是将数据库连接移到一个类装饰器,该装饰器接受数据库名称作为装饰器工厂的参数:

export function Mongo(connection: string) {
  return function (constructor: Function) {
    mongoose.connect(connection, { useNewUrlParser: true}, (e:unknown) => {
      if (e) {
        console.log(`Unable to connect ${e}`);
      } else {
        console.log(`Connected to the database`);
      }
    });
  }
}

在创建之前,不要忘记安装mongoose@types/mongoose

有了这个,当我们创建我们的server类时,我们只需要装饰它,就像这样:

@Mongo('mongodb://localhost:27017/packt_atp_chapter_06')
export class SocketServer {
}

就是这样。当SocketServer被实例化时,数据库将自动连接。我必须承认,我真的很喜欢这种简单的方法。这是一种优雅的技术,可以应用到其他应用程序中。

在上一章中,我们构建了一个DataAccessBase类来简化我们处理数据的方式。我们将采取这个类,并删除一些在本应用程序中不会使用的方法。同时,我们将看看如何删除硬模型约束。让我们从类定义开始:

export abstract class DataAccessBase<T extends mongoose.Document>{
  private model: Model;
  protected constructor(model: Model) {
    this.model = model;
  }
}

Add方法在上一章中也应该看起来很熟悉:

Add(item: T): Promise<boolean> {
  return new Promise<boolean>((callback, error) => {
    this.model.create(item, (err: unknown, result: T) => {
      if (err) {
        error(err);
      }
      callback(!result);
    });
  });
}

在上一章中,我们有一个约束,即查找记录需要有一个名为Id的字段。虽然那是一个可以接受的限制,但我们真的不想强制应用程序必须有Id作为字段。我们将提供一个更开放的实现,允许我们指定检索记录所需的任何条件以及选择要返回的字段的能力:

GetAll(conditions: unknown, fields: unknown): Promise<unknown[]> {
  return new Promise<T[]>((callback, error) => {
    this.model.find(conditions, fields, (err: unknown, result: T[]) => {
      if (err) {
        error(err);
      }
      if (result) {
        callback(result);
      }
    });
  });
}

就像在上一章中一样,我们将创建一个基于mongoose.Document的接口和一个Schema类型。这将形成消息合同,并将存储有关房间、消息文本和接收消息的日期的详细信息。然后,这些将被组合以创建我们需要用作数据库的物理模型。让我们看看如何做:

  1. 首先,我们定义mongoose.Document实现:
export interface IMessageSchema extends mongoose.Document{
  room: string;
  messageText: string;
  received: Date;
}
  1. 对应的Schema类型如下:
export const MessageSchema = new Schema({
  room: String,
  messageText: String,
  received: Date
});
  1. 最后,我们创建一个MessageModel实例,我们将使用它来创建数据访问类,用于保存和检索数据:
export const MessageModel = mongoose.model<IMessageSchema>('message', MessageSchema, 'messages', false);
export class MessageDataAccess extends DataAccessBase<IMessageSchema> {
  constructor() {
    super(MessageModel);
  }
}

为服务器添加 Socket.IO 支持

我们现在已经到了准备将 Socket.IO 引入到我们的服务器并创建一个运行的服务器实现的阶段。运行以下命令来整合 Socket.IO 和相关的DefinitelyTyped定义:

npm install --save socket.io @types/socket.io

有了这些定义,我们将把 Socket.IO 支持引入到我们的服务器中,并开始运行它,准备接收和传输消息:

export class SocketServer {
  public Start() {
    const appSocket = socket(3000);
    this.OnConnect(appSocket);
  }

  private OnConnect(io: socket.Server) {
  }
}
new SocketServer.Start();

我们的OnConnect方法接收的参数是在 Socket.IO 中接收和响应消息的起始点。我们用这个参数来监听连接消息,这将表明客户端已连接。当客户端连接时,它为我们打开了一个类似套接字的东西,用于开始接收和发送消息。当我们想直接向特定客户端发送消息时,我们将使用以下代码片段中返回的socket可用的方法:

io.on('connection', (socket:any) => {
});

在这一点上,我们需要明白,尽管技术的名称是 Socket.IO,但这不是一个 WebSocket 实现。虽然它可以使用 WebSockets,但并不能保证它实际上会使用;例如,企业政策可能禁止使用套接字。那么,Socket.IO 实际上是如何工作的呢?嗯,Socket.IO 由许多不同的协作技术组成,其中之一称为 Engine.IO,它提供了底层的传输机制。它在连接时采用的第一种连接类型是 HTTP 长轮询,这是一种快速高效的传输机制。在空闲期间,Socket.IO 会尝试确定传输是否可以切换到套接字,如果可以使用套接字,它会无缝地升级传输以使用套接字。对于客户端来说,它们连接得很快,消息也是可靠的,因为 Engine.IO 部分即使存在防火墙和负载均衡器,也能建立连接。

我们想为客户端提供的一件事是以前进行的对话的历史记录。这意味着我们希望读取并保存我们的消息到数据库中。在我们的连接中,我们将读取用户当前所在房间的所有消息并将它们返回给用户。如果用户没有登录,他们只能看到房间未设置的消息:

this.messageDataAccess.GetAll({room: room}, {messageText: 1, _id: 0}).then((msgs: string[]) =>{   socket.emit('allMessages', msgs);  });

语法看起来有点奇怪,所以我们将一步一步地分解它。对GetAll的调用是从我们的DataAccessBase类中调用通用的GetAll方法。当我们创建这个实现时,我们讨论了需要使它更通用,并允许调用代码指定要过滤的字段以及要返回的字段。当我们说{room: room}时,我们告诉 Mongo 我们要根据房间来过滤我们的结果。我们可以将等效的 SQL 子句视为WHERE room = roomVariable。我们还想指示我们想要返回什么结果;在这种情况下,我们只想要messageText而不是_id字段,所以我们使用{messageText: 1, _id: 0}语法。当结果返回时,我们需要使用socket.emit将消息数组发送到客户端。这个命令将这些消息发送到打开连接的客户端,使用allMessages作为键。如果客户端有代码来接收allMessages,它将能够对这些消息做出反应。

我们选择作为消息的事件名称引出了 Socket.IO 的一个限制。有一些事件名称是我们不能用作消息的,因为它们由于对 Socket.IO 具有特殊含义而被限制。这些事件名称包括errorconnectdisconnectdisconnectingnewListenerremoveListenerpingpong

如果我们在客户端没有任何东西来接收消息,那么创建服务器并发送消息就没有太大意义。即使我们还没有准备好所有的消息,但我们已经有了足够的基础设施来开始编写我们的客户端。

创建我们的聊天室客户端

我们将再次使用ng new命令创建我们的 Angular 应用程序。我们将提供路由支持,但是当我们开始进行路由部分时,我们将看到如何确保用户无法绕过我们的身份验证:

ng new Client --style scss --prefix atp --routing true

由于我们的 Angular 客户端将经常使用 Socket.IO,我们将使用一个特定于 Angular 的 Socket.IO 模块为 Socket.IO 提供支持:

npm install --save ngx-socket-io

app.module.ts中,我们将通过创建一个指向服务器 URL 的配置来创建与我们的 Socket.IO 服务器的连接:

import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';
const config: SocketIoConfig = { url: 'http://localhost:3000', options: {}}

当我们导入模块时,这个配置被传递到静态的SocketIoModule.forRoot方法中,这将为我们配置客户端 socket。一旦我们的客户端启动,它将建立一个连接,触发我们在服务器代码中描述的连接消息序列:

imports: [   BrowserModule,   AppRoutingModule,   SocketIoModule.forRoot(config),

使用装饰器添加客户端日志

我们想要在客户端代码中使用的一个功能是能够记录方法调用,以及传递给它们的参数。我们在之前创建装饰器时已经遇到过这种类型的功能。在这种情况下,我们想要创建一个Log装饰器:

export function Log() {
  return function(target: Object,
  propertyName: string,
  propertyDesciptor: PropertyDescriptor): PropertyDescriptor {
  const method = propertyDesciptor.value;
  propertyDesciptor.value = function(...args: unknown[]) {
  const params = args.map(arg => JSON.stringify(arg)).join();
  const result = method.apply(this, args);
  if (args && args.length > 0) {
  console.log(`Calling ${propertyName} with ${params}`);
 } else {
  console.log(`Calling ${propertyName}. No parameters present.`)
 }  return result;
 };  return propertyDesciptor;
 } } 

Log装饰器的工作方式是从propertyDescriptor.value中复制方法开始。然后,我们通过创建一个接收方法传递的任何参数的函数来替换这个方法。在这个内部函数中,我们使用args.map来创建参数和值的字符串表示形式,然后将它们连接在一起。在调用method.apply运行方法之后,我们将方法和参数的详细信息写到控制台上。有了前面的代码,我们现在有了一个简单的机制,只需使用@Log就可以自动记录方法和参数。

在 Angular 中设置 Bootstrap

在 Angular 中,我们可以选择使用 Bootstrap 来为我们的页面添加样式,而不是使用 Material。添加支持是一个相当简单的任务。我们首先要做的是安装相关的包。在这种情况下,我们将安装 Bootstrap:

npm install bootstrap --save

安装了 Bootstrap 之后,我们只需要在angular.jsonstyles部分中添加对 Bootstrap 的引用即可。

"styles": [   "src/styles.scss",   "node_modules/bootstrap/dist/css/bootstrap.min.css"  ],

有了这个配置,我们将创建一个位于页面顶部的navigation导航栏:

ng g c components/navigation

在我们添加navigation组件主体之前,我们应该替换我们的app.component.html文件的内容,以便在每个页面上提供我们的导航:

<atp-navigation></atp-navigation> <router-outlet></router-outlet> 

Bootstrap 导航

Bootstrap 提供了nav组件,我们可以在其中添加navigation。在其中,我们将创建一系列链接。就像在上一章中一样,我们将使用routerLink来告诉 Angular 应该路由到哪里:

<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
 <a class="navbar-brand" href="#">Navbar</a>
 <div class="collapse navbar-collapse" id="navbarNavAltMarkup">
 <div class="navbar-nav">
 <a class="nav-item nav-link active" routerLink="/general">General</a>
 <a class="nav-item nav-link" routerLink="/secret" *ngIf="auth.IsAuthenticated">Secret</a>
 <a class="nav-item nav-link active" (click)="auth.Login()" routerLink="#" *ngIf="!auth.IsAuthenticated">Login</a>
 <a class="nav-item nav-link active" (click)="auth.Logout()" routerLink="#" *ngIf="auth.IsAuthenticated">Logout</a>
 </div>
 </div> </nav> 

在路由方面变得有趣的地方涉及到使用身份验证来显示和隐藏链接。如果用户已经通过身份验证,我们希望他们能够看到秘密和注销链接。如果用户尚未通过身份验证,我们希望他们能够看到登录链接。

在导航中,我们可以看到一些 auth 引用。在幕后,这些都映射回OauthAuthorizationService。我们在本章开头注册 Auth0 时就提到过这个。现在,是时候为我们的用户添加连接到 Auth0 的授权服务了。

使用 Auth0 对用户进行授权和认证

我们的授权将由两部分组成——一个执行授权的服务,以及一个使授权工作变得简单的模型。我们将首先创建我们的Authorization模型,其中包含我们将从成功登录中收到的详细信息。请注意,构造函数引入了Socket实例:

export class Authorization {
  constructor(private socket: Socket);
  public IdToken: string;
  public AccessToken: string;
  public Expired: number;
  public Email: string; }

我们可以使用这个来创建一系列有用的辅助方法。我们要创建的第一个方法是在用户登录时设置公共属性。我们将一个成功的登录定义为我们收到访问令牌和 ID 令牌作为结果的登录:

@Log()  public SetFromAuthorizationResult(authResult: any): void {   if (authResult && authResult.accessToken && authResult.idToken) {   this.IdToken = authResult.idToken;   this.AccessToken = authResult.accessToken;   this.Expired = (authResult.expiresIn * 1000) + Date.now();   this.Email = authResult.idTokenPayload.email;   this.socket.emit('loggedOn', this.Email);  }  }

当用户登录时,我们将向服务器发送一个loggedOn消息,传递Email地址。当我们涵盖发送消息到服务器和处理返回的响应时,我们将很快回到这条消息。请注意,我们正在记录方法和属性。

当用户注销时,我们希望清除值并向服务器发送loggedOff消息:

@Log()  public Clear(): void {   this.socket.emit('loggedOff', this.Email);   this.IdToken = '';   this.AccessToken = '';   this.Expired = 0;   this.Email = '';  }

最终的帮助程序通过检查AccessToken字段是否存在以及票证到期的日期是否超过我们进行检查时的时间来告诉我们用户是否已经通过身份验证:

public get IsAuthenticated(): boolean {   return this.AccessToken && this.Expired > Date.now();  }

在创建我们的OauthAuthorizationService服务之前,我们需要一些与 Auth0 通信的手段,因此我们将为其提供支持:

npm install --save auth0-js

有了这个,我们可以将auth0.js作为script标签的引用添加进来:

<script type="text/javascript" src="node_modules/auth0-js/build/auth0.js"></script>

现在我们已经准备好创建我们的服务了:

ng g s services/OauthAuthorization

我们的服务的开始是相当简单的。当我们构建服务时,我们实例化刚刚创建的帮助类:

export class OauthAuthorizationService {
  private readonly authorization: Authorization;
  constructor(private router: Router, private socket: Socket) {
    this.authorization = new Authorization(socket);
  } }

我们现在准备好连接到 Auth0 了。您可能还记得,当我们注册 Auth0 时,我们得到了一系列的设置。从这些设置中,我们需要客户端 ID 和域。我们将在实例化auth0-js中的WebAuth时使用这些设置,以便唯一标识我们的应用程序。responseType告诉我们,我们需要用户的身份验证令牌和 ID 令牌在成功登录后返回。scope告诉用户我们在登录时想要访问的功能。例如,如果我们想要配置文件,我们可以将范围设置为openid email profile。最后,我们提供redirectUri告诉 Auth0 我们想在成功登录后返回到哪个页面:

auth0 = new auth0.WebAuth({   clientID: 'IvDHHA20ZKx7zvUQWNPrMy15vLTsFxx4',   domain: 'dev-gdhoxa3c.eu.auth0.com',   responseType: 'token id_token',   redirectUri: 'http://localhost:4200/callback',   scope: 'openid email'  });

redirectUri必须与 Auth0 设置部分中包含的内容完全匹配。我更喜欢将其设置为站点上不存在的页面,并手动控制重定向,因此对我来说回调是一个有用的页面,因为我可以应用条件逻辑来确定用户需要时重定向到的页面。

现在,我们可以添加我们的Login方法。这使用authorize方法加载身份验证页面:

@Log()  public Login(): void {   this.auth0.authorize();  }

登出就像调用logout,然后在我们的帮助类上调用Clear来重置到期点并清除其他属性一样简单:

@Log()
public Logout(): void {   this.authorization.Clear();   this.auth0.logout({   return_to: window.location.origin
  });  }

显然,我们需要一种方法来检查身份验证。以下方法检索 URL 哈希中的身份验证并使用parseHash方法解析它。如果身份验证不成功,用户将被重定向回不需要登录的一般页面。另一方面,如果用户成功通过身份验证,用户将被引导到仅对经过身份验证的用户可用的秘密页面。请注意,我们正在调用我们之前编写的SetFromAuthorizationResult方法来设置访问令牌、到期时间等:

@Log()  public CheckAuthentication(): void {   this.auth0.parseHash((err, authResult) => {   if (!err) {   this.authorization.SetFromAuthorizationResult(authResult);   window.location.hash = '';   this.router.navigate(['/secret']);  } else {   this.router.navigate(['/general']);   console.log(err);  }  });  }

当用户回到网站时,让他们再次访问而无需重新进行身份验证是一个好的做法。以下的Renew方法检查他们的会话,如果成功,重置他们的身份验证状态:

@Log()  public Renew(): void {   this.auth0.checkSession({}, (err, authResult) => {   if (authResult && authResult.accessToken && authResult.idToken) {   this.authorization.SetFromAuthorizationResult(authResult);  } else if (err) {   this.Logout();  }  });  }

这段代码都很好,但我们在哪里使用它呢?在app.component.ts中,我们引入我们的授权服务并检查用户身份验证:

constructor(private auth: OauthAuthorizationService) {   this.auth.CheckAuthentication();  }

ngOnInit() {
  if (this.auth.IsAuthenticated) {
    this.auth.Renew();
  }
}

不要忘记添加对NavigationComponent的引用以连接OauthAuthorizationService

constructor(private auth: OauthAuthorizationService) {  }

使用安全路由

有了我们的身份验证,我们希望确保用户不能通过输入页面的 URL 来绕过它。如果用户可以轻松地绕过它,尤其是在我们费了很大的劲提供安全授权之后,我们就不会设置太多安全措施。我们要做的是放置另一个服务,路由器将使用它来确定是否可以激活路由。首先,我们创建服务,如下所示:

ng g s services/Authorization

服务本身将实现CanActivate接口,路由器将使用该接口来确定是否可以激活路由。此服务的构造函数只需接收路由器和我们的OauthAuthorizationService服务:

export class AuthorizationService implements CanActivate {
  constructor(private router: Router, private authorization: OauthAuthorizationService) {}
}

canActivate签名的样板代码看起来比我们的目的需要复杂得多。我们真正要做的是检查认证状态,如果用户未经过身份验证,我们将重新将用户重定向到一般页面。如果用户经过了身份验证,我们返回true,用户继续访问受保护的页面。

canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):   Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {   if (!this.authorization.IsAuthenticated) {   this.router.navigate(['general']);   return false;  }   return true; }

我们将遵循两条路线,就像我们在导航链接中看到的那样。在添加路由之前,让我们创建要显示的组件:

ng g c components/GeneralChat
ng g c components/SecretChat

最后,我们已经到达了连接路由的地步。正如我们在上一章中看到的,添加路由非常简单。我们要添加的秘密武器是canActivate。有了这个路由,用户无法绕过我们的认证:

const routes: Routes = [{
  path: '',
  redirectTo: 'general',
  pathMatch: 'full' }, {
  path: 'general',
  component: GeneralchatComponent }, {
  path: 'secret',
  component: SecretchatComponent,
  canActivate: [AuthorizationService] }]; 

尽管我们必须在 Auth0 配置中提供回调 URL,但我们不在路由中包含它,因为我们想要控制页面,我们要导航到我们的授权服务。

此时,我们希望从客户端向服务器发送消息并接收消息。

添加客户端聊天功能

当我们编写我们的认证代码时,我们大量依赖于放置服务来处理它。类似地,我们将提供一个聊天服务,该服务提供客户端套接字消息的中心点:

ng g s services/ChatMessages

毫不奇怪,此服务还将在构造函数中包含Socket

export class ChatMessagesService {   constructor(private socket: Socket) { }
}

当我们从客户端向服务器发送消息时,我们使用套接字上的emit方法。用户要发送的文本将通过message键发送过来:

public SendMessage = (message: string) => {   this.socket.emit('message', message);  };

在房间里工作

在 Socket.IO 中,我们使用房间来隔离消息,以便只将它们发送给特定的用户。当客户端加入一个房间时,发送到该房间的任何消息都将可用。一个有用的想法是将房间想象成房子里的房间,门是关着的。当有人想告诉你什么事情时,他们必须和你在同一个房间里才能告诉你。

我们的一般和秘密链接都将连接到房间。一般页面将使用一个空房间名称,相当于默认的 Socket.IO 房间。秘密链接将加入一个名为secret的房间,这样发送到secret的任何消息都将自动显示给该页面上的任何用户。为了简化我们的生活,我们将提供一个辅助方法,将emit方法从客户端发送到服务器的joinRoom方法:

private JoinRoom = (room: string) => {   this.socket.emit('joinRoom', room);  };

当我们加入一个房间时,使用socket.emit发送的任何消息都会自动发送到正确的房间。我们不必做任何聪明的事情,因为 Socket.IO 会自动为我们处理这个。

获取消息

对于一般页面和秘密消息页面,我们将获取相同的数据。我们将使用 RxJS 创建一个可观察对象,该对象包装从服务器获取单个消息以及从服务器获取所有当前发送的消息。

根据传入的房间字符串,GetMessages方法加入秘密房间或一般房间。加入房间后,我们返回一个Observable实例,在特定事件上做出反应。在接收到单个消息时,我们调用Observable实例的next方法。客户端组件将订阅此方法,并将其写出。同样,我们还在套接字上订阅allMessages,以便在加入房间时接收所有先前发送的消息。同样,我们遍历消息并使用next将消息写出。

我最喜欢的部分是fromEvent。这与userLogOn消息的socket.on方法是同义的,它允许我们写出有关谁在会话期间登录的详细信息:

public GetMessages = (room: string) => {   this.JoinRoom(room);   return Observable.create((ob) => {
 this.socket.fromEvent<UserLogon>('userLogOn').subscribe((user:UserLogon) => {
      ob.next(`${user.user} logged on at ${user.time}`);
    });  this.socket.on('message', (msg:string) => {   ob.next(msg);  });   this.socket.on('allMessages', (msg:string[]) => {   msg.forEach((text:any) => ob.next(text.messageText));  });  });  }

到目前为止,在阅读本章时,我在使用术语“消息”和“事件”时一直比较宽松。在这种情况下,它们都指的是同一件事情。

完成服务器套接字

在我们添加实际组件实现之前,我们将添加其余的服务器端套接字行为。您可能还记得我们添加了读取所有历史记录并将它们发送回新连接的客户端的功能:

socket.on('joinRoom', (room: string) => {   if (lastRoom !== '') {   socket.leave(lastRoom);  }   if (room !== '') {   socket.join(room);  }   this.messageDataAccess.GetAll({room: room}, {messageText: 1, _id: 0}).then((msgs: string[]) =>{   socket.emit('allMessages', msgs);  });   lastRoom = room;  });

这里我们看到服务器对来自客户端的joinRoom做出反应。当我们收到这个事件时,如果已经设置了上一个房间,我们就离开上一个房间,然后加入来自客户端传递过来的房间;同样,只有在已经设置的情况下。这使我们能够获取所有记录,然后在当前套接字连接上emit它们回去。

当客户端向服务器发送message事件时,我们将消息写入数据库,以便以后检索:

socket.on('message', (msg: string) => {   this.WriteMessage(io, msg, lastRoom);  });

这种方法首先将消息保存到数据库中。如果设置了房间,我们使用io.sockets.in将消息发送给所有活跃在房间中的客户端。如果没有设置房间,我们希望通过io.emit将消息发送给通用页面上的所有客户端:

private WriteMessage(io: socket.Server, message: string, room: string) {   this.SaveToDatabase(message, room).then(() =>{   if (room !== '') {   io.sockets.in(room).emit('message', message);   return;  }   io.emit('message', message);  });  }

在这里,我们已经看到了io.socket.之间的主要区别。当我们想要将消息发送给当前连接的客户端时,我们使用socket部分。当我们需要将消息发送给更多的客户端时,我们使用io部分。

保存消息就像做以下操作一样简单:

private async SaveToDatabase(message: string, room: string) {   const model: IMessageSchema = <IMessageSchema>{   messageText: message,   received: new Date(),   room: room   };   try{   await this.messageDataAccess.Add(model);  }catch (e) {   console.log(`Unable to save ${message}`);  }  }

你可能会问自己为什么我们在服务器端分配日期,而不是在客户端创建消息时分配日期。当我们在同一台机器上运行客户端和服务器时,我们做法并不重要,但是当我们构建分布式系统时,我们应该始终参考集中时间。使用集中的日期和时间意味着来自世界各地的事件将在同一时区协调。

在客户端上,我们对稍微复杂的登录事件做出了反应。当我们收到loggedOn事件时,我们创建等效的服务器端事件,将其传输给任何在秘密房间中收听的人:

socket.on('loggedOn', (msg: any) => {   io.sockets.in('secret').emit('userLogOn', { user: msg, time: new Date() });  });

我们现在已经有了客户端基础设施,并且服务器已经完成。我们现在需要做的就是添加服务器端组件。从功能上讲,由于GeneralChatSecretChat组件几乎相同(唯一的区别是它们监听的房间不同),我们将集中精力只关注其中一个。

Socket.IO 中的命名空间

想象一下,我们正在编写一个可以被任意数量的客户端应用程序使用的服务器,而这些客户端应用程序也可以使用任意数量的其他 Socket.IO 服务器。如果我们使用与来自其他 Socket.IO 服务器的消息相同的消息名称,我们可能会向客户端应用程序引入错误。为了避免这个问题,Socket.IO 使用了一个叫做命名空间的概念,允许我们将我们的消息隔离开来,以避免与其他应用程序冲突。

命名空间是提供唯一端点以连接的便捷方式,我们使用以下代码连接到它:

const socket = io.of('/customSocket');
socket.on('connection', function(socket) {
  ...
});

这段代码应该看起来很熟悉,因为除了io.of(...)之外,它与我们之前用来连接套接字的代码相同。也许令人惊讶的是,我们的代码已经在使用命名空间,尽管我们自己没有指定。除非我们自己指定一个命名空间,否则我们的套接字将连接到默认命名空间,这相当于io.of('/).

在为命名空间想一个名称时,尽量考虑一个独特而有意义的名称。我过去见过的一个标准是利用公司名称和项目来创建命名空间。因此,如果你的公司叫做WonderCompany,你正在开发Project Antelope,你可以使用/wonderCompany_antelope作为命名空间。不要随意分配随机字符,因为这样很难记住,这会增加输入错误的可能性,意味着套接字无法连接。

用 GeneralchatComponent 完成我们的应用程序

让我们首先添加 Bootstrap 代码来显示消息。在这种情况下,我们将row消息包裹在 Bootstrap 容器中,或者说是container-fluid。在我们的组件中,我们将从通过 socket 接收的消息数组中读取消息:

<div class="container-fluid">
 <div class="row">
 <div *ngFor="let msg of messages" class="col-12">
 {{msg}}
 </div>
 </div> </div>

我们还将在屏幕底部的navigation栏中添加一个文本框。这与组件中的CurrentMessage字段绑定。我们使用SendMessage()发送消息:

<nav class="navbar navbar-dark bg-dark mt-5 fixed-bottom">
 <div class="navbar-expand m-auto navbar-text">
 <div class="input-group mb-6">
 <input type="text" class="form-control" placeholder="Message" aria-label="Message" 
        aria-describedby="basic-addon2" [(ngModel)]="CurrentMessage" />
 <div class="input-group-append">
 <button class="btn btn-outline-secondary" type="button" (click)="SendMessage()">Send</button>
 </div>
 </div>
 </div> </nav>

在这个 HTML 背后的组件中,我们需要连接到ChatMessageService。我们将使用Subscription实例,并将其用于不久后填充messages数组。

export class GeneralchatComponent implements OnInit, OnDestroy {   private subscription: Subscription;
  constructor(private chatService: ChatMessagesService) { }

  CurrentMessage: string;
  messages: string[] = []; }

当用户输入消息并按下发送按钮时,我们将使用聊天服务的SendMessage方法将其发送到服务器。我们之前做的准备工作在这里真的开始发挥作用:

SendMessage(): void {   this.chatService.SendMessage(this.CurrentMessage);   this.CurrentMessage = '';  }

现在,我们只剩下两个部分要添加。在我们的组件初始化中,我们将从GetMessages中检索Observable实例,并对其进行subscribe。当订阅中有消息时,我们将其推送到消息中,这就是 Angular 绑定的魔力所在,界面会随着最新消息的更新而更新:

ngOnInit() {   this.subscription = this.chatService.GetMessages('').subscribe((msg: string) =>{   this.messages.push(msg);  });  }

请注意,GetMessages方法是我们链接到房间的地方。在SecretchatComponent中,这将变成this.chatService.GetMessages('secret')

我们做的一件事是引用订阅。当我们销毁当前页面时,我们将清除订阅,以防止内存泄漏:

ngOnDestroy() {   if (this.subscription) {   this.subscription.unsubscribe();  }  }

最后对这个实现做一个说明。当我们开始在这里编写代码时,我们必须对如何在用户按下发送时将当前屏幕填充消息做出有意识的决定。实际上,我们有两种选择。我们可以选择直接将当前消息添加到消息数组的末尾,而不是从服务器发送回客户端,或者我们可以将其发送到服务器,然后让服务器将其发送回给我们。我们可以选择任一种方法,那么为什么我选择将其发送到服务器,然后再回传给客户端呢?这个答案与顺序有关。在我使用过的大多数聊天应用程序中,每个用户看到的消息顺序都是完全相同的。做到这一点最简单的方法是让服务器为我们协调消息。

总结

在本章中,我们发现了如何编写代码来在客户端和服务器之间建立永久连接,使我们能够根据消息来回传递消息。我们还看到了如何注册 Auth0 并将其用作应用程序的身份验证机制。然后,我们学会了编写客户端身份验证。在过去的几章中,我们一直在研究 Angular 中的 Material,现在我们又回到了使用 Bootstrap,并看到了在 Angular 中使用它是多么简单。

在下一章中,我们将学习如何应用必应地图来创建一个自定义的基于地图的应用程序,让我们能够在基于云的数据库中选择和保存兴趣点,并使用基于位置的搜索来检索商业信息。

问题

  1. 我们如何向所有用户发送消息?

  2. 我们如何向特定房间中的用户发送消息?

  3. 我们如何向除了发送原始消息的用户之外的所有用户发送消息?

  4. 为什么我们不应该使用一个名为 connect 的消息?

  5. 什么是 Engine.IO?

在我们的应用程序中,我们只使用了一个房间。添加其他房间,这些房间不需要用户在使用之前进行身份验证,并添加需要用户进行身份验证的房间。我们也没有存储发送消息的人的详细信息。增强应用程序以存储这些细节,并将它们作为消息的一部分双向传输。

进一步阅读

第七章:使用 Firebase 进行 Angular 基于云的地图

在过去的几章中,我们花了相当多的时间编写我们自己的后端系统,以返回信息给客户端。在过去的几年里,有一种趋势是使用第三方云系统。云系统可以帮助降低编写应用程序的成本,因为其他公司提供了我们需要使用的所有基础设施,并负责测试、升级等。在本章中,我们将研究如何使用必应地图团队和 Firebase 的云基础设施来提供数据存储。

本章将涵盖以下主题:

  • 注册必应地图

  • 计费云功能的含义

  • 注册 Firebase

  • 添加地图组件

  • 使用地图搜索功能

  • 使用EventEmitter来通知父组件子组件事件

  • 响应地图事件以添加和删除自己的兴趣点

  • 在地图上叠加搜索结果

  • 整理事件处理程序

  • 将数据保存到 Cloud Firestore

  • 配置 Cloud Firestore 身份验证

技术要求

完成的项目可以从github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter07下载。

下载项目后,您将需要使用npm install命令安装软件包要求。

现代应用程序和转向云服务

在整本书中,我们一直在专注于编写应用程序,其中我们控制应用程序运行的基础设施以及数据的物理存储位置。在过去的几年里,趋势是摆脱这种类型的应用程序,转向其他公司通过所谓的基于云的服务提供这种基础设施的模式。云服务已经成为一个用来描述使用其他公司的按需服务的总称营销术语,依赖于它们提供应用程序功能、安全性、扩展性、备份功能等。其背后的想法是,我们可以通过让其他人为我们处理这些功能来减少资本成本,从而使我们能够编写利用这些功能的应用程序。

在本章中,我们将研究如何使用微软和谷歌的基于云的服务,因此我们将研究注册这些服务的过程,使用它们的含义,以及如何在我们最终的 Angular 应用程序中使用它们。

项目概述

对于我们最后的 Angular 应用程序,我们将使用必应地图服务来展示我们日常使用的地图类型,以搜索位置。我们将进一步使用微软的本地洞察服务来搜索当前可见地图区域内的特定业务类型。这是我在为这本书制定计划时最激动人心的两个应用程序之一,因为我对基于地图的系统情有独钟。

除了显示地图,我们还可以通过直接点击地图上的点来选择地图上的兴趣点。这些点将由彩色图钉表示。我们将保存这些点的位置和名称,以及它们在谷歌的基于云的数据库中。

这个应用程序应该需要大约一个小时来完成,只要你在 GitHub 上的代码旁边工作。

在本章中,我们将不再提供如何使用npm添加软件包,或者如何创建 Angular 应用程序、组件等的详细信息,因为到这个时候你应该已经熟悉如何做这些了。

完成后,应用程序应该看起来像这样(也许不要放大到纽卡斯尔):

开始使用 Angular 中的必应地图

这是我们最后一个 Angular 应用程序,所以我们将以与之前章节中创建应用程序相同的方式开始。同样,我们将使用 Bootstrap,而不是 Angular Material。

我们在本章中要专注的包如下:

  • bootstrap

  • bingmaps

  • firebase

  • guid-typescript

由于我们将把我们的代码连接到基于云的服务,我们首先必须注册它们。在本节中,我们将看看我们需要做什么来注册。

注册必应地图

如果我们想要使用必应地图,我们必须注册必应地图服务。导航到www.bingmapsportal.com并单击“登录”按钮。这需要一个 Windows 帐户,所以如果你没有一个,你需要设置一个。现在,我们假设你有一个 Windows 帐户可用:

当我们登录时,我们需要创建一个密钥,我们的应用程序将使用它来向必应地图服务标识自己,以便他们知道我们是谁,并可以跟踪我们的地图使用情况。从“我的帐户”选项中,选择“我的密钥”:

当密钥屏幕出现时,你会看到一个名为“点击此处创建新密钥”的链接。点击链接将显示以下屏幕:

这个屏幕上的大部分信息都相当容易理解。应用程序名称用于在我们有多个密钥并且需要搜索它们时使用。URL 不需要设置,但如果我部署到不同的 Web 应用程序,我喜欢这样做。这是一个方便的方式来记住哪个密钥与哪个应用程序相关联。由于我们不打算使用付费企业服务,我们唯一可用的密钥类型是基本的。

应用程序类型可能是这里最重要的字段,从我们的角度来看。我们可以选择多种应用程序类型,每种类型都有关于它可以接受的交易数量的限制。我们将坚持使用 Dev/Test,它限制我们在一年的时间内累计的可计费交易次数为 125,000 次。

当我们在本章中使用本地洞察代码时,这将生成可计费的交易。如果你不想承担任何费用的风险,我建议你禁用执行此搜索的代码。

当我们点击“创建”时,我们的地图密钥被创建,并且可以通过点击表中出现的“显示密钥”或“复制密钥”链接来获取。现在我们已经设置好了地图密钥所需的一切,让我们继续注册数据库。

注册 Firebase

Firebase 需要一个 Google 帐户。假设我们有一个可用的 Google 帐户,我们可以在console.firebase.google.com/上访问 Firebase 的功能。当出现这个屏幕时,点击“添加项目”按钮开始添加 Firebase 支持的过程:

为项目选择一个有意义的名称。在我们创建项目之前,我们应该阅读使用 Firebase 的条款和条件,并在同意时勾选复选框。请注意,如果我们选择共享 Google Analytics 的使用统计数据,我们应该阅读适当的条款和条件,并勾选控制器-控制器条款复选框:

点击“创建项目”后,我们现在可以访问 Firebase 项目。虽然 Firebase 作为云服务提供商不仅仅是一个数据库,还提供存储、托管等功能,但我们只是使用数据库选项。当我们点击数据库链接时,会出现 Cloud Firestore 屏幕,我们需要点击“创建数据库”来开始创建数据库的过程:

每当我在本章中提到 Firebase 时,我是在简单地说这是 Firebase 云平台的 Firestore 功能。

在创建数据库时,我们需要选择要应用于我们的数据库的安全级别。我们在这里有两个选项。我们可以从数据库被锁定开始,以便禁用读写。然后,通过编写数据库将检查以确定是否允许写入的规则来启用对数据库的访问。

然而,为了我们的目的,我们将以测试模式开始,这允许对数据库进行无限读写:

与 Bing 地图类似,Firebase 有使用限制和成本影响。我们正在创建一个 Spark 计划数据存储,这是免费的 Firebase 版本。这个版本有硬性限制,比如每月只能存储 1GB 的数据,每天可以读取 50000 次,每天可以写入 20000 次。有关定价和限制的详细信息,请阅读firebase.google.com/pricing/。

一旦我们点击了启用并有一个可用的数据库,我们需要能够访问 Firebase 为我们创建的密钥和项目详细信息。要找到这些信息,请点击菜单上的项目概述链接。按钮弹出一个屏幕,显示我们需要复制到我们的项目的详细信息:

我们现在已经设置好了云基础设施,并且有了我们需要的密钥和详细信息。我们现在准备编写我们的应用程序。

使用 Angular 和 Firebase 创建 Bing Maps 应用程序

在过去几年中,增长最快的应用程序类型之一是地图应用程序的爆炸,无论是用于您的卫星导航系统还是在手机上运行 Google 地图。在这些应用程序的底层,有由微软或谷歌等公司开发的地图服务。我们将使用 Bing 地图服务来为我们的应用程序添加地图支持。

我们的地图应用程序有以下要求:

  • 点击位置将把该位置添加为兴趣点

  • 添加兴趣点时,将显示一个信息框,显示有关它的详细信息

  • 再次点击兴趣点将删除它

  • 兴趣点将被保存到数据库中

  • 用户将能够移动兴趣点,更新数据库中的详细信息

  • 在可用的情况下,将自动检索并显示商业信息

添加地图组件

我们将为这一步创建两个 Angular 组件,一个叫做MappingcontainerComponent,另一个叫做MapViewComponent

我将它们分开,因为我想使用MappingcontainerComponent来包含引导程序基础设施,而MapViewComponent将只包含地图本身。如果你愿意,你可以将它们合并在一起,但是为了清晰地描述每个部分的情况,对我来说在这里创建两个组件更容易。这意味着我们需要在这两个组件之间引入一些协调,这将加强我们在第五章中介绍的EventEmitter行为,Angular ToDo App with GraphQL and Apollo

在为这些组件添加任何内容之前,我们需要编写一些模型和服务,以提供我们的地图和数据访问所需的基础设施。

兴趣点

每个兴趣点都由一个图钉表示,并且可以表示为纬度和经度坐标,以及它的名称。

纬度和经度是地理术语,用于准确标识地球上的位置。纬度告诉我们某物距赤道有多远,纬度为 0。这意味着正数表示我们在赤道以北,负数表示我们在赤道以南。经度告诉我们我们距离地球的中心线有多远,按照惯例,这条线穿过伦敦的格林威治。同样,如果我们向东移动,数字是正数,而从格林威治线向西移动意味着数字将是负数。

表示此模型如下所示:

export class PinModel {
  id: string;
  lat: number;
  long: number;
  name: string;
}

在本节中,我们将引用图钉和兴趣点。它们都代表同一件事,因此我们将交替使用它们。

当我们创建一个实例时,我们将使用 GUID 来表示它。由于 GUID 是唯一的,我们将其用作查找兴趣点的便捷方式。这并不是我们将在数据库中存储模型的确切表示,因为此标识符旨在用于跟踪地图上的图钉,而不是用于跟踪数据库中的图钉。为此,我们将添加一个单独的模型,用于在数据库中存储模型项:

export interface PinModelData extends PinModel {
 storageId: string;
}

我们将其创建为接口,因为 Firebase 只希望接收数据,而不希望有围绕它的类基础设施。我们也可以将PinModel创建为接口,但是实例化它的语法稍微麻烦一些,这就是为什么我们选择将其创建为类的原因。

有了这些模型,我们现在准备连接到 Firebase。我们将使用官方的 Angular Firebase 库AngularFire,而不是直接使用 Firebase 的npm。这个库的npm引用是@angular/fire

当我们设置我们的 Firebase 数据存储时,我们得到了需要创建一个唯一标识连接的设置。我们将把这些设置复制到我们的environment.tsenvironment.prod.ts文件中。当我们将应用程序发布到生产环境时,Angular 会将environment.prod.ts重新映射到环境文件,以便我们可以拥有单独的开发和生产设置:

firebase: {
  apiKey: "AIzaSyC0MzFxTtvt6cCvmTGE94xc5INFRYlXznw",
  authDomain: "advancedtypescript3-mapapp.firebaseapp.com",
  databaseURL: "https://advancedtypescript3-mapapp.firebaseio.com",
  projectId: "advancedtypescript3-mapapp",
  storageBucket: "advancedtypescript3-mapapp.appspot.com",
  messagingSenderId: "6102469443"
}

通常不建议在开发和生产系统中使用相同的端点,因此您可以创建一个单独的 Firebase 实例来保存生产映射信息,并将其存储在environment.prod.ts中。

app.module中,我们将导入AngularFire模块,然后在导入中引用它们。当我们引用AngularFireModule时,我们调用静态的initializeApp方法,该方法将使用environment.firebase设置来建立与 Firebase 的连接。

首先,import语句如下:

import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';
import { AngularFireStorageModule } from '@angular/fire/storage';

接下来,我们设置 Angular 的imports

imports: [
  BrowserModule,
  HttpClientModule,
  AngularFireModule.initializeApp(environment.firebase),
  AngularFireStorageModule,
  AngularFirestoreModule
],

对于 Firebase 的功能,有一个服务作为与数据库交互的单一实现点是有帮助的。这就是为什么我们将创建一个FirebaseMapPinsService

export class FirebaseMapPinsService {
}

在这个类中,我们将使用AngularFire的一个功能,称为AngularFirestoreCollection。Firebase 公开了QueryCollectionReference类型,以对数据库中的基础数据执行 CRUD 操作。AngularFirestoreCollection将此行为封装成一个方便的流。我们将通用类型设置为PinModelData,以说明将保存到数据库中的数据是什么:

private pins: AngularFirestoreCollection<PinModelData>;

我们的服务将提供一个模型,创建一个PinModelData数组的可观察对象,连接到pins属性。我们将这一切连接在一起的方式在构造函数中,该构造函数接收AngularFirestore。通过传递将存储在数据库中的集合名称,pins集合与底层集合相关联(将数据保存为 JSON 文档)。我们的Observable监听集合上的valueChanges,如下所示:

constructor(private readonly db: AngularFirestore) { 
  this.pins = db.collection<PinModelData>('pins');
  this.model = this.pins.valueChanges();
}

在设计这个应用程序时,我做出的一个决定是,从 UI 中删除标记应该导致从数据库中删除相关的兴趣点。由于它没有被任何其他东西引用,我们不需要将其保留为引用数据。删除数据就像使用doc从数据存储中获取基础文档记录一样简单,然后将其删除:

Delete(item: PinModelData) {
  this.pins.doc(item.storageId).delete();
}

当用户添加一个兴趣点时,我们希望在数据库中创建相应的条目,但当他们移动标记时,我们希望更新记录。我们可以将逻辑合并到一个方法中,因为我们知道一个具有空storageId的记录之前没有保存到数据库中。因此,我们使用 Firebase 的createId方法为其提供一个唯一的 ID。如果storageId存在,那么我们就要更新它:

Save(item: PinModelData) {
  if (item.storageId === '') {
    item.storageId = this.db.createId();
    this.pins.doc(item.storageId).set(item);
  }
  else {
    this.pins.doc(item.storageId).update(item);
  }
}

表示地图标记

我们可以很好地将标记保存到数据库中,但我们还需要一种方法来表示地图上的标记,以便在地图会话期间显示它们并根据需要移动它们。这个类还将作为与数据服务的连接。我们将要编写的类将演示 TypeScript 3 中引入的一个巧妙的小技巧,称为rest tuples,并且起始如下:

export class PinsModel {
  private pins: PinModelData[] = [];
  constructor(private firebaseMapService: FirebaseMapService) { }
}

我们要引入的第一个功能涉及在用户点击地图时添加标记的数据。这个方法的签名看起来有点奇怪,所以我们将花一两分钟来解释它是如何工作的。签名看起来像这样:

public Add(...args: [string, string, ...number[]]);

当我们看到...args作为最后(或唯一)参数时,我们立刻想到的是我们将使用 REST 参数。如果我们从开始就分解参数列表,我们可以将其看作是这样开始的:

public Add(arg_1: string, arg_2: string, ...number[]);

这几乎看起来是有道理的,但在那里还有另一个 REST 参数。这基本上意味着我们可以在元组的末尾有任意数量的数字。我们必须对此应用...,而不仅仅是应用number[],是因为我们需要展开元素。如果我们只使用数组格式,我们将不得不在调用代码中将元素推入这个数组。有了元组中的 REST 参数,我们可以取出数据,保存到数据库中,并将其添加到我们的pins数组中,就像这样:

public Add(...args: [string, string, ...number[]]) {   const data: PinModelData = {   id: args[0],   name: args[1],   lat: args[2],   long: args[3],   storageId: ''   };   this.firebaseMapService.Save(data);   this.pins.push(data);  }

使用这样的元组的含义是,调用代码必须确保将值放入正确的位置。

当我们到达调用这个代码的地方时,我们可以看到我们的方法是这样调用的:

this.pinsModel.Add(guid.toString(), geocode, e.location.latitude, e.location.longitude);

当用户在地图上移动标记时,我们将使用类似的技巧来更新其位置。我们所需要做的就是在数组中找到模型并更新其数值。我们甚至需要更新名称,因为移动标记的行为将改变标记的地址。我们在数据服务上调用相同的Save方法,就像我们在Add方法中所做的那样:

public Move(...args: [string,string, ...number[]]) {   const pinModel: PinModelData = this.pins.find(x => x.id === args[0]);   if (pinModel) {   pinModel.name = args[1];   pinModel.lat = args[2];   pinModel.long = args[3];  }   this.firebaseMapService.Save(pinModel);  }

其他类也需要访问数据库中的数据。我们在这里面临两个选择——我们可以让其他类也使用 Firebase 地图服务,并且可能错过对这个类的调用,或者我们可以使这个类成为地图服务的唯一访问点。我们将依赖这个类成为与FirebaseMapPinsService的唯一联系点,这意味着我们需要通过Load方法公开model

public Load(): Observable<PinModelData[]>{   return this.firebaseMapService.model;  }

删除兴趣点使用的方法签名比添加或移动兴趣点简单得多。我们只需要记录的客户端端id,然后使用它来找到PinModelData项目并调用Delete从 Firebase 中删除。一旦我们删除了记录,我们就会找到这条记录的本地索引,并通过对数组进行拼接来删除它:

public Remove(id: string) {
  const pinModel: PinModelData = this.pins.find(x => x.id === id);
  this.firebaseMapService.Delete(pinModel);
  const index: number = this.pins.findIndex(x => x.id === id);
  if (index >= 0) {
    this.pins.splice(index,1);
  }
}

尝试有趣的地图搜索

当涉及到获取用户放置或移动图钉的位置名称时,我们希望这是自动发生的。我们真的不希望用户在映射时必须手动输入这个值,映射可以自动为我们选择。这意味着我们将不得不使用映射功能来为我们获取这些信息。

必应地图有许多可选模块,我们可以选择使用,这些模块使我们能够进行基于位置的搜索等操作。为了做到这一点,我们将创建一个名为MapGeocode的类,它将为我们进行搜索:

export class MapGeocode {
}

您可能注意到,对于我们的一些类,我们是在没有创建服务的情况下创建它们的。这意味着我们将不得不手动实例化这个类。这没问题,因为我们可以手动控制我们类的生命周期。如果你愿意,在重新创建代码时,你可以将MapGeocode等类转换为服务并注入它。

由于搜索是一个可选功能,我们需要加载它。为此,我们将传入我们的地图并使用loadModule来加载Microsoft.Maps.Search模块,传入SearchManager的新实例作为选项:

private searchManager: Microsoft.Maps.Search.SearchManager;
constructor(private map: Microsoft.Maps.Map) {
  Microsoft.Maps.loadModule('Microsoft.Maps.Search', () => {
    this.searchManager = new Microsoft.Maps.Search.SearchManager(this.map);
  });
}

我们要做的所有事情就是编写一个执行查找的方法。由于这可能是一个耗时的操作,我们需要将其设置为Promise类型,返回将被填充为名称的字符串。在这个Promise中,我们创建一个包含位置的请求和一个回调,当reverseGeocode方法执行时,将使用位置的名称更新Promise中的回调。有了这个,我们调用searchManager.reverseGeocode来执行搜索:

public ReverseGeocode(location: Microsoft.Maps.Location): Promise<string> {
  return new Promise<string>((callback) => {
    const request = {
      location: location,
      callback: function (code) { callback(code.name); }
    };
    if (this.searchManager) {
      this.searchManager.reverseGeocode(request);
    }
  });
}

在编码中,名称很重要。在地图制作中,当我们进行地理编码时,我们将物理地址转换为位置。将位置转换为地址的行为称为反向地理编码。这就是为什么我们的方法有一个相当繁琐的名字ReverseGeocode

还有另一种类型的搜索需要考虑。我们希望进行一种使用可见地图区域(视口)来识别该区域内的咖啡店的搜索。为此,我们将使用微软的新 Local Insights API 来搜索特定区域内的企业等内容。目前这种实现有一个限制,即 Local Insights 仅适用于美国地址,但计划在其他国家和地区推出此功能。

为了证明我们仍然可以在服务中使用地图,我们将创建一个PointsOfInterestService,它接受一个HttpClient,我们将使用它来获取 REST 调用的结果:

export class PointsOfInterestService {
  constructor(private http: HttpClient) {}
}

REST 调用端点接受一个查询,告诉我们我们感兴趣的企业类型,用于执行搜索的位置以及地图密钥。同样,我们的搜索功能可能是长时间运行的,所以我们将返回一个Promise,这次是一个自定义的PoiPoint,返回纬度和经度,以及企业的名称:

export interface PoiPoint {
  lat: number,
  long: number,
  name: string
}

当我们调用 API 时,我们将使用http.get,它返回一个 observable。我们将使用pipemap来使用MapData对结果进行转换。我们将订阅结果并解析结果(注意我们并不真正知道返回类型,所以我们将其留空为any)。返回类型可以包含多个resourceSets,大多用于一次性进行多种类型的查询,但我们只需要关注初始的resourceSet,然后用它来提取资源。以下代码显示了我们从这次搜索中感兴趣的元素的格式。当我们完成解析结果后,我们将取消订阅搜索订阅,并在Promise上回调刚刚添加的点:

public Search(location: location): Promise<PoiPoint[]> {
  const endpoint = `https://dev.virtualearth.net/REST/v1/LocalSearch/?query=coffee&userLocation=${location[0]},${location[1]}&key=${environment.mapKey}`;
  return new Promise<PoiPoint[]>((callback) => {
    const subscription: Subscription = this.http.get(endpoint).pipe(map(this.MapData))
    .subscribe((x: any) => {
      const points: PoiPoint[] = [];
      if (x.resourceSets && x.resourceSets.length > 0 && x.resourceSets[0].resources) {
        x.resourceSets[0].resources.forEach(element => {
          if (element.geocodePoints && element.geocodePoints.length > 0) {
            const poi: PoiPoint = {
              lat: element.geocodePoints[0].coordinates[0],
              long: element.geocodePoints[0].coordinates[1],
              name: element.name
            };
            points.push(poi)
          }
        });
      }
      subscription.unsubscribe();
      callback(points);
    })
  });
}

在我们的查询中,我们只是在一个点上搜索——如果需要的话,我们可以很容易地扩展到在我们的视图范围内搜索一个边界框,方法是接受地图边界框并将userLocation更改为userMapView=${boundingBox{0}},${boundingBox{1}},${boundingBox{2}},${boundingBox{3}}(其中boundingBox是一个矩形)。有关扩展搜索的更多细节,请参见docs.microsoft.com/en-us/previous-versions/mt832854(v=msdn.10)

现在我们已经完成了地图搜索功能和数据库功能,是时候在屏幕上实际放置地图了。让我们现在来处理这个问题。

将 Bing 地图添加到屏幕上

就像我们之前讨论的那样,我们将使用两个组件来显示地图。让我们从MapViewComponent开始。这个控件的 HTML 模板非常简单:

<div #myMap style='width: 100%; height: 100%;'> </div> 

是的,这确实是我们的 HTML 的全部内容。它背后发生的事情要复杂一些,这就是我们将学习 Angular 如何让我们连接到标准 DOM 事件的地方。我们通常不显示整个@Component元素,因为它几乎是样板代码,但在这种情况下,我们将不得不做一些稍微不同的事情。这是我们组件的第一部分:

@Component({
  selector: 'atp-map-view',
  templateUrl: './map-view.component.html',
  styleUrls: ['./map-view.component.scss'],
  host: {
  '(window:load)' : 'Loaded()'
  } }) export class MapViewComponent implements OnInit {
  @ViewChild('myMap') myMap: { nativeElement: string | HTMLElement; };    constructor() { }    ngOnInit() {  }
}

@Component部分,我们将窗口加载事件挂钩到Loaded方法。我们很快会添加这个方法,但现在知道这是我们如何将组件挂钩到主机事件的方式很重要。在组件内部,我们使用@ViewChild来挂钩到我们模板中的div。基本上,这允许我们通过名称引用视图内的元素,以便我们可以以某种任意的方式处理它。

我们添加Loaded方法的原因是因为 Bing 地图有一个特别讨厌的习惯,即在 Chrome 或 Firefox 等浏览器中不正常工作,除非我们在window.load事件中挂接地图。我们将在模板中添加一个div语句来托管地图,使用一系列地图加载选项,包括地图凭据和默认缩放级别:

Loaded() {   // Bing has a nasty habit of not working properly in browsers like 
  // Chrome if we don't hook the map up 
 // in the window.load event.   const map = new Microsoft.Maps.Map(this.myMap.nativeElement, {   credentials: environment.mapKey,   enableCORS: true,   zoom: 13   });
  this.map.emit(map);
}

如果我们想选择特定类型的地图类型来显示,我们可以在地图加载选项中设置如下:

mapTypeId:Microsoft.Maps.MapTypeId.road

我们的MapViewComponent将托管在另一个组件内部,因此我们将创建一个EventEmitter,我们可以用它来通知父组件。我们已经在我们的Loaded方法中添加了发射代码,将刚加载的地图传回给父组件:

@Output() map = new EventEmitter();

现在让我们添加父容器。大部分模板只是用来创建带有行和列的 Bootstrap 容器。在div列内,我们将托管刚刚创建的子组件。同样,我们可以看到我们使用了EventEmitter,所以当地图被发射时,它触发MapLoaded事件:

<div class="container-fluid h-100">
 <div class="row h-100">
 <div class="col-12">
 <atp-map-view (map)="MapLoaded($event)"></atp-map-view>
 </div>
 </div> </div>

大多数映射容器代码现在应该是我们熟悉的领域。我们注入FirebaseMapPinsServicePointsOfInterestService,我们用它们在MapLoaded方法中创建MapEvents实例。换句话说,当atp-map-view组件触发window.load时,填充的 Bing 地图就会回来:

export class MappingcontainerComponent implements OnInit {   private map: Microsoft.Maps.Map;
  private mapEvents: MapEvents;
  constructor(private readonly firebaseMapPinService: FirebaseMapPinsService, 
private readonly poi: PointsOfInterestService) { }    ngOnInit() {
 }    MapLoaded(map: Microsoft.Maps.Map) {
  this.map = map;
  this.mapEvents = new MapEvents(this.map, new PinsModel(this.firebaseMapPinService), this.poi);
 } }

关于显示地图的说明——我们确实需要设置htmlbody的高度,以使其延伸到浏览器窗口的全高。在styles.scss文件中设置如下:

html,body {
  height: 100%; }

地图事件和设置标记

我们有地图,我们有逻辑来将兴趣点保存到数据库并在内存中移动它们。我们唯一没有的是处理用户实际从地图本身创建和管理标记的代码。现在是时候纠正这种情况并添加一个MapEvents类来为我们处理这个问题。就像MapGeocodePinModelPinsModel类一样,这个类是一个独立的实现。让我们从添加以下代码开始:

export class MapEvents {
  private readonly geocode: MapGeocode;
  private infoBox: Microsoft.Maps.Infobox;

  constructor(private map: Microsoft.Maps.Map, private pinsModel: PinsModel, private poi: PointsOfInterestService) {

  }
}

Infobox是在将兴趣点添加到屏幕上时出现的框。我们可以在添加每个兴趣点时添加一个新的,但这将是一种资源浪费。相反,我们将添加一个单独的Infobox,并在添加新点时重用它。为此,我们将添加一个辅助方法,检查之前是否已设置Infobox。如果之前没有设置,我们将实例化Infobox的新实例,输入图钉位置、标题和描述。我们将使用setMap来设置此Infobox将出现在的地图实例。当我们重用这个Infobox时,我们只需要在选项中设置相同的值,然后将可见性设置为true

private SetInfoBox(title: string, description: string, pin: Microsoft.Maps.Pushpin): void {
  if (!this.infoBox) {
    this.infoBox = new Microsoft.Maps.Infobox(pin.getLocation(), { title: title, description: description });
    this.infoBox.setMap(this.map);
  return;
  }
  this.infoBox.setOptions({
    title: title,
    description: description,
    location: pin.getLocation(),
    visible: true
  });
}

在我们添加从地图中选择点的能力之前,我们还需要向这个类添加一些辅助方法。我们要添加的第一个方法是从本地见解搜索中获取兴趣点并将它们添加到地图上。在这里,我们可以看到我们添加图钉的方式是创建一个绿色的Pushpin,然后将其添加到我们的 Bing 地图上的正确Location。我们还添加了一个事件处理程序,以响应对图钉的点击,并使用我们刚刚添加的方法显示Infobox

AddPoi(pois: PoiPoint[]): void {
  pois.forEach(poi => {
    const pin: Microsoft.Maps.Pushpin = new Microsoft.Maps.Pushpin(new Microsoft.Maps.Location(poi.lat, poi.long), {
      color: Microsoft.Maps.Color.fromHex('#00ff00')
    });
    this.map.entities.push(pin);
    Microsoft.Maps.Events.addHandler(pin, 'click', (x) => {
      this.SetInfoBox('Point of interest', poi.name, pin);
    });
  })
}

下一个辅助方法更复杂,所以我们将分阶段添加它。当用户在地图上单击时,将调用AddPushPin代码。签名如下:

AddPushPin(e: any): void {
}

在这个方法中,我们要做的第一件事是创建一个Guid,用于在添加PinsModel条目时使用,并在点击位置添加一个可拖动的Pushpin

const guid: Guid = Guid.create();
const pin: Microsoft.Maps.Pushpin = new Microsoft.Maps.Pushpin(e.location, {
  draggable: true
});

有了这个方法,我们将调用之前编写的ReverseGeocode方法。当我们从中获取结果时,我们将添加我们的PinsModel条目,并在显示Infobox之前将Pushpin推到地图上:

this.geocode.GeoCode(e.location).then((geocode) => {
  this.pinsModel.Add(guid.toString(), geocode, e.location.latitude, e.location.longitude);
  this.map.entities.push(pin);
  this.SetInfoBox('User location', geocode, pin);
});

我们还没有完成这个方法。除了添加一个Pushpin,我们还必须能够拖动它,以便用户在拖动图钉时选择一个新的位置。我们将使用dragend事件来移动图钉。同样,我们之前付出的辛苦工作得到了回报,因为我们有一个简单的机制来Move PinsModel并显示我们的Infobox

const dragHandler = Microsoft.Maps.Events.addHandler(pin, 'dragend', (args: any) => {
  this.geocode.GeoCode(args.location).then((geocode) => {
    this.pinsModel.Move(guid.toString(), geocode, args.location.latitude, args.location.longitude);
    this.SetInfoBox('User location (Moved)', geocode, pin);
  });
});

最后,当用户点击图钉时,我们希望从PinsModel和地图中删除图钉。当我们为dragendclick添加事件处理程序时,我们将处理程序保存到变量中,以便我们可以使用它们从地图事件中删除事件处理程序。自我整理是一个好习惯,特别是在处理事件处理程序之类的事情时:

const handler = Microsoft.Maps.Events.addHandler(pin, 'click', () => {
  this.pinsModel.Remove(guid.toString());
  this.map.entities.remove(pin);

  // Tidy up our stray event handlers.
  Microsoft.Maps.Events.removeHandler(handler);
  Microsoft.Maps.Events.removeHandler(dragHandler);
});

好了,我们的辅助方法已经就位。现在我们只需要更新构造函数,以便在地图上单击以设置兴趣点并在用户查看的视口发生变化时搜索本地见解。让我们从响应用户在地图上单击开始:

this.geocode = new MapGeocode(this.map);
Microsoft.Maps.Events.addHandler(map, 'click', (e: any) => {
  this.AddPushPin(e);
});

在这里,我们不需要将处理程序存储为变量,因为我们将其与在浏览器中运行时不会被移除的东西关联起来,即地图本身。

当用户移动地图以便查看其他区域时,我们需要执行本地见解搜索,并根据返回的结果添加兴趣点。我们将事件处理程序附加到地图viewchangeend事件以触发此搜索:

Microsoft.Maps.Events.addHandler(map, 'viewchangeend', () => {
  const center = map.getCenter();
  this.poi.Search([center.latitude, center.longitude]).then(pointsOfInterest => {
    if (pointsOfInterest && pointsOfInterest.length > 0) {
      this.AddPoi(pointsOfInterest);
    }
  })
})

我们不断看到事先准备方法可以节省我们很多时间。我们只是利用PointsOfInterestService.Search方法来进行本地见解搜索,然后将结果传递给我们的AddPoi方法。如果我们不想执行本地见解搜索,我们可以简单地删除此事件处理程序,而无需进行任何搜索。

我们唯一剩下要做的就是处理从数据库加载我们的标记。这里的代码是我们已经看到的用于添加clickdragend处理程序的代码的变体,但我们不需要执行地理编码,因为我们已经有了每个兴趣点的名称。因此,我们不打算重用AddPushPin方法。相反,我们将选择在整个部分内联执行。加载订阅如下所示:

const subscription = this.pinsModel.Load().subscribe((data: PinModelData[]) => {
  data.forEach(pinData => {
    const pin: Microsoft.Maps.Pushpin = new Microsoft.Maps.Pushpin(new Microsoft.Maps.Location(pinData.lat, pinData.long), {
      draggable: true
    });
    this.map.entities.push(pin);
    const handler = Microsoft.Maps.Events.addHandler(pin, 'click', () => {
      this.pinsModel.Remove(pinData.id);
      this.map.entities.remove(pin);
    Microsoft.Maps.Events.removeHandler(handler);
      Microsoft.Maps.Events.removeHandler(dragHandler);
    });
    const dragHandler = Microsoft.Maps.Events.addHandler(pin, 'dragend', (args: any) => {
      this.geocode.GeoCode(args.location).then((geocode) => {
        this.pinsModel.Move(pinData.id, geocode, args.location.latitude, args.location.longitude);
        this.map.entities.push(pin);
    this.SetInfoBox('User location (moved)', geocode, pin);
      });
    });
  });
  subscription.unsubscribe();
  this.pinsModel.AddFromStore(data);
});

需要注意的是,由于我们正在处理订阅,一旦完成订阅,我们就会从中取消订阅。订阅应返回一个PinModelData项目数组,我们可以遍历并根据需要添加元素。

就是这样。我们现在已经有了一个可用的映射解决方案。这是我最期待写的章节之一,因为我喜欢映射应用程序。我希望你和我一样享受这个过程。然而,在我们离开这一章之前,如果你想防止人们未经授权访问数据,你可以在下一节中应用这些知识。

保护数据库

这一部分是提供数据库安全性所需的可选概述。您可能还记得,当我们创建 Firestore 数据库时,我们设置了访问权限,以便任何人都可以完全不受限制地访问。在开发小型测试应用程序时这没问题,但通常不适用于商业应用程序的部署。

我们将更改数据库的配置,以便只有在授权 ID 设置时才允许读/写访问。为此,请在数据库中选择“规则”选项卡,并将if request.auth.uid != null;添加到规则列表中。match /{document=**}的格式简单地意味着这个规则适用于列表中的任何文档。可以设置只适用于特定文档的规则,但在这样的应用程序环境中并没有太多意义。

请注意,这样做意味着我们必须添加身份验证,就像我们在第六章中所做的那样,使用 Socket.IO 构建聊天室应用程序。设置这一点超出了本章的范围,但从上一章复制导航并提供登录功能应该很简单:

这是一段相当漫长的旅程。我们经历了注册不同在线服务的过程,并将映射功能引入了我们的代码。与此同时,我们还看到了如何使用 TypeScript 支持在 Angular 应用程序中搭建脚手架,而无需生成和注册服务。现在,您应该能够拿起这段代码,并尝试添加您真正想要的映射功能。

摘要

在本章中,我们已经完成了使用 Microsoft 和 Google 的云服务引入 Angular 项目的工作,这些云服务以 Bing Maps 和 Firebase 云服务的形式存储数据。我们注册了这些服务,并从中获取了相关信息,以便为客户端访问它们。在编写代码的过程中,我们创建了与 Firestore 数据库一起工作的类,并与 Bing Maps 交互,执行诸如基于用户点击搜索地址、在地图上添加标记以及使用本地洞察力搜索咖啡店等操作。

继续我们的 TypeScript 之旅,我们介绍了 rest 元组。我们还看到如何向 Angular 组件添加代码以响应浏览器主机事件。

在下一章中,我们将重新审视 React。这一次,我们将创建一个使用 Docker 包含各种微服务的有限微服务 CRM。

问题

  1. Angular 如何允许我们与主机元素交互?

  2. 纬度和经度是什么?

  3. 逆地理编码的目的是什么?

  4. 我们使用哪项服务来存储我们的数据?

第八章:使用 React 和微服务构建 CRM

在我们使用 REST 服务的先前章节中,我们专注于有一个用于处理 REST 调用的单个站点。现代应用程序经常使用微服务,可能托管在基于容器的系统(如 Docker)中。

在本章中,我们将学习如何使用 Swagger 创建托管在多个 Docker 容器中的一组微服务来设计我们的 REST API。我们的 React 客户端应用程序将负责将这些微服务整合在一起,创建一个简单的客户关系管理(CRM)系统。

本章将涵盖以下主题:

  • 理解 Docker 和容器

  • 微服务是什么,它们的用途是什么

  • 将单片架构分解为微架构

  • 共享通用的服务器端功能

  • 使用 Swagger 设计 API

  • 在 Docker 中托管微服务

  • 使用 React 连接到微服务

  • 在 React 中使用路由

技术要求

完成的项目可以从github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter08下载。

下载项目后,您将需要使用npm install命令安装软件包要求。由于服务分布在多个文件夹中,您将需要逐个安装每个服务。

理解 Docker 和微服务

由于我们正在构建一个使用 Docker 容器托管的微服务系统,所以我们需要事先了解一些术语和理论。

在本节中,我们将在继续了解微服务是什么、它们旨在解决什么问题以及如何将单片应用程序拆分为更模块化的服务之前,先看一下常见的 Docker 术语及其含义。

Docker 术语

如果您是 Docker 的新手,您将遇到许多围绕它的术语。了解这些术语将有助于我们在设置服务器时,因此让我们从基础知识开始。

容器

如果您在互联网上看到过任何 Docker 文献,这可能是您已经遇到的术语。容器是运行实例,接收运行应用程序所需的各种软件。这是我们的起点。容器是从镜像构建的,您可以自己构建或从中央 Docker 数据库下载。容器可以向其他容器、主机操作系统甚至向更广泛的世界开放,使用端口和卷。容器的一个重要卖点是它们易于设置和创建,并且可以快速停止和启动。

镜像

正如我们在上一段中所介绍的,容器最初是一个镜像。已经有大量可供使用的镜像,但我们也可以创建自己的镜像。创建镜像时,创建步骤会被缓存,以便轻松重复使用。

端口

这对您来说可能已经很熟悉了。Docker 中的端口术语与操作系统中的端口术语完全相同。这些是对主机操作系统可见的 TCP 或 UDP 端口,或者连接到外部世界的端口。当我们的应用程序在内部使用相同的端口号但使用不同的端口号向外界公开时,本章后面将会有一些有趣的代码。

可视化卷的最简单方法是将其视为共享文件夹。创建容器时,卷被初始化,并允许我们持久保存数据,无论容器的生命周期如何。

注册表

实际上,注册表可以被视为 Docker 世界的应用商店。它存储可以下载的 Docker 镜像,并且本地镜像可以以类似于将应用程序推送到应用商店的方式推送回注册表。

Docker Hub

Docker Hub 是最初由 Docker 提供的 Docker 注册表。该注册表存储了大量的 Docker 镜像,其中一些来自 Docker,一些是由软件团队为其构建的。

在本章中,我们不打算涵盖安装 Docker,因为安装和设置 Docker 本身就是一个章节,特别是因为在 Windows 上安装 Docker 与在 macOS 或 Linux 上安装 Docker 是不同的体验。但我们将使用的命令来组合 Docker 应用程序和检查实例的状态不会改变,所以我们会在需要时进行覆盖。

微服务

在企业软件世界中很难不听到微服务这个术语。这是一种架构风格,将所谓的单体系统拆分为一系列服务。这种架构的特点是服务范围紧凑且可测试。服务应该松散耦合,以限制它们之间的依赖关系——将这些服务组合在一起应该由最终应用程序来完成。这种松散耦合促进了它们可以独立部署的想法,服务通常专注于业务能力。

尽管我们可能会听到来自营销大师和咨询公司的声音,他们希望销售服务,但微服务并不总是应用的合适选择。有时,保持单体应用可能更好。如果我们无法使用前面段落中概述的所有想法来拆分应用程序,那么应用程序很可能不适合作为微服务的候选。

与我们迄今为止在本书中涵盖的许多内容不同,例如模式,微服务没有官方批准的定义。你不能遵循一个清单并说,“这是一个微服务,因为它正在执行 a、b 和 c”。相反,对于构成微服务的内容的共识观点已经发展,基于看到什么有效和什么无效,演变成一系列特征。对于我们的目的,构成微服务的重要属性包括以下内容:

  • 该服务可以独立部署,不依赖于其他微服务。

  • 该服务基于业务流程。微服务应该是粒度细小的,因此将它们组织在单一的业务领域周围有助于从小而专注的组件创建大规模应用程序。

  • 服务之间的语言和技术可以是不同的。这为我们提供了在必要时利用最佳和最合适的技术的机会。例如,我们可能有一个服务在内部托管,而另一个服务可能在 Azure 等云服务中托管。

  • 服务应该规模小。这并不意味着它不应该有太多代码;相反,它意味着它只专注于一个领域。

使用 Swagger 设计我们的 REST API

在开发 REST 驱动的应用程序时,我发现使用 Swagger 的功能非常有用。Swagger 具有许多功能,使其成为我们想要执行诸如创建 API 文档、为 API 创建代码和测试 API 等操作时的首选工具。

我们将使用 Swagger UI 来原型化检索人员列表的能力。从这里,我们可以生成与我们的 API 一起使用的文档。虽然我们可以从中生成代码,但我们将使用可用的工具来查看我们最终 REST 调用的形状,然后使用我们之前创建的数据模型来实现自己的实现。我喜欢这样做的原因有两个。首先,我喜欢打造小而干净的数据模型,我发现原型可以让我可视化模型。其次,有很多生成的代码,我发现当我自己编写代码时更容易将我的数据模型与数据库联系起来。

在本章中,我们将自己编写代码,但我们将使用 Swagger 来原型设计我们想要交付的内容。

我们需要做的第一件事是登录 Swagger:

  1. 从主页,点击登录。这会弹出一个对话框,询问我们要登录哪个产品,即 SwaggerHub 或 Swagger Inspector。Swagger Inspector 是一个用于测试 API 的好工具,但由于我们将开发 API,我们将登录 SwaggerHub。以下截图显示了它的外观:

  1. 如果您没有 Swagger 帐户,可以通过注册或使用 GitHub 帐户从这里创建一个。为了创建一个 API,我们需要选择创建新的>创建新的 API。在模板下拉菜单中选择 None,并填写如下:

  1. 在这个阶段,我们准备开始填写我们的 API。我们得到的开箱即用的是以下内容:
swagger: '2.0'
info:
  version: '1.0'
  title: 'Advanced TypeScript 3 - CRM'
  description: ''
paths: {}
# Added by API Auto Mocking Plugin
host: virtserver.swaggerhub.com
basePath: /user_id/AdvancedTypeScript3CRM/1.0
schemes:
 - https

让我们开始构建这个 API。首先,我们要创建 API 路径的开始。我们需要创建的任何路径都放在paths节点下。Swagger 编辑器在构建 API 时验证输入,所以不用担心在填写时出现验证错误。在我们的示例中,我们将创建 API 来检索我们添加到数据库中的所有人的数组。因此,我们从这里开始,我们的 API 端点,替换paths: {}行:

paths:
  /people:
    get:
     summary: "Retrieves the list of people from Firebase"
     description: Returns a list of people

因此,我们已经说过我们的 REST 调用将使用GET动词发出。我们的 API 将返回两种状态,HTTP 200HTTP 400。让我们通过在responses节点中填充这些状态的开始来提供这一点。当我们返回400错误时,我们需要创建定义我们将通过网络返回的内容的模式。schema返回一个包含单个message字符串的object,如下所示:

     responses:
        200:
        400:
          description: Invalid request 
          schema:
            type: object
            properties: 
              message:
                type: string

由于我们的 API 将返回一个人的数组,我们的模式被定义为一个array。构成人的items与我们在服务器代码中讨论的模型相对应。因此,通过填写我们200响应的schema,我们得到了这个:

          description: Successfully returned a list of people 
          schema:
            type: array
            items:
              type: object
              properties:
                ServerID:
                  type: string
                FirstName:
                  type: string
                LastName:
                  type: string
                Address:
                  type: object
                  properties:
                    Line1: 
                      type: string
                    Line2: 
                      type: string
                    Line3: 
                      type: string
                    Line4: 
                      type: string
                    PostalCode: 
                      type: string
                    ServerID: 
                      type: string

这是编辑器中我们的schema的样子:

现在我们已经看到了 Swagger 如何用于原型设计我们的 API,我们可以继续定义我们想要构建的项目。

使用 Docker 创建微服务应用

我们要编写的项目是 CRM 系统的一个小部分,用于维护有关客户的详细信息并为这些客户添加潜在客户。应用程序的工作方式是用户创建地址;当他们添加有关联系人的详细信息时,他们将从他们已经创建的地址列表中选择地址。最后,他们可以创建使用他们已经添加的联系人的潜在客户。这个系统的想法是,以前,应用程序使用一个大数据库来存储这些信息,我们将把它分解成三个独立的服务。

与 GitHub 代码一起工作,本章应该需要大约三个小时才能完成。完成后,应用程序应如下所示:

完成这些后,我们将继续看如何为 Docker 创建应用程序,以及这如何补充我们的项目。

使用 Docker 创建微服务应用的入门

在本章中,我们将再次使用 React。除了使用 React,我们还将使用 Firebase 和 Docker,托管 Express 和 Node。我们的 React 应用程序与 Express 微服务之间的 REST 通信将使用 Axios 完成。

如果您在 Windows 10 上进行开发,请安装 Windows 版的 Docker Desktop,可在此处下载:hub.docker.com/editions/community/docker-ce-desktop-windows

要在 Windows 上运行 Docker,您需要安装 Hyper-V 虚拟化。

如果您想在 macOS 上安装 Docker Desktop,请前往hub.docker.com/editions/community/docker-ce-desktop-mac

Docker Desktop 在 Mac 上运行在 OS X Sierra 10.12 和更新的 macOS 版本上。

我们将要构建的 CRM 应用程序演示了如何将多个微服务集成到一个统一的应用程序中,最终用户不知道我们的应用程序正在使用来自多个数据源的信息。

我们应用程序的要求如下:

  • CRM 系统将提供输入地址的功能。

  • 系统将允许用户输入有关一个人的详细信息。

  • 当有关一个人的详细信息被输入时,用户可以选择之前输入的地址。

  • 系统将允许用户输入有关潜在客户的详细信息。

  • 数据将保存到云数据库中。

  • 人员、潜在客户和地址信息将从单独的服务中检索。

  • 这些单独的服务将由 Docker 托管。

  • 我们的用户界面将作为一个 React 系统创建。

我们一直在努力实现在我们的应用程序中共享功能的能力。我们的微服务将通过尽可能共享尽可能多的公共代码,然后只添加它们需要定制的数据,来将这种方法推向更高水平。我们之所以能够这样做,是因为我们的服务在需求上是相似的,所以它们可以共享很多公共代码。

我们的微服务应用程序从单体应用程序的角度开始。该应用程序由一个系统管理所有的人员、地址和潜在客户。我们将对这个单体应用程序进行适当的处理,并将其分解成更小、离散的部分,其中每个组成部分都存在于其他部分之外。在这里,潜在客户、地址和人员都存在于自己独立的服务中。

我们将从我们的tsconfig文件开始。在之前的章节中,每章都有一个服务,一个tsconfig文件。我们将通过拥有一个根级tsconfig.json文件来改变这种情况。我们的服务将都使用它作为一个共同的基础:

  1. 让我们从创建一个名为Services的文件夹开始,它将作为我们服务的基础。在此之下,我们将创建单独的AddressesCommonLeadsPeople文件夹,以及我们的基础tsconfig文件。

  2. 当我们完成这一步时,我们的Services文件夹应该如下所示:

  1. 现在,让我们添加tsconfig设置。这些设置将被我们将要托管的所有服务共享:
{
  "compileOnSave": true,
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "removeComments": true,
    "strict": true,
    "esModuleInterop": true,
    "inlineSourceMap": true,
    "experimentalDecorators": true,
  }
}

您可能已经注意到我们在这里还没有设置输出目录。我们将稍后再进行设置。在进行这一步之前,我们将开始添加将由我们的微服务共享的公共功能。我们的共享功能将被添加到Common文件夹中。我们将要添加的一些内容应该看起来非常熟悉,因为我们在之前的章节中构建了类似的服务器代码。

我们的服务将保存到 Firebase,因此我们将从编写我们的数据库代码开始。我们需要安装的npm包是firebase@types/firebase。在添加这些的同时,我们还应该导入guid-typescript以及我们之前安装的基本 nodecorsexpress包。

当每个服务将数据保存到数据库时,它将以相同的基本结构开始。我们将有一个ServerID,我们将使用 GUID 自己设置。我们将使用的基本模型如下所示:

export interface IDatabaseModelBase {
  ServerID: string;
}

我们将创建一个abstract基类,它将与IDatabaseModelBase的实例一起工作,使我们能够Get记录,GetAll记录和Save记录。与 Firebase 一起工作的美妙之处在于,虽然它是一个强大的系统,但我们必须编写的代码来完成这些任务非常简短。让我们从类定义开始:

export abstract class FirestoreService<T extends IDatabaseModelBase> {
  constructor(private collection: string) { }
}

正如你所看到的,我们的类是通用的,这告诉我们每个服务都将扩展IDatabaseModelBase并在其特定的数据库实现中使用它。集合是将在 Firebase 中写入的集合的名称。对于我们的目的,我们将共享一个 Firebase 实例来存储不同的集合,但我们的架构之美在于如果我们不想要,我们不需要这样做。如果需要,我们可以使用单独的 Firebase 存储;事实上,在生产环境中通常会发生这种情况。

我们添加我们的GET方法是没有意义的,如果我们没有保存任何数据,所以我们要做的第一件事是编写我们的Save方法。毫不奇怪,我们的Save方法将是异步的,因此它将返回一个Promise

public Save(item: T): Promise<T> {
  return new Promise<T>(async (coll) => {
    item.ServerID = Guid.create().toString();
    await firebase.firestore().collection(this.collection).doc(item.ServerID).set(item);
    coll(item);
  });
}

可能看起来奇怪的是async (coll)的代码。由于我们使用了=>,我们创建了一个简化的函数。由于这是一个函数,我们在其中添加了async关键字,以指示代码可以在其中使用await。如果我们没有将其标记为async,那么我们将无法在其中使用await

我们的代码在调用一系列方法设置数据之前为ServerID分配了一个 GUID。让我们分块处理代码,看看每个部分的作用。正如我们在第七章中讨论的那样,使用 Firebase 进行 Angular 基于云的映射,Firebase 提供的不仅仅是数据库服务,所以我们需要做的第一件事是访问数据库部分。如果我们在这里不遵循方法链接,我们可以将其写成如下形式:

const firestore: firebase.firestore.Firestore = firebase.firestore();

在 Firestore 中,我们不是将数据保存在表中,而是将其保存在命名集合中。一旦我们有了firestore,我们就会得到CollectionReference。在前面的代码片段之后,我们可以将其重写如下:

const collection: firebase.firestore.CollectionReference = firestore.collection(this.collection);

一旦我们有了CollectionReference,我们就可以使用我们在方法中之前设置的ServerID来访问单个文档。如果我们不提供自己的 ID,系统会为我们创建一个:

const doc: firebase.firestore.DocumentReference = collection.doc(item.ServerID);

现在,我们需要设置我们要写入数据库的数据:

await doc.set(item);

这将把数据保存到 Firestore 中适当的集合中的文档中。我不得不承认,虽然我喜欢输入可以像这样分解的代码的能力,但是如果可以使用方法链接,我很少这样做。当链中的下一步逻辑上从前一步逻辑上逻辑上跟随时,我经常将方法链接在一起,因为如果没有经过前面的步骤,就无法到达下一步,而且这样做可以让我很容易地将步骤序列可视化。

一旦项目保存到数据库中,我们将返回保存的项目,包括ServerID,返回到调用代码,以便可以立即使用。这就是这行代码的作用:

coll(item);

我们FirestoreService的下一步是添加GET方法。这个方法,像Save方法一样,是一个async方法,返回一个包装在 promise 中的T类型的单个实例。由于我们知道 ID,我们的 Firestore 代码的绝大部分是相同的。不同之处在于我们调用get(),然后用它来返回数据:

public async Get(id: string): Promise<T> {
  const qry = await firebase.firestore().collection(this.collection).doc(id).get();
  return <T>qry.data();
}

猜猜看?我们还有一个async GetAll方法要写,这次返回一个T数组。由于我们想要检索多个记录,而不仅仅是单个文档,我们在我们的collection上调用get()。一旦我们有了记录,我们使用一个简单的forEach来构建我们需要返回的数组:

public async GetAll(): Promise<T[]> {
  const qry = await firebase.firestore().collection(this.collection).get();
  const items: T[] = new Array<T>();
  qry.forEach(item => {
    items.push(<T>item.data());
  });
  return items;
}

我们的数据库代码已经就位,让我们看看实际情况是什么样子。我们将从Addresses服务开始,创建一个扩展IDatabaseModelBaseIAddress接口:

export interface IAddress extends IDatabaseModelBase {
  Line1 : string,
  Line2 : string,
  Line3 : string,
  Line4 : string,
  PostalCode : string
}

有了IAddress,我们现在可以创建将我们的服务与我们将在 Firebase 中存储的addresses集合联系起来的类。通过我们的努力,AddressesService就像这样简单:

export class AddressesService extends FirestoreService<IAddress> {
  constructor() {
    super('addresses');
  }
}

您可能想知道数据模型和数据库访问的代码是否与其他微服务一样简单。让我们看看我们的People接口和数据库服务是什么样子的:

export interface IPerson extends IDatabaseModelBase {
  FirstName: string;
  LastName: string;
  Address: IAddress;
}
export class PersonService extends FirestoreService<IPerson> {
  constructor() {
    super('people');
  }
}

您可能还想知道为什么我们将地址信息存储在IPerson内部。如果您是从关系数据库的角度来看待 NoSQL 架构,那么很容易认为我们应该只开始引用地址,而不是重复数据,特别是在关系数据库中,记录是通过外键链接在一起创建指针来建立关系。 老式 SQL 数据库使用外部表来最小化记录中的冗余,以便我们不会创建跨多个记录共享的重复数据。虽然这是一个有用的功能,但它确实使查询和检索记录变得更加复杂,因为我们感兴趣的信息可能分散在几个表中。通过将地址存储在人员旁边,我们减少了我们需要查询以构建人员信息的表的数量。这是基于我们想要查询记录的频率远远超过我们想要更改记录的想法,因此,如果我们需要更改地址,我们将更改主地址,然后单独的查询将运行通过所有人员记录,寻找需要更新的地址。我们将实现这一点,因为人员记录中地址部分的ServerID将与主地址中的ServerID匹配。

我们不会涵盖Leads数据库代码;您可以在源代码中阅读它,它几乎与此相同。我们的做法是,我们的微服务在功能上非常相似,因此我们可以简单地利用继承。

添加服务器端路由支持

除了有一个与数据库共同工作的常见方式之外,我们的传入 API 请求在端点方面都将非常相似。在写这本书的时候,我试图整理一些以后可以重复使用的代码片段。其中一个片段是我们处理 Express 路由的方式。我们在第四章中组合的服务器端代码,MEAN Stack - 构建照片库,就是这样一个区域,特别是路由的代码。我们可以几乎完全按照当时写的方式引入这段代码。

这是代码的快速提醒。首先,我们有我们的IRouter接口:

export interface IRouter {
  AddRoute(route: any): void;
}

然后,我们有我们的路由引擎 - 这段代码我们将直接插入到我们的服务器中:

export class RoutingEngine {
  constructor(private routing: IRouter[] = new Array<IRouter>()) {
  }
  public Add<T1 extends IRouter>(routing: (new () => T1), route: any) {
    const routed = new routing();
    routed.AddRoute(route);
    this.routing.push(routed);
  }
}

那么,在实践中,这是什么样子呢?好吧,这是保存从客户端发送过来的地址的代码。当我们从客户端收到一个/add/请求时,我们从请求体中提取详细信息,并将其转换为IAddress,然后用于保存到地址服务中:

export class SaveAddressRouting implements IRouter {
  AddRoute(route: any): void {
    route.post('/add/', (request: Request, response: Response) => {
      const person: IAddress = <IAddress>{...request.body};
      new AddressesService().Save(person);
      response.json(person);
    });
  }
}

获取地址的代码非常相似。我们不打算解剖这个方法,因为现在它应该看起来非常熟悉:

export class GetAddressRouting implements IRouter {
  AddRoute(route: any): void {
    route.get('/get/', async (request: Request, response: Response) => {
      const result = await new AddressesService().GetAll();
      if (result) {
        response.json(result);
      }
      response.send('');
    });
  }
}

LeadsPeople服务的代码几乎是相同的。请阅读我们的 GitHub 存储库中的代码,以熟悉它。

服务器类

再次,为了尽可能地重用代码,我们将使用我们在第四章中编写的 Express Server类的略微修改版本,The MEAN Stack – Building a Photo Gallery。我们将快速浏览代码以重新熟悉它。首先,让我们放置类定义和构造函数。我们的构造函数是第四章中构造函数的简化版本,The MEAN Stack – Building a Photo Gallery

export abstract class Server {
  constructor(private port: number = 3000, private app: any = express(), protected routingEngine: RoutingEngine = new RoutingEngine()) {}
  }
}

我们还想要添加 CORS 支持。虽然我们可以将其设为强制性,但我仍然喜欢将是否要这样做的控制权交给服务开发人员,因此我们将保持这个方法为public

public WithCorsSupport(): Server {
  this.app.use(cors());
  return this;
}

为了使我们的实际服务器实现工作,我们需要赋予它们添加路由的能力。我们通过AddRouting方法来实现这一点:

protected AddRouting(router: Router): void {
}

现在我们有了AddRouting方法,我们需要编写代码来启动我们的服务器:

public Start(): void {
  this.app.use(bodyParser.json()); 
  this.app.use(bodyParser.urlencoded({extended:true}));
  const router: Router = express.Router();
  this.AddRouting(router);
  this.app.use(router);
  this.app.listen(this.port, ()=> console.log(`logged onto server at ${this.port}`));
}

您可能已经注意到,我们缺少一个重要的部分。我们的服务器中没有数据库支持,但我们的服务需要初始化 Firebase。在我们的服务器中,我们添加了以下内容:

public WithDatabase(): Server {
  firebase.initializeApp(Environment.fireBase);
  return this;
}

请注意,我没有在存储库中包含Environment.fireBase,因为它包含我使用的服务器和密钥的详细信息。这是一个包含 Firebase 连接信息的常量。您可以将其替换为您在云中创建 Firebase 数据库时设置的连接信息。要添加这个,您需要在Common文件夹中创建一个名为Environment.ts的文件,其中包含如下代码:

export const Environment = {
  fireBase: {
    apiKey: <<add your api key here>>,
    authDomain: "advancedtypescript3-containers.firebaseapp.com",
    databaseURL: "https://advancedtypescript3-containers.firebaseio.com",
    projectId: "advancedtypescript3-containers",
    storageBucket: "advancedtypescript3-containers.appspot.com",
    messagingSenderId: <<add your sender id here>>
  }
}

创建我们的 Addresses 服务

现在我们已经有了创建实际服务所需的一切。在这里,我们将看一下Addresses服务,理解其他服务将遵循相同的模式。由于我们已经有了数据模型、数据访问代码和路由,我们所要做的就是创建我们的实际AddressesServer类。AddressesServer类就是这么简单:

export class AddressesServer extends Server {
  protected AddRouting(router: Router): void {
    this.routingEngine.Add(GetAddressRouting, router);
    this.routingEngine.Add(SaveAddressRouting, router);
  }
}

我们这样启动服务器:

new AddressesServer()
  .WithCorsSupport()
  .WithDatabase().Start();

代码就是这么简单。我们尽可能地遵循一个叫做不要重复自己DRY)的原则。这简单地表示您应该尽量少地重复输入代码。换句话说,您应该尽量避免在代码库中散布着完全相同的代码。有时候,您无法避免这种情况,有时候,为了一个或两行代码而费力地创建大量代码框架是没有意义的,但是当您有大型功能区域时,您绝对应该尽量避免将其复制粘贴到代码的多个部分中。部分原因是,如果您复制并粘贴了代码,随后发现了一个 bug,您将不得不在多个地方修复这个 bug。

使用 Docker 来运行我们的服务

当我们看我们的服务时,我们可以看到一个有趣的问题;即它们都使用相同的端口启动。显然,我们不能真的为每个服务使用相同的端口,那么我们是不是给自己造成了问题?这是否意味着我们不能启动多个服务,如果是这样,这是否会破坏我们的微服务架构,意味着我们应该回到单体服务?

鉴于我们刚刚讨论的潜在问题以及本章介绍了 Docker,毫不奇怪地得知 Docker 就是解决这个问题的答案。通过 Docker,我们可以启动一个容器,部署我们的代码,并使用不同的端点暴露服务。那么,我们该如何做到这一点呢?

在每个服务中,我们将添加一些常见的文件:

node_modules
npm-debug.log

第一个文件叫做.dockerignore,它选择在复制或添加文件到容器时要忽略的文件。

我们要添加的下一个文件叫做 Dockerfile。这个文件描述了 Docker 容器以及如何构建它。Dockerfile 通过构建一系列指令的层来构建容器。第一层在容器中下载并安装 Node,具体来说是 Node 版本 8:

FROM node:8

下一层用于设置默认工作目录。该目录用于后续命令,比如 RUNCOPYENTRYPOINTCMDADD

WORKDIR /usr/src/app

在一些在线资源中,你会看到人们创建自己的目录作为工作目录。最好使用预定义的、众所周知的位置,比如 /usr/src/app 作为 WORKDIR

由于我们现在已经有了一个工作目录,我们可以开始设置代码了。我们想要复制必要的文件来下载和安装我们的 npm 包:

COPY package*.json ./
RUN npm install

作为一个良好的实践,我们在复制代码之前复制 package.jsonpackage-lock.json 文件,因为安装会缓存安装的内容。只要我们不改变 package.json 文件,如果代码再次构建,我们就不需要重新下载包。

所以,我们的包已经安装好了,但是我们还没有任何代码。让我们将本地文件夹的内容复制到工作目录中:

COPY . .

我们想要将服务器端口暴露给外部世界,所以现在让我们添加这一层:

EXPOSE 3000

最后,我们想要启动服务器。为了做到这一点,我们想要触发 npm start

CMD [ "npm", "start" ]

作为运行 CMD["npm", "start"] 的替代方案,我们可以完全绕过 npm,使用 CMD ["node", "dist/server.js"](或者服务器代码叫什么)。我们考虑这样做的原因是,运行 npm 会启动 npm 进程,然后启动我们的服务器进程,所以直接使用 Node 减少了运行的服务数量。此外,npm 有一个擅自消耗进程退出信号的习惯,所以除非 npm 告诉它,Node 不知道进程已经退出。

现在,如果我们想要启动地址服务,例如,我们可以从命令行运行以下命令:

docker build -t ohanlon/addresses .
docker run -p 17171:3000 -d ohanlon/addresses

第一行使用 Dockerfile 构建容器镜像,并给它一个标签,这样我们就可以在 Docker 容器中识别它。

一旦镜像构建完成,下一个命令运行安装并将容器端口发布到主机。这个技巧是使我们的服务器代码工作的 魔法,它将内部端口 3000 暴露给外部世界作为 17171。请注意,我们在这两种情况下都使用 ohanlon/addresses 来将容器镜像与我们要运行的镜像绑定(你可以用任何你想要的名称替换这个名称)。

-d 标志代表分离,这意味着我们的容器在后台静默运行。这允许我们启动服务并避免占用命令行。

如果你想找到可用的镜像,可以运行 docker ps 命令。

使用 docker-compose 来组合和启动服务

我们不再使用 docker builddocker run 来运行我们的镜像,而是有一个叫做 docker-compose 的东西来组合和运行多个容器。使用 Docker 组合,我们可以从多个 docker 文件或者完全通过一个名为 docker-compose.yml 的文件创建我们的容器。

我们将使用 docker-compose.yml 和我们在上一节中创建的 Docker 文件的组合来创建一个可以轻松运行的组合。在服务器代码的根目录中,创建一个名为 docker-compose.yml 的空文件。我们将首先指定文件符合的组合格式。在我们的情况下,我们将把它设置为 2.1

version: '2.1'

我们将在容器内创建三个服务,所以让我们首先定义这些服务本身:

services:
  chapter08_addresses:
  chapter08_people:
  chapter08_leads:

现在,每个服务由离散信息组成,其中的第一部分详细说明了我们要使用的构建信息。这些信息在一个构建节点下,并包括上下文,它映射到我们的服务所在的目录,以及 Docker 文件,它定义了我们如何构建容器。可选地,我们可以设置NODE_ENV参数来标识节点环境,我们将设置为production。我们的谜题的最后一部分映射回docker run命令,我们在其中设置端口映射;每个服务都可以设置自己的ports映射。这是放在chapter08_addresses下的节点的样子:

build: 
  context: ./Addresses
  dockerfile: ./Dockerfile
environment:
  NODE_ENV: production
ports: 
  - 17171:3000

当我们把所有这些放在一起时,我们的docker-compose.yml文件看起来像这样:

version: '2.1'

services:
  chapter08_addresses:
    build: 
      context: ./Addresses
      dockerfile: ./Dockerfile
    environment:
      NODE_ENV: production
    ports: 
      - 17171:3000
  chapter08_people:
    build: 
      context: ./People
      dockerfile: ./Dockerfile
    environment:
      NODE_ENV: production
    ports: 
      - 31313:3000
  chapter08_leads:
    build: 
      context: ./Leads
      dockerfile: ./Dockerfile
    environment:
      NODE_ENV: production
    ports: 
      - 65432:3000

在我们开始这些过程之前,我们必须编译我们的微服务。Docker 不负责构建应用程序,因此在尝试组合我们的服务之前,我们有责任先这样做。

现在,我们有多个容器可以使用一个组合文件一起启动。为了运行我们的组合文件,我们使用docker-compose up命令。当所有容器都启动后,我们可以使用docker ps命令验证它们的状态,这给我们以下输出:

我们现在已经完成了服务器端的代码。我们已经准备好了需要创建我们的微服务的一切。现在我们要做的是继续创建将与我们的服务交互的用户界面。

创建我们的 React 用户界面

我们花了很多时间构建 Angular 应用程序,所以回到构建 React 应用程序是公平的。就像 Angular 可以与 Express 和 Node 一起工作一样,React 也可以与它们一起工作,既然我们已经有了 Express/Node 端,现在我们要创建我们的 React 客户端。我们将从创建具有 TypeScript 支持的 React 应用程序的命令开始:

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

这将创建一个标准的 React 应用程序,我们将修改以满足我们的需求。我们需要做的第一件事是引入对 Bootstrap 的支持,这次使用react-bootstrap包。在此期间,我们也可以安装以下依赖项——react-table@types/react-tablereact-router-dom@types/react-router-domaxios。我们将在本章中使用它们,因此现在安装它们将节省一些时间。

在本书中,我们一直在使用npm来安装依赖项,但这并不是我们唯一的选择。npm有一个优点,它是 Node 的默认包管理器(毕竟它叫 Node Package Manager),但 Facebook 在 2015 年推出了自己的包管理器,叫做 Yarn。Yarn 是为了解决当时npm版本存在的问题而创建的。Yarn 使用自己的一组锁文件,而不是npm使用的默认package*.lock。你使用哪一个取决于你的个人偏好和评估它们提供的功能是否是你需要的。对于我们的目的,npm是一个合适的包管理器,所以我们将继续使用它。

使用 Bootstrap 作为我们的容器

我们希望使用 Bootstrap 来渲染我们整个显示。幸运的是,这是一个微不足道的任务,围绕着对我们的App组件进行一些小修改。为了渲染我们的显示,我们将把内容包裹在一个容器内,就像这样:

export class App extends React.Component {
  public render() {
    return (
      <Container fluid={true}>
        <div />
      </Container>
    );
  }
}

现在,当我们渲染我们的内容时,它将自动渲染在一个容器内,该容器延伸到页面的整个宽度。

创建一个分页用户界面

在添加导航元素之前,我们将创建用户单击链接时将链接到的组件。我们将从AddAddress.tsx开始,我们将在其中添加代码以添加地址。我们首先添加类定义:

export class AddAddress extends React.Component<any, IAddress> {
}

我们组件的默认状态是一个空的IAddress,所以我们添加了它的定义,并将组件状态设置为我们的默认值:

private defaultState: Readonly<IAddress>;
constructor(props:any) {
  super(props);
  this.defaultState = {
    Line1: '',
    Line2: '',
    Line3: '',
    Line4: '',
    PostalCode: '',
    ServerID: '',
  };
  const address: IAddress = this.defaultState;
  this.state = address;
}

在我们添加代码来渲染表单之前,我们需要添加一些方法。正如您可能还记得我们上次学习 React 时,我们学到如果用户在显示中更改任何内容,我们必须显式更新状态。就像上次一样,我们将编写一个UpdateBinding事件处理程序,当用户更改显示中的任何值时我们将调用它。我们将在所有的Add*xxx*组件中看到这种模式重复出现。作为一个复习,ID 告诉我们用户正在更新哪个字段,然后我们使用它来设置状态中的适当字段与更新值。根据这些信息,我们的event处理程序看起来像这样:

private UpdateBinding = (event: any) => {
  switch (event.target.id) {
    case `address1`:
      this.setState({ Line1: event.target.value});
      break;
    case `address2`:
      this.setState({ Line2: event.target.value});
      break;
    case `address3`:
      this.setState({ Line3: event.target.value});
      break;
    case `address4`:
      this.setState({ Line4: event.target.value});
      break;
    case `zipcode`:
      this.setState({ PostalCode: event.target.value});
      break;
  }
}

我们需要添加的另一个支持方法是触发 REST 调用到我们的地址服务。我们将使用 Axios 包来传输一个POST请求到添加地址的端点。Axios 给我们提供了基于 promise 的 REST 调用,这样我们就可以,例如,发出调用并等待它返回再继续处理。我们将选择一个简单的代码模型,并以一种忘记即可的方式发送我们的请求,这样我们就不必等待任何结果返回。为了简单起见,我们将立即重置 UI 的状态,准备让用户添加另一个地址。

既然我们已经添加了这些方法,我们将编写我们的render方法。定义如下:

public render() {
  return (
    <Container>
  </Container>
  );
}

Container元素映射回我们从 Bootstrap 中习惯的好老容器类。这里缺少的是实际的输入元素。每个输入都被分组在Form.Group中,这样我们就可以添加LabelControl,就像这样:

<Form.Group controlId="formGridAddress1">
  <Form.Label>Address</Form.Label>
  <Form.Control placeholder="First line of address" id="address1" value={this.state.Line1} onChange={this.UpdateBinding} />
</Form.Group>

作为另一个提醒,绑定的当前值通过单向绑定呈现在我们的显示中,表示为value={this.state.Line1},用户的任何输入都会通过UpdateBinding事件处理程序触发对状态的更新。

我们添加的用于保存状态的Button代码如下:

<Button variant="primary" type="submit" onClick={this.Save}>
  Submit
</Button>

把所有这些放在一起,这就是我们的render方法的样子:

public render() {
  return (
    <Container>
      <Form.Group controlId="formGridAddress1">
        <Form.Label>Address</Form.Label>
        <Form.Control placeholder="First line of address" id="address1" value={this.state.Line1} onChange={this.UpdateBinding} />
      </Form.Group>
      <Form.Group controlId="formGridAddress2">
        <Form.Label>Address 2</Form.Label>
        <Form.Control id="address2" value={this.state.Line2} onChange={this.UpdateBinding} />
      </Form.Group>
      <Form.Group controlId="formGridAddress2">
        <Form.Label>Address 3</Form.Label>
        <Form.Control id="address3" value={this.state.Line3} onChange={this.UpdateBinding} />
      </Form.Group>
      <Form.Group controlId="formGridAddress2">
        <Form.Label>Address 4</Form.Label>
        <Form.Control id="address4" value={this.state.Line4} onChange={this.UpdateBinding} />
      </Form.Group>
      <Form.Group controlId="formGridAddress2">
        <Form.Label>Zip Code</Form.Label>
        <Form.Control id="zipcode" value={this.state.PostalCode} onChange={this.UpdateBinding}/>
      </Form.Group>
      <Button variant="primary" type="submit" onClick={this.Save}>
        Submit
      </Button>
    </Container>
  )
}

那么,这段代码一切都好吗?嗯,不,Save代码有一个小问题。如果用户点击按钮,因为状态在Save方法中不可见,所以不会保存到数据库。当我们执行onClick={this.Save}时,我们正在为Save方法分配一个回调。内部发生的是this上下文丢失,所以我们无法使用它来获取状态。现在,我们有两种修复方法;一种是我们已经经常见到的,就是使用箭头函数=>来捕获上下文,以便我们的方法可以处理它。

解决这个问题的另一种方法(也是我们故意编写Save方法不使用箭头函数的原因,这样我们就可以看到这个方法的操作)是在构造函数中添加以下代码来绑定上下文:

this.Save = this.Save.bind(this);

好了,这就是我们添加地址的代码。我希望您会同意这是一个足够简单的代码;一次又一次,人们创造了不必要复杂的代码,而一般来说,简单是一个更有吸引力的选择。我非常喜欢使代码尽可能简单。行业中有一种习惯,就是试图使代码变得比必要复杂,只是为了给其他开发人员留下印象。我敦促人们避免这种诱惑,因为清晰的代码更加令人印象深刻。

我们用于管理地址的用户界面是分页的,所以我们有一个标签页负责添加地址,而另一个标签页显示一个包含我们当前添加的所有地址的网格。现在是时候添加标签页和网格代码了。我们将创建一个名为addresses.tsx的新组件,它为我们完成这些工作。

同样,我们首先创建我们的类。这次,我们将state设置为空数组。我们这样做是因为我们将稍后从我们的地址微服务中填充它:

export default class Addresses extends React.Component<any, any> {
  constructor(props:any) {
    super(props);
    this.state = {
      data: []
    }
  }
}

为了从我们的微服务加载数据,我们需要一个处理这个任务的方法。我们将再次使用 Axios,但这次我们将使用 promise 功能在从服务器返回时设置状态:

private Load(): void {
  axios.get("http://localhost:17171/get/").then(x =>
  {
    this.setState({data: x.data});
  });
}

现在的问题是,我们何时想要调用Load方法?我们不想在构造函数中尝试获取状态,因为那会减慢组件的构建速度,所以我们需要另一个点来检索这些数据。答案在于 React 组件的生命周期。组件在创建时经历几种方法。它们的顺序如下:

  1. constructor();

  2. getDerivedStateFromProps();

  3. render();

  4. componentDidMount();

我们要实现的效果是使用render显示组件,然后使用绑定更新要在表格中显示的值。这告诉我们我们想要在componentDidMount中加载我们的状态:

public componentWillMount(): void {
  this.Load(); 
};

我们确实有另一个潜在的触发更新的点。如果用户添加了一个地址,然后切换标签回到显示表格的标签,我们将希望自动检索更新后的地址列表。让我们添加一个方法来处理这个问题:

private TabSelected(): void {
  this.Load();
}

现在是时候添加我们的render方法了。为了保持简单,我们将分两个阶段添加;第一阶段是添加TabAddAddress组件。在第二阶段,我们将添加Table

添加标签需要我们引入Reactified Bootstrap 标签组件。在我们的render方法中,添加以下代码:

return (
  <Tabs id="tabController" defaultActiveKey="show" onSelect={this.TabSelected}>
    <Tab eventKey="add" title="Add address">
      <AddAddress />
    </Tab>
    <Tab eventKey="show" title="Addresses">
      <Row>
      </Row>
    </Tab>
  </Tabs>
)

我们有一个Tabs组件,其中包含两个单独的Tab项。每个标签都被赋予一个eventKey,我们可以使用它来设置默认的活动键(在这种情况下,我们将其设置为show)。当选择一个标签时,我们触发数据的加载。我们将看到我们的AddAddress组件已经添加到Add Address标签中。

我们在这里要做的所有事情就是添加我们将用来显示地址列表的表格。我们将创建一个我们想要在表格中显示的列的列表。我们使用以下语法创建列列表,其中Header是将显示在列顶部的标题,accessor告诉 React 从数据行中选择哪个属性:

const columns = [{
  Header: 'Address line 1',
  accessor: 'Line1'
}, {
  Header: 'Address line 2',
  accessor: 'Line2'
}, {
  Header: 'Address line 3',
  accessor: 'Line4'
}, {
  Header: 'Address line 4',
  accessor: 'Line4'
}, {
  Header: 'Postal code',
  accessor: 'PostalCode'
}]

最后,我们需要在我们的Addresses标签中添加表格。我们将使用流行的ReactTable组件来显示表格。将以下代码放入<Row></Row>部分以添加它:

<Col>
  <ReactTable data={this.state.data} columns={columns} 
    defaultPageSize={15} pageSizeOptions = {[10, 30]} className="-striped -highlight" /></Col>

这里有一些有趣的参数。我们将data绑定到this.state.data,以便在状态改变时自动更新它。我们创建的列与columns属性绑定。我喜欢我们可以使用defaultPageSize控制每页显示多少行,以及让用户使用pageSizeOptions选择覆盖行数的功能。我们将className设置为-striped -highlight,这样显示就会在灰色和白色之间有条纹,当鼠标移动到表格上时,行高亮会显示鼠标停留在哪一行。

在添加一个人时使用选择控件选择地址

当用户想要添加一个人时,他们只需要输入他们的名字和姓氏。我们向用户显示一个选择框,其中填充了先前输入的地址列表。让我们看看如何使用 React 处理这样一个更复杂的场景。

我们需要做的第一件事是创建两个单独的组件。我们有一个AddPerson组件用于输入名字和姓氏,还有一个AddressChoice组件,用于检索和显示用户可以选择的完整地址列表。我们将从AddressChoice组件开始。

这个组件使用了一个自定义的IAddressProperty,它为我们提供了访问父组件的能力,这样我们就可以在这个组件改变值时触发当前选择的地址的更新:

interface IAddressProperty {
  CurrentSelection : (currentSelection:IAddress | null) => void;
}
export class AddressesChoice extends React.Component<IAddressProperty, Map<string, string>> {
}

我们告诉 React,我们的组件接受IAddressProperty作为组件的 props,并且Map<string, string>作为状态。当我们从服务器检索地址列表时,我们用这个地图填充地址;键用于保存ServerID,值保存地址的格式化版本。由于这背后的逻辑看起来有点复杂,我们将从加载地址的方法开始,然后再回到构造函数:

private LoadAddreses(): void {
  axios.get("http://localhost:17171/get/").then((result:AxiosResponse<any>) =>
  {
    result.data.forEach((person: any) => {
      this.options.set(person.ServerID, `${person.Line1} ${person.Line2} ${person.Line3} ${person.Line4} ${person.PostalCode}`);
    });
    this.addresses = { ...result.data };
    this.setState(this.options);
  });
}

我们首先向服务器发出请求,获取完整的地址列表。当我们收到列表后,我们将遍历地址,构建我们刚刚讨论过的格式化地图。我们用格式化地图填充状态,并将未格式化的地址复制到一个单独的地址字段中;我们这样做的原因是,虽然我们希望将格式化版本显示到显示器上,但当选择改变时,我们希望将未格式化的版本发送回给调用者。我们还可以通过其他方式实现这一点,但这是一个简单的有用的小技巧。

有了加载功能,我们现在可以添加我们的构造函数和字段:

private options: Map<string, string>;
private addresses: IAddress[] = [];
constructor(prop: IAddressProperty) {
  super(prop);
  this.options = new Map<string, string>();
  this.Changed = this.Changed.bind(this);
  this.state = this.options;
}

请注意,我们在这里有一个changed绑定,与我们在前一节讨论的bind代码保持一致。数据加载再次发生在componentDidMount中:

public componentDidMount() {
 this.LoadAddreses();
}

现在我们准备构建我们的渲染方法。为了简化构建选择项的条目的可视化,我们将这段代码分离成一个单独的方法。这个方法简单地遍历this.options列表,创建要添加到select控件的选项:

private RenderList(): any[] {
  const optionsTemplate: any[] = [];
  this.options.forEach((value, key) => (
    optionsTemplate.push(<option key={key} value={key}>{value}</option>)
  ));
  return optionsTemplate;
}

我们的渲染方法使用了一个选择Form.Control,它将Select...显示为第一个选项,然后从RenderList中渲染出列表:

public render() {
  return (<Form.Control as="select" onChange={this.Changed}>
    <option>Select...</option>
    {this.RenderList()}
  </Form.Control>)
}

细心的读者会注意到,我们已经两次引用了Changed方法,但实际上并没有添加它。这个方法接受选择值并使用它来查找未格式化的地址,如果找到了,就使用props来触发CurrentSelection方法:

private Changed(optionSelected: any) {
  const address = Object.values(this.addresses).find(x => x.ServerID === optionSelected.target.value);
  if (address) {
    this.props.CurrentSelection(address);
  } else {
    this.props.CurrentSelection(null);
  }
}

在我们的AddPerson代码中,AddressesChoice在渲染中被引用如下:

<AddressesChoice CurrentSelection={this.CurrentSelection} />

我们不打算覆盖AddPerson内部的其余内容。我建议跟随下载的代码来查看这个位置。我们也不打算覆盖其他组件;如果我们继续剖析其他组件,特别是因为它们大部分都遵循我们刚刚讨论过的控件的相同格式,这一章可能会变成一个长达一百页的怪物。

添加我们的导航

我们想要添加到我们客户端代码库的最后一部分代码是处理客户端导航的能力。我们在讨论 Angular 时已经看到了如何做到这一点,现在是时候看看如何根据用户选择的链接显示不同的页面。我们将使用 Bootstrap 导航和 React 路由操作的组合。我们首先创建一个包含我们导航的路由器:

const routing = (
  <Router>
    <Navbar bg="light">
      <Navbar.Collapse id="basic-navbar-nav">
        <Nav.Link href="/">Home</Nav.Link>
        <Nav.Link href="/contacts">Contacts</Nav.Link>
        <Nav.Link href="/leads">Leads</Nav.Link>
        <Nav.Link href="/addresses">Addresses</Nav.Link>
      </Navbar.Collapse>
    </Navbar>
  </Router>
)

我们留下了一个主页,这样我们就可以添加适当的文档和图片,如果我们想要装饰它,使它看起来像一个商业 CRM 系统。其他href元素将与路由器绑定,以显示适当的 React 组件。在Router内部,我们添加了将path映射到componentRoute条目,因此,如果用户选择Addresses,例如,将显示Addresses组件:

<Route path="/" component={App} />
<Route path="/addresses" component={Addresses} />
<Route path="/contacts" component={People} />
<Route path="/leads" component={Leads} />

我们的routing代码现在看起来像这样:

const routing = (
  <Router>
    <Navbar bg="light">
      <Navbar.Collapse id="basic-navbar-nav">
        <Nav.Link href="/">Home</Nav.Link>
        <Nav.Link href="/contacts">Contacts</Nav.Link>
        <Nav.Link href="/leads">Leads</Nav.Link>
        <Nav.Link href="/addresses">Addresses</Nav.Link>
      </Navbar.Collapse>
    </Navbar>
    <Route path="/" component={App} />
    <Route path="/addresses" component={Addresses} />
    <Route path="/contacts" component={People} />
    <Route path="/leads" component={Leads} />
  </Router>
)

为了添加我们的导航,包括路由,我们进行了以下操作:

ReactDOM.render(
  routing,
  document.getElementById('root') as HTMLElement
);

就是这样。我们现在有一个客户端应用程序,可以与我们的微服务进行通信,并协调它们的结果,使它们一起工作,即使它们的实现是相互独立的。

总结

在这一点上,我们已经创建了一系列微服务。我们首先定义了一系列共享功能,然后以此为基础创建专业服务。这些服务都在 Node.js 中使用了相同的端口,这本应该给我们带来问题,但我们通过创建一系列 Docker 容器来解决了这个问题,启动我们的服务并将内部端口重定向到不同的外部端口。我们看到了如何创建相关的 Docker 文件和 Docker 组合文件来启动服务。

然后,我们创建了一个基于 React 的客户端应用程序,通过引入选项卡来使用更高级的布局,以将微服务的查看结果与向服务添加记录的能力分开。在这个过程中,我们还使用了 Axios 来管理我们的 REST 调用。

在进行 REST 调用时,我们看到了如何使用 Swagger 来定义我们的 REST API,并讨论了是否在我们的服务中使用 Swagger 提供的 API 代码。

在下一章中,我们将远离 React,看看如何创建一个与 TensorFlow 一起工作的 Vue 客户端,以自动执行图像分类。

问题

  1. 什么是 Docker 容器?

  2. 我们用什么来将 Docker 容器分组在一起启动它们,我们可以使用什么命令来启动它们?

  3. 我们如何使用 Docker 将内部端口映射到不同的外部端口?

  4. Swagger 为我们提供了哪些功能?

  5. 如果一个方法在 React 中看不到状态,我们需要做什么?

进一步阅读

第九章:使用 Vue.js 和 TensorFlow.js 进行图像识别

当前计算机领域最热门的话题之一是机器学习。在本章中,我们将进入机器学习的世界,使用流行的TensorFlow.js包进行图像分类,以及姿势检测。作为对 Angular 和 React 的改变,我们将转向 Vue.js 来提供我们的客户端实现。

本章将涵盖以下主题:

  • 机器学习是什么,以及它与人工智能的关系

  • 如何安装 Vue

  • 使用 Vue 创建应用程序

  • 使用 Vue 模板显示主页

  • 在 Vue 中使用路由

  • 卷积神经网络CNNs)是什么

  • TensorFlow 中模型的训练方式

  • 使用预训练的 TensorFlow 模型构建图像分类类

  • TensorFlow 支持的图像类型,用于图像分类和姿势检测

  • 使用姿势检测显示身体关节

技术要求

完成的项目可以从github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/chapter09下载。本项目使用 TensorFlow,因此本章将使用以下额外组件:

  • @tensorflow-models/mobilenet

  • @tensorflow-models/posenet

  • @tensorflow/tfjs

我们还将在 Vue 中使用 Bootstrap,因此我们需要安装以下 Bootstrap 组件:

  • bootstrap

  • bootstrap-vue

下载项目后,您将需要使用npm install命令安装包要求。

什么是机器学习,TensorFlow 如何适用?

现在很难摆脱人工智能机器的概念。人们已经习惯于使用 Siri、Alexa 和 Cortana 等工具,这些工具给人一种科技能理解我们并与我们互动的假象。这些语音激活系统使用自然语言处理来识别句子,比如“今天 Kos 的天气如何?”

这些系统背后的魔力就是机器学习。为了选择其中一个系统,我们将快速查看 Alexa 在展示之前的工作,然后再看机器学习与人工智能的关系。

当我们问 Alexa 一个问题时,会认出的名字,这样她就知道应该开始倾听后面的内容以开始处理。这相当于在某人的肩膀上轻拍以引起他们的注意。然后 Alexa 会记录以下句子,直到达到一个点,Alexa 可以通过互联网将录音传输到 Alexa 语音服务。这项极其复杂的服务尽其所能地解析录音(有时,重口音可能会让服务混淆)。然后服务根据解析的录音进行操作,并将结果发送回您的 Alexa 设备。

除了回答关于天气的问题,Alexa 还有大量的技能供用户使用,亚马逊鼓励开发者创建超出他们有时间想出的技能。这意味着轻松订购披萨和查看最新的赛车结果一样容易。

这个序言引导我们开始接触机器学习与 Alexa 有什么关系。Alexa 背后的软件使用机器学习不断更新自己,所以每次出错时,都会反馈回去,这样系统在下一次变得更聪明,并且不会在未来犯同样的错误。

正如你可以想象的那样,解释语音是一项非常复杂的任务。这是我们作为人类从小就学会的东西,与机器学习的类比令人叹为观止,因为我们也是通过重复和强化来学习语音的。因此,当一个婴儿随机说出“爸爸”时,婴儿已经学会发出这些声音,但还不知道这个声音的正确语境。通常由父母指向自己来提供的强化用于将声音与人物联系起来。当我们使用图片书时,类似的强化也会发生;当我们教婴儿“牛”的时候,我们会指向一张牛的图片。这样,婴儿就学会将这个词与图片联系起来。

由于语音解释非常复杂,它需要大量的处理能力,也需要一个庞大的预先训练的数据集。想象一下,如果我们不得不教 Alexa 一切会有多么令人沮丧。这在一定程度上解释了为什么机器学习系统现在才真正开始发挥作用。我们现在有足够的基础设施,可以将计算卸载到可靠、强大和专用的机器上。此外,我们现在有足够强大和快速的互联网来处理传输到这些机器学习系统的大量数据。如果我们仍然使用 56K 调制解调器,我们肯定无法做到现在能做到的一半。

什么是机器学习?

我们知道计算机擅长是或否答案,或者说 1 和 0。这意味着计算机基本上无法回答“-ish”,因此它无法对问题回答“有点是”。请稍等片刻,这很快就会变得清楚。

在其最基本的层面上,我们可以说,机器学习归结为教计算机以我们相同的方式学习。它们学会解释来自各种来源的数据,并利用这种学习对数据进行分类。机器将从成功和失败中学习,从而使其更准确和能够进行更复杂的推断。

回到计算机处理是或否答案的想法,当我们得出一个答案,相当于“嗯,这取决于”的时候,我们基本上是基于相同的输入得出多个答案——相当于通过多种途径得出是或否的答案。机器学习系统在学习方面变得越来越好,因此它们背后的算法能够利用越来越多的数据,以及越来越多的强化来建立更深层次的联系。

在幕后,机器学习应用了一系列令人难以置信的算法和统计模型,以便系统可以执行一些任务,而无需详细说明如何完成这些任务。这种推断水平远远超出了我们传统构建应用程序的方式,这是因为,鉴于正确的数学模型,计算机非常擅长发现模式。除此之外,它们同时执行大量相关任务,这意味着支持学习的数学模型可以将其计算结果作为反馈输入,以便更好地理解世界。

在这一点上,我们必须提到 AI 和机器学习并不相同。机器学习是基于自动学习的 AI 应用,而无需为处理特定任务而进行编程。机器学习的成功基于系统学习所需的足够数量的数据。可以应用一些算法类型。有些被称为无监督学习算法,而其他一些被称为监督学习算法。

无监督算法接收以前未分类或标记的数据。这些算法在这些数据集上运行,以寻找潜在或隐藏的模式,这些模式可以用来创建推断。

监督学习算法利用其先前的学习,并使用标记的示例将其应用于新数据。这些标记的示例帮助它学习正确的答案。在幕后,有一个训练数据集,学习算法用它来完善他们的知识并学习。训练数据的级别越高,算法产生正确答案的可能性就越大。

还有其他类型的算法,包括强化学习算法和半监督学习算法,但这些超出了本书的范围。

什么是 TensorFlow,它与机器学习有什么关系?

我们已经讨论了机器学习是什么,如果我们试图自己实现它,可能会显得非常令人生畏。幸运的是,有一些库可以帮助我们创建自己的机器学习实现。最初由 Google Brain 团队创建,TensorFlow 是这样一个旨在支持大规模机器学习和数值计算的库。最初,TensorFlow 是作为混合 Python/C++库编写的,其中 Python 提供了用于构建学习应用程序的前端 API,而 C++端执行它们。TensorFlow 汇集了许多机器学习和神经网络(有时称为深度学习)算法。

鉴于原始 Python 实现的成功,我们现在有了一个用 TypeScript 编写的 TensorFlow 实现(称为TensorFlow.js),我们可以在我们的应用程序中使用。这是我们将在本章中使用的版本。

项目概述

我们将在本章中编写的项目是我在为这本书写提案时最激动人心的项目。我对所有 AI 相关的事物都有长期的热爱;这个主题让我着迷。随着TensorFlow.js等框架的兴起(我将简称为 TensorFlow),在学术界之外进行复杂的机器学习的能力从未如此容易获得。正如我所说,这一章真的让我兴奋,所以我们不仅仅使用一个机器学习操作——我们将使用图像分类来确定图片中的内容,并使用姿势检测来绘制关键点,如人体的主要关节和主要面部标志。

与 GitHub 代码一起工作,这个主题应该需要大约一个小时才能完成,完成后应该是这样的:

现在我们知道我们要构建的项目是什么,我们准备开始实施。在下一节中,我们将开始安装 Vue。

在 Vue 中开始使用 TensorFlow

如果您尚未安装 Vue,则第一步是安装 Vue 命令行界面CLI)。使用以下命令使用npm安装:

npm install -g @vue/cli

创建基于 Vue 的应用程序

我们的 TensorFlow 应用程序将完全在客户端浏览器中运行。这意味着我们需要编写一个应用程序来托管 TensorFlow 功能。我们将使用 Vue 来提供我们的客户端,因此需要以下步骤来自动构建我们的 Vue 应用程序。

创建我们的客户端就像运行vue create命令一样简单,如下所示:

vue create chapter09

这开始了创建应用程序的过程。在进行客户端创建过程时,需要进行一些决策点,首先是选择是否接受默认设置或手动选择要添加的功能。由于我们想要添加 TypeScript 支持,我们需要选择手动选择功能预设。以下截图显示了我们将要进行的步骤,以选择我们 Vue 应用程序的功能:

我们的项目可以添加许多功能,但我们只对其中一些感兴趣,所以取消选择 Babel,选择添加 TypeScript、Router、VueX 和 Linter / Formatter。通过使用空格键来进行选择/取消选择:

当我们按下Enter时,将呈现出许多其他选项。按下Enter将为前三个选项设置默认值。当我们到达选择linter(缩写为Lexical INTERpreter)的选项时,请从列表中选择 TSLint,然后继续按Enter处理其他选项。linter 是一个自动解析代码的工具,寻找潜在问题。它通过查看我们的代码来检查是否违反了一组预定义的规则,这可能表明存在错误或代码样式问题。

当我们完成了整个过程,我们的客户端将被创建;这将需要一些时间来完成,因为有大量的代码需要下载和安装。

现在我们的应用程序已经创建,我们可以在客户端文件夹的根目录中运行npm run serve来运行它。与 Angular 和 React 不同,浏览器不会默认显示页面,所以我们需要自己打开页面,使用http://localhost:8080。这样做时,页面将如下所示:

当我们编写图像分类器时,我们将使生活更加轻松,因为我们将通过修改主页来展示我们的图像分类器的运行情况,从而重用 Vue CLI 为我们创建的一些现有基础设施。

显示带有 Vue 模板的主页

与 React 以.jsx/.tsx扩展名为我们提供将代码和网页放在一起的特殊扩展名类似,Vue 为我们提供了单文件组件,创建为.vue文件。这些文件允许我们将代码和网页模板混合在一起构建我们的页面。在继续创建我们的第一个 TensorFlow 组件之前,让我们打开我们的Home.vue页面并对其进行分析。

我们可以看到我们的.vue组件分为两个部分。有一个模板部分定义了将显示在屏幕上的 HTML 的布局,还有一个单独的脚本部分,我们在其中包含我们的代码。由于我们使用 TypeScript,我们的script部分的语言是ts

脚本部分首先通过定义import部分开始,这与标准的.ts文件中看到的方式非常相似。在导入中看到@时,这告诉我们导入路径是相对于src目录的,因此HelloWorld.vue组件位于src/components文件夹中:

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import HelloWorld from '@/components/HelloWorld.vue';
</script>

接下来我们需要做的是创建一个从Vue类继承的类。我们使用@Component创建一个名为Home的组件注册,可以在其他地方使用:

@Component
export default class Home extends Vue {}

还有一件事情我们需要做。我们的模板将引用一个外部的HelloWorld组件。我们必须用模板将要使用的组件装饰我们的类,就像这样:

@Component({
  components: {
    HelloWorld,
  },
})
export default class Home extends Vue {}

模板非常简单。它由一个单一的div类组成,我们将在其中渲染HelloWorld组件:

<template>
  <div class="home">
    <HelloWorld />
  </div>
</template>

从前面的代码模板中,我们可以看到,与 React 不同,Vue 没有为我们提供一个明确的render函数来处理 HTML 和状态的渲染。相反,渲染的构建更接近于 Angular 模型,其中模板被解析为可以提供的内容。

我们提到 Angular 的原因是因为 Vue.js 最初是由 Evan You 开发的,他当时正在谷歌的 AngularJS 项目上工作;他想要创建一个性能更好的库。虽然 AngularJS 是一个很棒的框架,但它需要完全接受 Angular 生态系统才能使用(Angular 团队正在努力解决这个问题)。因此,虽然 Vue 利用了 Angular 的特性,比如模板,但它的影响力很小,你只需在现有代码中添加一个脚本标签,然后慢慢将现有代码迁移到 Angular。

Vue 从 React 中借鉴了一些概念,比如使用虚拟 DOM(我们在介绍 React 时讨论过)。Vue 也使用虚拟 DOM,但以稍微不同的方式实现,主要是 Vue 只重新渲染有变化的组件,而 React 默认情况下也会重新渲染子组件。

现在我们要修改HelloWorld组件,以便与 TensorFlow 一起使用。但在这之前,我们需要编写一些支持类来处理 TensorFlow 的重要工作。这些类在代码量上并不大,但非常重要。我们的ImageClassifier类以标准的类定义开始,如下所示:

export class ImageClassifier {
}

下一步是可选的,但如果应用程序在 Windows 客户端上运行,它对应用程序的稳定性有重大影响。在底层,TensorFlow 使用 WebGLTextures,但在 Windows 平台上创建 WebGLTextures 存在问题。为了解决这个问题,我们的构造函数需要修改如下:

constructor() {
  tf.ENV.set('WEBGL_PACK', false);
}

由于我们可以运行图像分类任意次数,我们将添加一个表示标准MobileNet TensorFlow 的私有变量:

private model: MobileNet | null = null;

MobileNet 介绍

此时,我们需要稍微了解一下 CNN 的世界。MobileNet是一个 CNN 模型,因此稍微了解 CNN 是如何帮助我们理解它与我们解决的问题有关。不用担心,我们不会深入研究 CNN 背后的数学,但了解一点它们的工作原理将有助于我们欣赏它们为我们带来了什么。

CNN 分类器通过接收输入图像(可能来自视频流),处理图像,并将其分类到预定义的类别中。为了理解它们的工作原理,我们需要退后一步,从计算机的角度思考问题。假设我们有一张马的照片。对于计算机来说,那张照片只是一系列像素,所以如果我们展示一张稍微不同的马的照片,计算机无法仅通过比较像素来判断它们是否匹配。

CNN 将图像分解成片段(比如 3x3 像素的网格),并比较这些片段。简单地说,它寻找的是这些片段能够匹配的数量。匹配的数量越多,我们就越有信心有一个匹配。这是对 CNN 的一个非常简化的描述,它涉及多个步骤和滤波器,但它应该有助于理解为什么我们想要在 TensorFlow 中使用MobileNet这样的 CNN。

MobileNet是一个专门的 CNN,除其他功能外,它为我们提供了针对 ImageNet 数据库中的图像进行训练的图像分类(www.image-net.org/)。当我们加载模型时,我们加载的是一个为我们创建的预训练模型。我们使用预训练网络的原因是它已经在服务器上的大型数据集上进行了训练。我们不希望在浏览器中运行图像分类训练,因为这将需要从服务器到浏览器传输太多负载以执行训练。因此,无论您的客户端 PC 有多强大,复制训练数据集都会太多。

我们提到了MobileNetV1MobileNetV2,但没有详细介绍它们是什么以及它们是在什么数据集上训练的。基本上,MobileNet模型是由谷歌开发的,并在 ImageNet 数据集上进行了训练,该数据集包含了 140 万张图像,分为 1000 类图像。之所以称这些模型为MobileNet模型,是因为它们是针对移动设备进行训练的,因此它们被设计为在低功耗和/或低存储设备上运行。

使用预训练模型,我们可以直接使用它,或者我们可以自定义它以用于迁移学习。

分类方法

现在我们对 CNN 有了一点了解,我们准备将这些知识付诸实践。我们将创建一个异步分类方法。当 TensorFlow 需要检测图像时,它可以使用多种格式,因此我们将概括我们的方法,只接受适当的类型:

public async Classify(image: tf.Tensor3D | ImageData | HTMLImageElement | 
HTMLCanvasElement | HTMLVideoElement):   Promise<TensorInformation[] | null> {
}

这些类型中只有一个是特定于 TensorFlow 的——Tensor3D类型。所有其他类型都是标准的 DOM 类型,因此可以在网页中轻松消耗,而无需跳过许多环节将图像转换为适当的格式。

我们还没有介绍我们的TensorInformation接口。当我们从MobileNet接收分类时,我们会收到一个分类名称和一个分类的置信水平。这作为Promise<Array<[string, number]>>从分类操作返回,因此我们将其转换为对我们的消费代码更有意义的东西:

export interface TensorInformation {
  className: string;
  probability: number; }

现在我们知道我们将返回一个分类数组和一个概率(置信水平)。回到我们的Classify方法,如果以前没有加载MobileNet,我们需要加载它。这个操作可能需要一段时间,这就是为什么我们对它进行缓存,这样我们下次调用这个方法时就不必重新加载它了:

if (!this.model) {   this.model = await mobilenet.load();  }

我们已经接受了load操作的默认设置。如果需要,我们可以提供一些选项:

  • version:这设置了MobileNet的版本号,默认为 1。现在,可以设置两个值:1表示我们使用MobileNetV12表示我们使用MobileNetV2。对我们来说,版本之间的区别实际上与模型的准确性和性能有关。

  • alpha:这可以设置为0.250.50.751。令人惊讶的是,这与图像上的alpha通道无关。相反,它指的是将要使用的网络宽度,有效地以性能换取准确性。数字越高,准确性越高。相反,数字越高,性能越慢。alpha的默认值为1

  • modelUrl:如果我们想要使用自定义模型,我们可以在这里提供。

如果模型成功加载,那么我们现在可以执行图像分类。这是对classify方法的直接调用,传入我们方法中传递的image。完成此操作后,我们返回分类结果的数组:

if (this.model) {   const result = await this.model.classify(image);   return {   ...result,  };  }

model.classify方法默认返回三个分类,但如果需要,我们可以传递参数返回不同数量的分类。如果我们想要检索前五个结果,我们将更改model.classify行如下:

const result = await this.model.classify(image, 5);

最后,如果模型加载失败,我们将返回null。有了这个设置,我们完成的Classify方法如下所示:

public async Classify(image: tf.Tensor3D | ImageData | HTMLImageElement | 
HTMLCanvasElement | HTMLVideoElement):   Promise<TensorInformation[] | null> {   if (!this.model) {   this.model = await mobilenet.load();  }   if (this.model) {   const result = await this.model.classify(image);   return {   ...result,  };  }   return null;  }

TensorFlow 确实可以如此简单。显然,在幕后,隐藏了大量的复杂性,但这就是设计良好的库的美妙之处。它们应该保护我们免受复杂性的影响,同时为我们留出空间,以便在需要时进行更复杂的操作和定制。

这样,我们的图像分类组件就写好了。但是我们如何在 Vue 应用程序中使用它呢?在下一节中,我们将看到如何修改HelloWorld组件以使用这个类。

修改 HelloWorld 组件以支持图像分类

当我们创建 Vue 应用程序时,CLI 会为我们创建一个HelloWorld.vue文件,其中包含HelloWorld组件。我们将利用我们已经有这个组件的事实,并将其用于对预加载图像进行分类。如果我们愿意,我们可以使用它来使用文件上传组件加载图像,并在更改时驱动分类。

现在,让我们看看我们的HelloWorld TypeScript 代码是什么样子的。显然,我们将从类定义开始。就像我们之前看到的那样,我们已经用@Component装饰器标记了这个组件:

@Component export default class HelloWorld extends Vue {
}

我们有两个成员变量要在我们的类中声明。我们知道我们想要使用刚刚编写的ImageClassifier类,所以我们会引入它。我们还想创建一个TensorInformation结果数组,原因是我们将不得不在操作完成时绑定到它:

private readonly classifier: ImageClassifier = new ImageClassifier();  private tensors : TensorInformation[] | null = null;

在我们完成编写我们的类之前,我们需要看一下我们的模板会是什么样子。我们从template定义开始:

<template>
 <div class="container">
 </div> </template>

正如我们所看到的,我们正在使用 Bootstrap,所以我们将使用一个div容器来布置我们的内容。我们要添加到容器中的第一件事是一个图像。我选择在这里使用一组边境牧羊犬的图像,主要是因为我是狗的粉丝。为了我们能够在 TensorFlow 中读取这个图像,我们需要将crossorigin设置为anonymous。在这一部分中特别注意ref="dogId",因为我们很快会再次需要它:

<img crossorigin="anonymous" id="img" src="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQ0ucPLLnB4Pu1kMEs2uRZISegG5W7Icsb7tq27blyry0gnYhVOfg" alt="Dog" ref="dogId" >

在图像之后,我们将进一步添加 Bootstrap 支持,使用rowcol类:

<div class="row">  <div class="col">  </div>  </div>

在这一行内,我们将创建一个 Bootstrap 列表。我们看到 Vue 有自己的 Bootstrap 支持,所以我们将使用它的版本来支持列表,即b-list-group

<b-list-group>  </b-list-group>

现在,我们终于到了模板的实质部分。我们在类中公开张量数组的原因是为了在数组被填充时能够迭代每个结果。在下面的代码中,我们使用v-for动态创建了b-list-group-item的数量,以自动迭代每个张量项。这创建了b-list-group-item条目,但我们仍然需要显示单独的classNameprobability项。使用 Vue,我们使用{{ <<item>> }}来绑定文本项,比如这样:

<b-list-group-item v-for="tensor in tensors" v-bind:key="tensor.className">   {{ tensor.className }} - {{ tensor.probability }}
</b-list-group-item>

我们之所以在v-for旁边添加了v-bind:key,是因为 Vue 默认提供了所谓的原地修补。这意味着 Vue 使用这个键作为提示,以唯一地跟踪该项,以便在更改时保持值的最新状态。

就是这样;我们的模板完成了。正如我们所看到的,以下是一个简单的模板,但其中有很多内容。我们有一个 Bootstrap 容器显示一个图像,然后让 Vue 动态绑定tensor的细节:

<template>
 <div class="container">
 <img crossorigin="anonymous" id="img" src="https://encrypted-  
      tbn0.gstatic.com/imagesq=tbn:ANd9GcQ0ucPLLnB4Pu1kMEs2uRZ
      ISegG5W7Icsb7tq27blyry0gnYhVOfg" alt="Dog" ref="dogId" >
 <div class="row">
 <div class="col">
 <b-list-group>
 <b-list-group-item v-for="tensor in tensors" 
              v-bind:key="tensor.className">
  {{ tensor.className }} - {{ tensor.probability }}
          </b-list-group-item>
 </b-list-group>
 </div>
 </div>
 </div> </template>

回到我们的 TypeScript 代码,我们将编写一个方法,该方法获取图像,然后使用它调用我们的ImageClassifier.Classify方法:

public Classify(): void {
}

由于我们正在将图像加载到客户端上,我们必须等待页面呈现图像,以便我们可以检索它。我们将从构造函数中调用我们的Classify方法,因此在页面创建时运行,我们需要使用一个小技巧来等待图像加载。具体来说,我们将使用一个名为nextTick的 Vue 函数。重要的是要理解 DOM 的更新是异步发生的。当值发生变化时,更改不会立即呈现。相反,Vue 请求 DOM 更新,然后由计时器触发。因此,通过使用nextTick,我们等待下一个 DOM 更新时刻并执行相关操作:

public Classify(): void {   this.$nextTick().then(async () => {  });  }

我们在then块内标记async函数的原因是,我们将在此部分执行等待,这意味着我们也必须将其作为async范围。

在模板中,我们使用ref语句定义了我们的图像,因为我们希望从类内部访问它。为此,我们在这里查询 Vue 为我们维护的ref语句映射,由于我们已经设置了自己的引用为dogId,我们现在可以访问图像。这个技巧使我们不必使用getElementById来检索我们的 HTML 元素。

/* tslint:disable:no-string-literal */  const dog = this.$refs['dogId'];  /* tslint:enable:no-string-literal */

在构建 Vue 应用程序时,CLI 会自动为我们设置 TSLint 规则。其中一个规则涉及通过字符串字面量访问元素。我们可以使用tslint:disable:no-string-literal临时禁用该规则。要重新启用该规则,我们使用tslint:enable:no-string-literal。还有一种禁用此规则的替代方法是在单行上使用/* tslint:disable-next-line:no-string-literal */。您采取的方法并不重要;重要的是最终结果。

一旦我们有了对狗图片的引用,我们现在可以将图像转换为HTMLImageElement,并在ImageClassifier类中的Classify方法调用中使用它:

if (dog !== null && !this.tensors) {   const image = dog as HTMLImageElement;   this.tensors = await this.classifier.Classify(image);  }

Classify调用返回时,只要模型已加载并成功找到分类,它将通过绑定的力量填充我们的屏幕列表。

在我们的示例中,我尽量保持我们的代码库尽可能干净和简单。代码已分离为单独的类,以便我们可以创建小而强大的功能块。要了解为什么我喜欢这样做,这是我们的HelloWorld代码的样子:

@Component export default class HelloWorld extends Vue {
  private readonly classifier: ImageClassifier = new ImageClassifier();
  private tensors: TensorInformation[] | null = null;    constructor() {
  super();
  this.Classify();
 }  public Classify(): void {
  this.$nextTick().then(async () => {
  /* tslint:disable:no-string-literal */
  const dog = this.$refs['dogId'];
  /* tslint:enable:no-string-literal */
  if (dog !== null && !this.tensors) {
  const image = dog as HTMLImageElement;
  this.tensors = await this.classifier.Classify(image);
 } }); } }

总共,包括tslint格式化程序和空格,这段代码只有 20 行。我们的ImageClassifier类只有 22 行,这是一个可以在其他地方使用而无需修改的ImageClassifier类。通过保持类简单,我们减少了它们可能出错的方式,并增加了重用它们的机会。更重要的是,我们遵循了名为保持简单,愚蠢KISS)原则,该原则指出系统在本质上尽可能简单时效果最好。

现在我们已经看到图像分类的实际操作,我们可以考虑将姿势检测添加到我们的应用程序中。在这样做之前,我们需要看一下其他一些对我们重要的 Vue 领域。

Vue 应用程序入口点

我们还没有涉及的是 Vue 应用程序的入口点是什么。我们已经看到了Home.vue页面,但那只是一个在其他地方呈现的组件。我们需要退一步,看看我们的 Vue 应用程序实际上是如何处理加载自身并显示相关组件的。在这个过程中,我们还将涉及 Vue 中的路由,以便我们可以看到所有这些是如何联系在一起的。

我们的起点位于public文件夹内。在那里,我们有一个index.html文件,我们可以将其视为应用程序的主模板。这是一个相当标准的 HTML 文件-我们可能希望给它一个更合适的title(在这里,我们选择Advanced TypeScript - Machine Learning):

<!DOCTYPE html> <html lang="en">
 <head>
 <meta charset="utf-8">
 <meta http-equiv="X-UA-Compatible" content="IE=edge">
 <meta name="viewport" content="width=device-width,
      initial-scale=1.0">
 <link rel="icon" href="<%= BASE_URL %>favicon.ico">
 <title>Advanced TypeScript - Machine Learning</title>
 </head>
 <body>
 <noscript>
 <strong>We're sorry but chapter09 doesn't work properly without 
        JavaScript enabled. Please enable it to continue.</strong>
 </noscript>
 <div id="app"></div>
  <!-- built files will be auto injected -->
  </body> </html>

这里的重要元素是div,其id属性设置为app。这是我们将要呈现组件的元素。我们控制这个的方式是从main.ts文件中进行的。让我们首先通过添加 Bootstrap 支持来添加 Bootstrap 支持,既通过添加 Bootstrap CSS 文件,又通过使用Vue.use注册BootstrapVue插件:

import 'bootstrap/dist/css/bootstrap.css'; import 'bootstrap-vue/dist/bootstrap-vue.css';  Vue.use(BootstrapVue); 

尽管我们已经有了 Bootstrap 支持,但我们没有任何东西将我们的组件连接到app div。我们添加此支持的原因是创建一个新的 Vue 应用程序。这接受一个路由器,一个用于包含 Vue 状态和突变等内容的 Vue 存储库,以及一个render函数,在呈现组件时调用。传递给我们的render方法的App组件是我们将用于呈现所有其他组件的顶级App组件。当 Vue 应用程序创建完成时,它被挂载到index.html中的app div 中:

new Vue({
  router,
  store,
  render: (h) => h(App), }).$mount('#app'); 

我们的App.vue模板由两个独立的区域组成。在添加这些区域之前,让我们定义template元素和包含的div标签:

<template>
 <div id="app">
  </div>
</template>

在这个div标签中,我们将添加我们的第一个逻辑部分——我们的老朋友,导航栏。由于这些来自 Vue Bootstrap 实现,它们都以b-为前缀,但现在不需要解剖它们,因为到这一点它们应该非常熟悉:

<b-navbar toggleable="lg" type="dark" variant="info">  <b-collapse id="nav-collapse" is-nav>  <b-navbar-nav>  <b-nav-item to="/">Classifier</b-nav-item>  <b-nav-item to="/pose">Pose</b-nav-item>  </b-navbar-nav>  </b-collapse>  </b-navbar>

用户导航到页面时,我们需要显示适当的组件。在幕后,显示的组件由 Vue 路由器控制,但我们需要一个地方来显示它。这是通过在我们的导航栏下方使用以下标签来实现的:

<router-view/>

这是我们的App模板完成后的样子。正如我们所看到的,如果我们想要路由到其他页面,我们需要将单独的b-nav-item条目添加到此列表中。如果我们愿意,我们可以使用v-for以类似的方式动态创建这个导航列表,就像我们在构建图像分类器视图时看到的那样:

<template>
 <div id="app">
 <b-navbar toggleable="lg" type="dark" variant="info">
 <b-collapse id="nav-collapse" is-nav>
 <b-navbar-nav>
 <b-nav-item to="/">Classifier</b-nav-item>
 <b-nav-item to="/pose">Pose</b-nav-item>
 </b-navbar-nav>
 </b-collapse>
 </b-navbar>
 <router-view/>
 </div> </template>

当我们开始研究路由时,可能会认为将路由添加到我们的应用程序是一件非常复杂的事情。到现在为止,你应该对路由更加熟悉了,而且不会感到惊讶,因为在 Vue 中添加路由支持是直接而简单的。我们首先通过以下命令在 Vue 中注册Router插件:

Vue.use(Router);

有了这个,我们现在准备构建路由支持。我们导出一个Router的实例,可以在我们的new Vue调用中使用:

export default new Router({ });

现在我们需要添加我们的路由选项。我们要设置的第一个选项是路由模式。我们将使用 HTML5 history API 来管理我们的链接:

mode: 'history',

我们可以使用 URL 哈希进行路由。这在 Vue 支持的所有浏览器中都可以工作,并且如果 HTML5 history API 不可用,则是一个不错的选择。或者,还有一种抽象的路由模式,可以在包括 Node 在内的所有 JavaScript 环境中工作。如果浏览器 API 不存在,无论我们将模式设置为什么,路由器都将自动强制使用这个模式。

我们想要使用history API 的原因是它允许我们修改 URL 而不触发整个页面的刷新。由于我们知道我们只想替换组件,而不是替换整个index.html页面,我们最终利用这个 API 只重新加载页面的组件部分,而不进行整个页面的重新加载。

我们还想设置应用程序的基本 URL。如果我们想要覆盖此位置以从deploy文件夹中提供所有内容,那么我们将其设置为/deploy/

base: process.env.BASE_URL,

虽然设置路由模式和基本 URL 都很好,但我们错过了重要的部分——设置路由本身。每个路由至少包含一个路径和一个组件。路径与 URL 中的路径相关联,组件标识将作为该路径结果显示的组件。我们的路由看起来像这样:

routes: [  {   path: '/',   name: 'home',   component: Home,  },  {   path: '/pose',   name: 'Pose',   component: Pose,  }, {
    path: '*',
    component: Home,
  } ],

我们的路由中有一个特殊的路径匹配。如果用户输入一个不存在的 URL,那么我们使用*来捕获它,并将其重定向到特定的组件。我们必须将其放在最后一个条目,否则它将优先于精确匹配。敏锐的读者会注意到,严格来说,我们不需要第一个路径,因为我们的路由仍然会显示Home组件,因为我们的*回退。

我们在路由中添加了一个指向尚不存在的组件的引用。现在我们将通过添加Pose组件来解决这个问题。

添加姿势检测功能

在开始处理姿势检测之前,我们将添加一个组件,该组件将承载相关功能。由于这是我们第一个从头开始的组件,我们也将从头开始介绍它。在我们的views文件夹中,创建一个名为Pose.vue的文件。这个文件将包含三个逻辑元素,所以我们将首先添加这些元素,并设置我们的模板以使用 Bootstrap:

<template>
  <div class="container">
  </div>
</template>
<script lang="ts">
</script>
<style scoped>
</style>

到目前为止,我们还没有看过的是style部分。作用域样式允许我们应用仅适用于当前组件的样式。我们很快将应用本地样式,但首先,我们需要设置要显示的图像。

对于我们的示例代码,我选择了一张宽 1200 像素,高 675 像素的图片。这些信息很重要,因为当我们进行姿势检测时,我们将在图像上绘制这些点,这意味着我们需要进行一些样式安排,以便在图像上放置一个画布,我们可以在上面绘制与图像上的位置匹配的点。我们首先使用两个容器来容纳我们的图像:

<div class="outsideWrapper">  <div class="insideWrapper">  </div>
</div>

我们现在要在我们的样式作用域部分添加一些 CSS 来固定尺寸。我们首先设置外部包装器的尺寸,然后相对于外部包装器定位我们的内部包装器,并将宽度和高度设置为 100%,以便它们完全填充边界:

.outsideWrapper{   width:1200px; height:675px;  }  .insideWrapper{   width:100%; height:100%;   position:relative;  }

回到insideWrapper,我们需要在其中添加我们的图像。我选择的示例图像是一个中性姿势,显示了关键身体点。我们的图像标签的格式应该看起来很熟悉,因为我们已经用图像分类代码做过这个:

<img crossorigin="anonymous" class="coveredImage" id="img" src="https://www.yogajournal.com/.image/t_share/MTQ3MTUyNzM1MjQ1MzEzNDg2/mountainhp2_292_37362_cmyk.jpg" alt="Pose" ref="poseId" >

在相同的insideWrapper div标签中,就在我们的图像下面,我们需要添加一个画布。当我们想要绘制关键身体点时,我们将使用这个画布。关键是画布的宽度和高度与容器的尺寸完全匹配:

<canvas ref="posecanvas" id="canvas" class="coveringCanvas" width=1200 height=675></canvas>

在这一点上,我们的template看起来像这样:

<template>
 <div class="container">
 <div class="outsideWrapper">
 <div class="insideWrapper">
 <img crossorigin="anonymous" class="coveredImage" 
          id="img" src="https://www.yogajournal.com/.image/t_share/
          MTQ3MTUyNzM1MjQ1MzEzNDg2/mountainhp2_292_37362_cmyk.jpg" 
          alt="Pose" ref="poseId" >
 <canvas ref="posecanvas" id="canvas" 
          class="coveringCanvas" width="1200" height="675"></canvas>
 </div>
 </div>
 </div> </template> 

我们已经为图像和画布添加了类,但我们还没有添加它们的定义。我们可以使用一个类来覆盖两者,但我对我们分别设置宽度和高度为 100%的类感到满意,并将它们绝对定位在容器内部:

.coveredImage{   width:100%; height:100%;   position:absolute; 
  top:0px; 
  left:0px;  }  .coveringCanvas{   width:100%; height:100%;   position:absolute; 
  top:0px; left:0px;  }

我们完成后,样式部分将如下所示:

<style scoped>
 .outsideWrapper{
  width:1200px; height:675px;
 } .insideWrapper{
  width:100%; height:100%;
  position:relative;
 } .coveredImage{
  width:100%; height:100%;
  position:absolute; 
 top:0px; 
 left:0px;
 } .coveringCanvas{
  width:100%; height:100%;
  position:absolute; 
 top:0px; 
 left:0px;
 } </style> 

在这一点上,我们需要编写一些辅助类——一个用于进行姿势检测,另一个用于在图像上绘制点。

在画布上绘制关键点

每当我们检测到一个姿势,我们都会得到一些关键点。每个关键点由位置(xy坐标)、分数(或置信度)和关键点表示的实际部分组成。我们希望循环遍历这些点并在画布上绘制它们。

一如既往,让我们从我们的课程定义开始:

export class DrawPose { }

我们只需要获取一次画布元素,因为它不会改变。这表明我们可以将这个作为我们的画布,因为我们对画布的二维元素感兴趣,我们可以直接从画布中提取绘图上下文。有了这个上下文,我们清除画布上以前绘制的任何元素,并将fillStyle颜色设置为#ff0300,我们将用它来填充我们的姿势点:

constructor(private canvas: HTMLCanvasElement, private context = canvas.getContext('2d')) {   this.context!.clearRect(0, 0, this.canvas.offsetWidth, this.canvas.offsetHeight);   this.context!.fillStyle = '#ff0300';  }

为了绘制我们的关键点,我们编写一个方法,循环遍历每个Keypoint实例,并调用fillRect来绘制点。矩形从xy坐标偏移 2.5 像素,以便绘制一个 5 像素的矩形实际上是在点的大致中心绘制一个矩形:

public Draw(keys: Keypoint[]): void {   keys.forEach((kp: Keypoint) => {   this.context!.fillRect(kp.position.x - 2.5, 
                           kp.position.y - 2.5, 5, 5);  });  }

完成后,我们的DrawPose类如下所示:

export class DrawPose {
  constructor(private canvas: HTMLCanvasElement, private context = 
    canvas.getContext('2d')) {
  this.context!.clearRect(0, 0, this.canvas.offsetWidth, 
        this.canvas.offsetHeight);
  this.context!.fillStyle = '#ff0300';
 }    public Draw(keys: Keypoint[]): void {
  keys.forEach((kp: Keypoint) => {
  this.context!.fillRect(kp.position.x - 2.5, 
                             kp.position.y - 2.5, 5, 5);
 }); } }

在图像上使用姿势检测

之前,我们创建了一个ImageClassifier类来执行图像分类。为了保持这个类的精神,我们现在要编写一个PoseClassifier类来管理物理姿势检测:

export class PoseClassifier {
}

我们将为我们的类设置两个私有成员。模型是一个PoseNet模型,在调用相关的加载方法时将被填充。DrawPose是我们刚刚定义的类:

private model: PoseNet | null = null;  private drawPose: DrawPose | null = null;

在我们进一步进行姿势检测代码之前,我们应该开始了解姿势检测是什么,它适用于什么,以及一些约束是什么。

关于姿势检测的简要说明

我们在这里使用术语姿势检测,但这也被称为姿势估计。如果你还没有接触过姿势估计,这简单地指的是计算机视觉操作,其中检测到人物形象,无论是从图像还是视频中。一旦人物被检测到,模型就能大致确定关键关节和身体部位(如左耳)的位置。

姿势检测的增长速度很快,它有一些明显的用途。例如,我们可以使用姿势检测来进行动作捕捉以制作动画;工作室越来越多地转向动作捕捉,以捕捉现场表演并将其转换为 3D 图像。另一个用途在体育领域;事实上,体育运动有许多潜在的动作捕捉用途。假设你是一支大联盟棒球队的投手。姿势检测可以用来确定在释放球时你的站姿是否正确;也许你倾斜得太远,或者你的肘部位置不正确。有了姿势检测,教练们更容易与球员合作纠正潜在问题。

在这一点上,值得注意的是,姿势检测并不等同于人物识别。我知道这似乎很明显,但有些人被这项技术所困惑,以为这种技术可以识别一个人是谁。那是完全不同的机器学习形式。

PoseNet 是如何工作的?

即使使用基于摄像头的输入,执行姿势检测的过程也不会改变。我们从输入图像开始(视频的一个静止画面就足够了)。图像通过 CNN 进行第一部分处理,识别场景中人物的位置。下一步是将 CNN 的输出传递给姿势解码算法(我们稍后会回到这一点),并使用它来解码姿势。

我们之所以说姿势解码算法是为了掩盖我们实际上有两个解码算法的事实。我们可以检测单个姿势,或者如果有多个人,我们可以检测多个姿势。

我们选择了单姿势算法,因为它是更简单和更快的算法。如果图片中有多个人,算法有可能将不同人的关键点合并在一起;因此,遮挡等因素可能导致算法将人 2 的右肩检测为人 1 的左肘。在下面的图片中,我们可以看到右侧女孩的肘部遮挡了中间人的左肘:

遮挡是指图像的一部分遮挡了另一部分。

PoseNet检测到的关键点如下:

  • 鼻子

  • 左眼

  • 右眼

  • 左耳

  • 右耳

  • 左肩

  • 右肩

  • 左肘

  • 右肘

  • 左腕

  • 右腕

  • 左臀

  • 右臀

  • 左膝

  • 右膝

  • 左踝

  • 右踝

我们可以看到它们在我们的应用程序中的位置。当它完成检测点时,我们会得到一组图像叠加,如下所示:

回到我们的姿势检测代码

回到我们的PoseClassifier类,我们的构造函数处理了与我们的ImageClassifier实现讨论过的完全相同的 WebGLTexture 问题:

constructor() {   // If running on Windows, there can be issues 
  // loading WebGL textures properly.  // Running the following command solves this.   tf.ENV.set('WEBGL_PACK', false);  }

我们现在要编写一个异步的Pose方法,它会返回一个Keypoint项的数组,或者如果PoseNet模型加载失败或找不到任何姿势,则返回null。除了接受图像,这个方法还将接受提供上下文的画布,我们将在上面绘制我们的点:

public async Pose(image: HTMLImageElement, canvas: HTMLCanvasElement): Promise<Keypoint[] | null> {   return null;  }

就像ImageClassifier检索MobileNet模型一样,我们将检索PoseNet模型并对其进行缓存。我们将利用这个机会来实例化DrawPose实例。执行这样的逻辑是为了确保这是我们只做一次的事情,无论我们调用这个方法多少次。一旦模型不为空,代码就会阻止我们尝试再次加载PoseNet

if (!this.model) {   this.model = await posenet.load();   this.drawPose = new DrawPose(canvas);  }

当我们加载模型时,我们可以提供以下选项:

  • Multiplier:这是所有卷积操作的通道数(深度)的浮点乘数。可以选择 1.01、1.0、0.75 或 0.50。这里有速度和准确性的权衡,较大的值更准确。

最后,如果模型成功加载,我们将使用我们的图像调用estimateSinglePose来检索Pose预测,其中还包含我们将绘制的keypoints

if (this.model) {   const result: Pose = await this.model.estimateSinglePose(image);   if (result) {   this.drawPose!.Draw(result.keypoints);   return result.keypoints;  }  }

再次将所有这些放在一起,以展示我们不必写大量代码来完成所有这些工作,以及将代码分离成小的、自包含的逻辑块,使我们的代码更容易理解,也更容易编写。这是完整的PoseClassifier类:

export class PoseClassifier {
  private model: PoseNet | null = null;
  private drawPose: DrawPose | null = null;
  constructor() {
  // If running on Windows, there can be 
    // issues loading WebGL textures properly.
 // Running the following command solves this.  tf.ENV.set('WEBGL_PACK', false);
 }    public async Pose(image: HTMLImageElement, canvas: 
    HTMLCanvasElement): Promise<Keypoint[] | null> {
  if (!this.model) {
  this.model = await posenet.load();
  this.drawPose = new DrawPose(canvas);
 }    if (this.model) {
  const result: Pose = await 
             this.model.estimateSinglePose(image);
  if (result) {
  this.drawPose!.Draw(result.keypoints);
  return result.keypoints;
 } }  return null;
 } }

完成我们的姿势检测组件

回到我们的Pose.vue组件,现在我们需要填写script部分。我们需要以下import语句和组件的类定义(记住我承诺过我们会从头开始构建这个类)。同样,我们可以看到使用@Component来给我们一个组件注册。我们在 Vue 组件中一次又一次地看到这一点:

import { Component, Vue } from 'vue-property-decorator';  import {PoseClassifier} from '@/Models/PoseClassifier';  import {Keypoint} from '@tensorflow-models/posenet';  @Component  export default class Pose extends Vue {
}

我们已经到了可以编写我们的Classify方法的地步,当图像和画布被创建时,它将检索图像和画布,并将其传递给PoseClassifier类。我们需要一些私有字段来保存PoseClassifier实例和返回的Keypoint数组:

private readonly classifier: PoseClassifier = new PoseClassifier();  private keypoints: Keypoint[] | null;

在我们的Classify代码中,我们将使用相同的生命周期技巧,在检索名为poseId的图像引用和名为posecanvas的画布之前等待nextTick

public Classify(): void {   this.$nextTick().then(async () => {   /* tslint:disable:no-string-literal */   const pose = this.$refs['poseId'];   const poseCanvas = this.$refs['posecanvas'];   /* tslint:enable:no-string-literal */  });  }

一旦我们有了图像引用,我们将它们转换为适当的HTMLImageElementHTMLCanvasElement类型,然后调用Pose方法,并用结果值填充我们的keypoints成员:

if (pose !== null) {   const image: HTMLImageElement = pose as HTMLImageElement;   const canvas: HTMLCanvasElement = poseCanvas as HTMLCanvasElement   this.keypoints = await this.classifier.Pose(image, canvas);  }

在这一点上,我们可以运行应用程序。看到keypoints结果叠加在图像上非常令人满意,但我们可以做得更多。只需稍加努力,我们就可以在 Bootstrap 表格中显示keypoints结果。返回到我们的模板,并添加以下div语句以在图像下方添加 Bootstrap 行和列:

<div class="row">  <div class="col">  </div>  </div>

由于我们已经暴露了keypoints结果,我们可以简单地使用b-table创建一个 Vue Bootstrap 表格。我们使用:items将绑定设置为我们在类中定义的keypoints结果。这意味着每当keypoints条目获得新值时,表格将更新以显示这些值。

<b-table striped hover :items="keypoints"></b-table>

刷新我们的应用程序会在图像下方添加表格,表格如下所示:

虽然这是一个合理的开始,但如果我们能更多地控制表格就更好了。目前,b-table自动捕捉并格式化字段。通过小小的改变,我们可以将Position实例分离为两个单独的条目,并使ScorePart字段可排序。

在我们的Pose类中,我们将创建一个fields条目。fields条目将分数条目映射到Confidence标签,并将其设置为sortablepart字段映射到Partlabel值,并且也设置为sortable。我们将position分为两个单独的映射条目,分别标记为XY

private fields =  {'score':  { label: 'Confidence', sortable: true},   'part':  { label: 'Part', sortable: true},   'position.x':  {label:'X'},   'position.y': {label: 'Y'}};

我们需要做的最后一件事是将fields输入连接到b-table。我们可以使用:fields属性来实现这一点,就像这样:

<b-table striped hover :items="keypoints" :fields="fields"></b-table>

刷新我们的应用程序会显示这些微小更改的效果。这是一个更具吸引力的屏幕,用户可以轻松地对Confidence(原名score)和Part字段进行排序,这显示了 Vue 的强大之处:

就是这样——我们已经介绍了 TensorFlow 和 Vue。我们避开了 CNN 背后的数学方面,因为尽管乍一看可能令人生畏,但实际上并没有那么糟糕,但典型的 CNN 有很多部分。Vue 还有很多功能可以使用;对于一个如此小的库来说,它非常强大,这种小巧和强大的组合是它变得越来越受欢迎的原因之一。

总结

在本章中,我们迈出了使用流行的TensorFlow.js库编写机器学习应用程序的第一步。除了了解机器学习是什么,我们还看到了它如何适用于人工智能领域。虽然我们编写了类来连接到MobileNet和姿势检测库,但我们也介绍了 CNN 是什么。

除了研究TensorFlow.js,我们还开始了使用 Vue.js 的旅程,这是一个正在迅速赢得人气的客户端库,与 Angular 和 React 并驾齐驱。我们看到了如何使用.vue文件,以及如何将 TypeScript 与 Web 模板结合使用,包括使用 Vue 的绑定语法。

在下一章中,我们将迈出一大步,看看如何将 TypeScript 与 ASP.NET Core 结合起来,构建一个将 C#与 TypeScript 结合的音乐库。

问题

  1. TensorFlow 最初是用哪些语言发布的?

  2. 什么是监督式机器学习?

  3. 什么是MobileNet

  4. 默认情况下,我们会返回多少个分类?

  5. 我们用什么命令来创建 Vue 应用程序?

  6. 我们如何在 Vue 中表示一个组件?

进一步阅读

Packt 有大量关于 TensorFlow 的书籍和视频,如果您想提高对 TensorFlow 的了解。这些书籍不仅限于TensorFlow.js,因此涵盖了与 TensorFlow 最初实现相关的各种主题。以下是我推荐的一些书籍:

除了 TensorFlow,我们还研究了使用 Vue,因此以下内容也将有助于进一步提高您的知识:

第十章:构建一个 ASP.NET Core 音乐库

这一章标志着我们的方向发生了变化。在之前的章节中,我们集中使用 TypeScript 作为我们的主要开发语言。在这一章中,我们将看看如何在 Microsoft 的 ASP.NET Core 中使用 TypeScript,以学习如何混合 ASP.NET Core、C#和 TypeScript,制作一个艺术家搜索程序,我们可以搜索音乐家并检索有关他们音乐的详细信息。

本章将涵盖以下主题:

  • 安装 Visual Studio

  • 理解为什么我们有 ASP.NET Core MVC

  • 创建一个 ASP.NET Core 应用程序

  • 理解为什么我们有Program.csStartup.cs

  • 向 ASP.NET 应用程序添加 TypeScript 支持

  • 在 TypeScript 中使用fetch promise

技术要求

本章需要.NET Core Framework 版本 2.1 或更高版本。安装这个框架的最简单方法是下载并安装 Visual Studio;微软提供了一个功能齐全的社区版,你可以在visualstudio.microsoft.com/downloads/获取。

完成的项目可以从github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter10下载。

.NET 应用程序通常不使用npm来下载包;相反,它们使用 NuGet 来管理.NET 包。构建源代码将自动下载包。

介绍 ASP.NET Core MVC

微软在 Web 框架方面有着悠久而相对坎坷的历史。我在 20 世纪 90 年代末开始开发基于服务器的应用程序,使用的是他们的Active Server Pages技术,现在被称为经典的ASP。这项技术允许开发人员根据用户请求创建动态网页,并将生成的网页发送回客户端。这项技术需要一个特殊的Internet Information ServicesIIS)插件才能工作,因此它完全基于 Windows,并且是专有的 VBScript 语言和 HTML 的奇怪混合。这意味着我们经常看到这样的代码:

<%
Dim connection
Set connection = Server.CreateObject("ADODB.Connection")
Response.Write "The server connection has been created for id " & Request.QueryString("id")
%>
<H1>Hello World</H1>

语言非常冗长,用于将动态内容与 HTML 混合,底层类型不安全,这意味着使用 ASP 进行开发特别容易出错,调试也具有挑战性,至少可以这么说。

ASP 演变的下一步正式发布于 2002 年,被称为 ASP.NET(或 ASP.NET Web Forms)。这是基于微软的新.NET 框架,彻底改变了我们构建 Web 应用程序的方式。使用这个,我们可以使用 C#或 VB.NET 等语言构建应用程序,并在我们的网页中组合用户控件,以创建小型的独立组件,可以插入我们的网页中。这是微软的一个很大的进步,但仍然存在一些根本性的问题,人们花了很多时间来解决。最大的问题是网页本质上与逻辑混合在一起,因为实际的服务器端实现是使用代码后台处理的。还有一个严格的页面编译周期,所以默认的架构是基于客户端和服务器之间会有一个往返。同样,这可以被解决(并经常被解决),但作为默认的架构,它还有很多不足之处。此外,这项技术与 Windows 平台绑定,因此它没有达到它本应有的影响力。尽管.NET 和 C#被标准化,以便可以创建其他实现,但 Web Forms 是一项专有技术。

认识到 Web Forms 模型的局限性,微软内部的一个团队决定研究一种形式的 ASP,它将不再受限于 Web Forms 的代码后端限制。这是一个重大进步,因为它使架构对开发者更加开放,使他们能够更好地遵循面向对象的最佳实践,包括关注点分离。突然之间,微软给开发者提供了一个开发遵循 SOLID 设计原则的应用程序的机会。这个框架被称为 ASP.NET MVC,它允许我们开发遵循模型视图控制器(MVC)模式的应用程序。这是一个强大的模式,因为它允许我们将代码分离到单独的逻辑区域中。MVC 代表以下内容:

  • 模型:这是代表驱动应用程序行为的逻辑的业务层

  • 视图:这是用户看到的显示

  • 控制器:这处理输入和交互

以下图表显示了 MVC 模式中的交互:

这种架构对于我们想要开发全栈 Web 应用程序又是又一个重大进步;然而,它仍然存在一个问题,即它依赖于 Windows 来托管。

间接地,从这个图表中,我们可以得出 ASP.NET 代表在客户端和服务器上都运行的代码。这意味着我们不需要运行服务器端的 Node 实例,因此我们可以利用.NET 堆栈的功能和特性来构建这个架构。

让很多人感到惊讶的是,微软开始将注意力从长期以来被视为公司摇钱树的 Windows 转向更开放的模式,应用程序运行的操作系统变得不那么重要。这反映了其核心优先事项的转变,云操作,通过其出色的 Azure 产品,已经成为了重点。如果微软继续沿着原有的 Web 架构发展,那么它将错失许多正在开放的机会;因此,它开始了一个多年的.NET Framework 重新架构,以消除对 Windows 的依赖,并使其对使用者来说是平台无关的。

这导致微软发布了 ASP.NET Core MVC,它完全消除了对 Windows 的依赖。现在,我们可以从一个代码库中同时针对 Windows 或 Linux 进行目标设置。突然之间,我们可以托管我们的代码的服务器数量激增,运行服务器的成本可能会下降。与此同时,随着微软发布的每个连续版本的 Core,他们都在调整和优化性能,以在请求服务器统计数据中提供相当大的提升。此外,我们可以免费开发这些应用程序,并且也可以针对 Linux 进行托管,这意味着这项技术对初创公司来说更加令人兴奋。我完全期待,在未来几年,随着成本障碍的降低,加入 ASP.NET Core MVC 阵营的初创公司数量将显著增加。

提供项目概述

本章我们正在构建的项目与我们迄今为止编写的任何项目都大不相同。这个项目让我们远离了纯 TypeScript,转而使用混合编程语言,即 C#和 TypeScript,我们将看到如何将 TypeScript 整合到 ASP.NET Core Web 应用程序中。该应用程序本身使用 Discogs 音乐 API,以便用户可以搜索艺术家并检索其唱片和艺术作品的详细信息。搜索部分使用纯 ASP.NET 和 C#完成,而艺术品检索则使用 TypeScript 完成。

只要您在 GitHub 存储库中与代码一起工作,本章应该需要大约 3 小时才能完成,当我们一起尝试代码时,这看起来不会太多!完成的应用程序将如下所示:

所以,让我们开始吧!

使用 ASP.NET Core,C#和 TypeScript 创建音乐库的入门

我是一个音乐迷。我弹吉他已经很多年了,这导致我听了很多音乐家的音乐。跟踪他们所创作的所有音乐可能是一个非常复杂的任务,所以我一直对公开可用的 API 感兴趣,让我们可以搜索所有与音乐家相关的事物。我认为提供给我们最广泛选择的查询专辑、艺术家、曲目等的公共 API 是 Discog 库。

在本章中,我们将利用这个 API,并编写一个应用程序,利用 ASP.NET Core 来展示我们如何可以协同使用 C#和 TypeScript。

为了运行这个应用程序,您需要在 Discogs 上设置一个账户,如下所示:

  1. www.discogs.com/users/create开始注册一个账户。

  2. 虽然我们可以创建一个 Discogs API 应用程序,特别是如果我们想要利用身份验证和访问完整 API 等功能,但我们只需要通过点击生成令牌按钮来生成个人访问令牌,如下面的截图所示:

现在我们已经注册了 Discogs 并生成了我们的令牌,我们准备创建我们的 ASP.NET Core 应用程序。

使用 Visual Studio 创建我们的 ASP.NET Core 应用程序

在之前的章节中,我们是通过命令行创建我们的应用程序的。然而,使用 Visual Studio,通常的做法是通过可视化方式创建我们的应用程序。

让我们看看这是如何完成的:

  1. 打开 Visual Studio 并选择创建新项目以开始创建新项目的向导。我们将创建一个 ASP.NET Core Web 应用程序,如下所示:

较早版本的.NET 只能在 Windows 平台上运行。虽然.NET 是一个很好的框架,C#是一种很棒的语言,但这种缺乏跨平台能力意味着.NET 只受到拥有 Windows 桌面或 Windows 服务器的公司的青睐。一段时间以前,微软决定解决这个缺陷,通过将.NET 剥离并重新架构成可以跨平台运行的东西。这极大地扩展了.NET 的影响力,被称为.NET Core。对我们来说,这意味着我们可以在一个平台上开发,并将我们的应用程序部署到另一个平台上。在内部,.NET Core 应用程序有特定于平台的代码,这些代码被隐藏在一个单一的.NET API 后面,所以,例如,我们可以进行文件访问而不必担心底层操作系统如何处理文件。

  1. 我们需要选择我们将放置代码的位置。我的本地 Git 仓库位于E:\Packt\AdvancedTypeScript3下,所以将其作为我的位置告诉 Visual Studio 在该目录下的一个文件夹中创建必要的文件。在这种情况下,Visual Studio 将创建一个名为Chapter10的解决方案,其中包含我们所有的文件。点击创建以创建所有我们需要的文件:

  1. 一旦 Visual Studio 完成创建我们的解决方案,应该会有以下文件可用。在我们开发应用程序的过程中,我们将讨论更重要的文件,并看看我们如何使用它们:

  1. 我们也可以构建和运行我们的应用程序(按下F5即可),应用程序会像这样启动:

创建了我们的应用程序后,在下一节中,我们将涵盖生成的代码的重要点,首先从启动和程序文件开始,然后再开始修改它并引入我们的搜索功能。

了解应用程序结构

行为方面,我们应用程序的起点是Startup类。这个文件的目的是在启动过程中设置系统,因此我们要处理配置应用程序如何处理 cookie 以及添加 HTTP 支持等功能。虽然这个类在功能上大部分是样板代码,但我们以后会回来添加对我们即将编写的 Discogs 客户端的支持。问题是,这个功能是从哪里调用的?实际上是什么启动了我们的物理应用程序?这些问题的答案是Program类。如果我们快速分解这段代码,我们会看到启动功能是如何引入的,以及它如何帮助构建我们的托管应用程序。

.NET 可执行应用程序以Main方法开始。有时,这对开发人员是隐藏的,但总会有一个。这是可执行应用程序的标准入口点,我们的 Web 应用程序也不例外。这个静态方法简单地调用CreateWebHostBuilder方法,传入任何命令行参数,然后调用 Build 和 Run 来构建主机并运行它:

public static void Main(string[] args)
{
  CreateWebHostBuilder(args).Build().Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
  WebHost.CreateDefaultBuilder(args)
    .UseStartup<Startup>();

这里的=>的使用方式不同于使用 fat arrow。在这个特定的上下文中,它所做的是替换return关键字,所以如果你有一个只有一个return操作的方法,这可以简化。等效的代码,包括return语句,看起来像这样:

public static IWebHostBuilder CreateWebHostBuilder(string[] args)
{
   return WebHost.CreateDefaultBuilder(args).UseStartup<Startup>();
}

CreateDefaultBuilder用于配置我们的服务主机,设置 Kestrel web 引擎、加载配置信息和设置日志支持等选项。UseStartup方法告诉默认构建器,我们的Startup类是需要用来启动服务的。

启动类

那么,我们的Startup类实际上是什么样子的呢?嗯,在与我们使用 TypeScript 开发的方式类似的方式中,C#从类定义开始:

public class Startup
{
}

与 JavaScript 不同,C#没有特殊的constructor关键字。相反,C#使用类的名称来表示构造函数。请注意,就像 JavaScript 一样,当我们创建构造函数时,我们不给它一个返回类型(我们很快就会看到 C#如何处理返回类型)。我们的构造函数将接收一个配置条目,以允许我们读取配置。我们使用以下get;属性将其公开为 C#属性:

public Startup(IConfiguration configuration)
{
  Configuration = configuration;
}
public IConfiguration Configuration { get; }

当运行时启动我们的主机进程时,将调用ConfigureServices方法。这是我们需要挂接任何服务的地方;在这段代码中,我添加了一个IDiscogsClient/DiscogsClient注册,这将这个特定组合添加到 IoC 容器中,以便我们以后可以将其注入到其他类中。我们已经在这个类中看到了依赖注入的一个例子,配置被提供给构造函数。

不要担心我们还没有看到IDiscogsClientDiscogsClient。我们很快就会在我们的代码中添加这个类和接口。在这里,我们正在将它们注册到服务集合中,以便它们可以自动注入到类中。正如你可能还记得我们在本书前面所说的,单例将只给出一个类的实例,无论它在哪里使用。这与我们在 Angular 中生成服务时非常相似,我们在那里将服务注册为单例:

public void ConfigureServices(IServiceCollection services)
{
  services.Configure<CookiePolicyOptions>(options =>
  {
    options.CheckConsentNeeded = context => true;
    options.MinimumSameSitePolicy = SameSiteMode.None;
  });

  services.AddHttpClient();
  services.AddSingleton<IDiscogsClient, DiscogsClient>();
  services.AddMvc().SetCompatibilityVersion(
    CompatibilityVersion.Version_2_1);
}

这里需要注意的是,设置返回类型的位置与 TypeScript 不同。就像我们在 TypeScript 中看到的那样,我们在方法声明的最后设置返回类型。在 C#中,返回类型在名称之前设置,所以我们知道ConfigureServices有一个void返回类型。

AddSingleton上的语法显示了 C#也支持泛型,所以这个语法对我们来说不应该是可怕的。虽然语言中有很多相似之处,但 TypeScript 在这里有一些有趣的差异,例如没有专门的anynever类型。如果我们想让我们的 C#类型做类似于any的事情,它将不得不使用object类型。

现在基础服务已经配置好,这个类的最后一步是配置 HTTP 请求管道。这只是告诉应用程序如何响应 HTTP 请求。在这段代码中,我们可以看到我们已经启用了静态文件的支持。这对我们非常重要,因为我们将依赖静态文件支持来连接我们的 TypeScript(编译后的 JavaScript 版本)以便与我们的 C#应用程序共存。我们还可以看到我们的请求已经设置了路由:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
  if (env.IsDevelopment())
  {
    app.UseDeveloperExceptionPage();
  }
  else
  {
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
  }

  app.UseHttpsRedirection();
  app.UseStaticFiles();
  app.UseCookiePolicy();

  app.UseMvc(routes =>
  {
    routes.MapRoute(
              name: "default",
              template: "{controller=Home}/{action=Index}/{id?}");
  });
}

创建 C#基础设施来启动我们的应用程序是很好的,但如果我们没有任何东西可以显示,那么我们就是在浪费时间。现在是时候看看将要提供的基本文件了。

组成基本视图的文件

我们视图的入口是特殊的_ViewStart.cshtml文件。这个文件定义了应用程序将显示的通用布局。我们不直接向这个文件添加内容,而是将内容放在一个名为_Layout.cshtml的文件中,并在设置Layout文件时引用这个文件(去掉文件扩展名)。

@{
    Layout = "_Layout";
}

.cshtml结尾的文件对 ASP.NET 有特殊的意义。这告诉应用程序这些文件是 C#和 HTML 的组合,底层引擎在将结果提供给浏览器之前必须编译。我们现在应该对这个概念非常熟悉了,因为我们在 React 和 Vue 中看到了类似的行为。

现在我们已经涵盖了视图入口,我们需要考虑_Layout本身。默认的 ASP.NET 实现目前使用的是 Bootstrap 3.4.1,因此在浏览这个文件时,我们将进行必要的更改以使用 Bootstrap 4。让我们从当前的标题开始:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, 
      initial-scale=1.0" />
    <title>@ViewData["Title"] - Chapter10</title>

    <environment include="Development">
        <link rel="stylesheet" 
          href="~/lib/bootstrap/dist/css/bootstrap.css" />
        <link rel="stylesheet" href="~/css/site.css" />
    </environment>
    <environment exclude="Development">
        <link rel="stylesheet" 
          href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/
                css/bootstrap.min.css"
          asp-fallback-href="~/lib/bootstrap/dist/
                             css/bootstrap.min.css"
          asp-fallback-test-class="sr-only" 
          asp-fallback-test-property="position" 
          asp-fallback-test-value="absolute" />
        <link rel="stylesheet" href="~/css/site.min.css" 
          asp-append-version="true" />
    </environment>
</head> 

这个标题看起来像一个相当正常的标题,但它有一些小小的怪癖。在标题中,我们从@ViewData中获取Title。我们使用@ViewData在控制器和视图之间传输数据,所以如果我们查看index.cshtml文件(例如),文件的顶部部分会这样说:

@{
    ViewData["Title"] = "Home Page";
}

这一部分与我们的布局结合起来,将我们的title标签设置为Home Page - Chapter 10@符号告诉编译器 ASP.NET 的模板引擎 Razor 将对那段代码进行处理。

我们标题的下一部分根据我们是否处于开发环境来决定包含哪些样式表的逻辑。如果我们运行开发构建,我们会得到一组文件,而发布版本会得到压缩版本。

我们将通过从 CDN 提供 Bootstrap 来简化我们的标题,而不管我们是否处于开发模式,并稍微改变我们的标题:

<head>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width, 
    initial-scale=1.0"/>
  <title>@ViewData["Title"] - AdvancedTypeScript 3 - Discogs</title>

  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/
    bootstrap/4.0.0/css/bootstrap.min.css" 
    integrity="sha384-  
      Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" 
        crossorigin="anonymous">
  <environment include="Development">
    <link rel="stylesheet" href="~/css/site.css"/>
  </environment>
  <environment exclude="Development">
    <link rel="stylesheet" href="~/css/site.min.css" 
      asp-append-version="true"/>
  </environment>
</head>

我们页面布局的下一个部分是body元素。我们将逐个部分地分解这个部分。从body元素开始,我们首先要看的是navigation元素:

<body>
    <nav class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" 
                    data-toggle="collapse" 
                    data-target=".navbar-collapse">
                    <span class="sr-only">Toggle navigation</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a asp-area="" asp-controller="Home" 
                  asp-action="Index" class="navbar-brand">Chapter10</a>
            </div>
            <div class="navbar-collapse collapse">
                <ul class="nav navbar-nav">
                    <li><a asp-area="" asp-controller="Home" 
                      asp-action="Index">Home</a></li>
                    <li><a asp-area="" asp-controller="Home" 
                      asp-action="About">About</a></li>
                    <li><a asp-area="" asp-controller="Home" 
                      asp-action="Contact">Contact</a></li>
                </ul>
            </div>
        </div>
    </nav>

</body>

这基本上是一个熟悉的navigation组件(尽管是在 Bootstrap 3 格式中)。将navigation组件转换为 Bootstrap 4,我们得到以下结果:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <div class="container">
    <a class="navbar-brand" asp-area="" asp-controller="Home" 
      asp-action="Index">AdvancedTypeScript3 - Discogs</a>
    <div class="navbar-header">
      <button class="navbar-toggler" type="button" 
        data-toggle="collapse" 
        data-target="#navbarSupportedContent" 
        aria-controls="navbarSupportedContent" 
        aria-expanded="false" 
        aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
    </div>
    <div class="navbar-collapse collapse">
      <ul class="nav navbar-nav">
        <li>
          <a class="nav-link" asp-area="" asp-controller="Home" 
            asp-action="Index">Home</a>
        </li>
        <li>
          <a class="nav-link" asp-area="" asp-controller="Home" 
            asp-action="About">About</a>
        </li>
        <li>
          <a class="nav-link" asp-area="" asp-controller="Home" 
            asp-action="Contact">Contact</a>
        </li>
      </ul>
    </div>
  </div>
</nav>

在这里,不熟悉的地方在于a链接内部。asp-controller类将视图链接到controller类;按照惯例,这些类名会扩展成<<name>>Controller,所以Home变成了HomeController。还有一个相关的asp-action,它与控制器类内的方法相关联,我们将调用这个方法。点击About链接将调用HomeController.cs内的About方法:

public IActionResult About()
{
  ViewData["Message"] = "Your application description page.";
  return View();
}

这个方法设置一个消息,将被写入About页面,然后返回该视图。ASP.NET 足够聪明,可以使用View()来确定它应该返回About.cshtml页面,因为这是About操作。这是我们开始看到 MVC 中控制器部分与视图部分的连接的地方。

回到_Layout文件,我们感兴趣的下一部分是以下部分,在这里我们使用@RenderBody来渲染主体内容:

<div class="container body-content">
    @RenderBody()
    <hr />
    <footer>
        <p>&copy; 2019 - Chapter10</p>
    </footer>
</div>

我们选择从我们的控制器显示的视图将在声明@RenderBody的地方呈现,因此我们可以假设此命令的目的是充当放置相关视图的占位符。我们将稍微更改此内容,以正确使用我们的 Bootstrap 知识并添加一个更有意义的页脚。考虑以下代码:

<div class="container">
  <div class="row">
    <div class="col-lg-12">
      @RenderBody()
    </div>
  </div>
  <hr/>
  <footer>
    <p>&copy; 2019 - Advanced TypeScript3 - Discogs Artist search</p>
  </footer>
</div>

我们不需要覆盖此文件的其余部分,因为我们真的需要开始查看我们将要渲染的模型和视图,但请从 GitHub 阅读源代码,并在此文件中进行相关的 JavaScript 更改,以便您使用 Bootstrap 4 代替 Bootstrap 3。

现在我们准备开始编写 MVC 代码库的模型部分。我们将通过编写将请求发送到 Discogs API 并将结果转换为可以发送到客户端的内容的模型来实现这一点。

创建一个 Discogs 模型

您会记得我们之前添加了一个IDiscogsClient模型的注册。在那时我们实际上还没有添加任何代码,所以我们的应用将无法编译。现在我们将创建接口和实现。IDiscogClient是一个模型,所以我们将在我们的模型目录中创建它。要在 Visual Studio 中创建接口和模型,我们需要右键单击Models文件夹以显示上下文菜单。在菜单中,选择添加 > 类....以下截图显示了这一点:

这将弹出以下对话框,我们可以在其中创建类或相关接口:

为了简洁起见,我们可以在同一个文件中创建接口和类定义。我已经在 GitHub 代码中将它们分开,但是我们在这里的类不需要这样做。首先,我们有以下接口定义:

public interface IDiscogsClient
{
  Task<Results> GetByArtist(string artist);
}

我们在定义中使用Task<Results>的用法类似于在 TypeScript 中指定返回特定类型的 promise。我们在这里所说的是,我们的方法将以异步方式运行,并且在某个时候将返回Results类型。

设置 Results 类型

我们从 Discogs 获取的数据以字段的层次结构返回。最终,我们希望有一些代码可以转换并返回结果,类似于以下内容:

在幕后,我们将把我们的调用的 JSON 结果转换为一组类型。顶层类型是Results类型,我们将从我们的GetByArtist调用中返回它。此层次结构显示在以下图表中:

为了查看映射的样子,我们将从头开始构建CommunityInfo类型。这个类将在我们的SearchResult类中使用,以提供我们在之前的 QuickWatch 截图中选择的社区字段。创建一个名为CommunityInfo的类,并在文件顶部添加以下行:

using Newtonsoft.Json;

我们添加这一行是因为我们想要使用这里的一些功能;具体来说,我们想要使用JsonProperty将 C#属性的名称映射到 JSON 结果中存在的属性。我们有两个字段需要CommunityInfo返回——一个用于标识有多少人“想要”音乐标题,另一个用于标识有多少人“拥有”它。我们将遵循标准的 C#命名约定,并使用 Pascal 大小写来命名属性(这意味着首字母大写)。由于属性名称使用 Pascal 大小写,我们将使用JsonProperty属性将该名称映射到适当的 REST 属性名称,因此Want属性将映射到结果中的want

public class CommunityInfo
{
  [JsonProperty(PropertyName = "want")]
  public int Want { get; set; }
  [JsonProperty(PropertyName = "have")]
  public int Have { get; set; }
}

我们不打算逐个讨论所有的类和属性。我绝对建议阅读 GitHub 代码以获取更多细节,但这肯定会有助于澄清项目结构是什么。

编写我们的 DiscogsClient 类

当我们编写DiscogsClient类时,我们已经有了它将基于的合同,以及接口定义。这告诉我们,我们的类开始如下:

public class DiscogsClient : IDiscogsClient
{
  public async Task<Results> GetByArtist(string artist)
  {
  }
}

我们的类的定义看起来与我们的接口略有不同,因为我们不必说明GetByArtistpublic,或者该方法是async。当我们在方法声明中使用async时,我们正在设置一个编译期望,即该方法将在其中具有await关键字。这对我们来说应该非常熟悉,因为我们在 TypeScript 中使用了async/await

当我们调用 Discogs API 时,它总是以https://api.discogs.com/ URL 开头。为了在我们的代码库中使生活变得更容易,我们将在类中将其定义为常量:

private const string BasePath = "https://api.discogs.com/";

我们的类将与 REST 端点进行通信。这意味着我们必须能够从我们的代码中访问 HTTP。为了做到这一点,我们的构造函数将具有一个实现了IHttpClientFactory接口的类,该接口已经被注入其中。客户端工厂将实现一个称为工厂模式的模式,为我们构建一个适当的HttpClient实例,以便在需要时使用:

private readonly IHttpClientFactory _httpClientFactory;
public DiscogsClient(IHttpClientFactory httpClientFactory)
{
  _httpClientFactory = httpClientFactory ?? throw new 
     ArgumentNullException(nameof(httpClientFactory));
}

构造函数中的这种看起来相当奇怪的语法只是说明我们将使用传入的 HTTP 客户端工厂设置成员变量。如果客户端工厂为空,??表示代码将继续执行下一个语句,该语句将抛出一个声明参数为空的异常。

那么,我们的GetByArtist方法是什么样子的?我们首先要做的是检查我们是否已经将艺术家传递给了该方法。如果没有,那么我们将返回一个空的Results实例:

if (string.IsNullOrWhiteSpace(artist))
{
  return new Results();
}

为了创建我们的 HTTP 请求,我们需要构建我们的请求地址。在构建地址的同时,我们将使用我们定义为常量的BasePath字符串与GetByArtist的路径进行连接。假设我们想要搜索Peter O'Hanlon作为艺术家。我们将构建我们的搜索字符串,以便转义用户输入的文本,以防止发送危险的请求;因此,我们最终会构建一个类似于api.discogs.com/database/search?artist=Peter O%27Hanlon&per_page=10所示的 HTTP 请求字符串。我们限制结果数量为 10,以保持在 Discogs 请求限制范围内。我们从辅助方法开始,将这两个字符串连接在一起:

private string GetMethod(string path) => $"{BasePath}{path}";

有了辅助程序,我们可以构建GET请求。正如我们之前讨论的,我们需要更改艺术家,以便对潜在危险的搜索词进行消毒。使用Uri.EscapeDataString,我们已经用其等效的 ASCII 值%27替换了我的名字中的撇号:

HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, 
  GetMethod($"database/search?artist={Uri.EscapeDataString(artist)}&per_page=10"));

创建请求后,我们需要向其添加一些标头。我们需要添加一个Authorization令牌和一个user-agent,因为 Discogs 希望收到它们。Authorization令牌采用Discogs token=<<token>>的格式,其中<<token>>是我们在注册时创建的令牌。user-agent只需要是有意义的东西,所以我们将其设置为AdvancedTypeScript3Chapter10

request.Headers.Add("Authorization", "Discogs token=MyJEHLsbTIydAXFpGafrrphJhxJWwVhWExCynAQh");
request.Headers.Add("user-agent", "AdvancedTypeScript3Chapter10");

我们谜题的最后一部分是使用工厂来创建HttpClient。创建后,我们调用SendAsync将我们的请求发送到 Discogs 服务器。当这个请求返回时,我们读取Content响应,然后需要使用DeserializeObject来转换类型:

using (HttpClient client = _httpClientFactory.CreateClient())
{
  HttpResponseMessage response = await client.SendAsync(request);
  string content = await response.Content.ReadAsStringAsync();
  return JsonConvert.DeserializeObject<Results>(content);
}

当我们把所有这些放在一起时,我们的类看起来是这样的:

public class DiscogsClient : IDiscogsClient
{
  private const string BasePath = "https://api.discogs.com/";
  private readonly IHttpClientFactory _httpClientFactory;
  public DiscogsClient(IHttpClientFactory httpClientFactory)
  {
    _httpClientFactory = httpClientFactory ?? throw new 
                 ArgumentNullException(nameof(httpClientFactory));
  }

  public async Task<Results> GetByArtist(string artist)
  {
    if (string.IsNullOrWhiteSpace(artist))
    {
      return new Results();
    }
    HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, 
      GetMethod($"database/search?artist=
        {Uri.EscapeDataString(artist)}&per_page=10"));
    request.Headers.Add("Authorization", "Discogs 
      token=MyJEHLsbTIydAXFpGafrrphJhxJWwVhWExCynAQh");
    request.Headers.Add("user-agent", "AdvancedTypeScript3Chapter10");
    using (HttpClient client = _httpClientFactory.CreateClient())
    {
      HttpResponseMessage response = await client.SendAsync(request);
      string content = await response.Content.ReadAsStringAsync();
      return JsonConvert.DeserializeObject<Results>(content);
    }
  }
  private string GetMethod(string path) => $"{BasePath}{path}";
}

我们提到了有一个速率限制。不过,这实际上是什么意思呢?

Discogs 速率限制

Discog 限制了可以从单个 IP 发出的请求数量。对于经过身份验证的请求,Discog 将请求速率限制为每分钟 60 次。对于未经身份验证的请求,在大多数情况下,可以发送的请求数量为每分钟 25 次。请求的数量使用移动窗口进行监控。

我们已经编写了我们的 Discogs API 模型;现在,是时候让我们来看看如何将我们的模型连接到我们的控制器。

连接我们的控制器

我们将利用依赖注入的强大功能来传递我们刚刚编写的 Discogs 客户端模型:

public class HomeController : Controller
{
  private readonly IDiscogsClient _discogsClient;
  public HomeController(IDiscogsClient discogsClient)
  {
    _discogsClient = discogsClient;
  }
}

正如您可能记得的,当我们设置导航时,我们将asp-action设置为Index。当我们执行搜索时,我们的视图将把搜索字符串传递给Index并调用GetByArtist方法。当我们得到搜索结果时,我们将使用结果列表设置ViewBag.Result。最后,我们提供View,这将是Index页面:

public async Task<IActionResult> Index(string searchString)
{
  if (!string.IsNullOrWhiteSpace(searchString))
  {
    Results client = await _discogsClient.GetByArtist(searchString);
    ViewBag.Result = client.ResultsList;
  }

  return View();
}

但我们的视图是什么样的?我们现在需要设置Index视图。

添加 Index 视图

在文件的顶部,我们将ViewData设置为Title。我们在查看_Layout.cshtml时看到了这样做的效果,但值得重复的是,我们在这里设置的值用于帮助构建我们主要布局页面的标题。当我们运行应用程序时,这将把标题设置为主页 - AdvancedTypeScript 3 - Discogs

@{
  ViewData["Title"] = "Home Page";
}

用户通过搜索控件与我们的应用程序进行交互。是时候为它添加了。我们将添加一个名为pageRootdiv ID,其中将包含一个form元素:

<div id="pageRoot">
  <form asp-controller="Home" asp-action="Index" class="form-inline">
  </form>
</div>

再次,我们可以看到我们在这里充分利用了 ASP.NET 的全部功能。我们的表单是 MVC 感知的,所以我们告诉它我们正在使用HomeController(记住控制器的约定)通过asp-controller。我们将操作设置为Index,因此我们将调用与导航到此页面时相同的Index方法。我们之所以能够这样做,是因为当我们完成搜索时,我们仍然希望显示当前页面,以便用户在必要时可以搜索不同的艺术家。我们的Index方法足够聪明,可以知道我们是否已经传递了搜索字符串来触发搜索,因此当用户在我们的表单内触发搜索时,将提供搜索字符串,这将触发搜索本身。

在表单内,我们需要添加一个输入搜索字段和一个按钮,按下按钮时触发submit表单。这里的类元素只是用来将我们的buttoninput字段转换为 Bootstrap 版本:

<div class="form-group mx-sm-3 mb-10">
  <input type="text" name="SearchString" class="form-control" 
    placeholder="Enter artist to search for" />
</div>
<button type="submit" class="btn btn-primary">Search</button>

有了这个设置,我们的搜索部分看起来是这样的:

<div id="pageRoot">
  <form asp-controller="Home" asp-action="Index" class="form-inline">
    <div class="form-group mx-sm-3 mb-10">
      <input type="text" name="SearchString" class="form-control" 
        placeholder="Enter artist to search for" />
    </div>
    <button type="submit" class="btn btn-primary">Search</button>
  </form>
</div>

如果我们现在运行应用程序,我们会看到以下内容。如果我们输入艺术家的详细信息并按下搜索按钮,搜索将被触发,但屏幕上不会显示任何数据:

现在我们有了搜索结果返回,我们需要从ViewBag中获取我们添加结果的结果。很容易被ViewBagViewData搞混,所以值得花点时间来谈谈它们,因为它们都有同样的目的,即在控制器和视图之间双向传递数据,只是略有不同:

  • 当我们添加搜索结果时,我们将其设置为ViewBag.Result。但是,如果我们看一下ViewBag的源代码,我们实际上找不到一个名为Result的属性。这是因为ViewBag是动态的;换句话说,它允许我们创建可以在控制器和视图之间共享的任意值,可以被称为任何东西。一般来说,使用ViewBag是一个合理的选择,但由于它是动态的,它没有编译器检测是否存在错误的好处,所以你必须确保在控制器中设置的属性与在视图中设置的属性完全相同。

  • ViewData,然而,依赖于使用字典(类似于 TypeScript 中的map),在这里我们可能有许多键/值对持有数据。在内部,值是一个对象,所以如果我们在视图中设置值并将其传递回控制器,我们必须将对象转换为适当的类型。这样做的效果是,在视图中设置ViewBag.Counter = 1意味着我们可以直接在控制器中将ViewBag.Counter视为整数,但在视图中设置ViewData["Counter"] = 1意味着我们必须将ViewData["Counter"]转换为整数,然后才能对其进行任何操作。转换看起来像这样:

int counter = (int)ViewData["Counter"];

对于我们的目的,我们可以使用任一种方法,因为设置结果的责任在于我们的控制器,但我很高兴使用ViewBag来设置我们的结果。那么,我们如何添加数据呢?我们知道我们的Index页面是一个.cshtml文件,所以我们可以混合 C#和 HTML 在一起。我们使用@{ }来表示 C#部分,所以为了呈现结果,我们需要检查ViewBag.Result中是否有值(请注意,C#使用!=,而不是 JavaScript 格式的!==,来测试结果是否为空)。我们编写的代码以这样开始呈现我们的结果:

@{ if (ViewBag.Result != null)
  {
  }
}

在我们的结果中,我们将创建一个 Bootstrap 表格,其中TitleArtwork作为两列。我们要构建的表的 HTML 标记从这里开始:

<table class="table">
  <thead>
    <tr>
      <th>Title</th>
      <th>Artwork</th>
    </tr>
  </thead>
  <tbody>
  </tbody>
</table>

在我们的表体(tbody)中,我们将不得不循环遍历我们结果中的每一项,并将相关值写出。我们首先要做的是创建一个名为index的变量。我们现在要把这个放在这里,预期到需要添加一个带有唯一名称的图像的地方(我们将在下一节中介绍)。

接下来,我们将使用foreach来遍历ViewBag.Result中的每一项。对于每个项目,我们将创建一个新的表行使用<tr></tr>,在行内,我们将写出两个表数据单元(<td></td>)包含标题和资源 URL,如下所示:

<tbody>
  @{
    int index = 0;
  }
  @foreach (var item in ViewBag.Result)
  {
    <tr>
      <td>@item.Title</td>
      <td>@item.ResourceUrl</td>
    </tr>
    index++;
  }
</tbody>

如果我们现在运行我们的应用程序,我们将得到结果,并且这些结果将被写入表格:

显然,艺术品元素是错误的。那不是一张图片,所以我们需要放置一些东西去检索图片本身,这需要我们的代码为每个结果进行另一个 REST 调用。我们希望这发生在结果返回后,所以当我们看到如何利用 TypeScript 为我们获取图像结果时,我们现在将转向客户端功能。

向我们的应用程序添加 TypeScript

我们 TypeScript 的起点——几乎总是——是我们的tsconfig.json文件。我们将尽可能地使其精简。我们将在这里设置特定的outDir,因为我们的项目创建了一些文件在wwwroot中。在wwwroot/js文件夹中,ASP.NET 已经创建了一个site.js文件,所以我们将把我们的脚本定位到与它并存:

{
  "compileOnSave": true,
  "compilerOptions": {
    "lib": [ "es2015", "dom" ],
    "noImplicitAny": true,
    "noEmitOnError": true,
    "removeComments": true,
    "sourceMap": true,
    "target": "es2015",
    "outDir": "wwwroot/js/"
  },
  "exclude": [
    "wwwroot"
  ]
}

我们将使用一个单一的方法调用 Discogs API 来检索相关的图像。我们不会依赖于从外部来源加载的任何 TypeScript 包来进行我们的 API 调用,因为 JavaScript 为我们提供了fetch API,允许我们在没有任何依赖关系的情况下进行 REST 调用。

我们首先添加一个名为discogHelper.ts的文件,其中包含我们将从 ASP.NET 应用程序中调用的函数。我们添加这个作为 TypeScript 方法的原因是,我们希望它在客户端上运行,而不是在服务器端。这样可以减少将初始结果加载到客户端屏幕上所需的时间,因为我们将让客户端为我们获取并异步加载图像。

我们的函数的签名看起来像这样:

const searchDiscog = (request: RequestInfo, imgId: string): Promise<void> => {
  return new Promise((): void => {
  }
}

RequestInfo参数将接受服务器上图像请求的 URL。这是因为 Discog 并不返回有关特定音乐标题的完整详细信息,因此在这一点上专辑封面不可用。相反,它返回了我们必须进行的 REST 调用,以检索完整详细信息,然后我们可以解析出来检索封面。例如,Steve Vai 的 Passion and Warfare 专辑信息返回了api.discogs.com/masters/44477链接的ResourceUrl。这成为我们传递给request的 URL,以检索包括封面在内的完整详细信息。

我们接受的第二个参数是img对象的id。当我们遍历初始搜索结果来构建结果表时,我们还包括一个唯一标识的图像,将其传递给我们的函数。这允许我们在完成检索有关专辑的详细信息后动态更新src。有时,这可能会导致客户端出现有趣的效果,因为有些专辑的检索时间比其他专辑长,所以很可能图像列表的更新顺序不一致,这意味着后面的图像比前面的图像更早地填充。这并不是什么大问题,因为我们故意这样做是为了显示我们的客户端代码确实是异步的。

如果我们真的想要担心让我们的图像按顺序显示,我们会改变我们的函数来接受一个请求和图像占位符的数组,发出我们的调用,并且只有在所有 REST 调用完成后才更新图像。

毫不奇怪,fetch API 使用了一个名为fetch的 promise 来进行我们的调用。这接受请求,以及一个RequestInit对象,允许我们传递自定义设置到我们的调用中,包括我们想要应用的 HTTP 动词和我们想要设置的任何标头:

fetch(request,
  {
    method: 'GET',
    headers: {
      'authorization': 'Discogs 
           token=MyJEHLsbTIydAXFpGafrrphJhxJWwVhWExCynAQh',
      'user-agent': 'AdvancedTypeScript3Chapter10'
    }
  })

猜猜看?我们在这里使用了与 C#代码中设置的相同的authorizationuser-agent标头。

我们已经说过fetch API 是基于 promise 的,所以我们可以合理地期望fetch调用在返回结果之前等待完成。为了获取我们的图像,我们将执行一些转换。第一个转换是将响应转换为 JSON 表示:

.then(response => {
  return response.json();
})

转换操作是异步的,所以我们的转换的下一个阶段也可以在自己的then块中发生。此时,如果一切顺利,我们应该有一个响应主体。我们使用我们传递给函数的图像 ID 来检索HTMLImageElement。如果这是一个有效的图像,那么我们将src设置为我们收到的第一个uri150结果,这给我们了来自服务器的 150 x 150 像素图像的地址:

.then(responseBody => {
  const image = <HTMLImageElement>document.getElementById(imgId);
  if (image) {
    if (responseBody && responseBody.images && 
         responseBody.images.length > 0) {
      image.src = responseBody.images["0"].uri150;
    }
  }
})

将所有这些放在一起,我们的搜索函数看起来像这样:

const searchDiscog = (request: RequestInfo, imgId: string): Promise<void> => {
  return new Promise((): void => {
    fetch(request,
      {
        method: 'GET',
        headers: {
          'authorization': 'Discogs 
            token=MyJEHLsbTIydAXFpGafrrphJhxJWwVhWExCynAQh',
          'user-agent': 'AdvancedTypeScript3Chapter10'
        }
      })
      .then(response => {
        return response.json();
      })
      .then(responseBody => {
        const image = <HTMLImageElement>document.getElementById(imgId);
        if (image) {
          if (responseBody && responseBody.images && 
               responseBody.images.length > 0) {
            image.src = responseBody.images["0"].uri150;
          }
        }
      }).catch(x => {
        console.log(x);
      });
  });
}

Discogs 允许我们发出 JSONP 请求,这意味着我们必须传递一个回调查询字符串参数。为了发出 JSONP 请求,我们必须安装来自github.com/camsong/fetch-jsonp的 Fetch JSONP 包。这需要将fetch调用的签名更改为fetchJsonp。除此之外,我们的其他函数看起来都一样。

到目前为止,我们应该已经熟悉了在承诺中使用async/await。如果我们想要一个稍微不那么冗长的函数,我们可以将代码更改为这样:

const searchDiscog = (request: RequestInfo, imgId: string): Promise<void> => {
  return new Promise(async (): void => {
    try
    {
      const response = await fetch(request,
        {
          method: 'GET',
          headers: {
            'authorization': 'Discogs 
              token=MyJEHLsbTIydAXFpGafrrphJhxJWwVhWExCynAQh',
            'user-agent': 'AdvancedTypeScript3Chapter10'
          }
        });
      const responseBody = await response.json();
      const image = <HTMLImageElement>document.getElementById(imgId);
      if (image) {
        if (responseBody && responseBody.images && 
             responseBody.images.length > 0) {
          image.src = responseBody.images["0"].uri150;
        }
      }
    }
    catch(ex) {
      console.log(ex);
    } 
  });
}

在下一节中,我们将讨论如何从 ASP.NET 调用我们的 TypeScript 功能。

从 ASP.NET 调用我们的 TypeScript 功能

回到我们的 ASP.NET 代码,我们现在可以连接searchDiscog函数来检索我们的图像。我们需要做的第一件事是包含对搜索脚本的引用:

<script src="~/js/discogHelper.js"></script>

有了这个,我们现在可以扩展我们的图像部分以包括搜索脚本:

<td>
  <img id="img_@index" width="150" height="150" />
  <script type="text/javascript">
      searchDiscog('@item.ResourceUrl', 'img_@index');
  </script>
</td>

将所有这些放在一起,我们的Index页面现在看起来像这样:

@{
  ViewData["Title"] = "Home Page";
}
<div id="pageRoot">
  <form asp-controller="Home" asp-action="Index" class="form-inline">
    <div class="form-group mx-sm-3 mb-10">
      <input type="text" name="SearchString" class="form-control" 
         placeholder="Enter artist to search for" />
    </div>
    <button type="submit" class="btn btn-primary">Search</button>
  </form>
</div>
@{ if (ViewBag.Result != null)
  {
    <script src="~/js/discogHelper.js"></script>
    <table class="table">
      <thead>
        <tr>
          <th>Title</th>
          <th>Artwork</th>
        </tr>
      </thead>
      <tbody>
        @{
          int index = 0;
        }
        @foreach (var item in ViewBag.Result)
        {
          <tr>
            <td>@item.Title</td>
            <td>
              <img id="img_@index" width="150" height="150" />
              <script type="text/javascript">
                  searchDiscog('@item.ResourceUrl', 'img_@index');
              </script>
            </td>
          </tr>
          index++;
        }
      </tbody>
    </table>
  }
}

现在,当我们运行应用程序时,执行搜索后将返回标题和图像。重新运行相同的搜索现在给我们这个:

就是这样。我们有一个 ASP.NET Core MVC 应用程序,可以用来搜索艺术家并检索标题和艺术品。所有这些都是使用 ASP.NET MVC、HTML、Bootstrap、C#和 TypeScript 的组合实现的。

总结

在我们的最后一章中,我们转向使用 ASP.NET Core、C#和 TypeScript 开发应用程序。我们借此机会了解了在创建 ASP.NET Core Web 应用程序时,Visual Studio 为我们生成了什么。我们发现 ASP.NET Core 强调使用 MVC 模式来帮助我们分离代码的责任。为了构建这个应用程序,我们注册了 Discogs 网站并注册了一个令牌,以便我们开始使用 C#检索艺术家的详细信息。从艺术家的结果中,我们创建了一些调用同一网站检索专辑艺术品的 TypeScript 功能。

在构建应用程序时,我们介绍了如何在同一个.cshtml文件中混合 C#和 HTML 代码,这构成了视图。我们编写了自己的模型来执行艺术家搜索,并学习了如何更新控制器以将模型和视图联系在一起。

我希望您喜欢使用 TypeScript 的旅程,并希望我们已经增强了您的知识,以至于您想要更多地使用它。TypeScript 是一种美妙的语言,总是很愉快地使用,所以,请尽情享受它,就像我一样。我期待着看到您的作品。

问题

  1. 为什么 TypeScript 看起来与 C#相似?

  2. 什么 C#方法启动我们的程序?

  3. ASP.NET Core 与 ASP.NET 有什么不同?

  4. Discog 的速率限制是什么?

进一步阅读

ASP.NET Core 是一个庞大的主题,需要覆盖的时间比我们在这个简短的章节中拥有的时间要多得多。考虑到这一点,我建议您阅读以下书籍,以继续您的 ASP.NET 之旅:

第十一章:评估

第一章

  1. 使用联合类型,我们可以编写一个接受FahrenheitToCelsius类或CelsiusToFahrenheit类的方法:
class Converter {
    Convert(temperature : number, converter : FahrenheitToCelsius | CelsiusToFahrenheit) : number {
        return converter.Convert(temperature);
    }
}

let converter = new Converter();
console.log(converter.Convert(32, new CelsiusToFahrenheit()));
  1. 要接受键/值对,我们需要使用映射。将我们的记录添加到其中看起来像这样:
class Commands {
    private commands = new Map<string, Command>();
    public Add(...commands : Command[]) {
        commands.forEach(command => {
            this.Add(command);
        })
    }
    public Add(command : Command) {
        this.commands.set(command.Name, command);
    }
}

let command = new Commands();
command.Add(new Command("Command1", new Function()), new Command("Command2", new Function()));

我们实际上在这里添加了两种方法。如果我们想一次添加多个命令,我们可以使用 REST 参数来接受命令数组。

  1. 我们可以使用装饰器来在调用我们的Add方法时自动记录。例如,我们的log方法可能如下所示:
function Log(target : any, propertyKey : string | symbol, descriptor : PropertyDescriptor) {
    let originalMethod = descriptor.value;
    descriptor.value = function() {
        console.log(`Added a command`);
        originalMethod.apply(this, arguments);
    }
    return descriptor;
}

我们只会将这个添加到以下的Add方法中,因为接受 REST 参数的Add方法无论如何都会调用这个方法:

@Log
public Add(command : Command) {
    this.commands.set(command.Name, command);
}

不要忘记我们使用@符号来表示这是一个装饰器。

  1. 要添加一个具有相等大小的六个中等列的行,我们使用六个div语句,并将类设置为col-md-2,就像这样:
<div class="row">
  <div class="col-md-2">
  </div>
  <div class="col-md-2">
  </div>
  <div class="col-md-2">
  </div>
  <div class="col-md-2">
  </div>
  <div class="col-md-2">
  </div>
  <div class="col-md-2">
  </div>
</div>

请记住,根据我们在 Bootstrap 中的讨论,一行中的列数应该等于 12。

第三章

  1. React 为我们提供了特殊的文件类型,.jsx(用于 JavaScript)或.tsx(用于 TypeScript),以创建一个可以转译为 JavaScript 的文件,因此 React 将类似 HTML 的元素呈现为 JavaScript。

  2. classfor都是 JavaScript 中的保留关键字。由于.tsx文件似乎在同一个方法中混合了 JavaScript 和 HTML,我们需要别名来指定 CSS 类和label关联的控件。React 提供了className来指定应该应用于 HTML 元素的类,以及htmlFor来指定标签关联的控件。

  3. 当我们创建验证器时,我们正在创建可重复使用的代码片段,可以用来执行特定类型的验证;例如,检查字符串是否达到最小长度。由于这些被设计为可重复使用,我们必须将它们与实际应用验证的验证代码分开。

  4. 通过用\d替换[0-9],我们将^(?:\\((?:[0-9]{3})\\)|(?:[0-9]{3}))[-. ]?(?:[0-9]{3})[-. ]?(?:[0-9]{4})$转换为以下表达式:^(?:\\((?:\d{3})\\)|(?:\d{3}))[-. ]?(?:\d{3})[-. ]?(?:\d{4})$

  5. 使用硬删除,我们从数据库中删除物理记录。使用软删除,我们保留记录,但对其应用一个标记,表示该记录不再处于活动状态。

第四章

  1. MEAN 堆栈由四个主要组件组成:
  • MongoDB:MongoDB 是一个 NoSQL 数据库,已成为在 Node 中构建数据库支持的事实标准。还有其他数据库选项可用,但 MongoDB 是一个非常受欢迎的选择。

  • Express:Express 封装了在 Node 下处理服务器端代码的许多复杂性,并使其更易于使用。例如,如果我们想处理 HTTP 请求,Express 使这变得微不足道,而不是编写等效的 Node 代码。

  • Angular:Angular 是一个客户端框架,使得创建强大的 Web 前端更容易。

  • Node:Node(或 Node.js)是服务器上应用程序的运行时环境。

  1. 我们提供一个前缀使得我们的组件唯一。假设我们有一个组件,我们想要称为label;显然,这将与内置的 HTML 标签冲突。为了避免这种冲突,我们的组件选择器将是atp-label。由于 HTML 控件从不使用连字符,我们保证不会与现有的控件选择器冲突

  2. 要启动我们的 Angular 应用程序,我们在顶层 Angular 文件夹中运行以下命令:

ng serve --open
  1. 与我们自己的语言被分解和结构化为单词和标点符号一样,我们可以将视觉元素分解为结构,例如颜色和深度。例如,语言告诉我们颜色的含义,因此,如果我们在应用程序中的一个屏幕上看到一个带有一个颜色的按钮,它应该在我们应用程序的其他屏幕上具有相同的基础用法;我们不会在一个对话框上使用绿色按钮来表示确定,然后在另一个对话框上使用取消。设计语言背后的理念是元素应该是一致的。因此,如果我们将我们的应用程序创建为一个 Material 应用程序,那么对于使用 Gmail 的人来说,它应该是熟悉的(例如)。

  2. 我们使用以下命令创建服务:

ng generate service <<servicename>>

这可以缩短为以下内容:

ng g s <<servicename>>
  1. 每当请求进入我们的服务器时,我们需要确定如何处理最好的请求,这意味着我们必须将其路由到处理请求的适当功能部分。Express 路由是我们用来实现这一点的机制。

  2. RxJS 实现了观察者模式。这种模式有一个对象(称为subject),它跟踪一系列依赖项(称为observers),并通知它们有趣的行为,例如状态更改。

  3. CORS代表跨域请求共享。使用 CORS,我们允许已知的外部位置访问我们站点上的受限操作。在我们的代码中,由于 Angular 是从与我们的 Web 服务器不同的站点运行的(localhost:4200,而不是localhost:3000),我们需要启用 CORS 支持来进行发布,否则当我们从 Angular 发出请求时,我们将不会返回任何内容。

第五章

  1. GraphQL 并不打算完全取代 REST 客户端。它可以作为一种合作技术,因此它很可能会自己消耗多个 REST API 来生成图。

  2. 变异是一种旨在以某种方式更改图中数据的操作。我们可能想要向图中添加新项目,更新项目或删除项目。重要的是要记住,变异只是改变了图 - 如果更改必须持久保存到图从中获取信息的地方,那么图就有责任调用底层服务来进行这些更改。

  3. 为了将值传递给子组件,我们需要使用@Input()来公开一个字段,以便从父级进行绑定。在我们的代码示例中,我们设置了一个Todo项目,如下所示:

@Input() Todo: ITodoItem;
  1. 使用 GraphQL,解析器代表了如何将操作转换为数据的指令;它们被组织为与字段的一对一映射。另一方面,模式代表了多个解析器。

  2. 要创建一个单例,我们需要做的第一件事是创建一个带有私有构造函数的类。私有构造函数意味着我们可以实例化我们的类的唯一位置是从类本身内部:

export class Prefill {
  private constructor() {}
}

接下来我们需要做的是添加一个字段来保存对类实例的引用,然后提供一个公共静态属性来访问该实例。公共属性将负责实例化类(如果尚未可用),以便我们始终能够访问它:

private static prefill: Prefill;
public static get Instance(): Prefill {
  return this.prefill || (this.prefill = new this());
}

第六章

  1. 使用io.emit,我们可以向所有连接的客户端发送消息。

  2. 如果我们想要向特定房间中的所有用户发送消息,我们将使用类似以下的内容,其中我们说我们要向哪个房间发送消息,然后使用emit来设置eventmessage

io.to('room').emit('event', 'message');
  1. 要将消息发送给除发送方之外的所有用户,我们需要进行广播:
socket.broadcast.emit('broadcast', 'my message');
  1. 有一些事件名称,我们不能用作消息,因为它们由于具有对 Socket.IO 具有特殊含义而受到限制。这些是errorconnectdisconnectdisconnectingnewListenerremoveListenerpingpong

  2. Socket.IO 由许多不同的协作技术组成,其中之一称为 Engine.IO。这提供了底层传输机制。它在连接时采用的第一种连接类型是 HTTP 长轮询,这是一种快速高效的传输机制。在空闲期间,Socket.IO 会尝试确定传输是否可以切换到套接字,如果可以使用套接字,它会无缝地升级传输以使用套接字。对于客户端来说,它们连接迅速,消息可靠,因为 Engine.IO 部分建立连接,即使存在防火墙和负载均衡器。

第七章

  1. @Component定义中,我们使用host将我们要处理的主机事件映射到相关的 Angular 方法。例如,在我们的MapViewComponent中,我们使用以下组件定义将window load事件映射到Loaded方法:
@Component({
  selector: 'atp-map-view',
  templateUrl: './map-view.component.html',
  styleUrls: ['./map-view.component.scss'],
  host: {
    '(window:load)' : 'Loaded()'
  }
})
  1. 纬度和经度是用于确定地球上某个位置的地理术语。纬度告诉我们某物距赤道有多远,赤道为 0;正数表示我们在赤道以北,负数表示我们在赤道以南。经度告诉我们我们距离地球的中心线(按照惯例,通过伦敦的格林威治)有多远。同样,如果我们向东移动,数字是正数,而向西移动意味着数字是负数。

  2. 将经度和纬度表示的位置转换为地址的行为称为反向地理编码。

  3. 我们使用 Firestore 数据库,这是 Google 的 Firebase 云服务的一部分,用来保存我们的数据。

第八章

  1. 容器是一个运行实例,它接收运行应用程序所需的各种软件。这是我们的起点;容器是从镜像构建的,您可以自己构建或从中央 Docker 数据库下载。容器可以向其他容器打开,例如主机操作系统,甚至可以使用端口和卷向更广泛的世界打开。容器的一个重要卖点是它易于设置和创建,并且可以快速停止和启动。

  2. 当我们启动 Docker 容器时,我们讨论了两种实现方法。第一种方法涉及使用docker builddocker run的组合来启动服务:

docker build -t ohanlon/addresses .
docker run -p 17171:3000 -d ohanlon/addresses

使用-d表示它不会阻塞控制台,因为它会在后台分离并静默运行。这使我们能够一起运行一组这些命令。在下载中,您会找到一个我创建的批处理文件,用于在 Windows 上启动它们。

第二种方法,也是我推荐的方法,使用 Docker 组合。在我们的示例中,我们创建了一个docker-compose.yml文件,用于将我们的微服务组合在一起。要运行我们的组合文件,我们需要使用以下命令:

docker-compose up
  1. 如果我们使用docker run来启动容器,我们可以使用-p开关在其中指定端口。以下示例将端口3000重新映射到17171
docker run -p 17171:3000 -d ohanlon/addresses

当我们使用 Docker 组合时,我们在docker-compose.yml文件中指定端口重映射。

  1. Swagger 为我们提供了许多有用的功能。我们可以用它来创建 API 文档,原型化 API,并用它来自动生成我们的代码,以及进行 API 测试。

  2. 当 React 方法无法看到状态时,我们有两个选择。我们可以将其更改为使用=>,以便自动捕获this上下文,或者我们可以使用 JavaScript 的bind功能来绑定到正确的上下文。

第九章

  1. 虽然 TensorFlow 现在支持 TypeScript/JavaScript,但最初是作为 Python 库发布的。TensorFlow 的后端是使用高性能 C++编写的。

  2. 监督式机器学习利用先前的学习,并利用这些来处理新数据。它使用标记的示例来学习正确的答案。在这背后,有训练数据集,监督算法会根据这些数据集来完善它们的知识。

  3. MobileNet 是一种专门的卷积神经网络CNN),除其他外,它提供了预先训练的图像分类模型。

  4. MobileNet 的classify方法默认返回包含分类名称和概率的三个分类。这可以通过指定要返回的分类数量来覆盖。

  5. 当我们想要创建 Vue 应用程序时,我们使用以下命令:

vue create <<applicationname>>

由于我们想创建 TypeScript 应用程序,我们选择手动选择功能,并在功能屏幕上确保选择 TypeScript 作为我们的选项。

  1. 当我们在.vue文件中创建一个类时,我们使用@Component来标记它为一个可以在 Vue 中注册的组件。

第十章

  1. JavaScript 和 C#都可以追溯到 C 语言的语法根源,因此它们在很大程度上遵循类似的语言范式,比如使用{}来表示操作的范围。由于所有的 JavaScript 都是有效的 TypeScript,这意味着 TypeScript 在这方面完全相同。

  2. 启动我们程序的方法是static Main方法。它看起来像这样:

public static void Main(string[] args)
{
  CreateWebHostBuilder(args).Build().Run();
}
  1. ASP.NET Core 使用了重写的.NET 版本,去除了它只能在 Windows 平台上运行的限制。这意味着 ASP.NET 的覆盖范围大大增加,因为它现在可以在 Linux 平台上运行,也可以在 Windows 上运行。

  2. Discog 限制了单个 IP 发出的请求数量。对于经过身份验证的请求,Discog 将请求速率限制为每分钟 60 次。对于未经身份验证的请求,在大多数情况下,可以发送的请求数量为每分钟 25 次。请求的数量使用移动窗口进行监控。

posted @ 2024-05-22 12:07  绝不原创的飞龙  阅读(22)  评论(0编辑  收藏  举报