TypeScript-研讨会-全-

TypeScript 研讨会(全)

原文:zh.annas-archive.org/md5/4e3ff648672eea803b67f47091a10d37

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:TypeScript 实战工作坊

自信、有效的 TypeScript 编程实用指南

Ben Grynhaus, Jordan Hudgens, Rayon Hunte, Matt Morgan 和 Wekoslav Stefanovski

TypeScript 实战工作坊

版权 © 2021 Packt 出版

所有权利保留。未经出版商事先书面许可,本课程的部分或全部不得以任何形式或通过任何手段进行复制、存储在检索系统中或以任何方式传输,但简要引用嵌入在评论文章或评论中除外。

在准备本课程的过程中,已尽一切努力确保所提供信息的准确性。然而,本课程中包含的信息销售时不附带任何明示或暗示的保证。作者、Packt 出版公司及其经销商和分销商不对由此课程直接或间接造成的或声称造成的任何损害承担责任。

Packt 出版公司已尽力通过适当使用大写字母来提供关于本课程中提到的所有公司和产品的商标信息。然而,Packt 出版公司不能保证此信息的准确性。

作者: Ben Grynhaus, Jordan Hudgens, Rayon Hunte, Matt Morgan 和 Wekoslav Stefanovski

审稿人: Yusuf Salman 和 Cihan Yakar

管理编辑: Mahesh Dhyani

收购编辑: Royluis Rodrigues 和 Sneha Shinde

生产编辑: Shantanu Zagade

编辑委员会: Megan Carlisle, Mahesh Dhyani, Heather Gopsill, Manasa Kumar, Alex Mazonowicz, Monesh Mirpuri, Bridget Neale, Abhishek Rane, Brendan Rodrigues, Ankita Thakur, Nitesh Thakur 和 Jonathan Wray

首次出版:2021 年 7 月

生产参考:1280721

ISBN:978-1-83882-849-3

由 Packt 出版有限公司出版

Livery Place, 35 Livery Street

Birmingham B3 2PB,英国

目录

前言

1. TypeScript 基础

简介

TypeScript 的发展

TypeScript 的设计目标

开始使用 TypeScript

TypeScript 编译器

设置 TypeScript 项目

练习 1.01:使用 tsconfig.json 和开始使用 TypeScript

类型及其用途

TypeScript 和函数

练习 1.02:在 TypeScript 中使用函数

TypeScript 和对象

练习 1.03:使用对象

基本类型

练习 1.04:检查 typeof

字符串

数字

布尔值

数组

元组

Schwartzian 转换

练习 1.05:使用数组和元组创建对象的高效排序

枚举

Any 和 Unknown

Null 和 Undefined

Never

函数类型

创建自己的类型

练习 1.06:创建计算器函数

活动 1.01:创建用于处理字符串的库

总结

2. 声明文件

简介

声明文件

练习 2.01:从头创建声明文件

异常

第三方代码库

DefinitelyTyped

分析外部声明文件

练习 2.02:使用外部库创建类型

使用 DefinitelyTyped 的发展工作流程

练习 2.03:创建棒球阵容卡应用程序

活动 2.01:构建热图声明文件

总结

3. 函数

简介

TypeScript 中的函数

练习 3.01:在 TypeScript 中开始使用函数

函数关键字

函数参数

参数与参数

可选参数

默认参数

多个参数

剩余参数

解构返回类型

函数构造函数

练习 3.02:比较数字数组

函数表达式

箭头函数

类型推断

练习 3.03:编写箭头函数

理解 this

练习 3.04:在对象中使用 this

闭包和作用域

练习 3.05:使用闭包创建订单工厂

柯里化

练习 3.06:重构为柯里化函数

函数式编程

将函数组织到对象和类中

练习 3.07:将 JavaScript 重构为 TypeScript

导入、导出和 require

练习 3.08:导入和导出

活动 3.01:使用函数构建航班预订系统

使用 ts-jest 进行单元测试

活动 3.02:编写单元测试

错误处理

摘要

4. 类和对象

介绍

类和对象是什么?

练习 4.01:构建你的第一个类

使用构造函数扩展类行为

this 关键字

练习 4.02:定义和访问类的属性

练习 4.03:将类型集成到类中

TypeScript 接口

练习 4.04:构建接口

在方法中生成 HTML 代码

练习 4.05:生成和查看 HTML 代码

与多个类和对象一起工作

练习 4.06:组合类

活动 4.01:使用类、对象和接口创建用户模型

摘要

5. 接口和继承

介绍

接口

案例研究 – 编写你的第一个接口

练习 5.01:实现接口

练习 5.02:实现接口 – 创建原型博客应用

练习 5.03:为更新用户数据库的函数创建接口

活动 5.01:使用接口构建用户管理组件

TypeScript 继承

练习 5.04:创建基类和两个扩展子类

练习 5.05:使用多层继承创建基类和扩展类

活动 5.02:使用继承创建一个原型车辆展厅 Web 应用

摘要

6. 高级类型

介绍

类型别名

练习 6.01:实现类型别名

类型字面量

练习 6.02:类型字面量

交集类型

练习 6.03:创建交集类型

联合类型

练习 6.04:使用 API 更新产品库存

索引类型

练习 6.05:显示错误消息

活动 6.01:交集类型

活动 6.02:联合类型

活动 6.03:索引类型

摘要

7. 装饰器

介绍

反射

设置编译器选项

装饰器的意义

横切关注点问题

解决方案

装饰器和装饰器工厂

装饰器语法

装饰器工厂

类装饰器

属性注入

练习 7.01:创建简单的类装饰器工厂

构造函数扩展

练习 7.02:使用构造函数扩展装饰器

构造函数包装

练习 7.03:为类创建日志装饰器

方法和访问器装饰器

实例函数上的装饰器

练习 7.04: 创建标记函数可枚举的装饰器

静态函数上的装饰器

方法包装装饰器

练习 7.05: 为方法创建日志装饰器

活动 7.01: 创建用于调用计数的装饰器

在装饰器中使用元数据

Reflect 对象

练习 7.06: 通过装饰器向方法添加元数据

属性装饰器

练习 7.07: 创建和使用属性装饰器

参数装饰器

练习 7.08: 创建和使用参数装饰器

在单个目标上应用多个装饰器

活动 7.02: 使用装饰器应用横切关注点

总结

8. TypeScript 中的依赖注入

简介

依赖注入设计模式

Angular 中的依赖注入

练习 8.01: 向 Angular 应用添加 HttpInterceptor

Nest.js 中的依赖注入

InversifyJS

练习 8.02: 使用 InversifyJS 实现 "Hello World"

活动 8.01: 基于依赖注入的计算器

总结

9. 泛型和条件类型

简介

泛型

泛型接口

泛型类型

泛型类

练习 9.01: 泛型集合类

泛型函数

泛型约束

练习 9.02: 泛型 memoize 函数

泛型默认值

条件类型

活动 9.01: 创建 DeepPartial 类型

总结

10. 事件循环和异步行为

简介

多线程方法

异步执行方法

执行 JavaScript

练习 10.01:函数堆叠

浏览器和 JavaScript

浏览器中的事件

环境 API

setTimeout

练习 10.02:探索 setTimeout

AJAX(异步 JavaScript 和 XML)

活动 10.01:使用 XHR 和回调的影片浏览器

Promise

练习 10.03:计数到五

什么是 Promise?

练习 10.04:使用 Promise 计数到五

活动 10.02:使用 fetch 和 Promise 的影片浏览器

async/await

练习 10.05:使用 async 和 await 计数到五

活动 10.03:使用 fetch 和 async/await 的影片浏览器

总结

11. 高阶函数和回调

简介

高阶组件(HOC)简介 – 示例

高阶函数

练习 11.01:使用高阶函数编排数据过滤和操作

回调

事件循环

Node.js 中的回调

回调地狱

避免回调地狱

在文件级别将回调处理程序拆分为函数声明

链式回调

Promise

async/await

活动 11.01:高阶管道函数

总结

12. TypeScript 中 Promise 指南

简介

Promise 的演变和动机

Promise 的结构

Promise 回调

then 和 catch

挂起状态

已满足状态

拒绝状态

链式调用

练习 12.01:链式调用 Promise

finally

Promise.all

练习 12.02:递归 Promise.all

Promise.allSettled

练习 12.03:Promise.allSettled

Promise.any

Promise.race

使用类型增强 Promise

练习 12.04:异步渲染

库和本地 Promise - 第三方库、Q 和 Bluebird

Promise 的 polyfill

Promisify

Node.js util.promisify

异步文件系统

fs.readFile

fs.readFileSync

fs Promises API

练习 12.05:fs Promises API

与数据库一起工作

使用 REST 进行开发

练习 12.06:实现基于 sqlite 的 RESTful API

整合所有内容 - 构建 Promise 应用

活动 12.01:构建 Promise 应用

总结

13. TypeScript 中的 Async/Await

介绍

演变和动机

TypeScript 中的 async/await

练习 13.01:转译目标

选择目标

语法

async

练习 13.02:async 关键字

练习 13.03:使用 then 解析异步函数

await

练习 13.04:await 关键字

练习 13.05:等待一个 Promise

语法糖

异常处理

练习 13.06:异常处理

顶层 await

Promise 方法

练习 13.07:Express.js 中的 async/await

练习 13.08:NestJS

练习 13.09:TypeORM

活动 13.01:将链式承诺重构为使用 await

总结

14. TypeScript 和 React

介绍

类型化 React

React 中的 TypeScript

你好,React

组件

有状态组件

无状态组件

纯组件

高阶组件

JSX 和 TSX

练习 14.01:使用 Create React App 启动

路由

练习 14.02:React Router

React 组件

类组件

函数组件(函数声明)

函数组件(箭头函数表达式)

无 JSX

函数组件中的状态

React 中的状态管理

练习 14.03:React Context

Firebase

练习 14.04:开始使用 Firebase

样式化 React 应用

主样式表

组件作用域样式

CSS-in-JS

组件库

活动 14.01:博客

总结

附录

前言

关于本书

通过学习 TypeScript,开发者可以开始编写更干净、更易于阅读的代码,这些代码更容易理解,且更不容易出现错误。有什么不喜欢的呢?

这当然是一个诱人的前景,但学习一门新语言可能会很有挑战性,而且并不总是容易知道从哪里开始。这本书是开始学习的完美地方。它为 JavaScript 程序员提供了一个理想的平台,以掌握编写优雅、高效的 TypeScript 代码。

与许多理论性强的书籍不同,《TypeScript 实战工作坊》 在清晰的解释与动手实践的机会之间取得了平衡。你将很快就能开始构建功能网站,而无需翻阅大量关于历史和枯燥、干瘪的内容。指导练习清晰地展示了关键概念在现实世界中的应用,并且每一章都以一个活动结束,挑战你在现实场景中应用新知识。

无论你是热衷于开始下一个项目的业余爱好者,还是希望解锁下一个晋升机会的专业开发者,拿起这本书开始吧!无论你的动机是什么,到这本书的结尾,你都将拥有信心和了解,用 TypeScript 实现你的目标。

关于作者

本·格里豪斯 是一位全栈开发者,对前端充满热情。拥有超过 7 年的经验,其中大部分时间都在使用各种技术栈进行网页开发,他专注于 TypeScript、React 和 Angular。本在微软工作过多个产品,现在是一家营销领域的创新初创公司的一员。他发布了多个开源 npm 模块,帮助在 Angular 应用程序开发中,特别是与 React 集成时。

乔丹·哈德格斯 是一位全栈开发者和 DevCamp 以及 Bottega Code School 的创始人。作为一名拥有 15 年开发经验的开发者,他专注于 Ruby on Rails、React、Vue.js 和 TypeScript,重点在于 API 开发。他为包括 Eventbrite 和 Quip 在内的各种组织构建了应用程序。他发布了和维护多个开源 Node 包管理器 (npm) 模块,帮助个人自动化 JavaScript 和 TypeScript 应用程序的开发过程。此外,他还发布了超过 30 门课程,全球教授了 42,000 名学生,并撰写了多本编程书籍。

雷昂·亨特已经使用 Angular 和 TypeScript 工作了超过 3 年。他构建了复杂的 Web 应用程序,例如车辆管理系统和地方政府使用的土地管理 Web 应用程序。TypeScript 使雷昂能够利用他对 JavaScript 和 Web 框架的知识来构建复杂、可扩展的 Web 应用程序。作为一名开发团队负责人,雷昂亲身体验到大型项目如何随着时间的推移和功能的增加而变得过于复杂,难以修改和扩展。他意识到,在现代 Web 开发中,为项目添加强类型是至关重要的,对他来说,TypeScript 确实是一个真正的游戏改变者。

马特·摩根已经是一名软件工程师、架构师和技术领导者超过 20 年。多年来,他与许多技术合作过,例如 RDBMS、Java 和 Node.js,见证了多代 Web 框架的兴衰。他偶尔是开源软件的贡献者,也是一位频繁的博主。马特最感兴趣的是寻找改进工作流程和开发者体验的方法。一个伟大的工具链是一个力量倍增器。

韦科斯拉夫·斯坦福夫斯基拥有大约二十年的专业开发者经验,使用过各种开发技术。他从上千年前的 JavaScript 开始使用,并与它有着漫长而复杂的爱恨情仇。另一方面,与 TypeScript 的第一次编译就是一见钟情,从那时起就越来越好。他的热情包括构建更好的程序和培养更好的程序员。

本书面向对象:

TypeScript 研讨会是为希望通过学习 TypeScript 来拓宽技能范围的软件开发者设计的。为了最大限度地利用这本书,你应该具备 JavaScript 的基本知识或使用其他类似编程语言的经验。

关于章节:

第一章TypeScript 基础,为你提供了 TypeScript 的基础知识。你将首先学习如何设置编译器选项。然后,你将在 TypeScript 类型和对象上执行各种练习。

第二章声明文件,教你如何从零开始创建声明文件,并实现创建声明文件的常见开发模式。

第三章函数,深入探讨了 TypeScript 中的函数。本章首先介绍了 TypeScript 中的基本函数,然后逐步教授你高级主题,例如类型推断、柯里化和使用导入、导出以及require语法。

第四章类和对象,教你如何定义类并实例化它们以创建对象。你将学习如何创建接受多个对象作为参数的类,以构建动态行为,并自信地使用 TypeScript 生成 HTML 代码。

第五章接口和继承,展示了如何使用 TypeScript 中接口和继承的力量来编写更好、更易于维护的代码,具有结构良好的函数、类和对象,并且能够有效地重用现有代码。

第六章高级类型,教您如何使用类型字面量和类型别名。该章节还讨论了如何实现复杂类型,如交集和联合类型的基本概念。

第七章装饰器,首先建立了装饰器的动机,然后教您如何使用它们在不使应用程序逻辑混乱的情况下向代码添加复杂逻辑。

第八章TypeScript 中的依赖注入,介绍了 TypeScript 中的依赖注入DI)。该章节从 TypeScript 中设计模式的一些基本概念开始,教您如何在简单应用程序中使用 DI 设计模式。

第九章泛型和条件类型,描述了 TypeScript 中泛型和条件类型的基本概念。该章节随后描述了如何使用泛型使您的代码更安全,避免在运行时出现错误。

第十章事件循环和异步行为,首先建立了事件循环和异步行为的动机,然后通过几个练习教您如何在 TypeScript 中使用异步方法。

第十一章高阶函数和回调函数,从 TypeScript 中高阶函数和回调函数的基本概念开始,然后通过几个练习和示例教您如何在 TypeScript 中实现它们。

第十二章TypeScript 中 Promise 指南,首先建立了使用 Promise 的动机,然后教您如何在 TypeScript 中实现它们。

第十三章TypeScript 中的 Async/Await,涵盖了async/await的常见用法,并讨论了 TypeScript 中异步编程的格局。

第十四章TypeScript 和 React,涵盖了 React 库以及如何使用 TypeScript 构建增强型用户界面。您将使用Create React App命令行界面启动 React 应用程序。

习惯用法

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“此代码使用参数'world'调用myFunction,并将函数调用的结果分配给新的常量 message(const message)。”

代码块设置如下:

const message = myFunction('world');
console.log(message);
// Hello world!

在开始之前

在开始执行书中提供的代码之前,请确保您已遵循有关安装所需编译器和代码编辑器的说明。

硬件要求

为了获得最佳体验,我们建议以下硬件配置:

  • 处理器:Intel Core i5 或等效处理器

  • 内存:4 GB RAM

  • 存储:35 GB 可用空间

软件要求

您还需要提前安装以下软件:

  • 操作系统:Windows 7 SP1 64 位、Windows 8.1 64 位或 Windows 10 64 位、Ubuntu Linux 或最新版本的 macOS

  • 浏览器:Google Chrome 或 Mozilla Firefox 的最新版本

安装和设置

VS Code

本书使用 VS Code 作为 IDE 来保存和运行 TypeScript 和 JavaScript 文件。您可以从以下网站下载 VS Code:code.visualstudio.com/download。滚动到页面底部并点击与您的系统相关的下载按钮。按照屏幕上显示的说明操作。

Node.js

您需要安装 Node.js 的最新版本,它包括npm。您可以从 nodejs.org/en/download/ 下载并安装 Node.js。点击并下载与您的系统相关的安装程序。

TypeScript

本书使用 TypeScript 版本 4.1.3。一旦您在系统中安装了 VS Code 和 Node.js,您可以通过打开终端并运行以下命令来安装 TypeScript:

npm install -g typescript@4.1.3

上述命令将全局安装版本 4.1.3。在执行本书中的练习和活动时,您可能还需要安装几个其他库和依赖项。然而,有关如何进行安装的说明已提供在相关的章节/部分。

安装代码包

从 GitHub 下载代码文件,链接为 github.com/PacktWorkshops/The-TypeScript-Workshop。这里的文件包含了每章的练习和活动的代码。这可以作为您阅读本书时的有用参考。您可以选择下载.zip格式的代码文件,或者使用 GitHub Desktop 将整个仓库克隆到您的桌面。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

copyright@packt.com,并附上相关材料的链接。

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com

请留下评论

请通过在 Amazon 上留下详细、公正的评论来告诉我们您的看法。我们非常重视所有反馈——它帮助我们继续制作优质产品并帮助有抱负的开发者提升技能。请抽出几分钟时间留下您的想法——这对我们来说意义重大。您可以通过以下链接在 Amazon 上留下评论:packt.link/r/1838828494

第二章:1. TypeScript 基础知识

概述

在本章中,我们将简要说明 JavaScript 开发环境中存在的问题,并确切了解 TypeScript 是如何帮助我们编写更好、更易于维护的代码的。本章将首先帮助你设置 TypeScript 编译器,然后教授基础知识。此外,我们将开始我们的类型之旅,因为它们是 TypeScript 的核心特性——这就在其名称中。最后,你将能够通过创建自己的库来测试你新获得的语言技能。

简介

在过去的几十年里,在线应用程序的世界已经发生了巨大的变化。随着它的发展,基于 Web 的应用程序不仅在规模上增长,而且在复杂性上也增长。原本被认为是核心应用程序逻辑与用户界面之间的中介语言 JavaScript,现在正以不同的视角被看待。它是开发 Web 应用程序的事实上的语言。然而,它并不是为构建具有许多移动部件的大型应用程序而设计的。TypeScript 随之而来。

TypeScript 是 JavaScript 的超集,它提供了 JavaScript 所缺乏的大量企业级特性,例如模块、类型、接口、泛型、管理异步等。它们使我们的代码更容易编写、调试和管理。在本章中,你将首先了解 TypeScript 编译器的工作原理,编译是如何发生的,以及如何设置编译器选项以满足你的需求。然后,你将直接深入 TypeScript 的类型、函数和对象。你还将学习如何在 TypeScript 中创建自己的类型。最后,你可以通过尝试创建自己的字符串库来测试你的技能。本章作为一个发射台,可以帮助你启动你的 TypeScript 之旅。

TypeScript 的演变

TypeScript 是由微软设计的一种专用语言,其单一目标是——使人们能够编写更好的 JavaScript。但为什么这会成为问题呢?为了理解这个问题,我们必须追溯到网络脚本语言的根源。

在最初,JavaScript 被设计为仅允许在网络上实现基本级别的交互性。

注意

JavaScript 最初是在 1995 年由布兰登·艾奇(Brendan Eich)为在 Netscape Navigator 中使用而开发的。

它并不是专门设计为在网页内运行的主体语言,而是一种在浏览器和插件(如在该网站上运行的 Java 小程序)之间的粘合剂。重负载应该由插件代码来完成,JavaScript 提供一层简单的互操作性。JavaScript 甚至没有任何方法可以使其访问服务器。JavaScript 的另一个设计目标是它必须易于非专业开发者使用。这意味着该语言必须对错误非常宽容,并且在语法上相当宽松。

几年来,JavaScript(或者更准确地说,ECMAScript,因为它已经被标准化)实际上就是在做这项任务。但随着越来越多的网页出现,越来越多的网页需要动态内容。突然之间,人们需要使用大量的 JavaScript。网页变得越来越复杂,现在它们被称为 Web 应用程序。JavaScript 通过 AJAX 获得了访问服务器甚至其他网站的能力,并出现了一个完整的生态系统,这些库帮助我们编写更好的 Web 应用程序。

然而,该语言本身仍然缺少许多大多数语言都具备的功能——主要是针对专业开发者的功能。

注意

其中一些最常讨论的功能包括缺少模块/命名空间支持、类型检查表达式、更好的作用域机制以及更好的异步功能支持。

由于它是为小规模使用而设计的,因此构建和维护用 JavaScript 构建的大型应用程序非常麻烦。另一方面,一旦它被标准化,JavaScript 就成为了在浏览器内部实际运行代码的唯一方式。因此,2000 年代流行的一种解决方案是创建一个仿真层——一种工具,允许开发者使用他们喜欢的语言开发应用程序,该应用程序将原始源代码作为输入,并输出等效的 JavaScript 代码。这样的工具被称为转译器——它是“translator”(翻译器)和“compiler”(编译器)两个词的组合。虽然传统的编译器将源代码作为输入,并输出可以在目标机器上直接执行的机器代码,但转译器基本上是将源代码从一种语言翻译成另一种语言,特别是翻译成 JavaScript。然后,生成的代码在浏览器上执行。

注意

实际上,代码是在浏览器内部编译的,但这又是另一个故事。

当时存在两组显著的转译器——一组是从现有语言(C#、Java、Ruby 等)转译而来,另一组是从专门设计来简化 Web 开发的语言(CoffeeScript、Dart、Elm 等)转译而来。

注意

你可以在packt.link/YRoA0看到一份全面的列表。

大多数转译器的主要问题在于它们不是针对 Web 和 JavaScript 本地的。生成的 JavaScript 代码令人困惑且不符合语言习惯——看起来像是机器写的而不是人写的。这本来是可以的,但生成的混乱代码实际上是正在执行的代码。因此,使用转译器意味着我们必须放弃调试体验,因为我们无法理解实际正在运行的内容。此外,生成的代码文件大小通常很大,而且往往包括一个巨大的基础库,在我们可以运行转译后的代码之前需要加载。

基本上,到 2012 年,有两个选择——使用纯 JavaScript 编写大型 Web 应用程序,所有这些缺点都存在,或者使用转换器编写大型 Web 应用程序,编写更好、更易于维护的代码,但我们的代码实际上运行的平台上被移除。

然后,TypeScript 被引入。

注意

可以在channel9.msdn.com/Events/Build/2012/3-012找到介绍性讲座的视频。

TypeScript 的设计目标

它背后的核心思想,事后看来似乎相当明显。为什么不直接用另一种语言替换 JavaScript,而是添加缺失的功能呢?为什么不以这种方式添加它们,以便在转换步骤中可以非常合理地移除它们,这样生成的代码不仅看起来和感觉是惯用的,而且相当小且性能良好?如果我们能够添加诸如静态类型这样的功能,但以可选的方式,这样我们就可以根据需要使用多少?如果我们开发时所有这些功能都存在,我们就可以拥有良好的工具和良好的环境,同时我们仍然能够调试和理解生成的代码?

TypeScript 的设计目标,如最初所述,如下所示:

  • 扩展 JavaScript 以方便编写大型应用程序。

  • 创建一个 JavaScript 的严格超集(即,任何有效的 JavaScript 都是有效的 TypeScript)。

  • 增强开发工具支持。

  • 生成在任何 JavaScript 执行环境中运行的 JavaScript。

  • TypeScript 和 JavaScript 代码之间的轻松迁移。

  • 生成干净、惯用的 JavaScript。

  • 与未来的 JavaScript 标准保持一致。

听起来像是一个天方夜谭的承诺,最初的反应有些冷淡。但随着时间的推移,当人们真正尝试并开始在现实应用中使用它时,好处变得明显。

注意

作者关于 TypeScript 的讲座,这是第一个由非微软员工向全球播出的讲座,可以在www.slideshare.net/sweko/typescript-javascript-done-right找到。

TypeScript 成为主要玩家的两个领域是 JavaScript 库和服务器端 JavaScript,其中类型检查的严格性和正式模块的引入使得代码质量更高。目前,所有最受欢迎的 Web 开发框架要么是原生用 TypeScript 编写的(如 Angular、Vue 和 Deno),要么与 TypeScript 有紧密的集成(如 React 和 Node)。

TypeScript 入门

考虑以下 TypeScript 程序——一个简单的添加两个数字的函数:

Example 01.ts
1 function add (x, y) {
2    return x + y;
3 }
Link to the example on GitHub: https://packt.link/P9k6d

不,这不是一个玩笑——这是真实的 TypeScript。我们只是没有使用任何 TypeScript 特定的功能。我们可以将此文件保存为add.ts,并使用以下命令将其编译为 JavaScript:

tsc add.ts

这将生成我们的输出文件,add.js。如果我们打开它并查看内部内容,我们可以看到生成的 JavaScript 代码如下:

Example 01.js
1 function add(x, y) {
2     return x + y;
3 }
Link to the example on GitHub: https://packt.link/mTfWp

是的,除了一些间距外,代码是相同的,我们完成了第一次成功的转换。

TypeScript 编译器

当然,我们将在示例中添加更多内容,但让我们花一点时间分析一下发生了什么。首先,我们给文件添加了.ts文件扩展名。所有 TypeScript 文件都有这个扩展名,它们包含我们应用程序的 TypeScript 源代码。但是,即使我们的代码是有效的 JavaScript(如本例所示),我们也不能直接在浏览器中加载.ts文件并运行它们。我们需要使用名为“TypeScript 编译器”的工具或简称tsc来编译/转换它们。这个工具的作用是将 TypeScript 文件作为参数,生成 JavaScript 文件作为输出。在我们的例子中,我们的输入是add.ts,输出是add.jstsc编译器是一个非常强大的工具,它有很多我们可以设置的选项。我们可以使用此命令获取选项的完整列表:

tsc --all

最常见且最重要的选项如下:

  • –outFile:使用此选项,我们可以指定要生成的输出文件名。如果没有指定,它将默认为与输入文件相同的名称,但带有.js扩展名。

  • –outDir:使用此选项,我们可以指定输出文件的位置。默认情况下,生成的文件将与源文件位于同一位置。

  • –types:使用此选项,我们可以指定在源代码中允许的附加类型。

  • –lib:使用此选项,我们指定需要加载的库文件。由于 JavaScript 有不同的执行环境,默认库也不同(例如,浏览器 JavaScript 有一个window对象,而 Node.js 有一个process对象),我们可以指定我们想要的目标。我们还可以使用此选项允许或禁止特定的 JavaScript 功能。例如,array.include方法是在es2016 JavaScript 版本中添加的。如果我们假设该方法将是可用的,那么我们需要添加es2016.array.include库。

  • –target:使用此选项,我们指定要针对的 ECMAScript(即 JavaScript)语言的版本。也就是说,如果我们需要支持旧版浏览器,我们可以使用ES3ES5值,这将编译我们的代码为可以在相应地支持 JavaScript 语言 3 和 5 版本的任何环境中执行的 JavaScript 代码。另一方面,如果我们知道我们将在一个超现代环境中运行,比如最新的 Node.js 运行时,我们可以使用ES2020目标,甚至ESNEXT,这是 ECMAScript 语言的下一个可用版本。

  • 还有更多选项;然而,我们在这里只讨论了其中的一些。

设置 TypeScript 项目

由于 TypeScript 编译器有很多选项,而我们又需要使用其中很多选项,每次转换文件时指定所有选项会很快变得繁琐。为了避免这种情况,我们可以将默认选项保存在一个特殊文件中,该文件将由 tsc 命令访问。生成此特殊文件(名为 tsconfig.json)的最佳方式是使用带有 --init 选项的 tsc 本身。因此,导航到您想要存储 TypeScript 项目的文件夹,并执行以下命令:

tsc --init

这将生成一个包含最常用选项的 tsconfig.json 文件。其余选项已注释掉,因此如果我们想使用其他一组选项,我们可以简单地取消注释所需的选项。如果我们忽略注释(其中包含有关选项的文档链接),我们得到以下内容:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

您可以看到 tsconfig.json 文件中的每个选项都有一个对应的命令行开关,例如 moduletarget 等。如果指定了命令行开关,则具有优先级。然而,如果没有定义命令行开关,则 tsc 会查找目录层次结构中的最近 tsconfig.json 文件,并采用那里指定的值。

练习 1.01:使用 tsconfig.json 和 TypeScript 入门

在这个练习中,我们将看到如何使用 tsconfig.json 文件来命令 TypeScript。我们将看到如何创建 TypeScript 文件并将它们转换为 JavaScript,这取决于我们指定的选项:

注意

请确保您已安装Visual StudioVS)Code,并按照前言中提到的安装步骤进行操作。本练习的代码文件可以在此处找到:packt.link/30NuU

  1. 创建一个新的文件夹,并在其中打开一个新的终端,然后执行以下命令:

    tsc --init
    
  2. 验证文件夹内是否已创建一个新的 tsconfig.json 文件,并且其目标值是 es5

  3. 在其中创建一个名为 squares.ts 的新文件。

  4. squares.ts 文件中,创建一个名为 squares 的函数:

    function squares(array: number[]) {
    
  5. 使用 JavaScript map 函数和箭头函数参数从输入参数创建一个新数组:

        const result = array.map(x => x * x);
    
  6. 从函数中返回新的数组:

        return result;
    }
    
  7. 保存文件,并在文件夹中运行以下命令:

    tsc squares.ts
    
  8. 验证文件夹中是否有一个名为 squares.js 的新文件,其内容如下:

    function squares(array) {
        var result = array.map(function (x) { return x * x; });
        return result;
    }
    

    这里,我们可以看到转换步骤做了几件事情:

    • 它从 array: number[] 参数中移除了类型注解,将其转换为 array

    • 它将 const result 变量声明更改为 var result 声明。

    • 它将箭头函数 x=>x*x 转换为普通函数 function (x) { return x * x; }

    虽然第一个是 TypeScript 特定的代码,第二个和第三个是 TypeScript 向后兼容性的示例——箭头函数和 const 声明都是 JavaScript 特性,这些特性是在语言 ES6 版本中引入的。

  9. 在文件夹中运行以下命令:

    tsc --target es6 squares.ts
    

    这将覆盖 tsconfig.json 文件中的设置,并将 TypeScript 代码转换为与 ES6 兼容的 JavaScript。

  10. 验证 squares.js 文件的内容现在如下所示:

    function squares(array) {
        const result = array.map(x => x * x);
        return result;
    }
    

    你可以注意到,与第 8 步的结果相比,现在 const 关键字和箭头函数仍然完好无损,因为指定的目标环境原生支持它们。这是 TypeScript 的一个极其重要的特性。有了这个特性,即使我们不使用 TypeScript 提供的丰富类型系统,我们也可以编写最现代版本的 JavaScript 代码,并且 TypeScript 会无缝地将我们的代码转换为顾客实际可以消费的版本。

类型及其用途

我们已经提到 TypeScript 的类型系统是其区别于其他语言的特征,因此让我们更深入地了解一下。JavaScript 被称为弱类型语言。这意味着它不对定义的变量及其值施加任何规则。例如,假设我们定义一个名为 count 的变量并将其设置为 3 的值:

let count = 3;

没有任何东西阻止我们将该变量设置为字符串、日期、数组或基本上任何对象的值。以下所有赋值都是有效的:

count = "string";
count = new Date();
count = false;
count = [1, 2, 3];
count = { key: "value" };

在几乎所有场景中,这并不是我们真正希望的行为。此外,由于 JavaScript 无法在编写代码时知道变量包含的是字符串还是数字,它无法阻止我们尝试,例如,将其转换为小写。我们无法知道该操作是否成功或失败,直到我们实际尝试运行代码的那一刻。

让我们看一个以下示例:

let variable;
if (Math.random()>0.5) {
    variable = 3;
} else {
    variable = "String";
}
console.log(variable.toLowerCase());

这段代码将输出 "String" 或抛出 variable.toLowerCase is not a function 错误。确定这段代码是否会出错的唯一方法就是实际运行它。简而言之,在弱类型语言中,虽然值本身有类型,但变量另一方面却没有。它们只是取当前持有的值的类型。因此,任何关于变量上是否可以执行方法的检查,例如 variable.toLowerCase(),只能在我们有实际值时进行,也就是说,当我们运行代码时。再次强调,这对于小型应用程序来说相当不错,但对于大型应用程序来说可能会变得繁琐。相比之下,强类型语言对值和它们所居住的变量的类型规则进行强制执行。这意味着语言本身可以在你编写代码时检测到错误,因为它对你的代码正在发生的事情有更多的信息。

因此,在一个大型软件产品中,(在大多数情况下)我们不希望变量具有不同类型的值。因此,我们希望能够以某种方式说明“这个变量必须是数字,如果有人试图在其中放入非数字的内容,则引发错误。”

这就是 TypeScript 作为一种强类型语言发挥作用的地方。我们可以使用两种方法来将变量绑定到类型。更简单的一种方法是将我们想要的类型直接注解到变量上,就像这样:

let variable: number;

代码中的 : number 部分被称为 类型注解,我们正是这样做的——说“这个变量必须是一个数字,如果有人试图在其中放入不是数字的东西,就抛出一个错误。”

现在,如果我们尝试将一个数字赋值给这个变量,一切正常。但是,当我们尝试将一个字符串赋值给变量时,我们会收到一个错误信息:

Figure 1.1: Error message from assigning an incorrect type

img/B14508_01_01.jpg

图 1.1:赋值错误类型时的错误信息

这种类型的注解是明确的,并且是 TypeScript 特有的。另一种方法是将一个值赋给一个变量,让 TypeScript 发挥其魔法。这种魔法被称为 类型推断,这意味着 TypeScript 将尝试根据提供的值猜测变量的类型。

让我们定义一个变量并用一个值来初始化它,就像这样:

let variable = 3;

现在,如果我们尝试将一个字符串赋值给这个变量,TypeScript 将会抛出一个错误:

Figure 1.2: Error message from assigning an incorrect type

img/B14508_01_02.jpg

图 1.2:赋值错误类型时的错误信息

从错误信息中,我们可以看到 TypeScript 正确推断出的变量的类型——number。实际上,在大多数情况下,我们甚至不需要添加类型注解,因为 TypeScript 强大的类型推断引擎将正确推断出变量的类型。

TypeScript 和函数

TypeScript 的另一个巨大好处是自动函数调用检查。假设我们使用了我们第一个 TypeScript 文件中的函数:

function add (x, y) {
    return x + y;
}

即使没有任何类型注解,TypeScript 仍然对这个函数有一些信息——即它接受两个,且恰好两个参数。

与此相反,JavaScript 不强制实际参数的数量必须符合定义的参数数量,因此以下所有调用在 JavaScript 中都是有效的调用:

add(1, 2); // two arguments
add(1, 2, 3); // three arguments
add(1); // one argument
add(); // no arguments

在 JavaScript 中,我们可以用一个比参数多的参数个数、少的参数个数,甚至没有任何参数的方式来调用一个函数。如果我们提供的参数多于所需的,额外的参数将被简单地忽略(并存储在神奇的 arguments 变量中),如果我们提供的参数少于所需的,额外的参数将被赋予 undefined 的值。因此,本质上,前面的调用将被相应地转换为以下形式:

add(1, 2); // no changes, as the number of arguments match the number of parameters.
add(1, 2); // the third argument is ignored
add(1, undefined); // the second parameter is given a value of undefined
add(undefined, undefined); // both parameters are given a value of undefined

在第三和第四种情况下,函数的返回值将是特殊的数值 NaN

TypeScript 对此问题采取了根本不同的方法。一个函数只能使用有效的参数调用——无论是数量还是类型。因此,如果我们用相同的代码,但这次是在 TypeScript 文件中编写,我们将得到适当的错误信息。对于有额外参数的情况,我们将在额外参数上得到错误信息:

![图 1.3:使用错误数量的参数的错误信息——参数过多]

参数——参数过多

![图片 B14508_01_03.jpg]

![图 1.3:使用错误数量的参数的错误信息——参数过多]

对于参数数量过少的情况,错误信息会显示在方法本身上:

![图 1.4:使用错误数量的参数的错误信息]

参数数量——在这个例子中参数过少

![图片 B14508_01_04.jpg]

![图 1.4:使用错误数量的参数的错误信息——参数过少]

在这种情况下,我们会收到通知,指出缺少了一个必需的参数,以及该参数的名称和类型。请注意,在 JavaScript 中,有一个常见的技巧,即方法可以接受可变数量的参数,可以接受可选参数,或者如果未指定参数,则提供一些默认值。所有这些情况(以及更多)都由 TypeScript 正确处理。

注意

关于如何使用 TypeScript 编写此类方法的详细信息包含在第三章“函数”中。

当然,参数检查不仅适用于参数的数量,也适用于参数的类型。我们希望add函数只与数字一起工作——例如,将布尔值和对象相加是没有意义的。在 TypeScript 中,我们可以这样注释我们的函数:

function add (x: number, y: number) {
    return x + y;
}

这将导致编译器不仅检查参数数量是否与参数数量匹配,还要验证用于参数的类型是否实际有效。由于 JavaScript 无法检查这一点,添加一个布尔值和一个对象实际上是调用我们add方法 JavaScript 等价物的有效调用。此外,由于 JavaScript 尽可能地宽容,我们甚至不会得到运行时错误——调用将成功,因为 JavaScript 会将这两个值都强制转换为共同的字符串表示形式,然后尝试(并成功)将这两个值相加。

让我们将以下对函数的调用解释为 JavaScript 和 TypeScript:

const first = { property: 'value'};
const second = false;
const result = add(first, second);

这段 JavaScript 代码虽然不合逻辑,但却是有效的。如果运行,它将产生 [object Object]false 的结果,这在任何上下文中都不会有用。

将相同的代码解释为 TypeScript,将产生以下编译类型错误:

![图 1.5:VS Code 上的错误信息]

![图片 B14508_01_05.jpg]

![图 1.5:VS Code 上的错误信息]

我们还可以注释函数的返回类型,在参数列表之后添加类型注释:

function add (x: number, y: number): number {
    return x + y;
}

通常情况下并不需要这样做,因为 TypeScript 实际上可以从给定的返回语句中推断出返回类型。在我们的例子中,由于xy是数字,x+y也将是数字,这意味着我们的函数将返回一个数字。然而,如果我们确实注释了返回类型,TypeScript 也会强制执行该合约:

图 1.6:TypeScript 强制执行正确的类型

图片

图 1.6:TypeScript 强制执行正确的类型

在任何情况下,无论是显式注释返回类型还是推断,函数的类型都将应用于调用函数产生的任何值。因此,如果我们将返回值赋给某个变量,该变量也将具有number类型:

图 1.7:VS Code 显示变量的类型

图片

图 1.7:VS Code 显示变量的类型

此外,如果我们尝试将返回值赋给已知不是数字的变量,我们会得到适当的错误:

图 1.8:VS Code 上的错误信息

图片

图 1.8:VS Code 上的错误信息

让我们再谈谈 TypeScript 及其类型系统的一个优点。如所见,本章中的截图并没有显示实际的编译器错误信息——它们是从代码编辑器内部(VS Code,这是一个用 TypeScript 编写的编辑器)获取的。

我们甚至不必实际编译代码。相反,我们在编写代码时得到了错误信息——这是其他强类型语言(如 C#或 Java)的开发者所熟悉的经验。

这是因为 TypeScript 编译器的设计,特别是其语言服务 API。这使得编辑器能够轻松地使用编译器来检查代码,以便我们得到一个良好且直观的 GUI。此外,由于所有编辑器都将使用相同的编译器,因此不同编辑器之间的开发体验将相似。这与我们最初的情况形成了鲜明对比——完全编写、加载并实际执行 JavaScript 代码,以了解它是否有意义。

注意

近年来,一些编辑器已经开始在 JavaScript 代码上使用 TypeScript 语言服务 API,因此 TypeScript 甚至提高了纯 JavaScript 的开发体验。

简而言之,使用 TypeScript 将 JavaScript 开发中最普遍的痛点之一——不一致且有时甚至不可能的工具支持——转变为更简单、更方便的体验。在我们的案例中,我们只需要在调用add函数时打开一个括号,我们就会看到以下内容:

图 1.9:函数可以接受的参数列表

图片

图 1.9:函数可以接受的参数列表

我们可以看到一个参数列表,显示该函数(可以由另一位开发者定义,位于另一个文件中)接受两个数字,并返回一个数字。

练习 1.02:在 TypeScript 中使用函数

在这个练习中,我们将定义一个简单的函数,并看看我们如何可以和不可以调用它。我们将开发的函数将是一个字符串实用函数,用于将字符串缩短为片段。我们基本上会在给定的长度之后截断文本,但要注意不要将单词截成两半。如果字符串长度超过最大长度,我们将在末尾添加省略号():

注意

这个练习的代码文件可以在以下位置找到:packt.link/BHj53

  1. 创建一个名为snippet.ts的新文件。

  2. snippet.ts中,定义一个名为snippet的简单函数:

    function snippet (text: string, length: number) : string {
    
  3. 检查文本是否小于指定的长度,如果是,则返回它未更改:

        if (text.length < length) {
            return text;
        }
    
  4. 如果文本长度超过最大长度,我们需要添加省略号。我们能够显示的最大字符数是指定的长度减去省略号的长度(因为它也占用空间)。我们将使用slice字符串方法从文本中提取这么多字符:

        const ellipsis = "...";
        let result = text.slice(0, length - ellipsis.length);
    
  5. 我们将使用lastIndexOf找到截止点之前的最后一个单词边界,然后结合到那个点的文本和省略号:

        const lastSpace = result.lastIndexOf(" ");
        result = `${result.slice(0, lastSpace)}${ellipsis}`;
    
  6. 从函数返回结果:

        return result;
    }
    
  7. 在函数之后,创建几个使用不同参数类型的函数调用:

    // correct call and usage
    const resultOne = snippet("TypeScript is a programming language that is a strict syntactical superset of JavaScript and adds optional static typing to the language.", 40);
    console.log(resultOne);
    // missing second parameter
    const resultTwo = snippet("Lorem ipsum dolor sit amet");
    console.log(resultTwo);
    // The first parameter is of incorrect type
    const resultThree = snippet(false, 40);
    console.log(resultThree);
    // The second parameter is of incorrect type
    const resultFour = snippet("Lorem ipsum dolor sit amet", false);
    console.log(resultFour);
    // The result is assigned to a variable of incorrect type
    var resultFive: number = snippet("Lorem ipsum dolor sit amet", 20);
    console.log(resultFive);
    
  8. 保存文件并在文件夹中运行以下命令:

    tsc snippet.ts
    
  9. 验证文件没有正确编译。您将从编译器那里获得关于找到的错误的具体信息,并且编译将以以下消息结束:

    Found 3 errors.
    
  10. 注释掉或删除除了第一个之外的所有调用:

    // correct call and usage
    var resultOne = snippet("TypeScript is a programming language that is a strict syntactical superset of JavaScript and adds optional static typing to the language.", 40);
    console.log(resultOne);
    
  11. 保存文件并再次编译:

    tsc snippet.ts
    
  12. 验证编译成功结束,并且在同一文件夹中生成了一个snippet.js文件。在node环境中使用以下命令执行它:

    node snippet.js
    

    您将看到一个如下所示的输出:

    TypeScript is a programming language...
    

在这个练习中,我们开发了一个简单的字符串实用函数,使用了 TypeScript。我们看到了 TypeScript 的两个主要优势。一方面,我们可以看到代码是符合 JavaScript 语法的 – 我们可以利用现有的 JavaScript 知识来编写函数。从步骤 3步骤 6,函数的实际主体在 JavaScript 和 TypeScript 中完全相同。

接下来,我们看到 TypeScript 会确保我们正确地调用函数。在步骤 7中,我们尝试了五种不同的函数调用方式。最后四种调用是错误的 – 在 JavaScript 或 TypeScript 中它们都会导致错误。重要的区别是,使用 TypeScript,我们立即得到了关于使用无效的反馈。在 JavaScript 中,错误只有在实际执行代码时,我们或客户端才会看到。

TypeScript 和对象

JavaScript 的一个优点是其对象字面量语法。在有些语言中,为了创建一个对象,我们必须做很多前期工作,比如创建类和定义构造函数,但在 JavaScript 中,以及 TypeScript 中,我们只需将对象作为字面量创建。所以,如果我们想创建一个具有 firstNamelastName 属性的 person 对象,我们只需要编写以下内容:

const person = {
    firstName: "Ada",
    lastName: "Lovelace"
}

JavaScript 使得创建和使用对象变得很容易,就像任何其他值一样。我们可以访问其属性,将其作为参数传递给 methods,从函数中接收它作为 return 值,等等。由于 JavaScript 的动态特性,很容易向我们的对象添加属性。如果我们想向对象添加一个 age 属性,我们可以简单地写出以下内容:

person.age = 36;

然而,由于类型宽松,JavaScript 对我们的对象一无所知。它不知道我们对象的可能属性是什么,以及哪些方法和不能将其用作参数或返回值。所以,比如说我们犯了一个拼写错误,例如,写出如下内容:

console.log("Hi, " + person.fristName);

JavaScript 会愉快地执行此代码并输出 Hi undefined。这并不是我们想要的,并且只有在代码在浏览器中实际运行时才会可见和可检测。使用 TypeScript,我们有几种选项来解决这个问题。所以,让我们用 TypeScript 重新编写我们的 person 对象:

const person = {
    firstName: "Ada",
    lastName: "Lovelace"
}
console.log(`Hi, ${person.fristName}`);

此代码将被编译器立即标记为无效,即使我们没有添加任何类型信息:

![图 1.10:TypeScript 编译器推断对象的类型图片

![图 1.10:TypeScript 编译器推断对象的类型从错误信息中,我们可以看到 TypeScript 编译器为我们对象的类型推断了什么——它认为其类型由两个属性组成,firstName 类型为 stringlastName 类型为 string。根据这个定义,没有名为 fristName 的另一个属性的位置,所以我们收到了一个错误。注意注意到建议 你是指 'firstName' 吗? 以及到 person 类定义的链接。由于拼写错误很常见,类型推断算法试图检测并提供对常见拼写错误的建议。因此,再次强调,我们仅通过使用 TypeScript 就检测到了代码中的错误,而无需编写任何额外的代码。TypeScript 通过分析对象的定义并从中提取数据来实现这一点。它将允许我们编写如下代码:jsperson.lastName = "Byron";但它不会允许我们编写将 lastName 设置为数字的代码:![图 1.11:将 lastName 分配为错误类型时的错误信息图片

图 1.11:将 lastName 分配为错误类型时的错误信息

有时候,我们对对象的形状比 TypeScript 更了解。例如,TypeScript 推断出我们的类型只有 firstNamelastName 属性。所以,如果我们用 person.age = 36; 在 TypeScript 中设置年龄,我们会得到一个错误。在这种情况下,我们可以显式地定义我们对象的类型,使用 TypeScript 接口。我们可以使用的语法如下:

interface Person {
    firstName: string;
    lastName: string;
    age? : number;
}

使用这段代码,我们定义了一个抽象 – 一个某些对象需要满足的结构,以便能够被当作 Person 对象对待。注意 age 变量旁边的问号(?)。这表示该属性实际上是可选的。一个对象不一定要有 age 属性才能成为 Person 对象。然而,如果它有 age 属性,该属性必须是数字。另外两个属性(firstNamelastName)是必需的。

使用这个定义,我们可以使用以下方式定义和使用我们的对象:

const person: Person = {
    firstName: "Ada",
    lastName: "Lovelace"
}
person.age = 36;

我们可以将接口用作函数参数和返回类型的类型注解。例如,我们可以定义一个名为 showFullName 的函数,它将接受一个人员对象并在控制台显示全名:

function showFullName (person: Person) {
    console.log(`${person.firstName} ${person.lastName}`)
}

如果我们用 showFullName(person) 调用这个函数,我们会在控制台看到它将显示 Ada Lovelace。我们还可以定义一个函数,它将接受两个字符串,并返回一个符合 Person 接口的新对象:

function makePerson (name: string, surname: string): Person {
    const result = {
        firstName: name,
        lastName: surname
    }
    return result;
}
const babbage = makePerson("Charles", "Babbage");
showFullName(babbage);

我们需要指出的一件重要的事情是,与其它语言不同,TypeScript 中的接口是结构性的,而不是名义性的。这意味着,如果我们有一个满足接口“规则”的特定对象,那么这个对象可以被认为是该接口的值。在我们的 makePerson 函数中,我们没有指定 result 变量是 Person 类型 – 我们只是使用了一个具有 firstNamelastName 属性的对象字面量,这些属性都是字符串。由于这足以被认为是人,代码可以编译并正常运行。这对类型推断系统来说是一个巨大的好处,因为我们可以在不显式定义它们的情况下进行大量的类型检查。事实上,省略函数的返回类型是很常见的。

练习 1.03:与对象一起工作

在这个练习中,我们将定义一个简单的对象,它封装了一个带有几个属性的书籍。我们将尝试访问和修改对象的数据,并验证 TypeScript 根据推断或显式规则来约束我们。我们还将创建一个函数,该函数接受一个书籍对象并打印出书籍的详细信息:

注意

这个练习的代码文件可以在以下链接找到:packt.link/N8y1f

  1. 创建一个名为 book.ts 的新文件。

  2. book.ts 中,定义一个简单的接口 Book。我们将为书籍的作者和标题添加属性,为书籍的页数添加可选属性,以及一个表示我们是否已阅读这本书的布尔值:

    interface Book {
        author: string;
        title: string;
        pages?: number;
        isRead?: boolean;
    }
    
  3. 添加一个名为showBook的函数,该函数将显示书籍的作者和标题到控制台。它还应显示书籍是否已被阅读,即isRead属性是否存在:

    function showBook(book: Book) {
        console.log(`${book.author} wrote ${book.title}`);
        if (book.isRead !== undefined) {
            console.log(`  I have ${book.isRead ? "read" : "not read"} this book`);
        }
    }
    
  4. 添加一个名为setPages的函数,该函数将接受一本书和页数作为参数,并将书的pages属性设置为提供的值:

    function setPages (book: Book, pages: number) {
        book.pages = pages;
    }
    
  5. 添加一个名为readBook的函数,该函数将接受一本书并将其标记为已阅读:

    function readBook(book: Book) {
        book.isRead = true;
    }
    
  6. 创建几个满足接口的对象。你可以,但不必,用我们创建的接口注释它们:

    const warAndPeace = {
        author: "Leo Tolstoy",
        title: "War and Peace",
        isRead: false
    }
    const mobyDick: Book = {
        author: "Herman Melville",
        title: "Moby Dick"
    }
    
  7. 添加一个调用书籍上方法的代码:

    setPages(warAndPeace, 1225);
    showBook(warAndPeace);
    showBook(mobyDick);
    readBook(mobyDick);
    showBook(mobyDick);
    
  8. 保存文件并在文件夹中运行以下命令:

    tsc book.ts
    
  9. 验证编译是否成功结束,并在同一文件夹中生成了一个book.js文件。使用以下命令在node环境中执行它:

    node book.js
    

    你将看到如下所示的输出:

    Leo Tolstoy wrote War and Peace
      I have not read this book
    Herman Melville wrote Moby Dick
    Herman Melville wrote Moby Dick
      I have read this book
    

在这个练习中,我们创建并使用了接口,这是一个纯 TypeScript 结构。我们用它来描述我们将使用的对象的形状。在没有实际创建任何特定形状的对象的情况下,我们能够使用 TypeScript 的工具和类型推断的全部功能来创建几个操作给定形状对象的函数。

之后,我们实际上创建了一些具有所需形状的对象(无论是否明确声明)。我们能够将这两种类型的对象作为函数的参数使用,并且结果与我们所声明的接口一致。

这演示了添加一个接口如何使我们的代码在编写和执行时更加安全。

基本类型

尽管 JavaScript 是一种弱类型语言,但这并不意味着值没有类型。JavaScript 开发者可以使用几种原始类型。我们可以使用typeof运算符来获取值的类型,该运算符在 JavaScript 和 TypeScript 中都可用。让我们检查一些值并查看结果:

const value = 1234;
console.log(typeof value); 

上述代码的执行将在控制台写入字符串"number"。现在,考虑另一个片段:

const value = "textual value";
console.log(typeof value); 

上述表达式将在控制台写入字符串"string"。考虑以下片段:

const value = false;
console.log(typeof value); 

这将在控制台输出"boolean"

所有的前述类型都被称为“原始类型”。它们直接嵌入到执行环境中,无论是浏览器还是服务器端应用程序。我们总是可以根据需要使用它们。还有一个只有一个值的额外原始类型,那就是 undefined 类型,其唯一值就是 undefined。如果我们尝试对 undefined 调用 typeof,我们将收到字符串 "undefined"。除了原始类型之外,JavaScript 和 TypeScript 还有两种所谓的“结构化”类型。分别是对象,即包含数据的自定义代码片段,和函数,即包含逻辑的自定义代码片段。这种数据和逻辑之间的区别并不是一个明确的界限,但它可以是一个有用的近似。例如,我们可以使用对象字面量语法定义一个具有一些属性的对象:

const days = {
    "Monday": 1,
    "Tuesday": 2,
    "Wednesday": 3,
    "Thursday": 4,
    "Friday": 5,
    "Saturday": 6,
    "Sunday": 7,
}

days 对象调用 typeof 操作符将返回字符串 "object"。如果我们有一个之前定义的 add 函数,我们也可以使用 typeof 操作符:

function add (x, y) {
    return x + y;
}
console.log(typeof add);

这将显示字符串 "function"

注意

JavaScript 的最新版本增加了 bigintsymbol 作为原始类型,但它们只在特定场景下才会遇到。

练习 1.04:检查 typeof

在这个练习中,我们将看到如何使用 typeof 操作符来确定值的类型,并且我们将调查这些响应:

注意

本练习的代码文件可以在以下链接找到:packt.link/uhJqN

  1. 创建一个名为 type-test.ts 的新文件。

  2. type-test.ts 文件中,定义几个具有不同值的变量:

    const daysInWeek = 7;
    const name = "Ada Lovelace";
    const isRaining = false;
    const today = new Date();
    const months = ["January", "February", "March"];
    const notDefined = undefined;
    const nothing = null;
    const add = (x:number, y: number) => x + y;
    const calculator = {
        add
    }
    
  3. 使用数组字面量语法将所有变量添加到一个包含数组中:

    const everything = [daysInWeek, name, isRaining, today, months, notDefined, nothing, add, calculator];
    
  4. 使用 for..of 循环遍历所有变量,并对每个值调用 typeof 操作符。在控制台上显示结果,包括值本身:

    for (const something of everything) {
        const type = typeof something;
        console.log(something, type);
    }
    
  5. 保存文件,并在文件夹中运行以下命令:

    tsc type-test.ts
    
  6. 编译完成后,你将有一个 type-test.js 文件。使用以下命令在 node 环境中执行它:

    node type-test.js
    

    你将看到以下输出:

    7 number
    Ada Lovelace string
    false boolean
    2021-04-05T09:14:56.259Z object
    [ 'January', 'February', 'March' ] object
    undefined undefined
    null object
    [Function: add] function
    { add: [Function: add] } object
    

特别注意 monthsnothing. typeof 变量的输出,它们对于数组和 null 值都会返回字符串 "object"。还要注意,calculator 变量是一个对象,其唯一的属性实际上是一个函数;也就是说,我们有一个对象,其数据部分实际上是一段逻辑。这是可能的,因为函数在 JavaScript 和 TypeScript 中是一等值,这意味着我们可以像处理常规值一样操作它们。

字符串

单词和文本是任何应用程序的一部分,就像它们是日常生活的一部分一样。在 JavaScript 中,它们由string类型表示。与 C++或 Java 等其他语言不同,JavaScript 中的字符串不是作为由更小部分(字符)组成的类似数组的对象来处理的。相反,字符串是 JavaScript 的一等公民。此外,JavaScript 字符串原生支持 Unicode,因此我们不会遇到像西里尔文或阿拉伯文这样的字符问题。就像在 JavaScript 中一样,要在 TypeScript 中定义一个字符串,我们可以使用单引号(')或双引号(")。当然,如果我们以单引号开始字符串,我们必须以单引号结束,反之亦然。我们还可以使用一种特殊的字符串定义类型,称为模板字符串。这些字符串由反引号字符(``` ` ``)分隔,并支持对 Web 开发非常重要的两个特性——换行和嵌入表达式。它们在所有支持 ES2015 的环境中得到支持,但 TypeScript 能够编译到任何 JavaScript 目标环境。

在字符串内部使用嵌入表达式和换行,使我们能够生成漂亮的 HTML,因为与字符串连接不同,我们能够使用嵌入表达式来获得对生成输出的更清晰视图。例如,如果我们有一个具有firstNamelastName属性的person对象,并且我们想在<div>标签内显示一个简单的问候语,我们必须编写如下代码:

const html = "<div class=\"greeting\">\nHello, " + firstName + " " + lastName + "\n</div>";

从这段代码(它可以变得更加复杂)中,很难看出实际会写什么以及在哪里写。使用模板字符串可以将它转换为以下形式:

const html = `<div class="greeting">
    Hello, ${firstName} ${lastName}
</div>";

为了输出firstNamelastName的值,我们必须用括号({})将它们括起来,并在其前加上美元符号($)。我们不仅限于变量名,还可以有整个表达式,包括条件运算符(?:)。

数字

数字是世界的 重要方面。我们用它们来量化我们周围的一切。值得注意的是,你在日常生活中会遇到两种相当不同的数字类型——整数和实数。这两种数字之间的一种区别是,整数是没有小数部分的数字。这些通常来自计数;例如,城镇中的人数。另一方面,实数可以有分数部分。例如,一个人的体重或身高通常是一个实数。

在大多数编程语言中,这两种类型的数字至少用两种不同的原始类型来表示;例如,在 C#中,我们有一个名为int的整数类型和一个名为float的实数类型。

在 JavaScript 中,以及 TypeScript 中,它们确实是相同的原始类型。这个原始类型简单地称为 number。在底层,它是一个 64 位浮点数,完全实现了 IEEE 754 标准。这个标准是为实数指定的,这导致了一些在 JavaScript 中特有的奇怪现象。例如,在大多数环境中,除以零会导致错误。在 JavaScript 和 TypeScript 中,除以零会导致一些特殊的数字,例如 InfinityNaN。此外,JavaScript 中没有整数除法这个概念,因为除法总是使用实数进行。

然而,即使所有内容都存储为浮点实数,JavaScript 保证所有仅使用整数算术可以进行的操作都将被精确执行。一个著名的例子是将 0.1 加到 0.2 上。在所有符合标准的 JavaScript 引擎中,我们得到的结果是 0.30000000000000004,这是因为底层类型的有限精度。我们保证的是,如果我们正在添加整数,我们永远不会得到小数结果。引擎确保 1+1=2,没有小数余数。所有整数操作都是完全安全的,但前提是结果在指定的范围内。JavaScript 定义了一个特殊的常量(Number.MAX_SAFE_INTEGER),其值为 9007199254740991(带数字分组,表示为 9.007.199.254.740.991),超过这个值我们可能会遇到精度和舍入错误。

布尔类型

布尔类型是最简单,同时也是最常用且最有用的原始类型之一。这种数据类型恰好有两个值,truefalse。有用的地方在于,如果一个变量的这种类型没有特定的值,那么它自动具有另一个值,因为这是唯一可能的另一个选项。在理论上,这是合理的,但在 JavaScript 中,有很多可能导致错误的情况。由于它没有类型信息,它不能保证某个变量实际上持有布尔值,这意味着我们总是必须小心我们的布尔检查。

TypeScript 完全解决了这个问题。比如说,我们定义一个变量为布尔类型,无论是使用类型注解还是类型推断,如下所示:

let isRead = false;

我们可以绝对确信变量将始终具有两种可能值之一。

数组

除了访问社交网站和玩电子游戏之外,计算机之所以受欢迎,其中一个原因就是它们能够对整个值集合运行相同的处理算法,无论需要多少次,都不会感到无聊或出错。为了能够做到这一点,我们需要以某种方式将数据组织成一系列相似的值,我们可以一次访问一个。在 JavaScript 中,处理此类数据的主要机制是数组。JavaScript 使用数组字面量语法创建数组具有极其简单的接口。我们只需列出元素,用括号([ ])包围,就得到了一个数组:

const numbers = [1, 2, 3, 4, 5];

我们可以使用索引来访问该数组:

console.log(numbers[3]) // writes out 4, as arrays in JavaScript are //…0-based
numbers[1] = 200; // the second element becomes 200

这使得使用 for 循环遍历元素并使用单一代码块处理所有元素变得很容易:

for (let index = 0; index < numbers.length; index += 1) {
    const element = numbers[index];
    console.log(`The element at index ${index} has a value of ${element}`);
}

我们也可以使用 for..of 循环来遍历值,以下代码片段将计算数组中所有数字的总和:

let sum = 0;
for (const element of numbers) {
    sum += element;
}

与 JavaScript 中的任何事物一样,它没有机制来强制数组中的所有项目都满足我们之前提到的“相似性”要求。因此,我们无法阻止我们向定义的数字数组中添加字符串、布尔值、对象,甚至函数。所有这些都是有效的 JavaScript 命令,将成功执行:

numbers[1] = false;
numbers[2] = new Date();
numbers[3] = "three";
numbers[4] = function () { 
    console.log("I'm really not a number");
};

在几乎所有情况下,拥有具有不同类型的数组元素对我们来说都没有好处。数组的主要好处是我们可以将相似的项目分组在一起,并使用相同的代码处理所有这些项目。如果我们有不同的类型,我们就失去了这个优势,所以我们甚至可以不使用数组。

在 TypeScript 中,我们可以限制类型,使得数组只允许其元素具有单一类型。数组有一种被称为 复合泛型 的类型。这意味着当我们指定数组的类型时,我们实际上是间接指定的,通过另一个类型。

在这种情况下,我们通过数组元素的类型来定义数组的类型,例如,我们可以有一个元素为数字的数组,或者一个元素为字符串的数组。在 TypeScript 中,我们通过编写元素类型并在类型名称后附加括号来表示这一点。所以,如果我们需要我们的 numbers 数组只接受类型为 number 的值,我们将表示如下:

let numbers: number[];

更好的是,如果我们初始化数组,我们可以省略类型注解,让 TypeScript 推断值:

![图 1.12:TypeScript 推断数组中元素的类型图片 B14508_01_12.jpg

图 1.12:TypeScript 推断数组中元素的类型

如前所述,TypeScript 不会让我们使用与数组元素类型不匹配的 push 方法,也不会允许将元素设置为无效值。

表示数组类型的另一种等效方法是使用泛型类型语法。在这种情况下,我们可以使用Array类型,并在尖括号中指定实际元素的类型:

let numbers: Array<number>;

泛型类和方法将在第九章“泛型和条件类型”中详细介绍。

这里的好处是,我们可以确信,如果一个数组声称具有某种类型的元素,它确实会有那种类型的元素,我们可以处理它们而不用担心引入了不兼容的元素。

元组

在 JavaScript 中,数组的一个常见用法是分组数据——就像对象一样,但不需要(且没有)属性名带来的麻烦(和好处)。例如,我们可以创建一个person数组而不是创建一个person对象,按照惯例,我们将使用第一个元素来保存名字,第二个元素来保存姓氏,第三个元素来保存年龄。我们可以使用以下方式定义这样的数组:

const person = ["Ada", "Lovelace", 36];
console.log(`First Name is: ${person[0]}`);
console.log(`Last Name is: ${person[1]}`);
console.log(`Age is: ${person[2]}`);

在这种情况下,尽管我们使用的是相同的结构——一个数组——但我们不是用它来分组未知数量的无关数据,而是用它来分组已知数量的相关数据,这些数据可以是不同的类型。这种数组被称为元组。再次强调,JavaScript 没有机制来强制执行元组的结构,因此在我们的代码中我们可以做很多在语法上是有效的,但在语义上却是无意义的。我们可以在数组中添加第四个元素,我们可以将第一个元素设置为数字,第三个元素设置为函数,等等。

使用 TypeScript,我们可以正式定义元组内部所需的数据元素的数量和类型,使用如下语法:

const person: [string, string, number] = ["Ada", "Lovelace", 36];

[string, string, number]声明告诉 TypeScript 我们打算使用一个包含三个元素的元组,前两个元素将是字符串,第三个将是数字。TypeScript 现在有足够的信息来强制执行结构。因此,如果我们编写代码来在元组的第一个元素上调用toLowerCase方法并将第三个元素乘以 10,这将有效,因为第一个操作对字符串是有效的,第二个操作对数字是有效的:

console.log(person[0].toLowerCase());
console.log(person[2] * 10);

但如果我们尝试以相反的顺序执行操作,两次调用都会出现错误:

图 1.13:TypeScript 在执行错误操作时的错误

图片 B14508_01_13.jpg

图 1.13:TypeScript 在执行错误操作时的错误

此外,如果我们尝试访问定义范围之外的一个元素,也会出现错误:

图 1.14:TypeScript 在访问定义范围之外的元素时

图片 B14508_01_14.jpg

图 1.14:TypeScript 在访问定义范围之外的元素时

Schwartzian 转换

数组有一个有用的排序函数,我们可以用它来对数组中的对象进行排序。然而,在排序过程中,会对相同的对象进行多次比较。例如,如果我们对一个包含 100 个数字的数组进行排序,比较两个数字的方法平均会被调用超过 500 次。假设我们有一个Person接口,如下定义:

interface Person {
    firstName: string;
    lastName: string;
}

如果我们想获取某人的全名,我们可能会使用如下函数:

function getFullName (person: Person) {
    return `${person.firstName} ${person.lastName}`;
}

如果我们有一个名为personsPerson对象数组,并想按全名对其进行排序,我们可能会使用以下代码:

persons.sort((first, second) => {
    const firstFullName = getFullName(first);
    const secondFullName = getFullName(second);
    return firstFullName.localeCompare(secondFullName);
})

这将按不高效的方式对persons数组进行排序。如果我们有 100 个Person对象,这意味着我们有 100 个不同的目标调用getFullName函数。但如果比较函数的调用超过 500 次,那么就意味着对getFullName函数的调用超过 1000 次,所以至少有 900 次调用是多余的。

注意

关系变得更糟:对于 10,000 个人,我们将有大约 25 万的冗余调用。

我们的方法既快又简单,但如果需要一些昂贵的计算,简单的排序可能会减慢我们的应用程序。

幸运的是,有一种简单的技术称为 Schwartzian 转换,可以帮助我们解决这个问题。这项技术有三个部分:

  • 我们将把数组中的每个元素转换成一个包含两个元素的元组。元组的第一个元素将是原始值,第二个将是排序函数的结果(口语中称为 Schwartz)。

  • 我们将根据元组的第二个元素对数组进行排序。

  • 我们将转换每个元组,丢弃排序元素,并取原始值。

我们将在下面的练习中使用这项技术。

练习 1.05:使用数组和元组创建对象的高效排序

在这个练习中,我们将使用 Schwartzian 转换来排序并打印预定义的程序员数组。每个程序员对象将是上一节中定义的Person接口的一个实例。

我们希望根据全名对程序员进行排序,这可以通过上一节中的getFullName函数来计算。

为了实现 Schwartzian 转换,我们将采取以下步骤:

我们将使用数组的map方法来将程序员转换成[Person, string]类型的元组,其中第一个元素是实际的程序员,第二个元素是全名字符串。

我们将使用数组的sort方法对元组进行排序,使用每个元组的第二个元素。

我们将再次使用map方法将元组转换回程序员的数组,只需取第一个元素并丢弃第二个元素。

让我们开始:

注意

这个练习的代码文件可以在以下链接找到:packt.link/EgZnX

  1. 创建一个名为person-sort.ts的新文件。

  2. 在文件中,为Person对象创建接口:

    interface Person {
        firstName: string;
        lastName: string;
    }
    
  3. 创建一个获取给定人员全名的函数:

    let count = 0;
    function getFullName (person: Person) {
        count += 1;
        return `${person.firstName} ${person.lastName}`;
    }
    

    我们将使用count变量来检测函数的总调用次数。

  4. 定义一个人员数组,并添加一些具有firstNamelastName属性的几个对象:

    const programmers: Person[] = [
        { firstName: 'Donald', lastName: 'Knuth'},
        { firstName: 'Barbara', lastName: 'Liskow'},
        { firstName: 'Lars', lastName: 'Bak'},
        { firstName: 'Guido', lastName: 'Van Rossum'},
        { firstName: 'Anders', lastName: 'Hejslberg'},
        { firstName: 'Edsger', lastName: 'Dijkstra'},
        { firstName: 'Brandon', lastName: 'Eich'},
        // feel free to add as many as you want
    ];
    
  5. 定义一个简单直接的排序函数:

    // a naive and straightforward sorting function
    function naiveSortPersons (persons: Person[]): Person[] {
        return persons.slice().sort((first, second) => {
            const firstFullName = getFullName(first);
            const secondFullName = getFullName(second);
            return firstFullName.localeCompare(secondFullName);
        })
    }
    
  6. 使用 Schwartzian 转换并定义一个函数,该函数将接受人员数组并返回(排序后的)人员数组:

    function schwartzSortPersons (persons: Person[]): Person[] {
    
  7. 使用数组的map函数将每个元素转换为一个元组:

        const tuples: [Person, string][] = persons.map(person => [person, getFullName(person)]);
    
  8. 使用标准的sort方法对元组的tuples数组进行排序:

        tuples.sort((first, second) => first[1].localeCompare(second[1]));
    

    我们应该注意,sort函数接受两个对象,在我们的情况下,两个元组,我们根据getFullName调用的结果按第二个元素对元组进行排序。

  9. 将排序后的元组数组转换为所需的格式——只是一个person对象的数组——通过取每个元组的第一个元素,丢弃 Schwartz:

        const result = tuples.map(tuple => tuple[0]);
    
  10. 最后三个步骤是 Schwartzian 转换的三个部分。

  11. 从函数返回新的数组:

        return result;
    }
    
  12. 添加一行代码,调用我们定义的数组上的naiveSortPersons函数:

    count = 0;
    const sortedNaive = naiveSortPersons(programmers);
    
  13. 输出排序后的数组和计数变量。

    console.log(sortedNaive);
    console.log(`When called using the naive approach, the function was called ${count} times`);
    
  14. 添加一行代码,调用我们定义的数组上的schwartzSortPersons函数:

    count = 0;
    const sortedSchwartz = schwartzSortPersons(programmers); 
    
  15. 输出sorted数组和count变量。count变量应该与数组中的项目数量相同,在我们的例子中是 7。没有优化,该方法将被调用 28 次:

    console.log(sortedSchwartz);
    console.log(`When called using the Schwartzian transform approach, the function was called ${count} times`); 
    
  16. 保存并编译文件:

    tsc person-sort.ts
    
  17. 验证编译是否成功,并在同一文件夹中生成一个person-sort.js文件。在node环境中使用以下命令执行它:

    node person-sort.js
    

    你将看到如下所示的输出:

    [
      { firstName: 'Anders', lastName: 'Hejslberg' },
      { firstName: 'Barbara', lastName: 'Liskow' },
      { firstName: 'Brandon', lastName: 'Eich' },
      { firstName: 'Donald', lastName: 'Knuth' },
      { firstName: 'Edsger', lastName: 'Dijkstra' },
      { firstName: 'Guido', lastName: 'Van Rossum' },
      { firstName: 'Lars', lastName: 'Bak' }
    ]
    When called using the naive approach, the function was called 28 times
    [
      { firstName: 'Anders', lastName: 'Hejslberg' },
      { firstName: 'Barbara', lastName: 'Liskow' },
      { firstName: 'Brandon', lastName: 'Eich' },
      { firstName: 'Donald', lastName: 'Knuth' },
      { firstName: 'Edsger', lastName: 'Dijkstra' },
      { firstName: 'Guido', lastName: 'Van Rossum' },
      { firstName: 'Lars', lastName: 'Bak' }
    ]
    When called using the Schwartzian transform approach, the function was called 7 times 
    

我们可以轻松地检查输出的值是按照全名排序的。我们还可以注意到输出末尾的 7,那是getFullName函数调用的总次数。由于程序员数组中有 7 个项目,我们可以得出结论,该函数对每个对象只调用了一次。

我们可以改用以下代码直接对程序员数组进行排序:

programmers.sort((first, second) => {
    const firstFullName = getFullName(first);
    const secondFullName = getFullName(second);
    return firstFullName.localeCompare(secondFullName);
});
console.log(count);

在这种情况下,对于这个数组,getFullName函数的执行次数将是 28 次,是优化版本的 4 倍。

枚举

通常我们有一些具有预定义值集的类型,没有其他值是有效的。例如,有四个且只有四个基本方向(东、西、北、南)。一副牌中只有四种不同的花色。那么,我们如何定义一个应该具有这种值的变量呢?

在 TypeScript 中,我们可以使用enum类型来实现这一点。定义枚举的最简单方法如下:

enum Suit {
    Hearts,
    Diamonds,
    Clubs,
    Spades
}

然后,我们可以定义并使用此类变量,TypeScript 将帮助我们使用它:

let trumpSuit = Suit.Hears;

TypeScript 会推断trumpSuit变量的类型为Suit,并且只允许我们访问这四个值。任何尝试将其他内容赋值给该变量的操作都将导致错误:

图 1.15:TypeScript 推断 trumpSuit 的类型

图 1.15:TypeScript 推断 trumpSuit 的类型

到目前为止,我们遇到的所有类型都是 TypeScript 增强的 JavaScript 类型。与这些不同,枚举是 TypeScript 特有的。在底层,Suit类实际上编译成一个具有如下值的对象:

{
  '0': 'Hearts',
  '1': 'Diamonds',
  '2': 'Clubs',
  '3': 'Spades',
  Hearts: 0,
  Diamonds: 1,
  Clubs: 2,
  Spades: 3
}

TypeScript 会自动将零开始的数字分配给提供的选项,并添加反向映射,因此如果我们有选项,我们可以得到值,但如果我们有值,我们也可以映射到选项。我们还可以显式设置提供的数字:

enum Suit {
    Hearts = 10,
    Diamonds = 20,
    Clubs = 30,
    Spades = 40
}

我们也可以使用字符串而不是数字,语法如下:

enum Suit {
    Hearts = "hearts",
    Diamonds = "diamonds",
    Clubs = "clubs",
    Spades = "spades"
}

这些枚举被称为基于字符串的枚举,它们编译成如下对象:

{
  Hearts: 'hearts',
  Diamonds: 'diamonds',
  Clubs: 'clubs',
  Spades: 'spades'
}

Any 和 Unknown

到目前为止,我们已经解释了 TypeScript 推断的工作原理及其强大之处。但有时我们实际上希望拥有 JavaScript 的“任何事都可以”的行为。例如,如果我们确实需要一个变量有时会包含字符串有时会包含数字怎么办?以下代码将引发错误,因为我们试图将字符串赋值给 TypeScript 推断为数字的变量:

let variable = 3;
if (Math.random()>0.5) {
    variable = "not-a-number";
}

这是在 VS Code 中显示的代码及其错误信息:

图 1.16:TypeScript 推断变量的类型

图 1.16:TypeScript 推断变量的类型

我们需要做的是以某种方式暂停对该特定变量的类型推断。为了能够做到这一点,TypeScript 为我们提供了any类型:

let variable: any = 3;
if (Math.random()>0.5) {
    variable = "not-a-number";
}

这种类型注解将variable变量恢复到 JavaScript 的默认行为,因此涉及该变量的所有调用都不会被编译器检查。此外,大多数包含any类型变量的调用都会推断出相同类型的返回值。这意味着any类型具有高度传染性,即使我们在应用程序的单一位置定义它,它也可以传播到很多地方。

由于使用any实际上否定了 TypeScript 的大部分好处,因此最好尽可能少地使用它,并且仅在绝对必要时使用。它是使用 TypeScript 的 opt-in/opt-out 设计的一个强大工具,这样我们就可以逐步将现有的 JavaScript 代码升级到 TypeScript。

有时使用的一个场景是any的动态性和 TypeScript 的静态性相结合——我们可以有一个元素可以是任何内容的数组:

const everything: any[] = [ 1, false, "string"];

从 3.0 版本开始,TypeScript 还提供了一种具有动态语义的另一种类型——unknown类型。虽然仍然是动态的,但它对可以执行的操作有更多的限制。例如,以下代码将使用any编译:

const variable: any = getSomeResult(); // a hypothetical function //with some return value we know nothing about
const str: string = variable;  // this works, as any might be a //string, and "anything goes";
variable.toLowerCase();        // we are allowed to call a method, //and we'll determine at runtime whether that's possible

另一方面,带有 unknown 类型注解的相同代码会导致以下结果:

图 1.17:TypeScript 编译器错误信息

![图 1.17:TypeScript 编译器错误信息unknown 类型基本上是反转了断言和证明责任。使用 any 时,流程是这样的,因为我们不知道它不是字符串,所以我们可以将其视为字符串。使用 unknown 时,我们不知道它是否是字符串,因此不能将其视为字符串。为了对 unknown 执行任何有用的操作,我们需要显式地测试其值并根据该值确定我们的操作:jsconst variable: unknown = getSomeResult(); // a hypothetical function with some return value we know nothing aboutif (typeof variable === "string") {    const str: string = variable; // valid, because we tested if the value inside `variable` actually has a type of string    variable.toLowerCase();}## Null 和 UndefinedJavaScript 的一个具体特性是它有两个表示没有值的独立值:`null` 和 `undefined`。这两个值之间的区别在于 `null` 必须被显式指定——所以如果某物是 `null`,那是因为有人将其设置为 `null`。另一方面,如果某物的值为 `undefined`,通常意味着该值根本未设置。例如,让我们看看以下定义的 `person` 对象:jsconst person = {    firstName: "Ada",    lastName: null}````lastName` 属性的值已被显式设置为 `null`。另一方面,`age` 属性根本未设置。因此,如果我们打印它们,我们会看到 `lastName` 属性的值为 `null`,而 `age` 属性的值为 `undefined`:jsconsole.log(person.lastName);console.log(person.age);我们应该注意,如果我们有一个对象中有可选属性,它们的默认值将是 `undefined`。同样,如果我们有一个函数中有可选参数,参数的默认值也将是 `undefined`。## NeverTypeScript 中还有一个特定的“非值”类型,那就是特殊的 `never` 类型。此类型表示永远不会发生值的类型。例如,如果我们有一个函数,函数的末尾不可达且没有返回语句,其返回类型将是 `never`。这样的函数示例如下:jsfunction notReturning(): never {    throw new Error("point of no return");}const value = notReturning();````value` 变量的类型将被推断为 `never`。`never` 另一个有用的场景是我们有一个无法为真的逻辑条件。作为一个简单的例子,让我们看看以下代码:jsconst x = true;if (x) {    console.log(`x is true: ${x.toString()}`);}条件语句始终为 `true`,因此我们总会看到控制台中的文本。但如果我们给这段代码添加一个 `else` 分支,分支内的 `x` 的值不能为 `true`,因为我们处于 `else` 分支中,但不能是任何其他值,因为它被定义为 `true`。因此,实际推断的类型是 `never`。由于 `never` 没有任何属性或方法,这个分支将抛出编译错误:图 1.18:使用 never 类型导致的编译器错误

图 1.18:使用 never 类型导致的编译器错误

函数类型

我们将要查看的 JavaScript 的最后一个内置类型实际上并不是数据的一部分——它是一段代码。由于函数在 JavaScript 中是一阶对象,因此在 TypeScript 中也是如此。就像其他类型一样,函数也有类型。函数的类型比其他类型要复杂一些。为了识别它,我们需要所有参数及其类型,以及返回值及其类型。让我们看看以下定义的 add 函数:

const add = function (x: number, y: number) {
    return x + y;
}

为了完全描述函数的类型,我们需要知道它是一个接受一个数字作为第一个参数,另一个数字作为第二个参数并返回一个数字的函数。在 TypeScript 中,我们将它写成 (x: number, y: number) => number

创建自己的类型

当然,除了使用 JavaScript 中已经可用的类型之外,我们还可以定义自己的类型。我们有几种选择。我们可以使用 JavaScript 的 class 规范来声明自己的类,包括属性和方法。一个简单的类可以用以下方式定义:

class Person {
    constructor(public firstName: string, public lastName: string, public age?: number) {
    }
    getFullName() {
        return `${this.firstName} ${this.lastName}`;
    }
}

我们可以创建此类对象并使用它们的方法:

const person = new Person("Ada", "Lovelace");
console.log(person.getFullName());

另一种形式化我们复杂结构的方法是使用一个接口:

interface Person
{
    firstName: string;
    lastName: string;
    age?: string;
}

与将编译为 JavaScript 类或构造函数(取决于编译目标)的类不同,接口是 TypeScript 独有的结构。在编译时,它们会进行静态检查,然后从编译后的代码中删除。

如果实现类层次结构,类和接口都是有用的,因为这两种结构都适合扩展和继承。

另一种方法是使用类型别名,使用 type 关键字。基本上,我们可以将用作类型别名的名称放入 TypeScript 中几乎任何可用的东西。例如,如果我们想为原始的 number 类型提供一个别名,例如 integer,我们可以始终这样做:

type integer = number;

如果我们想给一个用于存储人的元组 [string, string, number?] 命名,我们可以用以下方式别名它:

type Person = [string, string, number?];

我们也可以在类型别名的定义中使用对象和函数:

type Person = {
    firstName: string;
    lastName: string;
    age?: number;
}
type FilterFunction = (person: Person) => boolean;

我们将在第四章“类和对象”、第五章“接口和继承”和第六章“高级类型”中更详细地探讨 classinterfacetype 关键字的复杂性和细节。

练习 1.06:创建计算器函数

在这个练习中,我们将定义一个计算器函数,它将接受操作数和操作作为参数。我们将设计它以便于扩展额外的操作,并利用这种行为来扩展它:

注意

本练习的代码文件可以在以下位置找到:packt.link/dKoCZ

  1. 创建一个名为 calculator.ts 的新文件。

  2. calculator.ts 中,定义一个枚举,其中包含我们想要在代码中支持的运算符:

    enum Operator {
        Add = "add",
        Subtract = "subtract",
        Multiply = "multiply",
        Divide = "divide",
    }
    
  3. 定义一个空的(目前)calculator函数,它将是我们的主要接口。该函数应接受三个参数:我们要操作的数字以及一个操作符:

    const calculator = function (first: number, second: number, op: Operator) {
    }
    
  4. 为执行两个数字计算的函数创建一个类型别名。此类函数将接受两个数字作为参数并返回一个数字:

    type Operation = (x: number, y: number) => number;
    
  5. 创建一个可以存储多个[Operator, Operation]类型元组的空数组。这将是我们字典,其中存储所有我们的方法:

    const operations: [Operator, Operation][] = [];
    
  6. 创建一个满足Operation类型的add方法(你不需要显式引用它):

    const add = function (first: number, second: number) {
        return first + second;
    };
    
  7. 创建一个包含Operator.Add值和add函数的元组,并将其添加到operations数组中:

    operations.push([Operator.Add, add]);
    
  8. 对减法、乘法和除法函数重复步骤 6步骤 7

    const subtract = function (first: number, second: number) {
        return first - second;
    };
    operations.push([Operator.Subtract, subtract]);
    const multiply = function (first: number, second: number) {
        return first * second;
    };
    operations.push([Operator.Multiply, multiply]);
    const divide = function (first: number, second: number) {
        return first / second;
    };
    operations.push([Operator.Divide, divide]);
    
  9. 实现一个calculator函数,使用operations数组通过提供的Operator找到正确的元组,然后使用相应的Operation值进行计算:

    const calculator = function (first: number, second: number, op: Operator) {
        const tuple = operations.find(tpl => tpl[0] === op);
        const operation = tuple[1];
        const result = operation(first, second);
        return result;
    }
    

    注意,只要一个函数具有所需类型,即它接受两个数字并输出一个数字,我们就可以将其用作操作。

  10. 让我们测试一下计算器。编写一些代码来调用calculator函数,并传递不同的参数:

    console.log(calculator(4, 6, Operator.Add));
    console.log(calculator(13, 3, Operator.Subtract));
    console.log(calculator(2, 5, Operator.Multiply));
    console.log(calculator(70, 7, Operator.Divide));
    
  11. 保存文件,并在文件夹中运行以下命令:

    tsc calculator.ts
    
  12. 验证编译是否成功完成,并在同一文件夹中生成一个calculator.js文件。使用以下命令在node环境中执行它:

    node calculator.js
    

    你将看到输出如下所示:

    10
    10
    10
    10
    
  13. 现在,让我们尝试通过添加模运算来扩展我们的计算器。首先,我们需要将此选项添加到Operator枚举中:

    enum Operator {
        Add = "add",
        Subtract = "subtract",
        Multiply = "multiply",
        Divide = "divide",
        Modulo = "modulo"
    }
    
  14. 添加一个名为moduloOperation类型函数,并添加一个相应的元组到operations数组中:

    const modulo = function (first: number, second: number) {
        return first % second;
    };
    operations.push([Operator.Modulo, modulo]);
    
  15. 在文件末尾添加一个调用calculator函数的调用,该函数使用Modulo运算符:

    console.log(calculator(14, 3, Operator.Modulo));
    
  16. 保存并编译文件,并使用以下命令运行生成的 JavaScript:

    node calculator.js
    

    你将看到如下所示的输出:

    10
    10
    10
    10
    2
    

注意,当我们通过模运算功能扩展计算器时,我们根本就没有改变calculator函数。在这个练习中,我们看到了如何使用元组、数组和函数类型来有效地设计一个可扩展的系统。

活动一.01:创建用于处理字符串的库

你的任务是创建一系列简单的函数,这些函数将帮助你执行一些常见的字符串操作。其中一些操作已经在标准 JavaScript 库中得到支持,但你将使用它们作为一个方便的学习练习,既包括 JavaScript 内部机制,也包括 TypeScript 作为一门语言。我们的库将包含以下函数:

  1. toTitleCase:这将处理一个字符串,并将每个单词的首字母大写,但将其他所有字母转换为小写。

    此函数的测试用例如下:

    "war AND peace" => "War And Peace"
    "Catcher in the Rye" => "Catcher In The Rye"
    "tO kILL A mOCKINGBIRD" => "To Kill A MockingBird"
    
  2. countWords:这将计算字符串中不同单词的数量。单词由空格、连字符(-)或下划线(_)分隔。

    此函数的测试用例如下:

    "War and Peace" => 3 
    "catcher-in-the-rye" => 4 
    "for_whom the-bell-tolls" => 5
    
  3. toWords: 此函数将返回字符串中所有的单词。单词由空格、破折号(-)或下划线(_)分隔。

    此函数的测试用例如下:

    "War and Peace" => [War, and, peace] 
    "catcher-in-the-rye" => [catcher, in, the, rye] 
    "for_whom the-bell-tolls"=> [for, whom, the, bell, tolls]
    
  4. repeat: 此函数将接受一个字符串和一个数字,并返回重复该次数的相同字符串。

    此函数的测试用例如下:

    "War", 3 => "WarWarWar" 
    "rye", 1 => "rye"
    "bell", 0 => ""
    
  5. isAlpha: 如果字符串仅包含字母字符(即,字母),此函数将返回true。此函数的测试用例如下:

    "War and Peace" => false 
    "Atonement" => true 
    "1Q84" => false
    
  6. isBlank: 如果字符串为空,即仅由空白字符组成,此函数将返回true

    此函数的测试用例如下:

    "War and Peace" => false 
    "         " => true 
    "" => true
    

    在编写函数时,请确保考虑参数的类型和返回值的类型。

    注意

    此活动的代码文件可以在以下位置找到:packt.link/TOZuy

以下是一些帮助你创建前面函数的步骤(请注意,实现每个函数的方法有多种,因此将这些步骤视为建议):

  1. 创建toTitleCase函数:为了更改每个单词,我们首先需要获取所有单词。你可以使用split函数将单个字符串转换成单词数组。接下来,我们需要从单词的其余部分slice掉第一个字母。我们可以使用toLowerCasetoUpperCase方法来分别创建小写和大写。在得到所有单词正确的大小写后,我们可以使用join数组方法将字符串数组转换成单个长字符串。

  2. 创建countWords函数:为了获取单词,我们可以将原始字符串根据任意一个分隔符(" ", "_", 和 "-")的出现进行分割。幸运的是,split函数可以接受一个正则表达式作为参数,我们可以利用这一点。一旦我们有了单词数组,我们只需计算元素的数量。

  3. 创建towards函数:此方法可以使用与前面相同的方法。我们不需要计数单词,只需返回它们即可。请注意此方法的返回类型。

  4. 创建repeat函数:创建一个具有所需长度的数组(使用Array构造函数),并使用数组的fill方法将每个元素设置为输入值。之后,你可以使用数组的join方法将值连接成一个长字符串。

  5. 创建isAlpha函数:我们可以设计一个正则表达式来测试这一点,但我们也可以使用字符串的split方法将字符串拆分成单个字符。一旦我们有了字符数组,我们可以使用 map 函数将所有字符转换为小写。然后我们可以使用 filter 方法返回不在"a"和"z"之间的字符。如果没有这样的字符,则输入仅包含字母,因此我们应该返回 true。否则,我们应该返回 false。

  6. 创建isBlank函数:创建此类函数的一种方法是通过反复测试第一个字符是否为空,如果是,则移除它(while循环最适合这种情况)。这个循环会在遇到第一个非空字符或当它耗尽第一个元素时(即输入为空时)中断。在前一种情况下,字符串不是空的,因此我们应该返回false;否则,我们应该返回true

    注意

    该活动的解决方案可以通过此链接找到。

摘要

在本章中,我们回顾了 TypeScript 出现之前的世界,并描述了 TypeScript 实际上旨在解决的问题和问题。我们简要概述了 TypeScript 在底层是如何运行的,了解了tsc编译器,并学习了如何使用tsconfig.json文件来控制它。

我们熟悉了 TypeScript 和 JavaScript 之间的差异,并了解了 TypeScript 是如何从我们提供的值中推断类型的。我们学习了 TypeScript 中不同原始类型是如何处理的,最后,我们学习了如何创建自己的类型来构建大型、企业级 Web 应用程序的基本构建块。掌握了基础知识后,你现在可以进一步深入研究 TypeScript,下一章将教你关于声明文件的内容。

第三章:2. 声明文件

概述

本章将引导你开始使用 TypeScript 的声明文件。你将学习如何使用声明文件,包括如何从头开始构建自己的声明文件,然后在外部库中使用类型。到本章结束时,你将能够从头开始创建声明文件,实现创建声明文件的常见开发模式,并在与第三方 NPM 代码库合作时进行类型检查。

简介

在本章中,你将了解 TypeScript 声明文件。声明文件使 TypeScript 能够获取有关函数或类结构的更多信息。

为什么理解声明文件的工作原理很重要?从技术上讲,声明文件直接针对 TypeScript 变得如此流行的核心动机。使用 TypeScript 的一个常见理由是它指导开发者通过应用程序的过程。让我们通过一个真实世界的案例研究来探讨这个问题。

在纯 JavaScript 中,如果我们开始使用一个我们之前从未使用过的日期格式化代码库,例如 Moment JS,我们首先需要查阅文档以了解我们可以传递给 Moment JS 函数的数据类型。当与一个新库合作时,确定需求,例如每个函数需要多少个函数参数以及每个参数需要的数据类型,是一项繁琐的工作。

然而,使用声明文件时,TypeScript 会通知文本编辑器库中每个函数的要求。因此,我们不必仅仅依赖文档和 Google 搜索,文本编辑器本身就会告诉开发者如何使用每个函数。例如,借助 TypeScript 的帮助,文本编辑器会告诉我们 Moment JS 格式函数接受零到一 个参数,并且可选参数需要是一个字符串。声明文件使得这一切成为可能。

声明文件

每当我们被要求编写额外的样板代码时,我们的第一个问题是:为什么这样做很重要?考虑到这一点,在我们开始创建和管理声明文件之前,让我们首先分析声明文件在开发过程中的作用。

我们最初使用 TypeScript 的全部原因是为了给我们的应用程序提供一个基于类型的指定结构。声明文件通过允许我们定义程序的形状来扩展这一功能。

在本节中,我们将探讨两种处理声明文件的方法。第一种方法是从头开始创建我们的声明文件。这是一个很好的起点,因为它提供了对声明过程如何工作的洞察。在第二部分,我们将看到如何将类型集成到第三方 NPM 库中。

注意

声明文件在编程世界中不是一个新概念。相同的原理已经在上个世纪的编程语言,如 Java、C 和 C++ 中使用了数十年。

在我们进入本章的示例项目之前,让我们看看构成 TypeScript 声明文件的核心元素。考虑以下代码,它将一个字符串值赋给一个变量:

firstName = "Kristine";

上述 TypeScript 代码将生成一个编译警告,指出 Cannot find name 'firstName',这在以下截图中可以看到:

![Figure 2.1:TypeScript 无法找到变量声明时的编译错误]

![img/B14508_02_01.jpg]

图 2.1:TypeScript 无法找到变量声明时的编译错误

这个错误显示,因为每次我们尝试给变量赋值时,TypeScript 都会寻找变量名定义的位置。我们可以通过使用 declare 关键字来修复这个问题。以下代码将纠正我们在上一个案例中遇到的错误:

declare let firstName: string;
firstName = "Kristine";

如以下截图所示,使用 declare 关键字后,编译警告消失了:

![Figure 2.2:TypeScript 中定义变量的示例]

![img/B14508_02_02.jpg]

图 2.2:TypeScript 中定义变量的示例

现在,这可能看起来不是什么大问题,因为我们可以通过简单地定义一个 let 变量,例如以下内容,来达到相同的目的:

let firstName: string;
firstName = "Kristine"

在 Visual Studio Code 编辑器中查看上述代码时,不会生成错误。

那么,使用 declare 的目的是什么?随着我们构建复杂的模块,声明过程允许我们以无法通过简单地定义一个变量的方式来描述我们模块的完整结构。现在你已经了解了声明文件的作用以及基本语法,让我们在接下来的练习中从头开始创建一个声明文件。

练习 2.01:从头创建声明文件

在这个练习中,我们将从头开始创建一个声明文件。我们将声明文件约定、导入,然后使用已声明的文件。假设你正在开发一个需要用户使用电子邮件、用户角色和密码等凭证注册自己的网络应用程序。这些凭证的数据类型将在我们创建的声明文件中声明。如果用户未能输入正确的凭证,则不允许用户登录。

注意

此练习的代码文件可以在以下位置找到:packt.link/bBzat

执行以下步骤以实现此练习:

  1. 打开 Visual Studio Code 编辑器。

  2. 创建一个新的目录,然后创建一个名为 user.ts 的文件。

  3. 启动 TypeScript 编译器,并使用以下终端编译命令监视文件的变化:

    tsc user.ts ––watch
    

    以下截图显示了命令在终端中的外观:

    ![Figure 2.3:使用 watch 标志运行 TypeScript 编译器]

    ![img/B14508_02_03.jpg]

    图 2.3:使用 watch 标志运行 TypeScript 编译器

    目前可以留这个文件为空。我们很快就会开始构建我们的实现。现在让我们创建我们的声明文件。

  4. 在我们程序的根目录下创建一个名为types/的目录,然后在其中创建一个名为AuthTypes.d.ts的文件。

    我们的项目目录现在应该看起来像这样:

    图 2.4:AuthTypes 文件结构

    图 2.4:AuthTypes 文件结构

    注意

    传统上,声明文件被保存在一个名为types/的单独目录中,然后由它们定义的模块导入。使用.d.ts文件扩展名而不是.ts作为声明文件的扩展名也是标准约定。

  5. 在新的声明文件中,定义我们的AuthTypes模块的形状。在文件顶部使用declare关键字。这告诉 TypeScript 我们即将描述AuthTypes模块应该如何结构化:

    declare module "AuthTypes" {
        export interface User {
            email: string;
            roles: Array<string>;
        }
    }
    

    在前面的代码中,可能有一段语法与您习惯的写法不同,那就是我们将模块名用引号括起来。当我们实现程序时,您会看到如果我们去掉引号,我们就无法导入模块。在模块内部,我们可以放置任何数量的导出,我们希望模块拥有的。需要记住的一个重要概念是,声明文件不包含任何实现代码;它们只是描述了模块中使用的元素的类型和结构。以下截图给出了代码的视觉表示:

    图 2.5:AuthTypes 接口

    图 2.5:AuthTypes 接口

    编译器消息表明导入应该成功,因为到目前为止没有出现任何错误。

    在这一步,我们导出一个用户界面,它定义了两个数据点:电子邮件和角色。就数据类型而言,email属性需要是一个字符串,而roles需要是一个填充字符串的数组。这样的类型定义将确保任何使用此模块的人如果尝试使用不正确的数据结构,会立即得到通知。

    现在我们已经定义了AuthTypes模块,我们需要将其导入到我们的 TypeScript 文件中,以便我们可以使用它。我们将使用引用导入过程将文件引入我们的程序中。

  6. 前往user.ts文件,并添加以下两行代码:

    /// <reference path = "./types/AuthTypes.d.ts" />
    import auth = require("AuthTypes");
    

    编辑器中的代码看起来可能像这样:

    图 2.6:导入声明文件

    图 2.6:导入声明文件

    前面的代码中的第一行将使AuthTypes.d.ts对我们的程序可用,第二行导入模块本身。显然,您可以为导入语句使用您喜欢的任何变量名。在这段代码中,我们导入AuthTypes模块并将其存储在auth关键字中。

    导入我们的模块后,我们现在可以开始构建程序的实现了。我们将从定义一个变量并将其分配给我们在声明文件中定义的用户界面类型开始。

  7. 将以下代码添加到 user.ts 文件中:

    let jon: auth.User;
    

    user.ts 文件的更新代码将看起来像这样:

    /// <reference path = "./types/AuthTypes.d.ts" />
    import auth = require("AuthTypes");
    let jon: auth.User;
    

    我们在这里所做的是相当令人印象深刻的。我们实际上在单独的文件中创建了自己的类型/接口,导入它,并告诉 TypeScript 编译器我们的新变量将是 User 类型。

  8. 使用以下代码,通过添加以下代码帮助 jon 变量的 emailroles 实际值:

    jon = {
        email: "jon@snow.com",
        roles: ["admin"]
    };
    

    在放置了必需的形状后,程序可以正确编译,你可以执行任何你需要做的任务。

  9. 创建另一个 User 并看看我们如何处理可选属性。将以下代码添加到添加用户 alice 的详细信息:

    let alice: auth.User;
    alice = {
        email: "alice@snow.com",
        roles: ["super_admin"]
    };
    

    现在,让我们假设我们有时会跟踪用户是如何找到我们的应用的。并不是所有用户都会有这个属性,所以我们需要在不破坏其他用户账户的情况下将其设置为可选。你可以通过在冒号前添加问号来标记一个属性为可选。

  10. 在声明文件中添加 source 属性:

    declare module "AuthTypes" {
        export interface User {
            email: string;
            roles: Array<string>;
            source?: string;
        }
    }
    
  11. 使用 sourcefacebook 更新我们的 alice 用户:

    /// <reference path = "./types/AuthTypes.d.ts" />
    import auth = require("AuthTypes");
    let jon: auth.User;
    jon = {
        email: "jon@snow.com",
        roles: ["admin"]
    };
    let alice: auth.User;
    alice = {
        email: "alice@snow.com",
        roles: ["super_admin"],
        source: "facebook"
    }
    

    注意,即使没有 source 值,jon 变量仍然工作得很好。这有助于我们为程序构建灵活的接口,这些接口定义了可选和必需的数据点。

  12. 打开终端并运行以下命令以生成 JavaScript 文件:

    tsc user.ts
    

    现在,让我们看看生成的 user.js 文件,如下面的截图所示:

图 2.7:未添加到生成的 JavaScript 代码中的声明文件规则

图 2.7:未添加到生成的 JavaScript 代码中的声明文件规则

图 2.7:未添加到生成的 JavaScript 代码中的声明文件规则

嗯,这很有趣。在生成的 JavaScript 代码中,实际上没有提到声明文件。这让我们知道,当涉及到声明文件和 TypeScript 时,这是一个非常重要的知识点:声明文件仅用于开发者的利益,并且仅由 IDE 利用。

当涉及到程序中渲染的内容时,声明文件完全被绕过。考虑到这一点,希望声明文件的目标变得更加清晰。你的声明文件越好,IDE 理解你的程序以及你自己和其他开发者与你代码合作就会越容易。

异常

让我们看看当我们不遵循接口规则时会发生什么。记住在之前的练习中,我们的接口需要两个数据元素(emailroles),并且它们需要是 stringArray<string> 类型。所以,看看当我们不使用以下代码实现适当的数据类型时会发生什么:

jon = {
    email: 123
}

这将生成以下编译器错误,如下面的截图所示:

![图 2.8:TypeScript 显示对象的必需数据类型]

![图片 B14508_02_08.jpg]

图 2.8:TypeScript 显示对象的必需数据类型

这非常有帮助。想象一下,你正在使用一个之前从未使用过的库。如果你使用的是纯 JavaScript,这个实现将静默失败,并迫使你挖掘库的源代码以查看它需要什么结构。

这种编译器错误是有意义的,在现实生活中的应用中,例如以下代码的AuthTypes

jon = {
    email: "jon@snow.com"
}

我们可以看到编译器会将错误信息移动到jon变量名。如果你悬停在它上面,或者查看终端输出,你将看到以下屏幕截图中的错误:

![图 2.9:TypeScript 显示对象的必需属性]

![图片 B14508_02_09.jpg]

图 2.9:TypeScript 显示对象的必需属性

这是一个极其有用的功能。如果你是新手开发者,这可能看起来不是什么大问题。然而,这种类型的信息正是 TypeScript 继续受到欢迎的精确原因。像这样的错误信息立即提供了我们需要的信息,以便修复错误并处理程序。在前面的屏幕截图中,信息告诉我们程序无法编译,因为我们缺少一个必需的值,即roles

现在我们已经从头开始构建了自己的声明文件,是时候继续前进,看看其他库是如何利用声明文件的。

第三方代码库

根据你构建的应用程序类型,你可能永远不需要构建自己的声明文件。然而,如果你使用 TypeScript 并与第三方模块一起工作,你需要了解声明文件是如何工作的,因为这样你就可以无缝地与外部库一起工作。

DefinitelyTyped

让我们暂时回到过去。当 TypeScript 最初开发时,将类型集成到 JavaScript 应用程序中的想法引起了极大的兴奋。然而,开发者开始感到沮丧,因为他们虽然用类型构建程序,但每次他们导入外部库,如 lodash,他们都被迫编写没有类型签名和几乎没有任何 IDE 指导的代码。

实际上,这意味着每次我们调用外部库中的函数时,我们都没有很高的信心认为我们正在正确地使用它。

幸运的是,开源社区已经有了答案,并创建了 DefinitelyTyped 库。DefinitelyTyped 是一个非常大的仓库,包含成千上万的 JavaScript 代码库的声明文件。这意味着像reactlodash以及几乎所有其他流行的库都有完整的声明文件,我们可以在 TypeScript 程序中使用。

注意

想了解更多关于 DefinitelyTyped 的信息,请访问definitelytyped.org

分析外部声明文件

在我们学习如何导入和使用外部库中的类型之前,让我们先看看它们的样子:

图 2.10:DefinitelyTyped 使用声明文件的示例

img/B14508_02_10.jpg

图 2.10:DefinitelyTyped 使用声明文件的示例

在前面的屏幕截图中,如果你查看数组数据结构的lodash声明文件,你会看到单个声明文件超过 2,000 行长。这可能会让人有些畏惧,所以让我们试着简化它。

注意

lodash是一个提供与对象、字符串、数组等一起工作的功能的实用库。如前所述的屏幕截图所示,lodash库的数组数据结构声明文件可以在这里找到:github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/lodash/common/array.d.ts

你会很高兴地知道,前面声明文件中的元素与我们练习 1.01中构建的完全一样:从头开始创建声明文件。它从声明一个module实例开始,然后从这个点开始,列出使用数组数据结构的每个元素的接口。实际上,如果你剖析代码,你会看到lodash为库中的每个函数提供了三个接口。你不必知道这些接口的作用;然而,当你构建自己的代码库时,意识到你可以提供所需数量的接口是有帮助的。

现在我们来看看last函数的接口:

图 2.11:lodash 实现接口的方式

img/B14508_02_11.jpg

图 2.11:lodash 实现接口的方式

这是一个很好的函数来查看,因为当我们到达本节的示例时,我们会使用它。你可以看到接口的大部分实际上是一个注释。如果你以前从未见过这种语法,它使用的是 JSDoc 语法。这非常有帮助,因为像 Visual Studio Code 这样的 IDE 会直接将注释、参数和返回类型拉入 IntelliSense 界面。这意味着当我们开始使用lodash工作时输入last函数时,IDE 会自动拉入注释数据,这样我们就可以轻松地阅读如何使用该函数。

之后,这个声明相当基础。它只是简单地描述了最后一个函数的形状,具体来说,它接受一个值列表作为参数,然后返回Tundefined。不用担心所有关于T的引用;你将在第八章泛型中了解到它代表什么。现在,只需知道这意味着它正在返回一个值。

按照我们从头创建声明文件的相同模式,在下一节中,让我们创建一个新的 TypeScript 项目,并通过一个实际例子来了解为什么需要类型。

练习 2.02:使用外部库创建类型

在这个练习中,我们将安装类型并将我们的类型与外部库集成。我们还将探索一个场景,其中我们将检查当传递给函数的错误类型参数时函数的行为。为此练习,您需要从一个空目录开始。

注意

本练习的代码文件可以在此处找到:packt.link/k7Wbt

执行以下步骤以实现此练习:

  1. 打开 Visual Studio Code 编辑器。

  2. 在您的计算机上创建一个空目录,并运行以下命令以创建一个新的 NPM 项目:

    npm init -y
    

    前面的代码将生成一个 package.json 文件。

  3. 要安装 Lodash 库,打开终端并输入以下命令:

    npm i lodash
    

    前面的命令安装了 Lodash 库。package.json 文件现在应该看起来像这样,其中 lodash 已安装到依赖列表中:

    Figure 2.12: The generated package.json file

    img/B14508_02_12.jpg

    图 2.12:生成的 package.json 文件

  4. 在该目录中创建一个名为 lodash_examples.ts 的文件,启动 TypeScript 编译器,并让它监视更改。在新的 .ts 文件中,添加以下代码:

    import _ = require("lodash");
    const nums = [1, 2, 3];
    console.log(_.last(nums));
    
  5. 在终端中运行前面的程序,输入以下命令:

    tsc lodash_examples.ts
    node lodash_examples.js
    

    控制台生成了 3 的输出,如下面的截图所示:

    Figure 2.13: Running the generated lodash_example.js program

    img/B14508_02_13.jpg

    图 2.13:运行生成的 lodash_example.js 程序

  6. 创建另一个名为 number 的变量,并将其赋值为 10。然后我们将这个数字作为参数传递给 Lodash 库的 _.last() 函数。编写以下代码来完成此操作:

    import _ = require("lodash");
    //const nums = [1, 2, 3];
    //console.log(_.last(nums));
    const number = 10;
    console.log(_.last(number));
    

    由于我们已经查看过声明文件,我们知道最后一个函数期望一个数组或某种类型的列表。然而,现在让我们假装我们没有这个信息,这是我们第一次使用 Lodash 库。

    注意

    The Lodash 库的 last 函数也可以与字符串一起使用,因为它将字符串中的字符视为字符集合。例如,_.last("hey") 将返回 "y",因为它是字符串中的最后一个字符。

  7. 在终端中运行前面的程序,输入以下命令:

    tsc lodash_examples.ts
    node lodash_examples.js
    

    执行前面的命令时,将生成以下输出:

    Figure 2.14: What happens when the wrong argument is passed to the last function

    img/B14508_02_14.jpg

    图 2.14:将错误的参数传递给最后一个函数时会发生什么

    在如此小的程序中,这似乎是一个微不足道的问题。然而,在一个大系统中,当我们期望一个实际值时却得到了一个未定义的值,可能会很耗时,因为我们需要花费更多的时间进行调试。

    为了解决这个问题,让我们利用 DefinitelyTyped 仓库并引入 lodash 类型。如果你将鼠标悬停在文件顶部的 import 语句上,你甚至还会看到以下警告和建议,如下面的截图所示:

    图 2.15:TypeScript 推荐从 DefinitelyTyped 安装 Lodash 类型

    图 2.15:TypeScript 推荐从 DefinitelyTyped 安装 Lodash 类型

    这非常有帮助。警告本身就告诉我们如何安装库的类型。

  8. 按照建议,在终端中运行以下命令来安装 lodash 类型:

    npm install @types/lodash
    

    注意

    每次你看到以 @types/ 开头的 install 命令时,这意味着 NPM 将从 DefinitelyTyped 仓库中提取。

    如果你运行该命令,import 语句中的警告应该会自动消失。但更重要的是,你现在应该看到 IDE 正在抱怨我们尝试将数字传递给 last 函数的那一行代码。如果你将鼠标悬停在单词 number 上,你应该会看到以下截图所示的错误:

图 2.16:IntelliSense 显示与最后一个函数一起使用的正确类型

图 2.16:IntelliSense 显示与最后一个函数一起使用的正确类型

从前面的截图可以看出,last 函数不会接受任何 number 类型的参数。它接受一个数组或列表作为参数。因此,让我们想象我们正在构建一个现实世界的应用程序,并尝试使用 last 函数。如果我们使用原生 JavaScript,我们可能直到我们或甚至用户在运行程序时遇到错误才会意识到我们的错误。然而,通过利用 TypeScript 和 DefinitelyTyped,如果我们尝试以错误的方式使用函数,程序甚至无法编译。

使用 DefinitelyTyped 的发展流程

现在你已经看到了如何安装和使用类型,我们将通过一个完整的发展流程来展示,这样你就可以观察到使用类型的优势。如果没有将类型集成到外部库中,我们就必须具备对该库的先验知识,或者翻阅文档以发现正确的用法。

然而,有了类型,我们将看到在处理像 lodash 这样的库时,过程会多么流畅。让我们在下一节解决一个练习,以便更好地理解这一点。

练习 2.03:创建棒球阵容卡应用程序

在这个练习中,我们将创建一个棒球阵容应用程序,其中我们有一个从 API 获取的球员名字数组,然后我们在应用程序中有一个名为 lineupOrder 的常量变量。我们的阵容卡应用程序需要将 API 中的名字与 lineupOrder 配对:

注意

这个练习的代码文件可以在以下链接找到:packt.link/01spI

  1. 打开 Visual Studio Code 编辑器。

  2. 创建一个名为 lodash_newexamples.ts 的文件,并添加以下代码,其中我们有一个数组变量 playerNames 和一个列表 lineupOrder

    import _ = require("lodash");
    const playerNames = [
        "Springer",
        "Bregman",
        "Altuve",
        "Correa",
        "Brantley",
        "White",
        "Gonzalez",
        "Kemp",
        "Reddick"
    ];
    const lineupOrder = [1, 2, 3, 4, 5, 6, 7, 8, 9]; 
    

    这正是使用 Lodash 库中的 zip 函数的完美情况。让我们假设我们已经听说过 zip 函数,但还不完全清楚如何使用它。首先,在同一个文件中编写以下代码:

    _.zip()
    
  3. 在输入前面的代码后,将光标放在括号之间。你将直接从 DefinitelyTyped 获得有关如何使用该函数的指导,如下面的截图所示:![图 2.17:IntelliSense 在 lodash 中使用 zip 函数的指导]

    ![图片 B14508_02_17.jpg]

    图 2.17:IntelliSense 在 lodash 中使用 zip 函数的指导

    注意

    从前面的截图,我们可以看到 zip 函数接受两个参数。两个参数都需要是 ArrayLike,这意味着它们需要作为某种集合的功能。此外,该函数将元素分组并返回分组集合。因此,无需查阅 lodash 文档,我们就能在构建程序时利用类型定义。它为我们提供了在处理函数时所需的指导。

    现在我们来测试一下。我们知道 zip 函数接受两个数组。所以,让我们提供 playerNameslineupOrder 数组。

  4. 向文件中添加以下代码,为 zip 函数提供两个数组 playerNameslineupOrder

    console.log(_.zip(lineupOrder, playerNames));
    

    如果运行前面的代码,你会看到 zip 函数确实做了它所说的。它将元素分组并返回我们需要的确切数据结构。渲染的阵容卡看起来可能像以下截图所示:

![图 2.18:从 lodash 正确运行 zip 函数]

![图片 B14508_02_18.jpg]

图 2.18:从 lodash 正确运行 zip 函数

在完成此过程时,你可以看到 DefinitelyTyped 如何允许你直接扩展第三方库中的类型,以便你可以在程序中获得类型指导。

活动 2.01:构建热图声明文件

在此活动中,你将构建一个名为 heat map log system 的 TypeScript 应用程序,用于跟踪棒球投球数据并确保数据完整性。你将利用 TypeScript 声明文件来构建程序的类型系统。从那时起,你将导入 Lodash 库,并通过实现来自 DefinitelyTyped 的类型定义来向程序添加类型检查。

步骤如下:

  1. 访问以下 GitHub 仓库并下载包含规范和配置元素的 activity 项目:https://packt.link/vnj1R。

  2. 创建一个名为 heat_map_data.ts 的文件。

  3. 在文件上运行 TypeScript 编译器并观察更改。

  4. 创建一个声明文件并定义一个名为 HeatMapTypes 的模块,并导出名为 Pitcher 的接口。

  5. Pitcher 模块定义三个属性:batterHotZonespitcherHotZonescoordinateMap

  6. 所有三个属性的数据结构应该相同,即 Array<Array<number>>,但 coordinateMap 应该是可选的。

  7. 然后,将声明文件导入到 heat_map_data.ts 文件中。接着,创建并导出一个名为 datalet 变量,并将其分配给 Pitcher 类型。

  8. 添加符合声明规则的数据,确保嵌套数组中的一个与 batterHotZonespitcherHotZones 属性中的一个是相同的。

  9. 创建一个名为 findMatch 的新函数,该函数接受 batterHotZonespitcherHotZones 数组,并使用 lodash 函数 intersectionWith 返回相同的嵌套数组。您需要导入在最初运行 npm install 时安装的 Lodash 库。最后,将 findMatch 的值存储在声明文件中定义的 coordinateMap 属性中。

    该活动的预期输出将是一个类似以下的嵌套数组:

    [[10.2, -5], [3, 2]]
    

    注意

    该活动的解决方案可以通过此链接找到。

摘要

在本章中,我们介绍了如何在 TypeScript 中使用声明文件。我们分析了声明文件如何帮助 IDE 指导程序的结构。我们看到了结构化声明文件的示例。将声明文件导入 TypeScript 文件有助于开发周期。我们学习了如何将对象分配给在声明文件中定义的自定义类型。它将类型指导注入到 IDE 的 IntelliSense 过程中。我们还了解了 DefinitelyTyped 以及如何利用它为第三方库添加类型,并像有类型的程序一样与之协作。

在掌握了所有关于声明文件的知识后,在下一章中,我们将深入探讨在 TypeScript 中使用函数。我们将使用类型定义一个函数,在一个模块中构建一系列函数,构建一个执行特定任务的函数类,并探索单元测试。

第四章:3. 函数

概述

函数是任何应用程序的基本构建块。本章将教你如何利用 TypeScript 的多功能函数释放其力量,这些功能可能在其他编程语言中找不到。我们将讨论 this 关键字,并查看函数表达式、成员函数和箭头函数。本章还讨论了函数参数,包括剩余参数和默认参数。我们还将查看 importexport 关键字。

本章还将教你如何编写通过不同参数组合的测试用例,并将预期的输出与实际输出进行比较。我们将通过设计一个原型应用程序并完成单元测试来结束本章。

简介

到目前为止,我们已经学习了 TypeScript 的一些基础知识,如何设置项目以及定义文件的使用。现在我们将深入探讨函数的主题,这些函数将成为你工具箱中最重要的工具。甚至面向对象编程范式也严重依赖于函数作为业务逻辑的基本构建块。

函数,有时也称为例程或方法,是每种高级编程语言的一部分。重用代码段的能力至关重要,但函数提供的作用比这更为重要,因为它们可以接受不同的参数或变量来执行并产生不同的结果。编写好的函数是优秀程序与卓越程序之间的区别。你首先需要从学习语法开始,然后再考虑通过考虑它应该接受什么参数以及它应该产生什么来编写一个好的函数。

在本章中,我们将介绍创建函数的三种不同方法。我们将描述使用 this 关键字的陷阱和正确用法。我们将探讨强大的编程技术,包括柯里化、函数式编程和闭包的使用。我们将探讨 TypeScript 模块系统以及如何通过 importexport 关键字在模块之间共享代码。我们将看到函数如何组织到类中,以及如何将 JavaScript 代码重构为 TypeScript。然后我们将学习如何使用流行的 Jest 测试框架。

将这些技能付诸实践,我们将设计、构建和测试一个原型航班预订系统。

TypeScript 中的函数

函数的简单定义是一组可以被调用的语句;然而,函数的使用和约定并不能如此简单地总结。TypeScript 中的函数比某些其他语言具有更大的实用性。除了正常调用外,函数还可以作为其他函数的参数,并可以从函数中返回。实际上,函数是一种可以调用的特殊对象。这意味着除了参数外,函数实际上还可以有自己的属性和方法,尽管这很少发生。

只有极小的程序才会避免大量使用函数。大多数程序将由许多.ts文件组成。这些文件通常导出函数、类或对象。程序的其它部分将通过调用函数与导出的代码进行交互。函数创建了你应用程序逻辑的重用模式,并允许你编写DRY不要重复自己)的代码。

在深入研究函数之前,让我们进行一个练习,以了解函数在一般情况下是如何有用的。如果你不理解练习中的一些与函数相关的语法,请不要担心。你将在本章的后面学习所有这些内容。以下练习的目的是帮助你理解函数的重要性。

练习 3.01:在 TypeScript 中开始使用函数

为了举例说明函数的有用性,你将创建一个计算平均值的程序。这个练习首先创建一个不使用任何函数的程序。然后,将使用函数执行相同的计算平均值任务。

让我们开始吧:

注意

这个练习的代码文件可以在 https://packt.link/ZHrsh 找到。

  1. 打开 VS Code 并创建一个名为 Exericse01.ts 的新文件。编写以下不使用除console.log语句以外的任何函数的代码:

    const values = [8, 42, 99, 161];
    let total = 0;
    for(let i = 0; i < values.length; i++) {
        total += values[i];
    }
    const average = total/values.length;
    console.log(average);
    
  2. 通过在终端中执行npx ts-node Exercise 01.ts来运行文件。你会得到以下输出:

    77.5\. 
    
  3. 现在,使用内置函数和我们的自定义函数calcAverage重写相同的代码:

    const calcAverage = (values: number[]): number =>     (values.reduce((prev, curr) =>     prev + curr, 0) / values.length);
    const values = [8, 42, 99, 161];
    const average = calcAverage(values);
    console.log(average);
    
  4. 运行文件并观察输出:

    77.5\. 
    

    输出相同,但这段代码更简洁、更易于理解。我们编写了自己的函数,同时也使用了内置的array.reduce函数。理解函数的工作原理将使我们能够编写自己的有用函数,并利用强大的内置函数。

让我们继续在此基础上进行练习。除了获取平均值之外,考虑一个计算标准差的程序。这可以写成没有函数的过程化代码:

Example01_std_dev.ts
1  const values = [8, 42, 99, 161];
2  let total = 0;
3  for (let i = 0; i < values.length; i++) {
4      total += values[i];
5  }
6  const average = total / values.length;
7  const squareDiffs = [];
8  for (let i = 0; i < values.length; i++) {
9      const diff = values[i] - average;
10    squareDiffs.push(diff * diff)
11 }
12 total = 0;
13 for (let i = 0; i < squareDiffs.length; i++) {
14     total += squareDiffs[i];
15 }
16 const standardDeviation = Math.sqrt(total / squareDiffs.length);
17 console.log(standardDeviation);
Link to the preceding example: https://packt.link/YdTYD

运行文件后,你会得到以下输出:

58.148516748065035

虽然我们得到了正确的结果,但这段代码非常低效,因为实现细节(在循环中求和数组,然后除以它的长度)被重复了。此外,由于没有使用函数,代码调试起来会很困难,因为程序的不同部分不能单独运行。如果我们得到一个错误的结果,整个程序必须反复运行,进行微小的修正,直到我们确信得到正确的结果。这对于包含数千或数百万行代码的程序来说是不适用的,因为许多主要的网络应用程序就是这样。现在考虑以下程序:

Example02_std_dev.ts
1  const calcAverage = (values: number[]): number =>
2  (values.reduce((prev, curr) => prev + curr, 0) / values.length);
3  const calcStandardDeviation = (values: number[]): number => {
4    const average = calcAverage(values);
5    const squareDiffs = values.map((value: number): number => {
6      const diff = value - average;
7      return diff * diff;
8    });
9    return Math.sqrt(calcAverage(squareDiffs));
10 }
11 const values = [8, 42, 99, 161];
12 console.log(calcStandardDeviation(values));
Link to the preceding example: https://packt.link/smsxT

输出如下:

58.148516748065035

再次强调,输出是正确的,并且在这个程序中我们两次重用了calcAverage函数,证明了编写该函数的价值。即使所有函数和语法现在还不明白,大多数程序员都会同意更简洁、更具表达力的代码比没有重用模式的代码块更可取。

关键字函数

创建函数的最简单方法是使用function关键字的功能声明。关键字位于函数名之前,之后是一个参数列表,函数体用大括号括起来。函数的参数列表始终用括号括起来,即使没有参数也是如此。在 TypeScript 中,括号始终是必需的,与一些其他语言(如 Ruby)不同:

function myFunction() {
  console.log('Hello world!');
}

一个成功完成的函数总是会返回一个或零个值。如果没有返回任何内容,可以使用void标识符来表示没有返回任何内容。一个函数不能返回超过一个值,但许多开发者通过返回一个包含多个值的数组或对象来绕过这个限制,这些值可以被重新分配到单独的变量中。函数可以返回 TypeScript 中的任何内置类型或我们编写的类型。函数还可以返回复杂或内联类型(在后面的章节中描述)。如果一个函数可能返回的类型不能通过函数体和return语句轻松推断,那么给函数添加一个返回类型是一个好主意。它看起来像这样。void的返回类型表示这个函数不返回任何内容:

function myFunction(): void {
  console.log('Hello world!');
}

函数参数

参数是传递给函数的值的占位符。可以为函数指定任意数量的参数。由于我们正在编写 TypeScript,参数应该有它们的类型注解。让我们改变我们的函数,使其需要一个参数并返回一些内容:

Example03.ts
1 function myFunction(name: string): string {
2   return `Hello ${name}!`;
3 }

与前面的例子相比,这个函数期望一个名为name的单个参数,其类型已被定义为string - (name: string)。函数体已更改,现在使用字符串模板将问候消息作为模板字符串返回。我们可以这样调用函数:

4 const message = myFunction('world');
5 console.log(message);
Link to the preceding example: https://packt.link/ITlEU

当你运行文件后,你会得到以下输出:

Hello world!

这段代码使用参数'world'调用myFunction,并将函数调用的结果赋值给一个新的常量messageconsole对象是一个内置对象,它公开了一个log函数(有时称为对象成员的方法)将给定的字符串打印到控制台。由于myFunction将给定的参数连接到一个模板字符串,因此Hello world!被打印到控制台。

当然,在将函数结果记录之前,没有必要将其存储在常量中。我们可以简单地写下以下代码:

console.log(myFunction('world'));

这段代码将调用函数并将结果记录到控制台,如下面的输出所示:

Hello world!

本章中的许多示例都将采用这种形式,因为这是一种验证函数输出的简单方法。更复杂的应用程序使用单元测试和更健壮的日志解决方案来验证函数,因此读者应小心不要在应用程序中填充 console.log 语句。

参数与参数

许多开发者将参数和参数互换使用;然而,参数一词指的是传递给函数的值,而参数指的是函数中的占位符。在 myFunction('world'); 的例子中,'world' 字符串是一个参数,而不是参数。函数声明中具有指定类型的 name 占位符是一个参数。

可选参数

与 JavaScript 相比,TypeScript 函数参数只有在后缀加上 ? 时才是可选的。前一个示例中的函数 myFunction 期望一个参数。考虑以下情况,我们未指定任何参数:

const message = myFunction();

这段代码将导致编译错误:期望 1 个参数,但得到 0。这意味着代码甚至无法编译,更不用说运行了。同样,考虑以下代码片段,其中我们提供了一个错误类型的参数:

const message = myFunction(5);

现在的错误信息显示:类型 '5' 无法分配给类型 'string' 的参数

这个错误信息给出了我们尝试传递的值的可能最窄的类型。而不是说 参数类型为 'number',编译器将类型视为简单的数字 5。这给我们一个提示,即类型可以比原始的 number 类型窄得多。

TypeScript 通过强制类型来防止我们犯这样的错误。但如果我们确实想使参数可选怎么办?一个选项是,如前所述,在参数后缀加上 ?,如下面的代码片段所示:

Example04.ts
1 function myFunction(name?: string): string {
2   return `Hello ${name}!`;
3 }

现在我们可以成功调用它:

4 const message = myFunction();
5 console.log(message);
Link to the preceding example: https://packt.link/cnW4c

运行此命令将显示以下输出:

Hello undefined!

在 TypeScript 中,任何尚未分配的变量都将具有 undefined 的值。当函数执行时,undefined 值在运行时被转换为 undefined 字符串,因此 Hello undefined! 被打印到控制台。

默认参数

在前面的示例中,name 参数已被设置为可选,并且由于它从未获得过值,所以我们打印了 Hello undefined!。更好的方法是给 name 赋予一个默认值,如下所示:

Example05.ts
1 function myFunction(name: string = 'world'): string {
2   return `Hello ${name}!`;
3 }
Link to the preceding example: https://packt.link/zS5Ej

现在,如果我们不提供参数,函数将给出默认值:

4 const message = myFunction();
5 console.log(message);

输出如下:

Hello world!

如果我们使用以下代码提供参数,它将给出我们传递的值:

const message = myFunction('reader');
console.log(message);

这将显示以下输出:

Hello reader!

这很简单。现在,让我们尝试处理多个参数。

多个参数

函数可以有任意数量或类型的参数。参数列表由逗号分隔。尽管您的编译器设置可以允许您省略参数类型,但启用noImplicitAny选项是一种最佳实践。这将导致编译器错误,如果您意外省略了类型。此外,尽可能避免使用广泛的any类型,正如在第一章TypeScript 基础知识与类型概述中所述。第六章高级类型将更深入地探讨高级类型,特别是交集和联合类型,这将帮助我们确保所有变量都有良好、描述性的类型。

剩余参数

扩展运算符()可以用作函数的最后一个参数。这将接受传递给函数的所有参数并将它们放入一个数组中。让我们看看这个是如何工作的:

Example06.ts
1 function readBook(title: string, ...chapters: number[]): void {
2   console.log(`Starting to read ${title}...`);
3   chapters.forEach(chapter => {
4     console.log(`Reading chapter ${chapter}.`);
5   });
6   console.log('Done reading.');
7 }
Link to the preceding example: https://packt.link/Fw2iC

现在,函数可以用可变参数列表来调用:

readBook('The TypeScript Workshop', 1, 2, 3);

第一个参数是必需的。其余的是可选的。我们完全可以不指定任何章节来阅读。然而,如果我们提供了额外的参数,它们必须是number类型,因为这是我们用作类型(number[])的剩余参数的类型。

运行前面的代码后,您将获得以下输出:

Starting to read The TypeScript Book...
Reading chapter 1.
Reading chapter 2.
Reading chapter 3.
Done reading.

注意,这个语法特别要求单个参数为number类型。我们可以不使用剩余参数来实现该函数,而是期望一个数组作为单个参数:

Example07.ts
1 function readBook(title: string, chapters: number[]): void {
2   console.log(`Starting to read ${title}...`);
3   chapters.forEach(chapter => {
4     console.log(`Reading chapter ${chapter}.`);
5   });
6   console.log('Done reading.');
7 }
Link to the preceding example: https://packt.link/AvInF

函数现在将精确需要两个参数:

readBook('The TypeScript Book', [1, 2, 3]);

输出如下:

Starting to read The TypeScript Book...
Reading chapter 1.
Reading chapter 2.
Reading chapter 3.
Done reading.

哪个更好?这需要你自己决定。在这种情况下,我们想要阅读的章节已经以数组形式存在,然后将其传递给函数可能最有意义。

注意,readBook函数中包含一个箭头函数。我们将在下一节中介绍箭头函数。

解构返回类型

有时,一个函数返回多个值可能是有用的。采用函数式编程范式的程序员通常希望有一个返回元组或包含不同类型两个元素的数组函数。回到我们之前的例子,如果我们想计算数字数组的平均值和标准差,可能有一个同时处理这两个操作的单一函数会更方便,而不是需要多次调用同一个数字数组。

TypeScript 中的函数只能返回一个值。然而,我们可以通过解构来模拟返回多个参数。解构是将对象或数组的一部分分配给不同变量的实践。这允许我们将返回值的各个部分分配给变量,给人一种返回多个值的感觉。让我们看一个例子:

Example08.ts
1 function paritySort(...numbers: number[]): { evens: number[], odds: 2 number[] } {
3   return {
4     evens: numbers.filter(n => n % 2 === 0),
5     odds: numbers.filter(n => n % 2 === 1)
6   };
7 }
Link to the preceding example: https://packt.link/SHkuW

此代码使用内置数组对象的 filter 方法遍历数组中的每个值并对其进行测试。如果测试返回 true 布尔值,则该值将被推入一个新数组,该数组将被返回。使用模运算符来测试余数将我们的数字数组过滤成两个单独的数组。然后函数将返回这些数组作为对象属性。我们可以利用这一点进行解构。考虑以下代码:

const { evens, odds } = paritySort(1, 2, 3, 4);
console.log(evens); 
console.log(odds);

在这里,我们给函数传递参数 1, 2, 3, 4,它返回以下输出:

[2, 4]
[1, 3]

函数构造函数

注意,TypeScript 语言包含一个大写的 Function 关键字。这不同于小写的 function 关键字,不应使用,因为它被认为不安全,因为它能够解析和执行任意代码字符串。Function 关键字仅在 TypeScript 中存在,因为 TypeScript 是 JavaScript 的超集。

练习 3.02:比较数字数组

TypeScript 比较运算符如 ===> 仅适用于原始类型。如果我们想比较更复杂的数据类型,如数组,我们需要使用库或实现自己的比较。让我们编写一个函数,可以比较一对未排序的数字数组,并告诉我们值是否相等。

注意

本练习的代码文件可在 packt.link/A0IxN 找到。

  1. 在 VS Code 中创建一个新文件,并将其命名为 array-equal.ts

  2. 从以下代码开始,该代码声明了三个不同的数组并输出,无论它们是否相等:

    const arrayone = [7, 6, 8, 9, 2, 25];
    const arraytwo = [6, 8, 9, 2, 25];
    const arraythree = [6, 8, 9, 2, 25, 7];
    function arrayCompare(a1: number[], a2: number[]): boolean {
      return true;
    }
    console.log(
      `Are ${arrayone} and ${arraytwo} equal?`,
      arrayCompare(arrayone, arraytwo)
    );
    console.log(
      `Are ${arrayone} and ${arraythree} equal?`,
      arrayCompare(arrayone, arraythree)
    );
    console.log(
      `Are ${arraytwo} and ${arraythree} equal?`,
      arrayCompare(arraytwo, arraythree)
    );
    

    由于函数尚未实现,它仅返回 true,因此所有三个比较的输出都将为 true。

    我们的功能 arrayCompare 接受两个数组作为参数,并返回一个布尔值,表示它们是否相等。我们的业务规则是,如果所有值在排序后匹配,则未排序的数组将被视为相等。

  3. 使用以下代码更新 arrayCompare

    function arrayCompare(a1: number[], a2: number[]): boolean {
      if(a1.length !== a2.length) {
        return false;
      }
      return true;
    }
    

    在前面的代码中,我们正在测试传入的两个数组是否相等。我们首先应该检查数组长度是否相等。如果它们长度不相等,那么值就不可能相等,因此我们将从函数中返回 false。如果在执行过程中遇到返回语句,则函数的其余部分将不会执行。

    在这一点上,该函数将只能告诉我们数组是否长度相等。为了完成挑战,我们需要比较数组中的每个值。在尝试比较之前对值进行排序将使这项任务容易得多。幸运的是,数组对象原型包括一个 sort() 方法,这将为我们处理这一切。使用内置函数可以节省大量开发时间。

  4. 实现排序 sort() 方法以排序数组值:

    function arrayCompare(a1: number[], a2: number[]): boolean {
      if(a1.length !== a2.length) {
        return false;
      }
      a1.sort();
      a2.sort();
      return true;
    }
    

    sort() 方法就地排序数组元素,因此没有必要将结果分配给新变量。

    最后,我们需要遍历其中一个数组,比较相同索引处的每个元素。我们使用 for 循环遍历第一个数组,并将每个索引处的值与第二个数组中相同索引处的值进行比较。由于我们的数组使用原始值,!== 比较运算符将起作用。

  5. 使用以下 for 循环遍历数组:

    function arrayCompare(a1: number[], a2: number[]): boolean {
      if(a1.length !== a2.length) {
        return false;
      }
      a1.sort();
      a2.sort();
      for (let i = 0; i < a1.length; i++) {
        if (a1[i] !== a2[i]) {
          return false;
        }
      }
      return true;
    }
    

    再次,如果任何比较失败,我们将返回 false 并退出函数。

  6. 使用 ts-node 执行程序:

    npx ts-node array-equal.ts
    

    程序将产生以下输出:

    Are 7,6,8,9,2,25 and 6,8,9,2,25,8 equal? false
    Are 2,25,6,7,8,9 and 6,8,9,2,25,7 equal? true
    Are 2,25,6,8,8,9 and 2,25,6,7,8,9 equal? False
    
  7. 尝试不同的数组组合,并验证程序是否正确运行。

一个好的函数接受一个参数列表并返回一个单一值。你现在既有编写函数的经验,也有利用内置函数解决问题的经验。

函数表达式

函数表达式与函数声明不同之处在于它们可以被分配给变量、内联使用或立即调用——立即调用的函数表达式或 IIFE。函数表达式可以是命名的或匿名的。让我们看看几个例子:

Example09.ts
1 const myFunction = function(name: string): string {
2   return `Hello ${name}!`;
3 };
4 console.log(myFunction('function expression'));
Link to the preceding example: https://packt.link/2JeGQ

你将得到以下输出:

Hello function expression!

这看起来与之前我们看过的例子非常相似,并且几乎以相同的方式工作。以下是用于比较的函数声明:

function myFunction(name: string = 'world'): string {
  return `Hello ${name}!`;
}

唯一的一点不同是函数声明是 提升的,这意味着它们被加载到内存中(连同任何声明的变量一起),因此可以在代码中声明之前使用。通常认为依赖提升是一种不好的做法,因此现在许多代码检查工具不允许这样做。过度使用提升的程序可能存在难以追踪的 bug,甚至可能在不同的系统中表现出不同的行为。函数表达式之所以受欢迎,其中一个原因就是它们不允许提升,因此避免了这些问题。

函数表达式可以用来创建匿名函数,即没有名字的函数。这与函数声明不同。匿名函数通常用作原生函数的回调。例如,考虑以下使用 Array.filter 函数的代码片段:

Example10.ts
1 const numbers = [1, 3, 2];
2 const filtered = numbers.filter(function(val) {return val < 3});
3 console.log(filtered);
Link to the preceding example: https://packt.link/aJyhj

输出如下:

[1, 2]

记住,在 TypeScript(以及 JavaScript)中,函数可以作为参数传递给其他函数,或者从其他函数返回。这意味着我们可以将匿名函数 function(val) { return val < 3 } 作为 Array.filter 函数的参数。这个函数没有名字,不能被其他代码引用或调用。这在大多数情况下是可以的。如果我们想,我们可以给它起一个名字:

const filtered = numbers.filter(function myFilterFunc(val) {return val < 3});

在大多数情况下,这样做没有太大意义,但如果函数需要自引用,例如递归函数,这可能会很有用。

注意

更多关于回调的信息,请参阅 第十一章TypeScript 中的高阶函数和回调

立即调用的函数表达式看起来像这样:

Example11.ts
1 (function () {
2    console.log('Immediately invoked!');
3 })();
Link to the preceding example: https://packt.link/iQoSX

函数输出以下内容:

 "Immediately invoked!"

函数是内联声明的,然后末尾的额外()括号调用函数。TypeScript 中 IIFE 的主要用例涉及另一个称为闭包的概念,这将在本章后面讨论。现在,只需学会识别这种立即声明和调用的函数语法即可。

箭头函数

箭头函数提供了一种更紧凑的语法,同时也为围绕this关键字令人困惑且不一致的规则提供了一个替代方案。让我们首先看看语法。

箭头函数移除了function关键字,并在参数列表和函数体之间放置了一个“胖箭头”或=>。箭头函数从不命名。让我们重写一个打印Hello的函数:

const myFunction = (name: string): string => {
  return `Hello ${name}!`;
};

这个函数可以变得更加紧凑。如果函数只是返回一个值,则可以省略大括号和return关键字。我们的函数现在看起来是这样的。

const myFunction = (name: string): string => `Hello ${name}!`;

箭头函数在回调函数中非常常用。前一个过滤器函数的回调可以使用箭头函数重写。再次强调,回调将在第十一章“TypeScript 中的高阶函数和回调”中更详细地讨论。下面是箭头函数的另一个示例:

Example12.ts
1 const numbers = [1, 3, 2];
2 const filtered = numbers.filter((val) => val < 3);
2 console.log(filtered);
Link to the preceding example: https://packt.link/lUTCm

输出如下:

[1, 2]

这种简洁的语法一开始可能看起来有些令人困惑,所以让我们来分解一下。filter函数是 TypeScript 中数组对象的一个内置方法。它将返回一个新数组,包含所有在回调函数中符合标准的数组项。因此,我们说对于每个val,如果val小于3,就将其添加到新数组中。

箭头函数不仅仅是语法上的不同。虽然函数声明和函数表达式创建一个新的执行作用域,但箭头函数不会。这在使用this(见下文)和new(见第四章,“类和对象”)关键字时具有影响。

类型推断

让我们考虑以下代码:

const myFunction = (name: string): string => `Hello ${name}!`;
const numbers = [1, 3, 2];
const filtered = numbers.filter((val) => val < 3);
console.log(filtered);

输出如下:

[1, 2]

注意,在上述代码中,我们没有为numbers常量指定类型。但是等等,这不是一本关于 TypeScript 的书吗?是的,现在我们来到了 TypeScript 的一个最佳特性:类型推断。TypeScript 有在省略类型的情况下为变量分配类型的能力。当我们声明const numbers = [1, 2, 3];时,TypeScript 会直观地理解我们正在声明一个数字数组。如果我们想的话,我们可以写const numbers: number[] = [1, 2, 3];,但 TypeScript 会认为这些声明是相等的。

上述代码是 100%有效的 ES6 JavaScript。这很好,因为任何 JavaScript 开发者都能阅读和理解它,即使他们没有 TypeScript 的经验。然而,与 JavaScript 不同,TypeScript 会通过将错误类型的值放入numbers数组来防止你出错。

因为 TypeScript 推断出了我们的numbers数组类型,所以我们无法向其中添加除了数字以外的值;例如,numbers.push('hello');将导致编译器错误。如果我们想声明一个允许其他类型的数组,我们需要明确声明这一点——const numbers: (number | string)[] = [1, 3, 2];。现在,我们可以稍后向这个数组赋值一个字符串。或者,声明为const numbers = [1, 2, 3, 'abc'];的数组已经具有这种类型。

回到我们的filter函数,这个函数也没有指定参数或返回类型。为什么允许这样做?又是我们的朋友,类型推断。因为我们正在遍历一个数字数组,该数组中的每个项目都必须是数字。因此,val将始终是数字,无需指定类型。同样,表达式val < 3是一个布尔表达式,所以返回类型将始终是布尔类型。记住,可选意味着你总是可以选择提供所需类型,如果你认为这可以提高代码的清晰度或可读性,那么你绝对应该这样做。

当一个箭头函数只有一个参数且类型可以推断时,我们可以通过省略参数列表周围的括号来使我们的代码稍微简洁一些。最后,我们的filter函数可能看起来像这样:

Example13.ts
1 const numbers = [1, 3, 2];
2 const filtered = numbers.filter(val => val < 3);
3 console.log(filtered);
Link to the preceding example: https://packt.link/hvbsc

输出结果如下:

[1, 2]

你选择的语法完全是个人品味的问题,但许多经验丰富的程序员倾向于更简洁的语法,因此至少能够阅读和理解它。

练习 3.03:编写箭头函数

现在,让我们编写一些箭头函数,熟悉这种语法,并开始构建我们的实用库。实用库的一个好候选函数是可能被调用的函数。在这个练习中,我们将编写一个函数,它接受一个主语、谓语和对象列表,并返回一个语法正确的句子。

注意

这个练习的代码文件可以在packt.link/yIQnz找到。

  1. 在 VS Code 中创建一个新文件,并将其保存为arrow-cat.ts

  2. 从我们需要实现的函数的模式开始,以及一些对其的调用:

    export const sentence = (
      subject: string,
      verb: string,
      ...objects: string[]
    ): string => {
      return 'Meow, implement me!';
    };
    console.log(sentence('the cat', 'ate', 'apples', 'cheese', 'pancakes'));
    console.log(sentence('the cat', 'slept', 'all day'));
    console.log(sentence('the cat', 'sneezed'));
    

    我们的sentence函数显然没有做我们需要的。我们可以修改实现以使用模板字符串来输出主语、谓语和宾语。

  3. 使用以下代码实现一个模板字符串来输出主语、谓语和宾语:

    export const sentence = (
      subject: string,
      verb: string,
      ...objects: string[]
    ): string => {
      return `${subject} ${verb} ${objects}.`;
    };
    

    现在,当我们执行我们的程序时,我们得到以下输出:

    the cat ate apples,cheese,pancakes.
    the cat slept all day.
    the cat sneezed .
    

    这看起来是可读的,但我们有关于大小写和单词间距的几个问题。我们可以添加一些额外的函数来帮助解决这些问题。思考这些情况应该逻辑上发生什么,如果有多个对象,我们希望在它们之间添加逗号,并在最后一个对象之前使用“and”。如果只有一个对象,则不应有逗号或“and”,如果没有对象,则不应有空格,就像这里一样。

  4. 实现一个新的函数,将此逻辑添加到我们的程序中:

    export const arrayToObjectSegment = (words: string[]): string => {
      if (words.length < 1) {
        return '';
      }
      if (words.length === 1) {
        return ` ${words[0]}`;
      }
      ...
    };
    

    在这里,我们实现了较简单的情况。如果没有对象,我们希望返回一个空字符串。如果只有一个对象,我们返回该对象,前面加一个空格。现在,让我们来处理多个对象的情况。

    我们需要将对象添加到一个以逗号分隔的列表中,如果已经到达最后一个对象,则用“and”连接。

  5. 要做到这一点,我们将初始化一个空字符串,并遍历对象的数组:

    export const arrayToObjectSegment = (words: string[]): string => {
      if (words.length < 1) {
        return '';
      }
      if (words.length === 1) {
        return ` ${words[0]}`;
      }
      let segment = '';
      for (let i = 0; i < words.length; i++) {
        if (i === words.length - 1) {
          segment += ` and ${words[i]}`;
        } else {
          segment += ` ${words[i]},`;
        }
      }
      return segment;
    };
    

    通过将问题分解成小的组件,我们提出了一个解决所有用例的函数。现在,我们的sentence函数的return语句可以写成return ${subject} \({verb}\){arrayToObjectSegment(objects)}.;

    注意,返回字符串的函数可以完美地嵌入我们的字符串模板中。运行这个程序,我们得到以下输出:

    the cat ate apples, cheese, and pancakes.
    the cat slept all day.
    the cat sneezed.
    

    这几乎是对的,但句子的第一个字母应该大写。

  6. 使用另一个函数来处理大小写,并用它来包裹整个字符串模板:

    export const capitalize = (sentence: string): string => {
      return `${sentence.charAt(0).toUpperCase()}${sentence
        .slice(1)
        .toLowerCase()}`;
    };
    
  7. 这个函数使用了几个内置函数:charAttoUpperCaseslicetoLowerCase,所有这些都在字符串模板内部。这些函数从我们的句子中获取第一个字符,将其转换为大写,然后将其与句子的其余部分连接起来,所有内容都转换为小写。

    现在,当我们执行程序时,我们得到期望的结果:

    The cat ate apples, cheese, and pancakes.
    The cat slept all day.
    The cat sneezed.
    

为了完成这个练习,我们编写了三个不同的函数,每个函数都服务于单一目的。我们本可以将所有功能都塞进一个函数中,但那样会使生成的代码的可重用性降低,并且更难以阅读和测试。从简单的单一目的函数构建软件仍然是编写干净、可维护代码的最好方法之一。

理解这一点

许多开发者都曾因this关键字而感到沮丧。this名义上指向当前函数的运行时。例如,如果调用一个对象的成员函数,this通常指向该对象。在其他上下文中使用this可能看起来不一致,其使用可能导致许多异常错误。部分问题在于,在 C++或 Java 等语言中,关键字的使用相对简单,那些有经验的程序员可能会期望 TypeScript 中的this有类似的行为。

让我们看看this的一个非常简单的用例:

const person = {
    name: 'Ahmed',
    sayHello: function () {
        return `Hello, ${this.name}!`
    }
}
console.log(person.sayHello());

在这里,我们声明了一个具有属性name和方法sayHello的对象。为了让sayHello读取name属性,我们使用this来引用对象本身。这段代码没有问题,许多程序员会认为它非常直观。

当我们需要声明另一个内联函数时,问题就会出现,这可能是作为之前查看的filter函数等回调函数的一部分。

让我们设想我们想要将arrayFilter函数封装在一个对象中,该对象可以有一个属性来指定允许的最大数量。这个对象将与之前的对象有些相似,我们可能会期望能够使用this来获取那个最大值。让我们看看当我们尝试时会发生什么:

const arrayFilter = {
    max: 3,
    filter: function (...numbers: number[]) {
        return numbers.filter(function (val) {
            return val <= this.max;
        });
    }
}
console.log(arrayFilter.filter(1, 2, 3, 4));

TypeScript 不喜欢我的代码。根据我的编辑器,this下面会出现一条红色的波浪线,我将无法执行我的程序。即使程序执行了,你也不会得到预期的输出。

这里的问题是,我使用function关键字创建了一个新的作用域,this不再具有我想要的值。事实上,它没有任何值。它是undefined

原因在于,与 C++和 Java 这样的面向对象语言不同,this的值将在运行时确定,并将其设置为调用范围。在这种情况下,我们的回调函数不属于任何设置上下文或对象,因此thisundefined。实际上,undefined在这里并不重要。重要的是它不是我们想要的。

这些年来,已经出现了许多解决这个问题的方法。其中之一是我们将this引用缓存到另一个变量中,并在我们的回调函数中使该变量可用。另一种方法是使用Function原型的bind成员函数来设置this引用。你可能会遇到类似这样的代码。

一个更好的解决方案是简单地使用箭头函数而不是函数表达式。不仅语法更简洁、更现代,而且箭头函数不会创建新的this上下文。你将得到你想要的this引用,即顶层对象的引用。让我们使用箭头函数重写代码:

Example14.ts
1 const arrayFilter = {
2     max: 3,
3     filter: function(...numbers: number[]) {
4         return numbers.filter(val => {
5             return val <= this.max;
6         });
7     }
8 }
9 console.log(arrayFilter.filter(1, 2, 3, 4));
Link to the preceding example: https://packt.link/90JSJ

该函数产生以下输出:

[1, 2, 3]

TypeScript 不再对this提出抱怨,代码运行正确。

但是等等,为什么我们为filter函数使用函数表达式,而为回调使用箭头函数?这是因为我们实际上需要function的作用域创建能力,以便this具有值。如果我们把filter函数重写为箭头函数,this将永远不会设置,我们就无法访问max属性。

这确实令人困惑,这也是为什么在 TypeScript 和 JavaScript 中,this比其他语言更让人讨厌的原因。重要的是要记住,当你使用this编程时,你希望任何对象或类方法都是函数表达式,任何回调都是箭头函数。这样,你将始终拥有正确的this实例。

第四章类和对象,将更深入地探讨类并探索其他模式。现在让我们在下面的练习中使用对象中的this

练习 3.04:在对象中使用 this

对于这个练习,我们将想象我们必须实现一些会计软件。在这个软件中,每个账户对象将跟踪应付款总额,以及已付款金额,并将有几个实用方法来获取账户的当前状态和需要支付的余额。

让我们先创建一个对象,其方法尚未实现。这个例子将演示一个简化的工作流程,其中我们打印出账户,尝试支付超过应付款额(收到错误),然后支付应付款额,最后支付全额应付款额:

注意

这个练习的代码文件可以在packt.link/P6YIf找到。

  1. 编写以下代码,这是我们程序的起点:

    export const account = {
      due: 1000,
      paid: 0,
      status: 'OPEN',
      payAccount: function (amount: number): string {
        return 'unimplemented!';
      },
      printStatus: function (): string {
        return 'unimplemented!';
      },
    };
    console.log(account.printStatus());
    console.log(account.payAccount(1500));
    console.log(account.payAccount(500));
    console.log(account.payAccount(500));
    

    我们需要实现两种方法。printStatus方法将只输出应付款总额、已付款金额以及账户是开放还是关闭(或已全额支付)。

  2. 使用字符串模板来输出状态,但为了访问account对象上的属性,请使用this关键字:

      printStatus: function (): string {
        return `$${this.paid} has been paid and $${
          this.due - this.paid
        } is outstanding. This account is ${this.status}.`;
      },
    

    我们将printStatus函数表达式实现为一个使用this来访问同一对象属性的字符串模板。作为提醒,我们必须在这里使用函数表达式,而不能使用箭头函数,即使我们可能更喜欢那种语法,因为箭头函数不会创建一个新的执行上下文。

    如果有任何疑问,这里没有双美元符号运算符。第一个是表示货币的文本,第二个是模板字符串的一部分。

    现在让我们处理付款。我们的要求是,如果支付的金额超过应付款额,我们应该抛出一个错误,并且不应用任何付款。否则,我们跟踪额外的付款。如果余额达到$0,则关闭账户。我们应在每次交易后打印当前状态。

  3. 编写处理付款的代码:

      payAccount: function (amount: number): string {
        if (amount > this.due - this.paid) {
          return `$${amount} is more than the outstanding balance of $${
            this.due - this.paid
          }.`;
        }
        this.paid += amount;
        if (this.paid === this.due) {
          this.status = 'CLOSED';
        }
        return this.printStatus();
      },
    
  4. 执行程序并检查输出:

    $0 has been paid and $1000 is outstanding. This account is OPEN.
    $1500 is more than the outstanding balance of $1000.
    $500 has been paid and $500 is outstanding. This account is OPEN.
    $1000 has been paid and $0 is outstanding. This account is CLOSED
    

在这个练习中,我们使用了函数表达式作为对象方法来访问对象上的属性。方法不仅可以读取对象上的属性,还可以更新它们。在面向对象编程中,有一个常见的模式是对象既包含数据,又具有访问和修改这些数据的方法。有时,这些方法会被设置为私有,并且只能通过getset等访问器来访问。关于这个主题的更多内容将在第四章类和对象中介绍。

正如我们在这次练习中看到的,在实现面向对象模式时,了解和掌握函数表达式仍然很重要。

闭包和作用域

除了我们之前讨论的所有内容之外,在 TypeScript 中函数还有一些特殊的行为。当一个函数被声明时(无论是函数声明、表达式还是箭头函数),它会封闭任何更高作用域中的变量。这被称为闭包。任何函数都可以是闭包。闭包简单地是一个封闭了变量的函数。

范围的概念简单地说就是每个函数创建一个新的作用域。正如我们所看到的,函数可以在其他函数内部声明。内部函数可以读取在外部函数中声明的任何变量,但外部函数无法看到在内部函数中声明的变量。这就是作用域。以下代码通过在外部函数内部声明第二个函数来建立外部作用域和内部作用域。内部函数能够访问外部作用域中的变量,但内部作用域中声明的world变量在该函数外部不可见:

Example15.ts
1 const outer = (): void => {
2     const hello = 'Hello';
3     const inner = (): void => {
4         const world = 'world!';
5         console.log(`${hello} ${world}`);
6     }
7     inner();
8 
9     console.log(`${hello} ${world}`);
10 }
11 outer();
Link to the preceding example: https://packt.link/USZ74

该函数产生以下输出:

Hello world!
ReferenceError: world is not defined

当这个函数被调用时,会到达内部日志语句并记录"Hello world!",然后到达外部日志语句,我们得到ReferenceError。我们可以通过在外部函数中添加let world;来修复ReferenceError

Example16.ts
1  const outer = (): void => {
2      const hello = 'Hello';
3      let world;
4      const inner = (): void => {
5          const world = 'world!';
6          console.log(`${hello} ${world}`);
7      }
8      inner();
8 
9      console.log(`${hello} ${world}`);
10  }
11  outer();
Link to the preceding example: https://packt.link/yC0Zq

该函数产生以下输出:

Hello world!
Hello undefined!

这是因为内部函数声明了一个新的world变量,而外部函数无法访问它。我们可以从内部声明中删除const

Example17.ts
1  const outer = (): void => {
2      const hello = 'Hello';
3      let world;
4      const inner = (): void => {
5          world = 'world!';
6          console.log(`${hello} ${world}`);
7      }
8      inner();
9  
10     console.log(`${hello} ${world}`);
11 }
12 
13 outer();
Link to the preceding example: https://packt.link/fCsaY

该函数产生以下输出:

Hello world!
Hello world!

函数最终可以工作,因为内部函数是在外部函数的作用域中声明的变量上操作的。在退出内部作用域后,它仍然可见,因此可以打印出来。

让我们看看一个更有用的例子。斐波那契数列是一个数集,其中下一个数是前两个数的和:[0, 1, 1, 2, 3, 5, 8, 13, 21, …]。斐波那契数列通常用于帮助解释递归函数。在这种情况下,我们将用它来通过编写一个每次调用都返回序列中下一个值的函数来演示闭包。

我们程序的逻辑将是,我们将跟踪函数返回的当前数字,下一个应该是什么数字,以及增加数字的数量。每次调用时,这三个数字都将更新以准备下一次调用。实现这一点的一种方法是将这些值定义为全局作用域变量,并编写一个简单的函数来更新和跟踪它们。这可能看起来像这样:

Example_Fibbonacci_1.ts
1 let next = 0;
2 let inc = 1;
3 let current = 0; 
4 
5 for (let i = 0; i < 10; i++) {
6     [current, next, inc] = [next, inc, next + inc];
7     console.log(current);
8 }
Link to the preceding example: https://packt.link/17Hda

该函数产生以下输出:

0
1
1
2
3
5
8
13
21
34

这个程序可以运行并返回期望的结果,但由于它不是一个函数,程序将只执行一次然后停止。如果你想在其他过程中获取下一个斐波那契数,你将无法做到。如果你只是将它包裹在一个函数中,这也不会起作用:

Example_Fibbonacci_2.ts
1  const getNext = (): number => {
2      let next = 0;
3      let inc = 1;
4      let current = 0;
5      [current, next, inc] = [next, inc, next + inc];
6      return current;
7  };
8  
9  for (let i = 0; i < 10; i++) {
10     console.log(getNext());
11 }
Link to the preceding example: https://packt.link/rfDuz

该函数产生以下输出:

0
0
//...

这个函数每次被调用时都会返回0,因为所有变量在调用时都会被重新声明。我们可以通过将变量移出函数来修复这个问题。这样,它们只声明一次,并由被调用的函数修改。

我们的功能现在设置了下一个要返回的值,增量值以及最近返回的值。在循环中的每次函数调用,它将当前值替换为下一个值,下一个值替换为增量值,增量值替换为下一个值加上前一个增量值的总和。然后它记录当前值:

Example_Fibbonacci_3.ts
1  let next = 0;
2  let inc = 1;
3  let current = 0;
4  
5  const getNext = (): number => {
6      [current, next, inc] = [next, inc, next + inc];
7      return current;
8  };
9  
10 for (let i = 0; i < 10; i++) {
11     console.log(getNext());
12 }
Link to the preceding example: https://packt.link/mAEds

函数产生了以下输出:

0
1
1
2
3
5
8
13
21
34

这成功了!它之所以能成功,是因为getNext函数能够访问更高作用域中的变量。这个函数是一个闭包。这可能会显得很标准且预期,但可能意想不到的是,即使函数被导出并由程序的其他部分调用,这也能工作。这可以通过创建另一个函数来更好地说明:

Example_Fibbonacci_4.ts
1  const fibonacci = () => {
2      let next = 0;
3      let inc = 1;
4      let current = 0;
5      return () => {
6          [current, next, inc] = [next, inc, next + inc];
7          return current;
8      };
9  };
10 const getNext = fibonacci();
11 for (let i = 0; i < 10; i++) {
12     console.log(getNext());
13 }
Link to the preceding example: https://packt.link/CdKte

输出没有变化:

0
1
1
2
3
//...

调用fibonacci函数将返回一个新的函数,该函数可以访问fibonacci中声明的变量。如果我们想运行另一个斐波那契数列,我们可以再次调用fibonacci()以获得一个新的作用域和初始化的变量:

Example_Fibbonacci_5.ts
1  const fibonacci = () => {
2     let next = 0;
3     let inc = 1;
4      let current = 0;
5      return () => {
6          [current, next, inc] = [next, inc, next + inc];
7          return current;
8      };
9  };
10 const getNext = fibonacci();
11 const getMoreFib = fibonacci();
12 for (let i = 0; i < 10; i++) {
13     console.log(getNext());
14 }
15 for (let i = 0; i < 10; i++) {
16     console.log(getMoreFib());
17 }
Link to the preceding example: https://packt.link/0nGph

我们将再次看到相同的输出,但这次是两次:

0
1
1
2
//…
21
34
0
1
1
2
//…

注意

为了便于展示,这里只显示实际输出的部分。

在这两种情况下,闭包都覆盖了更高作用域中的变量,并且在函数调用中仍然可用。这是一个强大的技术,正如所展示的,但如果使用不当可能会导致内存泄漏。在这种闭包中声明的变量,在存在对其的引用时无法被垃圾回收。

练习 3.05:使用闭包创建订单工厂

闭包可能难以处理,但一个真正体现其有用性的常见模式有时被称为工厂模式。简单来说,这是一个返回另一个已经设置好并准备好使用的函数的函数。在这个模式中,闭包被用来确保变量可以在函数调用之间持久化。我们将在本练习中探讨这个模式。

让我们从几乎实现我们想要的功能的代码开始。我们正在为某种服装的订单系统工作。每个订单将指定相同颜色和大小的服装的数量。我们只需要为每件服装生成一个具有唯一 ID 的记录以进行跟踪:

注意

本练习的代码文件可以在packt.link/fsqdd找到。

  1. 在 VS Code 中创建一个新文件,并将其保存为order.ts。从以下代码和一些示例调用开始:

    interface Order {
      id: number;
      color: string;
      size: string;
    }
    export const createOrder = (
      color: string,
      size: string,
      quantity: number
    ): Order[] => {
      let id = 0;
      const orders = [];
      for (let i = 0; i < quantity; i++) {
        orders.push({ id: id++, color, size });
      }
      return orders;
    };
    const orderOne = createOrder('red', 'M', 4);
    console.log(orderOne);
    const orderTwo = createOrder('blue', 'S', 7);
    console.log(orderTwo);
    

    代码看起来没问题。让我们运行它看看效果如何。你会得到以下输出:

    [
      { id: 0, color: 'red', size: 'M' },
      { id: 1, color: 'red', size: 'M' },
      { id: 2, color: 'red', size: 'M' },
      { id: 3, color: 'red', size: 'M' }
    ]
    [
      { id: 0, color: 'blue', size: 'S' },
      { id: 1, color: 'blue', size: 'S' },
      { id: 2, color: 'blue', size: 'S' },
      { id: 3, color: 'blue', size: 'S' },
      { id: 4, color: 'blue', size: 'S' },
      { id: 5, color: 'blue', size: 'S' },
      { id: 6, color: 'blue', size: 'S' }
    ]
    

    这是不正确的。我们不能每次都从零开始重新开始 ID 号码。我们该如何解决这个问题?

    有几种方法可以解决这个问题。最简单的方法是在 orderFactory 之外声明 ID 号码。然而,这样做可能会随着系统复杂性的增长而导致错误。位于最高层或甚至全局作用域中的变量可以由系统的任何部分访问,并且可能被某些边缘情况修改。

  2. 使用闭包来解决这个问题。创建一个 orderFactory 函数,它返回 createOrder 的一个实例,将 ID 号码放在 createOrder 上方的范围中。这样,ID 就会在 createOrder 的多次调用之间跟踪。

    export const orderFactory = (): ((
      color: string,
      size: string,
      qty: number
    ) => Order[]) => {
      let id = 0;
      return (color: string, size: string, qty: number): Order[] => {
        const orders = [];
        for (let i = 0; i < qty; i++) {
          orders.push({ id: id++, color, size });
        }
        return orders;
      };
    };
    

    这个工厂函数返回另一个函数,该函数作为箭头函数内联定义。在返回该函数之前,id 变量在其上方的作用域中声明。每次调用返回的函数都会看到相同的 id 实例,因此它将在调用之间保留其值。

  3. 为了使用工厂,请调用一次函数:

    const createOrder = orderFactory();
    

    调用一次 orderFactory 将初始化 ID 变量,并使其在现在分配给 createOrder 的返回函数中可用。该变量现在是封装的。没有其他代码能够访问它,或者更重要的是,修改它。

  4. 运行程序并观察我们现在得到了正确的输出:

    [
      { id: 0, color: 'red', size: 'M' },
      { id: 1, color: 'red', size: 'M' },
      { id: 2, color: 'red', size: 'M' },
      { id: 3, color: 'red', size: 'M' }
    ]
    [
      { id: 4, color: 'blue', size: 'S' },
      { id: 5, color: 'blue', size: 'S' },
      { id: 6, color: 'blue', size: 'S' },
      { id: 7, color: 'blue', size: 'S' },
      { id: 8, color: 'blue', size: 'S' },
      { id: 9, color: 'blue', size: 'S' },
      { id: 10, color: 'blue', size: 'S' }
    ]
    

没有实践,闭包可能很难理解。TypeScript 初学者不应该担心立即掌握它们,但认识到工厂模式和封装变量的行为非常重要。

柯里化

柯里化(以数学家 Haskell Brooks Curry 命名,Haskell、Brooks 和 Curry 编程语言也是以他的名字命名的)是将一个函数(或数学中的公式)分解成具有单个参数的单独函数的行为。

注意

更多关于柯里化的信息,请参考以下网址:javascript.info/currying-partials

由于 TypeScript 中的函数可以返回函数,箭头语法为我们提供了一个特殊的简洁语法,使得柯里化成为一种流行的实践。让我们从一个简单的函数开始:

Example_Currying_1.ts
1 const addTwoNumbers = (a: number, b: number): number => a + b;
2 console.log(addTwoNumbers(3, 4));
Link to the preceding example: https://packt.link/InDVT

输出如下:

7

这里,我们使用了箭头语法来描述一个没有花括号或 return 关键字的函数体。该函数返回体中的单个表达式的结果。这个函数期望两个参数,可以重写为每个参数只有一个参数的柯里化函数:

Example_Currying_2.ts
1 const addTwoNumbers = (a: number): ((b: number) => number) => (b: 
2 number): number => a + b;
3 console.log(addTwoNumbers(3)(4));
Link to the preceding example: https://packt.link/975cf

输出如下:

7

实际上这是两个函数声明。第一个函数返回另一个函数,实际上执行计算。由于闭包,a 参数在第二个函数内部可用,以及它自己的参数 b。两组括号意味着第一个返回一个新的函数,然后立即被第二个函数调用。前面的代码可以重写为更长的形式:

Example_Currying_3.ts
1 const addTwoNumbers = (a: number): ((b: number) => number) => {
2     return (b: number): number => {
3         return a + b;
4     }
5 }
6 
7 const addFunction = addTwoNumbers(3);
8 
9 console.log(addFunction(4));
Link to the preceding example: https://packt.link/TgC17

输出如下:

7

当这样写的时候看起来有点傻,但它们确实做了完全相同的事情。

那么 Currying 有什么用途呢?

高阶函数是 Curried 函数的一种形式。高阶函数既接受一个函数作为参数,又返回一个新的函数。这些函数通常封装或修改一些现有的功能。我们如何将我们的 REST 客户端封装在一个高阶函数中,以确保所有响应,无论是成功还是错误,都以统一的方式处理?这将是下一个练习的重点。

练习 3.06:将代码重构为 Curried 函数

Currying 技术利用闭包,并且与上一个练习紧密相关,因此让我们回到它,并将上一个练习的解决方案作为本练习的起点。我们的 orderFactory 函数正在正常工作并正确跟踪 ID,但每种服装类型的初始化太慢了。当第一次收到红色中号的订单时,我们预计启动这个特定配方需要一些时间,但随后的红色中号订单也遭受了相同的延迟。我们的系统在处理热门商品的需求方面效率不足。我们需要一种方法来减少每次类似订单到来时的设置时间:

注意

本练习的代码文件可以在 packt.link/jSKic 找到。

  1. 查看来自 练习 3.05,使用闭包创建订单工厂 (order-solution.ts) 的代码:

    interface Order {
      id: number;
      color: string;
      size: string;
    }
    export const orderFactory = (): ((
      color: string,
      size: string,
      qty: number
    ) => Order[]) => {
      let id = 0;
      return (color: string, size: string, qty: number): Order[] => {
        const orders = [];
        for (let i = 0; i < qty; i++) {
          orders.push({ id: id++, color, size });
        }
        return orders;
      };
    };
    const createOrder = orderFactory();
    const orderOne = createOrder('red', 'M', 4);
    console.log(orderOne);
    const orderTwo = createOrder('blue', 'S', 7);
    console.log(orderTwo);
    

    我们如何使用 Currying 来提高效率?你需要将代码重构为 Curried 函数。

  2. orderFactory 重构为返回 Curried 函数,通过将返回的函数分解为三个单独的函数,每个函数都返回下一个函数:

    export const orderFactory = () => {
      let id = 0;
      return (color: string) => (size: string) => (qty: number) => {
        const orders = [];
        for (let i = 0; i < qty; i++) {
          orders.push({ id: id++, color, size });
        }
        return orders;
      };
    };
    

    在这种情况下,我们的重构就像在每个参数之间放置一个箭头一样简单。请注意,此代码省略了函数的返回类型。有两个原因。一个是类型可以从代码中合理推断,并且非常清晰。另一个是添加所有返回类型将显著使代码杂乱。

    如果我们将所有返回类型相加,代码将看起来像这样:

    export const orderFactory = (): ((
      color: string
    ) => (size: string) => (qty: number) => Order[]) => {
      let id = 0;
      return (color: string): ((size: string) => (qty: number) => Order[]) => (
        size: string
      ) => (qty: number): Order[] => {
        const orders = [];
        for (let i = 0; i < qty; i++) {
          orders.push({ id: id++, color, size });
        }
        return orders;
      };
    };
    

    TypeScript 给我们提供了在显式声明类型和允许类型推断之间进行选择的灵活性,当类型清晰时,可以提供正确的类型。

    现在 orderFactory 返回 Curried 函数,我们可以利用它。

  3. 而不是将每个参数传递给 createOrder,我们可以只使用第一个参数来调用 createOrder,以建立我们的红色服装线:

    const redLine = createOrder('red');
    
  4. 然后,进一步分解可用的单个项目:

    const redSmall = redLine('S');
    const redMedium = redLine('M');
    
  5. 当需要或适当的时候,在一行中创建一个项目:

    const blueSmall = createOrder('blue')('S')
    
  6. 尝试创建许多不同的订单组合并打印出结果:

    const orderOne = redMedium(4);
    console.log(orderOne);
    const orderTwo = blueSmall(7);
    console.log(orderTwo);
    const orderThree = redSmall(11);
    console.log(orderThree);
    
  7. 当你运行程序时,你会看到以下输出:

    [
      { id: 0, color: 'red', size: 'M' },
      { id: 1, color: 'red', size: 'M' },
      { id: 2, color: 'red', size: 'M' },
      { id: 3, color: 'red', size: 'M' }
    ]
    //...
    

    注意

    为了便于展示,这里只显示了实际输出的部分。

Currying 是一种强大的技术,用于缓存变量和部分函数结果。到目前为止,我们已经探讨了闭包、高阶函数和 Currying,这些都展示了 TypeScript 中函数的强大功能和多功能性。

函数式编程

函数式编程是一个深奥的话题,本身就是一个许多书籍的主题。本书只能触及这个话题的皮毛。函数式编程的一个基础概念是使用具有输入和输出且不修改其作用域之外变量的简单函数:

Example_Functional_1.ts
1 let importantNumber = 3;
2
3 const addFive = (): void => {
4     importantNumber += 5;
5 };
6 
7 addFive();
8 
9 console.log(importantNumber);
Link to the preceding example: https://packt.link/CTn1X

函数产生以下输出:

8

该程序的输出是正确的。我们确实将5加到了初始值3上,但addFive方法访问了更高作用域中的变量并对其进行了修改。在函数式编程范式下,更倾向于返回新值并允许外部作用域控制在其中声明的变量。我们可以修改addFive,使其不再操作其作用域之外的变量,而是仅对其参数操作并返回正确的值:

Example_Functional_2.ts
1 let importantNumber = 3;
2
3 const addFive = (num: number): number => {
4     return num + 5;
5 };
6 
7 importantNumber = addFive(importantNumber);
8 
9 console.log(importantNumber);
Link to the preceding example: https://packt.link/6fWcF.

函数产生以下输出:

8

函数现在更加便携。由于它不依赖于更高作用域中的任何东西,因此更容易测试或重用。函数式编程范式鼓励使用更小的函数。有时,程序员可能会编写执行太多不同功能的函数,这些函数难以阅读和维护。这通常是错误或对团队速度产生负面影响的原因。通过保持函数小而简单,我们可以以支持维护和可重用的方式链接逻辑。

函数式编程中的一个流行概念是不可变性。这就是一旦声明了变量,其值就不应该改变的概念。为了理解这为什么会是一个理想的特性,考虑一个需要打印客户 ID 的程序,客户名称之后:

Example_Functional_3.ts
1 const customer = {id: 1234, name: 'Amalgamated Materials'}
2 
3 const formatForPrint = ()=> {
4   customer.name = `${customer.name} id: ${customer.id}`;
5 };
6 
7 formatForPrint();
8 
9 console.log(customer.name);
Link to the preceding example: https://packt.link/TX81Z

该程序按预期运行。当打印客户名称时,它后面有 ID;然而,我们实际上已经更改了客户对象中的名称:

Amalgamated Materials id: 1234

如果formatForPrint被反复调用会发生什么?通过微小的重构,我们的代码更加安全和一致:

const customer = {id: 1234, name: 'Amalgamated Materials'}
const formatForPrint = ()=> {
  return `${customer.name} id: ${customer.id}`;
};
console.log(formatForPrint());

输出如下:

Amalgamated Materials id: 1234

如果将客户对象传递进去而不是让formatForPrint在更高作用域中访问它,会更好。

TypeScript 支持函数式编程和面向对象范式。许多应用程序都借鉴了两者。

将函数组织成对象和类

有时候,将函数组织成对象和类的成员函数是有意义的。这些概念将在第四章“类和对象”中更详细地介绍,但就目前而言,我们可以考察如何将一个函数声明添加到对象或类中。

让我们考虑一个简单的函数:

Example_OrganizingFuncs_1.ts
1 function addTwoNumbers(a: number, b: number) { return a + b; }

如果我们想要一个包含多个数学函数的对象,我们可以简单地添加以下函数到其中:

2 const mathUtils = {
3     addTwoNumbers
4 };
5 
6 console.log(mathUtils.addTwoNumbers(3, 4));
Link to the preceding example: https://packt.link/qX1QO

输出如下:

7

注意在mathUtils对象中使用的语法是简写,意味着赋值语句的左右两侧是相同的。这也可以写成这样:

Example_OrganizingFuncs_2.ts
5 const mathUtils = {
6     addTwoNumbers: addTwoNumbers
7 };

我们还可以选择使用函数表达式定义方法:

5 const mathUtils = {
6     addTwoNumbers: function(a: number, b: number) { return a + b; }
7 };
Link to the preceding example: https://packt.link/Ew4vi

无论是哪种情况,输出结果如下:

7

记住,函数表达式通常是在对象中使用最好的东西,因为它们将具有正确的this引用。在我们的mathUtils对象中,我们没有使用this关键字,所以可以使用箭头函数,但请注意,如果稍后其他开发者重构此对象,他们可能不会想到将箭头函数更改为函数表达式,你可能会得到有错误的代码。

向类中添加函数可以完全以相同的方式进行,实际上语法也非常相似。假设我们想使用类而不是普通对象,并希望内联定义addTwoNumbersMathUtils类可能看起来像这样:

class MathUtils {
    addTwoNumbers(a: number, b: number) { return a + b; }
};

现在我们正在使用类,为了调用函数,我们需要实例化一个对象:

const mathUtils = new MathUtils();
console.log(mathUtils.addTwoNumbers(3, 4));

输出结果如下:

7

有关类的更多信息,请参阅第四章类和对象

练习 3.07:将 JavaScript 重构为 TypeScript

将较旧的 JavaScript 代码更新为 TypeScript 并不困难。如果原始代码编写得很好,我们可以保留大部分结构,并通过接口和类型来增强它。在这个练习中,我们将使用一个示例遗留 JavaScript 代码,该代码根据给定的尺寸打印各种形状的面积:

注意

这个练习的代码文件可以在packt.link/gRVxx找到。

  1. 从以下遗留代码开始,并决定我们希望通过将其转换为 TypeScript 来改进什么:

    var PI = 3.14;
    function getCircleArea(radius) {
      return radius * radius * PI;
    }
    //...
    

    注意

    这里只展示了实际代码的一部分。完整的代码可以在packt.link/pahq2找到。

    其中一些更改很简单。我们将用const替换var。确定面积的函数相当不错,但getArea会修改形状对象。最好是只返回面积。我们的所有形状都定义得相当好,但通过接口可以进一步改进。

  2. 让我们创建一些接口。在 VS Code 中创建一个新文件,并将其保存为refactor-shapes-solution.ts

  3. 首先,创建一个包含枚举类型和面积属性的Shape接口。我们可以从这个接口扩展我们的CircleSquareRectangleRightTriangle接口:

    const PI = 3.14;
    interface Shape {
      area?: number;
      type: 'circle' | 'rectangle' | 'rightTriangle' | 'square';
    }
    interface Circle extends Shape {
      radius: number;
      type: 'circle';
    }
    interface Rectangle extends Shape {
      length: number;
      type: 'rectangle';
      width: number;
    }
    interface RightTriangle extends Shape {
      base: number;
      height: number;
      type: 'rightTriangle';
    }
    interface Square extends Shape {
      type: 'square';
      width: number;
    }
    
  4. 现在,让我们改进并简化getArea。而不是访问每个形状上的属性,getArea可以简单地传递形状到正确的函数以确定面积,然后返回计算值:

    const getArea = (shape: Shape) => {
      switch (shape.type) {
        case 'circle':
          return getCircleArea(shape as Circle);
        case 'rectangle':
          return getRectangleArea(shape as Rectangle);
        case 'rightTriangle':
          return getRightTriangleArea(shape as RightTriangle);
        case 'square':
          return getSquareArea(shape as Square);
      }
    };
    

    这个更改要求我们对所有计算面积的函数进行一些小的修改。

  5. 现在不是每个单独的属性都传递进来,而是传递形状,然后在函数内部获取属性:

    const getCircleArea = (circle: Circle): number => {
      const { radius } = circle;
      return radius * radius * PI;
    };
    const getRectangleArea = (rectangle: Rectangle): number => {
      const { length, width } = rectangle;
      return length * width;
    };
    const getSquareArea = (square: Square): number => {
      const { width } = square;
      return getRectangleArea({ length: width, type: 'rectangle', width });
    };
    const getRightTriangleArea = (rightTriangle: RightTriangle): number => {
      const { base, height } = rightTriangle;
      return (base * height) / 2;
    };
    

    这种模式在现代 Web 应用开发中非常常见,在 TypeScript 开发中也非常有效。

  6. 在我们的对象声明中添加一些类型提示:

    const circle: Circle = { radius: 4, type: 'circle' };
    console.log({ ...circle, area: getArea(circle) });
    const rectangle: Rectangle = { type: 'rectangle', length: 7, width: 4 };
    console.log({ ...rectangle, area: getArea(rectangle) });
    const square: Square = { type: 'square', width: 5 };
    console.log({ ...square, area: getArea(square) });
    const rightTriangle: RightTriangle = {
      type: 'rightTriangle',
      base: 9,
      height: 4,
    };
    console.log({ ...rightTriangle, area: getArea(rightTriangle) });
    
  7. 运行程序会得到正确的输出:

    { radius: 4, type: 'circle', area: 50.24 }
    { type: 'rectangle', length: 7, width: 4, area: 28 }
    { type: 'square', width: 5, area: 25 }
    { type: 'rightTriangle', base: 9, height: 4, area: 18 }
    

这个练习为我们提供了将遗留 JavaScript 代码重构为 TypeScript 的实际经验。这些技能可以帮助我们识别原始 JavaScript 代码中的代码质量问题,并在将代码迁移到 TypeScript 时进行改进。

导入、导出和需求

非常小的程序,如编程书籍中经常找到的那种,可以与单个文件中的所有代码一起工作得很好。大多数时候,应用程序将由多个文件组成,通常被称为模块。一些模块可能是从 Node 包管理器(npm)安装的依赖项,而另一些可能是你或你的团队编写的模块。当你查看其他项目时,你可能会看到用于链接不同模块的关键字importexportmodulerequireimportrequire都服务于相同的目的。它们允许你在当前正在工作的模块(文件)中使用另一个模块。exportmodule则相反。它们允许你使你的模块的部分或全部可供其他模块使用。

我们将在下面介绍不同的语法选项。之所以有这么多做事情的方法,通常是因为语言和运行时的发展方式。Node.js 是目前最流行的服务器端 JavaScript 运行时,大多数编译的服务器端 TypeScript 代码都将在这里运行。Node.js 于 2009 年发布,当时 JavaScript 还没有标准模块系统。当时许多 JavaScript 网络应用程序会简单地将函数和对象附加到全局 window 对象上。对于网络应用程序来说,这可以工作得很好,因为 window 对象在页面加载时会刷新,存在于网络浏览器中,所以它只被单个用户使用。

尽管 Node.js 中存在全局对象,但这并不是将模块链接在一起的实际方法。这样做可能会风险一个模块覆盖另一个模块,内存泄漏,暴露客户数据,以及各种其他灾难。模块系统的好处在于,你可以只共享你打算共享的模块部分。

由于需要更健壮的解决方案,Node.js 采用了 CommonJS 规范以及modulerequire关键字。module用于共享你的模块的全部或部分,而require用于消费另一个模块。这些关键字在 Node.js 中是标准的,直到 ECMAScript 6 引入了importexport语法。后者在 TypeScript 中已经支持多年,并且是首选的,尽管require语法仍然是有效的,并且可以继续使用。

本书将使用importexport语法,因为这是标准的。下面的示例将使用这种语法,但也将包含require语法的注释,以便读者可以进行比较。

任何包含importexport关键字的文件都被视为模块。模块可以导出它们声明的任何变量或函数,无论是作为声明的一部分还是通过显式地这样做:

// utils.ts
export const PI = 3.14;
export const addTwoNumbers = (a: number, b: number): number => a + b;

这与显式导出等效。以下是 utils.ts 的完整代码:

Example_Import_Exports/utils.ts
1 // utils.ts
2 const PI = 3.14;
3 
4 const addTwoNumbers = (a: number, b: number): number => a + b;
5
6 export { PI, addTwoNumbers };
7 // module syntax:
8 // module.exports = { PI, addTwoNumbers };
Link to the preceding example: https://packt.link/3FEbm

现在,我们可以将我们的导出导入到另一个模块中(另一个 .ts 文件 - app.ts):

Example_Import_Exports/app.ts
1 // app.ts
2 import { PI, addTwoNumbers } from './utils';
3 // require syntax:
4 // const { PI, addTwoNumbers } = require('./utils');
5 console.log(PI);
6 console.log(addTwoNumbers(3, 4));
Link to the preceding example: https://packt.link/ozz9N

一旦运行 app.ts,你将获得以下输出:

3.14
7

注意

前例的代码文件可以在以下位置找到:packt.link/zsCDe

我们应用程序的一部分模块通过从项目根目录的相对路径导入。从我们安装的依赖项导入的模块通过名称导入。注意,文件扩展名不是必需路径的一部分,只是文件名。

模块也可以有默认导出,使用 default 关键字。默认导出不需要括号。考虑以下示例:

Example_Import_Export_2/utils.ts
1 // utils.ts
2 const PI = 3.14;
3 const addTwo = (a: number, b: number): number => {
4   return a + b;
5 };
6 const fetcher = () => {
7   console.log('it is fetched!');
8 };
9 export default { addTwo, fetcher, PI };
Link to the preceding example: https://packt.link/h3R4r

app.ts 的代码如下:

1 // app.ts
2 import utils from './utils';
3 console.log(utils.addTwo(3, 4));
Link to the preceding example: https://packt.link/oamFn

一旦运行 app.ts 文件,你将得到以下输出:

7

练习 3.08:导入和导出

回顾上一个练习,我们有一个包含许多实用函数的单个文件,然后我们有过程式代码创建一些对象,调用函数并输出日志。让我们重构练习 3.07,将 JavaScript 重构为 TypeScript,使用 importexport 关键字,并将这些函数移动到单独的模块中:

注意

本练习的代码文件可以在 packt.link/2K4ds 找到。本练习的第一步要求你将一些代码行复制粘贴到练习文件中。因此,我们建议你在开始此练习之前,要么从本存储库下载代码文件,要么将其迁移到您的桌面。

  1. shapes.ts 的前 61 行剪切粘贴到 shapes-lib.ts 中。您的 IDE 应该开始警告您它无法再找到相关的函数。

  2. 查看代码在 shapes-lib.ts 中的情况。哪些函数和接口需要导出?正方形、圆形以及其他直接在 shapes.ts 中使用,但形状接口没有使用,所以只需要这四个导出。同样,PI 常量仅在 shapes-lib.ts 中使用,因此不需要导出该常量:

    const PI = 3.14;
    interface Shape {
      area?: number;
      type: 'circle' | 'rectangle' | 'rightTriangle' | 'square';
    }
    export interface Circle extends Shape {
      radius: number;
      type: 'circle';
    }
    export interface Rectangle extends Shape {
      length: number;
      type: 'rectangle';
      width: number;
    }
    export interface RightTriangle extends Shape {
      base: number;
      height: number;
      type: 'rightTriangle';
    }
    export interface Square extends Shape {
      type: 'square';
      width: number;
    }
    
  3. 只需要导出的函数是 getArea,因为它是 shapes.ts 中唯一引用的:

    export const getArea = (shape: Shape) => {
      switch (shape.type) {
        case 'circle':
          return getCircleArea(shape as Circle);
        case 'rectangle':
          return getRectangleArea(shape as Rectangle);
        case 'rightTriangle':
          return getRightTriangleArea(shape as RightTriangle);
        case 'square':
          return getSquareArea(shape as Square);
      }
    };
    
  4. 现在,让我们将导出的接口和函数导入到 shapes.ts 中。您的 IDE 可能会协助您完成此任务。例如,在 VS Code 中,如果将鼠标悬停在可以导入的模块上,它应该询问您是否要添加导入:

    import {
      Circle,
      getArea,
      Rectangle,
      RightTriangle,
      Square,
    } from './shapes-lib-solution';
    
  5. 在设置好所有导入和导出后,再次运行程序。你应该得到正确的结果:

    { radius: 4, type: 'circle', area: 50.24 }
    { type: 'rectangle', length: 7, width: 4, area: 28 }
    { type: 'square', width: 5, area: 25 }
    { type: 'rightTriangle', base: 9, height: 4, area: 18 }
    

学习一门新编程语言更具挑战性的事情之一是如何构建模块。一个好的经验法则是,如果模块变得太大,总是准备好将它们拆分成更小的块。这个练习帮助我们理解如何将应用程序逻辑与实用程序或可重用函数分开,这种做法将导致代码整洁、易于维护。

活动 3.01:使用函数构建航班预订系统

作为一家在线预订初创公司的开发者,你需要实现一个管理航空公司预订的系统。这个系统的架构已经确定。将有一个用于管理航班和座位可用性的系统,以及一个用于管理预订的系统。用户将直接与预订系统交互,而该系统将搜索并更新航班信息。

为了使这项活动保持可管理的规模,我们将抽象出许多事物,例如客户信息、支付、航班日期,甚至出发城市。在理解我们需要解决的问题时,创建一个描述我们需要实现的流程的图表非常有帮助。以下图表显示了我们的用户预期的流程:

备注

这个活动的代码文件可以在以下位置找到:packt.link/o5n0t

![图 3.1:需要在航班预订系统中实现的流程]

备注

图 3.1:需要在航班预订系统中实现的流程

程序的流程如下:

  1. 获取可供选择的航班列表。

  2. 从这些航班中选择一个开始预订。

  3. 为航班支付费用。

  4. 在航班上预留座位后完成预订。

如图表所示,用户将与两个不同的系统交互,即预订系统和航班系统。在大多数情况下,用户与预订系统交互,但他们直接访问航班系统来搜索航班。

在这个活动中,这些系统可以用一个bookings.ts文件和一个flights.ts文件来表示,这两个文件是两个 TypeScript 模块。为了完成这个活动,请使用 TypeScript 实现这两个模块。以下是一些步骤来帮助你:

  1. 由于用户和预订系统都依赖于航班系统,因此我们应该从航班系统开始,即flights.ts。由于活动被简化了,当用户想要访问航班时,我们可以简单地返回一个目的地列表。为了允许访问bookings.ts模块,我们将在一个函数上使用export关键字。

  2. 尽管用户已经获取了航班信息,但在开始预订之前,我们需要检查可用性。这是因为我们的系统将有多个用户,可用性可能会每分钟变化。公开一个用于检查可用性的函数,另一个用于在交易完成前保留座位。

  3. 支付流程实际上暗示了支付系统的第三个系统,但我们将不会在这个活动中包含该系统,所以当用户到达支付步骤时,只需将预订标记为已支付即可。航班系统不需要知道支付状态,因为这是由预订系统管理的。

  4. 当我们完成预订时,保留的座位将转换为预留座位。我们的预订已经完成,座位不再在航班上可用。

  5. 这种活动的典型输出将看起来像这样:

    Booked to Lagos {
      bookingNumber: 1,
      flight: {
        destination: 'Lagos',
        flightNumber: 1,
        seatsHeld: 0,
        seatsRemaining: 29,
        time: '5:30'
      },
      paid: true,
      seatsHeld: 0,
      seatsReserved: 1
    //...
    

    备注

    为了便于展示,这里只显示了实际输出的部分。此活动的解决方案可以通过此链接找到。

在这里还有许多其他场景可以探索。尝试保留所有剩余的座位,未能为该航班启动新的预订,然后完成原始预订。这应该会与我们在这里实现的逻辑兼容!这个练习使用了几个函数来创建一个连贯的程序。它使用了闭包、柯里化、函数式编程概念以及importexport关键字在模块之间共享函数。

使用 ts-jest 进行单元测试

大型系统需要持续测试以确保它们是正确的。这就是单元测试发挥作用的地方。世界上一些最大的软件项目有数亿行代码和数千个特性和视图。手动测试每个特性是不可能的。这就是单元测试发挥作用的地方。单元测试测试代码的最小单元,通常是单个语句或函数,并给我们提供快速反馈,如果我们做了什么来改变应用程序的行为。短的反馈周期是开发者的好朋友,而单元测试是实现它们的强大工具之一。

有许多测试框架可以帮助我们进行单元测试我们的代码。Jest 是来自 Facebook 的一个流行的测试框架。你也可能遇到其他框架,如 Jasmine、Mocha 或 Ava。Jest 是一个“包含电池”的框架,对于使用那些其他框架的用户来说会感觉熟悉,因为它试图整合它们中的所有最佳特性。

Jest、Mocha、Ava 以及其他都是 JavaScript 库,而不是 TypeScript 库,因此在使用它们之前需要做一些特殊准备。ts-jest是一个库,它帮助我们用 TypeScript 编写 TypeScript 测试,并使用 Jest 测试运行器和 Jest 的所有优点。

要开始,我们将安装jestts-jest和用于jesttypings@types/jest):

npm install -D jest ts-jest @types/jest

一旦安装了库,我们就可以使用npx来使用默认配置初始化ts-jest,这将允许我们编写我们的第一个测试:

npx ts-jest config:init

运行此命令将创建一个名为jest.config.js的配置文件。随着你越来越习惯于使用 Jest 编写测试,你可能希望修改此文件,但到目前为止,默认设置将完全适用。

一些开发者将单元测试放在测试目录中,而另一些则将测试直接放在源代码旁边。我们的默认 Jest 配置将找到这两种类型的测试。单元测试的惯例是测试模块的名称,后面跟着一个点,然后是单词spectest,然后是文件扩展名,在我们的情况下将是ts。如果我们创建符合这种命名约定的文件,并且这些文件位于我们的项目根目录下,Jest 将能够找到并执行这些测试。

让我们添加一个简单的测试。创建一个名为 example.spec.ts 的文件。然后向该文件添加以下代码。这段代码只是测试的占位符,实际上并没有做任何事情,只是验证 Jest 是否正确工作:

describe("test suite for `sentence`", () => {
  test("dummy test", () => {
    expect(true).toBeTruthy();
  });
});

我们可以通过在控制台输入 npx jest 来运行 Jest,或者我们可以添加一个 npm 脚本。尝试在控制台输入 npm test。如果你没有更改默认测试,你应该看到以下类似的内容:

npm test
> ex1@1.0.0 test /Users/mattmorgan/typescript/function-chapter/exercises
> echo "Error: no test specified" && exit 1
Error: no test specified
npm ERR! Test failed.  See above for more details.

现在让我们更新 package.json 文件,使其运行 Jest 而不是直接失败。找到 package.json 文件,你会在其中看到以下配置:

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },

我们可以用简单的 jest 替换整个测试:

  "scripts": {
    "test": "jest"
  },

现在,再次尝试 npm test

npm test
> ex1@1.0.0 test /Users/mattmorgan/typescript/function-chapter/exercises
> jest
 PASS  ./example.spec.ts
  test suite for `sentence`
    ✓ dummy test (1ms)
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.449s
Ran all test suites

当然,这个测试没有做任何有用的事情。现在,让我们导入我们想要测试的函数,并编写一些真正有用的测试。首先,让我们清理一下 arrow-cat-solution.ts 文件(来自练习 3.03,编写箭头函数)。我们可以删除所有的控制台语句,因为我们将通过编写测试来验证我们的代码,而不是仅仅记录控制台。然后,让我们为每个函数添加 export 关键字,以便我们的测试可以导入它们。arrow-cat-solution.ts 现在看起来是这样的:

export const arrayToAnd = (words: string[]) => {
  return words.reduce((prev, curr, index) => {
    if (words.length === 1) {
      return ` ${curr}`;
    }
    if (words.length - 1 === index) {
      return `${prev} and ${curr}`;
    }
    return `${prev} ${curr},`;
  }, "");
};
export const capitalize = (sentence: string) => {
  return `${sentence.charAt(0).toUpperCase()}${sentence
    .slice(1)
    .toLowerCase()}`;
};
export const sentence = (
  subject: string,
  verb: string,
  ...objects: string[]
): string => {
  return capitalize(`${subject} ${verb}${arrayToAnd(objects)}.`);
};

让我们尝试编写一个针对 capitalize 函数的测试。我们只需要调用该函数,并将结果与预期结果进行比较。首先,在一个新文件中导入该函数 (arrow-cat-solution.spec.ts):

import { capitalize } from './arrow-cat-solution'; 

然后,编写一个期望。我们期望我们的函数将全大写的 "HELLO" 转换为 "Hello"。现在让我们编写这个测试并执行它:

describe("test suite for `sentence`", () => {
  test("capitalize", () => {
    expect(capitalize("HELLO")).toBe("Hello");
  });
});

它工作了吗?

npm test
> ex1@1.0.0 test /Users/mattmorgan/typescript/function-chapter/exercises
> jest
 PASS  ./example.spec.ts
  test suite for `sentence`
    ✓ capitalize (1ms)
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.502s, estimated 2s
Ran all test suites.

describe 关键字用于分组测试,它的唯一目的是影响测试报告的输出。test 关键字应该包装实际的测试。除了 test,你还可以写 it。使用 it 的测试通常写成带有 should 的断言:

  it("should capitalize the string", () => {
    expect(capitalize("HELLO")).toBe("Hello");
  });

现在,为其他函数编写测试。

活动三.02:编写单元测试

在上一个活动中,我们为航空公司构建了一个预订系统,并将 TypeScript 函数应用于确保航班预订涉及的场景。我们从单个 index.ts 文件中执行这些场景,代表用户交互。这种方法在我们学习时足够好,但它有点杂乱,实际上并没有断言任何场景是正确的。换句话说,它几乎是一个单元测试,但并不如单元测试那样好。

我们已经学习了如何安装 Jest,所以让我们使用它来对 活动 3.01,使用函数构建航班预订系统 进行单元测试。对于每个我们编写的函数,我们将编写一个测试来调用该函数并测试输出:

注意

本活动的代码文件可以在 packt.link/XMOZO 找到。

  1. 为本活动提供的代码占位符包括 bookings.test.tsflights.test.ts,其中包含一些未实现的测试。实现这些测试以完成本活动。

    你可以通过运行 npm test 来执行测试。你也可以只运行解决方案,使用 npm run test:solution

  2. 要测试一个函数,你需要将其 import 到你的测试文件中。

  3. 使用示例输入调用函数,然后使用 Jest 的 expect 断言来测试输出,例如,expect(value).toBe(5);

  4. 可以使用 try/catch 块来测试错误场景,捕获函数抛出的错误,然后测试错误条件。在单元测试中使用 catch 时,使用 expect.assertions 来指示你想要测试多少断言是一个最佳实践。否则,你的测试可能在没有调用 catch 块的情况下完成。

  5. 尝试在覆盖率报告中达到 100% 的行覆盖率(已经使用 --coverage 配置)。

    注意

    此活动的解决方案可以通过此链接找到。

在这个活动中,我们使用了一些好的单元测试来应用最佳实践,我们编写的一个程序。现在,我们知道现有代码已经过测试,添加额外的功能和场景会容易得多。我们不再需要编写索引文件来调用各种函数,现在我们有逻辑上分组、排序和测试的东西。我们有一个机制来跟踪行覆盖率,并了解我们的代码中有多少是经过测试的。

错误处理

当我们编写函数时,我们需要牢记并非所有事情总是完美无缺。如果函数接收到意外的输入,我们会怎么做?如果我们需要调用的某个函数工作不完美,我们的程序会如何反应?始终验证函数输入是一个好主意。是的,我们正在使用 TypeScript,我们可以合理地确信,如果我们期望一个字符串,我们不会得到一个对象,但有时,外部输入不符合我们的类型。有时,我们自己的逻辑可能是错误的。考虑这个函数:

const divide = (numerator: number, denominator: number) => {
    return numerator / denominator;
}

看起来不错,但如果我们传入数字 0 作为分母会怎样?我们不能除以零,所以结果将是常数,NaNNaN 在任何数学方程中使用时,总会返回 NaN。这可能会在我们的系统中引入一个严重的错误,这需要避免。

为了解决这个问题,我们需要弄清楚如果得到无效输入会发生什么。记录它?抛出错误?只是返回零?退出程序?一旦决定,我们就可以在我们的函数中添加一些验证:

const divide = (numerator: number, denominator: number) => {
    if(denominator === 0) {
        throw 'Cannot divide by zero!'
    }
    return numerator / denominator;
}

至少我们现在不会默默失败,因为我们已经在屏幕上显示了一个警告,Cannot divide by zero!。抛出异常总比函数失败而无人察觉要好。

摘要

到现在为止,你知道如何创建任何 TypeScript 程序最重要的构建块——函数。我们已经探讨了函数表达式和箭头函数之间的区别,以及何时使用哪一个。我们研究了立即执行的函数表达式、闭包、柯里化和其他强大的 TypeScript 技术。

我们讨论了函数式编程范式,并探讨了如何在对象和类中包含函数。我们还研究了如何将遗留的 JavaScript 代码转换为现代 TypeScript,以及通过这样做如何改进我们的软件。

我们概述了 TypeScript 模块系统和至关重要的importexport关键字。我们编写了大量的 TypeScript 代码,并学习了如何使用ts-jest对其进行测试。

最后,我们通过讨论错误处理来完善了本章内容。在异步编程方面,我们将更深入地探讨更高级的错误处理技术,包括在第十二章“TypeScript 中的 Promise 指南”和第十三章“TypeScript 中的 Async Await”。

本章涵盖了相当多的主题,大多数读者可能不会立即记住所有内容。这没关系!你已经在本章中编写了许多函数,在接下来的章节中你还将编写更多。编写好的函数是一种随着实践而提高的技能,随着你在 TypeScript 掌握方面的进步,你将能够回过头来参考本章以检查你的学习情况。

在下一章中,我们将通过研究class关键字以及如何构建类型安全的对象,进一步探索面向对象编程范式。

第五章:4. 类和对象

概述

在本章中,你将学习如何定义类并实例化它们以创建对象。你还将学习如何使用接口定义可以传递给类的数据类型。到本章结束时,你将能够构建一个包含数据属性、构造函数、方法和接口的基本类。你将能够创建接受多个对象作为参数的类,以构建动态行为,并自信地使用 TypeScript 生成 HTML 代码。

简介

面向对象编程OOP)自 20 世纪 60 年代以来一直存在,许多流行的编程语言都采用了它,包括 JavaRubyPython。在 OOP 出现之前,开发者通常遵循过程式编程风格。采用过程式编程的语言,其执行过程是从代码文件顶部到底部的。最终,开发者开始希望将整个过程和数据封装起来,以便它们可以在程序的不同部分和不同时间被调用。这就是面向对象编程诞生的原因。

从一个高层次的角度来看,面向对象编程允许程序将数据和行为一起封装,以创建完整的系统。因此,与过程式程序从上到下运行代码不同,面向对象程序允许你创建代码蓝图并建立程序运行的规则,然后你可以从应用程序的其他部分调用这些蓝图。

如果现在还不明白,请不要担心——我们将在本章中详细讲解如何在 TypeScript 中处理面向对象编程。我们将从学习面向对象编程的基本构建块——对象开始。

在前面的章节中,我们涵盖了广泛的主题,包括声明变量的各种方法、如何处理高级类型、别名、联合类型和断言,以及如何检查类型。你已经为你的 TypeScript 技能集增添了相当多的知识。

在本章中,我们将使用 TypeScript 构建一个计分板应用程序,并在过程中学习类和对象。如果你对面向对象编程或它如何应用于 TypeScript 没有任何先前的知识或熟悉度,请不要担心。如果你对类和对象有一些经验,那么你可以跳过本章后面的一些更高级的内容——尽管你仍然可能从这些关键概念的复习中受益。

什么是类和对象?

在我们构建类之前,让我们退一步,了解类是如何工作的。你可以把类想象成一个蓝图。它为我们想要构建的东西建立了一个结构,并在其中包含了一些行为。现在,类本身并不做任何事情。它仅仅是一个蓝图。为了与之交互,我们必须执行一个称为实例化的过程。

实例化是将一个类转换成我们可以使用的实际类的对象的过程。让我们通过一个例子来进一步了解实例化。想象一下你正在建造一栋房子,就像一个好的建筑商一样,你有一张你想要建造的蓝图。这个蓝图就像我们的类。房子的蓝图只是一套规则、属性和行为。房子的蓝图定义了诸如面积、房间数量、浴室数量以及管道走向等元素。技术上,蓝图只是一套打印出来或存储在计算机上的规则;它不是房子本身,或者在这个例子中,不是程序本身。为了创建房子,有人需要拿走蓝图并实际建造房子,编程也是如此。

类本身除了为程序建立规则外,什么都不做。为了使用类,我们需要创建该类的实例或对象。所以,回到建筑比喻,你可以将实例化想象为拿走房子的蓝图并建造它。

让我们看一下下面的代码片段,以了解如何在 TypeScript 中出现类和对象:

class Person {
    name:string;
    constructor(name) {
        this.name = name;
    }
    read() {
        console.log(this.name+ "likes to read.");
    }
}
const obj = new Person("Mike");
obj.read();

让我们逐一分析前面代码中的每个元素,以便你可以在脑海中形成与 TypeScript 中类和对象相关的关键术语的模型,然后我们将进行一个深入练习,你将看到如何处理每个元素:

  • class Person {} 创建或定义一个类。

  • name: string; 创建类属性。

  • constructor() 允许你在创建对象时执行设置工作。

  • read() 是一个允许你在类中实现自定义行为的方法。

  • const obj = new Person("Mike"); 从一个类创建一个对象并将其存储在变量中,以便可以使用它。

  • obj.read(); 在一个对象上调用一个方法。在这个例子中,它会在控制台输出值 Mike 喜欢阅读

在下一节中,我们将解决一个练习,其中我们将构建我们的第一个 TypeScript 类。

练习 4.01:构建你的第一个类

在这个练习中,我们将构建一个名为 Team 的类,并在其中添加一个名为 generateLineup 的行为或方法。我们还将创建这个类的对象并访问其方法。执行以下步骤以实现此练习:

注意

本练习的代码文件可以在此处找到:packt.link/UJXSY

  1. 打开 Visual Studio Code 编辑器。

  2. 创建一个新的目录,然后创建一个名为 scoreboard.ts 的新文件。你将在它上面运行 TypeScript 编译器以生成一个 JavaScript 文件。在 TypeScript 编译器中添加以下命令以生成 JavaScript 文件:

    tsc scoreboard.ts
    

    执行此命令后,将生成一个 scoreboard.js 文件,如下面的截图所示:

    ![图 4.1:TypeScript 评分板和生成的 JavaScript 文件 图 B14508_04_01.jpg

    图 4.1:TypeScript 记分板和生成的 JavaScript 文件

  3. 现在,创建一个名为 Team 的类,然后利用实例化过程创建该类的对象。在 scoreboard.ts 文件中编写以下代码以创建一个类:

    class Team {
    }
    

    目前,这只是一个空的类,没有任何功能。让我们通过向类中添加一些行为来修复它。我们可以通过定义函数来添加行为。对于我们的 Team 类,我们将生成一个阵容,因此我们定义了一个名为 generateLineup 的函数,它不接受任何参数。

    注意

    从语法角度来看,请注意我们正在使用 class 关键字。class 是 TypeScript 和 JavaScript 中的一个保留字,它告诉编译器我们即将定义一个类。在这种情况下,我们正在调用 Team 类。

  4. 编写以下代码在类内部定义一个 generateLineup() 函数:

    class Team {
        generateLineup() {
            return "Lineup will go here…";
        }
    }
    

    如您所见,类中的函数,也称为方法,在语法上与 JavaScript 中的标准函数相似。现在,我们的 generateLineup 方法仅返回一个字符串。在本章的后面部分,我们将看到如何在此方法中实现动态行为。

    一旦我们创建了一个类并定义了其行为,我们就可以创建一个对象。为了创建 Team 类的对象,我们在 Team 类名称前调用 new 关键字,并将其分配给一个变量。在这种情况下,我们将实例化的对象存储在一个名为 astros 的变量中。

  5. 添加以下代码以创建 Team 类的对象:

    const astros = new Team();
    

    注意,在前面的代码中,我们在 Team 类名称后面也添加了括号,模仿我们在 TypeScript 中调用函数的方式。

    在所有这些准备就绪后,我们现在可以使用 astros 变量来调用其上的 generateLineup 方法。

  6. 添加以下代码以调用 generateLineup 方法:

    console.log(astros.generateLineup());
    
  7. 在终端中,输入以下命令以生成 JavaScript 代码并运行:

    tsc scoreboard.ts
    node scoreboard.js
    

    一旦运行前面的命令,终端将显示以下输出:阵容将在这里…

因此,我们已经创建了我们第一个类,然后从那里,我们使用了这个类,这个蓝图,然后通过实例化来创建一个对象。从那个点开始,我们能够调用类内部的函数。现在我们已经创建了一个类并使用其对象来访问其方法,在下一节中,我们将探讨 构造函数 的概念。

使用构造函数扩展类行为

在上一节中,我们为 TypeScript 中的类建立了语法。在我们开始之前程序的下一阶段之前,让我们退一步,讨论我们将要使用的一个元素,称为构造函数。如果您以前从未使用过构造函数,这个概念可能会令人困惑。

回到我们的蓝图/房屋类比,如果一个类就像房屋的蓝图,而对象是创建的房屋,那么构造函数就是去建材店购买建造房屋所需材料的过程。构造函数在创建对象时自动运行。通常,构造函数用于执行以下操作:

  • 设置属性数据,这是我们即将探讨的内容。

  • 运行任何设置过程。这包括调用外部 API 获取数据和与数据库通信。

    注意

    更多关于构造函数的内容将在第八章TypeScript 中的依赖注入中介绍。

this关键字

this关键字的含义是指当前正在执行的类的实例。它能够访问创建的对象的数据和行为。假设我们在一个类中有以下代码:

constructor(name){
    this.name = name;
}

在前面的代码中,如果this.name是指向类的实例和name属性,那么构造函数中的name参数代表什么?为了在我们的类中使用数据,我们需要一个机制将数据传递到对象中,这就是构造函数参数的作用。那么,为什么我们需要将this.name赋值为name?这似乎是多余的;然而,这对于理解 TypeScript 类中的变量作用域工作方式是有帮助的。我们需要将传递给对象的值赋给this.attributeName,这样类中的其他方法就可以访问这些值。如果我们只是将值传递给构造函数而没有执行this.name的赋值,类中的其他方法就无法访问name值。现在,让我们在下一个练习中扩展程序的行为,我们将探索类的属性。

练习 4.02:定义和访问类的属性

在这个练习中,我们将向我们在上一个练习中创建的Team类添加属性。我们将使用构造函数来定义和访问对象的属性。按照以下步骤实现这个练习。

注意

在这个练习中,我们将继续本章前面用Team类所做的操作,所以请确保将其作为起点。这个练习的代码文件可以在以下位置找到:packt.link/Diuyl

我们首先在Team类的顶部列出属性名称,然后通过传递name参数使用constructor函数设置值。从那里,我们将this.name的值设置为传递给构造函数的值:

  1. 编写以下代码以创建一个constructor函数:

    class Team {
        name: string;
        constructor(name) {
          this.name = name;
        }
        generateLineup() {
          return "Lineup will go here …";
        }
    }
    

    当我们创建astros对象时,this关键字代表被创建的对象。

  2. 创建另一个对象来查看this关键字在多个对象中的工作方式。将以下代码添加到scoreboard.ts文件中,以创建Team类的对象:

    const astros = new Team();
    console.log(astros.generatLineup());
    const bluJays = new Team();
    console.log(blueJays.generateLineup());
    

    在前面的代码中,我们创建了一个名为 blueJays 的另一个 Team 类对象。从那里,我们在该对象上调用 generateLineup 方法。当我们说 this.name 时,我们指的是类的实例。这意味着当我们对第一个对象说 this.name 时,我们指的是 astros 对象。然后,对于我们创建的新对象,this.name 指的是 blueJays 对象。

    我们的 generateLineup 方法可以访问 name 的值,因为我们已经在构造函数中对其进行了赋值。

  3. 通过编写以下代码将值传递给对象的构造函数:

    const astros = new Team("Astros");
    console.log(astros.generateLineup());
    const blueJays = new Team("Blue Jays");
    console.log(blueJays.generateLineup());
    

    注意

    如果你被问到 TypeScript 中参数和参数的区别,参数是你放在类中函数声明内的内容。参数是你传递给对象或函数的内容。

    为了将参数传递给一个类,你可以像上面那样传递它们,此外,当我们执行 this.name = name 这样的赋值操作时,这意味着当创建一个对象时,它也可以调用数据值。

  4. 编写以下代码以调用相关数据值:

    const astros = new Team("Astros");
    //console.log(astros.generateLineup());
    console.log(astros.name);
    const blueJays = new Team("Blue Jays");
    //console.log(blueJays.generateLineup());
    console.log(blueJays.name);
    
  5. 在终端中,键入以下命令以生成 JavaScript 代码并运行:

    tsc scoreboard.ts
    node scoreboard.js
    

    一旦运行前面的命令,终端将显示以下输出:

    Astros
    Blue Jays
    

如你在上一步的代码中所见,当我们调用 astros.name 时,它输出传递给实例化对象的名字值。当我们把名字值 Blue Jays 传递给新对象时,新值将在终端中打印出来。

我们现在能够理解类和对象的基本工作原理。我们还学习了如何通过构造函数将数据传递给对象。现在是时候扩展这些知识,看看我们如何将类型直接集成到我们的类中。

尽管当前实现有效,但我们没有充分利用 TypeScript 提供的关键优势。事实上,当前实现非常接近你用纯 JavaScript 构建类的方式。通过在类中使用类型,我们可以精确地定义如何处理代码,这将有助于使我们的代码更易于管理和扩展。

一个现实世界的例子是使用 TypeScript 的 React 应用程序与纯 JavaScript 相比。开发者遇到的最常见的错误之一是将错误类型的数据传递给类或方法,导致用户出现错误。想象一下,不小心将一个字符串传递给需要一个数组的类。当用户尝试访问与该类关联的页面时,他们不会看到任何数据,因为传递给方法的数据是错误的。

当你在 React 类中使用 TypeScript 和类型时,文本编辑器不会允许程序编译,因为它会向你解释每个类和过程需要的数据类型。在下一节中,我们将解决一个练习,我们将把不同的类型集成到我们的类中。

练习 4.03:将类型集成到类中

在这个练习中,我们将在我们的Team类中添加另一个名为players的属性。这个参数接受字符串数组。按照以下步骤来实施这个练习:

注意

我们将继续在之前的练习中用我们的Team类完成的工作,所以请确保将其作为起点引用。本练习的代码文件可以在以下位置找到:packt.link/tbav7

  1. 打开scoreboard.ts文件。

  2. Team类内部,声明另一个名为players的属性,它接受字符串数组。编写以下代码来声明string数组:

    players: string[]; 
    
  3. 通过添加nameplayers参数来更新constructor函数。将nameplayers参数的值分别设置为this.namethis.players。编写以下代码来更新我们的constructor函数:

    constructor(name, players){
        this.name = name;
        this.players = players;
    }
    
  4. 更新generateLineup()方法,使其连接将传递给对象的玩家名称。此方法将返回一个普通字符串。以下是更新后的generateLineup()方法的代码:

    generateLineup(){
        return this.players.join(", ");
    }
    
  5. 创建两个玩家数组,即astrosPlayersblueJaysPlayers。将四个玩家名称分配给每个数组,并将这些数组作为Team类对象的第二个参数传递。编写以下代码来完成此操作:

    const astrosPlayers = ["Altuve", "Bregman", "Correa", "Springer"];
    const astros = new Team("Astros", astrosPlayers);
    console.log(astros.generateLineup());
    console.log(astros.name);
    const blueJaysPlayers = ["Vlad", "Smoak", "Tellez", "Sogard"];
    const blueJays = new Team("Blue Jays", blueJaysPlayers);
    console.log(blueJays.generateLineup());
    console.log(blueJays.name);
    
  6. 现在,在终端中,键入以下命令以生成 JavaScript 代码并运行它:

    tsc scoreboard.ts
    node scoreboard.js
    

    一旦运行前面的命令,终端将显示以下输出:

    Altuve, Bregman, Correa, Springer
    Astros
    Vlad, Smoak, Tellez, Sogard
    Blue Jays
    

我们现在已经将类型集成到我们的Team类中。如果你能在控制台中查看传递给类的名称,这意味着你正在正确地使用类及其类型。在下一节中,我们将学习为什么需要接口以及它们是如何有用的。

TypeScript 接口

我们将在下一章深入探讨 TypeScript 接口。但到目前为止,只需知道接口允许你在创建对象时描述传递给类的数据。在前面的练习代码中,如果我们将鼠标悬停在 Visual Studio Code 中的Team类上,我们会得到以下消息:

![图 4.2:模糊的 IntelliSense 指导img/B14508_04_02.jpg

图 4.2:模糊的 IntelliSense 指导

如您在前面的截图中所见,Visual Studio Code 编辑器的 IntelliSense 表示 players 参数使用的是 any 数据类型。它在这里没有提供任何使用提示,这也开始说明了为什么我们需要接口,因为现在 players 数组可以是任何东西。它可以是字符串,也可以是对象,等等。这实际上破坏了使用 TypeScript 的主要好处之一。理想情况下,我们的程序应该是声明式的,以至于我们知道应该将什么类型的数据传递给我们的函数和类。我们将利用接口来实现这一点。定义接口的方式是先使用 interface 关键字,然后是接口的名称。在 TypeScript 社区中,常见的约定是以大写字母 I 开头,后面跟着为该接口构建的类的名称。

一旦我们创建了接口并更新了构造函数,我们将建立一个定义我们的参数和类型的方法。这将破坏使用旧参数语法创建的任何先前创建的对象,因为之前的参数不再与我们的新接口匹配。在下一节中,我们将完成一个练习,其中我们将构建一个接口。

练习 4.04:构建接口

在这个练习中,我们将构建一个接口并设置需要传递给我们的函数和类的数据类型。执行以下步骤以实现此练习:

注意

我们将继续在 Team 类中执行我们在上一个练习中完成的工作,所以请确保将其作为起点。此练习的代码文件可以在以下位置找到:packt.link/FWUA6

  1. 打开 scoreboard.ts 文件。

  2. 创建一个名为 ITeam 的接口,并使用与对象相同的键/值语法列出属性和数据类型。编写以下代码以创建接口:

    interface ITeam{
        name: string;
        players: string[];
    }
    
  3. 在我们的 Team 类内部,修改 constructor 函数中的参数列表,以便将数据作为一个 ITeam 类型的单个对象传递。编写以下代码以完成此操作:

    constructor(args: ITeam){
        this.name = args.name;
        this.players = args.players;
    }
    

    注意在前面的代码中,我们不是单独列出每个参数,而是声明了创建 Team 对象所需的精确结构。从那时起,我们从 args 参数中调用 nameplayers 值,因为我们的参数列表现在已经被重构为使用单个参数。

  4. 通过编写以下代码创建 Team 类的对象:

    const astros = new Team();
    

    现在请注意,当我们悬停在括号上时,它表示期望一个参数但得到了零个。查看以下截图以查看消息:

    图 4.3:IntelliSense 列出类所需的参数

    图 4.3:IntelliSense 列出类所需的参数

  5. 让我们更新创建对象的方式。在name属性中开始输入。编写以下代码来创建对象:

    const astros = new Team({
        name
    })
    

    添加name参数后,我们将看到以下错误:

    图 4.4:IntelliSense 描述创建对象所需的数据类型

    图 4.4:IntelliSense 描述创建对象所需的数据类型

    如果你将鼠标悬停在name属性上,你可以看到 TypeScript 正在帮助我们理解我们需要传递的其他参数,因为players属性缺失。所以,这已经为我们提供了关于我们的类应该如何工作的更多信息。

  6. 现在,传递两个属性nameplayers的值,并更新两个对象astrosblueJays的值。编写以下代码来完成此操作:

    const astrosPlayers = ["Altuve", "Bregman", "Correa", "Springer"];
    const astros = new Team({
        name: "Astros",
        players: astrosPlayers
    });
    console.log(astros.generateLineup());
    console.log(astros.name);
    const blueJaysPlayers = ["Vlad", "Smoak", "Tellez", "Sogard"];
    const blueJays = new Team({
        name: "Blue Jays",
        players: blueJaysPlayers
    });
    console.log(blueJays.generateLineup());
    console.log(blueJays.name);
    
  7. 现在,在终端中,键入以下命令以生成 JavaScript 代码并运行它:

    tsc scoreboard.ts
    node scoreboard.js
    

    运行前面的命令后,以下输出显示在终端中:

    Altuve, Bregman, Correa, Springer
    Astros
    Vlad, Smoak, Tellez, Sogard
    Blue Jays
    

我们现在已经建立了一个接口,并设置了需要传递给我们的函数和类的数据类型。尽管我们得到了与之前练习相同的结果,但我们现在清楚需要传递给我们的函数和类的是哪种类型的数据。

使用接口和基于对象的参数与类一起使用的另一个巨大好处是,参数不需要按特定顺序排列。你可以按你想要的任何顺序传递键,类仍然可以正确解析它们。如果你使用标准的参数名称,你总是需要知道传递参数到类和函数的顺序。

在方法中生成 HTML 代码

现在我们已经学会了如何构建接口,并且有能力传递数据,再加上 IntelliSense 的帮助,我们知道传递的数据类型,我们实际上可以生成一些 HTML。看到我们编写的代码生成自己的代码很有趣。我们选择包含这个示例的部分原因是因为这非常接近你在构建 React JS 或 Angular 应用程序时将使用的过程。在它们的内核中,标准 React 应用程序的目标是利用 JavaScript/TypeScript 代码来渲染可以呈现给用户的 HTML 代码。

在下一节中,我们将完成一个练习,在这个练习中我们将生成 HTML 代码并在浏览器中查看。

练习 4.05:生成和查看 HTML 代码

在这个练习中,我们将通过清理一些代码来生成一些 HTML。我们将移除name属性和接口。按照以下步骤实现这个练习:

注意

我们将继续使用Team类完成之前练习中执行的工作,所以请确保将其作为起点进行参考。本练习的代码文件可以在以下链接找到:packt.link/Bz5LV

  1. 打开scoreboard.ts文件。

  2. Team类中,声明players数组并创建一个constructor函数。编写以下代码以实现此功能:

    players: string[];
    constructor(players){
        this.players = players;
    }
    
  3. 通过编写以下代码来更新generateLineup()函数:

    generateLineup(): string{
        const playersWithOrderNumber = 
          this.players.map((player, idx) => {
            return `<div>${idx + 1} - ${player}</div>`;
        });
        return playersWithOrderNumber.join("");
    }
    

    map函数是一个有用的迭代工具,它遍历球员数组。您可以将其作为执行某种操作的函数传递。在前面的代码中,行

    ${idx + 1} – ${player}
    表明在每次迭代中,每个球员的数据都被 HTML 代码包裹。此外,每个返回的元素都存储在一个新的数组playersWithOrderNumber`中。

    注意

    注意我们为generateLineup方法声明的返回类型。这意味着我们正在告诉 TypeScript 编译器该方法将始终返回一个字符串值。之所以如此重要,是因为如果应用程序的任何其他部分调用此方法并尝试执行不适用于字符串数据类型的任务,它们将得到一个清晰的错误和建议如何修复它。

  4. 现在,在终端中,输入以下命令以生成 JavaScript 代码并运行它:

    tsc scoreboard.ts
    node scoreboard.js
    

    运行前面的命令后,终端中显示以下输出:

    图 4.5:显示两支球队球员阵容的输出

    图 4.5:显示两支球队球员阵容的输出

    在前面的输出中,您将看到我们得到的是打印出两支球队球员阵容的 HTML。

    但我们不要就此止步。让我们看看在浏览器中这看起来是什么样子。

  5. 将生成的代码保存到名为index.html的 HTML 文件中,并在浏览器中查看。浏览器中将显示以下输出:图 4.6:在浏览器中查看生成的 HTML 代码

图 4.6:在浏览器中查看生成的 HTML 代码

注意

您可能根据默认浏览器得到不同的图像;然而,显示的文本将与前面的截图中所列的相同。

您可以看到,我们为两支球队都有一套完整的球员阵容。然而,我们还没有格式化页面上的文本,因此除非您能访问代码,否则很难确定球员所属的球队。随着我们在本章的进展,我们将增强这个页面,添加更多信息并进行格式化。

注意,我们可以将对象本身传递给另一个类,该类将为我们将它们组合在一起并生成完整的得分板。在下一节中,我们将学习如何与多个类和对象一起工作。

多个类和对象的工作

在本节中,我们将学习如何创建一个结合其他类以提供更高级行为的类。这个概念之所以重要,是因为你将在许多不同类型的应用程序中需要实现这种类型的行为。例如,如果你正在构建一个 React 应用程序中的联系表单,你可能需要为 API、表单元素、表单验证和其他表单功能创建类,使它们协同工作。在下一节中,我们将查看一个将类结合起来的练习。

练习 4.06:组合类

在本练习中,我们将创建一个 scoreboard 类,它将允许我们传入对象并处理它们的数据和行为。这将使我们能够使用从其他类(如我们的 Team 类)创建的实例化对象。然后,我们将添加一些其他行为,以生成一个完整的分数板,展示阵容和数据。执行以下步骤以实现本练习:

注意

我们将使用之前练习中完成的 Team 类继续我们的工作,所以请确保将其作为起点进行引用。本练习的代码文件可以在以下位置找到:packt.link/UY5NP

  1. 打开 scoreboard.ts 文件。

  2. 创建一个 Scoreboard 类,列出三个属性,即 homeTeamawayTeamdate。在这里,homeTeamawayTeam 将是 Team 类型,而 date 将是 string 类型。编写以下代码以完成此操作:

    class Scoreboard{
        homeTeam: Team;
        awayTeam: Team;
        date: string;
    }
    

    在前面的代码中,注意我们如何能够调用 Team 类。这是因为当我们创建一个类时,我们能够将这个类当作 TypeScript 中的类型来处理。因此,TypeScript 现在知道我们的 homeTeamawayTeam 数据属性必须是 Team 对象。date 属性将代表分数板的日期。如果我们尝试传递 stringarray 或任何其他 Team 对象,程序将无法编译。

  3. 现在我们知道了我们的分数板需要的数据类型,让我们为它创建一个接口。编写以下代码以创建接口:

    interface IScoreboard{
        homeTeam: Team;
        awayTeam: Team;
        date: string;
    }
    

    这与我们在 ITeam 接口中实现的方式类似,但有一个很好的转折。因为我们的 homeTeamawayTeam 属性与基本数据类型(如 stringnumber)不相关联,所以我们让接口知道这些值必须是 Team 类的对象。

  4. 现在,在终端中,输入以下命令以生成 JavaScript 代码并运行:

    tsc scoreboard.ts
    

    当执行前面的命令时,将创建 scoreboard.js 文件。

  5. 打开 scoreboard.js 文件,你将在开头看到以下代码:图 4.7:生成的 JavaScript 代码显示接口仅由文本编辑器使用

    图 4.7:生成的 JavaScript 代码显示接口仅由文本编辑器使用

    在前面的屏幕截图中,我们实际上在这里做的事情几乎就像是为这个类的一个小型声明文件。我们正在定义类的形状。如果你记得,那些接口和那些声明文件不会编译成 JavaScript。你可以通过查看前面屏幕截图中的生成的 JavaScript 代码来确认这一点。

    现在我们已经定义了接口,我们实际上已经定义了Scoreboard类的形状。

  6. 现在我们实现一个constructor函数,允许Scoreboard类在创建新对象时知道期望的参数。编写以下代码以完成此操作:

    constructor(args: IScoreboard){
        this.homeTeam = args.homeTeam;
        this.awayTeam = args.awayTeam;
        this.date = args.date;
    }
    

    在此基础上,我们Scoreboard类中的任何函数都可以使用这些值。

  7. 现在让我们在Scoreboard类中创建一个名为scoreboardHtml()的函数。编写以下代码以完成此操作:

    scoreboardHtml(): string{
        return `
        <h1>${this.date}</h1>
        <h2>${this.homeTeam.name}</h2>
        <div>${this.homeTeam.generateLineup()}</div>
        <h2>${this.awayTeam.name}</h2>
        <div>${this.awayTeam.generateLineup()}</div>
        `;
    }
    

    在前面的代码中,我们有一个<h1>标题标签用于date,以及一个包含团队名称的<h2>标题标签。这很好,因为尽管Scoreboard类对Team类一无所知,但 IDE 可以让我们知道我们可以访问名称值。最后,我们能够调用Team函数。因此,在<div>标签包装器内部,我们调用TeamgenerateLineup()函数,我们知道从之前返回的是 HTML 元素列表。注意,此函数始终返回一个字符串,并且我们使用反引号以便可以使用字符串字面量,这些可以是动态的。

    注意

    在 TypeScript 和 JavaScript 中,字符串字面量可以写在多行,而引号不允许这样做。

  8. 使用name属性和constructor函数更新Team类。编写以下代码以完成此操作:

    name: string;
    players: string[];
    constructor(name, players){
        this.name = name;
        this.players = players;
    }
    
  9. 要查看最终的记分板,首先创建两个团队对象,然后创建Scoreboard类对象,然后将日期和我们的两个团队对象传递给它。编写以下代码以完成此操作:

    const astrosPlayers = ["Altuve", "Bregman", "Correa", "Springer"];
    const astros = new Team("Astros", astrosPlayers);
    //console.log(astros.generateLineup());
    const blueJaysPlayers = ["Vlad", "Smoak", "Tellez", "Sogard"];
    const blueJays = new Team("Blue Jays", blueJaysPlayers);
    //console.log(blueJays.generateLineup());
    const todaysGame = new Scoreboard({
        date: "5/24/19",
        homeTeam: astros,
        awayTeam: blueJays
    });
    console.log(todaysGame.scoreboardHtml());
    
  10. 现在,在终端中,键入以下命令以生成 JavaScript 代码并运行:

    tsc scoreboard.ts
    node scoreboard.js
    

    一旦运行前面的命令,终端将显示以下输出:

    图 4.8:生成的 HTML 代码

    图 4.8:生成的 HTML 代码

  11. 将此代码添加到 HTML 文件中,并在浏览器中查看。您将看到我们有一个完整的记分板,如下面的截图所示:图 4.9:浏览器中生成的代码

图 4.9:浏览器中生成的代码

最后,我们结合了两个类,即ScoreboardTeam。在Scoreboard类中,我们创建了Team类型的属性并添加了一些有助于生成包含两队阵容的完整记分板的操作。

到目前为止,我们已经介绍了 TypeScript 中的类和对象,并且有了这些知识,我们准备进入下一节的代码活动,我们将创建一个用户模型。

活动 4.01:使用类、对象和接口创建用户模型

在这个活动中,你将构建一个用户身份验证系统,模拟 TypeScript 应用程序如何将登录数据传递给后端 API 以注册和登录我们的棒球比分卡应用程序。这包括构建多个 TypeScript 类并将类和对象组合在一起来模拟身份验证功能。按照以下步骤实现此活动:

  1. 访问 GitHub 仓库并下载包含规范和配置元素的 activity 项目:packt.link/vJxBm

  2. 打开 Visual Studio Code 编辑器。

  3. 创建一个名为 auth.ts 的文件。

  4. 在文件上运行 TypeScript 编译器并观察更改。

  5. 创建一个 Login 类,它接受一个包含字符串属性 emailpassword 的对象。

  6. 创建一个名为 ILogin 的接口,它定义了 emailpassword 属性。

  7. 将它作为参数传递给构造函数。

  8. 创建一个 Auth 类,它接受一个包含 usersource 属性的对象。

  9. 创建一个名为 IAuth 的接口,它定义了 usersource 属性,并将其作为构造函数参数传递。让 user 属性为 Login 类型,source 属性为 string 类型。

  10. Auth 类添加一个 validUser() 方法,如果 email 等于 admin@example.com 并且 password 等于 secret123,则返回 true

  11. 确保你可以从实例化的 Auth 对象中访问 source 属性,并且它是一个字符串。

  12. 通过首先检查有效用户然后检查无效用户来测试用户模型。

    预期的输出应该看起来像这样:

    Validating user...User is authenticated: true
    Validating user...User is authenticated: false
    

    注意

    此活动的解决方案可以通过此链接找到。

摘要

首次学习面向对象开发模式可能是一项具有挑战性的任务。在本章中,你学习了面向对象开发,如何在 TypeScript 中定义类,如何实例化类并创建对象,如何在一个类中将数据和函数结合起来以封装完整的行为集,如何利用接口来定义可以传递给 TypeScript 类的数据,以及最后如何将对象传递给各种类型的类。

你现在也基本了解了身份验证系统的工作原理以及如何使用 TypeScript 生成 HTML 代码。

现在你已经基本了解了 TypeScript 中类和对象的工作原理,在下一章中,你将学习如何处理类继承的概念,并更深入地了解接口。

第六章:5. 接口和继承

概述

本章将向你介绍接口和继承。你将学习如何使用接口来塑造你的类、对象和函数。你还将了解接口如何帮助你编写更好的代码。在本章结束时,你将能够编写更好、更易于维护的代码,具有结构良好的函数、类和对象,并且能够有效地重用现有代码。

简介

上一章讨论了类和对象。你了解到类定义了对象及其功能。类是构建这些对象时遵循的蓝图。现在,我们将抽象层次提高一级。我们现在将构建接口。接口是描述符,允许你定义对象的形状。接口允许你定义契约,即规定数据形状的规则。

接口之所以重要,是因为它们使你的对象能够具有强类型,这让你能够编写更干净的代码。在小型应用程序中,定义对象的形状可能不是一个大问题,但当与大型应用程序一起工作时,接口将证明其价值,因为它们将使你的应用程序能够扩展,而不会使你的代码变得混乱且难以支持。

继承允许新对象继承现有对象的属性,使你能够在不重新定义公共属性的情况下扩展代码功能。继承将帮助你更好地理解如何结构化你的代码,以使你的方法更高效和逻辑。本章将首先介绍接口,并为你提供使用它们的技能,然后继续讨论继承这一主题。

接口

这里有一个定义用户对象形状的简单接口示例:

interface UserInterFace {
    email: string,
    token: string,
    resetPassword: ()=> boolean   
}

在前面的代码中,我们定义了一个接口,我们可以在任何应该遵循接口中定义的规则的对象上实现它。这种优势使我们与其他网络语言(如纯 JavaScript)相比具有优势,即所有实现此接口的对象都必须遵循接口定义的结构。这意味着我们的对象现在是强类型的,并且具有语言支持,如语法高亮、自动完成以及在实现错误时抛出异常。如果你是一位在大型应用程序上工作的开发者,这非常重要,因为你可以定义规则,现在可以确信所有实现UserInterface的对象都将具有接口中定义的相同属性。

这里是一个实现UserInterface接口的对象示例:

const User: UserInterFace = {
    email: 'home@home.com',
    token: '12345678',
    resetPassword(): boolean{
        return true
    }
}

如前例所示,我们现在能够实现一个遵守 UserInterFace 接口定义的指南的对象。当与大型团队或复杂网络应用程序一起工作时,拥有透明、易于理解的代码规则非常重要。

接口允许为你的对象创建一个共同的参考点,一个定义对象应该如何构建的规则的地方。在下一节中,我们将深入探讨 TypeScript 中的接口。

当你想为你的对象、类和函数的实施设置规则时,会使用接口。它们是一个合同,它规定了结构但不规定功能。这里有一个图表示接口及其与两个类(UserAdmin)的关系:

图 5.1:接口与类之间的关系

图片

图 5.1:接口与类之间的关系

在图中,我们有一个用户界面,描述了属于此接口的类应该如何实现。正如你所看到的,我们在两个类中提供了一些属性(用户界面中突出显示的代码)和方法。接口只为属性的名称、类型、方法结构和返回类型(如果不是 void)提供基本信息。请注意,接口不提供与方法工作相关的规则,只提供它们的结构。方法的实际功能是在类本身中定义的。如前所述,TypeScript 中的接口为你提供规则,你可以根据需要实现它们。这从前面的图中很明显。AdminUser 类有一个在 UserInterface 中未定义的方法;然而,这不是问题,因为该类符合接口的所有元素。没有规则说你不能向你的类中添加内容,只是你需要满足你的类实现的接口的要求。

案例研究 – 编写你的第一个接口

假设你正在与一个应用开发团队一起工作,为仓库地面工作人员开发应用程序。你的任务是构建产品创建类和函数。你已经根据应用程序的功能需求为你的类制定了一个计划。你首先创建了一个名为 ProductTemplate 的产品接口。ProductTemplate 定义了我们的产品对象的结构和基本要求。请注意,我们也可以以相同的方式使用类型对象,这可能更可取,因为这是一个简单的对象,不是一个类,不能由类型表示。然而,为了这个示例,并且为了让你明白接口也可以在定义简单对象时用作类型,我们已经构建了 ProductTemplate 接口:

Example_Interface_1.ts
1 //first interface
2 interface ProductTemplate {
3     height: number
4     width: number
5     color: string
6 }
Link to the preceding example: https://packt.link/wYJis.

在定义接口时,我们首先使用接口关键字,然后是接口的名称,ProductTemplate,如前文代码片段所示。我们的产品需要三个属性 – 高度、宽度和颜色。现在我们已经描述了我们的产品数据应该是什么样子,让我们使用它:

7 //make product function
8 const productMaker = (product: ProductTemplate) => {
9     return product
10 }

我们已经创建了一个名为 productMaker 的函数,它接受一个产品对象作为参数。为了确保只有符合我们 productMaker 函数所需属性的对象被传递给该函数,我们使用了我们的 ProductTemplate 接口,如前文代码片段所示。现在,我们只需要定义我们的产品对象;我们也将使用该接口:

11 // implement interface
12 const myProduct: ProductTemplate = {
13     height: 10,
14     width: 12,
15     color: 'red',
16 }

我们已经声明了一个名为 myProduct 的产品对象,并使用 ProductTemplate 接口添加了接口所需的属性。使用接口这种方式确保我们在创建产品对象时完全符合要求。现在,如果我们添加一个未定义的属性或删除 ProductTemplate 接口中定义的属性,IDE 或 TypeScript 编译器将抛出一个有用的错误信息。IDE 突出显示将取决于你的 IDE 和对 TypeScript 的支持程度。VS Code 应该突出显示前两种情况下的以下错误信息。

当你添加一个接口中未定义的属性 length 时,会出现以下错误信息:

(property) length: number
Type '{ height: number; width: number; color: string; length: number; }' is not assignable to type 'ProductTemplate'.
  Object literal may only specify known properties, and 'length' does not exist in type 'ProductTemplate'.ts(2322)

当你没有使用接口中定义的颜色属性时,会出现以下错误信息:

const myProduct: ProductTemplate
Property 'color' is missing in type '{ height: number; width: number; }' but required in type 'ProductTemplate'.ts(2741)
Example_Interface.ts(5, 5): 'color' is declared here.

现在我们有了产品对象,让我们将其传递给 productMaker 函数:

// call the function using console log to show the output
console.log(productMaker(myProduct));

一旦你使用 npx ts-node Example_Interface.ts 运行文件,你将获得以下输出:

{ height: 10, width: 12, color: 'red' }

这是理想的情况。但是,如果你传递一个不符合 ProductTemplate 接口的对象会发生什么呢?考虑以下代码表示这种情况:

const myBadProduct = {
    height: '20',
    color: 1
}
console.log (productMaker(myBadProduct))

当你使用 tsc 运行文件 [filename].ts 时,你会收到以下错误信息:

error TS2345: Argument of type '{ height: string; color: number; }' is not assignable to parameter of type 'ProductTemplate'.
  Property 'width' is missing in type '{ height: string; color: number; }' but required in type 'ProductTemplate'.

VS Code 会阻止你犯这样的错误。如果你在 VS Code 窗口中将鼠标悬停在红色下划线代码上,你会看到一个类似于前文错误信息的警告。

让我们回到我们的接口示例 (Example_Interface.ts)。现在,我们为产品定义了一个接口。让我们为 productMaker 函数做同样的事情。我们希望确保每次一个函数以我们的产品作为参数时,它都是按照正确的方式构建的。因此,我们构建了以下接口 – productInterfaceFunction

Example_Interface_2.ts
1 // first interface
2 interface ProductTemplate {
3     height: number
4     width: number
5     color: string
6 }
7 //function interface
8 interface productInterfaceFunction {
9     (product: ProductTemplate): ProductTemplate
10 }
Link to the preceding example: https://packt.link/Dzogj.

我们在 ProductTemplate 之后添加了函数接口 productInterfaceFunction。正如你所见,语法很简单,只是定义了函数可以接受哪些参数以及它应该返回什么。现在我们可以在函数声明中使用函数接口,如下所示:

//make product function
const productMaker: productInterfaceFunction = (product: ProductTemplate) => {
    return product }

你应该再次得到之前相同的输出:

{ height: 10, width: 12, color: 'red' }

我们现在已经以两种方式使用了接口:用于塑造一个对象和一个函数。这里唯一的问题是这种方式并不非常高效。作为优秀的开发者,我们希望尽可能高效,并遵守面向对象编程的标准。为此,我们现在将重构我们的代码,定义一个类来封装我们的产品属性和方法:

Example_Interface_3.ts
9  //product class interface
10 interface ProductClassInterface {
11    product: ProductTemplate
12    makeProduct(product: ProductTemplate) :ProductTemplate
13 }
Link to the preceding example: https://packt.link/kF4Ee.

在前面的代码片段中,我们为我们的类构建了一个接口,其中我们定义了一个product属性和makeProduct方法。

我们也在我们的产品对象和makeProduct上很好地使用了之前创建的接口。接下来,我们将使用新的接口ProductClassInterface来实例化一个新的类:

16 //class that implements product class interface 
17 class ProductClass implements ProductClassInterface  {
18    product: ProductTemplate
19    constructor(product: ProductTemplate){
20        this.product = product
21    }
22    makeProduct():ProductTemplate {
23        return this.product;
24    }
25 }
26
27 //new product object
28 const product: ProductTemplate = {height:100, width:200, color: 'pink'}

在前面的代码片段中,我们使用了implements关键字将接口规则应用到我们的ProductClass上。语法结构如下:class ProductClass后面跟着implements关键字,然后是您希望应用到类上的接口:class ProductClass implements ProductClassInterface。如您所见,这段代码更加简洁,易于管理。使用接口来定义我们的产品类使我们能够更加详细地描述,因为我们不仅可以定义我们的类,还可以定义与之相关的方法和属性。

类型别名也可以以类似的方式使用,但类型更多的是一个验证器而不是描述符,因此建议更多地使用类型来验证函数返回的对象或函数接收的参数。

接口和类型可以一起使用,而且应该这样使用。然而,它们的使用方式、使用位置以及如何在代码中应用,取决于您,因为它们在许多方面相似,尤其是在 TypeScript 语言的最新更新中。现在,让我们创建一个产品对象并使用我们的类实例newProduct

27 //new product object
28 const product: ProductTemplate = {height:100, width:200, color: 'pink'}
29 
30 //call make Product function
31 // instantiate product class with new product object 
32 const newProduct = new ProductClass(product)
33 // console our new product instance
34 console.log(newProduct.product)

在前面的代码片段中,我们构建了一个产品对象,并将其传递给我们的类的makeProduct函数。然后我们在控制台输出结果,与之前相同,但现在我们的功能代码被封装在类中。

您将得到以下输出:

{ height: 100, width: 200, color: 'pink' }

现在我们已经基本了解了如何在 TypeScript 中实现接口,让我们在接下来的练习中构建一个更现实的产品创建过程。

练习 5.01:实现接口

在这个练习中,我们将在一个对象、函数和类上实现一个接口。部分代码可能较为冗长,你可能在现实世界的应用中不会这样实现。然而,这个练习将让你了解在代码中实现接口的不同方式。我们将构建一个管理产品对象的类,并使用接口来强制实施与类实现相关的规则。我们还将使用接口来塑造我们的产品对象和类方法。在一个典型的 Web 应用中,这段代码可能属于产品管理接口的一部分——例如库存管理应用。或者,它也可能是产品创建过程的一部分,其中有一个表单用于接收用户数据并处理它:

注意

这个练习的代码文件可以在以下链接找到:packt.link/SR8eg。为了运行本章中的任何 TypeScript 文件,你需要进入文件目录并执行npx ts-node filename.ts

  1. 创建一个名为ProductObjectTemplate的接口:

    interface ProductObjectTemplate {
        height: number
        width: number
        color: string
    }
    

    当创建一个接口或类型对象时,你应该考虑你的接口或类型需要哪些共同元素。这可能基于应用需求,或者仅依赖于应用所需的功能。ProductObjectTemplate是一个简单的对象,在大多数情况下应该是一个类型,但为了展示接口也可以这样使用,我们选择将其作为一个接口。正如你所见,我们只定义了一些可能的产品基本属性——高度宽度颜色

  2. 使用前面步骤中定义的接口,定义一个名为ProductClassTemplate的函数:

    interface ProductFunctionTemplate {
        (product: ProductObjectTemplate)
    }
    

    在前面的步骤中,我们使用接口定义了一个函数,通过这样做,我们提供了函数可以接受哪些参数的规则。这将确保任何对这个函数的实现都只会接受ProductObjectTemplate作为参数。

  3. 为名为ProductClassTemplate的类构建一个接口。在你的新类中重用ProductFunctionTemplateProductObjectTemplate

    interface ProductClassTemplate {
        makeProduct: ProductFunctionTemplate
        allProducts():ProductObjectTemplate[]
    }
    

    在前面的步骤中,我们正在重用步骤 1 和 2中定义的函数和产品接口来构建我们的类接口。我们可以简化这一步骤的代码,因为我们正在重用我们在前两个步骤中创建的接口。步骤 3是一个很好的例子,说明了你如何在构建复杂性的同时使代码更加简洁。

  4. 创建一个Product类并实现我们的类接口:

    class Product implements ProductClassTemplate {
        products: ProductObjectTemplate []
        constructor() {
            this.products = []
        }
        makeProduct(product: ProductObjectTemplate) {
            this.products.push(product)
        }
    
        allProducts():ProductObjectTemplate[] {
            return this.products
        }}
    

    在此之前的步骤中,我们创建了一个实现ProductClassTemplate接口的类。这将确保我们的类遵守接口中定义的规则。我们还重用了ProductTemplate接口来验证我们的类方法是否接受正确的参数并返回正确的数据。在前面的步骤中,我们做了一些准备工作来设置接口,现在我们可以在代码库中重用它们,使整体代码更容易编写、更容易支持和理解。

  5. 按如下方式实例化我们的类:

    const productInstance: ProductClassTemplate = new Product()const productInstance: ProductClassTemplate = new Product()
    productInstance.makeProduct({})
    

    在这里,我们再次使用接口ProductClassTemplate来确保我们实现的类符合我们的规则集。

    如果我们尝试使用空对象调用makeProduct,我们会得到一个有用的错误消息,我们可以用它来解决我们的问题。请随意进行测试以确保你的接口按预期工作。在这里,我们有我们类实例方法makeProduct的正确实现。

  6. 调用makeProduct方法并提供一个符合我们产品接口定义的有效产品对象:

    productInstance.makeProduct(
        {
        color: "red", 
        height: 10, 
        width: 14
        }
    )
    
  7. 调用allProducts方法并将结果输出到控制台:

    console.log(productInstance.allProducts())
    

    allProducts方法返回一个产品数组。这相当于一个 API 调用,返回产品列表到你的前端。

  8. 现在,输出allProducts方法的结果:

    console.log(productInstance.allProducts())
    
  9. 通过执行npx ts-node Exercise01.ts来运行文件。

    你将获得以下输出:

     [ { color: 'red', height: 10, width: 14 } ]
    

    一旦正确遵循了步骤,你的输出应该是一个数组或产品对象,如前一张截图所示。接口为你提供了定义合约的手段,这些合约规定了你的代码应该如何实现,这正是强类型语言如 TypeScript 及其相对于 JavaScript 的主要优势所在。通过在练习中使用接口,我们现在有了更不容易出错且在处理大型应用程序或大型团队时更容易支持的代码。如果正确实现,接口对于开发过程可能非常有价值。

练习 5.02:实现接口 – 创建原型博客应用程序

想象一下,你是一名正在社交网站上工作的开发者。你被分配了一个任务,即设置一个博客系统,允许用户在网站上发布内容。该项目旨在全球范围内扩展,因此它将非常大。因此,你的代码需要定义得很好,包含所有必要的上下文。这里的主要主题是上下文。你正在编写代码的方式将导致无错误的代码,且易于支持和理解。

首先,我们从主对象开始——博客帖子。为了构建一个博客系统,我们需要定义什么是博客帖子。因为这个对象很简单,我们创建了一个类型别名,BlogPost。如前所述,我们可以使用接口来定义此对象,但对于简单、非复杂对象来说,类型更合适。类型更像是描述某个单元的描述符,例如,一个数字或一个字符串,而接口更像是如何与某个东西交互的指示,而不是它是什么:

注意

此代码文件的代码可以在此处找到:packt.link/6uFmG

  1. 按照以下代码片段定义博客类型:

    type BlogPost = {
        post: string,
        timeStamp: number,
        user: string
    }
    
  2. 创建一个名为AddToPost的接口:

    interface AddToPost {
         (post: BlogPost): BlogPost []
    }
    

    此接口将作为我们将用于添加到我们的博客列表的方法的主要接口。正如我们在前面的练习中所阐述的,AddToPost接口定义了我们将如何与我们的主要方法交互,以及当调用时它将返回什么。

  3. 创建一个接口来定义一个类,BlogPostClass

    interface IBlogPost {
        allPost: BlogPost [],
        addToPost: AddToPost
    }
    

    在这里,我们定义我们的类接口。我们知道我们需要一个地方来存放我们的博客,因此我们定义了一个allPost全局对象,它是一个BlogPost类型数组。我们还定义了一个方法,addToPost,它实现了AddPost接口。

  4. 创建一个名为blogPostClass的类,该类实现了blogPostClass接口:

    class blogPostClass implements IBlogPost{
        allPost: BlogPost [] = []
        addToPost(post: BlogPost): BlogPost[] {
            this.allPost = [
                ...this.allPost,
                post
            ]
            return this.allPost
        }
    }
    

    在前面的类中,我们重用我们的类型来强制和验证。addToPost方法的逻辑取决于你,开发者。在此步骤中,代码通过接受一个BlogPost类型的参数并返回一个BlogPost数组来实现方法,一旦它符合接口。

  5. 创建blogPostClass的实例:

    const blog = new blogPostClass();
    
  6. 构建BlogPost类型的三个对象:

    let post1: BlogPost = {post: 'Goodbye, 2020', timeStamp: 12345678, user: 'Rayon'}
    let post2: BlogPost = {post: 'Welcome, 2021', timeStamp: 12345678, user: 'Mark'}
    let post3: BlogPost = {post: 'What happened to 1999?', timeStamp: 12345678, user: 'Will'}
    

    此步骤模拟用户向您的博客网站发布帖子。在实际应用中,这将是一个表单,当提交时创建对象。

  7. 调用addToPost方法三次,并传递你在步骤 6中创建的帖子对象:

    blog.addToPost(post1)
    blog.addToPost(post2)
    blog.addToPost(post3)
    

    在实际的 Web 应用中,对addToPost的调用将涉及向应用程序的后端发送更新数据的 API 调用,但在此练习中,我们只是更新一个数组。例如,如果你正在使用某种前端状态管理,前面的代码可以非常类似于处理后端更新的状态管理。

  8. 从创建的类实例中控制台输出allPost全局对象,步骤 5

    console.log(blog.allPost)
    
  9. 通过执行npx ts-node Exercise02.ts来运行文件。

    你应该看到以下输出:

    [
      { post: 'Goodbye, 2020', timeStamp: 12345678, user: 'Rayon' },
      { post: 'Welcome, 2021', timeStamp: 12345678, user: 'Mark' },
      { post: 'What happened to 1999?', timeStamp: 12345678, user: 'Will' }
    ]
    

练习 5.03:为更新用户数据库的函数创建接口

作为网络应用开发团队的一员,你被分配了一个任务,即构建一个用于更新用户数据库的接口。在现实世界的应用中,这个函数可能是用户注册表单的一部分,通过 API 调用更新用户数据库。要求很简单:该函数应接受一个User类型的参数,该类型包含emailuserId属性。

为了这个练习,假设你只是在处理函数的逻辑,并且代码只是在你将其实现到你的工作应用之前的测试目的。因此,我们将有一个表示数据库的数组,它将预先加载一些用户对象:

注意

此练习的代码文件可以在以下位置找到:packt.link/XLIz9

  1. 创建一个具有emailuserId属性的User类型,如下所示:

    type User = {
        email: string,
        userId: number
    }
    

    创建一个用户类型可以简化你的函数接口。现在,你可以在定义下一步的接口时重用User类型。

  2. 建立一个名为SuperAddMe的函数接口,如下所示:

    interface SuperAddMe {
        (user: User): User[]
    };
    

    通过这样做,我们定义了我们如何与我们的函数交互。这是一件小事,但现在,所有此类函数都将有明确的规则。我们将知道它需要什么以及它将返回什么。

  3. 初始化一个User类型的数组,并填充一些用户:

    let allUsers: User[] = [
        { email: 'home@home.com', userId: 1 },
        { email: 'out@side.com', userId: 2 }
    ];
    

    此数组将模拟我们将要添加的用户数据库。

  4. 定义一个SuperAddMe接口类型的函数:

    let adduser: SuperAddMe
    adduser = function (user: User): User[] {
        return [
            ...allUsers,
            user
        ]
    }
    

    以这种方式实现函数时,你必须首先将其声明为接口类型,在这个例子中是SuperAddMe接口。接下来,使用函数变量,并分配一个符合我们接口规范的函数给它。这种实现与类型赋值非常相似,但由于函数的复杂性,使用了接口。此外,请注意,此代码可以通过在一行中完成声明和赋值来简化,但为了展示过程并使其更易于阅读,赋值被分部分实现。

  5. 显示对新函数adduser的调用结果,并传递一个User类型的用户对象。将结果输出到控制台以显示代码正在工作:

    console.log(
        adduser(
            { email: 'slow@mo', userId: allUsers.length }
        )
    )
    
  6. 使用npx ts-node命令运行代码。你应该看到以下输出:

    [
      { email: 'home@home.com', userId: 1 },
      { email: 'out@side.com', userId: 2 },
      { email: 'slow@mo', userId: 2 }
    ]
    

活动 5.01:使用接口构建用户管理组件

想象你正在开发一个网络应用,并被分配构建用户管理组件的任务。你需要构建一个类来封装应用的用户管理方面,并且作为一个优秀的开发者,你会使用接口来确保你的代码易于重用和支持。对于这个活动,你可以假设你的用户接口至少有三个属性:email、token 和 loginAt。这些属性与用户的电子邮件 ID、网络令牌以及用户登录系统的时间相关。

注意

此活动的代码文件可以在以下位置找到:packt.link/xsOhv

以下是一些帮助你完成此活动的步骤:

  1. 创建一个具有以下属性的user对象接口:email : stringloginAt : numbertoken: stringloginAttoken属性应该是可选属性。

  2. 创建一个具有全局属性user的类接口,并使用上一步创建的接口应用用户对象规则。

    你需要定义一个getUser方法,该方法返回user对象,然后使用接口确保返回的对象是用户对象。最后,定义一个接受user对象和password(type string)作为参数的login方法。使用user对象接口作为user参数类型。

  3. 声明一个名为UserClass的类,该类实现了先前的步骤中的类接口。你的登录方法应将本地函数的user参数赋值给全局user属性,并返回全局usergetUser方法应返回全局user

  4. 创建你在步骤 2中声明的类的实例。

  5. 创建一个user对象实例。

  6. 将我们的方法输出到控制台,以确保它们按预期工作。

预期的输出如下:

{ email: 'home@home.com', loginAt: 1614068072515, token: '123456' }
{ email: 'home@home.com', loginAt: 1614068072515, token: '123456' }

注意

此活动的解决方案可以通过此链接找到。

TypeScript 的诞生源于构建更不令人困惑、定义更清晰的代码的需求。接口允许你以最结构化的方式构建代码。一切都有规则,没有混淆,这与纯 JavaScript 不同。

要总结接口的重要性,你可以这样说:现在你可以生成结构更好、第三方更容易使用的代码。

例如,假设你像在先前的活动中做的那样构建了一个user类,现在你需要继续进行项目的另一部分。你构建的接口将对接管应用程序用户部分的开发者或可能其他开发者想要构建与你的用户类具有相似结构的用户类非常有帮助。通过使用你定义的接口,他们可以构建一个遵循你设定的所有规则的架构。这也对调试很有帮助,因为他们现在知道事情应该如何工作,并且可以通过使用接口作为指南来找到问题所在。

本章的下一节将专门介绍 TypeScript 中的继承。

TypeScript 继承

我们现在将深入探讨继承,这是面向对象编程的核心原则之一。它允许我们遵循 DRY(不要重复自己)原则。继承还允许我们通过抽象功能来实现多态。继承使你能够从原始类扩展到子类,这允许你保留父类或原始类的功能,并添加或覆盖你不需要的部分。

子类可以覆盖其父类的方法,并拥有自己的方法和对象。继承只允许你在父类的基础上构建;如何实现你的子类取决于你。然而,规则是,在子类中必须有一些需要从父类重用的代码,或者你应该创建一个新的类,因为没有必要扩展你不想使用任何代码的类。

假设你有一个用户类,用于管理你的应用程序中的用户。你正在开发一个 Web 应用程序,在规划阶段,你意识到你需要不止一种用户类型,因为不同的用户将具有不同的访问级别,并且能够根据他们的角色执行不同的操作。这正是使用继承的完美案例。每次你有通用属性和功能时,你都可以扩展而不是重复代码。在这种情况下,我们有几种用户类型,它们都具有用户的通用属性:电子邮件、创建日期、最后登录和令牌等。

因为这些属性对所有用户都是通用的,我们可以将它们全部放入一个用户类中。用户类将作为基类,我们可以将其扩展到子类中。现在,你的子类将拥有所有通用属性,而无需为每个子类单独声明它们。正如你所见,这是一种更有效的方法;它阻止了代码重复,并允许功能的整合。

首先,让我们回顾一下 TypeScript 中继承的一些基本规则:

TypeScript 只支持两种继承方式:单级和多级。因此,在 TypeScript 中,子类可以继承自父类(单级继承),或者子类可以继承自另一个子类(多级继承)。

注意

他们是其他类型的继承,但由于 TypeScript 不支持这些模式,本章将不会涉及这些类型。

这里,我们有一个 TypeScript 支持的两种继承类型的图示——单级和多级:

![图 5.2:单级和多级继承的示例图片 B14508_05_02.jpg

图 5.2:单级和多级继承的示例

单级继承发生在子类直接从父类继承时,如前图所示。Son 子类是从 Father 父类派生出来的,并具有所有其属性。它也可以有自己的属性和函数,这些属性和函数是子类独有的。继承的一个目标是在现有基础上构建,因此,仅仅创建类的副本将是毫无意义的。多级继承与单级继承的工作方式相同,除了子类从另一个子类继承,而不是直接从父类继承,如前图所示。换句话说,单级继承直接从基类继承,该基类没有父类,而多级子类从派生类继承。如您所见,Grandfather 类是基类,因此没有父类。Father 是从 GrandFather 派生出来的,但在这个例子中,Son 是从 Father 派生出来的,这使得这个例子是多级的。

TypeScript 使用 privatepublic 关键字来允许您隐藏子类中的代码,并控制子类如何通过 getter 和 setter 方法访问类属性。您可以使用 super 关键字覆盖父类公开的任何方法,super 是指向父类的直接链接。super 还允许您访问父类的属性和方法,即使它们在子类中被覆盖。

要了解代码中的继承是如何工作的,让我们回到本节引言中我们讨论过的用户示例。任何给定应用程序的用户都有一些共同的属性,例如电子邮件、创建日期、最后登录时间和令牌。我们将使用这些共同元素来构建基用户类:

Examples_Inheritance_1.ts
1 class UserOne {
2     email: string = "";
3     createDate: number = 0;
4     lastLogin: number = 0;
5     token: string = ""
6 
7     setToken(token: string): void {
8         // set user token
9        this.token = token;
10     }
11     resetPassword(password: string):string {
12         // return string of new password
13         return password;
14     }
15 }
Link to the preceding example: https://packt.link/23ts2.

这里有一些关于在基类中使用的属性的信息。这也有助于您理解为什么这些属性存在于基类中:

  • email: 这个属性作为唯一标识符。

  • createDate: 这个属性允许您知道用户何时被添加到系统中。

  • lastLogin: 这个属性让我们知道用户上次在系统上活跃的时间。

  • token: 这个属性将验证用户对应用程序 API 的请求。

  • setToken: 这个属性允许我们设置和重置令牌属性;例如,用户从应用程序注销,令牌需要被设置为 null。

  • resetPassword: 这个属性允许我们重置当前用户的密码。

我们还在 setToken 函数中使用 this 关键字来访问我们的类级别令牌。我们还在基类中提供了一些默认值,例如将电子邮件设置为空字符串,将 createDate 设置为零。这只是为了使创建类的实例更容易,因为我们不需要每次初始化类实例时都提供值。

现在,让我们继续讨论继承。我们将现在创建一个子类,AdminUser

16 class AdminUser extends UserOne {
17     // pages admin has access to
18     adminPages: string [] = ["admin", "settings"];
19 
20     // method that allows the admin to reset other users
21    resetUserPassword(email: string):string {
22         // return default user password
23         return "password123";
24     }
25 }

为了我们能够创建一个子类,我们必须使用extends关键字后跟父类,正如前面片段所示。语法结构如下:使用class关键字后跟子类的名称,然后是extends关键字,最后是你想要扩展的父类的名称:class AdminUser extends UserOne

在我们继续一些示例之前,让我们列出一些在 TypeScript 中使用类继承时不能做的事情:

  • 你不能使用除了单级和多级继承之外的其他类型的继承。

  • 如果你声明一个属性或方法为私有,你无法在派生类中直接访问它。

  • 除非你在派生类的构造函数中调用super,否则你不能覆盖基类的构造方法。

现在,让我们回到我们的子类AdminUser。请注意,我们为我们的子类添加了一些独特的属性和方法。对AdminUser独特的是adminPages,这是一个只有管理员用户可以访问的页面列表,以及resetUserPassword,它接受一个用户的电子邮件地址并返回一个默认密码:

注意

你也可以通过在子类中使用this关键字直接引用父类的属性和方法,因为AdminUser现在是一个组合类。

现在,考虑以下片段:

26 // create a instance of our child class
27 const adminUser: AdminUser = new AdminUser() 
28
29 // create a string to hold our props
30 let propString = ''
31
32 // loop through your props and appends prop names to propString
33 for(let u in adminUser) {
34     propString += u + ','
35 }

在前面的片段中,我们创建了一个子类AdminUser的实例。我们还声明了一个字符串propString,它是一个空字符串。这个字符串将保存你的类属性列表。使用for循环,我们遍历我们的类实例并将属性追加到propString

现在,输出我们子类的一个实例以验证我们是否已成功从基类继承:

36 // console out the results
37 console.log(propString)

你应该在控制台上看到我们子类和父类的属性和方法被打印出来:

email,createDate,lastLogin,token,adminPages,constructor,resetUserPassword,setToken,resetPassword,

前面的输出是预期的结果。你现在有一个UserOneAdminUser的组合属性列表,这表明我们已经成功将UserOne类扩展到AdminUser,换句话说,我们已经证明了AdminUserUserOne继承。

现在,让我们通过从AdminUser类派生一个新的类来将继承提升一级。将派生类命名为SuperAdmin,因为并非所有管理员都是平等的:

Examples_Inheritance_2.ts
class SuperAdmin extends AdminUser {
    superPages: string[] = ["super", "ultimate"]
    createAdminUser(adminUser: AdminUser ): AdminUser {
        return adminUser
    }
}
Link to the preceding example: https://packt.link/XcFR6.

如前所述的片段所示,我们现在正在扩展AdminUser类以创建一个SuperAdmin类。这意味着我们现在具有多级继承,因为我们的当前类正在从派生类继承。我们还添加了一个新的属性superPages和一个方法createAdmin

多级继承在构建复杂性的同时,还能保持你的代码易于管理。

接下来,我们将在SuperAdmin子类中重载resetPassword方法。

我们想在SuperAdmin类中创建一个新的方法来重置密码。我们需要一个方法来添加哈希,使用户密码更安全,因为这将是管理员超级用户的密码:

26 class SuperAdmin extends AdminUser {
27     superPages: string[] = ["super", "ultimate"]
28     readonly myHash: string
29 
30     constructor() {
31         super()
32         this.myHash = '1234567'
33     }
34 
35     createAdminUser(adminUser: AdminUser ): AdminUser {
36         return adminUser
37     }
38     resetPassword(password: string): string {
39         // add hash to password
40         return password + this.myHash; 
41     }
42 }

前面的代码片段创建了一个新的方法 resetPassword 并向 SuperAdmin 类添加了一个新的 myHash 属性。我们给我们的新方法取了与祖父类 UserOne 中的 resetPassword 方法相同的名字,即 resetPassword。然而,这个新方法返回了一个附加了我们的哈希属性的密码。

这被称为方法重写,因为方法具有相同的名称和签名,这意味着它们接受相同的参数。祖父类中的方法被重写,新的方法将优先于 SuperAdmin 类的实例。

当你需要向子类中的方法添加一些功能,但又不想改变签名时,这很有用,因为新方法做的是类似但不完全相同的事情。你的代码的消费者将能够使用相同的方法,但根据他们调用的派生子类,得到不同的结果。

在下面的代码片段中,我们将输出 SuperAdminAdminUser 类的实例以及 resetPassword 方法的结果:

43 const superAdmin = new SuperAdmin()
44 const newAdmin = new AdminUser()
45 console.log( superAdmin.resetPassword('iampassword'))
46 console.log( newAdmin.resetPassword('iampassword'))

你将得到以下输出:

iampassword1234567 
iampassword   

如您从输出中看到的,我们调用了相同的方法,但得到了不同的输出。这表明我们成功地重写了来自父类 UserOneresetPassword 方法。

你还可以向我们的类添加一些访问修饰符,以显示它们将如何影响我们的子类:

class UserOne {
    email: string = "";
    createDate: number = 0;
    lastLogin: number = 0;
    private token: string = ""
    setToken(token: string): void {
        // set user token
        this.token = token;
    }
    resetPassword(password: string):string {
        // return string of new password
        return password;
}}

在前面的代码片段中,我们向 token 属性添加了 private 访问修饰符。现在,我们只能通过公共的 setToken 方法访问 token 属性,而所有派生类都有权访问 setToken 方法。这在你想限制在子类中授予哪些方法和属性访问权限的情况下很有用。这也有助于抽象功能,从而使得消费者与你的代码交互更加容易。

我们想要确保每个 AdminUser 类的实例都初始化了一个电子邮件地址。因此,我们决定在我们的 AdminUser 类中添加一个构造函数方法,以便在创建 AdminUser 类实例时为我们的管理员用户创建一个电子邮件地址。

然而,我们不能仅仅创建一个构造函数,因为这是一个子类,这意味着我们已经有了一个带有构造函数方法的父类,并且我们不能在不调用基类构造函数方法的情况下重写构造函数方法。

要调用基类的构造函数方法,我们使用 super(),这是对基类构造函数方法的直接引用:

// adminUserTwo
class AdminUserTwo extends UserOne {
    // pages admin has access to
    constructor(email: string) {
        super()
        this.email = email;
      }

      adminPages: string [] = ["admin", "settings"];

      resetUserPassword():string {
          // return default user password
          return "password123";
      }

如您在前面的代码片段中看到的,我们有一个接受电子邮件地址并设置全局电子邮件地址的构造函数方法。我们还调用了 super 方法,这样我们就可以在父类上调用构造函数方法。

现在,你可以在创建 AdminUserTwo 类的实例时传递一个电子邮件地址。这对我们的 AdminUser 类的用户来说是完全透明的:

const adminUserTwo = new AdminUserTwo('home@home.com');

现在我们已经涵盖了继承,我们将把所学到的知识应用到即将到来的练习中。

练习 5.04:创建基类和两个扩展子类

假设您是开发团队的一员,正在为一家超市连锁店开发一个网络应用程序。您有建立一个表示应用程序中用户的类的任务。因为您是一位优秀的开发者,并且知道您不应该尝试为所有用例创建一个类,所以您将构建一个基类,其中包含您认为应用程序中所有用户都应该拥有的常见属性,然后根据需要扩展它为子类:

注意

该练习的代码文件可以在此处找到:packt.link/hMd62

  1. 创建一个 User 类,如下面的代码片段所示:

    class User {
        private userName: string; 
        private token: string = ''
        readonly timeStamp: number = new Date().getTime()
        constructor(userName: string, token: string) {
            this.userName =  userName
            this.token = token
        }
        logOut():void {
            this.userName = ''
            this.token = ''
        }
        getUser() {
            return {
                userName: this.userName,
                token: this.token,
                createdAt: this.timeStamp
            }
        }
        protected renewToken (newToken: string) {
            this.token = newToken
        }}
    

    应用程序要求所有用户在创建用户对象时都必须有 usernametoken,因此我们添加了这些属性,并在构造函数中初始化它们。

    我们还把它们设置为 private,因为我们不希望子类直接访问我们的属性。我们还有一个 timestamp 属性,我们将用它来为用户对象设置创建日期。这个属性被设置为 readonly,因为它在类实例化时创建,我们不希望它被修改。

    您的应用程序的不同部分也需要访问用户对象的属性。因此,我们添加了 getUser 方法,该方法返回您的用户属性。getUser 方法还将允许派生或子类以间接方式访问私有属性。应用程序允许用户在一定时间内登录,之后用户令牌过期。为了使用户能够在应用程序中继续工作,我们需要更新他们的令牌,因此我们添加了 renewToken 方法,允许设置用户令牌属性,而不直接访问属性。

  2. User 类派生一个 Cashier 类:

    class Cashier extends User {
        balance: number = 0
        float: number = 0
        start(balance: number, float: number): void {
            this.balance= balance
            this.float = float
        }
    }
    

    现在我们有一个新的用户类 Cashier,它是从 User 类派生出来的,具有一些独特的特性。Cashier 类型的用户需要在我们的应用程序中发挥作用。然而,我们没有访问父类中所有属性的权限。您不能直接访问 userNametoken。您能够访问 renewToken 方法,但不是通过 Cashier 类的实例。然而,您可以在构建 Cashier 类时调用该方法,作为您对收银员用户管理的部分。

    为什么我们要在子类中修改访问权限,而不是在父类中修改?这是因为封装和标准化:我们希望当我们的代码被他人使用时,降低代码的复杂性。

    例如,你一直在开发一个有用的函数库。你希望你的同事能够使用它,但他们不需要了解你的User类的内部工作原理。他们只需要能够通过公开的方法和属性访问该类。这允许你在不是扩展或实现代码的人的情况下引导这个过程。一个很好的例子是 JavaScript 中的Date类。你不需要知道它是如何工作的。你只需实例化它并按指示使用它。

  3. User类派生一个Inventory类:

    class Inventory extends User {
        products: string [] = []
        // override constructor method, add new prop
        constructor(userName: string, token: string, products: string[]) {
            // call parent constructor method
            super(userName, token)
            // set new prop
            this.products = products
    }}
    

    我们的新用户类型Inventory需要在声明新的库存用户时初始化产品,因为这个用户将直接处理产品,并且当用户登录应用程序时,他们应该在用户队列中拥有一些产品。

    为了实现这一点,我们在子类中重写了父类的构造函数方法。我们的构造函数现在接受一个新的参数products,它是一个字符串类型的数组。这意味着我们已经根据在父类中定义的内容改变了构造函数应该接受的参数数量。每次我们重写构造函数时,都需要调用super,这是对父类的引用。

    如您所见,这允许我们访问父构造函数方法,因此我们现在可以初始化userNametoken,并在这样做的同时满足子类的父类要求。从这个例子中我们可以吸取的主要教训是,我们所有的代码更改都是在子类中进行的。你为Inventory类编写的新代码不会影响从User类派生的其他类。你已经扩展并定制了你的代码以处理独特的情况,而不必为这个用户案例编写新代码,这节省了你的时间并使你的代码库保持简单。

    到目前为止,我们已经从我们的User类派生了两个类,这是单继承,因为我们所创建的子类直接从基类派生。下一步涉及到多层继承。

  4. 创建一个新的派生类FloorWorker

    class FloorWorker extends Inventory {
        floorStock: string [] = []
        CheckOut(id: number) {
            if(this.products.length >=0) {
                this.floorStock.push(
                    this.products[id]
                )
            }
        }
    }
    

    这就是多层继承。这个类考虑了楼层工作人员。这些是处理商店货架库存的用户,因此他们需要从库存中访问产品。他们还需要有一个移除产品的数量,以填充商店货架。他们需要能够访问User类的属性,以及访问Inventory类的Products数组。

    在下面的代码片段中,我们将实例化我们的不同用户类,并输出我们迄今为止所做的工作的结果。

  5. 实例化你的基本用户并输出结果:

    const basicUser = new User('user1', '12345678ttt')
    console.log(basicUser) 
    

    你将获得以下输出:

    User {
      token: '12345678ttt',
      timeStamp: 1614074754797,
      userName: 'user1'
    }
    
  6. 实例化Cashier类用户并输出结果:

    const cashUser = new Cashier('user2', '12345678')
    console.log(cashUser)
    cashUser.start(10, 1.5)
    console.log(cashUser)
    

    你将获得以下输出:

    Cashier {
      token: '12345678',
      timeStamp: 1614074754802,
      userName: 'user2',
      balance: 0,
      float: 0
    }
    Cashier {
      token: '12345678',
      timeStamp: 1614074754802,
      userName: 'user2',
      balance: 10,
      float: 1.5
    
  7. 实例化Inventory类用户并输出结果:

    // init inventory
    const iUser = new Inventory('user3', '123456789', [
        'orange', 'mango', 'playStation 2'
    ])
    console.log(iUser) 
    

    你将获得以下输出:

    Inventory {
      token: '123456789',
      timeStamp: 1614074754819,
      userName: 'user3',
      products: [ 'orange', 'mango', 'playStation 2' ]
    }
    
  8. 实例化FloorWorker类用户并输出结果:

    // FloorWorker
    const fUser = new FloorWorker('user4', '12345678', [
        'orange', 'mango', 'playStation 2'
    ])
    fUser.CheckOut(0)
    console.log(fUser.products) 
    console.log(fUser.floorStock)
    

    你将获得以下输出:

    [ 'orange', 'mango', 'playStation 2' ]
    [ 'orange' ]
    

    注意

    对于步骤 5-8,你也可以一次性实例化和控制台输出属于不同类别的所有用户,而不是单独输出,如下所示。

在这个练习中,你创建了一个基类、子类,并处理了多层和单层继承。你还使用了super和访问修饰符。

练习 5.05:使用多层继承创建基类和扩展类

你是一家手机公司的开发者,你被分配了一个构建手机模拟应用程序的任务。该公司制造两种类型的手机——智能手机和标准手机。测试部门希望能够展示他们手机的一些功能,并需要能够在实际设备更新时向这两种手机类型添加更多功能。在查看需求后,你意识到你需要能够模拟两种类型的手机,并且你还希望使更新你的代码变得容易,而无需进行大量的重构和破坏你的手机模型可能使用的其他代码。你还知道这两种手机有很多共同之处——它们都有通过语音和文本数据通信的基本功能。

注意

代码文件可以在这里找到:packt.link/pyqDK

  1. 创建一个作为我们子类基类的Phone类,如下所示:

    class Phone {
    powerButton: boolean;
    mic: boolean;
    speaker: boolean;
    serialNumber: string;
    powerOn: boolean = false;
    restart: boolean = false;
    constructor(
    powerButton: boolean,
    mic: boolean,
    speaker: boolean,
    serialNumber: string,
    ) {
    this.powerButton = powerButton
    this.mic = mic;
    this.speaker = speaker;
    this.serialNumber = serialNumber;
    }
    
    togglePower(): void {
    this.powerOn ? this.powerOn = false : this.powerOn = true
    }
    
    reboot(): void {
    this.restart = true
    }
    }
    

    Phone类是我们将存储所有手机通用元素的地方。这将使我们能够简化子类,只处理它们特有的元素。

  2. 创建一个扩展在步骤 1中创建的基类或父类的Smart类:

    class Smart extends Phone {
    touchScreen: boolean = true;
    fourG: boolean = true;
    constructor(serial: string) {
    super(true, true, true, serial)
    }
    playVideo(fileName: string): boolean {
    return true
    }
    }
    

    Smart子类使我们能够隔离Smart``Phone类的所有方法和属性。

  3. 创建一个扩展在步骤 1中创建的父类的Standard类,如下所示:

    class Dumb extends Phone {
    dialPad: boolean = true;
    threeG: boolean = true;
    constructor(serial: string) {
    super(true, true, true, serial)
    }
    NumberToLetter(number: number): string {
    const letter = ['a', 'b', 'c', 'd']
    return letter[number]
    }
    }
    

    第 2 步和第 3 步处理创建我们的子类,这使我们能够在不出现问题的情况下更新我们的代码,并保持我们的代码整洁和易于维护。因为我们在这个阶段计划得很好,如果我们需要向我们的Smart手机添加功能,我们只需更新一个子类即可。对于Standard手机类也是如此。此外,如果我们需要在两个子类中都使用的方法或属性,我们只需更新Phone父类。通过类继承,我们工作得聪明,而不是辛苦。

  4. 创建我们子类的两个实例并初始化它们:

    const smartPhone = new Smart('12345678')
    const standardPhone = new Standard('67890')
    
  5. 在控制台输出并调用我们类实例的独特方法,以验证我们的子类是否按预期工作:

    console.log(smartPhone.playVideo('videoOne'))
    console.log(standardPhone.NumberToLetter(3))
    

    你将获得以下输出:

    true
    d
    

    如果你回顾SmartStandard类的相应类定义,你将能够确认前面的输出确实是类按预期工作的证据。

  6. 显示子类实例以表明我们拥有父类和子类的所有属性和方法:

    console.log(smartPhone)
    console.log(standardPhone)
    

    你将获得以下输出:

    Smart {
      powerOn: false,
      restart: false,
      powerButton: true,
      mic: true,
      speaker: true,
      serialNumber: '12345678',
      touchScreen: true,
      fourG: true
    }
    Dumb {
      powerOn: false,
      restart: false,
      powerButton: true,
      mic: true,
      speaker: true,
      serialNumber: '67890',
      dialPad: true,
      threeG: true
    }
    

    对于前面的输出,重新查看SmartDumb类的相应类定义应该足以证明在这个练习中应用的继承是正确的。

现在你已经了解了 TypeScript 中继承的工作原理,我们将通过以下活动来测试我们的技能。

活动 5.02:使用继承创建一个原型车辆展厅的 Web 应用程序

你被要求创建一个车辆展厅的 Web 应用程序。你决定使用你在继承方面的新技能来构建出我们将需要的车辆对象类和子类。请注意,展厅有几种类型的车辆。然而,所有这些类型都将有一些共同的属性。例如,所有车辆都有轮子和车身。你可以使用这些信息来构建你的基类。

以下步骤将帮助你完成此活动:

注意

本练习的代码文件可以在此处找到:packt.link/6Xp8H

  1. 创建一个父类,它将包含所有基础车辆共有的方法和属性。定义一个构造函数方法,允许你初始化这个类的基类属性,并添加一个方法,该方法返回你的属性作为一个对象。

  2. 如果需要,可以向希望控制访问权限的属性和类方法添加访问修饰符。

  3. 从父类派生两个子类,例如CarTruck,作为车辆类型。

  4. 重写你的构造函数,根据车辆类型向子类添加一些独特的属性。

  5. 从第 3 步中创建的子类之一派生一个类,例如Suv,它将具有一些卡车可能具有的属性,因此扩展Truck是合理的。

  6. 实例化你的子类,并用数据初始化它们。

  7. 在控制台输出子类实例。

  8. 预期输出如下:

    Car { name: 'blueBird', wheels: 4, bodyType: 'sedan', rideHeight: 14 }
    Truck { name: 'blueBird', wheels: 4, bodyType: 'sedan', offRoad: true }
    Suv {
      name: 'xtrail',
      wheels: 4,
      bodyType: 'box',
      offRoad: true,
      roofRack: true,
      thirdRow: true
    }
    

    注意

    本活动的解决方案可以通过此链接找到。

摘要

在本章中,我们介绍了 TypeScript 中的接口。你学习了接口如何让你围绕对象、类和方法构建契约。你还了解到接口是概述代码实现规则的规则。本章介绍了使用接口如何使代码更容易理解,并且在大型团队中工作时,你和其他开发者都能得到更好的支持。

本章还向你介绍了继承,这是面向对象编程的核心原则之一。你学习了 TypeScript 支持的继承类型以及如何使用继承在代码中构建复杂性,而不会使代码变得更加复杂。本章阐明,将简单结构堆叠以形成更复杂的结构是一种良好的实践,因为它允许你重用代码,每次需要构建类时不必重新发明轮子。这也使得代码支持更好,因为你将只编写所需的代码,并拥有在整个应用程序中保持恒定的公共父类,从而使得错误和漏洞更容易被发现。

你现在对接口和继承有了很好的理解,这两个构建块将在你继续阅读本书以及使用 TypeScript 进行 Web 开发时发挥重要作用。

你在这里学到的概念将使你成为一个更好的开发者,因为你现在有了编写良好支持、干净、无错误的代码的工具。

在下一章中,你将学习高级类型,并了解类型别名、类型字面量、联合类型和交叉类型。

第七章:6. 高级类型

概述

本章将向你介绍高级类型。你将从高级类型的基础——类型别名、字符串和数字字面量开始。这将帮助你更好地理解,当你承担更复杂的概念,如联合类型时。你还将学习如何将类型组合起来构建更复杂的类型,例如交集。使用高级类型,本章将教你如何编写易于你自己和任何与你一起工作或继承项目的人理解的代码。到本章结束时,你将能够通过将原始类型,如字符串、数字和布尔值,与对象相结合来构建高级类型。

引言

在上一章中,我们讨论了接口和继承。你看到了它们如何允许扩展和建模你的类。接口给你的类提供了结构,继承允许你扩展并基于现有代码进行构建。

随着网络应用程序变得越来越复杂,能够对这种复杂性进行建模变得必要,TypeScript 通过高级类型使这一点变得简单。高级类型允许你将作为现代网络开发者将要处理的数据建模为复杂的数据。你将能够从原始类型中创建更复杂的类型,创建出条件性和灵活性的类型。这将使你能够编写易于理解的代码,因此也更容易与之工作。作为一名正在工作的开发者,你可能会遇到一个由 API 提供的需要集成到你的应用程序中的数据集。这些数据集可能很复杂。例如,Google 的 Cloud Firestore 是一个基于文档的实时数据库,可以在对象中嵌套对象。使用高级类型,你可以创建一个与 API 返回的数据完全一致的类型。这将为你提供更多的代码上下文,从而反过来使你和你团队的工作更容易。你还可以通过构建更简单的类型并将它们堆叠起来来构建更复杂的类型。

在本章中,我们将介绍高级类型的基础——类型别名和类型字面量。一旦我们学会了如何构建类型,我们就会继续学习更高级的概念,包括交集、联合和索引类型。所有这些概念都将帮助你学习如何使用高级类型为代码添加上下文和抽象复杂性。

类型别名

类型别名允许你声明对任何类型的引用——无论是高级类型还是原始类型。别名通过允许我们更简洁地表达来使我们的代码更容易阅读。别名允许你,作为开发者,一次性声明你的类型并在整个应用程序中重用它。这使得处理复杂类型变得更容易,并使你的代码更易于阅读和维护。

假设,例如,我们正在开发一个社交网络应用程序,我们需要为用户提供一个管理员用户类型,以便他们可以管理他们创建的页面。此外,我们还需要定义一个网站管理员用户。在基本层面上,他们都是管理员,因此类型之间会有一些共性。使用类型别名,我们可以创建一个如 图 6.1 所示的管理员类型,包含管理员用户将拥有的常见属性,并在创建我们的网站管理员和用户管理员类型时在此基础上构建。别名允许你隐藏代码的复杂性,这将使代码更容易理解。这里有一个将 Admin 别名分配给代表典型管理员共同属性的复杂 type 对象的图示。我们还有一个别名 One 的例子,它被分配给一个类型 number,这是一个原始类型:

图 6.1:别名分配复杂管理员类型别名

图 6.1:别名分配复杂管理员类型别名

考虑以下代码片段:

// primitive type assignment
type One = number;

在前面的例子中,我们创建了一个别名 One,它可以被用作任何数字的类型,因为它被分配为数字类型。

现在,考虑以下代码片段:

// complex (object assignment)
type Admin = {
    username: string,
    email: string,
    userId: string,
    AllowedPages: string   
};

在这里,我们创建了一个 Admin 别名,并将其分配给代表典型管理员共同属性的对象,在这个例子中。正如你所看到的,我们创建了一个对 type 对象的引用,我们现在可以在我们的代码中使用它,而无需每次都实现该对象。

如前图和代码片段所示,类型别名的工作方式与变量赋值类似,除了为原始类型和/或对象创建了一个引用。然后,这个引用可以作为数据模板使用。这将允许你利用强类型语言的所有好处,例如代码补全和数据验证。

在我们进行关于类型别名的第一个练习之前,我们将查看一些原始和复杂赋值的例子。

假设你正在开发一个类方法,该方法只接受数字作为参数。你想要确保当你的方法被使用时,只传递数字作为参数,如果传递了其他类型,则向用户显示正确的错误消息。

首先,我们需要使用以下语法创建一个数字类型别名:

type OnlyNumbers = number;

type 关键字后面跟着别名 OnlyNumbers,然后是 number 类型。

现在我们可以构建一个只接受数字作为参数的方法的类,并使用类型别名来强制执行我们的规则:

// instance of numbers only class
class NumbersOnly {
    count: number
    SetNumber(someNumber: OnlyNumbers) {
        this.count = someNumber
    }
}

现在,让我们实例化我们的类,并将一些参数传递给我们的方法,以查看我们的代码是否工作。

对于这个例子,让我们尝试将字符串作为参数类型分配:

// class instance
const onlyNumbers = new NumbersOnly;
// method with incorrect arguments
onlyNumbers.SetNumber("15");

在前面的代码片段中,我们提供了错误的string类型参数,这将导致警告,因为我们的方法SetNumber期望一个数字。此外,通过为你的类型别名提供有意义的名称,如onlyNumbers,你可以使你的代码更容易阅读和调试。对于这个例子,有问题的代码部分被突出显示,当你悬停在错误上时,你会得到一个非常有用的错误消息,告诉你问题是什么以及如何解决它:

图 6.2:VS Code 中的错误消息

图 6.2:VS Code 中的错误消息

假设你的 IDE 提供了正确的支持,这是这种情况。如果你没有 IDE 支持,你将在代码编译时看到错误消息。

这是一个简单的用例,但随着你的应用程序变得更大,时间过去了一些,或者你在一个大型团队中工作,这种类型安全对于编写无错误的代码至关重要。

让我们考虑另一个例子:假设你正在开发一个在线商店应用程序,你需要使用一个不是由你创建的类。如果创建这个类的人使用了类型并且使用了描述性的名称,那么你使用这段代码会更容易。

现在,让我们用正确的参数类型编辑第一个例子:

// method with correct arguments
onlyNumbers.SetNumber(15);

在前面的代码片段中,我们提供了正确的number类型参数,并且你的类方法没有问题地接收了参数。

现在,让我们考虑一个复杂的别名赋值。

例如,我们想要创建一个新的函数,该函数接受一个用户对象作为类型参数。我们可以将对象作为函数参数内联定义,如下所示:

// function and type definition  
function badCode(user: {
    email: string,
    userName: string,
    token: string, 
    lastLogin: number
}) {}

在前面的代码片段中,代码创建了一个函数,该函数接受一个用户作为参数,但类型是在函数本身中定义的。虽然这可以工作,但假设你在代码的几个地方使用这个对象,那么你将不得不每次都定义这个对象。这非常低效,作为一个好的开发者,你不想重复代码。这种工作方式也会导致错误;它会使你的代码更难处理和更新,因为代码中每个User类型的实例都需要更改。类型别名通过允许你一次性定义你的类型,从而解决了这个问题,我们将在下面的代码片段中演示。

正如我们定义了我们的原始类型一样,我们也定义了我们的User类型。我们使用type关键字,但现在我们将它映射到一个代表我们的User类型的对象模板。现在我们可以使用User别名,而无需每次需要定义User类型时都重新声明该对象:

// object / complex type User
type User = {
    email: string,
    userName: string,
    token: string, 
    lastLogin: number
};

正如你所见,我们创建了一个带有别名User的类型。这允许你在整个代码中只对这个对象类型进行一次引用并重用它。如果我们没有这样做,我们就必须直接引用该类型。

现在,你可以使用你的User类型构建一个新的函数:

// function with type alias
function goodCode(user: User){}

如您所见,此代码更加简洁易懂。所有关于User类型的代码都在一个位置,当对象发生变化时,所有别名都会更新。在接下来的练习中,你将实现我们到目前为止所学的知识来构建你自己的类型别名。

练习 6.01:实现类型别名

在这个练习中,我们将使用我们对类型的了解来构建一个创建产品的函数。例如,假设你正在开发一个购物应用程序,当库存管理员将产品添加到库存中时,你需要将此产品推送到你的产品数组中。这个练习演示了类型别名如何通过允许你一次性定义你的Product模型并在整个代码中重用它来发挥其作用。

现在,在一个实际的库存管理应用程序中,你可能有一个前端页面,允许用户手动输入产品名称和相关信息。为了这个练习的目的,让我们假设你想要添加的产品命名为Product_0Product_5,并且所有产品的价格都是 100,而每种产品添加到库存中的数量是 15。

这可能并不真正反映库存管理应用程序中的实际场景,但请记住,我们的关键目标是使用类型别名。所以现在,一个简单的for循环来完成上述任务就足够了:

注意

本章中所有文件都可以通过在终端中运行npx ts-node filename.ts来执行。本练习的代码文件可以在以下位置找到:packt.link/EAiHb

  1. 打开 VS Code 并创建一个名为Exercise01.ts的新文件。

  2. 创建一个原始类型别名,Count,它属于number类型。Count将用于跟踪产品的数量:

    //primitive type
    type Count = number;
    
  3. 创建一个对象类型别名,Product,它属于type对象。重用Count来定义产品的数量。Product类型别名将用于定义我们添加到库存中的每个产品。属性在所有产品中是通用的:

    // object type 
    type Product = {
        name: string,
        count: Count, //reuse Count
        price: number,
        amount:number,
    }
    
  4. 声明一个products变量,其类型为数组类型的Product

    // product array
    const products_list: Product[] = [];
    

    为了让我们能够使用Product类型,它首先在前面代码中被分配给一个变量,product_list变量是一个Product类型的对象数组。

  5. 创建一个函数,用于向数组中添加产品。重用Product类型别名来验证输入参数:

    // add products to product array function
    function makeProduct(p : Product ) {
        products_list.push(p); // add product to end of array
    }
    
  6. 使用for循环创建Product类型的产品对象并将它们添加到products数组中:

    // use a for loop to create 5 products
    for (let index = 0; index < 5; index++) {
        let p : Product = {
            name: "Product"+"_"+`${index}`,
            count: index,
            price: 100,
            amount: 15
        }//make product
        makeProduct(p);
    }
    console.log(products_list);
    
  7. 通过在正确的目录中执行npx ts-node Exercise01.ts来编译并运行程序。你应该获得以下输出:

     [
      { name: 'Product_0', count: 0, price: 100, amount: 15 },
      { name: 'Product_1', count: 1, price: 100, amount: 15 },
      { name: 'Product_2', count: 2, price: 100, amount: 15 },
      { name: 'Product_3', count: 3, price: 100, amount: 15 },
      { name: 'Product_4', count: 4, price: 100, amount: 15 }
    ]
    

在这个练习中,你创建了两个类型别名,这反过来又创建了对你实际类型的引用。

这允许你减少复杂性并使你的代码更易于阅读,因为现在你可以提供具有额外上下文的名字,例如 Productproducts_list。如果我们不使用别名来编写此代码,在练习中每次使用别名的地方,你都必须直接定义对象或类型。这在这个简单的函数中可能不是什么大问题,但请记住,构建一个类或大型项目你需要多少代码。

随着我们继续深入到更复杂的类型结构,这些知识将变得极其宝贵。在下一节中,当我们介绍类型字面量时,我们将继续构建我们的知识。

类型字面量

类型字面量允许你基于特定的字符串或数字创建一个类型。这本身可能不是非常有用,但随着我们继续到更复杂类型,如联合类型,它们的使用将变得明显。字面量很简单,所以我们不会花太多时间在它们上面,但当你进入下一阶段时,你需要理解字面量的概念。

让我们先创建我们的字符串和数字字面量。

我们将从一个字符串字面量开始:

Example01.ts

1 // string literal  
2 type Yes = "yes";
Link to the preceding example: https://packt.link/96IlD. 

之前的代码创建了一个 Yes 类型,它只接受特定的字符串 "yes" 作为输入。

同样,我们可以创建一个数字字面量:

3 // number literal
4 type One = 1;

在这里,我们创建了一个数字字面量类型 One,它只接受 1 作为输入。

在前一个示例中观察到的基本语法相当简单。我们以 type 关键字开始,然后是我们的新字面量的名称(别名),接着是字面量本身,如前一个语法所示。我们现在有了 yes 字符串和数字 1 的类型。

接下来,我们将构建一个将利用我们新类型的函数:

5 // process my literal 
6 function yesOne(yes: Yes, one: One ) {
7     console.log(yes, one);
8 }

我们已经将函数参数转换为我们的字面量类型,并且由于我们的类型是字面量,只有 "yes" 字符串或数字 1 将被接受作为参数。我们的函数不会接受其他参数。假设我们传递了 ""2 作为参数(yesOne("", 2))。你会在 VS Code 中注意到以下警告:

图 6.3:传递错误参数时 IDE 显示的警告

图 6.3:传递错误参数时 IDE 显示的警告

现在,假设我们传递了 "yes"2 作为参数。再次,你会得到以下警告:

图 6.4:传递无法分配的参数时显示的错误

图 6.4:传递无法分配的参数时显示的错误

以下是一些你可能期望的错误消息示例,如果你提供了错误的参数。错误消息很清晰,并确切地告诉你如何解决错误。正如你所看到的,尽管我们传递了一个字符串和一个数字,我们仍然得到了类型错误。这是因为这些参数是字面量;它们只能与自身完全匹配。

现在,让我们尝试传递正确的参数:

9 // function with the correct arguments 
10 yesOne("yes", 1);

一旦提供了正确的参数,函数就可以无任何问题地调用,如下所示输出:

yes 1

在我们继续介绍交集类型之前,让我们快速完成一个简单的练习,以巩固我们对字符串和数字字面量的知识。

练习 6.02:类型字面量

现在我们对字面量有了更好的理解,让我们通过一个小练习来巩固我们所学的内容。在这里,我们将创建一个函数,该函数接受一个字符串字面量并返回一个数字字面量:

注意

本练习的代码文件可以在此处找到:packt.link/hHgNa

  1. 打开 VS Code 并创建一个名为Exercise02.ts的新文件。

  2. 创建一个字符串字面量类型No,并将其值设置为字符串"no"。同时,创建一个数字字面量并将其值设置为 0:

    type No = "no"
    type Zero = 0
    
  3. 编写一个函数,该函数接受"No"字面量并将其打印到控制台:

    function onlyNo(no: No):Zero {
        return 0;
    }
    
  4. 输出函数调用的结果:

    console.log(
        onlyNo("no")
    )
    

    这将产生以下输出:

    0
    

字面量本身并不是非常有用,但与更复杂的类型结合使用时,它们的有用性将变得明显。目前,你需要了解如何创建字面量,这样你就可以在本书的后面部分使用它们。在下一节中,我们将继续介绍交集类型。到目前为止我们所完成的所有工作都将有助于我们使用类型别名和字面量。

交集类型

交集类型允许你结合类型以形成一个新类型,该类型具有组合类型的属性。这在以下情况下很有用:你有一个现有的类型,它本身并不能定义你需要的一些数据,但它可以与另一个现有类型结合使用。这类似于多类继承,因为子对象可以有一个或多个父对象,从中继承其属性。

假设你有一个名为A的类型,它具有姓名和年龄属性。你还有一个名为B的类型,它具有身高和体重属性。在你的应用程序中,你发现需要一个人员类型:你想要跟踪用户的姓名、年龄、身高和体重。你可以通过交集类型AB来形成一个Person类型。你可能会问,为什么不直接创建一个新的类型呢?好吧,这让我们回到了想要成为优秀的程序员和优秀的程序员保持 DRY(不要重复自己)的原则。除非一个类型在你的应用程序中真正独特,否则你应该尽可能重用代码。此外,还有集中化。

如果你需要修改Person类型的任何类型代码,你只需在AB中做出更改即可。这也有一点限制,因为可能存在类型A被多个对象使用的情况,如果你做出更改,它将破坏应用程序。使用交集,你可以简单地创建一个带有更改的C类型,并更新你的Person类型。你还可以合并具有共同属性的类型。

考虑这样一种情况,你有一个 name 属性在 A 中,也在 B 中。当类型相交时,你现在将只有一个 name 属性;然而,合并的属性不仅要在名称上相同,还应该是同一类型,否则类型将无法合并,并导致错误。

如果这还不清楚,让我们看看一个属性,age。这可以在一个类型中是数字,在另一个类型中是字符串。唯一能够相交这些类型的方法是使属性通用,因为它们都需要是字符串或数字。

想象一下,作为一个电子商务项目的一部分,你需要构建一个购物车对象,该对象从 Product 对象和 Order 对象中继承属性。

下面的图显示了每个对象的基本属性以及使用 ProductOrder 对象形成的新 Cart 对象的属性:

![图 6.5:显示购物车对象属性的图]

img/B14508_06_05.jpg

图 6.5:显示购物车对象属性的图

在图中,我们有父对象 ProductOrder,它们组合形成一个具有其父对象所有属性的子对象 Cart。请注意,我们可以有超过两个父对象在交集中,但为了解释的方便,我们将坚持两个,这样你可以更快地掌握这个概念。在接下来的示例中,我们将通过代码创建我们的新 Cart 类型,并展示一个基本的使用案例。

想象你正在开发购物应用程序。你需要创建一个对象来建模你将推送到购物车的产品数据。我们已经有了一个用于产品数据的 Product 类型。Product 类型包含了我们在网页上显示产品正确信息所需的大部分内容。然而,我们在结账时缺少一些必需的属性。我们将通过不创建新的产品类型来解决这个问题,而是创建一个只包含我们需要的属性的 Order 类型:orderIdamountdiscount,其中 discount 是可选的,因为它并不总是适用。

下面是声明 Product 类型的代码:

Example02.ts
1 // product type
2 type Product = {
3     name: string, 
4     price: number,
5     description: string
6 } 
7 
8 // order type
9 type Order = {
10     orderId: string,
11     amount: number,
12     discount?: number 
13 }
Link to the preceding example: https://packt.link/DZ7Iz

在前面的代码片段中,我们创建了父类型名称 ProductOrder。现在我们需要将它们合并。这将创建我们需要的类型来建模购物车数据:

14 // Alias Cart of Product intersect Order
15 type Cart = Product & Order;

我们通过将别名 Cart 分配给我们的 ProductOrder 类型,并在两个类型之间使用 &,如前面的代码片段所示来构建我们的购物车对象。我们现在有一个新的合并类型 Cart,我们可以用它来建模购物车数据:

16 // cart of type Cart
17 const cart: Cart = {
18     name: "Mango",
19     price: 400,
20     orderId: "x123456",
21     amount: 4,
22     description: "big sweet, full of sugar !!!" 
23 }

前面的例子展示了使用 Cart 类型声明的购物车对象。正如你所见,我们可以访问所有属性,并且可以省略那些可能不总是适用的可选属性,例如 discount

如果我们没有提供所有必需的属性,IDE 会给出一个非常有用的错误信息,告诉我们如何修复问题:

图 6.6:缺少必需属性时显示的错误信息

图 6.6:缺少必需属性时显示的错误信息

现在,让我们在控制台中输出我们的新购物车对象:这将显示以下输出:

{
  name: 'Mango',
  price: 400,
  orderId: 'x123456',
  amount: 4,
  description: 'big, sweet, and full of sugar !!!'
}

在下一节中,你将通过执行一个练习来获得创建交集类型的一些实际经验,在这个练习中,你将构建一个原型用户管理系统。

练习 6.03:创建交集类型

你正在开发一个电子商务应用程序;你被分配了构建用户管理系统的任务。在应用程序需求中,客户列出了他们期望将与系统交互的用户配置文件类型。你将使用类型交集来构建你的用户类型。这将允许你构建可以组合成更复杂类型并分离关注点的简单类型。这将导致更不易出错且支持更好的代码。在此,我们命名我们将构建的用户类型,并提供它们功能的概述:

  • _idemailtoken

  • accessPageslastLoginaccessPages 是一个字符串数组,表示此用户可以访问的页面,而 lastLogin 将帮助我们记录管理员用户的激活。

  • lastBackUpbackUpLocationlastBackUp 将告诉我们系统上次备份的时间,而 backUpLocation 将告诉我们备份文件存储的位置。

  • 超级用户:此用户是管理员和用户类型的交集。所有用户都需要基本用户的属性,但只有管理员用户需要管理员属性。在此,我们使用类型交集来构建我们需要的必要属性。

  • Backup 用户类型和 Basic 用户类型。再次,我们可以将此用户类型所需的必要复杂性纳入我们的基本用户中,以便其能够正常工作。

    注意

    本练习的代码文件可以在此处找到:packt.link/FVvj5

  1. 打开 VS Code 并创建一个名为 Exercise03.ts 的新文件。

  2. 创建一个基本的 User 类型:

    // create user object type
    type User = {
        _id: number;
        email: string;
        token: string;
    }
    

    这将是我们将用作应用程序中其他用户类型基础的类型。因此,它具有所有用户都将需要的常见用户属性。

  3. 为需要执行管理员功能的用户创建一个 Admin 用户类型:

    // create an admin object type
    type Admin = {
        accessPages: string[],
        lastLogin: Date
    }
    
  4. 为负责备份应用程序数据的用户创建一个 Backup 用户类型:

    // create backupUser object type
    type Backup = {
        lastBackUp: Date,
        backUpLocation: string
    }
    
  5. 使用你的 UserAdmin 类型,在 Admin 交集处声明一个 User 类型的 superuser 对象。添加所需的属性。为了创建超级用户,你必须为 UserAdmin 的属性提供值,如下面的代码块所示:

    // combine user and admin to create the user object
    const superUser: User & Admin = {
        _id: 1,
        email: 'rayon.hunte@gmail.com',
        token: '12345',
        accessPages: [
            'profile', 'adminConsole', 'userReset'
        ],
        lastLogin: new Date() 
    }; 
    

    在实际应用程序中,此代码可能位于登录函数中,返回的值可能来自登录时的 API。

  6. 通过将别名 BackUpUser 分配给 UserBackup 的交集来构建 BackUpUser 类型:

    // create BackUpUser type
    type BackUpUser = User & Backup
    
  7. 声明一个 backUpUser 对象,其类型为 BackUpUser 并添加必要的属性:

    // create backup user
    const backUpUser: BackUpUser = {
        _id: 2,
        email: 'rayon.backup@gmail.com',
        token: '123456',
        lastBackUp: new Date(),
        backUpLocation: '~/backup'
    };
    
  8. 在控制台输出你的 superUserbackupUser 对象:

    // console out superUser props
    console.log(superUser);
    // console out backup user props
    console.log(backUpUser);
    

    这将打印以下输出:

    {
      _id: 1,
      email: 'rayon.hunte@gmail.com',
      token: '12345',
      accessPages: [ 'profile', 'adminConsole', 'userReset' ],
      lastLogin: 2021-02-25T07:27:57.009Z
    }
    {
      _id: 2,
      email: 'rayon.backup@gmail.com',
      token: '123456',
      lastBackUp: 2021-02-25T07:27:57.009Z,
      backUpLocation: '~/backup'
    }
    

在前面的练习中,你使用了基于 UserAdminBackup 类型的 superUserbackupUser 交集来构建两个用户类型。使用交集允许你保持核心用户类型简单,因此可以用作大多数用户数据的模型。只有当需要建模特定用户情况时,AdminBackup 才与 User 交集。这是关注点的分离。现在,对 UserBackupAdmin 所做的任何更改都将反映在所有子类型中。现在,我们将探讨联合类型,这是一种类型功能。然而,与交集不同,当类型合并时,联合类型提供 OR 功能。

联合类型

使用 or 类型功能而不是 and 类型功能,这在交集类型中是常见的情况。这类似于 JavaScript 中的三元运算符,其中你正在组合的类型由 | 管道分隔。如果这让你感到困惑,随着我们继续到示例,一切都会变得清晰。我们还将探讨类型守卫,这是一个在应用联合类型中将发挥重要作用的模式。首先,考虑以下联合类型的视觉表示:

![图 6.7:联合类型赋值的说明

![img/B14508_06_07.jpg]

图 6.7:联合类型赋值的说明

在前面的图中,我们有一个联合类型赋值的基本图,其中 Age 可以是 numberstring 数据类型。你可以有超过两个选项的联合类型和非原始类型。这为你提供了编写更动态代码的选项。在接下来的示例中,我们将扩展之前提到的年龄示例,并构建一个基本的联合类型。

假设你正在开发一个需要验证某人年龄的应用程序。你想要编写一个函数,该函数将处理存储在数据库中作为数字的年龄和作为字符串从前端传入的年龄。在这种情况下,你可能会倾向于使用 any 作为类型。然而,联合类型允许我们通过不使用 any 来创建错误向量来处理这种场景:

Example03.ts
1 // basic union type
2 type Age =  number | string;
Link to the preceding example: https://packt.link/EHziL.

首先,我们创建一个联合类型 Age,它可以具有 numberstring 数据类型,如前面的语法所示。我们将我们的 Age 别名分配给由管道 | 分隔的类型。我们可以有超过两个选项,例如 "number" | "string" | "object"

现在我们创建一个函数,它将使用前面代码片段中所示的新类型 Age

3  function myAge(age: Age): Age {
4      if (typeof age === "number") {
5          return `my age is ${age} and this a number`;
6      } else if (typeof age === "string"){
7          return `my age is ${age} and this a string`;
8      } else {
9          return `incorrect type" ${typeof(age)}`;
10     }
11 }

myAge 函数接受 Age 类型作为参数,并使用 if …else 循环返回一个格式化的 Age 类型字符串。我们还在使用类型守卫模式,typeof,它允许你检查你的参数类型。这种类型检查在使用联合类型作为参数时是必要的,因为你的参数可以是几种类型之一,在这个前面的代码片段中,是字符串或数字。每种类型都需要用不同的逻辑进行处理。

联合类型也可以是对象;然而,在这种情况下,typeof 将不会非常有用,因为它只会返回类型,而这个类型始终是 object。为了解决这类情况,你可以检查你的对象是否有任何独特的属性,并以此方式应用你的逻辑。随着我们在下一节中完成练习,我们将看到这方面的示例。

现在,让我们回到示例。为了确保我们的函数按预期工作,我们通过调用它们并使用不同的参数类型(数字和字符串)来控制台输出结果:

console.log(myAge(45));
console.log(myAge("45"));

这将导致以下输出:

my age is 45 and this a number
my age is 45 and this a string

假设你传递了一个错误的参数:

console.log(myAge(false));

你将看到以下错误信息:

error TS2345: Argument of type 'boolean' is not assignable to parameter of type 'Age'.

练习 6.04:使用 API 更新产品库存

在以下练习中,我们将通过添加 API 扩展我们的库存管理示例 Exercise 03。这将允许远程用户通过 API PUTPOST 请求添加和更新我们的库存。

由于更新和添加产品的过程非常相似,我们将编写一个方法来处理这两个请求,并使用联合类型允许我们的方法接受这两种类型并保持类型安全。这也意味着我们可以编写更少的代码,并将所有相关代码封装到一个方法中,这将使我们或任何其他正在开发应用程序的开发者更容易找到和解决错误。

你可以使用 any 类型,但这样你的代码就会变得类型不安全,这可能导致错误和不稳定的代码:

注意

这个练习的代码文件可以在以下位置找到:packt.link/Qvx6D

  1. 打开 VS Code 并创建一个名为 Exercise04.ts 的新文件。

  2. 创建三个类型,ProductPostPut,以及你将需要的基对象,如下所示:

    type Product = {
        name: string,
        price: number,
        amount: number,
    }
    type Post = {
        header: string,
        method: string,
        product: Product
    }
    type Put = {
        header: string,
        method: string,
        product: Product,
        productId: number
    }
    

    我们首先创建一个产品类型,这将帮助我们定义在 PutPost 请求中产品数据将采取的格式。我们还定义了 PutPost,它们略有不同,因为 Put 请求需要更新已存在的记录。请注意,PutproductId 属性。

  3. 创建一个联合类型 SomeRequest,它可以是要么 Put 类型,要么 Post 类型:

    type SomeRequest =  Post | Put
    

    与联合类型匹配的数据可以是联合中的任何类型。请注意,联合类型并不组合类型;它们只是尝试将数据匹配到联合中的某个类型,这为你,开发者,提供了更多的灵活性。

  4. 创建一个 Product 类型的数组实例:

    const products: Product[] = [];
    
  5. 构建一个处理函数,该函数处理SomeRequest类型的请求:

    function ProcessRequest(request: SomeRequest ) {
        if ("productId" in request) { products.forEach(
                (p: Product, i: number) => {
                   products[request.productId] = {
                       ...request.product
                   };});        
        } else {
            products.push(request.product);
        }}
    

    此函数将接收PutPost类型的请求,并将附加的产品添加或更新到products数组中。为了知道它应该更新还是添加,它首先检查产品是否有productId参数。如果有,我们将遍历Products数组,直到找到匹配的productId参数。然后,我们使用扩展运算符使用请求中的数据更新产品数据。如果产品没有productId参数,我们则只需使用数组附加的push函数将新产品添加到数组中。

  6. 声明applemango对象为Product类型,如下所示:

    const apple: Product = {
        name: "apple",
        price: 12345,
        amount: 10
    }; 
    const mango: Product = {
        name: "mango",
        price: 66666,
        amount: 15
    };
    

    在真实的 API 中,数据将由用户通过请求发送提供,但为了本练习的目的,我们为您硬编码了一些数据来操作。

  7. 声明postAppleRequestputMangoRequest对象为PostPut类型:

    const postAppleRequest : Post = {
        header: "zzzzz",
        method: 'new',
        product: apple,
    };
    const putMangoRequest : Put = {
        header:"ggggg",
        method: 'update',
        product: mango,
        productId: 2
    };
    

    在前面的代码中,我们已定义我们的POSTPUT对象。我们将产品对象作为请求的有效负载附加。请记住,函数不是检查产品对象,而是检查请求类型,这将告诉函数它是POST还是PUT

  8. 调用处理函数,并传递postAppleRequestputMangoRequest作为参数,如下面的代码片段所示:

    ProcessRequest(postAppleRequest);
    ProcessRequest(putMangoRequest);
    

    在正常的 API 中,当用户发起PUTPOST请求时,会调用ProcessRequest方法。然而,我们只是在模拟 API 并自行进行调用。

  9. 输出控制台结果:

    console.log(products)
    

    您将看到以下输出:

    [
      { name: 'apple', price: 12345, amount: 10 },
      <1 empty item>,
      { name: 'mango', price: 66666, amount: 15 }
    ]
    

    在前面的输出中,我们现在可以看到传递给我们的方法的产品。这意味着我们使用联合类型编写的模拟 API 代码按预期工作。

联合类型,例如交集类型,在构建应用程序时为开发者提供了更多的功能性和灵活性。在前面的练习中,我们能够编写一个函数,该函数接受两种不同类型的单个参数,并根据类型检查模式或类型守卫应用逻辑。在下一节中,我们将继续探讨更多代码灵活性的主题,即索引类型。

索引类型

索引类型允许我们创建具有关于它们可能持有的属性数量灵活性的对象。假设您有一个定义错误消息的类型,该消息可以超过一个类型,并且您希望随着时间的推移添加更多类型的消息的灵活性。由于对象具有固定数量的属性,因此每当有新的消息类型时,我们都需要修改我们的消息代码。索引类型允许您使用接口定义您的类型签名,这为您提供了具有灵活数量的属性的能力。在下面的示例中,我们将在代码中扩展这一点:

Example04.ts
1 interface ErrorMessage  {
2    // can only be string | number | symbol
3    [msg: number ]: string;
4     // you can add other properties once they are of the same type
5     apiId: number
6 }
Link to the preceding example: https://packt.link/IqpWH

首先,我们创建我们的类型签名,如前文代码片段所示。这里有一个属性名和类型,即索引 [msg: number] 后跟值类型。msg 参数的名称可以是任何名称,但作为一个优秀的程序员,你应该提供一个在类型上下文中有意义的名称。请注意,你的索引只能是一个数字、字符串或符号。

你也可以向你的索引添加其他属性,但它们必须与索引具有相同的类型,如前文代码片段所示,apiId: number。接下来,我们通过类型转换使用你的类型,现在我们可以拥有一个具有所需属性数量的错误信息对象。随着消息列表的增长,我们不需要修改类型。我们保持灵活性,同时保持代码的类型化,这使得代码易于扩展和支持:

7  // message object of Index type ErrorMessage
8  const errorMessage: ErrorMessage  = {
9       0: "system error",
10      1: "overload",
11      apiId: 12345
12 };

现在,我们控制台输出新对象,以确保一切正常:

// console out object
console.log(
    errorMessage
);

运行文件后,你将得到以下输出:

{ '0': 'system error', '1': 'overload', apiId: 12345 }

如果我们尝试给一个属性名赋予错误类型,例如字符串,我们会得到你可能期望的错误信息:

图 6.8:显示类型错误的输出

图 6.8:显示类型错误的输出

然而,你可以使用数字字符串,例如,代码将像以前一样运行,输出也将相同:

14 // message object of Index type ErrorMessage
15 const errorMessage: ErrorMessage  = {
16    '0': "system error",
17     1: "overload",
18    apiId: 12345 };

你可能会认为这不会工作,因为值是字符串,但它会被转换为数字字面量。使用数字字面量转换为字符串的方式也可以工作。接下来在我们的练习中,我们将模拟索引类型的实际应用,构建一个简单的系统来处理错误信息。

练习 6.05:显示错误信息

在这个练习中,我们将构建一个系统来处理错误信息。我们还将重用我们在示例中创建的 ErrorMessage 索引类型。这个练习中的代码有些人为设计,但将有助于你更好地理解索引类型:

注意

这个练习的代码文件可以在以下位置找到:packt.link/ZkApY

  1. 打开 VS Code 并创建一个名为 Exercise05.ts 的新文件。

  2. 如果你还没有创建,请从我们的示例中创建 ErrorMessage 类型接口:

    interface ErrorMessage  {
        // can only be string | number | symbol
        [msg: number ]: string;
        // you can add other properties once they are of the same type
        apiId: number
    }
    
  3. 根据以下示例,构建一个 errorCodes 对象,其类型为 ErrorMessage

    const errorMessage : ErrorMessage = {
        400:"bad request",
        401:"unauthorized",
        403:"forbidden",  apiId: 123456,
     };
    
  4. 根据以下示例创建一个错误代码数组 errorCodes

    const errorCodes: number [] = [
        400,401,403
     ];
    
  5. 遍历 errorCodes 数组并在控制台输出错误信息:

    errorCodes.forEach(
        (code: number) =>  {
            console.log(
                errorMessage[code]
            );
        }
    );
    

    运行文件后,你将获得以下输出:

    bad request
    unauthorized
    forbidden
    

索引类型允许你在类型定义中具有灵活性,如前文练习所示。如果你需要添加新的代码,你不需要更改你的类型定义;只需将新的代码属性添加到你的 errorCode 对象中。索引类型在这里之所以有效,是因为尽管对象的属性不同,但它们都具有相同的基本结构——一个数字属性(键)后跟一个字符串值。

现在你有了高级类型的构建块,你可以完成以下活动。这些活动将利用你在本章中学到的所有技能。

活动六.01:交集类型

想象你是一名正在为定制卡车网站开发卡车构建功能的开发者。你需要让来到网站的客户能够构建各种卡车类型。为此,你需要通过组合两个类型MotorTruck来构建自己的交集类型PickUptruck。然后你可以使用你的新类型PickUpTruck,该函数返回类型并使用PickUpTruck交集类型验证其输入。

注意

此活动的代码文件可以在此处找到:packt.link/n4tfL

以下是一些帮助你完成此活动的步骤:

  1. 创建一个Motor类型,它将包含一些你可能单独或与其他类型组合使用的通用属性,用于描述车辆对象。你可以使用以下属性作为起点:颜色车门车轮四驱

  2. 创建一个具有卡车通用属性的Truck类型,例如双排座绞盘

  3. 将两个类型相交以创建一个PickUpTruck类型。

  4. 构建一个返回我们的PickUpTruck类型并接受PickUpTruck作为参数的TruckBuilder函数。

  5. 在控制台输出函数返回值。

  6. 一旦完成活动,你应该获得以下输出:

    {
      color: 'red',
      doors: 4,
      doubleCab: true,
      wheels: 4,
      fourWheelDrive: true,
      winch: true
    }
    

    注意

    此活动的解决方案通过此链接提供。

活动六.02:联合类型

一家物流公司要求你在他们的网站上开发一个功能,允许客户选择他们希望包裹如何运输的方式——通过陆路或空运。你决定使用联合类型来实现这一点。你可以创建自己的联合类型,称为ComboPack,它可以是指LandPackAirPack类型。你可以为你的包裹类型添加你认为将通用的任何属性。同时,考虑使用一个类型字面量来识别你的包裹是空运还是陆运,以及一个可选的标签属性。然后你需要构建一个类来处理你的包裹。你的类应该有一个方法来识别你的包裹类型,该方法接受ComboPack类型的参数,并使用你的字面量属性来识别包裹类型并添加正确的标签,空运货物陆运货物

注意

此活动的代码文件可以在此处找到:packt.link/GQ2ZS

以下是一些帮助你完成此活动的步骤:

  1. 构建LandPackAirPack类型。确保有一个字面量来识别包裹类型。

  2. 构建一个联合类型ComboPack,可以是LandPackAirPack

  3. 创建一个Shipping类来处理你的包裹。确保使用你的字面量来识别你的包裹类型,并使用正确的标签修改你的包裹。

  4. 创建两个AirPackLandPack类型的包裹对象。

  5. 实例化你的Shipping类,处理你的新对象,并在控制台输出修改后的对象。

    注意

    此活动的解决方案通过此链接展示。

活动六.03:索引类型

既然你已经很好地将运输选项整合到网站中,公司现在需要你添加一个功能,允许他们的客户跟踪其包裹的状态。对于客户来说,他们有权限随着公司的发展以及运输方式的变化添加新的包裹状态非常重要,他们希望有这种灵活性。

因此,你决定构建一个索引类型,PackageStatus,使用status属性的接口签名,其类型为string,值为Boolean类型。然后,你将构建一个包含一些常见包裹属性的Package类型。你还将包括一个packageStatus属性,其类型为PackageStatus。你将使用PackageStatus来跟踪你的包裹的三个状态:shippedpackeddelivered,设置为truefalse。然后,你将构建一个类,该类在初始化时接受Package类型的对象,包含一个返回status属性的方法,以及一个更新并返回status属性的方法,该方法接受status作为字符串和Boolean作为状态。

更新你的包的方法也应该返回你的packageStatus属性。

注意

此活动的代码文件可以在此处找到:packt.link/2LwHq

以下是一些帮助你完成此活动的步骤:

  1. 使用具有status属性为string类型和Boolean类型值的接口,构建你的PackageStatus索引类型。

  2. 创建一个包含PackageStatus类型属性和一些典型包裹常见属性的Package类型。

  3. 创建一个类来处理你的Package类型,该类在初始化时接受Package类型,有一个方法返回你的packageStatus属性,以及一个更新并返回packageStatus属性的方法。

  4. 创建一个名为packPackage对象。

  5. 使用你的新pack对象实例化PackageProcess类。

  6. 在控制台输出你的pack状态。

  7. 更新你的pack状态,并在控制台输出你的新pack状态。

    预期的输出如下:

    { shipped: false, packed: true, delivered: true }
    { shipped: true, packed: true, delivered: true }
    

    注意

    此活动的解决方案可以通过此链接找到。

摘要

在本章中,我们介绍了高级类型,这些类型允许您超越基本类型。随着应用变得更加复杂,前端承担更多功能,您的数据模型也将变得更加复杂。本章向您展示了 TypeScript 高级类型如何让您能够实现强类型,这将帮助您开发更干净、更可靠的应用程序。我们涵盖了高级类型的构建块——类型别名和字面量,然后通过一些实际示例、练习和活动,我们继续探讨了交集、联合和索引类型。

您现在拥有了创建复杂类型的能力,这将使您能够构建适用于现代应用的类型,并编写得到良好支持和可扩展的代码。达到这一点后,您现在拥有了应对像 Angular2 和 React 这样的 Web 框架的工具。您甚至可以在 Node.js 服务器端使用 TypeScript。高级类型还有很多内容,这个主题在实现上相当广泛、复杂和抽象。然而,在本章中,您已经获得了开始使用高级类型构建应用程序所需的所有技能。

第八章:7. 装饰器

概述

本章首先确立了装饰器的动机,然后描述了 TypeScript 中可用的各种装饰器类型。我们将探讨装饰器的使用方法和如何根据特定需求进行定制。我们还将涵盖编写自己的装饰器。到本章结束时,你将能够使用装饰器来改变代码的行为,并使用装饰器工厂来定制正在使用的装饰器。你还将学习如何创建自己的装饰器,以便在代码中使用或供他人使用。

简介

在前面的章节中,你看到了如何创建类型和类,以及如何使用接口、继承和组合将它们组合成一个合适的类层次结构。

使用 TypeScript 类型系统,你可以创建一些非常优雅的应用程序领域模型。然而,模型并非孤立存在;它们是更大图景的一部分——它们是应用程序的一部分。类需要意识到它们生活在一个更大的世界中,与许多其他系统部分并行运行,并且关注点超出了特定类的范围。

为了处理上述场景,向类添加行为或修改类并不总是容易。这正是装饰器大显身手的地方。装饰器是一些特殊的声明,可以添加到类声明、方法和参数中。

在本章中,我们将学习如何使用一种称为装饰器的技术,透明地给类添加复杂且常见的功能,而不会让你的应用程序逻辑因为额外的代码而变得混乱。

装饰器是 TypeScript 中可用且广泛使用的功能之一,但在 JavaScript 中不可用。JavaScript 中有一个装饰器的提案(github.com/tc39/proposal-decorators),但它还不是标准的一部分。你将在 TypeScript 中使用的装饰器与该提案非常相似,功能上几乎一样。

TypeScript 方法有其优点和缺点。一个优点是,一旦装饰器成为 JavaScript 的标准功能,你就可以无缝地将你的装饰技能转移到 JavaScript 上,TypeScript 编译器(tsc)生成的代码将更加符合 JavaScript 习惯用法。坏处是,直到它成为标准功能,提案可能会改变。这就是为什么默认情况下,编译器中关闭了装饰器的使用,并且为了使用它们,你需要传递一个标志,无论是作为命令行选项还是作为 tsconfig.json 的一部分。然而,在你深入了解如何实现之前,你首先需要理解反射的概念,这将在下一节中探讨。

反射

装饰代码的概念与一个称为反射的概念紧密相连。简而言之,反射是指某段代码能够检查和反思自身的能力——在某种意义上,进行自我审视。这意味着一段代码可以访问其内部定义的变量、函数和类。大多数语言都为我们提供了一些反射 API,使我们能够将代码本身视为数据,由于 TypeScript 建立在 JavaScript 之上,因此它继承了 JavaScript 的反射能力。

JavaScript 没有广泛的反射 API,但有一个提议(tc39.es/ecma262/#sec-reflection)要将适当的元数据(关于数据的数据)支持添加到语言中。

设置编译器选项

TypeScript 的装饰器使用了上述提议的功能,为了使用它们,你必须相应地启用 TypeScript 编译器(tsc)。如前言所述,有两种方法可以做到这一点。你可以在调用tsc时在命令行上添加必要的标志,或者你可以在tsconfig.json文件中配置必要的选项。

有两个与装饰器相关的标志。第一个是experimentalDecorators,这是使用装饰器所必需的。如果你有一个使用装饰器的文件,并且尝试在不指定它的情况下编译,你会得到以下错误:

tsc --target es2015 .\decorator-example.ts
decorator-example.ts:18:5 – error TS1219: 
  Experimental support for decorators is a feature 
  that is subject to change in a future release. 
  Set the 'experimentalDecorators' option in your 'tsconfig' or 
  'jsconfig' to remove this warning.

如果你指定了标志,你可以成功编译:

tsc --experimentalDecorators --target es2015 
  .\decorator-example.ts

为了避免每次都指定标志,请在tsconfig.json文件中添加以下标志:

{
  "compilerOptions": {
    "target": "ES2015",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
  }
}

注意

在你开始执行示例、练习和活动之前,我们建议你确保前面的编译器选项已经在你的tsconfig.json文件中启用。或者,你可以使用这里提供的文件:packt.link/hoeVy

装饰器的重要性

因此,现在你已经准备好开始装饰了。但为什么你想这样做呢?让我们通过一个简单的例子来模拟你将在以后遇到的真实场景。假设你正在构建一个简单的类,该类将封装篮球比赛的得分:

Example_Basketball.ts
1  interface Team {
2      score: number;
3      name: string;
4  }
5  
6  class BasketBallGame {
7      private team1: Team;
8      private team2: Team;
9  
10     constructor(teamName1: string, teamName2: string) {
11         this.team1 = { score: 0, name: teamName1 };
12         this.team2 = { score: 0, name: teamName2 };
13     }
14  
15     getScore() {
16         return `${this.team1.score}:${this.team2.score}`;
17     }
18 }
19 
20 const game = new BasketBallGame("LA Lakers", "Boston Celtics");
Link to the preceding example: https://packt.link/ORdNl.

我们的类有两个团队,每个团队都有一个名称和数值得分。你在类构造函数中初始化你的团队,并且有一个提供当前得分的方法。然而,你没有更新得分的方法。让我们添加一个:

updateScore(byPoints: number, updateTeam1: boolean) {
    if (updateTeam1) {
        this.team1.score += byPoints;
    } else {
        this.team2.score += byPoints;
    }
}

此方法接受要添加的点数和一个布尔值。如果布尔值为true,你正在更新第一支球队的得分,如果为false,你正在更新第二支球队的得分。你可以尝试运行你的类,如下所示:

const game = new BasketBallGame("LA Lakers", "Boston Celtics");
game.updateScore(3, true);
game.updateScore(2, false);
game.updateScore(2, true);
game.updateScore(2, false);
game.updateScore(2, false);
game.updateScore(2, true);
game.updateScore(2, false);
console.log(game.getScore());

这段代码将向我们展示湖人队在对阵凯尔特人的比赛中以7:8失利(如果有人想知道,这是 2010 年总决赛的第 7 场比赛)。

横切关注点问题

到目前为止,一切顺利,你的类在自身功能方面完全可用。然而,由于你的类将存在于整个应用程序中,你还有其他一些担忧。其中之一就是授权——是否任何人都能够更新分数?当然不是,因为常见的用例是只有一个被允许更新分数的人,而可能有成千上万的人只是观看分数的变化。

让我们使用一个假设的函数isAuthorized来添加这个担忧,该函数将检查当前用户是否实际上被授权更改分数。你将调用这个函数,如果它返回true,我们将继续执行方法的常规逻辑。如果返回false,那么我们只需发出适当的消息。代码将如下所示:

updateScore(byPoints: number, updateTeam1: boolean) {
    if (isAuthorized()) {
        if (updateTeam1) {
            this.team1.score += byPoints;
        } else {
            this.team2.score += byPoints;
        }
    } else {
        console.log("You're not authorized to change the score");
    }
}

再次强调,这将很好地工作,尽管它将你的方法代码行数从五行增加到九行,并增加了一些复杂性。坦白说,增加的行与计分无关,但它们必须被添加以支持授权。

那么,这就结束了?当然不是。即使你知道某人被授权,这也并不意味着你的操作员可以随时更新分数。审计员需要详细的信息,包括何时以及使用什么参数调用updateScore方法。没问题,我们可以使用一个假设的函数audit来添加这些信息。你还需要添加一些验证,以确保byPoints参数是一个合法的值(在篮球中,你只能有 1 分、2 分或 3 分的加成)。你还可以添加一些代码来记录方法执行的性能,以便追踪执行所需的时间。因此,你那清晰、简洁的五行方法将变成一个 17 行的怪物:

updateScore(byPoints: number, updateTeam1: boolean) {
    audit("updateScore", byPoints, updateTeam1);
    const start = Date.now();
    if (isAuthorized()) {
        if (validatePoints(byPoints)) {
            if (updateTeam1) {
                this.team1.score += byPoints;
            } else {
                this.team2.score += byPoints;
            }
        } else {
            console.log(`Invalid point value ${byPoints}`);
        }
    } else {
        console.log("You're not authorized to change the score");
    }
    const end = Date.now();
    logDuration("updateScore", start, end);
}

在所有这些复杂性中,你仍然有你简单、清晰的逻辑,如果布尔值为true,则更新湖人队的分数,如果为false,则更新凯尔特人队的分数。

这里重要的是,增加的复杂性并非来自你的特定商业模式——篮球比赛本身仍然有效。所有增加的功能都源于类所在的系统。篮球比赛本身不需要授权、性能指标或审计。但记分牌应用程序确实需要所有这些以及更多。

注意,所有添加的逻辑都已经封装在方法(auditisAuthorizedlogDuration)中,而实际执行上述所有操作的代码位于你的方法之外。你插入到函数中的代码只做了最基本的工作——然而,它仍然使你的代码变得复杂。

此外,授权、性能指标和审计将在你的应用程序的许多地方需要,而在这些地方,代码对授权、测量或审计的实际代码的工作并不起作用。

解决方案

让我们更仔细地看看上一节中的一个关注点,即性能指标,也就是持续时间测量。这对应用程序非常重要,要将它添加到任何特定的方法中,你需要在方法的开头和结尾添加几行代码:

const start = Date.now();
// actual code of the method
const end = Date.now();
logDuration("updateScore", start, end);

我们需要将其添加到你需要测量的每个方法中。这是一段非常重复的代码,每次你编写它时,你都在打开做错的可能性。此外,如果你需要更改它,即向logDuration方法添加参数,你将需要更改数百甚至数千个调用点。

为了避免这种风险,你可以做的是将方法的实际代码包裹在另一个函数中,该函数仍然会调用它。这个函数可能看起来像这样:

function wrapWithDuration(method: Function) {
    const result = {
        [method.name]: function (this: any, ...args: any[]) {
            const start = Date.now();
            const result = method.apply(this, args);
            const end = Date.now();
            logDuration(method.name, start, end);
            return result;
        },
    };
    return result[method.name];
}

wrapWithDuration函数(其细节你现在可以忽略)将接受一个方法并返回一个具有以下功能的函数:

  • 相同的this引用

  • 相同的方法名

  • 相同的签名(参数和返回类型)

  • 原始方法所具有的所有行为

  • 扩展行为,因为它将测量实际方法的持续时间

由于它实际上会调用原始方法,从外部看,新函数与原始函数完全无法区分。你在保持所有已有内容的同时添加了一些行为。现在,你可以用新的改进版本替换原始方法。

采用这种方法,你将得到的是:原始方法不会知道或关心应用程序的横切关注点,而是专注于自己的业务逻辑——应用程序可以在运行时用具有所有必要业务逻辑以及所有所需添加的函数“升级”该方法。

这种透明的“升级”通常被称为装饰,而执行装饰的方法被称为装饰器方法

这里所展示的只是装饰可以采取的一种形式。解决方案的数量可以与开发者的数量一样多,而且它们都不会简单直接。应该制定一些标准,TypeScript 设计团队决定采用提议的 JavaScript 语法。

本章的其余部分将使用该语法,你可以忽略这里给出的解决方案。

装饰器和装饰器工厂

如我们所见,装饰器只是特殊的包装函数,它们为你的常规方法、类和属性添加行为。它们特殊之处在于在 TypeScript 中的使用方式。TypeScript 支持以下装饰器类型:

  • 类装饰器:这些装饰器附加到类声明上。

  • 方法装饰器:这些装饰器附加到方法声明上。

  • 访问器装饰器:这些装饰器附加到属性的访问器声明上。

  • 属性装饰器:这些装饰器附加到属性本身。

  • 参数装饰器:这些装饰器附加到方法声明中的单个参数上。

因此,你有五个不同的地方可以使用装饰器,这意味着有五种不同类型的特殊函数可以用来装饰你的代码。所有这些都在下面的示例中展示:

@ClassDecorator
class SampleClass {
    @PropertyDecorator
    public sampleProperty:number = 0;
    private _sampleField: number = 0;
    @AccessorDecorator
    public get sampleField() { return this._sampleField; }
    @MethodDecorator
    public sampleMethod(@ParameterDecorator paramName: string) {}
}

样本装饰器是按照以下方式定义的函数:

function ClassDecorator (constructor: Function) {}
function AccessorDecorator (target: any, propertyName: string, descriptor: PropertyDescriptor) {}
function MethodDecorator (target: any, propertyName: string, descriptor: PropertyDescriptor) {}
function PropertyDecorator (target: any, propertyName: string) {}
function ParameterDecorator (target: any, propertyName: string, parameterIndex: number) {}

装饰器语法

向项目添加装饰器的语法是使用特殊的符号 @ 后跟装饰器的名称。装饰器放置在它装饰的代码之前,所以在前面的示例中,你执行了以下装饰:

  • @ClassDecorator 紧接在 SampleClass 类之前,是一个类装饰器。

  • @PropertyDecorator 紧接在 public sampleProperty 之前,是一个属性装饰器。

  • @AccessorDecorator 紧接在 public get sampleField() 之前,是一个 get 访问器装饰器。

  • @MethodDecorator 紧接在 public sampleMethod() 之前,是一个方法装饰器。

  • @ParameterDecorator 紧接在 paramName: string 之前,是一个参数装饰器。

虽然装饰器本身是常规函数,但按照惯例,它们的名称使用 PascalCase 而不是 lowerCamelCase

注意

想了解更多关于 PascalCaselowerCamelCase 的信息,请访问 techterms.com/definition/camelcasetechterms.com/definition/pascalcase

装饰器工厂

你可以看到,在前面的示例中,你没有为样本装饰器集合指定任何参数,然而装饰器函数接受一个到三个参数。这些参数由 TypeScript 本身处理,并在代码运行时自动提供。这意味着你无法直接配置装饰器,例如,通过传递额外的参数。

幸运的是,你可以使用一个称为 @ 符号的构造来指定装饰器,它将评估随后的表达式。因此,你不需要提供符合特殊装饰器要求的函数名称,而是可以提供一个将评估为该函数的表达式。换句话说,装饰器工厂仅仅是返回装饰器函数的高阶函数。

例如,让我们创建一个简单的函数,它将接受一个消息作为参数并将消息记录到控制台。该函数的返回值,其输入参数不符合类装饰器签名,将是一个函数,其输入参数符合类装饰器签名。这个生成的函数也将简单地记录消息到控制台。考虑以下代码:

Example_Decorator_Factory.ts
1 function ClassDecoratorFactory(message: string) {
2     console.log(`${message} inside factory`);
3     return function (constructor: Function) {
4       console.log(`${message} inside decorator`);
5     };
6 }
Link to the preceding example: https://packt.link/M2Ixp.

从本质上讲,ClassDecoratorFactory函数本身不是一个装饰器,但它的返回值是。这意味着你不能直接将ClassDecoratorFactory用作装饰器,但如果你调用它,例如ClassDecoratorFactory("Hi"),那么这个值确实是一个装饰器。你可以使用它用这种语法装饰几个类。以下示例将帮助你更好地理解这一点:

@ClassDecoratorFactory("Hi")
class DecoratedOne {}
@ClassDecoratorFactory("Hello")
class DecoratedTwo {}

在这里,你不再使用之前的@ClassDecorator这样的表达式,而是使用@ClassDecoratorFactory("hi")@ClassDecoratorFactory("hello")。由于ClassDecoratorFactory函数的执行结果是一个类装饰器,这是可行的,并且装饰器成功地装饰了代码。当你运行代码时,你会看到以下输出:

Hi inside factory
Hi inside decorator
Hello inside factory
Hello inside decorator

注意,你将使用的大多数装饰器实际上都是装饰器工厂,因为装饰时添加参数非常有用。大多数资料甚至一些文档都不会区分这两个术语。

类装饰器

类装饰器是一个应用于整个类的装饰器函数。它可以用来观察、更改或完全替换类定义。当一个类装饰器被调用时,它接收一个参数——调用类的构造函数。

属性注入

属性注入是类装饰器常用的场景之一。例如,假设你正在构建一个将模拟学校的系统。你将有一个名为Teacher的类,它将具有属性并模拟教师的行为。这个类的构造函数将接受两个参数,即教师的id和教师的name。这个类将看起来是这样的:

class Teacher {
    constructor (public id: number, public name: string) {}
    // other teacher specific code
}

假设我们构建了系统并且它正在运行。一切都很顺利,但过了一段时间,就需要更新它。

我们希望使用令牌实现一个访问控制系统。由于新系统与教学过程无关,最好在不更改班级本身代码的情况下添加它,这样你可以使用一个装饰器来完成这个任务,并且你的装饰器可以给Teacher类的原型注入一个额外的布尔属性。Teacher类可以按照以下方式更改:

Example_PropertyInjection.ts
1 @Token
2 class Teacher {
3     // old teacher specific code
4 }

可以用以下方式定义Token装饰器:

5 function Token (constructor: Function) {
6     constructor.prototype.token = true;
7 }

现在,考虑以下代码,它创建了类的实例并打印一条消息:

8 const teacher = new Teacher(1, "John Smith");
9 console.log("Does the teacher have a token? ",teacher["token"]);

运行所有这些代码将在控制台给出以下结果:

Does the teacher have a token? true
Link to the preceding example: https://packt.link/asjvA.

在注入场景中,您使用提供的 constructor 参数,但您的函数不返回任何内容。在这种情况下,类将继续像以前一样工作。通常,我们将使用构造函数的原型来向对象添加字段和属性。

注意

在本章的所有练习和活动中,在执行代码文件之前,您需要使用 npm i 在目标目录中安装所有依赖项。然后,您可以在目标目录中运行 npx ts-node 'filename' 来执行文件。

练习 7.01:创建简单的类装饰器工厂

在这个练习中,你将创建一个简单的 Token 装饰器工厂。从 Teacher 类的代码开始,我们将创建一个名为 Student 的类,该类需要使用 Token 装饰器进行装饰。我们将扩展装饰器以接受一个参数,并使用创建的装饰器工厂装饰这两个类。

以下步骤将帮助您找到解决方案:

注意

在开始之前,请确保您已设置正确的编译器选项,如 设置编译器选项 部分所述。此练习的代码文件也可以从 packt.link/UpdO9 下载。此存储库包含两个文件:school-token.start.tsschool-token.end.ts。前者包含此练习的 步骤 6 之前的代码,后者包含练习的最终代码。

  1. 打开 Visual Studio Code,在新的目录(Exercise01)中创建一个新文件,并将其保存为 school-token.ts

  2. school-token.ts 中输入以下代码:

    @Token
    class Teacher {
        constructor (public id: number, public name: string) {}
        // teacher specific code
    }
    function Token (constructor: Function) {
        constructor.prototype.token = true;
    }
    /////////////////////////
    const teacher = new Teacher(1, "John Smith");
    console.log("Does the teacher have a token? ",teacher["token"]);
    
  3. 执行代码,并注意它在控制台输出 true

  4. 在文件末尾添加一个 Student 类:

    class Student {
        constructor (public id: number, public name: string) {}
        // student specific code
    }
    
  5. 添加代码以创建一个学生并尝试打印其 token 属性:

    const student = new Student(101, "John Bender");
    console.log("Does the student have a token? ",student["token"]);
    
  6. 执行代码,并注意它在控制台输出 trueundefined

  7. Token 装饰器添加到 Student 类中:

    @Token
    class Student {//… 
    
  8. 执行代码,并注意它在控制台输出 true 两次。

  9. Token 函数更改为一个接受布尔参数的工厂函数:

    function Token(hasToken: boolean) {
        return function (constructor: Function) {
            constructor.prototype.token = hasToken;
        }
    }
    
  10. 修改 Teacher 类的 Token 装饰器以包含一个 true 布尔参数:

    @Token(true)
    class Teacher {//…
    
  11. 修改 Student 类的 Token 装饰器以包含一个 false 布尔参数:

    @Token(false)
    class Student {//…
    
  12. 通过在控制台运行 npx ts-node school-token.ts 来执行代码,并注意它按如下所示输出 truefalse

    Does the teacher have a token?  true
    Does the student have a token?  false
    

在这个练习中,你看到了如何添加一个类装饰器,该装饰器向装饰过的类添加一个属性。然后,你将装饰器更改为使用工厂,并为两个装饰过的类添加了两个不同的参数。最后,你验证了注入的属性确实存在于装饰过的类中,并且通过原型链具有你指定的值。

构造函数扩展

使用属性注入,你能够通过它们的原型向你装饰的对象添加行为和数据。这是可以的,但有时你可能想直接向构造对象本身添加数据。你可以通过继承来实现这一点,但你也可以用装饰器包装继承。

如果你从装饰器返回一个函数,该函数将用作类的替换构造函数。虽然这给了你完全改变类的超级能力,但这种方法的主要目标是让你能够通过添加一些新的行为或数据来增强类。让我们使用自动继承来向类添加属性。一个将 token 属性添加到构造对象本身而不是原型的装饰器看起来像这样:

type Constructable = {new(...args: any[]):{}};
function Token(hasToken: boolean) {
    return function <T extends Constructable>(constructor: T) {
        return class extends constructor {
            token: boolean = hasToken;
        }
    }
}

首次看到这样做时,语法可能看起来有点奇怪,因为你正在使用一个泛型参数来确保从你的装饰器返回的类仍然与传递给构造函数的构造函数兼容。除了语法之外,需要记住的重要部分是代码 token: boolean = hasToken; 将在常规构造函数之外执行。

练习 7.02:使用构造函数扩展装饰器

在这个练习中,你将创建一个用于 Token 装饰器的构造函数扩展装饰器工厂。从 Teacher 类代码开始,我们将添加一个名为 Token 的工厂,通过添加一个 token 布尔属性来增强类。我们将创建一个提供的类对象,并验证该对象确实有自己的 token 属性。以下步骤将帮助你找到解决方案:

注意

在开始之前,请确保你已经设置了如 设置编译器选项 部分所述的正确编译器选项。此练习的代码文件也可以从 packt.link/DhVfC 下载。此存储库包含两个文件:school-token.start.tsschool-token.end.ts。前者包含此练习的 步骤 3 之前的代码,后者包含练习的最终代码。

  1. 打开 Visual Studio Code,在新的目录(Exercise02)中创建一个新文件,并将其保存为 school-token.ts

  2. school-token.ts 文件中输入以下代码:

    class Teacher {
        constructor (public id: number, public name: string) {}
        // teacher specific code
    }
    /////////////////////////
    const teacher = new Teacher(1, "John Smith");
    console.log("Do you have a token:", teacher["token"]);
    console.log("Do you have a token property: ", teacher.hasOwnProperty("token"));
    
  3. 执行代码,并注意它将输出 undefinedfalse 到控制台:

    Do we have a token: undefined
    Do we have a token property:  false
    
  4. 在文件末尾添加一个 Token 函数:

    type Constructable = {new(...args: any[]):{}};
    function Token(hasToken: boolean) {
        return function <T extends Constructable>(constructor: T) {
            return class extends constructor {
                token: boolean = hasToken;
            }
        }
    }
    
  5. 使用 Token 装饰器工厂装饰 Teacher 类:

    @Token(true)
    class Teacher {
    
  6. 执行代码,并注意它将输出 true 两次到控制台:

    Do we have a token: true
    Do we have a token property:  true
    

在这个练习中,你看到了如何更改提供的类构造函数以在实例化对象时运行自定义代码。你使用了这一点来在构造对象本身上注入属性,然后验证注入的属性存在于装饰类的对象上,并且它们的值是你指定的。

构造函数包装

对于类装饰器来说,另一个常见的场景是在创建类的实例时只需运行一些代码,例如,在创建类的实例时添加一些日志记录。你不需要或想要以任何方式更改类的行为,但你确实希望能够以某种方式依赖这个过程。这意味着你需要在类构造器运行时执行一些代码——你不需要更改现有的构造器。

在这种情况下,解决方案是让装饰器函数返回一个新的构造器,该构造器执行装饰器本身所需的新代码以及原始构造器。例如,如果你想每次实例化装饰过的类时都向控制台写入一些文本,你可以使用这个装饰器:

Example_ConstructorWrapping.ts
1  type Constructable = { new (...args: any[]): {} };
2  
3  function WrapConstructor(message: string) {
4      return function <T extends Constructable>(constructor: T) {
5          const wrappedConstructor: any = function (...args: any[]) {
6              console.log(`Decorating ${message}`);
7              const result = new constructor(...args);
8              console.log(`Decorated ${message}`);
9              return result;
10         };
11         wrappedConstructor.prototype = constructor.prototype;
12         return wrappedConstructor;
13    };
14  }
Link to the preceding example: https://packt.link/kgAme.

这个装饰器工厂将使用提供的信息生成一个装饰器。由于你返回了一个新的构造器,你必须使用一个泛型参数来确保从你的装饰器返回的构造器仍然与传递给参数的原始构造器兼容。你可以在wrappedConstructor函数内部创建一个新的函数,在其中你可以调用自定义代码(DecoratingDecorated消息),并通过在原始构造器上调用new来实际创建对象,传递原始参数。

你应该在这里注意以下内容:你可以在对象创建前后添加自定义代码。在前面的例子中,Decorating消息将在对象创建之前打印到控制台,而Decorated消息将在创建完成后打印到控制台。

另一个非常重要的事情是,这种包装会破坏原始对象的原型链。如果你装饰的对象使用了通过原型链可用的任何属性或方法,它们将丢失,改变装饰类的行为。由于这与你想通过构造器包装实现的目标正好相反,你需要重置链。这是通过将新创建的包装函数的prototype属性设置为原始构造器的原型来完成的。

因此,让我们在客户端类上使用装饰器,如下所示:

@WrapConstructor("decorator")
class Teacher {
    constructor(public id: number, public name: string) {
        console.log("Constructing a teacher class instance");
    }
}

接下来,你可以创建一个Teacher类的对象:

const teacher = new Teacher(1, "John");

当你运行文件时,你将在控制台看到以下内容:

Decorating decorator
Constructing a teacher class instance
Decorated decorator

练习 7.03:为类创建一个日志装饰器

在这个练习中,你将创建一个用于LogClass装饰器的构造器包装装饰器工厂。从Teacher类的代码开始,你将添加一个名为LogClass的装饰器工厂,它将使用一些日志代码包装类构造器。你将创建提供的类的对象,并验证日志方法确实被调用。以下步骤将帮助你找到解决方案:

注意

在你开始之前,请确保你已经设置了如 设置编译器选项 部分所述的正确编译器选项。此练习的代码文件也可以从 packt.link/vBLMg 下载。

  1. 打开 Visual Studio Code,在新的目录(Exercise03)中创建一个新文件,并将其保存为 teacher-logging.ts

  2. teacher-logging.ts 中输入以下代码:

    class Teacher {
        constructor(public id: number, public name: string) {
            console.log("Constructing a teacher");
        }
    }
    /////////////////////////
    const teacher = new Teacher(1, "John Smith");
    
  3. 执行代码,注意它会将 Constructing a teacher 输出到控制台。

  4. 接下来,创建装饰器。首先,你需要添加 Constructable 类型定义:

    type Constructable = { new (...args: any[]): {} };
    
  5. 现在,添加你的装饰器工厂的定义:

    function LogClass(message: string) {
        return function <T extends Constructable>(constructor: T) {
            return constructor;
        };
    }
    

    在前面的代码中,构造函数接受一个字符串参数并返回一个装饰器函数。装饰器函数本身最初只会返回被装饰类的原始、未更改的构造函数。

  6. 使用带有适当消息参数的 LogClass 装饰器装饰 Teacher 类:

    @LogClass("Teacher decorator")
    class Teacher {
        constructor(public id: number, public name: string) {
            console.log("Constructing a teacher");
        }
    }
    
  7. 执行代码,注意行为没有任何变化。

  8. 现在,向你的应用程序添加一个日志对象:

    const logger = {
        info: (message: string) => {
            console.log(`[INFO]: ${message}`);
        },
    };
    

    在实际的生产级代码实现中,你可能需要将日志记录到数据库、文件、第三方服务等。在上一个步骤中,你只是将日志记录到控制台。

  9. 接下来,使用 logger 对象向你的装饰器添加一个包装构造函数:

        return function <T extends Constructable>(constructor: T) {
            const loggingConstructor: any = function(...args: any[]){
                logger.info(message);
                return new constructor(...args);
            }
            loggingConstructor.prototype = constructor.prototype;
            return loggingConstructor;
        };
    
  10. 执行代码并验证你是否在控制台收到日志消息:

    [INFO]: Teacher decorator
    Constructing a teacher
    
  11. 构造几个更多的对象并验证每次创建对象时构造函数都会运行:

    for (let index = 0; index < 10; index++) {
        const teacher = new Teacher(index +1, "LouAnne Johnson");
    }
    

    当你执行文件时,你会看到以下输出:

    [INFO]: Teacher decorator
    Constructing a teacher
    [INFO]: Teacher decorator
    Constructing a teacher
    [INFO]: Teacher decorator
    Constructing a teacher
    [INFO]: Teacher decorator
    Constructing a teacher
    [INFO]: Teacher decorator
    Constructing a teacher
    [INFO]: Teacher decorator
    Constructing a teacher
    [INFO]: Teacher decorator
    Constructing a teacher
    

在这个练习中,你看到了如何包装提供的类构造函数,以便它可以运行自定义代码,但又不改变对象的构造。通过包装,你向一个没有任何日志功能的类添加了日志功能。你构造了这个类的对象,并验证了日志功能是可操作的。

方法和访问器装饰器

方法装饰器是一个应用于类单个方法的装饰器函数。在方法装饰器中,你可以观察、修改或完全用装饰器提供的定义替换方法定义。当方法装饰器被调用时,它接收三个参数:targetpropertyKeydescriptor

  • target: 由于方法可以是实例方法(在类的实例上定义)和静态方法(在类本身上定义),target 可以是两件不同的事情。对于实例方法,它是类的原型。对于静态方法,它是类的构造函数。通常,你将此参数键入为 any

  • propertyKey: 这是你要装饰的方法的名称。

  • descriptor: 这是你要装饰的方法的属性描述符。PropertyDescriptor 接口定义如下:

    interface PropertyDescriptor {
        configurable?: boolean;
        enumerable?: boolean;
        value?: any;
        writable?: boolean;
        get?(): any;
        set?(v: any): void;
    }
    

此接口定义了对象属性的值,以及属性的属性(属性是否可配置、可枚举和可写)。我们还将使用此接口的强类型版本,TypedPropertyDescriptor,其定义如下所示:

interface TypedPropertyDescriptor<T> {
    enumerable?: boolean;
    configurable?: boolean;
    writable?: boolean;
    value?: T;
    get?: () => T;
    set?: (value: T) => void;
}

注意,在 JavaScript 和随后的 TypeScript 中,属性访问器只是管理属性访问的特殊方法。适用于装饰方法的一切也适用于装饰访问器。任何访问器特定的内容将单独介绍。

如果你在一个方法上设置了一个装饰器,我们将得到该方法的PropertyDescriptor实例,并且描述符的value属性将给我们访问其主体的权限。如果你在一个访问器上设置了一个装饰器,我们将得到相应属性的PropertyDescriptor实例,其getset属性分别设置为获取器和设置器访问器。这意味着如果你正在装饰属性访问器,你不需要分别装饰获取器和设置器,因为对其中一个的任何装饰都是对另一个的装饰。实际上,如果你这样做,TypeScript 将发出以下错误:

TS1207: Decorators cannot be applied to multiple get/set accessors of the same name.

方法装饰器不需要返回值,因为大多数情况下,你可以通过修改属性描述符来完成所需操作。然而,如果你返回一个值,那么这个值将替换最初提供的属性描述符。

实例函数上的装饰器

如前所述,任何接受targetpropertyKeydescriptor参数的函数都可以用来装饰方法和属性访问器。所以,让我们有一个将简单地记录targetpropertyKeydescriptor参数到控制台的功能:

Example_Decorators_Instance_Functions.ts
1 function DecorateMethod(target: any, propertyName: string,
2 descriptor: PropertyDescriptor) {
3     console.log("Target is:", target);
4     console.log("Property name is:", propertyName);
5     console.log("Descriptor is:", descriptor);
6 }
Link to the preceding example: https://packt.link/gle5U.

你可以使用这个函数来装饰类的成员方法。这是一个极其简单的装饰器,但你可以用它来调查方法装饰器的使用情况。

让我们从简单的类开始:

class Teacher {
    constructor (public name: string){}
    private _title: string = "";
    public get title() { 
        return this._title;
    }

    public set title(value: string) {
        this._title = value;
    }
    public teach() {
        console.log(`${this.name} is teaching`)
    }
}

这个类有一个构造函数,一个名为teach的方法,以及一个具有定义的获取器和设置器的title属性。访问器只是将控制权传递给_title私有字段。你可以使用以下代码将装饰器添加到teach方法上:

    @DecorateMethod
    public teach() {
        // ....

当你运行你的代码(不需要实例化类)时,你将在控制台得到以下输出:

    Target is: {}
    Property name is: teach
    Descriptor is: {
        value: [Function: teach],
        writable: true,
        enumerable: false,
        configurable: true
    }

考虑以下代码片段,其中你将装饰器应用于设置器或获取器(任何一个都可以正常工作,但不能同时使用):

    @DecorateMethod
    public get title() { 
        // ....

或者:

    @DecorateMethod
    public set title(value: string) {
        // ....

当你使用上述任何一种建议运行代码时,你将得到以下输出:

    Target is: {}
    Property name is: title
    Descriptor is: {
        get: [Function: get title],
        set: [Function: set title],
        enumerable: false,
        configurable: true
    }

注意,你无法在构造函数本身上添加方法装饰器,因为这会导致错误:

 TS1206: Decorators are not valid here.

如果你需要更改构造函数的行为,你应该使用类装饰器。

练习 7.04:创建一个标记函数可枚举的装饰器

在这个练习中,你将创建一个装饰器,它将能够改变它所装饰的方法和访问器的enumerable状态。你将使用这个装饰器来设置你将要编写的类中一些函数的enumerable状态,最后,你将验证当你枚举对象实例的属性时,你也会得到修改后的方法。

注意

在你开始之前,请确保你已经设置了正确的编译器选项,如设置编译器选项部分所述。这个练习的代码文件也可以从packt.link/1nAff下载。这个存储库包含两个文件:teacher-enumerating.start.tsteacher-enumerating.end.ts。前者包含这个练习的步骤 5之前的代码,后者包含练习的最终代码。

  1. 打开 Visual Studio Code,在新的目录(Exercise04)中创建一个新文件,并将其保存为teacher-enumerating.ts

  2. teacher-enumerating.ts中输入以下代码:

    class Teacher {
        constructor (public name: string){}
        private _title: string = "";
        public get title() { 
            return this._title;
        }
    
        public set title(value: string) {
            this._title = value;
        }
        public teach() {
            console.log(`${this.name} is teaching`)
        }
    }
    
  3. 编写代码以实例化这个类的对象:

    const teacher = new Teacher("John Smith");
    
  4. 编写代码以枚举创建的对象中的所有键:

    for (const key in teacher) {
        console.log(key);
    }
    
  5. 执行文件并验证在控制台上显示的唯一键是name_title

  6. 添加一个接受布尔参数的装饰器工厂,该工厂将生成一个设置提供的参数的enumerable状态的函数装饰器:

    function Enumerable(value: boolean) {
        return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
            descriptor.enumerable = value;
        }
    };
    
  7. 使用装饰器装饰title获取器或设置器访问器以及teach方法:

        @Enumerable(true)
        public get title() { 
            return this._title;
        }
    
        public set title(value: string) {
            this._title = value;
        }
        @Enumerable(true)
        public teach() {
            console.log(`${this.name} is teaching`)
        }
    
  8. 重新运行代码并验证titleteach属性是否被枚举:

    name
    _title
    title
    teach
    

在这个练习中,你看到了如何添加一个方法装饰器工厂并将其应用于实例方法或实例属性访问器。你学习了如何使属性可枚举,并使用这些知识来设置类中函数的enumerable状态。最后,你枚举了一个类的所有属性。

静态函数上的装饰器

就像实例方法一样,装饰器也可以用于静态方法。你可以这样向你的Teacher类添加一个静态方法:

Example_Decorator_StaticFunctions.ts
1 class Teacher {
2     //.....
3 
4     public static showUsage() {
5        console.log("This is the Teacher class")
6    }
7    //.....
Link to the preceding example https://packt.link/Ckuct.

我们同样允许在静态方法上使用方法装饰器。因此,你可以使用以下代码添加DecorateMethod装饰器:

    @DecorateMethod
    public static showUsage() {
        //......

当你运行代码时,你将得到类似以下的输出:

Target is: [Function: Teacher]
Property name is: showUsage
Descriptor is: {
  value: [Function: showUsage],
  writable: true,
  enumerable: false,
  configurable: true
}

与实例方法的主要区别是target参数。实例方法和访问器是在类原型上生成的,因此,当使用方法/访问器装饰器时,你会收到类原型作为target参数。静态方法和访问器是在类变量本身上生成的,因此,当使用方法/访问器装饰器时,你会收到类变量作为构造函数的化身作为target参数。

注意,这正是你作为类装饰器参数得到的确切相同的对象。你甚至可以用几乎相同的方式使用它。然而,在方法装饰器中,焦点应该放在我们实际装饰的属性上。在非类装饰器内部操作构造函数被认为是一种不好的做法。

方法包装装饰器

方法装饰器的最常见用法是将其用于包装原始方法,添加一些自定义的横切代码。例如,添加一些通用的错误处理或添加自动日志记录功能。

为了做到这一点,你需要更改被调用的函数。你可以通过使用方法属性描述符的 value 属性,以及使用属性访问器描述符的 getset 属性来实现这一点。

练习 7.05:为方法创建日志装饰器

在这个练习中,你将创建一个装饰器,每次调用被装饰的方法或访问器时都会记录日志。你将使用这个装饰器向 Teacher 类添加日志记录,并验证每次使用被装饰的方法和属性访问器时,都会得到适当的日志条目:

注意

在你开始之前,请确保你已经设置了正确的编译器选项,如 设置编译器选项 部分所述。此练习的代码文件也可以从 packt.link/rmEZi 下载。

  1. 打开 Visual Studio Code,在新的目录(Exercise05)中创建一个新文件,并将其保存为 teacher-logging.ts

  2. teacher-logging.ts 文件中输入以下代码:

    class Teacher {
        constructor (public name: string){}
        private _title: string = "";
        public get title() { 
            return this._title;
        }
    
        public set title(value: string) {
            this._title = value;
        }
        public teach() {
            console.log(`${this.name} is teaching`)
        }
    }
    /////////////////
    const teacher = new Teacher("John Smith");
    teacher.teach(); // we're invoking the teach method
    teacher.title = "Mr." // we're invoking the title setter
    console.log(`${teacher.title} ${teacher.name}`); // we're invoking the title getter
    
  3. 执行代码,注意它将输出 John Smith is teachingMr. John Smith 到控制台。

  4. 创建一个方法装饰器工厂,可以包装任何方法、获取器或设置器,并添加一个日志语句。它将接受一个字符串参数并返回一个装饰器函数。最初,你不会对属性描述符进行任何更改:

    function LogMethod(message: string) {
        return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
        };
    }
    
  5. 使用带有适当消息参数的 LogMethod 装饰器装饰 teach 方法和 title 获取器:

        @LogMethod("Title property")
        public get title() { 
        //...    
        @LogMethod("Teach method")
        public teach() {
        //...    
    
  6. 执行代码,注意行为没有发生变化。

  7. 现在,向你的应用程序添加一个 logger 对象:

    const logger = {
        info: (message: string) => {
            console.log(`[INFO]: ${message}`);
        },
    };
    

    在实际的生产级实现中,你可能需要将日志记录到数据库、文件、第三方服务等等。在上一个步骤中,你只是将日志记录到控制台。

  8. 向装饰器工厂添加代码,用于包装属性描述符,包括 valuegetset 属性(如果存在):

    function LogMethod(message: string) {
        return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
            if (descriptor.value) {
                const original = descriptor.value;
                descriptor.value = function (...args: any[]) {
                    logger.info(`${message}: Method ${propertyName} invoked`);
                    // we're passing in the original arguments to the method
                    return original.apply(this, args);
                }
            }
            if (descriptor.get) {
                const original = descriptor.get;
                descriptor.get = function () {
                    logger.info(`${message}: Getter for ${propertyName} invoked`);
                    // getter accessors do not take parameters
                    return original.apply(this, []);
                }
            }
            if (descriptor.set) {
                const original = descriptor.set;
                descriptor.set = function (value: any) {
                    logger.info(`${message}: Setter for ${propertyName} invoked`);
                    // setter accessors take a single parameter, i.e. the value to be set
                    return original.apply(this, [value]);
                }
            }
        }
    }
    
  9. 执行代码并验证,当你调用方法以及使用 title 属性时,都会在控制台得到日志消息。

    [INFO]: Teach method: Method teach invoked
    John Smith is teaching
    [INFO]: Title property: Setter for title invoked
    [INFO]: Title property: Getter for title invoked
    Mr. John Smith
    

在这个练习中,你看到了如何包装提供的方法和属性访问器类的定义,以便在每次调用时运行自定义代码,而不改变函数本身的行为。你使用了这一点来为没有任何日志记录功能的函数添加日志记录功能。你构建了这个类的对象并验证了日志功能的运行情况。

活动第 7.01 部分:创建用于调用计数的装饰器

作为网站后端服务的开发者,你被要求创建一个解决方案,使运营部门能够清楚地审计服务的操作行为。为此,应用程序需要统计所有类的实例化和方法调用。

在这个活动中,你将创建类和方法装饰器,这些装饰器可以用来统计类的实例化和方法调用次数。你将创建一个包含有关个人信息的数据的类,并使用装饰器来统计创建了多少个这样的对象以及每个方法被调用了多少次。在你构建了几个对象并使用它们的属性之后,查看计数器的值。

本活动的目的是展示类和方法装饰器的用途,以便解决应用程序的横切关注点,而不改变给定类的功能。你应该有关于你的对象生命周期的详细统计信息,而不增加任何业务逻辑的复杂性。

以下步骤将帮助你解决问题:

注意

在开始之前,请确保你已经设置了如 设置编译器选项 部分所述的正确编译器选项。此活动的代码文件也可以从 packt.link/UK49t 下载。

  1. 创建一个名为 Person 的类,具有名为 firstNamelastNamebirthday 的公共属性。

  2. 添加一个构造函数,通过构造函数参数初始化属性。

  3. 添加一个名为 _title 的私有字段,并通过名为 title 的属性作为 getter 和 setter 公开它。

  4. 添加一个名为 getFullName 的方法,它将返回一个人的全名。

  5. 添加一个名为 getAge 的方法,它将返回人的当前年龄(通过从当前年份减去生日)。

  6. 创建一个全局对象 count 并将其初始化为空对象。这将作为你的状态变量,用于存储每个实例化和调用的计数。

  7. 创建一个名为 CountClass 的构造函数包装装饰器工厂,它将接受一个名为 counterName 的字符串参数。我们将使用该参数作为 count 对象的键。

  8. 在包装代码内部,通过 counterName 参数定义的 count 对象的属性增加 1。

  9. 不要忘记设置包装构造函数的原型链。

  10. 创建一个名为 CountMethod 的方法包装装饰器工厂,它将接受一个名为 counterName 的字符串参数。

  11. 添加检查以确定descriptor参数是否具有valuegetset属性。您需要涵盖两种情况,即此装饰器用作访问器和方法装饰器时的情况。

  12. 在每个相应的分支中,添加包装方法的代码。

  13. 在包装代码内部,将counterName参数中定义的count对象的属性增加 1。

  14. 使用CountClass装饰器装饰类,并带有person参数。

  15. 使用CountMethod装饰器分别装饰getFullNamegetAgetitle属性获取器,参数分别为person-full-nameperson-ageperson-title。请注意,您只需要装饰一个属性访问器。

  16. 在类外部编写代码,以实例化三个person对象。

  17. 编写代码,调用对象的getFullNamegetAge方法。

  18. 编写代码,检查title属性是否为空,如果为空则设置它。

  19. 编写代码,将count对象记录到控制台,以查看您的装饰器是否运行正确。

预期输出如下:

{
    person: 3,
    "person-full-name": 3,
    "person-age": 3,
    "person-title": 6
}

此活动展示了使用装饰器扩展和增强类功能而不污染代码的能力。您能够在不更改任何底层业务逻辑的情况下,向对象中注入自定义代码执行。

注意

活动的解决方案可以通过此链接找到。

在装饰器中使用元数据

到目前为止,您一直在装饰类和方法。这些基本上是执行中的代码片段,您已经能够更改和增强执行中的代码。但您的代码不仅包括“活跃”的代码,还包括其他定义——特别是,您的类有字段,您的方 法有参数。在上一节的活动之前,您能够检测到何时访问title属性,因为您有一个获取值的方法和一个设置值的方法——所以您将代码附加到已经存在的“活跃”代码上。但您如何装饰程序的“被动”部分呢?您不能附加在“被动”代码执行时运行的代码,因为坦白地说,在public firstName: string中没有什么可以执行的。这是一个简单的定义。

您不能为您的“被动代码”附加任何执行代码,但您可以使用装饰器向有关装饰的“被动”代码片段的某个全局对象添加一些数据。在 活动 7.01:为调用计数创建装饰器 中,您定义了一个全局 count 对象,并在装饰器中使用它来跟踪执行。这种方法是可行的,但它需要创建一个全局变量,这在大多数情况下都是不好的。如果您能够在方法和类本身上定义某种属性,那就更干净利落了。但另一方面,您不希望添加太多与业务逻辑代码并存的属性——意外错误的概率太高。您需要能够以某种方式向您的类和方法添加元数据。

幸运的是,这是一个常见问题,并且有一个提议要为 JavaScript 添加适当的元数据支持。在此期间,有一个名为 reflect-metadata 的 polyfill 库可以用来。

备注

有关 reflect-metadata 库的更多信息,请访问 www.npmjs.com/package/reflect-metadata

这个库本质上所做的,是向您的类附加一个特殊属性,为我们提供了一个存储、检索和处理有关类元数据的地方。

在 TypeScript 中,为了使用此功能,您必须指定一个额外的编译器标志,无论是通过命令行还是通过 tsconfig.json。这就是 emitDecoratorMetadata 标志,需要将其设置为 true 以便与元数据方法一起使用。

Reflect 对象

reflect-metadata 库的 API 很简单,您主要可以关注以下方法:

  • Reflect.defineMetadata: 在类或方法上定义元数据

  • Reflect.hasMetadata: 返回一个布尔值,指示是否存在某个特定的元数据

  • Reflect.getMetadata: 如果存在,返回实际的元数据

考虑以下代码:

class Teacher {
    constructor (public name: string){}
    private _title: string = "";
    public get title() { 
        return this._title;
    }

    public set title(value: string) {
        this._title = value;
    }
    public teach() {
        console.log(`${this.name} is teaching`)
    }
}

这里有一个名为 Teacher 的类,它有一个简单的私有字段 _title,该字段为名为 title 的属性提供了 getset 访问器方法,还有一个名为 teach 的方法,该方法将向控制台记录教师实际上正在教学。

您可以在 Teacher 类上定义一个名为 call-count 的元数据键,并通过执行以下 defineMetadata 调用来将其值设置为 0

Reflect.defineMetadata("call-count", 0, Teacher);

如果您想在 teach 方法上而不是在 Teacher 类本身上添加一个名为 call-count 的元数据键,您可以使用以下 defineMetadata 调用来实现:

Reflect.defineMetadata("call-count", 10, Teacher, "teach");

这将在 Teacher 类的 teach 属性上定义一个名为 call-count 的元数据键,并将其值设置为 10。您可以使用以下命令检索这些值:

Reflect.getMetadata("call-count", Teacher); // will return 0
Reflect.getMetadata("call-count", Teacher, "teach"); // will return 10

从本质上讲,您可以使用以下代码创建一个方法,该方法将方法调用注册为:

function increaseCallCount(target: any, propertyKey: string) {
    if (Reflect.hasMetadata("call-count", target)) {
        const value = Reflect.getMetadata("call-count", target, propertyKey);
        Reflect.defineMetadata("call-count", value+1, target, propertyKey)
    } else {
        Reflect.defineMetadata("call-count", 1, target, propertyKey)
    }
}

此代码将首先调用hasMetadata方法,以检查你是否已经为call-count元数据定义了一个值。如果是true,则hasMetadata方法将调用getMetadata以获取当前值,然后调用defineMetadata以重新定义元数据属性,其值为增加的(value+1)。如果你没有这样的元数据属性,则defineMetadata方法将使用值为 1 来定义它。

当使用increaseCallCount(Teacher, "teach");调用时,它将成功增加Teacher类中teach方法的调用次数。添加到类中的元数据将不会以任何方式阻碍类已有的行为,因此正在执行的任何代码都不会受到影响。

练习 7.06:通过装饰器添加方法元数据

在这个练习中,我们将创建一个简单的类并为其方法添加一些元数据。完成此操作后,你将编写一个函数,给定一个类,将显示其可用的描述:

注意

在你开始之前,请确保你已经设置了如设置编译器选项部分中提到的正确编译器选项。此练习的代码文件也可以从packt.link/JG4F8下载。

  1. 打开 Visual Studio Code,在新的目录(Exercise06)中创建一个新文件,并将其保存为calculator-metadata.ts

  2. calculator-metadata.ts中输入以下代码:

    class Calculator {
        constructor (public first: number, public second: number) {}
        public add() {
            return this.first + this.second;
        }
        public subtract() {
            return this.first – this.second;
        }
        public multiply() {
            return this.first / this.second;
        }
        public divide() {
            return this.first / this.second;
        }
    }
    
  3. 接下来,为类及其一些方法添加元数据描述:

    Reflect.defineMetadata("description", "A class that offers common operations over two numbers", Calculator);
    Reflect.defineMetadata("description", "Returns the result of adding two numbers", Calculator, "add");
    Reflect.defineMetadata("description", "Returns the result of subtracting two numbers", Calculator, "subtract");
    Reflect.defineMetadata("description", "Returns the result of dividing two numbers", Calculator, "divide");
    
  4. 定义一个函数,当给定一个类时,将对其反思并提取并显示该类的description元数据:

    function showDescriptions (target: any) {
        if (Reflect.hasMetadata("description", target)) {
            const classDescription = Reflect.getMetadata("description", target);
            console.log(`${target.name}: ${classDescription}`);
        }
    }
    
  5. 使用showDescriptions(Calculator);调用该函数,并验证它将显示以下输出:

    Calculator: A class that offers common operations over two numbers
    

    为了获取类的所有方法列表,我们必须使用Object.getOwnPropertyNames函数。此外,由于方法实际上是在类的原型上定义的,因此获取类所有方法名称的正确行是const methodNames = Object.getOwnPropertyNames(target.prototype);

  6. 接下来,遍历返回的数组并检查每个方法是否有描述。showDescription函数现在将具有以下格式:

    function showDescriptions (target: any) {
        if (Reflect.hasMetadata("description", target)) {
            const classDescription = Reflect.getMetadata("description", target);
            console.log(`${target.name}: ${classDescription}`);
            const methodNames = Object.getOwnPropertyNames(target.prototype);
            for (const methodName of methodNames) {
                if (Reflect.hasMetadata("description", target, methodName)) {
                    const description = Reflect.getMetadata("description", target, methodName);
                    console.log(`  ${methodName}: ${description}`);
                }
            }
        }
    }
    
  7. 再次调用该函数并验证它将显示以下输出:

    Calculator: A class that offers common operations over two numbers
      add: Returns the result of adding two numbers
      subtract: Returns the result of subtracting two numbers
      divide: Returns the result of dividing two numbers
    

注意,你没有为multiply方法显示任何内容,因为你没有为其添加任何元数据。

在这个练习中,你学习了如何向类和方法添加元数据,以及如何检查其存在性,如果存在,则检索它。你还成功获取了给定类的所有方法的列表。

属性装饰器

属性装饰器是一个应用于类单个属性的装饰器函数。与方法或类装饰器不同,你不能修改或替换属性定义,但你确实可以观察它。

注意

由于你在装饰器中接收构造函数,这并不完全正确。你可以更改类的代码,但这非常不推荐。

当调用属性装饰器时,它接收两个参数:targetpropertyKey

  • target: 由于属性可以是实例属性(在类的实例上定义)和静态属性(在类本身上定义),因此target可以是两件不同的事情。对于实例属性,它是类的原型。对于静态属性,它是类的构造函数。通常,你会将此参数类型指定为any

  • propertyKey: 这是你要装饰的属性的名称。

与方法装饰器不同,你不会收到属性描述符参数,因为显然没有可用的参数。另外,因为你没有返回任何可以替换的代码,所以属性装饰器的返回值被忽略。

例如,你可以定义一个简单的属性装饰器工厂,它只是将消息记录到控制台以通知属性实际上已被装饰:

Example_PropertyDecorators.ts
1 function DecorateProperty(message: string) {
2     return function (target: any, propertyKey: string) {
3        console.log(`Decorated 
4 ${target.constructor.name}.${propertyKey} with '${message}'`);
5     }
6 }
Link to the preceding example: https://packt.link/HkkNi.

考虑以下类定义:

class Teacher {
    public id: number;
    public name: string;
    constructor(id: number, name: string) {
        this.id = id;
        this.name = name;
    }
}

你可以使用以下代码注释idname属性:

    @DecorateProperty("ID")
    public id: number;
    @DecorateProperty("NAME")
    public name: string;

如果你现在执行代码(我们不需要调用任何东西;TypeScript 引擎将调用它),你将获得以下输出:

Decorated Teacher.id with 'ID'
Decorated Teacher.name with 'NAME'

注意,你没有创建任何教师类的对象,也没有调用任何方法。装饰器在定义类时执行。由于属性装饰器是被动型的,通常你会使用它们将某种数据输入到某种机制中,该机制将使用这些数据。常见的方法之一是将被动装饰器与一个或多个主动装饰器(即类和方法的装饰器)结合使用。

注意

例如,在 Angular 中,这是常见的情况,其中被动的@Input@Output装饰器与主动的@Component装饰器结合使用。

另一个常见的用例是有一个额外的机制来获取装饰器提供的数据并使用它。例如,装饰器可以记录一些元数据,然后有另一个函数读取并使用这些元数据。

练习 7.07:创建和使用属性装饰器

在这个练习中,你将创建一个简单的属性装饰器工厂,将为每个属性提供一个描述。完成此操作后,你将编写一个函数,该函数给定一个类将显示其可用的描述:

注意

在开始之前,请确保你已经设置了如设置编译器选项部分所述的正确编译器选项。此练习的代码文件也可以从packt.link/1WU6d下载。

  1. 打开 Visual Studio Code,在新的目录(Exercise07)中创建一个新文件,并将其保存为teacher-properties.ts

  2. teacher-properties.ts文件中输入以下代码:

    class Teacher {
        public id: number;
        public name: string;
        constructor(id: number, name: string) {
            this.id = id;
            this.name = name;
        }
    }
    
  3. 添加一个装饰器工厂,它接受一个字符串参数并生成一个属性装饰器,该装饰器将为给定属性添加一个元数据description字段:

    function Description(message: string) {
        return function (target: any, propertyKey: string) {
            Reflect.defineMetadata("description", message, target, propertyKey)
        }
    }
    
  4. 接下来,使用以下描述注释Teacher类的属性:

        @Description("This is the id of the teacher")
        public id: number;
        @Description("This is the name of the teacher")
        public name: string;
    
  5. 定义一个函数,当给定一个对象时,将反思该对象并提取并显示该对象的description元数据:

    function showDescriptions (target: any) {
        for (const key in target) {
            if (Reflect.hasMetadata("description", target, key)) {
                const description = Reflect.getMetadata("description", target, key);
                console.log(`  ${key}: ${description}`);
            }
        }
    }
    
  6. 创建一个Teacher类的对象:

    const teacher = new Teacher(1, "John Smith");
    
  7. 将该对象传递给showDescriptions函数:

    showDescriptions(teacher);
    
  8. 执行代码并验证描述是否已显示:

      id: This is the id of the teacher
      name: This is the name of the teacher
    

在这个练习中,你学习了如何使用属性装饰器为属性添加元数据,以及如何使用属性装饰器为你的类添加快速的基本文档。

参数装饰器

参数装饰器是一个应用于函数调用单个参数的装饰器函数。就像属性装饰器一样,参数装饰器是被动型的,也就是说,它们只能用来观察值,但不能注入和执行代码。参数装饰器的返回值同样被忽略。因此,参数装饰器几乎总是与其他主动装饰器一起使用。

当调用参数装饰器时,它接收三个参数:targetpropertyKeyparameterIndex

  • target:这个参数的行为与相应的方法装饰器相同。如果参数在类的构造函数上,则有一个例外,但这将在稍后解释。

  • propertyKey:这是你要装饰的方法的名称(类的构造函数例外将在稍后解释)。

  • parameterIndex: 这是在函数参数列表中参数的序号索引(第一个参数从零开始)。

因此,让我们有一个函数,它将简单地记录targetpropertyKeyparameterIndex参数到控制台:

Example_ParameterDecorators.ts
1 function DecorateParam(target: any, propertyName: string,
2 parameterIndex: number) {
3    console.log("Target is:", target);
4    console.log("Property name is:", propertyName);
5    console.log("Index is:", parameterIndex);
6 }
Link to the preceding example: https://packt.link/5vuL2.

你可以使用这个函数来装饰函数的参数,并调查参数装饰器的使用情况。让我们从一个简单的类开始:

class Teacher {
    public id: number;
    public name: string;
    constructor(id: number, name: string) {
        this.id = id;
        this.name = name;
    }
    public getFullName(title: string, suffix: string) {
        return `${title} ${this.name}, ${suffix}`
    }
}

该类有一个接受两个参数idname的构造函数,以及一个名为getFullName的方法,该方法接受两个参数titlesuffix。假设你将装饰器添加到getFullName方法的第一个参数上,如下所示:

     public getFullName(@DecorateParam title: string, suffix: string) {
        // ....

如果你运行你的代码(不需要实例化类),你将在控制台上得到以下输出:

Target is: Teacher {}
Property name is: getFullName
Index is: 0

我们还可以将参数装饰器应用于构造函数本身的参数。比如说,你装饰第二个构造函数参数,如下所示:

    constructor(id: number, @DecorateParam name: string) {
        // ....

当你运行代码时,你会得到以下输出:

Target is: [Function: Teacher]
Property name is: undefined
Index is: 1

注意,在这种情况下,目标不是类的原型,而是类构造函数本身。另外,当装饰构造函数参数时,属性的名称是undefined

练习 7.08:创建和使用参数装饰器

在这个练习中,你将创建一个参数装饰器,它将指示某个参数是必需的;也就是说,它不应该有空的值。你还将创建一个用于方法的验证装饰器,以便验证实际上可以发生。我们将创建一个使用装饰器的类,你将尝试使用有效和无效的值调用该方法:

注意

在开始之前,请确保你已经设置了如设置编译器选项部分所述的正确编译器选项。此练习的代码文件也可以从packt.link/Hf3fv下载。

  1. 打开 Visual Studio Code,在新的目录(Exercise08)中创建一个新文件,并将其保存为teacher-parameters.ts

  2. teacher-parameters.ts中输入以下代码:

    class Teacher {
        public id: number;
        public name: string;
        constructor(id: number, name: string) {
            this.id = id;
            this.name = name;
        }
        public getFullName(title: string, suffix: string) {
            return `${title} ${this.name}, ${suffix}`
        }
    }
    
  3. 创建一个名为Required的参数装饰器,它将给定的属性的索引添加到类的required元数据字段中:

    function Required(target: any, propertyKey: string, parameterIndex: number) {
        if (Reflect.hasMetadata("required", target, propertyKey)) {
            const existing = Reflect.getMetadata("required", target, propertyKey) as number[];
            Reflect.defineMetadata("required", existing.concat(parameterIndex), target, propertyKey);
        } else {
            Reflect.defineMetadata("required", [parameterIndex], target, propertyKey)
        }
    }
    

    在这里,如果元数据已经存在,这意味着还有一个必需的参数。如果是这样,你加载它并将你的parameterIndex连接起来。如果没有先前的元数据,你使用包含你的parameterIndex的数组定义它。

  4. 接下来,创建一个方法装饰器,它将包装原始方法并在调用原始方法之前检查所有必需参数:

    function Validate(target: any, propertyKey:string, descriptor: PropertyDescriptor) {
        const original = descriptor.value;
        descriptor.value = function (...args: any[]) {
            // validate parameters
            if (Reflect.hasMetadata("required", target, propertyKey)) {
                const requiredParams = Reflect.getMetadata("required", target, propertyKey) as number[];
                for (const required of requiredParams) {
                    if (!args[required]) {
                        throw Error(`The parameter at position ${required} is required`)
                    }
                }
            }
            return original.apply(this, args);
        }
    }
    

    如果你的任何必需参数有一个假值,那么你的装饰器将不会执行原始方法,而是抛出一个错误。

  5. 然后,使用Required装饰器注释getFullName方法的title参数,并使用Validate装饰器注释该方法本身:

        @Validate
        public getFullName(@Required title: string, suffix: string) {
            // ....
    
  6. 创建Teacher类的对象:

    const teacher = new Teacher(1, "John Smith");
    
  7. 尝试使用空字符串作为第一个参数调用getFullName方法:

    try {
         console.log(teacher.getFullName("", "Esq"));
    } catch (e) {
         console.log(e.message);
    }
    
  8. 执行代码并验证是否显示错误消息:

    The parameter at position 0 is required
    

在这个练习中,你了解了如何创建参数装饰器以及如何使用它们来添加元数据。你还协调了将相同的元数据用于另一个装饰器,并构建了一个基本的验证系统。

在单个目标上应用多个装饰器

经常需要在单个目标上应用多个装饰器。由于装饰器可以(并且确实)更改实际执行的代码,因此了解不同装饰器如何协同工作是很重要的。

基本上,装饰器是函数,你使用它们来组合你的目标。这意味着本质上,装饰器将自下而上地应用和执行,最接近目标的目标装饰器先执行并提供结果给下一个装饰器,依此类推。这类似于函数式组合;也就是说,当我们试图计算f(g(x))时,首先调用g函数,然后调用f函数。

当使用装饰器工厂时,有一个小陷阱。组合规则仅适用于装饰器本身——而装饰器工厂本身不是装饰器。它们是需要执行以返回装饰器的函数。这意味着它们按照源代码顺序执行,即自上而下。想象一下,你有两个装饰器工厂:

Example_MultipleDecorators.ts
1 function First () {
2    console.log("Generating first decorator")
3    return function (constructor: Function) {
4        console.log("Applying first decorator")
5    }
6 }
Link to the preceding example https://packt.link/jMhDj.

第二个装饰器工厂:

7  function Second () {
8      console.log("Generating second decorator")
9      return function (constructor: Function) {
10         console.log("Applying second decorator")
11     }
12 }

现在想象它们被应用于单个目标:

13 @First()
14 @Second()
15 class Target {}

生成过程将在第二个装饰器之前生成第一个装饰器,但在应用过程中,第二个装饰器将被应用,然后才是第一个:

Generating first decorator
Generating second decorator
Applying second decorator
Applying first decorator

活动第 7.02 部分:使用装饰器应用横切关注点

在这个活动中,我们将完整地回到篮球游戏示例 (Example_Basketball.ts)。您需要以可维护的方式向 Example_Basketball.ts 文件添加所有必要的横切关注点,例如身份验证、性能指标、审计和验证。

您可以使用已经在 Example_Basketball.ts. 中存在的代码开始活动。首先,盘点一下文件中已经存在的元素:

  • 描述团队的接口。

  • 游戏本身的类。您有一个构造函数,根据团队名称创建团队对象。您还有一个 getScore 函数,用于显示得分,以及一个简单的 updateScore 方法,该方法更新游戏的得分,并接受得分团队和得分值作为参数。

现在您需要在不更改类本身代码的情况下,仅通过使用装饰器添加之前提到的横切关注点。

Example_Basketball.ts 中更早的时候,您必须完全将计分业务逻辑包含在处理其他所有事情(如授权、审计、指标等)所需的代码中。现在应用所有需要的装饰器技能,以便应用程序能够正常运行,同时代码库仍然清晰简洁。

注意

此活动的代码文件也可以从 packt.link/7KfCx 下载。

以下步骤将帮助您解决问题:

  1. BasketBallGame 类创建代码。

  2. 创建一个名为 Authenticate 的类装饰器工厂,它将接受一个 permission 参数并返回一个具有构造函数包装的类装饰器。类装饰器应加载 permissions 元数据属性(字符串数组),然后检查传递的参数是否为数组的元素。如果传递的参数不是数组的元素,类装饰器应抛出错误;如果存在,则继续进行类创建。

  3. 定义一个名为 permissionsBasketballGame 类元数据属性,其值为 ["canUpdateScore"]

  4. 使用参数值为 canUpdateScore 的类装饰器工厂对 BasketballGame 类进行应用。

  5. 创建一个名为 MeasureDuration 的方法装饰器,它将使用方法包装在方法体执行之前启动计时器,并在完成后停止计时。它应计算持续时间并将其推送到名为 durations 的方法元数据属性中。

  6. updateScore 方法上应用 MeasureDuration 方法装饰器。

  7. 创建一个名为 Audit 的方法装饰器工厂,它将接受一个 message 参数并返回一个方法装饰器。该方法装饰器应使用方法包装来获取方法的参数和返回值。在原始方法成功执行后,它应在控制台显示审计日志。

  8. Audit 方法装饰器工厂应用于 updateScore 方法,参数值为 Updated score

  9. 创建一个名为 OneTwoThree 的参数装饰器,它将装饰的参数添加到 one-two-three 元数据属性中。

  10. 创建一个名为 Validate 的方法装饰器,它将使用方法包装来加载 one-two-three 元数据属性的值,并对所有标记的参数检查它们的值。如果值为 123,则应继续执行原始方法。如果不是,则应停止执行并显示错误。

  11. OneTwoThree 装饰器应用于 updateScorebyPoints 参数,并将 Validate 装饰器应用于 updateScore 方法:

    Create a game object, and update its score a few times. The console should reflect the applications of all decoratorsas shown:
    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 3, true ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    //…
    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 2, true ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 2, false ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    7:8
    

    注意

    为了便于展示,这里只展示了预期输出的部分。本活动的解决方案可以通过这个链接找到。

在这个活动中,你利用装饰来快速高效地实现复杂的横切关注点。当你成功完成活动后,你将根据应用需求实现多种类型的装饰器,从而在不牺牲清晰度和可读性的情况下扩展代码的功能。

摘要

在本章中,你了解了一种称为 装饰 的技术,它在 TypeScript 中是原生支持的。本章首先确立了使用装饰器的动机,然后探讨了 TypeScript 中的多种装饰器类型(类、方法、访问器、属性和参数装饰器),并考察了每种的可能性。你学习了如何使用类装饰器交换或更改类的完整构造函数,如何使用方法装饰器包装单个方法或属性访问器,以及如何使用属性和参数装饰器丰富可用的元数据。

本章还讨论了主动装饰器和被动装饰器之间的区别,这归结为代码和定义之间的区别。你实现了每种装饰器类型的几个常见变体,并展示了不同类型的装饰器如何很好地相互补充。本章应有助于你轻松管理来自第三方库(如 Angular)以及你自己创建的装饰器工厂中装饰器的使用和创建。在下一章中,我们将开始探索 TypeScript 中的依赖注入。

第九章:8. TypeScript 中的依赖注入

概述

本章向您介绍 TypeScript 中的 依赖注入DI)。它演示了如何实现 DI 设计模式。您还将看到一些 DI 模式的常见用例,包括来自 Angular 和 Nest.js 等库的用例。本章将教会您如何构建一个简单的 Angular 应用程序,该程序使用 DI。您还将学习 InversifyJS 的基础知识以及如何在您的 TypeScript 应用程序中使用它。到本章结束时,您将能够使用 InversifyJS 构建一个利用 DI 的计算器应用程序。

简介

设计模式是一种通用的、可重复的解决软件设计中常见问题的方法。它不仅仅是您可以粘贴并用于您自己的代码的代码,而是一种编写代码的指南。它通常与任何特定语言无关,因此给定的模式可以从一种语言转换到另一种语言,其实现根据所需的语言和环境进行更改。

设计模式通常可以用于许多不同的场景,并帮助您解决许多不同的问题。例如,如果您想确保只有一个活动连接到数据库,您可能想使用 Singleton 设计模式,它基本上确保只有一个实例存在,或者如果您想编写一个 ORM 工具(一个对象关系映射工具,用于抽象数据库),允许使用多个数据库,您可能想使用 Adapter 设计模式,它允许 ORM 工具使用“通用语言”与多种类型的数据库驱动程序进行通信。

使用设计模式可以加快开发速度,因为它们通过数十年的先前使用在各种问题中得到了实战检验。此外,如果在一个团队中工作,与常规方法相比,解释给定的解决方案更容易。设计模式充当一种“通用语言”。

注意,当开始学习设计模式的概念时,可能很难理解它们,您可能会发现使用它们解决问题比不使用它们更困难。这是因为很难确定特定的设计模式是否适合给定的问题,尤其是当您没有使用它的经验,或者不完全理解该模式或问题时。还有一些模式比其他模式更容易理解(例如,Singleton 模式比 Adapter 模式更容易理解)。

此外,如果你刚开始使用设计模式,其有用性可能直到项目生命周期的后期才会显现,那时你可能实际上想要添加你最初没有考虑到的功能,或者只是修复一些错误。最后,需要注意的是,并不是所有问题都可以通过设计模式来解决,使用错误的设计模式可能会带来比解决的问题更多的问题。同样,并不是每个问题都需要设计模式——你可以在“Hello World”程序中添加尽可能多的模式,但它们的有用性是可疑的。因此,重要的是退一步,看看使用它是否真的适合你试图解决的问题。

依赖注入设计模式

依赖注入(DI)是一种技术,其中一个对象提供另一个对象的依赖。对象的依赖是指为了在应用程序中执行其操作所需的任何东西。在深入解释 DI 之前,让我们通过一个例子来尝试理解前定义中的基本元素。

假设我们有两个类:

![图 8.1:简单的类依赖关系

![img/B14508_08_01.jpg]

图 8.1:简单的类依赖关系

如前图所示,类 A 使用了类 B 的一些属性/方法。因此,我们可以说ClassBClassA的依赖。

让我们看看一个更实际的例子(尽管是简化的)。大多数网站,无论是社交媒体网站、政府网站提供的服务,还是电子商务平台,都需要用户注册才能使用网站提供的服务。想象一下,你正在开发这样一个网站。你需要一个UserRegistrationService类来收集用户详细信息,将它们保存到数据库、文件或其他存储库中,然后向用户发送一封电子邮件,告知他们注册成功。

你网站处理注册流程的方法可能看起来像这样:

class UserRegistrationService {
    registerUser(email: string, password: string) {
        // TODO: process registration
        // TODO: send registration success email
    }
}

此服务有两个主要职责——将用户的详细信息保存到持久存储中,并向他们发送电子邮件。目前,你不必关心这些详细信息是存储在数据库中、SaaS 中还是文件中。同样,你也不必关心注册电子邮件是自动化的还是手工完成的。因此,我们只想得到一些UserRepository和一些EmailService,如图所示:

interface User {
    email: string;
    password: string;
}
interface UserRepository {
    save(user: User): Promise<User>;
}
interface EmailService {
    sendEmail(to: string, subject: string, body?: string): Promise<void>;
}

如前所述,我们不在乎它们的实现,甚至不关心创建它们;我们希望别人来做这件事,因此我们的UserRegistrationService实现可能看起来像这样:

class UserRegistrationService {
    constructor(
        private userRepository: UserRepository,
        private emailService: EmailService
    ) {}
    async registerUser(email: string, password: string){
        await this.userRepository.save({
            email,
            password,
        });
        await this.emailService.sendEmail(email, 'Welcome to my website!');
    }
}

注意,我们不知道UserRepositoryEmailService的实际实现是什么;我们只知道它们的结构。

现在,如果我们改变用户保存的方式,例如,决定从文件迁移到 MySQL 数据库,或者如果我们改变我们的电子邮件提供商从 Mailchimp 到 SendGrid,UserRegistrationService类保持不变,并且只要任何实现都符合相同的UserRepositoryEmailService接口(例如,具有相同的结构——相同的方法签名,相同的参数等),并且提供与之前描述相同的函数性,它应该仍然像以前一样正常工作。

例如,在以下代码片段中,注意基于文件和基于 MySQL 的实现都实现了UserRepository,这是UserRegistrationService所知道的唯一东西。

基于文件的实现如下:

// FileUserRepository.ts
import * as fs from 'fs';
class FileUserRepository implements UserRepository {
  save(user: User): Promise<User> {
    return new Promise((resolve, reject) => {
      fs.appendFile('users.txt', JSON.stringify(user), err => {
        if (err) return reject(err);
        resolve(user);
      });
    });
  }
}

基于 MySQL 的实现如下:

// MySqlUserRepository.ts
import mysql from 'mysql';
class MySqlUserRepository implements UserRepository {
  connection = mysql.createConnection({
    // connection details
  });
  save(user: User): Promise<User> {
    return new Promise((resolve, reject) => {
      return this.connection.query(
        `INSERT INTO users (email, password)
        VALUES (?, ?)`,
        [user.email, user.password],
        (err, data) => {
          if (err) return reject(err);
          resolve(data);
        }
      );
    });
  }
}

简单来说,DI 允许我们将什么如何分开。依赖类只需要知道如何与用户仓库交互——通过调用一个名为save的方法,该方法接受一个User类型的单个参数),以及与邮件发送者交互——通过调用一个名为sendEmail的方法,该方法接受两个参数;一个收件人电子邮件地址,为string类型,第二个参数为电子邮件的主题,也为string类型,以及一个可选的第三个参数用于电子邮件的正文(也为string类型)。

然后,这些服务可以处理应该(实际上)做什么的部分——将用户的详细信息保存到文件、MySQL 数据库或完全不同的地方,然后自动使用 SaaS 服务发送电子邮件,排队等待稍后手动发送,或做任何其他事情。

回到依赖关系图,在这个例子中,依赖关系如下:

图 8.2:UserRegistrationService 依赖关系

图 8.2:UserRegistrationService 依赖关系

在这里使用依赖注入(DI)的另一个好处是它简化了从依赖关系中单独测试我们的实现。例如,当测试UserRegistrationServiceregisterUser方法时,我们只想测试registerUser方法;我们不在乎其在生产中依赖项的行为(我们将单独测试这些)。我们可以在测试时用任何实现来模拟它们,使它们按我们的意愿行事。记住,DI 的整个目的就是我们不关心依赖项做什么以及它们是如何做的,只要它们符合约定的接口——在这个例子中是UserRepositoryEmailService。以下是我们在代码中测试registerUser方法的方式:

interface User {
  email: string;
  password: string;
}
test('User registration', async () => {
  const mockUserRepository: UserRepository = {
    async save(user: User) {
      return user;
    },
  };
  const mockEmailService: EmailService = {
    async sendEmail(to: string, subject: string, body?: string) {},
  };
  const userRegistrationService = new UserRegistrationService(
    mockUserRepository,
    mockEmailService
  );
  await userRegistrationService.registerUser(
    'example@domain.com',
    'super-secret-password'
  );
  expect(mockUserRepository.save).toHaveBeenCalled();
  expect(mockEmailService.sendEmail).toHaveBeenCalled();
  // ...
});

尽管前面的例子只展示了类,但依赖项可以是任何类型——类、函数、普通对象,甚至是简单的常量(取决于语言和特定实现)。

例如,如果UserRegistrationService需要常数,例如,用于与用户密码散列的盐,它也会在构造函数中提供,作为另一个参数,如下所示:

import * as bcrypt from 'bcrypt';
class UserRegistrationService {
  constructor(
    private userRepository: UserRepository,
    private emailService: EmailService,
    private passwordHashSalt: string
  ) {}
  async registerUser(email: string, password: string) {
    const hashedPassword = await bcrypt.hash(password, this.passwordHashSalt);
    await this.userRepository.save({
      email,
      password: hashedPassword,
    });
    await this.emailService.sendEmail(email, 'Welcome to my website!');
  }
}

注意

以下章节将使用装饰器,这些装饰器在第七章中有所介绍。请在继续之前确保您已经阅读并理解了它们,因为装饰器是所有接下来介绍的 DI 库构建的基础的重要组成部分。

与 DI 相关的另一个概念是UserRepository抽象,它覆盖了MySqlUserRepository实现),在 IoC 中,关注的是让消费者决定组件/库应该做什么。例如,在我们上面的UserRegistrationService实现中,我们使用了 IoC,因为我们允许指定用户详情的发送方式以及消费者发送电子邮件的方式。在应用程序的情况下,它可以决定是否想要使用FileUserRepositoryMySqlUserRepository,而在测试代码中我们决定它们都应该不执行任何操作。这也是在消费者(测试代码)级别做出的决定。

总结来说,DI 关注的是让一个类了解实现之上的抽象,而 IoC 的关注点在于让消费者决定应该使用哪些实现。

一些流行的框架,无论是前端还是后端,都将 DI 作为其框架的核心部分——最流行的是前端开发中的 Angular 和后端开发中的 Nest.js。DI 使得构建在这些框架之上的应用程序非常健壮和灵活,尤其是在大型应用程序中,因为 DI 的性质允许将类的创建(以及其他依赖项)与其使用分离。

Angular 中的 DI

另一个实际的现实世界中的 DI 例子可以在 Angular 框架中找到——这是一个使用 TypeScript 构建前端应用程序的现代框架。Angular 有一个自己的 DI 库实现。此外,Angular 框架本身以及基于它的应用程序,都严重依赖于这个 DI 实现。

让我们看看一个简单的 Angular 应用程序,看看 DI 如何使构建易于维护、可扩展的应用程序变得简单。

一个 Angular 应用程序由几个NgModule组成,每个NgModule通常是应用程序的一个逻辑部分——这可以是功能、UI 组件库或其他任何东西。每个NgModule可以有两种类型的“东西”:

  1. 声明(ComponentDirective

  2. 提供者(通常是Service

声明构成了应用程序的 UI,例如WelcomeMessageComponent类(如下面的片段所示),它接受name作为输入(使用@Input装饰器,这有点像向函数或类的构造函数传递参数,只是针对组件),并在 HTML h1标签(用于显示主要标题的 HTML 标签)中显示它:

import { Component, Input } from '@angular/core';
@Component({
    selector: 'welcome-message',
    template: `
        <h1>Welcome {{ name }}!</h1>
    `,
})
export class WelcomeMessageComponent {
    @Input() name: string;
}

上述代码将产生以下输出:

![图 8.3:渲染 WelcomeMessageComponent 的显示输出将"John"传递给名称输入img/B14508_08_03.jpg

图 8.3:将"John"传递给名称输入时的 WelcomeMessageComponent 渲染输出

提供者通常是服务,它们持有应用程序的主要逻辑,通常用于与 UI 无关的任何事物。

例如,你可以有一个UsersService类,它负责从后端获取用户列表,如下所示:

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
export interface User {
    name: string;
}
@Injectable()
export class UsersService {
    getUsers(): Observable<User[]> {
        return of([
            { name: 'Alice' },
            { name: 'Bob' },
            { name: 'Charlie' }
        ]);
    }
}

前面的代码中有一个UsersService类,它只有一个方法——getUsers(),该方法返回一个静态的User对象数组。请注意,我们用of()包装我们的静态数组,它接受一个静态值并将其包装在一个Observable中,这样我们就可以稍后更改此方法的行为,使其异步返回数据(例如,从远程端点,如我们将在下一节中看到)。

注意

可观察对象是一个异步的数据流,基本上允许数据在“发布者”和“订阅者”之间传递。这些数据可以是一次性操作,例如使用 HTTP 调用,可以有多个发射(例如,按顺序每秒发射从 1 到 10 的递增数字),甚至可以是无限的(例如,每次用户点击特定的按钮时都会发射一个事件)。它是观察者模式的一部分。

然后,我们将在UsersList组件中使用UsersService,该组件以列表形式显示用户,如下所示:

import { Component } from "@angular/core";
import { Observable } from "rxjs";
import { UsersService, User } from "./users.service";
@Component({
  selector: 'users-list',
  template: `
    <ul>
      <li *ngFor="let user of (users$ | async)">
        {{ user.name }}
      </li>
    </ul>
  `
})
export class UsersListComponent {
  readonly users$: Observable<User[]>;
  constructor(private usersService: UsersService) {
    this.users$ = usersService.getUsers();
  }
}

在这里,我们创建了一个简单的组件,UsersListComponent,它显示用户列表,它从UsersService获取用户,该服务在创建时通过 Angular DI 注入到其中。

一旦服务被注入,我们就调用getUsers()并将返回的Observable存储在users$成员变量中,这样我们就可以稍后从模板中访问它,该模板利用async管道告诉 Angular 订阅Observable并在其基础值变化时更新模板:

图 8.4:运行应用程序的输出

图 8.4:运行应用程序的输出

我们不会深入探讨 Angular 的模板引擎或变更检测机制——这些本身都是两个很大的主题——但你可以参考 Angular 文档以获取更多相关信息。相反,让我们专注于 DI 方面的情况——注意我们在UsersListComponents构造函数中请求了一个UsersService对象;我们没有指定我们想要获取服务的特定实例,等等,只是我们想要一个。这非常强大,因为它将如何以及在哪里实例化此服务的逻辑卸载到一个专门的地方(NgModule),并打开了很多可能性。我们可以更容易地测试组件(通过提供一个假的UsersService),甚至可以在运行时用另一个实现替换UsersService

Angular 提供者也可以要求其他提供者;例如,我们可以有一个通用的 HTTP 客户端服务,该服务知道如何进行 HTTP 调用,然后将其注入到我们的 UsersService 中,这样我们的 UsersService 就可以专注于更高级别的细节,例如端点,它需要使用该端点来获取用户信息。实际上,Angular 内置了一个这样的 HTTP 服务,称为 HttpClient。您可以使用它,并使用真实的实现来修复我们之前为用户创建的模拟实现,如以下示例所示:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface User {
    name: string;
}
@Injectable()
export class UsersService {
    constructor(private httpClient: HttpClient) {}
    getUsers(): Observable<User[]> {
        return this.httpClient.get<User[]>('/api/users');
    }
}

在这里,我们请求一个 HttpClient 并使用其 get() 方法向我们的网站中的 /api/users 端点发起一个 GET 请求,该端点应返回一个 User 对象数组——即具有名为 namestring 类型属性的对象。

这通过调用外部端点而不是返回静态的用户列表,用更贴近现实世界的用例替换了我们之前使用的模拟实现。

再次注意,我们再次请求了一个 HttpClient 接口。我们不在乎它是如何实现的(这可能涉及使用 XMLHttpRequestfetch 或甚至另一个底层库),只要它符合 HttpClient 接口即可。

你可能已经注意到,我们从 HttpClient 请求的路径是相对路径。如果我们的后端与前端位于同一域名下(例如,example.com 是我们的网站,example.com/api/users 将返回用户信息),则这种方式是可行的。然而,如果我们想将后端迁移到不同的服务器,这将破坏我们的网站。在下一个练习中,我们将通过使用 Angular 的依赖注入机制并添加 HttpInterceptor 来解决这个问题。

HttpInterceptor 是 Angular 提供的一个接口,我们可以实现它来“钩子”或甚至更改网络请求,无论是在请求的途中(请求)还是在返回的途中(响应),在任何其他消费者“看到”响应之前。这将适用于应用程序中任何使用 HttpClient 的地方,而无需在其他使用 HttpClient 的服务中进行任何更多的代码修改。

注意

本节讨论的示例是我们下一个练习的基础。

练习 8.01:向 Angular 应用添加 HttpInterceptor

在这个练习中,我们将向我们在上一节中构建的现有 Angular 应用程序添加 HttpInterceptor,以便我们的后端服务可以位于与前端应用程序不同的域名上。这使得两个应用程序可以完全且非常容易地分离,而无需对应用程序的其他部分进行任何额外更改。以下是完成此练习的步骤:

注意

在开始之前,请确保在exercise-starter目录中运行npm install。本练习的代码文件可以在此处找到:packt.link/avWRA。此存储库包含两个文件夹,exercise-starterexercise-solution。前者包含您可以使用来与该练习一起编码的模板文件,而后者包含本练习的最终代码,供您参考。

  1. 从本节中已编写的应用程序开始克隆。这可以在packt.link/JAgZ7找到。

  2. 在新的文件api-http.interceptor.ts中创建一个名为ApiHttpInterceptor的类,并将其保存在exercise-starter/src/app/interceptors/文件夹中。此文件实现了HttpInterceptor接口(从@angular/common/http导入)。务必使用@Injectable装饰器标记它,以便 Angular 知道它是一个可以在 DI 中使用的服务:

    import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
    import { Injectable } from '@angular/core';
    import { Observable } from 'rxjs';
    @Injectable()
    export class ApiHttpInterceptor implements HttpInterceptor {
      intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        throw new Error('Method not implemented.');
      }
    }
    

    当任何HttpClient发起请求时,Angular 将调用ApiHttpInterceptorintercept()方法。我们获取请求(req)和HttpHandlernext),当我们完成时需要调用它们,以便 Angular 调用链中的任何其他HttpInterceptor

  3. 更新代码以更改 URL 路径:

    import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http";
    import { Injectable } from "@angular/core";
    import { Observable } from "rxjs";
    @Injectable()
    export class ApiHttpInterceptor implements HttpInterceptor {
        intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        if (!req.url.startsWith('/api/')) {
          return next.handle(req);
        }
        const relativeUrl = req.url.replace('/api/', '');
        const newRequest = req.clone({
    url: `https://jsonplaceholder.typicode.com/${relativeUrl}`
        });
        return next.handle(newRequest);
      }
    }
    

    上述代码检查 URL 路径。对于每个请求,如果它是一个以/api开头的相对路径,代码会将其更改。它是通过查看HttpRequesturl属性来做到这一点的。如果 URL 不以/api开头,您不需要做任何事情,只需使用原始请求调用next.handle()。否则,使用新的 URL 克隆原始请求,然后使用新的请求调用next.handle()。这是实际发送出去的请求。我们在这里使用jsonplaceholder.typicode.com,这是一个免费服务,它有一些预定义的端点,我们可以用来获取测试数据。在实际应用中,这将是你后端服务的端点。

    最后,我们还需要在我们的AppModule中注册此拦截器,以便它知道要注入到HttpClient中的拦截器。我们通过添加我们创建的提供者ApiHttpInterceptor来实现这一点,并告诉 Angular 在查找HTTP_INTERCEPTORS时使用它——这是 Angular 在通过HttpClient服务进行网络请求时请求所有所需拦截器的 DI 符号。

  4. 打开位于exercise-starter/src/app文件夹中的app.module.ts文件,并使用此处提供的代码进行更新:

    import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
    import { NgModule } from '@angular/core';
    import { BrowserModule } from '@angular/platform-browser';
    import { AppComponent } from './app.component';
    import { ApiHttpInterceptor } from './interceptors/api-http.interceptor';
    import { UsersListComponent } from './users-list.component';
    import { UsersService } from './users.service';
    import { WelcomeMessageComponent } from './welcome-message.component';
    @NgModule({    
      imports: [BrowserModule, HttpClientModule],
      declarations: [AppComponent, WelcomeMessageComponent, UsersListComponent],   
      providers: UsersService, { HTTP_INTERCEPTORS list (notice the bold line).
    
  5. exercise-starter目录中运行npm start -- --open来运行新应用程序。您的默认浏览器应该在http://localhost:4200打开,并且您应该看到一个包含 10 个用户的列表:

![图 8.5:练习输出

![图片

图 8.5:练习输出

如果你打开开发者工具,你应该只看到对 users 端点的单个请求,这是对 jsonplaceholder.typicode.com/users(而不是 localhost:4200/users)的请求:

图 8.6:对用户端点的请求

图 8.6:对用户端点的请求

注意,这里的 UsersService 完全没有改变(你可以想象如果我们有几十个这样的服务,会有什么好处),但它仍然按照预期工作。

本节和练习中解释的所有代码只是 DI 在 Angular 中应用的几个示例。然而,还有更多。你可以将任何值注册为依赖项进行注入(不仅仅是类)。你可以控制提供者的实例化,使其在整个应用程序中为单例,为每个 NgModule 或甚至每个 Component 实例创建一个新实例。你还可以通过工厂和更多复杂逻辑来创建它们。你只是刚刚触及了 Angular 提供的非常强大的 DI 库的表面。

Nest.js 中的依赖注入

另一个值得关注的框架,其架构也深受 Angular 影响,是 Nest.js,它也大量使用了依赖注入(DI)。Nest.js 是一个用于使用 Node.js 和 TypeScript 构建后端应用的框架。与 Angular 类似,Nest.js 也拥有 Modules(相当于 Angular 的 NgModule)和 Providers。它还包含 Controller,用于处理来自客户端的请求并返回响应。这些与 Angular 的组件类似——两者都是用户所看到的。在 Angular 中,ComponentDirective 构成了 UI,而在 Nest.js 中,Controller 构成了可消费的 API。

我们不会深入探讨 Nest.js 的架构,但这里有一个简单的例子,展示了它如何利用依赖注入(DI):

import { Controller, Get, Param } from '@nestjs/common';
import { HelloService } from './hello.service';
@Controller('hello')
export class HelloController {
  constructor(private helloService: HelloService) {}
  @Get(':username')
  async getByUsername(@Param('username') username: string) {
    const message = await this.helloService.getHello(username);
    return { message };
  }
}

这是一个简单的 "Hello World" 控制器,对于 /hello/fooGET 请求,将返回 { message: "Hello foo" }。控制器是在给定前缀下的端点容器(因此在这种情况下,任何以 "/hello" 开头的请求都将最终到达这个控制器),而 @Get 装饰器围绕 getByUserName() 函数告诉 Nest.js 当对给定路径执行 GET 方法时调用该方法(方法/装饰器的路径将与控制器的路径连接)——在这种情况下是 "/hello/:username"(以 : 开头的任何内容都是动态内容的占位符。在这种情况下,:username 是占位符,我们可以通过使用 Param 装饰器来获取它,给它占位符的名称)。

注意,我们通过 DI 在构造函数中获取 HelloService,类似于 Angular。我们还从 Param 装饰器中获取用户名参数,这也利用了背后的 DI 来获取当前的 Request 对象。最后,框架负责为我们创建 HelloServiceHelloController;我们不需要自己这样做。这就像在 Angular 中一样,使得测试 HelloController 变得容易,因为你可以只是用模拟实现来伪造 HelloService,以断言或修改控制器的行为。这是一个非常简单的例子,但你可以想象将 HelloService 替换为类似认证服务或数据库访问的 ORM 工具。

在下一节中,我们将介绍 InversifyJS - TypeScript(以及 JavaScript)应用程序的 IoC 容器。与仅适用于前端或仅适用于后端的 Angular 或 Nest.js 不同,它们都是框架,规定了应用程序的架构将是什么(至少在某种程度上),InversifyJS 是一个通用库,它只做 IoC,并允许你在任何应用程序中使用 DI。

InversifyJS

InversifyJS 是 TypeScript(以及 JavaScript)应用程序的 IoC 容器(控制反转,DI 是其一部分)的一个实现。它是众多实现之一,正如我们上面所看到的,一些框架自带自己的 DI 解决方案,例如 Angular 或 Nest.js。

注意

InversifyJS 之外的其他通用项目替代方案包括 TypeDITSyringe 以及 typescript-ioc

InversifyJS 的基本思想,与其他大多数 IoC 容器的实现一样,是有一个地方定义所有功能的具体实现,而应用程序的其余部分只依赖于抽象(例如,接口)。这大大减少了耦合度,将一个实现更改为另一个实现不会影响整个应用程序或需要大量的代码更改。

注意

耦合度是指两个组件(通常是类)之间整合/依赖的紧密程度,也就是说,如果我们改变其中一个,另一个在没有对其做出相应更改的情况下崩溃的可能性有多大?两个组件之间整合/连接得越紧密,它们的耦合度就越高,反之亦然。

理想情况下,改变一个类不应该需要改变其他类。在这种情况下,这些类被认为是解耦的(或松耦合的)。

要使 InversifyJS 运作,我们首先需要添加 typeofinstanceof 操作符。

此外,由于 InversifyJS 通过装饰器工作,您需要通过在项目的 tsconfig.json 文件中将 experimentalDecoratorsemitDecoratorMetadata 设置为 true 来启用它们(注意粗体行):

{
    "compilerOptions": {
        "target": "es5",
        "lib": ["es6", "dom"],
        "types": ["reflect-metadata"],
        "module": "commonjs",
        "moduleResolution": "node",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    }
}

注意

为了使 InversifyJS 正常工作,还有一些额外的要求,但所有现代浏览器和 Node.js 版本都应该能够使用它而无需进一步的多重填充。有关更多详细信息,请访问以下链接:github.com/inversify/InversifyJS/blob/master/wiki/environment.md

就像 Angular 和 Nest.js 的 DI 容器(分别对应NgModuleModule)一样,InversifyJS 也需要知道如何解析依赖。这通常在单个位置配置,通常在项目的根目录下名为inversify.config.ts的文件中。

注意

这是推荐的做法,但此文件可以放在任何地方,命名为任何名称,或者拆分为多个文件;例如,为了分离不同功能或域的类的注册,类似于 Angular 中的NgModules或 Nest.js 中的Modules

此文件应该是应用程序中唯一存在耦合的地方。应用程序的其余部分应仅依赖于抽象。

这些抽象通常会是接口,但你也可以依赖于一个特定的实现,或者一个(然后可以注入一个兼容的子类)。

此外,由于 TypeScript 中的接口仅在编译时存在(见第七章继承和接口),InversifyJS 也需要一个运行时抽象令牌来知道要解析什么。

练习 8.02:使用 InversifyJS 的“Hello World”

在这个练习中,我们将使用 InversifyJS 创建一个简单的“Hello World”应用程序。我们将实现典型用例的所有基本构建块。按照以下步骤实现此练习:

注意

此练习的代码文件可以在packt.link/bXSTd找到。

  1. 首先,在src文件夹中创建一个名为logger.interface.ts的新文件,使用interface创建我们日志记录器的抽象。这是消费者稍后将要引用的内容:

    export interface Logger {
        log(message: string): void;
    }
    
  2. 接下来,为Logger创建一个具体实现。当消费者稍后需要Logger时,他们将获得此实现注入的代码:

    import { injectable } from "inversify";
    import { Logger } from "./logger.interface";
    @injectable()
    export class ConsoleLogger implements Logger {
        log(message: string) {
            console.log(message);
        }
    }
    

    注意,ConsoleLogger实现了Logger。这确保了我们编写了一个与消费者期望兼容的实现,并且它们在运行时不会出错。此外,@injectable装饰器用于向 InversifyJS 指示此实现可以作为依赖项使用,并且也可以注入到其他依赖项中。这是我们让 InversifyJS 意识到ConsoleLogger是它应该知道的东西的方式。

  3. src文件夹中创建一个名为types.ts的新文件。然后,定义一个注入令牌,消费者可以在以后依赖它来请求 InversifyJS 在运行时注入其背后的任何实现:

    export const TYPES = {
        Logger: Symbol.for("Logger"),
    };
    

    在这个练习中,我们将坚持使用推荐的创建一个TYPES对象的方法,该对象解析为每个类型的Symbol(在 TypeScript 的大多数 DI 库中需要使用注入令牌,因为接口在运行时不存在,所以 InversifyJS 不能依赖于它们)。

    注意

    如果你的目标环境不支持symbols,你可以改用普通字符串。只需确保不要为多个类型注册相同的字符串。

  4. src文件夹中创建一个名为ioc.config.ts的新文件。然后,使用以下代码配置 IoC 容器:

    import { Container } from "inversify";
    import { ConsoleLogger } from "./console-logger";
    import { Logger } from "./logger.interface";
    import { TYPES } from "./types";
    console-logger, logger.interface, and types) together:
    
  5. src文件夹中创建一个名为main.ts的新文件,为 logger 创建一个消费者。注意,我们使用@inject装饰器来告诉 InversifyJS 我们想要Logger类型:

    import "reflect-metadata";
    import { inject, injectable } from "inversify";
    import { container } from "./ioc.config";
    import { Logger } from "./logger.interface";
    import { TYPES } from "./types";
    @injectable()
    class Main {
        constructor(@inject(TYPES.Logger) private logger: Logger) {}
        run() {
            this.logger.log('Hello from InversifyJS!');
        }
    }
    // Run the app:
    const main = container.resolve(Main);
    main.run();
    

    注意

    接口类型注解只是为了 TypeScript 能够对logger实例进行类型检查,但由于接口仅在编译时存在,这在运行时是不相关的,传递给@inject的参数才是关键。

  6. 现在,通过在父目录中执行npm start来运行应用。你应该在你的控制台看到以下输出:

    Hello from InversifyJS!
    

当然,对于这样一个简单的例子,最好只使用一行代码如下:

console.log('Running');

然而,在更复杂的应用中,甚至在简单的应用中,DI 可以帮助,特别是如果应用预计将积极维护,不断添加功能和修复错误。

在下一个活动中,你将负责创建一个更复杂的应用来展示 DI 如何帮助我们开发应用,同时保持最佳实践以使应用易于维护。

活动 8.01:基于 DI 的计算器

作为 TypeScript 开发者,你的任务是创建一个计算器。像任何计算器一样,你需要你的应用能够执行四个基本的数学运算:加法(+), 减法(-), 乘法(*)和除法(/)。

注意

为了保持简单并专注于 DI,你不会添加对其他操作符(例如,幂(^))的支持,也不会支持运算符的优先级,因此你的计算器将只从左到右遍历表达式并执行相关操作。例如,表达式(13+5*3-7)将得到47,而不是数学上正确的21

要完成这个活动,你必须实现 InversifyJS 并利用 IoC 来提供计算器可以操作的计算运算符。

你可以从入门项目开始,按照这里提供的高级步骤逐步构建。这项活动将挑战你在本章以及之前章节中开发的所有技能。因此,如果你在实现或代码中遇到任何问题,可以自由地查看解决方案进行调试。

注意

此活动基于最后一节,即 InversifyJS,因此在继续进行之前,请确保您完全理解它。您可以在packt.link/Pt3Vq找到活动启动器和解决方案。activity-starter文件夹包含您可以用于与该活动一起编码的模板文件。activity-solution文件夹包含代表此活动解决方案的文件。

执行以下步骤以实现此活动:

  1. 您将不得不从创建计算器的基本构建块开始——通过接口定义的运算符。

  2. 然后,创建加法、减法、乘法和除法运算符。

    对于前面的两个步骤,请注意,您需要创建必要的抽象接口和注入令牌。

  3. 实现一个使用 InversifyJS 的运算符的计算器类。此文件代表您的应用程序。您可能需要映射所有表达式部分并解析它们。为此,您可以参考位于src/utils文件夹中的maths.ts文件,该文件创建并导出两个这样的函数——tryParseNumberStringtryParseOperatorSymbol

  4. 配置 IoC 容器(位于src/ioc.config.ts文件中),以便当Calculator请求TYPES.AddOperator等时,它可以接收AddOperatorSubtractOperator等。您可以通过使用 barrels 进一步简化ioc.config.ts文件。相关代码可以在operator/index.ts文件中找到。您可以使用上述文件中的代码来配置并简化您的 IoC 容器。

  5. 创建main.ts文件,该文件将启动您的计算器。

    在解决前面的步骤之后,预期的输出应如下所示:

    result is 150
    
  6. 附加步骤:

    作为附加内容,假设您想要对计算器中执行的操作进行一些报告。您可以轻松地添加日志记录(基于控制台和文件),而无需太多更改:

  7. 对于基于控制台的日志记录,您需要通过依赖注入(DI)添加一个日志记录器,计算器将在每次表达式评估时写入该日志记录器。您可以按照以下步骤进行操作。首先,您需要定义Logger接口。然后,创建基于控制台的Logger实现。接下来,创建一个注入令牌并将其注册到我们的容器中。然后,在主计算器应用程序的代码中使用该日志记录器。

  8. 现在,假设我们想要将基于控制台的日志记录器替换为基于文件的日志记录器,这样它就可以在运行之间持久化,以便我们可以跟踪计算器的评估历史。

  9. 要做到这一点,您首先需要在src/logger文件夹中创建一个新文件,创建一个实现LoggerFileLogger类。然后,您需要在用于基于控制台的日志记录的ioc.config.ts文件中进行单行更改。

    对于基于控制台的日志记录,使用以下命令:

    container.bind<Logger>(TYPES.Logger).to(ConsoleLogger);
    

    对于基于文件的日志记录,使用以下命令:

    container.bind<Logger>(TYPES.Logger).to(FileLogger);
    

    然而,请注意,您必须正确地在所有文件中导入所有Logger接口。

    基于控制台的日志记录器的输出如下:

    [LOG] Calculated result of expression:13*10+20 is 150
    

    基于文件的记录器的输出如下:

    ![图 8.7:将应用程序更改为使用基于文件的记录器后,activity-starter/src/tmp/calculator.log 文件中的最终输出]

    图片

图 8.7:将应用程序更改为使用基于文件的记录器后,activity-starter/src/tmp/calculator.log 文件中的最终输出

注意

您可以通过这个链接找到这个活动的解决方案。

这个活动的解决方案(activity-solution)还包括针对所有内容的单元测试,因此您可以了解当使用 IoC 时测试是多么简单,以及检查您的实现是否通过测试。此外,activity-solution还包括一个创建ConfigurationService的文件,该服务为FileLogger提供动态的loggerPath,包括内存中的实现或基于环境变量的实现。

在 InversifyJS 方面还有更多内容需要探讨。然而,本章提供了一个良好的起点。我们鼓励您查看官方文档,以了解更多它所能提供的内容,并查看更多示例,包括工厂、容器模块和中间件。然而,这些主题超出了本章的范围。

摘要

本章首先通过解释如何实现依赖注入设计模式以及通过一系列用例向您展示,使您了解了 TypeScript 中依赖注入的基础知识。您还学习了如何使用依赖注入构建基本的 Angular 应用程序。

本章还介绍了 InversifyJS 的一些基础知识,并解释了如何在应用程序中使用它。您已经看到,在不破坏其他消费者的代码的情况下添加或更改依赖项是多么容易,以及 IoC 和 DI 如何以非常简单的方式替换一种实现为另一种实现,对所有消费者来说都是如此。

当然,关于这个主题的内容远不止本章所涵盖的。然而,本章为在 TypeScript 中使用依赖注入(DI)提供了一个良好的起点。在下一章中,你将学习 TypeScript 中的泛型。

第十章:9. 泛型和条件类型

概述

本章介绍了泛型和条件类型。本章首先教你了解泛型是什么,以及在不同上下文(如接口、类、函数等)中的一些基本泛型用法。接下来,你将学习泛型约束,以及如何在使用泛型时使你的代码更安全,以避免运行时错误。最后,你将学习条件类型以及它们如何通过在编译时引入类型级别的逻辑使泛型更加强大。

到本章结束时,你将能够将泛型应用于实际用例。

简介

在上一章中,我们看到了如何在 TypeScript 中使用依赖注入。在本章中,我们将介绍 TypeScript 类型系统提供的两个更高级的功能,这些功能主要用于高级应用程序或构建库时——泛型和条件类型。

TypeScript 包含一个非常强大的类型系统,涵盖了大量的用例和高级类型。在早期章节中,我们看到了一些更基本的方法,在构建应用程序时可以利用类型系统。

泛型是许多语言(如 Java、C#、Rust 和当然还有 TypeScript)的构建块之一,它们的目的是允许开发者在编写代码时使用未知类型(但稍后在使用这些泛型代码时会指定)来编写动态和可重用的泛型代码片段。换句话说,泛型在创建应用程序时不知道具体类型时是一种“占位符”。

例如,如果你想编写一个泛型List数据结构,其实现在乎存储什么类型的项都是相同的,但实际存储的项类型在编写List类时是未知的。然后我们可以使用泛型作为编写时的“占位符”类型,而List类的使用者将在知道它将使用的具体类型时指定它,从而填充这个“占位符”。

条件类型允许我们将逻辑引入 TypeScript 的类型系统,这将在编译时进行检查。这意味着我们的类型可以更安全,我们可以使代码更严格,并将一些逻辑从运行时移动到编译时,这意味着服务器或用户浏览器上需要运行的代码更少。此外,条件类型允许我们编写更复杂的类型,它们之间有更复杂的关系。

例如,如果我们想从一个字符串字面量联合中删除一些选项,我们可以使用Extract类型只取其中的一些:

type Only FooAndBar = Extract<"foo" | "bar" | "baz", "foo" | "bar">;  // "foo" | "bar"

虽然不限于与泛型类型一起使用,但条件类型通常在这些情况下使用,因为你想在未知类型上编写一些逻辑,并且提前编写,否则你可以自己明确地编写它。

在本章中,我们将探讨泛型和条件类型,并了解它们如何使您的代码更加健壮,对变化具有更强的抵抗力,并在外部使用时提供更好的开发者体验。

泛型

如前所述,泛型帮助我们编写在编写时类型未知但在稍后使用时类型已知的代码。它们允许我们在其他情况下会使用具体类型的地方放置“占位符”,并且这些占位符可以在稍后由我们代码的使用者填充。泛型允许我们编写一次代码,并用于多个类型,而不会在过程中失去类型安全,甚至可以提高与不使用泛型所能达到的类型安全相比的类型安全。

让我们看看泛型如何帮助我们更正确地进行类型化,从一个非常基本的函数——identity——开始:

// identity.ts
function identity(x: number): number {
    return x;
}

identity函数接受一个number类型的x,并仅返回x。现在,假设我们还想为字符串提供相同的功能:

// identityString.ts
function identityString(x: string) {
    return x;
}

由于类型信息仅用于编译时,这两个函数在编译后的 JavaScript 输出中是完全相同的:

// identity.js
function identity(x) {
    return x;
}
// identityString.js
function identityString(x) {
    return x;
}

由于输出 JavaScript 代码相同,并且鉴于 TypeScript 只是在现有 JavaScript 的基础上添加类型,因此有一种方法可以对现有的identity函数进行类型化,使其支持这两种用例。我们可以以多种方式对identity进行类型化——最简单的方式是将x类型化为any。然而,这意味着我们在函数内部以及return类型中都会失去类型安全:

function identity(x: any): any {
    return x;
}
const result = identity('foo');
result.toFixed();

这可能不是我们想要的。由于result的类型是any,TypeScript 无法知道前面的代码中的result.toFixed()将在运行时抛出错误(因为字符串没有toFixed()方法):

图 9.1:运行此代码会在运行时引发 TypeError

图片

图 9.1:运行此代码会在运行时引发 TypeError

相反,我们可以利用泛型——我们将x类型化为一个泛型类型T,并从函数中返回相同的类型。考虑以下代码:

function identity<T>(x: T): T {
	return x;
}

在 TypeScript 中,泛型使用尖括号编写,并在其中放置一个占位符类型名称。在前面的代码中,T是泛型,并充当“占位符”。现在,如果我们用以下详细信息更新代码,我们将得到一个编译时错误,如下所示(红色下划线):

图 9.2:由于使用了泛型而导致的编译时错误

图片

图 9.2:由于使用了泛型而导致的编译时错误

注意

占位符类型名称可以是任何名称,其名称仅对使用代码的开发者有用——因此请尝试为泛型类型提供有意义的名称,这些名称在它们使用的上下文中是有意义的。

注意,我们只有一个(恒等)函数实现,它可以同时用于字符串和数字。TypeScript 还能自动识别返回类型,并在编译时提供有用的错误信息。此外,我们可以将任何其他类型传递给identity函数,而无需对其进行任何修改。

注意

在调用 identity() 时,我们甚至不需要告诉 TypeScript 泛型的类型。TypeScript 通常可以从参数中推断泛型(s)的类型。

通常,在调用函数时必须手动指定泛型的类型是一个代码异味(一个潜在代码可能存在更大问题的信号),当可以从参数中推断出来时(尽管也有例外)。

泛型可以以各种形式出现——从我们刚才看到的函数,到接口、类型和类。它们的行为都是一样的,只是在其各自的范围内——所以函数泛型只适用于该函数,而类泛型适用于该类的实例,也可以在其方法/属性中使用。在接下来的几节中,我们将探索这些泛型类型中的每一种。

泛型接口

泛型接口是具有一些附加类型的接口,这些类型在接口的作者之前是未知的,这些类型“附加”到接口上。这种附加类型为接口提供了“上下文”,并在使用它时提供了更好的类型安全性。

实际上,如果你以前使用过 TypeScript,你可能已经与泛型交互过,也许甚至没有意识到。它们无处不在——只需看看这段基本的代码:

const arr = [1, 2, 3];

如果你将鼠标悬停在 arr 上,你会看到它的类型是 number[]

![图 9.3:arr 的类型被推断为 number[]]

图片 B14508_09_03.jpg

图 9.3:arr 的类型被推断为 number[]

number[] 只是 Array<number> 的简写——泛型再次发挥作用。

在数组中,泛型用于数组持有的元素类型。没有泛型,Array 就必须用 any 类型在所有地方进行类型化,或者为每种可能的类型(包括非内置类型)都有一个单独的 interface,这是不可能的。

让我们看看 Array<T> 接口定义:

![图 9.4:Array<T> 接口的一部分,其中泛型被大量使用]

图片 B14508_09_04.jpg

图 9.4:Array<T> 接口的一部分,其中泛型被大量使用

正如你所见,poppushconcat 方法都使用 T 泛型类型来知道它们返回什么,或者它们可以接受什么作为参数。这就是为什么以下代码无法编译:

![图 9.5:尝试将不兼容的类型推送到具有特定泛型的数组时出现的错误]

一个特定的泛型类型

图片 B14508_09_05.jpg

图 9.5:尝试将不兼容的类型推送到具有特定泛型的数组时出现的错误

这也是 TypeScript 如何推断 mapfilterforEach 的回调中 value 的类型的方式:

![图 9.6:使用 Array 的 map 方法时的类型推断]

图片 B14508_09_06.jpg

图 9.6:使用 Arraymap 方法时的类型推断

泛型类型

泛型可以用于普通类型,例如,创建一个 Dictionary<V> 类型,也可以用来描述一个在类型 V 的任何值(在事先未知的情况下)和字符串之间的映射,因此是泛型的:

type Dictionary<V> = Record<string, V>;

泛型类型还有更多用例,但大多数情况下,你将要么与泛型约束(在本章后面解释)一起使用它们,要么用接口描述它们(尽管几乎任何interface能做的事情,type也能做到)。

泛型类

泛型对于类也非常有用。正如我们在本章前面看到的,内置的Array类使用了泛型。这些泛型在类的定义中指定,并应用于该类的实例。类的属性和方法可以利用这个泛型类型来定义它们自己的类型。

例如,让我们创建一个简单的Box<T>类,它可以存储任何类型的T值,并允许稍后检索它:

class Box<T>  {
    private _value: T;
    constructor(value: T) {
        this._value = value
    }
    get value(): T {
        return this.value;
    }
}

_value属性、构造函数和value获取器使用类定义中的T泛型类型作为它们的类型。如果这个类中还有其他方法,这个类型也可以用于这些方法。

此外,类的方法可以添加它们自己的泛型,这只会应用于该方法的作用域——例如,如果我们想向Box类添加一个map方法,我们可以这样编写:

class Box<T>  {
    ...
    map<U>(mapper: (value: T) => U): U {
        return mapper(this.value)
    }
}

U泛型类型可以在map方法声明及其实现中使用,但不能在其他类成员中使用(例如,与之前提到的value获取器不同),与T不同——T的作用域是整个类。

练习 9.01:泛型集合类

在这个练习中,我们将创建一个Set<T>类,它实现了Set数据结构——一种可以存储项目,没有特定顺序,且没有重复的数据结构,使用泛型。

按照以下步骤实现这个练习:

注意

这个练习的代码文件可以在以下链接找到:packt.link/R336a

  1. 首先,创建一个具有泛型T类型的Set类。这个类型将是集合中项目的类型:

    class Set<T> {
    }
    
  2. 接下来,让我们添加一个构造函数,它接受一些可选的初始值。这些值需要是一个包含类型T的项目的数组,以匹配我们的Set项目:

    class Set<T> {
      private items: T[];
      constructor(initialItems: T[] = []) {
        this.items = initialItems;
      }
    }
    

    我们使用默认参数来初始化initialItems为一个空数组,如果我们没有提供,这使得这个参数是可选的,同时在构造函数实现中仍然方便使用。

  3. 让我们添加一个size获取器,它返回集合的大小。这将是我们的items长度的简单表示:

    class Set<T> {
      private items: T[];
      //...
      get size(): number {
        return this.items.length;
      }
    }
    
  4. 接下来,让我们添加一个has方法,它检查给定项目是否已经在集合中:

    class Set<T> {
      private items: T[];
      //...
      has(item: T): boolean {
        return this.items.includes(item);
      }
    }
    

    注意,我们在has定义中使用T类型——我们可以使用它,因为它是类的作用域内,其中T被声明。

  5. 最后,我们还需要一种方法来向我们的集合中添加和删除项目——让我们添加这些方法:

    class Set<T> {
      ...
      add(item: T): void {
        if (!this.has(item)) {
          this.items.push(item);
        }
      }
      remove(item: T): void {
        const itemIndex = this.items.indexOf(item);
        if (itemIndex >= 0) {
          this.items.splice(itemIndex, 1);
        }
      }
    }
    

    对于add方法,我们首先检查给定的item是否已经存在,如果不存在,则添加它。

    对于remove方法,我们寻找给定项目的索引。如果它存在,我们就从数组中删除它。

  6. 现在,编写以下两行代码:

    const set = new Set <number>([1,2,3]);  
    set.add(1) // works – since 1 is a number
    set.add('hello') //Error – since 'hello' is not a number
    

    在你的集成开发环境(IDE)中,你会看到以下内容:

    图 9.7:由于泛型而实现的 Set 类中的类型安全

    图 9.7:由于泛型而实现的 Set 类中的类型安全

    我们可以看到如何使用 Set 类,以及它是如何保持自身类型安全的,不允许在同一个类中将多种类型的项混合在一起,例如在以下 步骤 7 中。

  7. 最后,如果你回到 Set 类的实现,你会注意到类中 items 的类型是 T[],所以如果我们尝试向 items 数组添加 TypeScript 不认识的类型为 T 的项,我们会得到一个错误:

图 9.8:由于泛型而实现的 Set 类中的类型安全

图 9.8:由于泛型而实现的 Set 类中的类型安全

这是预期的,因为 T 可以是任何类型,而不仅仅是字符串——正如我们在前面的例子中创建 Set<number> 所见——一个只能包含数字的集合。

泛型函数

我们已经在本章开头简要地看到了泛型函数,即 identity<T>() 函数。但是,让我们看看一个更实际、更有用的用例——比如说,你想围绕 fetch() 编写一个包装器来获取 JSON 数据,这样用户就不必在响应上调用 .json()。考虑以下代码:

interface FetchResponse {
	status: number;
	headers: Headers;
	data: any;
}
async function fetchJson(url: string): Promise<FetchResponse> {
	const response = await fetch(url);
	return {
		headers: response.headers,
		status: response.status,
		data: await response.json(),
	};
}

这里,我们使用浏览器的 fetch 函数向给定的 url 发起 GET 调用,然后返回一个包含响应主要部分的对象——headers、状态码(status)和解析后的主体(data)。

注意

fetch() 不是 ECMAScript 的一部分,因此它不是语言的一部分。它在所有现代浏览器中都是原生的,并且可以通过 node-fetchisomorphic-fetch 等包在 Node.js 中使用。

json() 方法返回 Promise<any>。这意味着如果返回的对象没有 title 属性,或者不是 string 类型,以下代码在运行时可能会抛出异常:

	const { data } = await fetchJson('https://jsonplaceholder.typicode.com/todos/1');
	console.log(data.title.toUpperCase()); // does data have a title property? What type is it?..

如果调用 fetchJson 函数的消费者能够知道 data 的类型,那将是有用的。为此,我们可以在 fetchJson 函数中添加一个泛型类型,同时我们还需要在返回类型中以某种方式指示——这就是 interfacetype 泛型再次发挥作用的地方。考虑以下 fetchJson.ts 的代码:

// fetchJson.ts
interface FetchResponse<T> {
    status: number;
    headers: Headers;
    data: T;
}
async function fetchJson<T>(url: string): Promise<FetchResponse<T>> {
    const response = await fetch(url);
    return {
        headers: response.headers,
        status: response.status,
        data: await response.json(),
    };
}

这与之前看到的 fetchJson 的第一个声明非常相似。实际上,生成的 JavaScript 代码完全相同。然而,这个声明现在使用泛型来允许函数的用户指定从 GET 调用中期望的返回类型。

现在考虑 usage.ts 的代码:

// usage.ts
(async () => {
    interface Todo {
        userId: number;
        id: number;
        title: string;
        completed: boolean;
    }
    const { data } = await fetchJson<Todo>('https://jsonplaceholder.typicode.com/todos/1');
    console.log(data.title); // ✅ title is of type 'string'
    console.log(data.doesntExist); // ❌ 'doesntExist' doesn't compile
})();

在这里,我们允许用户向 fetchJson<T>() 传递一个 T 泛型类型,该函数声明随后将其传递给 FetchResponse<T> 接口,从而将事物联系起来。

注意

就像接口一样,泛型只存在于编译时。所以,你在那里写的任何内容都和你让编译器理解的一样安全。例如,如果你要输入 Todo 的方式不同,或者传递不同的类型,那么实际的结果——TypeScript 中没有内置的守卫来在运行时验证它(没有用户/库代码——见第五章,继承和接口中的用户类型守卫)。

注意,在前面的例子中,T 泛型是一个 便利泛型——它只是为了用户的便利——它只使用一次,并不比简单的类型断言提供更多的类型安全性:

const response = await fetchJson('https://jsonplaceholder.typicode.com/todos/1');
const todo = response.data as Todo;

注意,泛型,就像变量一样,有作用域,你可以在多个级别上定义泛型,让用户按需提供它们。例如,注意我们如何在内部函数中使用在 map 函数中声明的 T 泛型类型(在以下代码片段的第 2 行):

function map<T, U>(fn: (item: T) => U) {
    return (items: T[]) => {
        return items.map(fn);
    };
}
const multiplier = map((x: number) => x * 2);
const multiplied = multiplier([1, 2, 3]); // returns: [2, 4, 6]

这也适用于接口和类等。在 Array<T> 接口中,map 函数接受一个额外的泛型作为输出类型,如 TypeScript 中 Array<T> 接口声明所示:

interface Array<T> {
    // ...
    map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
    // ...
}

考虑以下截图:

![图 9.9:Array 的映射方法基于从 callbackfn 返回的类型推断返回类型]

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/ts-ws/img/B14508_09_09.jpg)

图 9.9:Array 的映射方法基于从 callbackfn 返回的类型推断返回类型

一旦我们添加了上面的代码,再次,我们不需要明确告诉 TypeScript Ustring ——它可以从回调函数的返回类型中 推断 它。在这种情况下,Array<T>map 方法基于从 callbackfn 返回的类型推断返回类型,推断为 string[]

泛型约束

有时你想要定义一个泛型,使其约束在类型的一个子集中。在本章的开头,我们看到了 identity 函数——在那里支持 任何 类型既简单又合理。但关于 getLength 函数的打字——它只对数组和字符串有意义。接受 任何 类型都没有意义——getLength(true) 的输出会是什么?为了约束函数可以接受值的类型,我们可以使用泛型约束。考虑以下代码:

function getLength<T extends any[] | string>(x: T): number {
	return x.length;
}

这个定义 约束 给定的 T 类型成为 any[](任何东西的数组——string[]number[] 或任何 Foo[] 都会是有效的类型)或 string 的子类型。如果我们传递一个无效的类型,我们会得到一个编译错误,就像你在这里看到的那样:

![图 9.10:当传递给 getLength 函数无效类型时,会给出编译时错误]

传递给 getLength 函数

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/ts-ws/img/B14508_09_10.jpg)

图 9.10:当传递给 getLength 函数无效类型时,会给出编译时错误

通用约束有许多用例,而且通常在使用泛型时,你会在代码中设置一些这些约束,因为当你编写代码时,你可能会假设一些底层类型。此外,使用泛型约束可以让 TypeScript 窄化泛型类型的可能类型,并为你提供更好的建议和类型检查。

例如,在一个更现实的情况下,我们可能有一些函数返回给我们普通的日期,而其他函数返回纪元。我们希望始终使用日期,因此我们可以创建一个函数 toDate,它接受这些类型并将它们转换为 Date 函数:

function toDate<T extends Date | number>(value: T) {
    if (value instanceof Date) {
        return value;
    }
    return new Date(value);
}

在这里,我们首先检查给定的值是否是日期。如果是,我们可以直接返回它。否则,我们使用 value 创建一个新的 Date 函数并返回它。

通用约束在创建高阶函数时特别强大,因为输入函数的类型可能很难编写,而保持类型安全对于代码的可维护性是一个很大的好处。在下一个练习中,我们将看到通用约束在现实世界应用中的更多用途,以及它如何为我们的代码带来更好的类型。

注意

高阶函数是接受另一个函数作为参数或返回一个函数的函数。我们将在 第十二章TypeScript 中的 Promise 指南 中进一步探讨这些内容。

练习 9.02:泛型缓存函数

在这个练习中,我们将创建一个 memoize 函数,它使用泛型,将完全类型安全——它接受一个函数并返回一个相同类型的函数。

注意

缓存是一种优化性能的方法,通过减少某些操作执行的次数。缓存函数是一个高阶函数,它缓存了传递给它的内部函数的结果。

按照以下步骤实现这个练习:

注意

本练习的代码文件可以在此处找到:packt.link/zUx6H

  1. 首先实现一个简单的函数定义。我们稍后会添加类型:

    function memoize(fn: Function, keyGetter?: (args: any[]) => string) {
        // TODO: we'll implement the function in the next steps
    }
    

    memoize 函数接受一个要缓存的函数 fn,以及一个可选的 keyGetter,用于将参数序列化为键,用于后续查找。

  2. 接下来,让我们实现这个函数本身:

    function memoize(fn: Function, keyGetter?: (args: any[]) => string) {
        const cache: Record<string, any> = {};
        return (...args: any[]) => {
            const key = (keyGetter || JSON.stringify)(args);
            if (!(key in cache)) {
                cache[key] = fn(...args);
            }
            return cache[key];
        };
    }
    

    memoize 函数中,我们创建一个空的 cache 字典——键是序列化的参数,值是运行 fn 函数对这些参数的结果。

    然后,我们返回一个函数,给定一些参数 args,将检查运行 fn 并使用这些参数的结果是否已经被缓存。如果没有,我们将使用这些参数运行 fn 并缓存结果。最后,我们返回存储在缓存中的值,这可能是之前的计算结果或我们刚刚运行并缓存的结果。

  3. 为了测试这一点,我们将编写一个“昂贵”的函数,该函数在添加两个数字之前会循环 10 秒钟:

    function expensiveCalculation(a: number, b: number) {
        const timeout = 10000;
        const start = Date.now();
        while (Date.now() <= start + timeout);
        return a + b;
    }
    

    注意

    由于缓存旨在减少调用次数,它通常在运行时间较长的函数中非常有效——为了说明这一点,我们创建了一个 expensiveCalculation 函数,它需要不必要地长时间运行(10 秒)。

  4. 接下来,让我们 memoize 它:

    const memoizedExpensiveCalculation = memoize(expensiveCalculation);
    

    注意到,缓存的版本不是类型安全的。它确实验证了我们提供了一个 function,但返回的值是一个类型非常松散的函数,如果类型不正确,可能在运行时失败或出现意外的行为——你可以向它传递任意数量的任意类型的参数,它仍然可以编译通过,尽管在运行时该函数期望只接受两个参数,这两个参数都应该是 number 类型。

    这里我们使用以下方式来缓存:

    expensiveCalculation("not-a-number", 1); 
    memoizedExpensiveCalculation("not-a-number", 1); 
    
  5. 在你的集成开发环境(IDE)中,将鼠标悬停在前面两行代码上。你会注意到以下内容:图 9.11:IDE 上的消息

    图 9.11:IDE 上的消息

    如前一个屏幕截图所示,缓存的 expensiveCalculation 版本不是类型安全的——它允许传递一个字符串作为第一个参数,而它应该只接受一个数字。

  6. 返回文件顶部,然后添加泛型约束,并使我们的 memoize 函数更加类型安全。首先,我们需要定义几个辅助类型:

    type AnyFunction = (...args: any[]) => any;
    type KeyGetter<Fn extends AnyFunction> = (...args: Parameters<Fn>) => string;
    

    第一个类型 AnyFunction 描述了一个接受任意数量参数并返回任意内容的函数。第二个类型 KeyGetter 描述了一个接受泛型约束函数 Fn 的参数并返回一个字符串的函数。注意,我们将 Fn 约束为 AnyFunction 类型。这确保了我们得到一个函数,并允许我们使用内置的 Parameters<T> 类型,它接受一个函数的类型并返回它接受的参数。

  7. 接下来,使用我们刚刚定义的两个类型使我们的 memoize 函数定义更加类型安全——以更好的方式对两个参数进行类型化:

    function memoize<Fn extends AnyFunction>(fn: Fn, keyGetter?: KeyGetter<Fn>) {
    

    再次,我们将 Fn 约束为 AnyFunction 类型以确保我们得到一个函数,就像之前一样,同时也为了能够稍后使用特定的函数类型,作为我们的返回类型。

    现在我们有一个更安全的函数,因为 keyGetter 现在是类型安全的,但它仍然不返回一个类型化的函数。

  8. 让我们通过使实现更加类型安全来修复这个问题:

    function memoize<Fn extends AnyFunction>(fn: Fn, keyGetter?: KeyGetter<Fn>) {
        const cache: Record<string, ReturnType<Fn>> = {};
        return (...args: Parameters<Fn>) => {
            const key = (keyGetter || JSON.stringify)(args);
            if (!(key in cache)) {
                cache[key] = fn(...args);
            }
            return cache[key];
        };
    }
    

    我们使用 ReturnType<Fn> 来表示缓存中的值,而不是 anyReturnType<T> 是另一个内置类型,它接受一个函数的类型并返回该函数的返回类型。我们在这里再次使用 Parameters<T> 类型,来描述从 memoize 返回的函数。

  9. 将鼠标悬停在 memoizedExpensiveCalculation('not-a-number') 上。现在,我们的 memoize 实现是完全类型安全的,并且在 步骤 4 中没有导致编译时错误的代码现在可以正确运行:

图 9.12:memoizedExpensiveCalculation 的类型与原始 expensiveCalculation 函数相同

图 9.12:memoizedExpensiveCalculation 类型的类型与原始的 expensiveCalculation 函数相同

这个练习展示了如何在函数和类型中使用泛型,以及它们是如何相互集成的。在这里使用泛型使得 memoize 函数完全类型安全,因此我们的代码在运行时遇到错误的几率更小。

泛型默认值

有时,你想要允许泛型,但不要求它们——你想要提供一些合理的默认值,但允许根据需要覆盖它们。例如,考虑以下 Identifiable 接口的定义:

interface Identifiable<Id extends string | number = number> {
    id: Id;
}

这可以被其他接口使用,如下所示:

interface Person extends Identifiable<number> {
    name: string;
    age: number;
}
interface Car extends Identifiable<string> {
    make: string;
}
declare const p: Person; // typeof p.id === 'number'
declare const c: Car; // typeof c.id === 'string';

当前实现要求每个 Identifiable 接口的实现者指定其 Id 的类型。但也许我们想要提供一个默认值,这样你只有在不想使用该默认类型时才需要指定它。考虑以下代码:

interface Identifiable<Id extends string | number = number> {
    id: Id;
}

注意 Id 泛型类型有一个默认类型 number,这简化了此接口实现者的代码:

interface Person extends Identifiable {
    name: string;
    age: number;
}
interface Car extends Identifiable<string> {
    make: string;
}

注意现在 Person 不必指定 Id 的类型,代码与之前相同。

另一个更贴近现实世界的场景是与 React 组件相关——每个 React 组件可能有属性和可能有状态,你可以在声明组件时指定这些(通过扩展 React 的 Component 类型),但它们不必都有,因此为这两个泛型类型提供了一个默认的 {}

![图 9.13:@types/react 包的部分片段

![图片 B14508_09_13.jpg]

@types/react package

这使得 React 组件默认没有属性和状态,但如果需要,可以指定它们。

条件类型

条件类型是在 TypeScript 2.8 中引入的,允许复杂的类型表达式,其中一些驱动了我们在之前看到的内置类型。它们非常强大,因为它们允许我们在类型中编写逻辑。这种语法的格式是 T extends U ? X : Y。这与常规的 JavaScript 三元运算符非常相似,允许内联条件,唯一的语法区别是你必须使用 extends 关键字,并且这个检查是在编译时进行的,而不是在运行时。

这允许我们编写一个 NonNullable<T> 类型:

type NonNullable<T> = T extends null | undefined ? never : T; 

这已经内建到语言中,但它是由你可以在你的应用程序中编写的相同代码驱动的。

这意味着你可以在编译时检查一个类型是否可以为 null,并根据这个检查来更改类型签名或推断。一个这样的用例可能是 isNonNullable 函数。考虑以下代码:

function isNonNullable<T>(x: T): x is NonNullable<T> {
    return x !== null && x !== undefined;
}

前面的代码与 Arrayfilter 方法一起使用,可以让你过滤出相关项。例如,考虑以下混合类型项的数组定义:

![图 9.14:arr 的类型是一个数组,其中每个元素是数字,

null 或 undefined

![图片 B14508_09_14.jpg]

图 9.14:arr 的类型是一个数组,其中每个元素是数字、null 或 undefined

当我们调用arr.filter(isNonNullable)时,我们可以得到一个正确类型的数组:

![图 9.15:nonNullalbeArr 的类型被推断为 number[]]

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/ts-ws/img/B14508_09_15.jpg)

图 9.15:nonNullalbeArr 的类型被推断为 number[]

最后,TypeScript 2.8 中添加了另一个新特性infer关键字,它允许你在推断某个类型时从另一个类型中获得编译器的帮助。

这里有一个简单的例子:

type ArrayItem<T extends any[]> = T extends Array<infer U> ? U : never;

在这里,我们希望得到数组的内部类型(例如,对于类型为Person[]的数组,你希望得到Person)。因此,我们检查传递的泛型类型T extends Array<infer U>infer关键字建议编译器尝试理解类型,并将其分配给U,然后我们将其用作从这个条件类型返回的值。

注意

这个特定的类型示例在之前的版本中也可以通过type ArrayItem<T extends any[]> = T[number]实现。

另一个非常有用的例子是,以前在数组之外不可能的“解包”类型。例如,给定Promise<Foo>类型,我们希望得到Foo类型。现在这可以通过infer关键字实现。

类似于上一个例子,其中我们提取了数组内部类型,我们可以使用相同的技巧处理任何其他“包装”另一个类型的泛型类型:

type PromiseValueType<T> = T extends Promise<any> ? T : never;

这将在 IDE 中产生以下类型信息:

![图 9.16:UnpromisedPerson 的类型是 Person]

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/ts-ws/img/B14508_09_16.jpg)

图 9.16:UnpromisedPerson 的类型是 Person

在下一个活动中,我们将探讨条件类型的更多实际用例,以及infer关键字的用法。

活动 9.01:创建 DeepPartial类型

在这个活动中,我们将使用本章学到的概念——泛型、条件类型和infer关键字——来创建DeepPartial<T>类型。这个类型类似于内置的Partial<T>类型。但我们将递归地工作,并使对象中的每个属性都是可选的,递归地。

这将允许你正确地类型化变量等,以便它们的属性在任意级别都可以是可选的。例如,一个REST服务器将提供资源,并允许使用PATCH请求修改它们,这应该得到原始资源的部分结构,以便修改。

注意

这个活动的代码文件可以在以下位置找到:packt.link/YQUex

要创建这个类型,我们需要处理几个情况:

  1. 原始类型——字符串、数字和其他原始类型,以及日期,都不是我们可以应用Partial的。所以DeepPartial<string> === string

  2. 对于像对象、ArraySetMap这样的结构,我们希望“深入”到结构中,并将DeepPartial应用于它们的值。

  3. 对于其他所有内容,我们只想应用Partial

执行以下步骤以实现此活动:

  1. 创建一个PartialPrimitive类型。

  2. 在顶级定义一个基本的DeepPartial<T>类型,它可以处理原始类型和对象。

  3. 通过定义一个DeepPartialArray<T>类型并在此DeepPartial<T>类型中添加对其的处理,来支持数组的支持。

  4. 通过定义一个DeepPartialSet<T>类型并在此DeepPartial<T>类型中添加对其的处理,来支持集合的支持。

  5. 通过定义一个DeepPartialMap<T>类型并在此DeepPartial<T>类型中添加对其的处理,来支持映射的支持。

  6. 通过在每个属性上应用?属性修饰符,并传递其值包裹在DeepReadonly中,来支持普通对象的支持。

    注意

    该活动的解决方案可以通过这个链接找到。

摘要

本章使你开始了解泛型和条件类型的基础。我们学习了泛型在许多不同的用例中的使用,为什么它们是有用的,以及它们基本使用的一些扩展——泛型默认值和条件类型。我们进行了一些练习,以展示你如何将泛型包含到你的代码中,使其类型安全并避免运行时错误。

泛型在所有类型的应用程序中都很有用,无论是前端还是后端,并且被广泛使用,尤其是在库中,因为在很多情况下,你希望公开一个利用应用程序类型的 API,而这些类型你可能事先不知道。

在下一章中,你将学习关于异步开发的内容,其中一些内容你在本章中在输入外部 API 时已经简要接触过。

第十一章:10. 事件循环与异步行为

概述

在本章中,你将探究网页如何在浏览器中实际工作,特别关注浏览器如何、何时以及为什么执行我们提供的 JavaScript 代码。你将深入了解事件循环的复杂性,并了解我们如何管理它。最后,你将了解 TypeScript 为你提供的工具。到本章结束时,你将能够更好地管理执行的异步特性。

简介

在上一章中,你学习了泛型和条件类型的基础知识。本章将向你介绍事件循环和异步行为。然而,在继续学习这些主题之前,让我们先看看一个假设场景,以便真正理解同步和异步执行是如何工作的。

想象一家小银行,它只有一位出纳员。他的名字叫汤姆,他全天都在为客户服务。由于银行规模小,客户不多,所以没有排队。因此,当客户进来时,他们会得到汤姆的全神贯注。客户提供了所有必要的文件,汤姆进行处理。如果这个过程需要某种外部输入,例如来自信用局或银行的后台部门,汤姆会提交请求,他和客户一起等待响应。他们可能会聊一会儿,当响应到来时,汤姆继续他的工作。如果需要打印文件,汤姆会将文件发送到他的办公桌上方的打印机,他们等待并聊天。当打印完成时,汤姆继续他的工作。一旦工作完成,银行又有一位满意的客户,汤姆继续他的工作日。如果有人来的时候汤姆正在为客户服务(这种情况很少发生),他们必须等待,直到汤姆完全完成前一个客户的服务,然后才开始他们的流程。即使汤姆正在等待外部响应,其他客户也必须等待他们的轮次,同时汤姆与当前客户闲聊。

汤姆有效地以同步和顺序的方式工作。这种工作方式有很多好处,即汤姆(和他的上司)总能知道他是否在为客户服务,他总是知道他的当前客户是谁,并且一旦客户离开,他可以完全忘记所有关于客户的数据,因为他知道他们已经得到了完全的服务。没有混淆来自不同客户的文件的问题。任何问题都容易诊断和修复。由于队列永远不会拥挤,这种设置让每个人都满意。

到目前为止,一切顺利。但是,当银行突然增加更多客户时会发生什么呢?随着越来越多的客户到来,我们排起了长队,每个人都等待着,而汤姆在与当前客户聊天,等待信用局的回复。可以理解,汤姆的老板对这种情况并不满意。当前系统根本无法扩展。因此,他想以某种方式改变系统,以便能够服务更多客户。他该如何做呢?你将在下一节中看到几个解决方案。

多线程方法

基本上,有两种不同的方法。一种是有多个汤姆。所以,每个柜员仍然会以前完全相同简单同步的方式工作——我们只是有很多个。当然,老板需要某种组织来了解哪个柜员可用,哪个柜员在工作,是否有为每个柜员单独的队列,或者一个大型队列,以及某种分配机制(即,为每个客户分配一个号码的系统)。老板可能还会得到一台大型的办公打印机,而不是每个柜员一台打印机,并制定某种规则以避免打印作业混淆。组织将会复杂,但每个柜员的任务将会简单明了。

到现在为止,你知道我们实际上并不是在讨论银行。这是服务器端处理通常采用的方法。粗略简化一下,服务器进程将会有多个子进程(称为线程)并行工作,而主进程将协调一切。每个线程将同步执行,有一个明确的开始、中间和结束。由于服务器通常是拥有大量资源的机器,在重负载下,这种方法是有意义的。它可以很好地适应低或高负载,处理每个请求的代码可以相对简单且易于调试。甚至有理由让线程等待某些外部资源(例如文件系统中的文件,或来自网络或数据库的数据),因为我们总是可以启动新的线程来处理更多的请求。然而,对于现实生活中的柜员来说并非如此。当客户增多时,我们无法简单地克隆一个新的柜员。线程(或汤姆)所进行的等待通常被称为忙等待。线程并没有做任何事情,但它无法进行任何工作,因为它正忙于做某事——它正忙于等待。就像汤姆实际上在等待信用局的回复时,正忙于与客户聊天一样。

我们有一个可以大规模并行和并发运行的系统,但每个部分仍然同步运行。这种方法的优点是我们可以同时服务成千上万的客户。一个明显的缺点是成本,无论是硬件还是复杂性。虽然我们设法保持了客户处理的简单性,但我们还需要一个巨大的基础设施来处理其他所有事情——添加柜员、移除柜员、排队等候的客户、管理办公室打印机的访问,以及类似的任务。

这将使用银行(或服务器)的所有可用资源,但这没关系,因为这就是整个目的——尽可能快地服务尽可能多的客户,没有其他。

然而,还有另一种方法——异步执行。

异步执行方法

另一种方法,即网络和扩展到 JavaScript 和 TypeScript 采用的方法,是只使用单个线程——所以汤姆仍然独自一人。但是,汤姆不会无所事事地与等待的客户聊天,他可以做其他事情。如果出现需要从后台办公室获取验证的情况,他只需在一张纸上写下他正在做什么以及他做到了哪一步,然后将那张纸交给客户,并让他们去队伍的后面。现在,汤姆可以开始服务下一个排队等候的客户。如果那个客户不需要外部资源,他们的请求将被完全处理,并可以自由离开。如果他们需要汤姆需要等待的其他东西,他们也会被送到队伍的后面。以此类推。这样,如果汤姆有任何客户,他正在处理他们的请求。他永远不会忙于等待,相反,他总是忙于工作。如果客户需要等待响应,他们会在汤姆之外单独等待。汤姆唯一空闲的时候是他没有任何客户的时候。

这种方法的优点相当明显——以前,汤姆花了很多时间聊天,现在他一直在工作(当然,这个好处是从汤姆老板的角度来看的——汤姆喜欢闲聊)。另一个好处是我们事先就知道我们的资源消耗。如果我们只有一个出纳员,我们就知道我们需要多少办公面积。然而,也有一些缺点。最重要的缺点是,我们的客户现在必须非常了解我们的流程。他们需要了解如何排队和重新排队,如何从他们离开的地方继续工作,等等。汤姆的工作也变得更加复杂。他需要知道如何暂停客户的处理,如何继续,如果未收到外部响应应该如何处理等等。这种工作模式通常被称为异步和并发。在执行他的工作时,汤姆会同时跳转到多个客户。不止一个客户会开始处理但未完成。而且客户无法估计一旦开始处理他们的任务将需要多长时间——这取决于汤姆同时处理多少其他客户。

从一开始,这个模型对网络来说就更有意义。首先,网络应用程序是在客户端的设备上处理的。我们不应该对此有任何技术假设——因为我们无法确定客户端可能会使用哪种设备。本质上,一个网页是客户端设备上的访客——并且它应该表现得体。例如,使用所有设备资源来展示一些花哨的动画根本不是合适的行为。另一个重要问题是安全性。如果我们把网页看作是包含一些代码的应用程序,那么每次我们在浏览器的地址栏中输入网址时,我们基本上就是在我们的机器上执行某人的代码。

浏览器需要确保页面上即使是有害的代码,也不能对我们的机器造成太大的影响。如果访问一个网站能让你的电脑爆炸,那么网络今天就不会像现在这样受欢迎了。

因此,由于浏览器无法事先知道它将用于哪些页面,所以决定每个网页只能访问一个线程。此外,出于安全考虑,每个网页将获得一个单独的线程,因此正在运行的网页不能干涉同时可能执行的其它页面的执行(通过如网络工作者和 Chrome 应用程序等特性,这些限制有所放宽,但原则上仍然适用)。

简单来说,网页无法生成足够的线程来系统性地蜂拥而至,或者网页无法从另一个网页获取数据。而且,由于网页需要同时做很多事情,使用同步和顺序的方法是不可能的。这就是为什么所有的 JavaScript 执行环境完全采用了异步、并发的方法。这样做到了这样的程度,以至于一些常见的同步技术,故意地,在 JavaScript 中不可用。

例如,许多其他语言都有一个“等待一段时间”的原语,或者一个执行此操作的库函数。例如,在 C#编程语言中,我们可以有如下代码:

Console.WriteLine("We will wait 10 s");
Thread.Sleep(10000);
Console.WriteLine("... 10 seconds later");
Thread.Sleep(15000);
Console.WriteLine("... 15 more seconds later");

这段代码将向控制台写入一些文本,然后在 10 秒后写入更多文本。在等待的 25 秒内,执行此代码的线程将完全无响应,但代码简单且线性——易于理解、易于更改和易于调试。JavaScript 没有这样的同步原语,但它有一个异步变体,即setTimeout函数。最简单的等效代码如下:

console.log("We will wait 10 s");
setTimeout(() => {
    console.log("... 10 seconds later");
    setTimeout(() => {
        console.log("... 15 more seconds later");
    }, 15000);
}, 10000);

很明显,这段代码比 C#的等效代码复杂得多,但我们获得的优势是这段代码是非阻塞的。在这段代码执行的 25 秒总时间内,我们的网页可以完成它需要做的所有事情。它可以响应用户事件,图片可以加载并显示,我们可以调整窗口大小,滚动文本——基本上,应用程序将恢复正常和预期的功能。

注意,虽然使用一些特殊的同步代码可以阻塞 JavaScript 执行,但这并不容易。当它实际上发生时,浏览器可以检测到这一行为并终止有问题的页面:

![图 10.1:无响应页面图片

图 10.1:无响应页面

执行 JavaScript

当一个 JavaScript 执行环境,如 node 或浏览器加载一个 JavaScript 文件时,它会解析它然后运行它。在 JavaScript 文件中定义的所有函数都会被注册,所有不在函数中的代码都会被执行。执行的顺序是根据代码在文件中的位置来确定的。所以,考虑一个包含以下代码的文件:

console.log("First");
console.log("Second");

控制台将始终显示以下内容:

First
Second

输出的顺序不能改变,除非修改代码本身。这是因为带有First的行将完全执行——总是如此——然后,并且只有然后,带有Second的行才会开始执行。这种方法是同步的,因为执行是由环境同步的。我们保证,除非上面的行完全执行完毕,否则第二行不会开始执行。但如果这一行调用了某个函数呢?让我们看看以下这段代码:

function sayHello(name){
    console.log(`Hello ${name}`);
}
function first(){
    second();
}
function second(){
    third();
}
function third(){
    sayHello("Bob");
}
first();

当代码被解析时,环境将检测到我们有四个函数——firstsecondthirdsayHello。它还会执行不在函数内部的代码行(first();),这将启动first函数的执行。但是,当这个函数正在执行时,它会调用second函数。然后,运行时会挂起first函数的执行,记住它的位置,并开始执行second函数。这个函数反过来又调用third函数。同样的事情再次发生——运行时开始执行third函数,记住一旦该函数完成,它应该继续执行second函数,一旦second函数完成,它应该继续执行first函数。

运行时用来记住哪个函数是活动的,哪些在等待的结构被称为,具体来说,是调用栈

注意

“栈”这个术语在这里是指盘子堆或煎饼堆。我们只能从顶部添加,也只能从顶部移除。

正在执行的函数被一个接一个地堆叠起来,最顶部的函数是正在积极执行的函数,如下面的表示所示:

![图 10.2:栈图 10.2:栈

图 10.2:栈

在示例中,third函数将调用sayHello函数,该函数反过来又调用console对象的log函数。一旦log函数执行完成,栈将开始回溯。这意味着一旦某个函数完成执行,它将从栈中移除,下面的函数将能够继续执行。因此,一旦sayHello函数执行完成,third函数将恢复并依次完成。这将触发second函数的继续执行,当该函数也完成时,first函数将继续,并最终完成。当first函数执行完成时,栈将变为空——运行时将停止执行代码。

值得注意的是,所有这些执行都是严格同步和确定性的。我们可以仅从查看代码中推断出函数调用的确切顺序和数量。我们还可以使用常见的调试工具,如断点和堆栈跟踪。

练习 10.01:堆叠函数

在这个练习中,我们将定义几个简单的相互调用的函数。每个函数在开始执行和即将完成执行时都会向控制台记录日志。我们将分析输出何时以及以何种顺序映射到控制台:

注意

这个练习的代码文件可以在packt.link/X7QZQ找到。

  1. 创建一个新的文件,stack.ts

  2. stack.ts中定义三个函数,分别命名为innermiddleouter。它们都不需要参数或返回类型:

    function inner () {
    }
    function middle () {
    }
    function outer () {
    }
    
  3. inner函数的主体中,添加一个缩进四个空格的log语句:

    function inner () {
        console.log("    Inside inner function");
    }
    
  4. middle函数的主体中,添加对inner函数的调用。在调用前后添加一个缩进两个空格的log语句:

    function middle () {
        console.log("  Starting middle function");
        inner();
        console.log("  Finishing middle function");
    }
    
  5. outer函数的主体中,添加对middle函数的调用。在调用前后添加一个log语句:

    function outer () {
        console.log("Starting outer function");
        middle();
        console.log("Finishing outer function");
    }
    
  6. 函数声明后,仅创建对outer函数的调用:

    outer();
    
  7. 保存文件,并使用以下命令进行编译:

    tsc stack.ts
    
  8. 验证编译是否成功完成,并且同一文件夹中生成了一个stack.js文件。在node环境中使用以下命令执行它:

    node stack.js
    

    你会看到输出看起来像这样:

    Starting outer function
      Starting middle function
        Inside inner function
      Finishing middle function
    Finishing outer function
    

输出显示了哪个函数首先开始执行(outer),因为这是首先显示的消息。还可以注意到,middle函数在inner函数完成后,但在outer函数完成之前已经执行完毕。

浏览器和 JavaScript

当用户请求网页时,浏览器需要做很多事情。我们不会深入到每个细节,但我们会看看它是如何处理我们的代码的。

首先,浏览器向服务器发送请求,并接收一个 HTML 文件作为响应。在该 HTML 文件中,嵌入了对页面所需资源的链接,例如图片、样式表和 JavaScript 代码。然后浏览器也下载这些资源,并将它们应用到下载的 HTML 文件上。图片被显示,元素被样式化,JavaScript 文件被解析并执行。

代码执行的顺序是按照 HTML 文件中的顺序,然后根据代码在文件中的位置。但是函数何时被调用呢?假设我们在文件中有以下代码:

function sayHello() {
    console.log("Hello");
}
sayHello();

首先,注册sayHello函数,然后当它稍后被调用时,函数实际上执行并将Hello写入控制台。现在看看以下代码:

function sayHello() {
    console.log("Hello");
}
function sayHi() {
    console.log("Hi");
}
sayHello();
sayHi();
sayHello();

当处理包含上述代码的文件时,它会注册两个函数,sayHellosayHi。然后它会检测到有三个调用,也就是说,有三个任务需要处理。环境有一个称为任务队列的东西,它会将所有需要执行的函数依次放入其中。因此,我们的代码将被转换为三个任务。然后,环境会检查栈是否实际上为空,如果是,它将从队列中取出第一个任务并开始执行。栈的大小会根据第一个任务的代码执行而增长和缩小,最终,当第一个任务完成时,它将是空的。因此,在第一个任务执行后,情况将如下所示:

  1. 执行栈将为空。

  2. 任务队列将包含两个任务。

  3. 第一个任务将完全完成。

一旦栈为空,下一个任务将被出队并执行,依此类推,直到任务队列和栈都为空,所有代码都执行完毕。再次强调,整个过程是同步进行的,按照指定的顺序。

浏览器中的事件

现在,看看另一个例子:

function sayHello() {
    console.log("Hello");
}
document.addEventListener("click", sayHello);

如果你有一个由浏览器加载的 JavaScript 文件中的此代码,你可以看到sayHello函数已被注册但尚未执行。然而,如果你在页面上任何地方点击,你会在控制台看到Hello字符串出现,这意味着sayHello函数已被执行。如果你多次点击,你将在控制台看到多个"Hello"实例。而且这段代码甚至没有调用一次sayHello函数;代码中根本就没有sayHello()的调用。

发生的事情是,你注册了我们的函数作为事件监听器。考虑一下你根本就没有调用我们的函数,但浏览器环境会在某些事件发生时为我们调用它——在这个例子中,是整个document上的click事件。由于这些事件是由用户生成的,我们无法知道我们的代码何时以及是否会被执行。事件监听器是我们代码与所在页面通信的主要方式,它们是以异步方式调用的——你不知道函数何时以及是否会被调用,也不知道它会被调用多少次。

当事件发生时,浏览器会查找其内部注册的事件处理程序表。在我们的例子中,如果document(即整个网页)上的任何地方发生click事件,浏览器会看到你已经注册了sayHello函数来响应它。该函数不会直接执行——相反,浏览器会将函数的调用放入任务队列。之后,将采取之前解释的常规行为。如果队列和栈都为空,事件处理程序将立即开始执行。否则,我们的处理程序将等待它的轮次。

这是对异步行为的一个核心影响——我们根本无法保证事件处理程序会立即执行。可能它确实会立即执行,但没有办法知道在特定时刻队列和栈是否为空。如果它们是空的,我们将立即执行,但如果它们不是空的,我们就必须等待我们的轮次。

环境 API

我们与浏览器的交互大部分将遵循相同的模式——你将定义一个函数,并将该函数作为参数传递给某个浏览器 API。该函数何时以及是否会被调度执行将取决于该 API 的具体情况。在前一个例子中,你使用了事件处理程序 API,即addEventListener,它接受两个参数,一个是事件的名称,另一个是当该事件发生时将被调度的代码。

注意

你可以在developer.mozilla.org/en-US/docs/Web/Events找到不同可能事件的列表。

在本章的其余部分,你还将使用另外两个 API,即环境方法来延迟一些代码的后续执行(称为setTimeout)以及调用外部资源的能力(通常称为 AJAX)。我们将使用两种不同的 AJAX 实现,原始的XMLHttpRequest实现和更现代、更灵活的fetch实现。

setTimeout

如前所述,环境不提供暂停 JavaScript 执行一定时间的可能性。然而,在经过一定时间后执行某些代码的需求相当常见。因此,我们不是暂停执行,而是执行一些具有相同结果的不同操作。我们可以安排一段代码在经过一段时间后执行。为此,我们使用setTimeout函数。此函数接受两个参数:一个需要执行的函数,以及以毫秒为单位延迟执行该函数的时间:

setTimeout(function() {
    console.log("After one second");
}, 1000);

这意味着传递给参数的匿名函数将在 1,000 毫秒后执行,即一秒后。

练习 10.02:探索 setTimeout

在这个练习中,你将使用setTimeout环境 API 调用来调查异步执行的行为以及它做了什么:

注意

本练习的代码文件可以在packt.link/W0mlS找到。

  1. 创建一个新的文件,delays-1.ts

  2. delays-1.ts中,在文件开头记录一些文本:

    console.log("Printed immediately");
    
  3. 添加两个对setTimeout函数的调用:

    setTimeout(function() {
        console.log("Printed after one second");
    }, 1000);
    setTimeout(function() {
        console.log("Printed after two second");
    }, 2000);
    

    在这里,我们不是创建一个函数并将其通过其名称传递给setTimeout函数,而是使用我们就地创建的匿名函数。我们也可以使用箭头函数代替使用function关键字定义的函数。

  4. 保存文件,并使用以下命令编译它:

    tsc delays-1.ts
    
  5. 验证编译是否成功完成,并在同一文件夹中生成一个delays-1.js文件。使用以下命令在node环境中执行它:

    node delays-1.js
    

    你会看到输出如下:

    Printed immediately
    Printed after one second
    Printed after two second
    

    输出的第二行和第三行不应立即出现,而应在 1 秒和 2 秒后分别出现。

  6. delays-1.ts文件中,交换对setTimeout函数的两个调用:

    console.log("Printed immediately");
    setTimeout(function() {
        console.log("Printed after two second");
    }, 2000);
    setTimeout(function() {
        console.log("Printed after one second");
    }, 1000);
    
  7. 再次编译并运行代码,并验证输出行为是否相同。即使先前的setTimeout先执行,其function参数也不计划在 2 秒后运行。

  8. delays-1.ts文件中,将初始的console.log移动到文件底部:

    setTimeout(function() {
        console.log("Printed after two second");
    }, 2000);
    setTimeout(function() {
        console.log("Printed after one second");
    }, 1000);
    console.log("Printed immediately");
    
  9. 再次编译并运行代码,并验证输出行为是否相同。这说明了代码行为异步时最常见的一个问题之一。即使该行位于我们文件的底部,它也是首先执行的。要心理追踪不遵循我们习惯的从上到下范式的代码要困难得多。

  10. 创建一个新的文件,delays-2.ts

  11. delays-2.ts中,添加对setTimeout函数的单次调用,并将其延迟时间设置为0。这意味着我们的代码需要等待 0 毫秒才能执行:

    setTimeout(function() {
        console.log("#1 Printed immediately?");
    }, 0);
    
  12. 在调用setTimeout之后添加一个console.log语句:

    console.log("#2 Printed immediately.");
    
  13. 保存文件,并使用以下命令编译它:

    tsc delays-2.ts
    
  14. 确认编译成功结束,并且在同一文件夹中生成了一个delays-2.js文件。在node环境中使用以下命令执行它:

    node delays-2.js
    

    你会看到输出看起来像这样:

    #2 Printed immediately.
    #1 Printed immediately?;
    

    嗯,这看起来有些意外。两行几乎立即就出现了,但位于setTimeout块中的那一行,在代码中排在第一位,却在脚本底部的行之后。我们明确地告诉setTimeout不要等待,也就是说,在代码执行前等待 0 毫秒。

为了理解发生了什么,我们需要回到调用队列。当文件加载时,环境检测到我们有两个任务需要完成,即对setTimeout的调用和脚本底部的对console.log的调用(#2)。因此,这两个任务被放入任务队列。由于当时栈是空的,setTimeout调用开始执行,而#2 被留在任务队列中。环境看到它有一个零延迟,所以立即取出了函数(#1),并将其放在任务队列的末尾,在#2 之后。因此,在setTimeout调用完成后,我们剩下两个console.log任务在队列中,#2 是第一个,#1 是第二个。

它们是顺序执行的,在我们的控制台中,我们首先得到#2,然后是#1。

AJAX(异步 JavaScript 和 XML)

在网络发展的早期,一旦页面加载完成,就无法从服务器获取数据。这对开发动态网页来说是一个巨大的不便,而这个问题是通过引入一个名为XMLHttpRequest的对象来解决的。这个对象使开发者能够在初始页面加载后从服务器获取数据——由于从服务器加载数据意味着使用外部资源,因此它必须以异步方式进行(即使它的名字中包含 XML,目前它将主要用于 JSON 数据)。要使用这个对象,你需要实例化它并使用它的一些属性。

为了说明其用法,我们将尝试从 Open Library 项目获取关于威廉·莎士比亚的数据。我们将使用以下 URL 来检索这些信息:openlibrary.org/authors/OL9388A.json,我们将使用的方法是GET,因为我们只会获取数据。

收到的数据是 Open Library 定义的特定格式,因此你将首先为你要实际使用的数据创建一个接口。你将只显示莎士比亚的图像(作为照片 ID 的数组接收),以及姓名,因此你可以将接口定义如下:

interface OpenLibraryAuthor {
  personal_name: string;
  photos: number[];
}

接下来,创建XMLHttpRequest对象,并将其分配给名为xhr的变量:

const xhr = new XMLHttpRequest();

现在你需要打开一个连接到我们的 URL:

const url = "https://openlibrary.org/authors/OL9388A.json";
xhr.open("GET", url);

这个调用实际上没有发送任何内容,但它为访问外部资源准备了系统。最后,您需要实际发送请求,使用send方法:

xhr.send();

由于请求是异步的,这个调用将立即执行并完成。为了在请求完成后实际处理数据,您需要向这个对象添加一些内容——一个回调。这是一个函数,它不会由我们执行,而是由xhr对象在某个事件发生时执行。这个对象有几个事件,如onreadystatechangeonloadonerrorontimeout,您可以为不同的事件设置不同的函数来响应,但在这个情况下,您将只使用onload事件。创建一个函数,它将从响应中获取数据并在我们的脚本运行的网页上显示它:

const showData = () => {
  if (xhr.status != 200) {
    console.log(`An error occured ${xhr.status}: ${xhr.statusText}`);
  } else {
    const response: OpenLibraryAuthor = JSON.parse(xhr.response);
    const body = document.getElementsByTagName("body")[0];

    const image = document.createElement("img");
    image.src = `http://covers.openlibrary.org/a/id/${response.photos[0]}-M.jpg`;
    body.appendChild(image);
    const name = document.createElement("h1");
    name.innerHTML = response.personal_name;
    body.appendChild(name);
  }
};

在这个方法中,您将使用之前定义的xhr变量的某些属性,例如status,它给我们提供了请求的 HTTP 状态码,或者response,它给我们实际的响应。如果我们自己调用showData方法,我们很可能会得到空字段或错误,因为响应还没有完成。所以,我们需要将这个函数给xhr对象,它将使用它来调用showData回:

xhr.onload = showData;

将此代码保存为shakespeare.ts,编译它,并使用以下方式将其添加到 HTML 页面中:

    <script src="img/shakespeare.js"></script>

您将得到以下类似的结果:

图 10.3:威廉·莎士比亚的检索图像

图 10.3:威廉·莎士比亚的检索图像

活动十.01:使用 XHR 和回调的影片浏览器

作为 TypeScript 开发者,您被分配了一个创建一个简单的页面来查看电影数据的任务。网页将很简单,有一个文本输入字段和一个按钮。当您在搜索输入字段中输入电影名称并按下按钮时,电影的一般信息将显示在网页上,以及一些与电影相关的图像。

您可以使用电影数据库([www.themoviedb.org/](https://www.themoviedb.org/))作为一般数据源,特别是它的 API。您需要使用XmlHttpRequest发出 AJAX 请求,并使用网站提供的数据来格式化您自己的对象。当使用 API 时,数据很少,如果不是永远,会是我们需要的形式。这意味着您将需要使用多个 API 请求来获取我们的数据,并逐步构建我们的对象。TypeScript 解决此问题的常见方法是用两组接口——一组与 API 格式完全匹配,另一组与您在应用程序中使用的数据匹配。在这个活动中,您需要使用Api后缀来表示与 API 格式匹配的接口。

另一个需要注意的重要事项是,这个特定的 API 不允许完全开放访问。您需要注册 API 密钥,然后在每个 API 请求中发送它。在本活动的设置代码中,将提供三个函数(getSearchUrlgetMovieUrlgetPeopleUrl),这些函数将在将apiKey变量设置为从 The Movie Database 接收到的值后生成所需的 API 请求的正确 URL。还将提供基本 HTML、样式以及实际显示数据的代码——唯一缺少的是数据本身。

这些资源在此列出:

  • display.ts – 一个 TypeScript 文件,包含showResultclearResults方法,您将调用这些方法来显示电影和清除屏幕。

  • interfaces.ts – 一个 TypeScript 文件,包含您将使用的接口。所有带有Api后缀的接口都是您将从 The Movie Database API 接收到的对象,其余的(MovieCharacter)将用于显示数据。

  • script.ts – 一个包含启动应用程序的样板代码的 TypeScript 文件。search函数在这里,这个函数将是本活动的重点。

  • index.html – 一个包含我们网页基本标记的 HTML 文件。

  • styles.css – 一个用于样式化结果的样式表文件。

以下步骤将有助于您解决问题:

备注

本活动的代码文件可以在https://packt.link/Qo4dB找到。

  1. script.ts文件中定位search函数,并验证它接受一个字符串参数,并且其主体为空。

  2. 构造一个新的XMLHttpRequest对象。

  3. 使用getSearchUrl方法构造一个新的搜索结果 URL 字符串。

  4. 调用xhr对象的opensend方法。

  5. xhr对象的onload事件添加一个事件处理器。获取响应并将其解析为 JSON 对象。将结果存储在SearchResultApi接口的变量中。此数据将在results字段中包含我们的搜索结果。如果没有结果,这意味着我们的搜索失败。

  6. 如果搜索没有返回结果,调用clearResults方法。

  7. 如果搜索返回了一些结果,只需取第一个并将其存储在变量中,忽略其他结果。

  8. onload处理器内部,在成功的搜索分支中,创建一个新的XMLHttpRequest对象。

  9. 使用getMovieUrl方法构造一个新的搜索结果 URL 字符串。

  10. 调用构造的xhr对象的opensend方法。

  11. xhr对象的onload事件添加一个事件处理器。获取响应,并将其解析为 JSON 对象。将结果存储在MovieResultApi接口的变量中。此响应将包含我们电影的通用数据,具体来说,是除了参与电影的人员之外的所有内容。您将需要另一个 API 调用以获取有关人员的数据。

  12. onload处理程序内部,在成功的搜索分支中,创建一个新的XMLHttpRequest对象。

  13. 使用getPeopleUrl方法构造一个新的搜索结果 URL 字符串。

  14. 调用构造的xhr对象的opensend方法。

  15. xhr对象的onload事件添加事件处理程序。获取响应,并将其解析为 JSON 对象。将结果存储在PeopleResultApi接口的变量中。此响应将包含有关参与电影的人物数据。

  16. 现在,你实际上已经拥有了所有需要的数据,因此你可以在people onload处理程序内部创建自己的对象,该处理程序位于电影onload处理程序内部,而电影onload处理程序又位于搜索onload处理程序内部。

  17. 人物数据具有castcrew属性。你将只取前六个演员,所以首先根据演员的order属性对cast属性进行排序。然后从第一个六个演员中切出一个新的数组。

  18. 将演员数据(即CastResultApi对象)转换为我们的Character对象。你需要将CastResultApicharacter字段映射到Charactername字段,将name字段映射到演员名字,将profile_path字段映射到image属性。

  19. 从人物数据的crew属性中,你只需要导演和编剧。由于可能有多个导演和编剧,你需要分别连接所有导演和编剧的名字。对于导演,从crew属性中筛选出具有Directing部门Director职位的导演。对于这些对象,取其name属性,并用&连接起来。

  20. 从人物数据的crew属性中,你只需要导演和编剧。由于可能有多个导演和编剧,你将分别连接所有导演和编剧的名字。对于导演,从crew属性中筛选出具有Directing部门Director职位的导演。对于这些对象,取其name属性,并用&连接起来。

  21. 创建一个新的Movie对象(使用对象字面量语法)。使用你迄今为止准备的电影和人物响应中的数据填写Movie对象的全部属性。

  22. 使用你构造的电影调用showResults函数。

  23. 在你的父目录(在这种情况下为Activity01)中,使用npm i安装依赖项。

  24. 使用tsc ./script.ts ./interfaces.ts ./display.ts编译程序。

  25. 验证编译是否成功结束。

  26. 使用你选择的浏览器打开index.html

    你应该在浏览器中看到以下内容:

    ![图 10.4:最终的网页

    ![图 10.4:最终的网页

图 10.4:最终的网页

注意

此活动的解决方案可以通过此链接找到。

我们将在活动 10.02,使用 fetch 和 Promises 的电影浏览器活动 10.03,使用 fetch 和 async/await 的电影浏览器中进一步改进此应用程序。然而,在我们这样做之前,你需要了解 TypeScript 中的 Promises 和async/await

Promises

使用回调函数进行异步开发可以完成任务——这真是太好了。然而,在许多应用中,我们的代码需要一直使用外部或异步资源。所以,很快我们就会遇到这样的情况:在我们的回调函数内部,还有一个异步调用,这需要回调函数嵌套在回调函数中,而这个回调函数又需要自己的回调……

在某些情况下,深入回调函数的层级多达十多层并不罕见。

练习 10.03:数到五

在这个练习中,我们将创建一个函数,当执行时,将输出从一至五的英文单词。每个单词将在上一个单词显示后 1 秒钟出现在屏幕上:

注意

本练习的代码文件可以在 packt.link/zD7TT 找到。

  1. 创建一个新的文件,counting-1.ts

  2. counting-1.ts 中添加一个包含从一至五(包括五)的英文数字名称的数组:

    const numbers = ["One", "Two", "Three", "Four", "Five"];
    
  3. setTimeout 函数进行单个调用,并在一秒后打印出第一个数字:

    setTimeout(function() {
        console.log(numbers[0]);
    }, 1000);
    
  4. 保存文件,并使用以下命令进行编译:

    tsc counting-1.ts
    
  5. 验证编译是否成功结束,并在同一文件夹中生成了一个 counting-1.js 文件。在 node 环境中使用此命令执行它:

    node counting-1.js
    

    你会看到输出看起来像这样:

    One
    

    这行应该在应用程序运行后 1 秒出现。

  6. counting-1.ts 文件中,在 setTimeout 函数内部,在 console.log 下方添加另一个嵌套的 setTimeout 函数调用:

    setTimeout(function() {
        console.log(numbers[0]);
        setTimeout(function() {
            console.log(numbers[1]);
        }, 1000);
    }, 1000);
    
  7. 再次编译并运行代码,并验证输出是否多了一行,在第一个输出后 1 秒显示:

    One
    Two
    
  8. counting-1.ts 文件中,在嵌套的 setTimeout 函数内部,在 console.log 之上添加另一个嵌套的 setTimeout 函数调用:

    setTimeout(function() {
        console.log(numbers[0]);
        setTimeout(function() {
            setTimeout(function() {
                console.log(numbers[2]);
            }, 1000);
            console.log(numbers[1]);
        }, 1000);
    }, 1000);
    
  9. 在最内层的 setTimeout 函数中,在 console.log 下方添加另一个嵌套的 setTimeout 函数调用,并且对第五个数也要重复这个过程。代码应该看起来像这样:

    setTimeout(function() {
        console.log(numbers[0]);
        setTimeout(function() {
            setTimeout(function() {
                console.log(numbers[2]);
                setTimeout(function() {
                    console.log(numbers[3]);
                    setTimeout(function() {
                        console.log(numbers[4]);
                    }, 1000);
                }, 1000);
            }, 1000);
            console.log(numbers[1]);
        }, 1000);
    }, 1000);
    
  10. 再次编译并运行代码,并验证输出是否按正确顺序显示,如下所示:

    One
    Two
    Three
    Four
    Five
    

在这个简单的例子中,我们实现了一个简单的功能——数到五。但正如你所能看到的,代码正变得越来越混乱。想象一下,如果我们需要数到 20 而不是 5 会怎样。那将是一个完全无法维护的混乱。虽然有一些方法可以使这段特定的代码看起来更好、更易于维护,但总的来说,情况并非如此。回调函数的使用本质上与混乱且难以阅读的代码相关。而混乱且难以阅读的代码是错误隐藏的最佳场所,因此回调函数确实有导致难以诊断的错误的声誉。

回调函数的另一个问题是,不同对象之间不能有一个统一的 API。例如,我们需要明确知道,为了使用 xhr 对象接收数据,我们需要调用 send 方法并为 onload 事件添加回调。并且我们需要知道,为了检查请求是否成功,我们必须检查 xhr 对象的 status 属性。

承诺是什么?

幸运的是,我们可以向您保证有一种更好的方法。这种方法最初是由第三方库实现的,但它已被证明非常有用且被广泛采用,以至于它被包含在 JavaScript 语言本身中。这个解决方案背后的逻辑相当简单。每个异步调用基本上是一个承诺,在未来的某个时刻,某个任务将被完成,某个结果将被获得。就像现实生活中的承诺一样,我们可以为一个承诺有三种不同的状态:

  • 一个承诺可能尚未解决。这意味着我们需要等待更多的时间才能得到结果。在 TypeScript 中,我们称这些承诺为“挂起。”

  • 一个承诺可能被负面地解决——许诺的人违背了承诺。在 TypeScript 中,我们称这些承诺为“拒绝”,通常我们会因此得到某种错误。

  • 一个承诺可能被正面解决——许诺的人履行了承诺。在 TypeScript 中,我们称这些承诺为“解决”,并且我们可以从它们中获得一个值——实际的结果。

由于承诺本身就是对象,这意味着承诺可以被分配给变量,从函数中返回,作为函数的参数传递,以及我们可以用常规对象做的许多其他事情。

承诺的另一个伟大特性是,围绕现有的基于回调的函数编写一个承诺包装器相对容易。让我们尝试将莎士比亚的例子承诺化。我们将首先查看 showData 函数。这个函数需要做很多事情,而且这些事情有时并不相互关联。它需要处理 xhr 变量以提取数据,并且它需要知道如何处理这些数据。因此,如果我们所使用的 API 发生变化,我们需要更改我们的函数。如果我们的网页结构发生变化,也就是说,如果我们需要显示一个 div 而不是 h1 元素,我们也需要更改我们的函数。如果我们需要将作者数据用于其他目的,我们也需要更改我们的函数。基本上,如果响应需要发生任何事情,它必须在那时发生。我们无法将此决策推迟到另一段代码中。这在我们代码中创建了不必要的耦合,使得维护变得更加困难。

让我们改变一下。我们可以创建一个新的函数,该函数将返回一个承诺,该承诺将提供关于作者的数据。它将不知道这些数据将被用于什么:

const getShakespeareData = () => {
  const result = new Promise<OpenLibraryAuthor>((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      const url = "https://openlibrary.org/authors/OL9388A.json";
      xhr.open("GET", url);
      xhr.send();
      xhr.onload = () => {
        if (xhr.status != 200) {
            reject({
                error: xhr.status,
                message: xhr.statusText
            })
        } else {
            const response: OpenLibraryAuthor = JSON.parse(xhr.response);
            resolve(response);
        }
      }
  });
  return result;
};

这个函数返回一个由 Promise 构造函数创建的 Promise 对象。这个构造函数接受一个单一参数,即一个函数。该函数也接受两个参数(也是函数),按照惯例称为 resolvereject。你可以看到构造函数内部的函数只是创建了一个 xhr 对象,调用其 opensend 方法,设置其 onload 属性,然后返回。所以,基本上,没有做任何事情,除了发起请求。

这样创建的 Promise 将处于挂起状态。并且 Promise 将保持这种状态,直到在主体内部调用 resolvereject 函数之一。如果调用 reject 函数,它将过渡到拒绝状态,我们可以使用 Promise 对象的 catch 方法来处理错误;如果调用 resolve 函数,它将过渡到解决状态,我们可以使用 Promise 对象的 then 方法。

我们应该注意的一件事是,这个方法不执行任何与 UI 相关的操作。它不会在控制台打印任何错误或更改任何 DOM 元素。它只是承诺给我们一个 OpenLibraryAuthor 对象。现在,我们可以随意使用这个对象:

getShakespeareData()
  .then(author => {
    const body = document.getElementsByTagName("body")[0];

    const image = document.createElement("img");
    image.src = `http://covers.openlibrary.org/a/id/${author.photos[0]}-M.jpg`;
    body.appendChild(image);
    const name = document.createElement("h1");
    name.innerHTML = author.personal_name;
    body.appendChild(name);
  })
  .catch(error => {
    console.log(`An error occured ${error.error}: ${error.message}`);
  })

在这段代码中,我们调用 getShakespeareData 数据函数,然后在其结果上调用两个方法,thencatchthen 方法仅在 Promise 处于已解决状态时执行,并接受一个将获取结果的函数。catch 方法仅在 Promise 处于拒绝状态时执行,并将错误作为其函数的参数。

对于 thencatch 方法的一个重要注意事项——它们也返回 Promise。这意味着 Promise 对象是可链的,所以与其深入,就像我们处理回调那样,我们可以说得更长。为了说明这一点,让我们再次数到五。

注意

第十二章,TypeScript 中的 Promise 指南 中将更全面地讨论 Promise。

练习 10.04:使用 Promise 计数到五

在这个练习中,我们将创建一个函数,当执行时,将输出从一至五的英语单词。每个单词将在上一个单词显示后 1 秒钟出现在屏幕上:

注意

本练习的代码文件可以在packt.link/nlge8找到。

  1. 创建一个新文件,counting-2.ts

  2. counting-2.ts 中,添加一个包含从一到五(包括五)的英语数字名称的数组:

    const numbers = ["One", "Two", "Three", "Four", "Five"];
    
  3. 添加 setTimeout 函数的包装器。这个包装器只有在指定的超时时间到期时才会执行:

    const delay = (ms: number) => {
        const result = new Promise<void>((resolve, reject) => {
            setTimeout(() => {
                resolve();
            }, ms)
        });
        return result;
    }
    

    由于我们的 Promise 不会返回任何有意义的值,而是在给定数量的毫秒后简单地解决,因此我们提供了 void 作为其类型。

  4. 使用参数 1000 调用 delay 方法,并在其解决后打印出第一个数字:

    delay(1000)
    .then(() => console.log(numbers[0]))
    
  5. 保存文件,并使用以下命令编译它:

    tsc counting-2.ts
    
  6. 验证编译是否成功完成,并在同一文件夹中生成一个counting-2.js文件。使用以下命令在node环境中执行它:

    node counting-2.js
    

    您将看到输出看起来像这样:

    One
    

    该行应在应用程序运行后 1 秒出现。

  7. counting-2.ts文件中,在then行之后,添加另一个then行。在其内部,再次调用delay方法,超时时间为 1 秒:

    delay(1000)
    .then(() => console.log(numbers[0]))
    .then(() => delay(1000)) 
    

    我们可以这样做,因为then方法的结果是Promise,它有自己的then方法。

  8. 在最后一个then行之后,添加另一个then行,在其内部打印出第二个数字:

    delay(1000)
    .then(() => console.log(numbers[0]))
    .then(() => delay(1000))
    .then(() => console.log(numbers[1])) 
    
  9. 再次编译并运行代码,并验证输出是否多了一行,在第一个输出后 1 秒显示。

  10. counting-2.ts文件中,为第三个、第四个和第五个数字添加两个额外的then行。代码应如下所示:

    delay(1000)
    .then(() => console.log(numbers[0]))
    .then(() => delay(1000))
    .then(() => console.log(numbers[1]))
    .then(() => delay(1000))
    .then(() => console.log(numbers[2]))
    .then(() => delay(1000))
    .then(() => console.log(numbers[3]))
    .then(() => delay(1000))
    .then(() => console.log(numbers[4])) 
    
  11. 再次编译并运行代码,并验证输出是否按正确顺序出现。

    让我们比较一下这段代码与之前练习中的代码。这不是最干净的代码,但其功能相对明显。我们可以看到如何扩展此代码以计数到 20。这里的主要好处是,尽管这段代码是异步的,但它仍然是顺序执行的。我们可以对其进行分析,并且位于顶部的行将在位于底部的行之前执行。此外,由于我们现在有了对象,我们甚至可以将此代码重构为更简单、更易于扩展的格式——我们可以使用for循环。

  12. counting-2.ts文件中,删除从delay(1000)开始的直到文件末尾的行。添加一行来定义一个已解决的 promise:

    let promise = Promise.resolve();
    
  13. 添加一个for循环,对于numbers数组中的每个数字,将 1 秒的延迟添加到 promise 链中,并打印该数字:

    for (const number of numbers) {
        promise = promise
            .then(() => delay(1000))
            .then(() => console.log(number))
    };}
    
  14. 再次编译并运行代码,并验证输出是否按正确顺序出现,如下所示:

    One
    Two
    Three
    Four
    Five
    

活动十点零二:使用 fetch 和 Promises 实现电影浏览器

此活动将重复之前的活动。主要区别在于,我们不会使用XMLHttpRequest及其onload方法,而是使用fetch网络 API。与XMLHttpRequest类相比,fetch网络 API 返回一个Promise对象,因此我们不需要嵌套回调以进行多个 API 调用,我们可以有一个更易于理解的 promise 解析链。

此活动与之前的活动具有相同的文件和资源。

以下步骤将有助于您解决问题:

  1. script.ts文件中,找到search函数并验证它是否接受一个字符串参数,并且其主体为空。

  2. search函数上方,创建一个名为getJsonData的辅助函数。此函数将使用fetch API 从端点获取数据并将其格式化为 JSON。它应接受一个名为url的单个字符串参数,并且它应返回一个Promise

  3. getJsonData 函数的主体中,添加调用带有 url 参数的 fetch 函数的代码,然后对返回的响应调用 json 方法。

  4. search 方法中,使用 getSearchUrl 方法构建一个新的搜索结果 URL 字符串。

  5. 使用 searchUrl 作为参数调用 getJsonData 函数。

  6. getJsonData 返回的 promise 上添加一个 then 处理程序。处理程序接受一个类型为 SearchResultApi 的单个参数。

  7. 在处理程序的主体中,检查是否有任何结果,如果没有,则抛出错误。如果有结果,则返回第一个项目。注意,处理程序返回一个具有 idtitle 属性的对象,但 then 方法实际上返回的是该数据的 promise。这意味着在处理程序之后,我们可以链式调用其他 then 调用。

  8. 在先前的处理程序中添加另一个 then 调用。此处理程序将接受一个包含电影 idtitlemovieResult 参数。使用 id 属性调用 getMovieUrlgetPeopleUrl 方法,分别获取电影详情和演员阵容及制作团队的正确 URL。

  9. 在获取到 URL 后,使用这两个 URL 调用 getJsonData 函数,并将结果值分配给变量。注意,getJsonData(movieUrl) 调用将返回一个 MovieResultApi 的 promise,而 getJsonData(peopleUrl) 将返回一个 PeopleResultApi 的 promise。将这些结果值分配给名为 dataPromisepeoplePromise 的变量。

  10. 使用 dataPromisepeoplePromise 作为参数调用静态 Promise.all 方法。这将基于这两个值创建另一个 promise,并且只有当包含在内的所有 promise 都成功解析时,这个 promise 才会成功解析。它的返回值将是一个包含结果的数组 promise。

  11. 从处理程序返回由 Promise.all 调用生成的 promise。

  12. 在链中添加另一个 then 处理程序。此处理程序将 Promise.all 返回的数组作为单个参数。

  13. 将参数解构为两个变量。数组的第一元素应该是 movieData 变量,其类型为 MovieResultApi,数组的第二元素应该是 peopleData 变量,其类型为 PeopleResultApi

  14. 人物数据有 castcrew 属性。我们只取前六个演员,所以首先根据演员的 order 属性对 cast 属性进行排序。然后从新数组中截取前六个演员。

  15. 将演员数据(即 CastResultApi 对象)转换为你的 Character 对象。我们需要将 CastResultApicharacter 字段映射到 Charactername 字段,将 name 字段映射到演员名字,将 profile_path 字段映射到 image 属性。

  16. 从人物数据的crew属性中,我们只需要导演和编剧。由于可能有多个导演和编剧,我们将分别连接所有导演和编剧的名字。对于导演,从crew属性中,筛选出那些有Directing部门且职位为Director的人。对于这些对象,取出name属性,并用&符号连接起来。

  17. 对于作家,从crew属性中筛选出那些有Writing部门且职位为Writer的人。对于这些对象,取出name属性,并用&符号连接起来。

  18. 创建一个新的Movie对象(使用对象字面量语法)。使用我们迄今为止准备的电影和人物响应数据填写Movie对象的全部属性。

  19. 从处理器返回Movie对象。

  20. 注意,我们在代码中没有进行任何 UI 交互。我们只是接收了一个字符串,进行了一些 promise 调用,并返回了一个值。现在,UI 工作可以在面向 UI 的代码中完成。在这种情况下,是在search按钮的click事件处理器中。我们应该简单地为search调用添加一个then处理器,该处理器将调用showResults方法,并添加一个catch处理器,该处理器将调用clearResults方法。

尽管我们在这次活动中使用了fetch和 promise,并且我们的代码现在更加高效但复杂,网站的基本功能将保持不变,你应该会看到一个与之前活动类似的输出。

注意

本活动的代码文件可以在packt.link/IeDTF找到。本活动的解决方案可以通过这个链接找到。

async/await

Promises 很好地解决了回调问题。然而,它们往往伴随着许多不必要的冗余。我们需要编写很多then调用,并且要小心不要忘记关闭任何括号。

下一步是向我们的 TypeScript 技能中添加一点语法糖。与其他章节中的内容不同,这个特性最初源于 TypeScript,后来也被 JavaScript 采纳。我指的是async/await关键字。这两个关键字是分开的,但它们总是一起使用,所以整个特性被称为async/await

我们可以给某个函数添加async修饰符,然后,在那个函数中,我们可以使用await修饰符轻松地执行 promise。让我们再次回到我们的莎士比亚示例,并将我们用来调用getShakespeareData的代码包裹在一个名为run的函数中:

function run() {
    getShakespeareData()
    .then(author => {
        const body = document.getElementsByTagName("body")[0];

        const image = document.createElement("img");
        image.src = `http://covers.openlibrary.org/a/id/${author.photos[0]}-M.jpg`;
        body.appendChild(image);
        const name = document.createElement("h1");
        name.innerHTML = author.personal_name;
        body.appendChild(name);
    })
    .catch(error => {
        console.log(`An error occured ${error.error}: ${error.message}`);
    })
}
run();

这段代码在功能上与之前的代码等效。但现在,我们有一个可以标记为async函数的函数,如下所示:

async function run() {

现在,我们可以直接获取 promise 的结果并将其放入变量中。所以整个then调用将变成这样:

    const author = await getShakespeareData();
    const body = document.getElementsByTagName("body")[0];

    const image = document.createElement("img");
    image.src = `http://covers.openlibrary.org/a/id/${author.photos[0]}-M.jpg`;
    body.appendChild(image);

    const name = document.createElement("h1");
    name.innerHTML = author.personal_name;
    body.appendChild(name);

你可以看到我们不再有任何包装函数调用了。可以将catch调用替换为简单的try/catch结构,最终版本的run函数将如下所示:

async function run () {
  try {
    const author = await getShakespeareData();
    const body = document.getElementsByTagName("body")[0];

    const image = document.createElement("img");
    image.src = `http://covers.openlibrary.org/a/id/${author.photos[0]}-M.jpg`;
    body.appendChild(image);

    const name = document.createElement("h1");
    name.innerHTML = author.personal_name;
    body.appendChild(name);
  } catch (error) {
    console.log(`An error occured ${error.error}: ${error.message}`);
  }
}

你会注意到深度嵌套的代码量大大减少了。现在我们可以查看代码,并从快速浏览中了解它做什么,这仍然是相同的深度异步代码,唯一的区别是它看起来几乎是同步的,并且绝对是顺序的。

练习 10.05:使用 async 和 await 计数到五

在这个练习中,我们将创建一个函数,当执行时,将输出英语单词一至五。每个单词将在上一个单词显示后 1 秒出现在屏幕上:

注意

该练习的代码文件可以在packt.link/TaH6b找到。

  1. 创建一个新的文件,counting-3.ts

  2. counting-3.ts中,添加一个包含从一至五(包括五)的英语数字名称的数组:

    const numbers = ["One", "Two", "Three", "Four", "Five"];
    
  3. 添加setTimeout函数的 promisified 包装器。此包装器仅在指定的超时时间到期时执行:

    const delay = (ms: number) => {
        const result = new Promise<void>((resolve, reject) => {
            setTimeout(() => {
                resolve();
            }, ms)
        });
        return result;
    }
    

    由于我们的承诺不会返回任何有意义的成果,因此我们不是在给定数量的毫秒后简单地解析,而是提供了void作为其类型。

  4. 创建一个名为countNumbers的空async函数,并在文件的最后一行执行它:

    async function countNumbers() {
    }
    countNumbers();
    
  5. countNumbers函数内部,使用参数1000等待delay方法,并在其解决后打印出第一个数字:

    async function countNumbers() {
        await delay(1000);
        console.log(numbers[0]);
    }
    
  6. 保存文件,并使用以下命令编译它:

    tsc counting-3.ts
    
  7. 验证编译是否成功完成,并在同一文件夹中生成一个counting-3.js文件。使用以下命令在node环境中执行它:

    node counting-3.js
    

    你会看到输出看起来像这样:

    One
    

    该行应在应用程序运行后 1 秒出现。

  8. counting-3.ts文件中,在console.log行之后,添加两行以显示剩余的数字。代码应如下所示:

    async function countNumbers() {
        await delay(1000);
        console.log(numbers[0]);
        await delay(1000);
        console.log(numbers[1]);
        await delay(1000);
        console.log(numbers[2]);
        await delay(1000);
        console.log(numbers[3]);
        await delay(1000);
        console.log(numbers[4]);
    }
    
  9. 再次编译并运行代码,并验证输出是否按正确顺序出现。

    由于所有数字的代码完全相同,因此用for循环替换它是微不足道的。

  10. counting-3.ts文件中,删除countNumbers函数的主体,并用一个for循环替换它,该循环对numbers数组中的每个数字,将等待一秒钟的延迟,然后打印该数字:

    for (const number of numbers) {
        await delay(1000);
        console.log(number);
    };
    
  11. 再次编译并运行代码,并验证输出是否按正确顺序出现:

    One
    Two
    Three
    Four
    Five
    

活动 10.03:使用 fetch 和 async/await 的影片浏览器

在这个活动中,我们将改进上一个活动。主要区别在于,我们不会使用Promises类的then方法,而是使用await关键字来神奇地完成它。我们将没有then调用链,而只有看起来完全正常的代码,其中穿插着一些await语句。

该活动与上一个活动具有相同的文件和资源。

以下步骤应有助于您解决问题:

  1. script.ts文件中,找到search函数并验证它是否接受一个字符串参数,并且其主体为空。请注意,此函数现在已标记为async关键字,这允许我们使用await运算符。

  2. search函数上方创建一个名为getJsonData的辅助函数。此函数将使用fetch API 从端点获取数据并将其格式化为 JSON。它应接受一个名为url的单个字符串参数,并且它应该返回一个 promise。

  3. getJsonData函数的主体中,添加调用带有url参数的fetch函数的代码,然后调用返回响应上的json方法。

  4. search方法中,使用getSearchUrl方法构造一个新的搜索结果 URL。

  5. 使用searchUrl作为参数调用getJsonData函数,并await结果。将结果放入SearchResultApi变量中。

  6. 检查我们是否有任何结果,如果没有,则抛出错误。如果有结果,将result属性的第一个项目设置到一个名为movieResult的变量中。此对象将包含电影的idtitle属性。

  7. 使用id属性调用getMovieUrlgetPeopleUrl方法,分别获取电影详情和演员及制作组的正确 URL。

  8. 获取 URL 后,使用两个 URL 调用getJsonData函数,并将结果值分配给变量。请注意,getJsonData(movieUrl)调用将返回一个MovieResultApi的 promise,而getJsonData(peopleUrl)将返回一个PeopleResultApi的 promise。将这些结果值分配给名为dataPromisepeoplePromise的变量。

  9. 使用dataPromisepeoplePromise作为参数调用静态Promise.all方法。这将基于这两个值创建另一个 promise,并且只有当包含在内的所有 promise 都成功解决时,这个 promise 才会成功解决。它的返回值将是一个包含结果的 promise 数组。await这个 promise,并将其结果放入一个类型为array的变量中。

  10. 将该数组解构为两个变量。数组的第一个元素应该是movieData变量,其类型为MovieResultApi,数组的第二个元素应该是peopleData变量,其类型为PeopleResultApi

  11. 人员数据有castcrew属性。我们只取前六个演员,所以首先根据演员的order属性对cast属性进行排序。然后从第一个六个演员中切出新的数组。

  12. 将演员数据(即CastResultApi对象)转换为我们的Character对象。我们需要将CastResultApicharacter字段映射到Charactername字段,将name字段映射到演员名称,将profile_path字段映射到image属性。

  13. 从人物数据的 crew 属性中,我们只需要导演和编剧。由于可能有多个导演和编剧,我们将获取所有导演和编剧的名字并将它们分别连接起来。对于导演,从 crew 属性中筛选出具有 Directing 部门和 Director 职位的个人。对于这些对象,取其 name 属性,并用 & 连接起来。

  14. 对于编剧,从 crew 属性中筛选出具有 Writing 部门和 Writer 职位的个人。对于这些对象,取其 name 属性,并用 & 连接起来。

  15. 创建一个新的 Movie 对象(使用对象字面量语法)。使用我们迄今为止准备的电影和人物响应数据,填写 Movie 对象的所有属性。

  16. 从函数中返回 Movie 对象。

  17. 注意,在我们的代码中我们没有进行任何 UI 交互。我们只接收了一个字符串,进行了一些承诺调用,并返回了一个值。现在,UI 的工作可以在面向 UI 的代码中完成。在这种情况下,是在 search 按钮的 click 事件处理器中。我们应该简单地给 search 调用添加一个 then 处理器,该处理器将调用 showResults 方法,并添加一个 catch 处理器,该处理器将调用 clearResults 方法。

    尽管我们在这次活动中使用了 fetchasync/await,与之前的活动相比,我们的代码现在既高效又简单,但网站的基本功能将保持不变,你应该会看到与之前活动类似的结果。

    注意

    本活动的代码文件可以在 packt.link/fExtR 找到。本活动的解决方案可以通过 此链接 获取。

摘要

在本章中,我们探讨了在网络上使用的执行模型,以及我们如何使用它来实际执行代码。我们简要地了解了异步开发的复杂性表面——以及我们如何使用它从外部资源加载数据。我们展示了当我们深入回调的陷阱时出现的问题,并设法使用承诺退出。最后,我们能够等待异步代码的执行,并兼得两者之长——代码看起来像是同步的,但实际上是异步执行的。

我们还通过创建一个电影数据查看器浏览器来测试本章中开发的技能,最初使用 XHR 和回调,然后逐步使用 fetch 和承诺进行改进,最后使用 fetchasync/await

下一章将教你关于高阶函数和回调的知识。

第十二章:11. 高阶函数和回调

概述

本章介绍了 TypeScript 中的高阶函数和回调。你将首先了解什么是高阶函数,它们为什么有用,以及如何在 TypeScript 中正确地给它们类型化。然后,本章将教你什么是回调,为什么它们被使用,以及在什么情况下使用。你还将了解到为什么回调被广泛使用,尤其是在 Node.js 中。

此外,本章还将为你提供一个关于事件循环的基本介绍。你不仅将了解“回调地狱”,还将学习如何避免它。到本章结束时,你将能够创建一个类型化的高阶 pipe 函数。

简介

你已经在 第三章 函数 中了解了 TypeScript 中函数的使用。本章将介绍 TypeScript 中的高阶函数。到目前为止,你在这本书中使用的所有函数,要么向它们传递参数或参数,要么通过扩展,TypeScript 有许多编写代码的组合方式。在本章中,我们将探讨其中一种模式——高阶函数/回调(以下简称 HOCs)是那些要么接受另一个函数作为参数,要么返回一个函数(或两者都是)的函数。

此外,本章还探讨了回调的概念。在 Node.js 以及其他 JavaScript 运行时中,回调是必需的,因为语言是单线程的,并在事件循环中运行,因此,为了不阻塞主线程,我们让其他代码运行,并在需要时调用我们的代码。本章还将涉及“回调地狱”,并为你提供避免它的技能。

HOCs 简介——示例

HOCs 在 JavaScript 中经常被使用,尤其是在 Node.js 中,即使是最简单的后端服务器应用程序也包含它。以下是一个示例:

const http = require("http");
http.createServer((req, res) => {
  res.write("Hello World");
  res.end();
}).listen(3000, () => {
  console.log("🚀 running on port 3000");
});

注意到 createServer 函数接受一个请求监听 函数,该函数将用于处理任何传入的请求。这个函数将接受两个参数,reqres 分别代表请求对象和响应对象:

图 11.1:Node.js 中 http 模块描述回调的部分

RequestListener 的结构

img/B14508_11_01.jpg

图 11.1:Node.js 中 http 模块描述 RequestListener 回调结构的部分

此外,listen 方法还接受一个可选的函数,当服务器准备好监听请求时,该函数将被执行。

createServerlisten 都是高阶组件(HOC),因为它们接受函数作为参数。这些参数函数通常被称为 回调函数,因为我们的代码可以通过这种方式在发生某些事情时被“回调”(通知),并且如果需要,可以适当地处理它。在前面的示例中,HTTP 服务器需要知道如何处理传入的请求,因此它调用我们提供的 requestListener 函数,该函数提供了相应的逻辑。稍后,listen 函数想要让我们知道它何时准备好接受请求,并且它会调用我们提供的回调函数。

另一个例子是 setTimeout 函数,它接受另一个 函数 作为参数,稍后(超时后)调用:

setTimeout(() => {
    console.log('5 seconds have passed');
},  5000);
function setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): NodeJS.Timeout;

另一个不接收回调函数的高阶组件(HOC)示例是 memoize 函数。它接受一个要 memoize 的函数作为参数,并返回一个具有相同签名的函数:

function memoize<Fn extends AnyFunction>(fn: Fn, keyGetter?: KeyGetter<Fn>): Fn;

注意

memoize 函数接受一个函数,并返回一个具有相同类型签名的函数;然而,返回的函数会缓存原始函数的结果。这对于运行时间较长且对相同参数返回相同输出的昂贵函数通常很有用。第九章泛型和条件类型练习 9.01 实现了这样的 memoize 函数。

在接下来的章节中,我们将更详细地探讨这两种高阶组件(HOC),并看看我们如何避免它们引入的一些陷阱。

高阶函数

高阶函数是遵循以下两个原则之一的常规函数:

  1. 它们接受一个或多个函数作为参数。

  2. 它们返回一个函数作为结果。

例如,假设我们想要编写一个 greet 函数:

Example01.ts
1 function greet(name: string) {
2   console.log(`Hello ${name}`);
3 }
4
5 greet('John'); 
Link to the preceding example: https://packt.link/GCFjN

以下是对应的输出:

Hello John

这是一个很好的函数,但它非常有限——如果每个人都有一句喜欢的问候语怎么办?考虑以下示例:

Example02.ts
1 const favoriteGreetings: Record<string, string> = {
2   John: 'Hey',
3   Jane: 'Hello',
4   Doug: 'Howdy',
5   Sally: 'Hey there',
6 };
Link to this example: https://packt.link/CXBrV

我们可以将它放在 greet 函数内部:

function greet(name: string) {
  const greeting = favoriteGreetings[name] || 'Hello';
  console.log(`${greeting} ${name}`);
}
greet('John'); 

以下是对应的输出:

Hey John

但这意味着 greet 函数本身就不再可重用了,因为如果我们使用它,我们还需要带上 favoriteGreetings 映射。相反,我们可以将其作为参数传递:

Example03.ts
1 function greet(name: string, mapper: Record<string, string>) {
2   const greeting = mapper[name] || 'Hello';
3   console.log(`${greeting} ${name}`);
4 }
5
6 greet('John', favoriteGreetings); // prints 'Hey John'
7 greet('Doug', favoriteGreetings); // prints 'Howdy Doug'
Link to this example: https://packt.link/bG0p7

以下是对应的输出:

Hey John
Howdy Doug

这可行,但每次调用时传递 favoriteGreetings 对象非常繁琐。

我们可以通过使 greet 函数接受一个函数来改进这一点,该函数将作为解决喜欢的问候语问题的更通用解决方案——它将接受名字并返回要使用的问候语:

Example04.ts
1 function greet(name: string, getGreeting: (name: string) => string) {
2   const greeting = getGreeting(name);
3   console.log(`${greeting} ${name}`);
4 }
5 
6 function getGreeting(name: string) {
7   const greeting = favoriteGreetings[name];
8   return greeting || 'Hello';
9 }
10
11 greet('John', getGreeting); // prints 'Hey John'
12 greet('Doug', getGreeting); // prints 'Howdy Doug'
Link to this example: https://packt.link/uRe2r

以下是对应的输出:

Hey John
Howdy Doug

这可能感觉和我们的上一个解决方案一样,该解决方案接受映射器对象作为参数,但传递函数要强大得多。我们可以用函数做很多事情,而不仅仅是静态对象。例如,我们可以根据一天中的时间来设置问候语:

Example05.ts
1  function getGreeting(name: string) {
2    const hours = new Date().getHours();
3   if (hours < 12) {
4     return 'Good morning';
5   }
6   
7   if (hours === 12) {
8     return 'Good noon';
9   }
10  
11  if (hours < 18) {
12    return 'Good afternoon';
13  }
14 
15  return 'Good night';
16  }
17 
18  greet('John', getGreeting); // prints 'Good morning John' if it's morning
19  greet('Doug', getGreeting); // prints 'Good morning Doug' if it's morning
Link to this example: https://packt.link/xSYDF

一个示例输出可能如下所示:

Good afternoon John
Good afternoon Doug

我们甚至可以更进一步,使函数返回一个随机问候语,从远程服务器获取,等等,这是没有在greet函数中传递函数无法做到的。

通过使greet接受一个函数,我们打开了无限的可能性,同时保持了greet的可重用性。

这很好,但每次调用时传递getGreeting函数仍然感觉有些繁琐。我们可以通过将greet函数改为同时接受一个函数并返回一个函数来改变这一点。让我们看看这是如何实现的:

Example06.ts
1 function greet(getGreeting: (name: string) => string) {
2   return function(name: string) {
3     const greeting = getGreeting(name);
4     console.log(`${greeting} ${name}`);
5   };
6 }
Link to this example: https://packt.link/8nHeD

你会注意到逻辑与上一个解决方案相同,但我们拆分了函数,首先接受getGreeting函数,然后返回另一个接受name参数的函数。这允许我们像这样调用greet

const greetWithTime = greet(getGreeting);
greetWithTime('John'); // prints 'Good morning John' if it's morning
greetWithTime('Doug'); // prints 'Good morning Doug' if it's morning

以这种方式拆分greet使我们具有更大的灵活性——因为我们现在只需要getGreeting函数一次,就可以将其内联,如果它在其他地方使用不合理的话:

8  const greetWithTime = greet(function(name: string) {
9   const hours = new Date().getHours();
10   if (hours < 12) {
11     return 'Good morning';
12   }
13 
14   if (hours === 12) {
15     return 'Good noon';
16   }
17 
18   if (hours < 18) {
19     return 'Good afternoon';
20   }
21 
22   return 'Good night';
23 });

我们还可以使用它来使用ArrayforEach方法问候一个人员(名字)数组:

const names = ['John', 'Jane', 'Doug', 'Sally'];
names.forEach(greetWithTime);

以下是输出:

Good afternoon John
Good afternoon Jane
Good afternoon Doug
Good afternoon Sally

高阶函数,尤其是接受其他函数的函数,非常普遍且有用,尤其是在操作数据集时。我们甚至在之前的章节中使用过它们。例如,ArraymapfilterreduceforEach方法接受函数作为参数。

练习 11.01:使用高阶函数编排数据过滤和操作

在此练习中,我们获取一个学生列表,并希望获取 2010 年毕业的学生的平均分数。此练习将使用高阶函数来完成此任务。

学生列表如下所示:

interface Student {
  id: number;
  firstName: string;
  lastName: string;
  graduationYear: number;
  score: number;
}
const students: Student[] = [
  { id: 1, firstName: 'Carma', lastName: 'Atwel', graduationYear: 2010, score: 88 },
  { id: 2, firstName: 'Shaun', lastName: 'Knoller', graduationYear: 2011, score: 84 },
  // ...
];

注意

您可以参考以下起始文件以获取学生界面代码:packt.link/6Jmeu

实施此练习的步骤如下:

注意

此练习的代码文件可以在以下位置找到:packt.link/fm3O4。请确保从之前提到的学生界面代码开始。

  1. 创建一个函数getAverageScore,它将接受一个Student[]参数,并返回一个number

    function getAverageScoreOf2010Students(students: Student[]): number {
      // TODO: implement
    }
    
  2. 首先,我们希望只获取2010 年毕业的学生。为此,我们可以使用数组的filter方法——这是一个接受谓词的高阶函数,它接受一个从数组中获取项的函数,并返回truefalse,这取决于该项是否应包含在结果中。filter返回一个新数组,该数组包含根据谓词的一些原始数组项。新数组的长度小于或等于原始数组的长度。

  3. 使用以下代码更新你的函数:

    function getAverageScoreOf2010Students(students: Student[]): number {
      const relevantStudents = students.filter(student => student.graduationYear === 2010);
    }
    

    接下来,我们只关心每个学生的分数。我们可以使用数组的map方法来做到这一点——这是一个接受映射函数的更高阶函数,该函数接受数组中的一个项,并为每个项返回一个新的、转换后的值(你选择的类型)。map返回一个包含转换后项的新数组。

  4. 按照以下方式使用map方法:

    function getAverageScoreOf2010Students(students: Student[]): number {
      const relevantStudents = students.filter(student => student.graduationYear === 2010);
      const relevantStudentsScores = relevantStudents.map(student => student.score);
    }
    

    最后,我们想要从分数数组中获取平均值。我们可以使用数组的reduce方法来做到这一点——这是一个接受减法函数和初始值的更高阶函数。

  5. 按照以下方式使用reduce方法更新函数:

    function getAverageScoreOf2010Students(students: Student[]): number {
      const relevantStudents = students.filter(student => student.graduationYear === 2010);
      const relevantStudentsScores = relevantStudents.map(student => student.score);
      const relevantStudentsTotalScore = relevantStudentsScores.reduce((acc, item) => acc + item, 0);
      return relevantStudentsTotalScore / relevantStudentsScores.length;
    }
    

    减法函数接受累加器和当前值,并返回一个累加器。reduce遍历数组中的项,在每次迭代中调用减法函数,使用当前项和之前返回的累加器(或第一次运行的初始值)。最后,它返回遍历整个数组后的结果累加器。在这种情况下,我们想要计算数组中数字的平均值,因此我们的减法函数将求和所有项,然后除以女性学生的数量。然后我们可以用任何数据集调用该函数,得到平均分数。

  6. 使用npx ts-node运行文件。你应该在你的控制台看到以下输出:

    The average score of students who graduated in 2010 is: 78.5
    

    注意

    在这个练习中,我们还可以将传递给filtermapreduce的每个函数提取出来,作为一个有名称的非内联函数,如果它在当前上下文之外使用是有意义的;例如,如果我们想在getAverageScoreOf2010Students之外测试过滤逻辑。

回调函数

回调函数是我们传递给其他函数的函数,它们在需要时会被调用。例如,在客户端,如果你想监听特定 DOM 元素上的点击事件,你可以通过addEventListener附加一个事件处理器。当你点击该元素时,传递进去的函数就会被调用:

const btnElement = document.querySelector<HTMLButtonElement>('.my-button');
function handleButtonClick(event: MouseEvent) {
  console.log('.my-button was clicked!');
}
btnElement.addEventListener('click', handleButtonClick);

在这个例子中,handleButtonClick是传递给addEventListener回调函数。每当有人点击.my-button元素时,它就会被调用。

注意

你也可以内联handleButtonClick函数,但之后你将无法调用removeEventListener,这在某些情况下是必需的,以避免内存泄漏。

在服务器上,回调函数被广泛使用。即使是 Node.js 的http模块中最基本的请求处理器也需要传递一个回调函数:

import http from 'http';
function requestHandler(req: http.IncomingMessage, res: http.ServerResponse) {
  res.write('Hello from request handler');
  res.end();
}
http
  .createServer(requestHandler)
  .listen(3000);

在这个例子中,requestHandler是传递给createServer的回调函数。每当有请求到达服务器时,它就会被调用,这就是我们定义我们想要做什么,以及我们想要如何响应的地方。

事件循环

由于 JavaScript 是单线程的,因此需要回调函数来保持主线程空闲——基本思想是,你给引擎一个函数来调用,当发生某些事情时,你可以处理它,然后返回控制权给需要运行的任何其他代码。

注意

在更近期的浏览器和 Node.js 版本中,你可以在浏览器中使用 Web Workers 或者在 Node.js 中使用 Worker Threads 来创建线程。然而,这些通常用于 CPU 密集型任务,并且它们不像回调或其他替代方案(例如,Promises – 在第十三章中更详细地探讨了,TypeScript 中的 Async Await)那样容易使用。

为了说明这一点,让我们看看一段没有回调的 JavaScript 代码版本,我们想要创建一个简单的服务器,通过用户的名字来问候用户:

// server.ts
function logWithTime(message: string) {
  console.log(`[${new Date().toISOString()}]: ${message}`);
}
http
  .createServer((req, res) => {
    logWithTime(`START: ${req.url}`);
    const name = req.url!.split('/')[1]!;
    const greeting = fetchGreeting(name);
    res.write(greeting);
    res.end();
    logWithTime(`END: ${req.url}`);
  })
  .listen(3000);

fetchGreeting 模拟了一个网络操作,它是同步执行的,以说明问题:

function fetchGreeting(name: string) {
  const now = Date.now();
  const fakeRequestTime = 5000;
  logWithTime(`START: fetchGreeting for user: ${name}`);

  while (Date.now() < now + fakeRequestTime);

  logWithTime(`END: fetchGreeting for user: ${name}`);
  return `Hello ${name}`;
}

在一个更贴近现实世界的例子中,fetchGreening 可以被替换为从数据库中获取用户数据的调用。

如果我们运行服务器并尝试同时请求几个问候语,你会注意到它们每个都要等待前一个请求完成后再开始请求当前的数据。我们可以通过多次调用 fetch 来模拟几个并发请求,而不必先等待前一个请求完成:

// client.ts
fetch('http://localhost:3000/john');
fetch('http://localhost:3000/jane');

你在服务器控制台看到的输出是:

图 11.2:同时进行多个请求时运行同步服务器的输出

图 11.2:同时进行多个请求时运行同步服务器的输出

如你所见,简必须等待约翰的请求完成(在这个例子中是 5 秒)之后,服务器才开始处理她的请求。问候两个用户所需的总时间是 10 秒。你能想象在一个真实的服务器上,同时处理数百或更多请求会发生什么吗?

让我们看看回调如何解决这个问题。

我们首先将 fetchGreeting 更改为使用回调 API – 在这种情况下,setTimeout 执行与之前的 while 循环相同的功能,而不会阻塞主线程:

function fetchGreeting(name: string, cb: (greeting: string) => void) {
  const fakeRequestTime = 5000;
  logWithTime(`START: fetchGreeting for user: ${name}`);
  setTimeout(() => {
    logWithTime(`fetched greeting for user: ${name}`);
    cb(`Hello ${name}`);
  }, fakeRequestTime);
  logWithTime(`END: fetchGreeting for user: ${name}`);
}

然后,将请求处理器更改为使用新的实现:

// server.ts
http
  .createServer((req, res) => {
    logWithTime(`START: ${req.url}`);
    const name = req.url!.split('/')[1]!;
    fetchGreeting(name, greeting => {
      logWithTime(`START: callback for ${name}`);
      res.write(greeting);
      res.end();
      logWithTime(`END: callback for ${name}`);
    });
    logWithTime(`END: ${req.url}`);
  })
  .listen(3000);

再次运行客户端代码。这导致以下输出:

图 11.3:同时进行多个请求时运行异步服务器的输出

同时进行多个请求

img/B14508_11_03.jpg

图 11.3:同时进行多个请求时运行异步服务器的输出

如你所见,服务器首先处理了约翰的请求,因为那是第一个到达的,但随后立即切换到处理简的请求,同时等待约翰的问候语准备好。当 5 秒后约翰的问候语准备好时,服务器发送了问候语,然后等待简的问候语在几毫秒后准备好,并将其发送给她。

总结来说,现在的 相同流程 只用了 5 秒来响应 两个用户,而不是之前的 10 秒。此外,大部分时间都是空闲的 – 等待接收更多请求来处理。这与回调之前的流程相反,那时服务器被卡住,大部分时间无法回答任何请求。

Node.js 中的回调

由于回调在 Node.js 中非常常见,而且整个生态系统依赖于使用外部包来处理许多事情,因此任何异步函数都有一个标准的回调 API 结构:

  1. 回调函数将是最后一个参数。

  2. 回调函数将err作为第一个参数,它可能是null(或undefined),响应数据作为第二个参数。

还允许有更多的参数,但这两个是强制性的。这导致处理回调的结构具有可预测性,以下是一个从文件系统中读取文件的示例:

import fs from "fs";
fs.readFile("some-file", (err, file) => {
  if (err) {
    // handle error...
    return;
  }
  // handle file...
});

回调地狱

很不幸,使用回调的代码可能会使得代码的可读性、理解性和推理性变得非常困难。每一个异步操作都需要另一个回调层级,如果你想要连续运行多个异步操作,你必须嵌套这些回调。

例如,假设我们想要构建一个社交网络,它有一个端点,你可以根据用户名请求特定用户的好友。获取这个好友列表需要多个操作,每个操作都需要一个依赖于前一个操作结果的异步操作:

  1. 从数据库中获取请求用户的 ID(给定他们的用户名)。

  2. 获取用户的隐私设置,以确保他们允许其他人查看他们的好友列表。

  3. 获取用户的好友(来自外部服务或其他方式)。

这里有一些示例代码,展示了如何使用回调来实现这一点。我们在这里使用express来设置一个基本的服务器,监听端口3000。服务器可以接受一个GET请求到/:username/friends(其中:username将被实际请求的用户名替换)。在接收请求后,我们从数据库中获取用户的 ID,然后使用用户的 ID 获取用户隐私偏好(这可以是在外部服务中,或其他方式),以检查他们是否允许其他人查看他们的好友列表,然后获取用户的好友,最后返回结果:

import express from 'express';
import request from 'request';
import sqlite from 'sqlite3';
const db = new sqlite.Database('db.sql', err => {
  if (err) {
    console.error('Error opening database:', err.message);
  }
});
const app = express();
app.get('/:username/friends', (req, res) => {
  const username = req.params.username;
  db.get(
    `SELECT id
    FROM users
    WHERE username = username`,
    [username],
    (err, row) => {
      if (err) {
        return res.status(500).end();
      }
      getUserPrivacyPreferences(row.id, (err, privacyPreferences) => {
        if (err) {
          return res.status(500).end();
        }
        if (!privacyPreferences.canOthersViewFriends) {
          return res.status(403).end();
        }
        getFriends(row.id, (err, friends) => {
          if (err) {
            return res.status(500).end();
          }
          return res
            .status(200)
            .send({ friends })
            .end();
        });
      });
    }
  );
});
app.get('*', (req, res) => {
  res.sendFile('index.html');
});
app.listen(3000);

还要注意,在每一个回调中,我们都得到了一个err参数,并且必须检查它是否为真,如果没有合适的错误代码,就提前退出。

前面的例子并不不切实际,很多情况需要比这更多的层级才能获取完成任务所需的所有数据。因此,“回调地狱”变得更加明显,理解和推理起来也更加困难,因为,如前所述,由于 JavaScript 的工作方式,Node.js 中的许多 API 都使用回调,这在事件循环部分有解释。

避免回调地狱

对于回调地狱问题,有相当多的解决方案。我们将查看最突出的几个,展示前述代码片段在每个变化中的样子:

  1. 将回调函数提取到文件级别的函数声明中,然后使用它们——这意味着你只有一层具有业务逻辑的函数,回调地狱函数变得更短。

  2. 使用高阶函数来链式连接回调,这意味着实际上只有一个回调层级。

  3. 使用可以在其上链式调用的Promise,如第十三章中所述,TypeScript 中的 Async Await

  4. 使用async/await(这是在Promise之上的语法糖),如第十三章中所述,TypeScript 中的 Async Await

在文件级别将回调处理程序拆分为函数声明

简化回调地狱的最简单方法是将一些回调提取到它们自己的顶级函数中,并让每个函数调用逻辑链中的下一个函数。

我们的主要端点处理程序将像之前一样调用dbget方法,但随后只需调用handleDatabaseResponse函数并传递响应,让它处理任何错误等。这就是为什么我们也将响应对象传递给函数,以防它需要将数据或错误返回给用户:

app.get('/:username/friends', (req, res) => {
  const username = req.params.username;
  db.get(
    `SELECT id
    FROM users
    WHERE username = username`,
    [username],
    (err, row) => {
      handleDatabaseResponse(res, err, row);
    }
  );
});

handleDatabaseResponse函数将执行之前的相同逻辑,但现在将getUserPrivacyPreferences响应的处理传递给handleGetUserPrivacyPreferences

function handleDatabaseResponse(res: express.Response, err: any, row: { id: string }) {
  if (err) {
    return res.status(500).end();
  }
  getUserPrivacyPreferences(row.id, (err, privacyPreferences) => {
    handleGetUserPrivacyPreferences(res, row.id, err, privacyPreferences);
  });
}

handleGetUserPrivacyPreferences将再次执行之前的相同逻辑,并将getFriends响应的处理传递给handleGetFriends

function handleGetUserPrivacyPreferences(
  res: express.Response,
  userId: string,
  err: any,
  privacyPreferences: PrivacyPreferences
) {
  if (err) {
    return res.status(500).end();
  }
  if (!privacyPreferences.canOthersViewFriends) {
    return res.status(403).end();
  }
  getFriends(userId, (err, friends) => handleGetFriends(res, err, friends));
}

最后,handleGetFriends将通过响应将数据返回给用户:

function handleGetFriends(res: express.Response, err: any, friends: any[]) {
  if (err) {
    return res.status(500).end();
  }
  return res
    .status(200)
    .send({ friends })
    .end();
}

现在我们只有一个嵌套层级,没有更多的回调地狱。

这里的主要权衡是,虽然代码的嵌套程度降低了,但它被分散到多个函数中,可能更难理解,尤其是在调试或快速浏览以了解高层次发生的事情时。

链式连接回调

有库可以帮助我们通过将回调链式连接来消除回调地狱问题,从而人为地从我们的代码中移除嵌套层级。其中之一是 async.js (github.com/caolan/async),它公开了一些函数来组合回调函数,例如parallelserieswaterfall。在我们的先前的代码示例中,我们可以使用waterfall函数将回调链式连接,使它们依次发生:

  1. 我们实现一个函数数组和一个最终处理程序。async将依次调用我们的函数,当我们每个函数中的回调被调用时,如这里所示:

    ...
    import async from 'async';
    ...
    type CallbackFn = <T extends any[]>(err: any, ...data: T) => void;
    class ServerError extends Error {
      constructor(public readonly statusCode: number, message?: string) {
        super(message);
      }
    }
    app.get('/:username/friends', (req, res) => {
      const username = req.params.username;
    
  2. 从数据库中获取用户 ID:

      async.waterfall(
        [
          // 1\. Get the user id from the database
          (cb: CallbackFn) => {
            db.get(
              `SELECT id
                FROM users
                WHERE username = username`,
              [username],
              (err, row) => {
                if (err) {
                  return cb(err);
                }
                return cb(null, row);
              }
            );
          },
    
  3. 获取用户的隐私设置:

          (row: { id: string }, cb: CallbackFn) => {
            getUserPrivacyPreferences(row.id, (err, privacyPreferences) => {
              if (err) {
                return cb(err);
              }
              return cb(null, privacyPreferences, row.id);
            });
          },
    
  4. 检查用户的隐私设置是否允许他人查看他们的好友:

          (privacyPreferences: PrivacyPreferences, userId: string, cb: CallbackFn) => {
            if (!privacyPreferences.canOthersViewFriends) {
              return cb(new ServerError(403, "User doesn't allow others to view their friends"));
            }
            return cb(null, userId);
          },
    
  5. 获取用户的好友:

          (userId: string, cb: CallbackFn) => {
            getFriends(userId, (err, friends) => {
              if (err) {
                return cb(err);
              }
              return cb(null, friends);
            });
          },
        ],
    
  6. 最后,处理发生的任何错误或最后一个回调返回的数据:

        (error, friends) => {
          if (error) {
            if (error instanceof ServerError) {
              return res
                .status(error.statusCode)
                .send({ message: error.message })
                .end();
            }
            return res.status(500).end();
          }
          return res
            .status(200)
            .send({ friends })
            .end();
        }
      );
    });
    

现在代码更容易理解——我们只有一个与响应对象相关联的错误处理程序,我们从上到下跟随代码,中间没有太多的嵌套,至少不是由于回调造成的。

Promise

Promises 允许你通过类似于 async.js 的 waterfall 方法来本质上扁平化回调树,但它更加无缝,是语言本身内置的,还允许承诺被“压缩”。

我们在这里不会过多深入——有关承诺的深入解释,请参阅 第十三章TypeScript 中的 Async Await

...
app.get('/:username/friends', (req, res) => {
  const username = req.params.username;
  promisify<string, string[], { id: string }>(db.get)(
    `SELECT id
  FROM users
  WHERE username = username`,
    [username]
  )
    .then(row => {
      return getUserPrivacyPreferences(row.id).then(privacyPreferences => {
        if (!privacyPreferences.canOthersViewFriends) {
          throw new ServerError(403, "User doesn't allow others to view their friends");
        }
        return row.id;
      });
    })
    .then(userId => {
      return getFriends(userId);
    })
    .then(friends => {
      return res
        .status(200)
        .send({ friends })
        .end();
    })
    .catch(error => {
      if (error instanceof ServerError) {
        return res
          .status(error.statusCode)
          .send({ message: error.message })
          .end();
      }
      return res.status(500).end();
    });
});

async/await

Async/await 在承诺的基础上构建,并在其之上提供了进一步的语法糖,以便使承诺看起来和读起来像同步代码,尽管在幕后它仍然是异步的。你可以在 第十三章TypeScript 中的 Async Await 中找到对它们的更深入解释,但使用承诺的前述代码等同于以下使用 async/await 的代码:

...
app.get('/:username/friends', async (req, res) => {
  const username = req.params.username;
  try {
    const row = await promisify<string, string[], { id: string }>(db.get)(
      `SELECT id
       FROM users
       WHERE username = username`,
      [username]
    );
    const privacyPreferences = await getUserPrivacyPreferences(row.id);
    if (!privacyPreferences.canOthersViewFriends) {
      throw new ServerError(403, "User doesn't allow others to view their friends");
    }
    const friends = await getFriends(row.id);
    return res
      .status(200)
      .send({ friends })
      .end();
  } catch (error) {
    if (error instanceof ServerError) {
      return res
        .status(error.statusCode)
        .send({ message: error.message })
        .end();
    }
    return res.status(500).end();
  }
});

活动 11.01:高阶管道函数

在此活动中,你被要求实现一个 pipe 函数——一个接受其他函数以及一个值的更高阶函数,并将它们组合——返回一个接受组合中第一个函数的参数的函数,运行它通过函数——将每个函数的输出作为输入(并将第一个函数的初始值作为输入),并返回最后一个函数的结果。

这样的函数存在于 Ramda 等实用库中(ramdajs.com/docs/#pipe),以及其他库如 Lodash (lodash.com/docs#chain) 和 RxJS (rxjs.dev/api/index/function/pipe) 中的变体。

注意

你可以在packt.link/CQLfx找到活动的起始文件和解决方案。

执行以下步骤以实现此活动:

  1. 创建一个接受函数作为参数并将它们组合的 pipe 函数,从左到右。

  2. 确保返回函数的返回类型正确——它应该接受类型为 T 的参数,其中 T 是链中第一个函数的参数,并返回类型为 R 的结果,其中 R 是链中最后一个函数的返回类型。

    注意,由于当前 TypeScript 的限制,你必须手动为想要支持的参数数量进行类型注解。

  3. 你的 pipe 函数应该可以通过多种方式调用——支持最多五个函数的组合,并且将仅支持组合具有单个参数的函数,以简化。

    这是你可以使用的 pipe 函数的结构:

    const func = pipe(
      (x: string) => x.toUpperCase(),
      x => [x, x].join(','),
      x => x.length,
      x => x.toString(),
      x => Number(x),
    );
    console.log('result is:', func('hello'));
    

在解决前面的步骤之后,此代码的预期输出如下所示:

result is: 11 

奖励:作为一个挑战,尝试扩展 pipe 函数以支持更多函数的组合,或者更多参数。

注意

通过此链接可以找到此活动的解决方案。

概述

在本章中,我们介绍了 TypeScript 中的两个关键概念——高阶函数和回调函数。本章首先定义了高阶组件(HOCs),并通过多个示例来阐述这一概念。您还使用高阶函数进行了数据过滤和操作。最后,您还通过创建一个高阶管道函数来测试了自己的技能。

关于回调函数,本章首先通过一些通用示例介绍了回调函数的定义,并辅以 Node.js 中与回调函数相关的示例。您还了解到如何轻易陷入回调地狱以及如何避免这种情况。尽管要掌握高阶函数和回调需要采取几个额外的步骤,但本章已经为您开启了这段旅程。下一章将探讨 TypeScript 中的另一个重要概念——Promise。

第十三章:12. TypeScript 中 Promise 的指南

概述

本章将探讨使用 Promise 在 TypeScript 中的异步编程,并讨论异步编程的用途以及如何在单线程 JavaScript 和事件循环中实现。到本章结束时,你应该对 Promise 的工作原理以及 TypeScript 如何增强它们有一个牢固的理解。你还将能够使用本章教授的概念构建一个基于 Promise 的应用程序。

简介

在上一章中,我们学习了使用回调函数进行异步编程。有了这些知识,我们可以管理并发请求,并编写非阻塞代码,使我们的应用程序能够更快地渲染网页或在一个 Node.js 服务器上处理并发请求。

在本章中,我们将学习 Promise 如何使我们能够编写更易于阅读和简洁的代码,以更好地管理异步过程,并永远摆脱深层次的回调嵌套,有时也称为“回调地狱”。我们将探讨 Promise 对象的演变以及它最终成为 JavaScript 语言的一部分。我们将查看 TypeScript 的不同转译目标以及 TypeScript 如何增强 Promise 并允许开发者利用泛型来推断返回类型。

我们将进行一些实际练习,例如管理来自网站的多个 API 请求以及管理 Node.js 中的并发。我们将使用 Node.js 文件系统 API 对文件执行异步操作,并看到异步编程有多么强大。

Promise 的演变及其动机

正如我们所学的,回调是一个作为另一个函数参数给出的函数,实际上是在说:“当你完成时做这个。”这种能力自 1995 年 JavaScript 诞生以来就存在,并且可以工作得很好,但随着 JavaScript 应用程序在 2000 年代的复杂性增长,开发者发现回调模式和嵌套尤其混乱且难以阅读,正如以下示例所示:

doSomething(function (err, data) {
  if(err) {
    console.error(err);
  } else {
    request(data.url, function (err, response) {
      if(err) {
        console.error(err);
      } else {
        doSomethingElse(response, function (err, data) {
          if(err) {
            console.error(err);
          } else {
            // ...and so it goes!
          }
        })
      }
    })
  }
});

除了使代码更易于阅读和简洁外,Promise 在回调之外还有优势,因为 Promise 是包含解决异步函数状态的对象。这意味着 Promise 可以被存储,并且可以在任何时间查询其当前状态,或者通过其 then()catch() 方法调用以获取 Promise 的解决状态。我们将在本章后面讨论这些方法,但在这里值得指出的是,Promise 不仅仅是语法糖。它们开辟了全新的编程范式,其中事件处理逻辑可以通过将事件存储在 Promise 中来与事件本身解耦。

Promise 并非 JavaScript 独有,但最初在 20 世纪 70 年代作为一个计算机编程概念被提出。

注意

更多信息,请参阅弗里德曼,丹尼尔;大卫·怀斯(1976)。应用编程对并行处理的影响。国际并行处理会议。第 263-272 页。

随着 Web 框架的流行,Promise 的提案始于 2009 年,jQuery 等库在 2011 年开始实现类似 Promise 的对象。

注意

如需更多信息,请参阅以下链接:groups.google.com/g/commonjs/c/6T9z75fohDkapi.jquery.com/category/version/1.5/

不久之后,Node.js 也开始拥有了一些 Promise 库。Google 的 AngularJS 捆绑了 Q 库。所有这些库都将回调封装在高级 API 中,这些 API 对开发者有吸引力,并帮助他们编写更干净、更易读的代码。

2012 年,Promise 被正式提出作为一项规范,以标准化 API。该规范于 2015 年获得批准,并自那时起在所有主要浏览器以及 Node.js 中得到了实现。

注意

更多详情,请参阅www.ecma-international.org/ecma-262/6.0/#sec-promise-constructor

“Promise 化”,即能够将现有的异步函数包裹在 Promise 中,这一功能被添加到许多库中,并成为了 Node.js 标准库中util包的一部分,自 8.0 版本(2017 年发布)起。

作为 JavaScript 的超集,TypeScript 将始终支持原生语言特性,如 Promise;然而,TypeScript 不提供 polyfills,因此如果目标环境不支持原生的 Promise,则需要一个库。

大多数 JavaScript 运行时(如网页浏览器或 Node.js 服务器)都是单线程执行环境。这意味着主 JavaScript 进程一次只能做一件事。多亏了事件循环,只要我们编写非阻塞代码,运行时就会看起来能够同时做很多事情。事件循环能够识别异步事件,并在等待这些事件解决的同时转向其他任务。

以一个需要调用 API 将数据加载到表格中的网页为例。如果该 API 调用是阻塞的,那么这意味着页面渲染无法完成,直到数据加载完毕。我们的用户将不得不盯着一个空白的页面,直到所有数据加载完毕,页面元素渲染完成。但是,由于事件循环,我们可以注册一个监听器,允许网站渲染继续进行,然后在我们的数据最终返回时加载表格。这将在以下图中展示:

![图 12.1:典型的事件循环]

图片

图 12.1:典型的事件循环

这可以通过回调或承诺来实现。事件循环使得这一点成为可能。Node.js 的工作方式类似,但现在我们可能需要响应来自众多客户端的请求。在这个简单的例子中,正在发出三个不同的请求:

![图 12.2:多个请求图 12.2:多个请求

图 12.2:多个请求

API 不是阻塞的,因此即使初始请求尚未被处理,也可以进来额外的请求。请求按照工作完成的顺序被处理。

承诺的构成

一个承诺(promise)是 JavaScript 对象,它可以存在于三种状态:待定(pending)已解决(fulfilled)已拒绝(rejected)。尽管承诺可以立即解决或拒绝,但最典型的情况是承诺在待定状态下创建,然后根据操作的成功或失败被解决为已解决或已拒绝。承诺是可链式的,并实现了几个方便的方法,我们将在后面介绍。

为了更好地理解承诺的状态,重要的是要知道承诺的状态不能被查询。作为程序员,我们不会检查承诺的状态并根据该状态采取行动。相反,我们提供一个函数回调,当承诺达到该状态时将被调用。例如,我们向我们的后端服务器发出 HTTP 请求并得到一个承诺作为响应。现在我们已经设置了事件,我们只需要告诉承诺接下来要做什么以及如何处理任何错误。以下是一些示例。

承诺回调

可以使用new关键字和Promise构造函数来实例化一个承诺。以这种方式实例化时,Promise期望一个包含实际要执行的工作的回调参数。该回调有两个自己的参数,resolvereject。这些参数可以被显式调用以解决或拒绝承诺。例如,我们可以创建一个在 100 毫秒后解决的承诺,如下所示:

new Promise<void>((resolve, reject) => {
  setTimeout(() => resolve(), 100);
});

我们还可以创建一个在 100 毫秒后拒绝的承诺:

new Promise<void>((resolve, reject) => {
  setTimeout(() => reject(), 100);
});

then 和 catch

可以使用thencatch将承诺链入它们自己的回调函数。then提供的回调函数只有在承诺被解决后才会触发,而catch提供的回调函数只有在承诺被拒绝时才会触发。大多数返回承诺的库会自动调用resolvereject,所以我们只需要提供thencatch。以下是一个使用 Fetch API 的示例:

fetch("https://my-server.com/my-resource")
  .then(value => console.log(value))
  .catch(error => console.error(error));

这段代码将调用我们的后端服务器并记录结果。如果调用失败,它也会记录下来。

如果这是一个真实的应用程序,我们可能有一些函数,如showDatahandleError,可以管理应用程序如何处理来自服务器的响应。在这种情况下,使用fetch可能如下所示:

fetch("https://my-server.com/my-resource")
  .then(data => showData(data))
  .catch(error => handleError(error));

使用承诺(promises)这种方式展示了我们如何将异步过程与业务逻辑和显示元素解耦。

待定状态

挂起承诺是指尚未完成其工作的承诺。创建一个永远处于挂起状态的承诺很简单:

const pendingPromise = new Promise((resolve, reject) => {});
console.log(pendingPromise);

这个承诺永远不会做任何事情,因为resolvereject从未被调用。承诺将保持挂起状态。如果我们执行此代码,它将输出Promise { <pending> }。如上所述,我们并不查询承诺的状态,而是提供一个回调以供承诺最终解决。上面的示例代码包含一个永远无法解决的承诺,因此可以被视为无效代码。无法解决的承诺没有用例。

完成状态

我们可以创建一个立即解决的承诺:

const fulfilledPromise = new Promise(resolve => {
  resolve("fulfilled!");
});
console.log(fulfilledPromise);

这将输出Promise { 'fulfilled!' }

与挂起状态不同,创建一个立即解决的承诺有更多实际用途。立即解决承诺的主要用途是在与期望承诺的 API 一起工作时。

拒绝状态

我们可以创建一个立即解决的承诺:

const rejectedPromise = new Promise((resolve, reject) => {
  reject("rejected!");
});
console.log(rejectedPromise);

这将输出Promise { <rejected> 'rejected!' },然后抛出一个未处理的承诺拒绝警告。拒绝的承诺总是需要被捕获。未能捕获承诺拒绝可能会导致我们的程序崩溃!

与完成状态一样,立即拒绝承诺的主要用例是当与期望承诺的 API 一起工作时。但可能有次要用例,在异步工作流程中,某些过程在执行过程中抛出错误,返回一个拒绝的承诺可能是有意义的。这种情况最有可能发生在与第三方库一起工作时,其中 API 并不完全符合我们的喜好,我们需要用更符合我们应用程序架构的东西来包装它。

链式

与回调相比,承诺的主要优势之一是能够将承诺链式连接起来。考虑一个等待 1 秒、生成 0 到 99 之间的随机数并将其添加到前一个结果的函数。虽然写递归函数有更好的方法,但这里是为了模拟一个网站对后端进行多次调用的过程:

Example01.ts
1  const getTheValue = async (val: number, cb: Function) => {
2    setTimeout(() => {
3      const number = Math.floor(Math.random() * 100) + val;
4      console.log(`The value is ${number}`);
5      cb(number);
6    }, 1000);
7  };
8  
9  getTheValue(0, (output: number) => {
10    getTheValue(output, (output: number) => {
11      getTheValue(output, (output: number) => {
12        getTheValue(output, (output: number) => {
13          getTheValue(output, (output: number) => {
14            getTheValue(output, (output: number) => {
15              getTheValue(output, (output: number) => {
16                getTheValue(output, (output: number) => {
17                  getTheValue(output, (output: number) => {
18                    getTheValue(output, () => {});
19                  });
20                });
21              });
22            });
23          });
24        });
25      });
26    });
27  });
Link to the example: https://packt.link/VHZJc

该程序的以下是一个示例输出:

The value is 49
The value is 133
The value is 206
The value is 302
The value is 395
The value is 444
The value is 469
The value is 485
The value is 528
The value is 615

每次我们调用getTheValue时,我们等待 1 秒,然后生成一个随机数并将其添加到我们传入的值中。在现实世界的场景中,我们可以将其视为一个完成多个异步任务的程序,其中一个任务的输出作为下一个任务的输入。

注意

由于程序的起点是一个随机数,你的输出将与上面展示的不同。

之前程序中的所有内容都工作正常;然而,回调嵌套看起来并不美观,可能难以维护或调试。接下来的练习将教你如何使用承诺编写更易读和可维护的代码。

练习 12.01:链式连接承诺

在这个练习中,我们将重构前面的示例,并链式连接承诺以消除嵌套,使代码更易读:

注意

本练习的代码文件可以在此处找到:packt.link/IO8Pz

  1. 编写以下程序,该程序使用承诺重构了前面的示例:

    const getTheValue = async (val: number): Promise<number> => {
      return new Promise(resolve => {
        setTimeout(() => {
          const number = Math.floor(Math.random() * 100) + val;
          console.log(`The value is ${number}`);
          resolve(number);
        }, 1000);
      });
    };
    getTheValue(0)
      .then((result: number) => getTheValue(result))
      .then((result: number) => getTheValue(result))
      .then((result: number) => getTheValue(result))
      .then((result: number) => getTheValue(result))
      .then((result: number) => getTheValue(result))
      .then((result: number) => getTheValue(result))
      .then((result: number) => getTheValue(result))
      .then((result: number) => getTheValue(result))
      .then((result: number) => getTheValue(result));
    

    嵌套消失了,代码的可读性也大大提高。我们的getTheValue函数现在返回一个承诺而不是使用回调。因为它返回一个承诺,所以我们可以对承诺调用.then(),这可以链入另一个承诺调用。

  2. 运行程序。承诺链将依次解决每个承诺,我们将得到与上一个程序类似的输出:

    The value is 50
    The value is 140
    The value is 203
    The value is 234
    The value is 255
    The value is 300
    The value is 355
    The value is 395
    The value is 432
    The value is 451
    

    注意,你将得到一个与上面显示的不同输出,因为程序使用一个随机数作为起始点。

在处理错误条件时,链式调用也可以大有帮助。如果我的getTheValue函数拒绝承诺,我可以通过在链的末尾链式调用一个catch来捕获错误:

Example02.ts
1  const getTheValue = async (val: number): Promise<number> => {
2   return new Promise((resolve, reject) => {
3      setTimeout(() => {
4        const number = Math.floor(Math.random() * 100) + val;
5        if (number % 10 === 0) {
6          reject("Bad modulus!");
7        } else {
8          console.log(`The value is ${number}`);
9          resolve(number);
10        }
11      }, 1000);
12    });
13  };
14  
15  getTheValue(0)
16    .then((result: number) => getTheValue(result))
17    .then((result: number) => getTheValue(result))
18    .then((result: number) => getTheValue(result))
19    .then((result: number) => getTheValue(result))
20    .then((result: number) => getTheValue(result))
21    .then((result: number) => getTheValue(result))
22    .then((result: number) => getTheValue(result))
23    .then((result: number) => getTheValue(result))
24    .then((result: number) => getTheValue(result))
25    .catch(err => console.error(err));
Link to the example: https://packt.link/sBTgk

我们在每个迭代中引入了 10%的概率(即我们的数字除以 10 后余数为 0 的概率)来抛出错误。平均而言,我们的程序现在失败的概率将比成功执行的概率更高:

The value is 25
The value is 63
The value is 111
Bad modulus!

finally

除了thencatch方法外,Promise对象还公开了一个finally方法。这是一个无论是否抛出或捕获错误都会被调用的回调函数。这对于记录日志、关闭数据库连接或简单地清理资源来说非常棒,无论最终承诺是如何解决的。

我们可以在上面的承诺中添加一个finally回调:

Example03.ts
1  const getTheValue = async (val: number) => {
2    return new Promise<number>((resolve, reject) => {
3      setTimeout(() => {
4        const number = Math.floor(Math.random() * 100) + val;
5        if (number % 10 === 0) {
6          reject("Bad modulus!");
7        } else {
8          console.log(`The value is ${number}`);
9          resolve(number);
10      }
11     }, 1000);
12   });
13 };
14 
15 getTheValue(0)
16   .then(result => getTheValue(result))
17   .then(result => getTheValue(result))
18   .then(result => getTheValue(result))
19   .then(result => getTheValue(result))
20   .then(result => getTheValue(result))
21   .then(result => getTheValue(result))
22   .then(result => getTheValue(result))
23   .then(result => getTheValue(result))
24   .then(result => getTheValue(result))
25   .catch(err => console.error(err))
26   .finally(() => console.log("We are done!"));
Link to the example: https://packt.link/izqwS

现在,“We are done!”将无论是否触发“Bad modulus!”错误条件都会被记录:

The value is 69
The value is 99
Bad modulus!
We are done!

Promise.all

Promise.allPromise提供最有用的实用方法之一。即使使用 async/await 语法编写的代码(见第十三章,Async/Await)也可以很好地使用Promise.all。此方法接受一个承诺的可迭代参数(可能是数组),并解决所有承诺。让我们看看我们如何使用Promise.all改变我们的示例承诺:

Example04.ts
1  const getTheValue = async (val: number = 0) => {
2    return new Promise<number>((resolve, reject) => {
3      setTimeout(() => {
4        const number = Math.floor(Math.random() * 100) + val;
5        if (number % 10 === 0) {
6          reject("Bad modulus!");
7        } else {
8          console.log(`The value is ${number}`);
9          resolve(number);
10       }
11     }, 1000);
12   });
13 };
14
15 Promise.all([
16   getTheValue(),
17   getTheValue(),
18   getTheValue(),
19   getTheValue(),
20   getTheValue(),
21   getTheValue(),
22   getTheValue(),
23   getTheValue(),
24   getTheValue(),
25   getTheValue()
26 ])
27   .then(values =>
28     console.log(
29       `The total is ${values.reduce((prev, current) => prev + current, 0)}`
30     )
31   )
32   .catch(err => console.error(err))
33   .finally(() => console.log("We are done!"));
Link to the example: https://packt.link/8pzx4

输出应该与前面示例中获得的输出类似。在这个例子中,我们调用了同一个函数 10 次,但想象一下,这些是 10 个不同的 API 调用,我们需要达到并求和。每个调用大约需要 1 秒钟。如果我们链式调用一系列承诺,这个操作将需要超过 10 秒钟。通过使用Promise.all,我们能够并行运行这些操作,现在完成函数只需要 1 秒钟。

Promise.all在任何你可以并行运行两个或更多异步过程时都很有用。它可以用于将数据持久化到多个数据库表,让多个独立组件在网页浏览器中独立渲染,或者进行多个 HTTP 请求。一个并行进行多个 HTTP 请求的好例子是监控其他服务正常运行时间和 ping 持续时间的服务。这种操作没有必要是同步的,Promise.all让我们在同一个过程中等待多个网络请求。

练习 12.02:递归 Promise.all

在这个练习中,我们不是重复相同的函数调用 10 次,而是将前一个示例中的程序优化,使其更加 DRY(不要重复自己)。我们可以加载一个承诺数组,然后使用Promise.all并行解决所有承诺,并使用catchfinally解决错误并确保我们返回一些输出:

注意

这个练习的代码文件也可以在这里找到:packt.link/KNpqx

  1. 以下代码将是这次重构的起点:

    const getTheValue = async (val: number = 0) => {
      return new Promise<number>((resolve, reject) => {
        setTimeout(() => {
          const number = Math.floor(Math.random() * 100) + val;
          if (number % 10 === 0) {
            reject('Bad modulus!');
          } else {
            console.log(`The value is ${number}`);
            resolve(number);
          }
        }, 1000);
      });
    };
    Promise.all([
      getTheValue(),
      getTheValue(),
      getTheValue(),
      getTheValue(),
      getTheValue(),
      getTheValue(),
      getTheValue(),
      getTheValue(),
      getTheValue(),
      getTheValue(),
    ])
      .then((values) =>
        console.log(
          `The total is ${values.reduce((prev, current) => prev + current, 0)}`
        )
      )
      .catch((err) => console.error(err))
      .finally(() => console.log('We are done!'));
    

    为了捕获错误并使程序递归,我们需要将Promise.all包裹在一个函数中。递归是一种模式,其中同一个函数可以在同一执行中多次调用。

  2. 要添加递归,创建一个新的函数并将Promise.all语句作为该函数的主体。然后调用该函数:

    const doIt = () => {
      Promise.all([
        getTheValue(),
        getTheValue(),
        getTheValue(),
        getTheValue(),
        getTheValue(),
        getTheValue(),
        getTheValue(),
        getTheValue(),
        getTheValue(),
        getTheValue(),
    ])
      .then((values) =>
          console.log(
            `The total is ${values.reduce((prev, current) => prev + current, 0)}`
          )
      )
      .catch((err) => console.error(err))
      .finally(() => console.log('We are done!'));
    

    我们可以使用一些函数式编程技术,而不是在数组中重复getTheValue() 10 次,程序化地构建一个包含 10 个元素的数组,其中所有元素都是该函数调用。这样做不会改变我们的程序操作方式,但会使它更容易处理。

  3. 将前一步骤中给出的代码更新如下:

      Promise.all(
      Array(10)
        .fill(null)
        .map(() => getTheValue())
    )
    

    这里的逻辑是Array(10)创建一个新的包含 10 个元素的数组,fill(null)将初始化数组,然后map将重新映射数组元素为getTheValue()函数调用。

    上述代码实际上调用了函数,并将挂起的承诺返回到已经包裹在Promise.all中的数组。

    现在我们希望在出现错误的情况下使用递归。我们将改变我们的catch()回调,从简单地记录错误到重新开始整个过程。在这种情况下,我们的业务规则是希望整个计算集完成,如果有错误我们将重新启动。执行此操作的代码非常简单,因为catch()期望一个函数作为其回调,所以我们可以再次将我们的doIt函数传递给它。

  4. doIt函数传递回catch()

      .catch(doIt)
    

    注意,我们在这里没有调用回调函数。我们想要传递一个函数,它将在出现错误时被调用。

  5. 我们现在想要稍微清理一下错误消息,以便我们可以有一个干净的运行:

    const getTheValue = async (val: number = 0) => {
      return new Promise<number>((resolve, reject) => {
        setTimeout(() => {
          const number = Math.floor(Math.random() * 100) + val;
          if (number % 10 === 0) {
            reject('Bad modulus!');
          } else {
            // console.log(`The value is ${number}`);
            resolve(number);
          }
        }, 1000);
      });
    };
    let loopCount = 0;
    const doIt = () => {
      Promise.all(
        Array(10)
          .fill(null)
          .map(() => getTheValue())
      )
        .then((values) =>
          console.log(
            `The total is ${values.reduce((prev, current) => prev + current, 0)}`
          )
        )
        .catch(doIt)
        .finally(() => console.log(`completed loop ${++loopCount}`));
    };
    doIt();
    

    当我们运行程序时,我们会看到程序循环的几个迭代。输出可能如下所示:

    completed loop 1
    The total is 438
    completed loop 2
    

    注意,根据迭代次数的不同,你可能会得到与上面显示不同的输出。

Promise.allSettled

这种方法是对 Promise.all 的一个变体,当一些承诺可以成功解析而另一些被拒绝时,它是非常理想的。让我们看看它与 Promise.all 有什么不同:

const getTheValue = async (val: number = 0) => {
  return new Promise<number>((resolve, reject) => {
    setTimeout(() => {
      const number = Math.floor(Math.random() * 100) + val;
      // Arbitrary error condition - if the random number is divisible by 10.
      if (number % 10 === 0) {
        reject("Bad modulus!");
      } else {
        console.log(`The value is ${number}`);
        resolve(number);
      }
    }, 1000);
  });
};
const generateTheNumber = (iterations: number): void => {
  Promise.allSettled(
    // Produces an array of `iterations` length with the pending promises of `getTheValue()`.
    Array(iterations)
      .fill(null)
      .map(() => getTheValue())
  )
    .then((settledResults) => {
      // Map all the results into the failed, succeeded and total values.
      const results = settledResults.reduce(
        (prev, current) => {
          return current.status === "fulfilled"
            ? {
                ...prev,
                succeeded: prev.succeeded + 1,
                total: prev.total + current.value,
              }
            : { ...prev, failed: prev.failed + 1 };
        },
        {
          failed: 0,
          succeeded: 0,
          total: 0,
        }
      );
      console.log(results);
    })
    .finally(() => console.log("We are done!"));
};
generateTheNumber(10);

程序将生成如下输出:

current { status: 'fulfilled', value: 85 }
current { status: 'fulfilled', value: 25 }
current { status: 'fulfilled', value: 11 }
current { status: 'fulfilled', value: 43 }
current { status: 'rejected', reason: 'Bad modulus!' }
current { status: 'fulfilled', value: 41 }
current { status: 'fulfilled', value: 81 }
current { status: 'rejected', reason: 'Bad modulus!' }
current { status: 'rejected', reason: 'Bad modulus!' }
current { status: 'fulfilled', value: 7 }
{ failed: 3, succeeded: 7, total: 293 } 
We are done!

我们在这里做了一些增强。一方面,我们现在将数组大小传递给 generateTheNumber,这可以为我们的程序增添一些风味或变化。现在的主要改进是使用 Promise.allSettled。现在,Promise.allSettled 允许我们有一个成功和失败混合的结果,而 Promise.all 则不同,如果所有的承诺都成功解析,它将调用 then() 方法;如果任何一个失败,它将调用 catch() 方法。Promise.allSettled 的输出可能看起来像这样:

settledResults [
  { status: 'fulfilled', value: 85 },
  { status: 'fulfilled', value: 25 },
  { status: 'fulfilled', value: 11 },
  { status: 'fulfilled', value: 43 },
  { status: 'rejected', reason: 'Bad modulus!' },
  { status: 'fulfilled', value: 41 },
  { status: 'fulfilled', value: 81 },
  { status: 'rejected', reason: 'Bad modulus!' },
  { status: 'rejected', reason: 'Bad modulus!' },
  { status: 'fulfilled', value: 7 }
]

每个已解析的承诺都将包含一个状态字符串,如果承诺成功解析,则为 'fulfilled';如果发生错误,则为 'rejected'。满足的承诺将有一个包含承诺解析到的值的 value 属性,而被拒绝的承诺将有一个包含错误的 reason 属性。

在给出的示例中,我们将拒绝的承诺进行总计,并将满足的承诺的值相加,然后返回一个新的对象。为了执行这个操作,我们使用内置的数组函数 reduce()。现在,reduce() 将遍历数组的每个元素,并在累加器中收集转换后的结果,该累加器由函数返回。MapReduce 函数在函数式编程范式中被广泛使用。

注意,Promise.allSettled 是 ECMAScript 中相对较新的一个功能,它在 Node.js 12.9 版本中首次出现。为了使用它,你需要在你的 tsconfig.json 文件中将 compilerOptions 的目标设置为 es2020esnext。大多数现代浏览器都支持这个方法,但在使用这个新功能之前验证其支持性是个好主意。

练习 12.03:Promise.allSettled

我们已经看到了使用 Promise.allSettled 来产生已满足和被拒绝的承诺的混合结果的例子。现在让我们将 Promise.allSettledPromise.all 结合起来,以聚合 getTheValue() 运行的多个结果:

注意

这个练习的代码文件也可以在这里找到:packt.link/D8jIQ

  1. 从上面的示例代码开始。我们打算调用 generateTheNumber() 三次。一旦我们得到所有结果,我们可以对它们进行排序,以打印出最高和最低的结果。我们可以使用上面描述的相同的 Array().fill().map() 技巧来创建一个新的 generateTheNumber() 调用数组:

    Promise.all(
      Array(3)
        .fill(null)
        .map(() => generateTheNumber(10))
    );
    
  2. 现在我们能够解析三个独立的调用,我们需要管理输出。首先,我们可以输出结果以查看下一步需要做什么:

    Promise.all(
      Array(3)
        .fill(null)
        .map(() => generateTheNumber(10))
    ).then((result) => console.log(result));
    

    我们记录了 [undefined, undefined, undefined]。这不是我们想要的。原因在于 generateTheNumber 实际上没有返回其承诺——在先前的例子中它不需要这样做。

  3. 我们可以通过添加一个 return 语句并移除 void 返回类型来解决这个问题。我们还需要我们的回调函数返回结果而不是简单地将其记录出来。所有这些更改都将帮助此类程序集成到更大的应用程序中:

    const generateTheNumber = (iterations: number) => {
      return Promise.allSettled(
        Array(iterations)
          .fill(null)
          .map(() => getTheValue())
      )
        .then((settledResults) => {
          const results = settledResults.reduce(
            (prev, current) => {
              return current.status === 'fulfilled'
                ? {
                    ...prev,
                    succeeded: prev.succeeded + 1,
                    total: prev.total + current.value,
                  }
                : { ...prev, failed: prev.failed + 1 };
            },
            {
              failed: 0,
              succeeded: 0,
              total: 0,
            }
          );
          return results;
        })
        .finally(() => console.log('Iteration done!'));
    };
    With that done we can get our output.
    [
      { failed: 0, succeeded: 10, total: 443 },
      { failed: 1, succeeded: 9, total: 424 },
      { failed: 2, succeeded: 8, total: 413 },
    ]
    
  4. 完成这个练习的最后一步是我们只想输出最高和最低的总数。为了实现这一点,我们可以使用 Array.map() 函数从输出中提取仅包含总数的部分,并使用 Array.sort() 函数将上述输出从低到高排序,然后打印第一和最后条目的总数:

      const totals = results.map((r) => r.total).sort();
      console.log(`The highest total is ${totals[totals.length - 1]}.`);
      console.log(`The lowest total is ${totals[0]}.`);
    

    你可能会得到以下类似的输出:

    The value is 62
    The value is 77
    The value is 75
    The value is 61
    The value is 61
    The value is 61
    The value is 15
    The value is 83
    The value is 4
    The value is 23
    Iteration done!
    .
    .
    .
    The highest total is 522.
    The lowest total is 401.
    

    注意,为了便于展示,这里只显示了实际输出的部分内容。

    这个练习向我们展示了我们如何过滤和排序许多承诺的结果,并创建准确反映应用程序状态的数据库结构。

Promise.any

Promise.allSettled 相比,另一端是 Promise.any。此方法接受一个承诺的可迭代对象(或数组),但它不会解决所有承诺,而是将解析为第一个成功解决的承诺的值。Promise.any 非常新,尚未在所有浏览器中实现,在撰写本文时,它尚未在 Node.js 的 LTS 版本中可用。在使用之前,你应该检查兼容性和可用性。

Promise.race

Promise.race 已经存在一段时间了,并且与 Promise.any 类似。现在,Promise.race 再次接受一个承诺的可迭代对象并执行它们。第一个解决或拒绝的承诺将解决或拒绝比赛。这与 Promise.any 相反,因为如果 Promise.any 中的第一个承诺拒绝,其他承诺仍然有机会成功解决:

const oneSecond = new Promise((_resolve, reject) => {
  setTimeout(() => reject("Too slow!"), 1000);
});
const upToTwoSeconds = new Promise(resolve => {
  setTimeout(() => resolve("Made it!"), Math.random() * 2000);
});
Promise.race([oneSecond, upToTwoSeconds])
  .then(result => console.log(result))
  .catch(err => console.error(err));

在这个例子中,一个承诺在 1 秒后总是拒绝,而另一个承诺在 0 到 2 秒之间的随机间隔内解决。如果 oneSecond 承诺赢得比赛,整个承诺将被拒绝。如果 upToTwoSeconds 小于 1 秒,那么承诺将成功解决,并带有消息 "Made It!"

使用 Promise.race 的一个实际例子可能是超时和回退功能,如果主网络服务在预期时间内无法响应,应用程序将切换到次要数据源或表现出其他行为。或者,我们可能想要处理浏览器中的慢速渲染问题,如果在预期时间内屏幕绘制未完成,我们将切换到更简单的视图。在 TypeScript 中处理异步操作时,Promise.race 可以简化许多情况。

通过类型增强承诺

我们正在处理的示例指定了承诺的输入类型,但我们必须在链的每个步骤中为结果提供类型。这是因为 TypeScript 不了解承诺可能解析为什么类型,因此我们必须告诉它我们得到的结果的类型。

换句话说,我们错过了一个 TypeScript 最强大的功能:类型推断。类型推断是 TypeScript 能够知道某物的类型应该是什么,而无需被告知的能力。类型推断的一个非常简单的例子如下:

const hello = "hello";

没有指定类型。这是因为 TypeScript 理解变量 hello 被分配了一个字符串,并且不能重新分配。如果我们尝试将这个变量作为参数传递给期望另一种类型的函数,我们将得到编译错误,即使我们从未指定过类型。让我们将类型推断应用于承诺。

首先,让我们看看 Promise 对象的类型定义:

new <T>(executor: (resolve: (value?: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;

T 是所谓的泛型。这意味着可以指定任何类型来代替 T。假设我们定义了一个像这样的承诺:

new Promise(resolve => {
  resolve("This resolves!");
});

我们在这里所做的就是声明 resolve 参数将解析为未知类型。接收代码需要为它提供一个类型。这可以通过为 T 添加一个类型值来改进:

new Promise<string>(resolve => {
  resolve("This resolves!");
});

现在的承诺构造函数解析为 Promise<string> 类型。当承诺被实现时,它预期返回一个 string 类型的值。

让我们考察一个示例,其中将承诺的返回类型转换为重要:

const getPromise = async () => new Promise(resolve => resolve(Math.ceil(Math.random() * 100)));
const printResult = (result: number) => console.log(result);
getPromise().then(result => printResult(result)); 

如果你把这个例子放入一个 IDE,比如 VS Code,你会看到在传递给 printResultresult 参数上有类型错误。getPromise 返回的承诺的类型是未知的,但 printResult 期望 number 类型。我们可以通过在声明承诺时提供类型来解决这个问题:

const getPromise = async () => new Promise<number>(resolve => resolve(Math.ceil(Math.random() * 100)));
const printResult = (result: number) => console.log(result);
getPromise().then(result => printResult(result));

我们在我们的承诺声明后立即添加了 <number>,TypeScript 知道这个承诺预期解析为数字。这种类型检查也将应用于承诺的解析。例如,如果我们尝试解析为 "Hello!" 的值,由于我们的承诺现在预期返回数字,我们将得到另一个类型错误。

练习 12.04:异步渲染

在这个练习中,我们将创建一个具有同步渲染的简单网站,并将其重构为异步渲染:

注意

这个练习的代码文件也可以在这里找到:packt.link/q8rka

  1. 从 GitHub (packt.link/q8rka) 克隆项目以开始。然后,安装依赖项:

    npm i
    

    我们刚刚在我们的项目中安装了 TypeScript 以及 http-server,这是一个简单的 Node.js HTTP 服务器,它将允许我们在本地主机上运行我们的网站。

    现在我们将添加一些文件以启动项目。

  2. 在项目的根目录中创建一个名为 index.html 的文件,并向其中添加以下行:

    <html>
      <head>
        <title>The TypeScript Workshop - Exercise 12.03</title>
        <link href="styles.css" rel="stylesheet"></link>
      </head>
      <body>
        <div id="my-data"></div>
      </body>
      <script type="module" src="img/data-loader.js"></script>
    </html>
    
  3. 接下来,可选地添加一个样式表作为默认样式相当难看。带来你自己的样式或者使用像这样简单的样式:

    body {
      font-family: Arial, Helvetica, sans-serif;
      font-size: 12px;
    }
    input {
      width: 200;
    }
    
  4. 将一个名为 data.json 的文件添加进来,以表示我们从远程服务器获取的数据:

    { "message": "Hello Promise!" }
    
  5. 还有一个要完成。让我们添加一个名为 data-loader.ts 的 TypeScript 文件:

    const updateUI = (message: any): void => {
      const item = document.getElementById("my-data");
      if (item) {
        item.innerText = `Here is your data: ${message}`;
      }
    };
    const message = fetch("http://localhost:8080/data.json");
    updateUI(message);
    

    这就是你运行 TypeScript 网络应用程序所需的所有内容!在本书的后面部分,我们将看到一些更健壮的解决方案,但到目前为止,这将让我们专注于 TypeScript,而无需太多花哨的功能。

  6. 为了查看我们的应用程序,我们需要将 TypeScript 编译并启动本地服务器。为了获得最佳体验,我们需要两个独立的命令提示符窗口。在一个窗口中,我们将输入一个命令来编译 TypeScript 并监视更改:

    npx tsc -w data-loader.ts
    
  7. 在另一个窗口中,我们将使用一个标志启动服务器以避免缓存,这样我们就可以立即看到我们的更改:

    npx http-server . -c-1
    
  8. 如果我们导航到 http://localhost:8080,我们会看到我们的应用程序加载并收到这条消息:

     "Here is your data: [object Promise]". 
    

    似乎某些地方没有正确工作。我们想要看到的是 "Here is your data: Hello Promise!"。如果我们查看 TypeScript 代码,我们会看到这一行:

    const message = fetch("http://localhost:8080/data.json");
    

这没有正确工作。fetch 是一个异步请求。我们只是看到了未解决的承诺并将其打印到屏幕上。

另一个警告信号是 updateUI 函数中使用 any 类型。为什么那里使用的是 any 类型,而应该是字符串?这是因为 TypeScript 不允许我们使用字符串。TypeScript 知道我们正在使用未解决的承诺调用 updateUI,因此如果我们尝试将其视为字符串类型,将会得到类型错误。新开发者有时认为通过使用 any 来修复问题,但更多的时候,他们将会忽略有效的错误。

为了使这段代码正确运行,你需要重构它,以便 fetch 返回的承诺得到解决。当它正确运行时,fetch 返回一个响应对象,该对象公开一个 data 方法,该方法也返回一个承诺,因此你需要解决两个承诺才能在你的页面上显示数据。

注意

fetch 库是浏览器的一个 Web API,它在原始 XMLHttpRequest 规范上有了很大的改进。它保留了 XMLHttpRequest 的所有功能,但 API 要更加人性化,因此被许多网络应用程序使用,而不是安装第三方客户端库。fetch 在 Node.js 中不是原生实现的,但有一些库提供了相同的功能。我们将在本章后面部分查看这些库。

库和原生承诺 — 第三方库、Q 和 Bluebird

如前所述,承诺在 2015 年成为了 ECMAScript 标准的一部分。在此之前,开发者使用 Q 或 Bluebird 等库来填补语言中的空白。虽然许多开发者选择使用原生的承诺,但这些库仍然非常受欢迎,每周的下载量仍在增长。话虽如此,我们应该仔细考虑是否依赖第三方库而不是原生语言特性是个好主意。除非这些库提供了我们无法没有的关键功能,否则我们应该优先考虑原生特性而不是第三方库。第三方库可能会引入错误、复杂性和安全漏洞,并需要额外的努力来维护。这并不是对开源的指控。

开源项目(如 TypeScript)是当今开发者生态系统的重要组成部分。尽管如此,仔细选择我们的依赖项并确保它们是维护良好的库,且不与原生特性重复,仍然是一个好主意。

值得注意的是,第三方库的 API 可能与原生语言特性不同。例如,Q 库从 jQuery 实现中借用了一个延迟对象:

import * as Q from "q";
const deferred = Q.defer();
deferred.resolve(123);
deferred.promise.then(val => console.log(val));

用原生承诺编写的代码更像是我们迄今为止看到的例子:

const p = new Promise<number>((resolve, reject) => {
  resolve(123);
});
p.then(val => console.log(val));

在这里,Q 实现本身并没有什么固有的错误,但它是不标准的,这可能会使我们的代码对其他开发者来说不那么易读,或者阻止我们学习标准最佳实践。

Bluebird 与原生承诺更相似。事实上,它可以作为 polyfill 使用。

Polyfilling Promises

TypeScript 会转译代码,但它不会为你的目标环境中不存在的原生语言特性进行 polyfill。这一点至关重要,以避免挫败感和神秘的错误。TypeScript 将为我们做的是允许我们指定目标环境。让我们看看一个简单的例子。

考虑以下tsconfig.json文件:

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "outDir": "./public",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  }
}

现在考虑这个位于promise.ts的模块:

const p = new Promise<number>((resolve, reject) => {
  resolve(123);
});
p.then(val => console.log(val));

我们的代码可以正常转译。我们输入npx tsc,转译后的 JavaScript 输出看起来非常像我们的 TypeScript 代码。唯一的区别是类型已被移除:

 const p = new Promise((resolve, reject) => {
    resolve(123);
});
p.then(val => console.log(val));

然而,考虑如果我们将目标更改为“es5”:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "outDir": "./public",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  }
}

现在这个项目将无法构建:

% npx tsc
src/promise.ts:1:15 - error TS2585: 'Promise' only refers to a type, but is being used as a value here. Do you need to change your target library? Try changing the `lib` compiler option to es2015 or later.
1 const p = new Promise<number>((resolve, reject) => {
                ~~~~~~~
Found 1 error.

TypeScript 甚至提醒我可能需要修复我的目标。请注意,“es2015”和“es6”是同一件事(同样,“es2016”和“es7”等等也是如此)。这是一个有些令人困惑的约定,我们只需要习惯它。

如果我能为es6+环境(如当前版本的 Node.js 或任何现代浏览器)构建我的项目,那么这将是可行的,但如果我们需要支持旧版浏览器或非常旧的 Node.js 版本,那么通过设置编译目标更高来“修复”这个问题只会导致应用程序损坏。我们需要使用 polyfill。

在这种情况下,Bluebird 可以是一个非常好的选择,因为它有一个与原生承诺非常相似的 API。实际上,我需要做的只是 npm install bluebird,然后把我模块中的库导入进来。Bluebird 库不包含类型定义,所以为了获得完整的 IDE 支持,你还需要将 @types/bluebird 作为 devDependency 来安装:

import { Promise } from "bluebird";
const p = new Promise<number>(resolve => {
  resolve(123);
});
p.then(val => console.log(val));

我转译的代码现在可以在一个非常早期的 Node.js 版本上运行,比如版本 0.10(2013 年发布)。

注意,Bluebird 是设计成一个功能齐全的 Promise 库。如果我只是寻找一个 polyfill,我可能更喜欢使用像 es6-promise 这样的东西。它的使用方法完全相同。我 npm install es6-promise,然后把我模块中的 Promise 类导入进来:

import { Promise } from "es6-promise";
const p = new Promise<number>(resolve => {
  resolve(123);
});
p.then(val => console.log(val));

如果你想要亲自尝试,请注意,TypeScript 的现代版本甚至无法在 Node.js 0.10 上运行!你将不得不在一个较新的版本(如 Node.js 12)上转译你的代码,然后切换到 Node.js 0.10 来执行代码。为此,使用版本管理器(如 nvmn)是个好主意。

这实际上是一个 TypeScript 强大功能的绝佳例子。我们可以在现代版本上编写和构建我们的代码,但目标是遗留运行时。设置编译目标将确保我们构建的代码适合那个运行时。

承诺化

承诺化是将期望回调的异步函数转换为承诺的实践。这本质上是一个便利工具,它允许你始终使用承诺而不是必须使用遗留 API 的回调。承诺化遗留 API 可以非常有助于使所有代码都使用承诺,并且易于阅读。但承诺化不仅仅是将回调转换为承诺的便利。一些现代 API 只接受承诺作为参数。如果我们只能用回调编写一些代码,我们就必须手动将回调异步代码包装在承诺中。承诺化节省了我们麻烦,并可能节省许多代码行。

让我们通过一个例子来了解如何将一个期望回调的函数进行承诺化。我们有几个选项可供选择。Bluebird 再次通过 Promise.promisify 提供这个功能。这次,我们将尝试一个 polyfill,es6-promisify。让我们从一个期望回调的函数开始:

const asyncAdder = (n1: number, n2: number, cb: Function) => {
  let err: Error;
  if (n1 === n2) {
    cb(Error("Use doubler instead!"));
  } else {
    cb(null, n1 + n2);
  }
};
asyncAdder(3, 4, (err: Error, sum: number) => {
  if (err) {
    throw err;
  }
  console.log(sum);
});

可以被承诺化的函数遵循一个约定,即回调函数的第一个参数是一个错误对象。如果错误是 null 或 undefined,则认为函数已成功调用。在这里,我正在调用 asyncAdder,给它两个数字和一个回调函数。我的回调理解到,如果抛出错误,asyncAdder 将在第一个参数位置有一个错误;如果成功,第二个参数位置将是两个数字的和。通过遵循这个模式,函数可以被承诺化。首先,我们 npm install es6-promisify,然后导入模块:

import { promisify } from "es6-promisify";
const asyncAdder = (n1: number, n2: number, cb: Function) => {
  let err: Error;
  if (n1 === n2) {
    cb(Error("Use doubler instead!"));
  } else {
    cb(null, n1 + n2);
  }
};
const promiseAdder = promisify(asyncAdder);
promiseAdder(3, 4)
  .then((val: number) => console.log(val))
  .catch((err: Error) => console.log(err));

我们使用promisify导入来包装我们的函数,现在我们可以完全使用承诺(promises)来工作。

Bluebird 为我们提供了完全相同的功能:

import { promisify } from "bluebird";
const asyncAdder = (n1: number, n2: number, cb: Function) => {
  if (n1 === n2) {
    cb(Error("Use doubler instead!"));
  } else {
    cb(null, n1 + n2);
  }
};
const promiseAdder = promisify(asyncAdder);
promiseAdder(3, 4)
  .then((val: number) => console.log(val))
  .catch((err: Error) => console.log(err));

Node.js util.promisify

Node.js 在版本 8(2017 年)中引入了自己的promisify版本作为原生功能。如果我们针对的是 Node.js 8+环境,我们可以利用util包。请注意,由于我们正在编写 TypeScript,我们需要添加@types/node依赖项来利用这个包。否则,TypeScript 将无法理解我们的导入。我们将运行npm install -D @types/node-D标志将安装类型作为devDependency,这意味着它可以排除在生产构建之外:

import { promisify } from "util";
const asyncAdder = (n1: number, n2: number, cb: Function) => {
  let err: Error;
  if (n1 === n2) {
    cb(Error("Use doubler instead!"));
  } else {
    cb(null, n1 + n2);
  }
};
const promiseAdder = promisify(asyncAdder);
promiseAdder(3, 4)
  .then((val: number) => console.log(val))
  .catch((err: Error) => console.log(err));

显然,如果我们想让我们的代码在浏览器中运行,这不会起作用,我们应该使用其他库之一,例如 Bluebird,来启用这个功能。

异步文件系统

截至 2018 年发布的 Node.js 10,文件系统 API(fs)提供了所有函数的承诺化异步版本以及它们的阻塞同步版本。让我们看看使用这三种替代方案执行相同操作的情况。

fs.readFile

许多 Node.js 开发者都使用过这个 API。这个方法将读取一个文件,第一个参数是文件路径,第二个参数是回调函数。回调函数将接收一个或两个参数,第一个参数是错误(如果发生错误),第二个参数是数据缓冲区对象,如果读取成功:

import { readFile } from "fs";
import { resolve } from "path";
const filePath = resolve(__dirname, "text.txt");
readFile(filePath, (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data.toString());
});

我们异步读取文件并将内容记录出来。任何过去使用过 Node.js fs库的人可能都见过类似这样的代码。这段代码是非阻塞的,这意味着即使文件非常大且读取速度很慢,它也不会阻止应用程序在此期间执行其他操作。这段代码没有问题,只是它不如我们希望的那样简洁和现代。

在上面的例子中,我们正在读取文件并将日志记录到控制台——这不是很有用,但在现实世界的场景中,我们可能在启动时读取配置文件,处理客户的文档,或者管理 Web 资源的生命周期。在 Node.js 应用程序中,你可能有很多需要访问本地文件系统的原因。

fs.readFileSync

fs库还公开了一个完全同步的 API,这意味着它的操作是阻塞的,事件循环不会在这些操作完成之前前进。这种阻塞操作通常与命令行工具一起使用,在这些工具中,充分利用事件循环不是优先事项,而是简单、干净的代码是优先事项。使用这个 API,我们可以编写一些简洁的代码,如下所示:

import { readFileSync } from "fs";
import { resolve } from "path";
const filePath = resolve(__dirname, "text.txt");
console.log(readFileSync(filePath).toString());

很可能有人会写这样的代码并就此结束,但 readFileSync 是一个阻塞操作,因此我们必须小心。主执行线程实际上会暂停,直到这项工作完成。这仍然可能适用于命令行工具,但将此类代码放入 Web API 可能会是一场灾难。

The fs Promises API

The fs library exposes the promises API, which can give us the best of both worlds, asynchronous execution and concise code:

import { promises } from "fs";
import { resolve } from "path";
const filePath = resolve(__dirname, "text.txt");
promises.readFile(filePath).then(file => console.log(file.toString()));

使用 promises API 让我们能够写出与同步版本几乎一样简洁的代码,但现在我们是完全异步的,这使得代码适合高吞吐量的 Web 应用程序或任何其他不允许阻塞操作的过程。

练习 12.05:The fs Promises API

在这个练习中,您将使用 fs 的 promises API 将两个文件合并成一个。尽可能通过使用函数使您的代码遵循 DRY(不要重复自己)原则。您将需要使用 readFilewriteFile。此程序所需的唯一依赖项是 ts-node(用于执行)、typescript@types/node,这样我们就有 Node.js 内置的 fspath 库的类型:

注意

此练习的代码文件也可以在这里找到:packt.link/M3MH3

  1. 以 GitHub 仓库中的文件为基础,导航到练习目录,并输入 npm i 来安装这些依赖项。

  2. 我们将需要使用 readFile 读取两个独立的文件,然后使用 writeFile 来写入我们的输出文本文件。示例项目已经包含了两个包含一些简单文本的文本文件。请随意添加您自己的文件和文本。

  3. 此项目可以使用 readFileSyncwriteFileSync 完成。该代码可能看起来像这样:

    import { readFileSync, writeFileSync } from "fs";
    import { resolve } from "path";
    const file1 = readFileSync(resolve(__dirname, 'file1.txt'));
    const file2 = readFileSync(resolve(__dirname, 'file2.txt'));
    writeFileSync(resolve(__dirname, 'output.txt'), [file1, file2].join('\n'));
    

    resolve 函数来自路径库,它会在您的文件系统上解析路径,通常与 fs 库一起使用,如上图所示。这两个库都是 Node.js 的标准库的一部分,所以我们只需要安装类型定义,而不是库本身。

  4. 我们可以使用 npx ts-node file-concat.ts 来执行此程序。这将生成一个名为 output.txt 的文件,其中包含以下文本:

    Text in file 1.
    Text in file 2.
    

    因此,这可以在没有 promises 的情况下工作。这可能对于由单个用户在单个工作站上执行的命令行工具来说是不错的。然而,如果将此类代码放入 Web 服务器,我们可能会开始看到一些阻塞问题。同步文件系统调用是 阻塞的,并阻塞事件循环。在生产应用程序中这样做可能会导致延迟或失败。

  5. 我们可以使用 readFilewriteFile 来解决这个问题,这两个都是接受回调的异步函数,但那时我们需要将第二个 readFile 嵌套在第一个中。代码看起来像这样:

    import { readFile, writeFile } from 'fs';
    import { resolve } from 'path';
    readFile(resolve(__dirname, 'file1.txt'), (err, file1) => {
      if (err) throw err;
      readFile(resolve(__dirname, 'file1.txt'), (err, file2) => {
        if (err) throw err;
        writeFile(
          resolve(__dirname, 'output.txt'),
          [file1, file2].join('\n'),
          (err) => {
            if (err) throw err;
          }
        );
      });
    });
    

    我们现在已经摆脱了阻塞问题,但代码看起来相当丑陋。不难想象另一位开发者可能无法理解这段代码的意图,并引入了错误。此外,通过将第二个 readFile 作为回调放在第一个中,我们使函数的运行速度比实际需要的慢。在一个完美的世界中,这些调用可以并行进行。为此,我们可以利用承诺 API。

  6. 使用承诺并行处理事情的最佳方式是 Promise.all。我们可以将我们的两个 readFile 调用包裹在一个 Promise.all 中。为此,我们需要将 readFile 函数进行承诺化。幸运的是,fs 库提供了一个帮助我们完成这一点的辅助函数。我们不是导入 readFile,而是从 fs 中导入承诺,并在该对象上调用 readFile 方法:

    import { promises } from 'fs';
    import { resolve } from 'path';
    Promise.all([
      promises.readFile(resolve(__dirname, 'file1.txt')),
      promises.readFile(resolve(__dirname, 'file2.txt')),
    ]);
    
  7. 这两个读取操作现在将异步并行运行。现在我们可以处理输出并使用之前示例中的相同 array.join 函数以及 promises.writeFile

    import { promises } from 'fs';
    import { resolve } from 'path';
    Promise.all([
      promises.readFile(resolve(__dirname, 'file1.txt')),
      promises.readFile(resolve(__dirname, 'file2.txt')),
    ]).then((files) => {
      promises.writeFile(resolve(__dirname, 'output.txt'), files.join('\n'));
    });
    
  8. 这段代码看起来比上面的嵌套代码干净得多。当我们使用 npx ts-node file-concat.ts 执行它时,我们得到预期的输出 output.txt,其中包含连接后的文本:

    Text in file 1.
    Text in file 2.
    

    现在我们已经使它工作,我们当然可以想象出更复杂的程序,这些程序可以操作其他类型的文件,例如作为网络服务的 PDF 合并功能。尽管其中的一些内部实现可能会更具挑战性,但原则是相同的。

与数据库一起工作

在 Node.js 应用程序中,与后端数据库(如 mysqlpostgres)一起工作是非常常见的。对数据库进行查询时异步执行是至关重要的。生产级别的 Node.js 网络服务可能每秒处理数千个请求。如果需要对数据库进行同步查询而暂停主执行线程,这些服务根本无法扩展。异步执行对于使这一切工作至关重要。

协商数据库连接、发送 SQL 字符串和解析响应的过程很复杂,并且不是 Node.js 的原生功能,所以我们几乎总是使用第三方库来管理这一点。这些库保证实现某种回调或承诺模式,我们将在它们的文档和示例中看到这一点。根据你选择的库,你可能需要实现回调模式,你可能可以使用承诺进行工作,或者你可能被提供 async/await(见 第十三章 Async/Await)。你甚至可以选择其中任何一个,因为确实可以提供所有这些选项作为选项。

对于这些示例,我们将使用sqlite。现在,sqlite是一个很好的库,它实现了相当标准的 SQL 语法,可以作为数据库操作静态文件,甚至可以在内存中运行。我们将使用内存选项。这意味着我们不需要做任何事情来设置我们的数据库。但我们将运行一些脚本在启动时创建一个或两个表并填充它们。将这些练习调整为与mysqlpostgres或甚至mongodb一起工作相当简单。所有这些数据库都可以安装在工作站上或在 Docker 容器中本地运行开发。

对于第一个例子,让我们看看sqlite3。这个库有一个异步 API。与mysqlpostgres等更永久和健壮的数据库不同,一些sqlite客户端库实际上是同步的,但我们将不会查看这些,因为它们对于演示 Promise 的工作方式并不很有用。所以sqlite3实现了一个异步 API,但它完全使用回调。以下是一个创建内存数据库、添加一个表、向该表中添加一行,然后查询我们添加的行的示例:

import { Database } from "sqlite3";
const db = new Database(":memory:", err => {
  if (err) {
    console.error(err);
    return db.close();
  }
  db.run("CREATE TABLE promise (id int, desc char);", err => {
    if (err) {
      console.error(err);
      return db.close();
    }
    db.run(
      "INSERT INTO promise VALUES (1, 'I will always lint my code.');",
      () => {
        db.all("SELECT * FROM promise;", (err, rows) => {
          if (err) {
            console.error(err);
            return db.close();
          }
          console.log(rows);
          db.close(err => {
            if (err) {
              return console.error(err);
            }
          });
        });
      }
    );
  });
});

这正是开发者们在抱怨“回调地狱”时所表达的意思。再次强调,这段代码执行得非常完美,但它毫无必要地冗长,变得非常嵌套,并且重复,尤其是在错误处理方面。当然,可以通过添加抽象和链式调用方法来改进代码,但这并不改变回调不是编写 Node.js 代码的现代方式的事实。

由于所有这些回调都遵循期望第一个参数是一个错误对象的模式,我们可以将sqlite3进行 Promise 化,但正如通常情况那样,有人已经为我们做了这项工作,并提供了一个名为sqlite的库,该库模仿了sqlite3的确切 API,但实现了 Promise API。

我可以使用这个库重写相同的代码,结果是好得多:

import { open } from "sqlite";
import * as sqlite from "sqlite3";
open({ driver: sqlite.Database, filename: ":memory:" }).then((db) => {  return db
    .run("CREATE TABLE promise (id int, desc char);")
    .then(() => {
      return db.run(
        "INSERT INTO promise VALUES (1, 'I will always lint my code.');"
      );
    })
    .then(() => {
      return db.all("SELECT * FROM promise;");
    })
    .then(rows => {
      console.log(rows);
    })
    .catch(err => console.error(err))
    .finally(() => db.close());
});

我们已经减少了近一半的代码行数,它也没有那么深地嵌套。这仍然可以改进,但现在它更干净了。最好的是,我们有一个单一的catch块后面跟着finally,以确保数据库连接在最后关闭。

使用 REST 进行开发

在下一个练习中,我们将构建一个 RESTful API。REST 是一个非常常见的网络流量标准。大多数网站和 Web API 都使用 REST 进行操作。它代表“表示状态传输”,定义了诸如操作(有时称为“方法”甚至“动词”)如GETDELETEPOSTPUTPATCH以及资源(“路径”或“名词”)等概念。REST 的完整范围超出了本书的范围。

在 RESTful API 上工作的开发者通常会发现在使用某种类型的 REST 客户端时很有用。REST 客户端可以被配置来执行不同类型的请求并显示响应。请求可以被保存并在将来再次运行。一些 REST 客户端允许创建场景或测试套件。

Postman 是一个流行的免费 REST 客户端。如果你还没有一个你舒服工作的 REST 客户端,在下一个练习之前,尝试在 www.postman.com/downloads/ 下载 Postman。一旦你安装了 Postman,检查其文档 (learning.postman.com/docs/getting-started/sending-the-first-request/) 并为下一个练习做好准备。

练习 12.06:实现由 sqlite 支持的 RESTful API

在这个练习中,你将创建一个由 sqlite 支持的 REST API。在这个项目中,你将在 sqlite 数据库中实现所有 CRUD(创建、读取、更新和删除)操作,并且我们将从我们的 web 服务器公开相应的 REST 动词(POSTGETPUTDELETE):

注意

这个练习的代码文件也可以在这里找到:packt.link/rlX7G

  1. 要开始,从 GitHub 克隆项目并切换到这个练习的目录。

  2. 安装依赖项:

    npm i
    

    这将安装 Node.js 的类型定义,以及 ts-nodetypescript 作为开发依赖项,而 sqlitesqlite3 是常规依赖项。所有这些依赖项都已经指定在项目的 package.json 文件中。一些依赖项,如 @types/nodets-nodetypescript,被指定为 devDependencies,而其他则是常规依赖项。对于这个练习的目的,这种区别不会很重要,但这是一个常见的做法,以便在运行应用程序构建时,只有必要的依赖项是生产构建的一部分,因此进行了分离。运行这种类型构建的方式是 npm install --production,如果你只想安装生产依赖项,或者 npm prune --production,如果你已经安装了你的 devDependencies 并希望移除它们。

  3. 现在让我们创建一个文件来保存我们的 sqlite 数据库。在你的项目根目录下添加一个名为 db.ts 的文件。我们将采用面向对象的方法来处理数据库,并创建一个单例对象来表示我们的数据库和访问模式。这样做的一个原因是我们将想要维护数据库是否已经初始化的状态。在内存中的 sqlite 数据库上调用 open 将会销毁数据库并立即创建另一个,因此我们只希望在数据库连接尚未打开时打开数据库连接:

    import { Database } from "sqlite";
    import sqlite from "sqlite3";
    export interface PromiseModel {
      id: number;
      desc: string;
    }
    export class PromiseDB {
      private db: Database;
      private initialized = false;
      constructor() {
        this.db = new Database({
          driver: sqlite.Database,
          filename: ":memory:",
        });
      }
    }
    

    总是创建一个类或接口来描述我们的实体是个好主意,因此我们创建了PromiseModel。这将有助于应用程序的其他部分理解我们的实体具有哪些属性以及它们的类型,因为数据库只会返回无类型的查询结果。我们导出这个接口,以便它可以被其他模块使用。

  4. 我们的数据库是一个对象,它有一个构造函数,该构造函数将有一个私有成员来表示实际的数据库连接,以及一个布尔值来跟踪数据库是否已初始化。让我们添加一个初始化方法:

      initialize = () => {
        if (this.initialized) {
          return Promise.resolve(true);
        }
        return this.db
          .open()
          .then(() =>
            this.db
              .run("CREATE TABLE promise (id INTEGER PRIMARY KEY, desc CHAR);")
              .then(() => (this.initialized = true))
          );
      };
    

    首先,我们检查数据库是否已经初始化。如果是这样,我们就完成了,并解析这个承诺。如果不是,我们调用open,一旦这个承诺解析完成,就运行我们的表创建 SQL 语句,然后最后更新数据库的状态,这样我们就不会意外地重新初始化它。

    我们可以在构造函数中尝试初始化数据库。这种方法的问题在于构造函数在返回之前不会解析承诺。构造函数可以调用返回承诺的方法,但它们不会解析承诺。通常,创建单例对象并在单独调用初始化承诺会更干净。有关单例类的更多信息,请参阅第八章,TypeScript 中的依赖注入

  5. 现在,让我们添加一些方法。由于我们的表只有两列,所以这将会很简单:

      create = (payload: PromiseModel) =>
        this.db.run("INSERT INTO promise (desc) VALUES (?);", payload.desc);
    

    这个方法接受一个类型为PromiseModel的对象作为参数,发送一个预处理语句(一个参数化的 SQL 语句,可以防止 SQL 注入攻击),然后返回RunResult,它包含有关所执行操作的一些元数据。由于sqlite库附带类型定义,我们能够推断返回类型,而无需指定它。在这种情况下,返回类型是Promise<ISqlite.RunResult<sqlite.Statement>>。我们可以将所有这些粘贴到我们的代码中,但这样更干净。记住,如果可以推断出良好的类型,最好让 TypeScript 来做繁重的工作。

  6. 除了create方法之外,我们还想添加deletegetAllgetOneupdate方法。delete方法非常直接:

      delete = (id: number) => this.db.run("DELETE FROM promise WHERE id = ?", id);
    
  7. 由于我们再次调用db.run,我们再次返回RunResult类型。让我们看看返回一些你自己的数据是什么样子:

      getAll = () => this.db.all<PromiseModel[]>("SELECT * FROM promise;");
      getOne = (id: number) =>
        this.db.get<PromiseModel>("SELECT * FROM promise WHERE id = ?", id);
    

    这些方法使用类型参数来指定预期的返回类型。如果省略了类型参数,这些方法将返回any类型,这对我们应用程序的其他部分帮助不大。

  8. 最后一个是update方法。这个方法将再次使用我们的PromiseModel来进行输入类型检查:

      update = (payload: PromiseModel) =>
        this.db.run(
          "UPDATE promise SET desc = ? where id = ?",
          payload.desc,
          payload.id
        );
    
  9. 类的最终代码看起来像这样:

    import { Database } from "sqlite";
    import sqlite from "sqlite3";
    export interface PromiseModel {
      id: number;
      desc: string;
    }
    export class PromiseDB {
      private db: Database;
      private initialized = false;
      constructor() {
        this.db = new Database({
          driver: sqlite.Database,
          filename: ":memory:",
        });
      }
      initialize = () => {
        if (this.initialized) {
          return Promise.resolve(true);
        }
        return this.db
          .open()
          .then(() =>
            this.db
              .run("CREATE TABLE promise (id INTEGER PRIMARY KEY, desc CHAR);")
              .then(() => (this.initialized = true))
          );
      };
      create = (payload: PromiseModel) =>
        this.db.run("INSERT INTO promise (desc) VALUES (?);", payload.desc);
      delete = (id: number) => this.db.run("DELETE FROM promise WHERE id = ?", id);
      getAll = () => this.db.all<PromiseModel[]>("SELECT * FROM promise;");
      getOne = (id: number) =>
        this.db.get<PromiseModel>("SELECT * FROM promise WHERE id = ?", id);
      update = (payload: PromiseModel) =>
        this.db.run(
          "UPDATE promise SET desc = ? where id = ?",
          payload.desc,
          payload.id
        );
    }
    

    下一步是构建一个实现 RESTful 接口的 HTTP 服务器。许多 Node.js 开发者使用 Express.js、Fastify 或 NestJS 等框架,但在这个练习中,我们只是构建一个基本的 HTTP 服务器。它不会有那些框架的所有便利功能,但它将帮助我们专注于异步编程。

  10. 要创建我们的服务器,我们将创建一个名为 App 的类并公开其一个实例。创建一个名为 app.ts 的文件并声明这个类:

    import { createServer, IncomingMessage, Server, ServerResponse } from "http";
    import { PromiseDB } from "./db";
    class App {
      public db: PromiseDB;
      private server: Server;
      constructor(private port: number) {
        this.db = new PromiseDB();
        this.server = createServer(this.requestHandler);
      }
    }
    export const app = new App(3000);
    
  11. 我们的 App 类接受一个参数,即我们将在其上运行服务器的端口号。该类将维护运行中的服务器状态以及数据库连接。类似于我们的 PromiseDB 类,构造函数需要补充一个 initialize 方法来处理异步设置:

      initialize = () => {
        return Promise.all([
          this.db.initialize(),
          new Promise((resolve) => this.server.listen(this.port, () => resolve(true))),
        ]).then(() => console.log("Application is ready!"));
      };
    

    此方法使用 Promise.all 以便我们可以并行初始化数据库和服务器。当两者都准备好时,它将记录一条消息,让我们知道应用程序已准备好处理请求。我们在 PromiseDB 实例上调用 initialize 方法,该实例我们已经公开给 App 类。不幸的是,server.listen 不返回一个 promise,而是实现了一个相当原始的 API,该 API 需要一个回调函数,因此我们将其包装在我们的自己的 promise 中。虽然我们很想用 util.promisify 包装 server.listen,但即使那样也不会工作,因为 util.promisify 预期回调函数的第一个参数是一个错误对象,而 server.listen 的回调函数不接受任何参数。有时,尽管我们尽了最大努力,但我们仍然不得不使用回调函数,但我们可以通常用 promises 包装它们。

  12. 我们还需要添加一个 requestHandler 方法。createServer 是 Node.js 中 http 模块公开的一个方法。它接受一个参数,该参数应该是一个处理请求并返回响应的函数。再次强调,http 模块的 API 相对较低级:

    requestHandler = (req: IncomingMessage, res: ServerResponse) => {
        res.setHeader("Access-Control-Allow-Origin", "*");
        res.setHeader("Access-Control-Allow-Headers", "*");
        res.setHeader(
          "Access-Control-Allow-Methods",
          "DELETE, GET, OPTIONS, POST, PUT"
        );
        if (req.method === "OPTIONS") {
          return res.end();
        }
        const urlParts = req.url?.split("/") ?? "/";
        switch (urlParts[1]) {
          case "promise":
            return promiseRouter(req, res);
          default:
            return this.handleError(res, 404, "Not Found.");
        }
      };
    

    我们希望我们的应用程序将 /promise 资源上的所有流量都导向我们的承诺 API。这将允许我们稍后添加更多资源(比如 /admin/users)。请求处理器的任务是检查我们是否请求了 /promise 路由,然后将流量导向那个特定的路由器。由于我们还没有定义任何其他资源,如果我们请求任何其他路由,我们将返回一个 404 状态码。

    注意,我们处理 OPTIONS HTTP 动词的方式与其他任何动词不同。如果我们收到一个带有该动词的请求,我们将设置 "Access-Control-Allow-Origin" 头并返回一个成功的响应。这是为了开发方便。CORS 的话题超出了本书的范围,建议读者在将其应用于生产环境之前先了解更多相关信息。

  13. 那个错误处理器需要一个定义,让我们添加一个:

      handleError = (
        res: ServerResponse,
        statusCode = 500,
        message = "Internal Server Error."
      ) => res.writeHead(statusCode).end(message);
    

    这是一个很好的单行代码,默认情况下会抛出一个 500 状态码 Internal Server Error,但可以接受可选参数以返回任何错误代码或消息。我们的默认处理器将状态码设置为 404 并提供消息 "Not Found"

  14. 我们在最后添加了一个对initialize的调用,然后我们就可以开始了。让我们再次看看App类:

    import { createServer, IncomingMessage, Server, ServerResponse } from "http";
    import { PromiseDB } from "./db";
    import { promiseRouter } from "./router";
    class App {
      public db: PromiseDB;
      private server: Server;
      constructor(private port: number) {
        this.db = new PromiseDB();
        this.server = createServer(this.requestHandler);
      }
      initialize = () => {
        return Promise.all([
          this.db.initialize(),
          new Promise((resolve) => this.server.listen(this.port, () => resolve(true))),
        ]).then(() => console.log("Application is ready!"));
      };
      handleError = (
        res: ServerResponse,
        statusCode = 500,
        message = "Internal Server Error."
      ) => res.writeHead(statusCode).end(message);
    requestHandler = (req: IncomingMessage, res: ServerResponse) => {
        res.setHeader("Access-Control-Allow-Origin", "*");
        res.setHeader("Access-Control-Allow-Headers", "*");
        res.setHeader(
          "Access-Control-Allow-Methods",
          "DELETE, GET, OPTIONS, POST, PUT"
        );
        if (req.method === "OPTIONS") {
          return res.end();
        }
        const urlParts = req.url?.split("/") ?? "/";
        switch (urlParts[1]) {
          case "promise":
            return promiseRouter(req, res);
          default:
            return this.handleError(res, 404, "Not Found.");
        }
      };
    }
    export const app = new App(3000);
    app.initialize();
    

    如果你已经在代码中实现了所有这些,你可能在promiseRouter上仍然会遇到错误。那是因为我们还没有写那个。

  15. 将一个router.ts文件添加到你的项目中。这将是我们构建这个简单 API 所需构建的最后一部分。一个更复杂的应用程序可能希望包括一个更复杂的目录结构,并且很可能会基于一个领先的框架,如 Express.js 或 NestJS。

    与我们的数据库和服务器模块不同,路由器是无状态的。它不需要初始化,也不跟踪任何变量。我们仍然可以为我们路由器创建一个类,但让我们改用函数式编程风格。实际上,没有绝对的对错之分。我们同样可以用函数式风格代替为数据库和服务器使用的类。

    我们将致力于创建几个处理器,基于 HTTP 动词将它们与一个路由器结合起来,并创建一个 body 解析器。让我们从 body 解析器开始。

  16. 对于有 Express.js 框架经验的读者来说,可能已经使用过其强大的bodyParser模块。对于这类事情,通常使用现成的解决方案是个好主意,但在这个练习中,你将编写自己的代码,以便更深入地了解我们如何将请求或IncomingMessage对象作为流,并将其转换为类型化对象:

    const parseBody = (req: IncomingMessage): Promise<PromiseModel> => {
      return new Promise((resolve, reject) => {
        let body = "";
        req.on("data", (chunk) => (body += chunk));
        req.on("end", () => {
          try {
            resolve(JSON.parse(body));
          } catch (e) {
            reject(e);
          }
        });
      });
    };
    

    数据流再次是一个相对底层的 API,我们必须将其封装在 Promise 中。流是事件驱动的,Node.js 的许多 API 也是如此。在这种情况下,我们正在监听两个独立的事件,dataend。每次我们收到data事件时,我们都会将数据添加到body字符串中。当我们收到end事件时,我们最终可以解析我们的 Promise。由于此时数据是一个字符串,而我们想要一个对象,我们将使用JSON.parse来解析对象。JSON.parse必须用try/catch封装来捕获任何解析错误。

    默认情况下,JSON.parse返回一个any类型。这个类型太宽泛了,在检查我们的应用程序的类型正确性时没有任何帮助。幸运的是,我们可以通过将parseBody的返回类型设置为Promise<PromiseModel>来添加适当的类型检查。这将使JSON.parse返回的对象类型缩小到PromiseModel,并且我们应用程序的其余部分可以期望该类型已被解析。请注意,这是一个编译时检查,并不能保证从第三方源(如最终用户)正确地收到了数据。建议将类型检查与验证器或类型守卫结合使用,以确保一致性。如有疑问,请使用良好的错误处理。

  17. 现在你已经有一个很好的解析请求体的方法了,让我们添加一个来处理创建操作:

    const handleCreate = (req: IncomingMessage, res: ServerResponse) =>
      parseBody(req)
        .then((body) => app.db.create(body).then(() => res.end()))
        .catch((err) => app.handleError(res, 500, err.message));
    
  18. 这个函数解析请求的正文,尝试将其插入我们的数据库,如果操作成功,则返回默认的 200 响应。请注意,最后的链式 catch 会捕获任何在 promise 中发生的错误。即使它放在链中的 db.create 之后,如果我们的正文解析失败,错误也会在这里被捕获。

  19. 现在让我们处理删除操作:

    const handleDelete = (requestParam: number, res: ServerResponse) =>
      app.db
        .delete(requestParam)
        .then(() => res.end())
        .catch((err) => app.handleError(res, 500, err.message));
    

    HTTP 的 DELETE 动词不使用正文。相反,我们将从 URL 中获取我们想要删除的行的 ID。我们将在稍后看到这个路由是如何工作的。

  20. GET 操作需要返回一些数据,并将使用 JSON.stringify 将其响应对象序列化以发送给客户端:

    const handleGetAll = (res: ServerResponse) =>
      app.db
        .getAll()
        .then((data) => res.end(JSON.stringify(data)))
        .catch((err) => app.handleError(res, 500, err.message));
    const handleGetOne = (requestParam: number, res: ServerResponse) =>
      app.db
        .getOne(requestParam)
        .then((data) => res.end(JSON.stringify(data)))
        .catch((err) => app.handleError(res, 500, err.message));
    
  21. 更新操作看起来与删除类似:

    const handleUpdate = (req: IncomingMessage, res: ServerResponse) =>
      parseBody(req)
        .then((body) => app.db.update(body).then(() => res.end()))
        .catch((err) => app.handleError(res, 500, err.message));
    
  22. 最后,我们只需要一个路由器。你的路由器将需要根据使用的 HTTP 动词以及可能引用我们想要交互的行的 ID 的任何请求参数做出决定。我们还将为所有响应设置 Content-Type 报头为 application/json。然后我们只需委托给正确的处理程序:

    export const promiseRouter = (req: IncomingMessage, res: ServerResponse) => {
      const urlParts = req.url?.split("/") ?? "/";
      const requestParam = urlParts[2];
      res.setHeader("Content-Type", "application/json");
      switch (req.method) {
        case "DELETE":
          if (requestParam) {
            return handleDelete(Number.parseInt(requestParam), res);
          }
        case "GET":
          if (requestParam) {
            return handleGetOne(Number.parseInt(requestParam), res);
          }
          return handleGetAll(res);
        case "POST":
          return handleCreate(req, res);
        case "PUT":
          return handleUpdate(req, res);
        default:
          app.handleError(res, 404, "Not Found.");
      }
    };
    
  23. 现在是时候尝试我们的应用程序了。我们之前安装了 ts-node。这个库允许我们一步完成转换和运行我们的 TypeScript 程序。在生产环境中不一定推荐使用 ts-node,但它是一个非常方便的开发工具。让我们现在试试:

     npx ts-node app.ts
    

    你应该在控制台看到以下内容:

    Application is ready!
    

    这意味着你的应用程序已经准备好开始接收请求。如果不是,你可能某个地方有拼写错误。让我们试试。你可以使用 REST 客户端或 curl。这个练习使用 Postman

  24. 如果你向 http://localhost:3000/promise 发起 GET 请求,你将返回一个空数组([]):图 12.3:初始 GET 请求

    图 12.3:初始 GET 请求

    这是因为我们还没有创建任何记录。

  25. 尝试使用有效载荷 {"desc":"Always lint your code"}POST 请求:图 12.4:POST 数据

    图 12.4:POST 数据

  26. 现在 GET 请求返回 [{"id":1,"desc":"Always lint your code"}]图 12.5:使用 GET 检索数据

    图 12.5:使用 GET 检索数据

  27. 如果你向 http://localhost:3000/promise/1 发起请求,你将返回一个单一的对象:图 12.6:单个对象

    图 12.6:单个对象

  28. 如果你请求 http://localhost:3000/promise/2,你将一无所获:图 12.7:没有找到项目

    图 12.7:没有找到项目

  29. 如果你请求 http://localhost:3000/something-else,你将得到一个 404 响应:

图 12.8:404 响应

图 12.8:404 响应

看起来一切都在正常工作。尝试不同的 HTTP 动词。尝试输入无效数据,看看错误处理是如何工作的。我们将在下一节中使用这个 API。

整合所有内容 - 构建 Promise 应用

我们已经学习了在 Web 项目和 Node.js API 中使用承诺的技术。让我们结合我们之前的练习来构建一个 Web 应用程序,该应用程序在数据准备就绪时逐步渲染,并利用服务器上的异步编程来避免阻塞事件循环。

活动 12.01:构建承诺应用程序

在此活动中,我们将构建一个与刚刚构建的 API 通信的 Web 应用程序。尽管 Angular、React 和 Vue 等框架非常流行,但这些将在后面的章节中介绍,所以我们将构建一个没有铃声或哨声的基本 TypeScript 应用程序。

注意

此活动提供了一个与我们在 练习 12.06 中构建的后端 API 通信的 UI 应用程序,该 API 是在 实现基于 sqlite 的 RESTful API 中构建的。为了获得显示的输出,您需要让您的 API 运行。如果需要帮助,请返回该练习。

此 UI 应用程序将连接到我们的 API,并允许我们修改存储在数据库中的数据。我们将能够列出我们已保存的数据(我们做出的承诺),创建新的条目进行保存,以及删除条目。我们的 UI 应用程序需要对后端 API 进行 GETPOSTDELETE 调用。它需要使用 HTTP 客户端来完成。我们可以安装一个库,如 axios 来处理它,或者我们可以使用所有现代 Web 浏览器中可用的原生 Fetch API。

我们的 Web 应用程序还需要能够动态更新 UI。现代视图库,如 reactvue,为我们做了这件事,但在这个案例中,我们是框架自由的,所以我们需要使用更多的 DOM(文档对象模型)API,如 getElementByIdcreateElementappendChild。这些在所有浏览器中都是原生可用的,无需任何库。

使用承诺实现此应用程序至关重要,因为所有的 API 调用都将异步进行。我们将执行一个动作,例如点击,我们的应用程序将调用 API,然后它将响应数据,然后并且只有在那时承诺才会解决并导致 DOM 状态的变化。

以下是一些高级步骤,可以帮助您创建应用程序:

注意

此活动的代码文件可以在此处找到:packt.link/RlYli

  1. 创建一个静态 html 页面,带有 css,通过 http-server 在本地开发中提供。

  2. 添加一个 app.ts 文件,使用 fetch 向后端发起 Web 请求,并根据响应进行必要的 DOM 操作。

  3. app.ts 文件转换为 app.js 并使用 Web 浏览器在本地服务器上进行测试。

  4. 调整 app.ts 并继续测试,直到所有场景都工作。

    完成活动后,您应该能够在 localhost:8080 上查看表格。这里有一个示例:

![图 12.9:完成后的表格img/B14508_12_09.jpg

图 12.9:完成后的表格

注意

此活动的解决方案可以通过 此链接 获取。

摘要

我们已经学习了承诺如何成为 ECMAScript 标准的一部分,参观了原生的实现方式,并通过使用承诺解决实际问题的示例项目进行了实践。我们还探讨了 TypeScript 如何增强承诺规范,以及如何在目标环境不支持原生承诺支持时进行 polyfill。我们对比了 Bluebird 承诺库与原生承诺。我们了解了使用 Node.js 与文件系统交互的不同方式,还涵盖了管理异步数据库连接和查询的内容。最后,我们将所有这些内容整合到一个工作应用中。

在下一章中,我们将通过介绍 asyncawait 来构建异步编程范式。我们将讨论何时使用这些功能而不是承诺,以及承诺在 TypeScript 生态系统中的位置。

第十四章:13. TypeScript 中的 Async/Await

概述

async/await 关键字为开发者提供了一种更简洁的方式来编写异步、非阻塞程序。在本章中,我们将了解这种语法糖,一个用于更简洁和表达性更强的语法的术语,以及它是如何推动现代软件开发。我们将探讨 async/await 的常见用法,并讨论 TypeScript 中异步编程的格局。到本章结束时,你将能够将 async/await 关键字应用于 TypeScript,并使用它们来编写异步程序。

简介

上一章让你在 TypeScript 中开始了承诺的学习。虽然承诺提高了我们编写异步代码的能力,避免了嵌套回调的丑陋,但开发者仍然想要一种更好的方式来编写异步代码。承诺语法对于有 C 语言家族背景的程序员来说有时可能具有挑战性,因此提出了将 async/await 的“语法糖”添加到 ECMAScript 规范的建议。

在本章中,我们将了解新的异步编程范式被引入 ECMAScript 标准的介绍,检查其语法,并查看它们在 TypeScript 中的使用。我们还将介绍(截至编写时)新的顶级 await 功能,它允许在 async 函数之外进行异步编程。我们还将再次探讨异步编程中的错误处理,并检查使用 async/await 语法与承诺相比的优缺点。

阅读过上一章的读者会看到,在承诺中仍然有一些嵌套。虽然通过多个承诺比嵌套回调更容易管理流程,但我们仍然没有一种机制可以将控制权返回到顶层。

例如,考虑一个返回承诺的 getData 函数。调用此函数的代码将类似于以下内容:

getData().then(data => {
  // do something with the data
});

我们没有方法将 data 值传播到外部作用域。我们无法在后续作用域中处理该值。一些程序员可能会尝试编写类似这样的代码:

let myData;
getData().then(data => {
  myData = data
});
console.log(myData);

这段代码将始终输出 undefined。它看起来应该可以工作,但不会,因为承诺回调将在承诺返回之前被调用。这种异步编程可能会令人困惑,并导致许多错误。async/await 通过允许我们在承诺解决之前暂停代码的执行来解决此问题。我们可以使用 async/await 语法重写前面的代码:

const myData = await getData();
console.log(myData);

我们已经从五行代码缩减到两行。console.log 的同步操作将等待承诺解决。代码更加易于理解,我们可以在顶层作用域中存储变量而不需要嵌套。

由于 TypeScript 在大多数情况下被转换为 JavaScript,我们需要确保我们选择正确的目标环境,以便我们的代码能够运行。这个主题将在本章后面更详细地讨论。

进化与动机

虽然承诺(promises)在异步编程范式方面取得了显著进展,但人们仍然渴望一种更轻量级的语法,它依赖于更少的显式声明承诺对象。将 async/await 关键字添加到 ECMAScript 规范中,将允许开发者减少样板代码并使用承诺(promises)。这一概念源自 C# 编程语言,而 C# 又从 F# 编程语言中借鉴了异步工作流的概念。

异步函数允许程序在函数调用尚未返回的情况下继续正常操作。程序不会等待异步函数调用完成,直到找到 await 关键字。更重要的是,使用 await 不会阻塞事件循环。即使我们暂停了程序的一部分以等待异步函数调用的结果,其他操作仍然可以完成。事件循环不会被阻塞。有关事件循环的更多信息,请参阅第十二章,TypeScript 中承诺的指南。

这些关键字的好处在于它们与承诺(promises)立即兼容。我们可以等待任何承诺(promise),从而避免使用 then() API。这种能力意味着,即使在集成较旧的库或模块时,我们也可以使用最新的语法,这与承诺化(promisification)的概念(见第十二章,TypeScript 中承诺的指南)相结合。为了演示这一点,让我们回到前一章的一个例子:

import { promises } from "fs";
promises.readFile('text.txt').then(file => console.log(file.toString()));

此示例使用 Node.js 的 fs(文件系统)模块中的 promises API。该代码从本地文件系统中读取文件并将内容记录到控制台。我们可以使用 await 语法来使用此代码:

import { promises } from "fs";
const text = (await promises.readFile('text.txt')).toString();
console.log(text);

注意,为了运行此代码,您必须能够使用顶层 await,这在撰写本文时需要一些额外的设置。请参阅本章后面的部分。从这个例子中我们可以得出的结论是,即使我们更喜欢 async/await,我们仍然可以使用 fs 模块中的 promises API。

TypeScript 中的 async/await

TypeScript 的维护者从审查过程的第 1 和第 2 阶段开始支持 ECMAScript 功能,但只有在它们达到第 3 阶段时才会正式发布。

TypeScript 从 2015 年 9 月发布的 1.6 版本开始提供对 async 函数的实验性支持,并在 2015 年 11 月发布的 1.7 版本中提供完全支持。TypeScript 程序员可以在官方浏览器和 Node.js 支持之前整整一年使用这种语法。

在 TypeScript 中使用 async/await 关键字与 JavaScript 的使用方式没有太大差异,但我们确实在明确指定哪些函数应该返回承诺(promises)以及哪些应该返回已解析的值或抛出错误方面具有优势。

在 TypeScript 中编写现代语法时,需要注意的一点是,大多数 TypeScript 代码在运行时(如网页浏览器或 Node.js)执行时会被转译为 JavaScript。我们需要理解转译和 polyfill 之间的区别。async/await 代码会被转译到只支持 promise 语法的环境中。polyfill 添加缺失的语言特性。如果我们的目标环境甚至不支持 promises,那么将 async/await 转译为 promises 就无法解决问题。我们还需要一个 polyfill。

练习 13.01:转译目标

在这个练习中,我们将使用一个虚构的 "Hello World!" 示例来演示 TypeScript 如何处理 async /await 关键字的转译:

注意

本练习的代码文件可以在此处找到:packt.link/NS8gY

  1. 导航到 Exercise01 文件夹,并使用 npm install 安装依赖项:

    npm install
    
  2. 这将安装 TypeScript 和 TS Node 执行环境。现在,通过输入 npx ts-node target.ts 来执行程序。结果如下:

    World!
    Hello
    

    Hello 之前打印了 World!

  3. 打开 target.ts 并检查导致这种情况的原因。这个程序创建了一个 sayHello 函数,该函数内部创建了一个在 1 毫秒后解决的 promise。你可能注意到,即使我们移除了 await 关键字,程序仍然会做完全相同的事情。这是可以的。这里有趣的不是 await 关键字,而是不同的转译目标。当我们使用 TS Node 运行这个程序时,这将针对我们正在运行的当前 Node.js 版本。假设这是一个较新的版本,async/await 将被支持。而不是这样做,让我们尝试使用 TypeScript 将代码转译为 JavaScript,看看会发生什么。

  4. 现在,打开 tsconfig.json 文件并查看它:

    {
      "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true
      }
    }
    
  5. target 选项设置为 es5 意味着 TypeScript 将尝试生成符合 ECMAScript5 规范的代码。所以让我们试一试:

    npx tsc
    

    没有输出意味着它执行成功。

  6. 查看由 TypeScript 生成的 target.js 文件。这个文件的大小可能因你的 TypeScript 版本而异,但转译后的代码模块可能超过 50 行:

    "use strict";
    var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
        function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
        return new (P || (P = Promise))(function (resolve, reject) {
    //….
    sayHello();
    console.log('World!');
    

    注意

    完整的代码可以在此处找到:packt.link/HSmyX

    我们可以通过在命令提示符中输入 node target.js 来执行转译后的代码,我们会看到与之前相同的输出。

    Promises 不是 ECMAScript5 规范的一部分,因此为了生成在 ECMAScript5 环境中可以工作的代码,转译器必须创建 __awaiter__generator 函数以支持类似 promise 的功能。

  7. 让我们把目标切换到 es6。打开 tsconfig.json 并将目标属性更改为 es6

    {
      "compilerOptions": {
        "target": "es6",
        "module": "commonjs",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true
      }
    }
    
  8. 使用 node target.js 调用函数,我们得到与之前完全相同的输出。现在让我们看看 TypeScript 在转译我们的源代码时做了什么:

    "use strict";
    var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
        function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
        return new (P || (P = Promise))(function (resolve, reject) {
            function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
            function rejected(value) { try { step(generator"throw"); } catch (e) { reject(e); } }
            function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
            step((generator = generator.apply(thisArg, _arguments || [])).next());
        });
    };
    const sayHello = () => __awaiter(void 0, void 0, void 0, function* () {
        yield new Promise((resolve) => setTimeout(() => resolve(console.log('Hello')), 1));
    });
    sayHello();
    console.log('World!');
    

    转译后的代码现在是 15 行,而不是超过 50 行,因为 ECMAScript6 比 es5 更接近支持我们需要的所有功能。async/await 关键字在 ECMAScript6 中不受支持,但 promises 是支持的,所以 TypeScript 正在利用 promises 来使输出的代码更加简洁。

  9. 现在,让我们将目标改为 esnext,再次运行 npx tsc,看看输出结果是什么:

    "use strict";
    const sayHello = async () => {
        await new Promise((resolve) => setTimeout(() => resolve(console.log('Hello')), 1));
    };
    sayHello();
    console.log('World!');
    

    这非常类似于我们的源代码!由于 async/await 在最新的 ECMAScript 规范中是支持的,因此不需要转换。

  10. 旧版本的 TypeScript 没有完全 polyfill promises 和 async/await。使用 npm i -D typescript@2 降级 TypeScript 版本,将编译目标恢复到 es5,然后尝试转译:

    npx tsc
    target.ts:1:18 - error TS2705: An async function or method in ES5/ES3 requires the 'Promise' constructor.  Make sure you have a declaration for the 'Promise' constructor or include 'ES2015' in your `--lib` option.
    1 const sayHello = async () => {
                       ~~~~~~~~~~~~~
    target.ts:2:13 - error TS2693: 'Promise' only refers to a type, but is being used as a value here.
    2   await new Promise((resolve) =>
                  ~~~~~~~
    target.ts:2:22 - error TS7006: Parameter 'resolve' implicitly has an 'any' type.
    2   await new Promise((resolve) =>
    

    这不起作用。

  11. 如果你将目标提升到 es6,它仍然会失败:

    % npx tsc
    target.ts:3:30 - error TS2345: Argument of type 'void' is not assignable to parameter of type '{} | PromiseLike<{}> | undefined'.
    3     setTimeout(() => resolve(console.log('Hello')))
    
  12. 使用 npm i -D typescript@latest 安装 TypeScript 的最新版本,然后一切应该和以前一样工作。

对于初学者来说,TypeScript 的这个方面可能会令人困惑。TypeScript 不会为缺失的 promises 提供 polyfill,但它会提供对功能等效的语法的转换。

选择目标

那么,我们如何选择编译目标呢?通常情况下,使用 ES2017 或更高版本是安全的,除非你需要支持过时的浏览器,例如 Internet Explorer,或者废弃的 Node.js 版本。有时,由于客户需求,我们别无选择,只能支持过时的浏览器,但如果我们对 Node.js 运行时环境有任何控制权,建议更新到当前支持的版本。这样做应该允许我们使用最新的 TypeScript 功能。

语法

这两个新关键字 async/await 通常会一起出现,但并不总是如此。让我们分别看看每个关键字的语法。

async

async 关键字修改了一个函数。如果使用函数声明或函数表达式,它放在 function 关键字之前。如果使用箭头函数,async 关键字放在参数列表之前。给函数添加 async 关键字将导致函数返回一个 promise。

例如:

function addAsync(num1: number, num2: number) {
  return num1 + num2;
}

只需在这个简单函数中添加 async 关键字,这个函数就会返回一个 promise,现在它是可等待的并且是 thenable 的。由于函数中没有异步操作,这个 promise 将立即解析。

这个箭头函数版本可以写成如下:

const addAsync = async (num1: number, num2: number) => num1 + num2;

练习 13.02:async 关键字

这个练习说明了给函数添加 async 关键字是如何使其返回一个 promise 的:

注意

这个练习的代码文件可以在以下位置找到:packt.link/BgujE

  1. 检查 async.ts 文件:

    export const fn = async () => {
      return 'A Promise';
    };
    const result = fn();
    console.log(result);
    

    你可能期望这个程序输出 A Promise,但让我们看看当我们运行它时实际上会发生什么:

    npx ts-node async.ts
    Promise { 'A Promise' }
    
  2. async 关键字将响应封装在一个 promise 中。我们可以通过移除该关键字并再次运行程序来确认这一点:

    npx ts-node async.ts
    A Promise
    
  3. 使用 async 修改我们的函数与将其包裹在一个承诺中完全等价。如果我们想使用承诺语法,我们可以将程序写成这样:

    export const fn = () => {
      return Promise.resolve('A Promise');
    };
    const result = fn();
    console.log(result);
    
  4. 再次,以这种方式编写的程序将输出未解决的承诺:

    npx ts-node async.ts
    Promise { 'A Promise' }
    

由于我们使用 TypeScript 并且返回类型可以被推断,使用 async 修改函数可以保证 TypeScript 总是将其视为返回一个承诺。

async 关键字使得它修改的函数被包裹在一个承诺中。你选择通过声明一个承诺或使用 async 关键字来显式地这样做,通常是一个品味和风格的问题。

我们如何解决一个 async 函数?我们稍后会谈到 await,但使用 then 和我们在 第十二章TypeScript 中承诺指南中学到的承诺链式操作呢?是的,这也是可能的。

练习 13.03:使用 then 解决 async 函数

本练习将教你如何使用 then 解决一个 async 函数:

注意

本练习的代码文件可以在这里找到:packt.link/4Bo4c

  1. 创建一个名为 resolve.async.ts 的新文件,并输入以下代码:

    export const fn = async () => {
      return 'A Promise';
    };
    const result = fn();
    result.then((message) => console.log(message));
    
  2. 通过在控制台中输入 npx ts-node resolve.async.ts 执行此代码,你会看到预期的文本消息被记录,而不是未解决的承诺:

    A Promise
    

尽管我们从未显式地声明承诺对象,但使用 async 确保我们的函数始终返回一个承诺。

await

这个组合的第二部分可能更有价值。await 关键字将尝试解决任何承诺,然后再继续。这将使我们摆脱 then 链式操作,并允许我们编写看起来是同步的代码。使用 await 的一个巨大好处是,如果我们想将异步调用的结果分配给某个值,然后对这个值进行一些操作。让我们看看在承诺中是如何做到这一点的:

asyncFunc().then(result => {
  // do something with the result
});

这可以正常工作,实际上,这种语法被广泛使用,但如果我们需要进行一些复杂的链式操作,它可能会有些问题:

asyncFuncOne().then(resultOne => {
  asyncFuncTwo(resultOne).then(resultTwo => {
    asyncFuncThree(resultTwo).then(resultThree => {
      // do something with resultThree
    });
  });
});

但等等。我以为承诺是用来消除回调地狱的!实际上,对于这种链式操作来说,这并不理想。让我们尝试使用 await 代替:

const resultOne = await asyncFuncOne();
const resultTwo = await asyncFuncTwo(resultOne);
const resultThree = await asyncFuncThree(resultTwo);
// do something with resultThree

大多数程序员都会同意这种语法更干净,实际上,这也是为什么在语言中添加 async/await 的主要原因之一。

练习 13.04:await 关键字

本练习将展示如何使用 await 解决一个承诺:

注意

本练习的代码文件可以在这里找到:packt.link/mUzGI

  1. 创建一个名为 await.ts 的文件,并输入以下代码:

    export const fn = async () => {
      return 'A Promise';
    };
    const resolveIt = async () => {
      const result = await fn();
      console.log(result);
    };
    resolveIt();
    

    在这里我们声明了两个 async 函数。其中一个使用 await 调用另一个,以解决承诺,并且应该打印出字符串,而不是未解决的承诺。

  2. 使用 npx ts-node await.ts 运行文件,你应该会看到以下输出:

    A Promise
    

为什么我们需要在第二个函数中包装 await?这是因为通常,await 不能在 async 函数之外使用。我们将在本章后面讨论顶级 await 功能,这是这个规则的例外。关于将 await 与承诺混合呢?这当然可以做到。

练习 13.05:等待承诺

这个练习教你如何使用 await 与承诺:

注意

这个练习的代码文件可以在以下位置找到:packt.link/mMDiw

  1. 创建一个名为 await-promise.ts 的新文件,并输入以下代码:

    export const resolveIt = async () => {
      const result = await Promise.resolve('A Promise');
      console.log(result);
    };
    resolveIt();
    
  2. 通过输入 npx ts-node await-promise.ts 执行代码,你会看到文本输出:

    A Promise
    
  3. 使用更明确的承诺声明来编写相同代码的另一种更长的写法是:

    export const resolveIt = async () => {
      const p = new Promise((resolve) => resolve('A Promise'));
      const result = await p;
      console.log(result);
    };
    resolveIt();
    

    这段代码的功能完全相同:

  4. 输入 npx ts-node src/await-promise.ts 以验证你是否得到以下输出:

    A Promise
    

语法糖

之前关于 async 函数和承诺的练习只是两种不同的方式,在 TypeScript 中表达完全相同的操作。同样,使用 await 和使用 then 解决承诺是等效的。async/await 关键字被称为“语法糖”,或者代码结构,它允许更具有表现力的语法,而不改变程序的行为。

这意味着有可能,有时甚至建议将 async/await 语法与承诺混合。这样做的一个非常常见的原因是,你正在使用一个编写为使用承诺的库,但你更喜欢 async/await 语法。混合两种语法的另一个原因可能是更明确地处理异常。我们将在本章后面详细讨论异常处理。

异常处理

我们已经讨论了如何将 then 链转换为 await,但关于 catch 呢?如果一个承诺被拒绝,错误将会冒泡并必须以某种方式捕获。在 async/await 世界中未能捕获异常与未能捕获承诺拒绝一样有害。事实上,它们是完全相同的,async/await 只是承诺之上的语法糖。

未处理被拒绝的承诺可能导致系统故障,其中在网页浏览器中运行的程序崩溃,导致空白页面或功能损坏,从而驱使用户远离你的网站。在服务器端未能处理被拒绝的承诺可能会导致 Node.js 进程退出和服务器崩溃。即使你有自我修复系统尝试将服务器恢复在线,你试图完成的任何工作都将失败,频繁的重启会使你的基础设施运行成本更高。

处理这些错误的最直接方法是使用 trycatch 块。这种语法不仅对 async/await 是独特的,自 ECMAScript3 以来一直是 ECMAScript 规范的一部分。它非常简单直接,易于使用:

try {
  await someAsync();
} catch (e) {
  console.error(e);
}

就像您可以从多个链式承诺中捕获抛出的错误一样,您也可以在这里实现类似的模式:

try {
  await someAsync();
  await anotherAsync();
  await oneMoreAsync();
} catch (e) {
  console.error(e);
}

可能会有需要更精细异常处理的情况。这些结构可以嵌套:

try {
  await someAsync();
  try {
    await anotherAsync();
  } catch (e) {
    // specific handling of this error
  }
  await oneMoreAsync();
} catch (e) {
  console.error(e);
}

然而,编写这样的代码会抵消 async/await 语法的大部分好处。更好的解决方案是抛出特定的错误消息并对其进行测试:

try {
  await someAsync();
  await anotherAsync();
  await oneMoreAsync();
} catch (e) {
  if(e instanceOf MyCustomError) {
    // some custom handling
  } else {
    console.error(e);
  }
}

使用这种技术,我们可以将所有内容都在同一个块中处理,避免嵌套和看起来杂乱的代码结构。

练习 13.06:异常处理

让我们看看我们如何在简单示例中实现错误处理。在这个练习中,我们将故意并明确地从 async 函数中抛出一个错误,并看看它是如何实现我们的程序操作的:

注意

这个练习的代码文件可以在以下位置找到:packt.link/wbA8E

  1. 首先创建一个名为 error.ts 的新文件,并输入以下代码:

    export const errorFn = async () => {
      throw new Error('An error has occurred!');
    };
    const asyncFn = async () => {
      await errorFn();
    };
    asyncFn();
    
  2. 当然,这个程序总是会抛出错误。当我们通过在控制台中输入 npx ts-node error.ts 来执行它时,我们可以清楚地看到错误没有被正确处理:

    (node:29053) UnhandledPromiseRejectionWarning: Error: An error has occurred!
        at Object.exports.errorFn (/workshop/async-chapter/src/error.ts:2:9)
        at asyncFn (/workshop/async-chapter/src/error.ts:6:9)
        at Object.<anonymous> (/workshop/async-chapter/src/error.ts:9:1)
        at Module._compile (internal/modules/cjs/loader.js:1138:30)
        at Module.m._compile (/workshop/async-chapter/node_modules/ts-node/src/index.ts:858:23)
        at Module._extensions..js (internal/modules/cjs/loader.js:1158:10)
        at Object.require.extensions.<computed> [as .ts] (/workshop/async-chapter/node_modules/ts-node/src/index.ts:861:12)
        at Module.load (internal/modules/cjs/loader.js:986:32)
        at Function.Module._load (internal/modules/cjs/loader.js:879:14)
        at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
    (node:29053) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)
    (node:29053) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
    

    注意到弃用警告。这不仅是一个难看的堆栈跟踪,在未来的某个时刻,这样的异常将导致 Node.js 进程退出。我们显然需要处理这个异常!

  3. 幸运的是,我们可以通过简单地用 trycatch 包围调用来实现这一点:

    export const errorFn = async () => {
      throw new Error('An error has occurred!');
    };
    const asyncFn = async () => {
      try {
        await errorFn();
      } catch (e) {
        console.error(e);
      }
    };
    asyncFn();
    
  4. 现在,当我们执行程序时,我们得到一个更有序的异常和堆栈跟踪记录:

    Error: An error has occurred!
        at Object.exports.errorFn (/workshop/async-chapter/src/error.ts:2:9)
        at asyncFn (/workshop/async-chapter/src/error.ts:7:11)
        at Object.<anonymous> (/workshop/async-chapter/src/error.ts:13:1)
        at Module._compile (internal/modules/cjs/loader.js:1138:30)
        at Module.m._compile (/workshop/node_modules/ts-node/src/index.ts:858:23)
        at Module._extensions..js (internal/modules/cjs/loader.js:1158:10)
        at Object.require.extensions.<computed> [as .ts] (/workshop/node_modules/ts-node/src/index.ts:861:12)
        at Module.load (internal/modules/cjs/loader.js:986:32)
        at Function.Module._load (internal/modules/cjs/loader.js:879:14)
        at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
    

    当然,这个消息只出现是因为我们明确地记录了它。我们也可以选择抛出一个默认值或执行其他一些操作而不是记录错误。

  5. 如果系统行为不正确,记录一个错误总是一个好主意,但根据您的系统要求,您可能更愿意写一些像这样的事情:

    const primaryFn = async () => {
      throw new Error('Primary System Offline!');
    };
    const secondaryFn = async () => {
      console.log('Aye aye!');
    };
    const asyncFn = async () => {
      try {
        await primaryFn();
      } catch (e) {
        console.warn(e);
        secondaryFn();
      }
    };
    asyncFn();
    

    在这种情况下,我们只是抛出一个警告并回退到二级系统,因为这个程序被设计成容错的。仍然记录这个警告以便我们可以追踪我们的系统行为是个好主意。现在就先这样变体一次。

  6. 让我们把我们的 trycatch 块放在顶层,并像这样重写我们的程序:

    export const errorFN = async () => {
      throw new Error('An error has occurred!');
    };
    const asyncFn = async () => {
      await errorFN();
    };
    try {
      asyncFn();
    } catch (e) {
      console.error(e);
    }
    
  7. 这是您得到的输出:

    Error: Primary System Offline!
        at primaryFn (C:\Users\Mahesh\Documents\Chapter13_TypeScript\Exercise13.06\error-secondary.ts:2:9)
        at asyncFn (C:\Users\Mahesh\Documents\Chapter13_TypeScript\Exercise13.06\error-secondary.ts:11:11)
        at Object.<anonymous> (C:\Users\Mahesh\Documents\Chapter13_TypeScript\Exercise13.06\error-secondary.ts:18:1)
        at Module._compile (internal/modules/cjs/loader.js:1063:30)
        at Module.m._compile (C:\Users\Mahesh\AppData\Roaming\npm-cache\_npx\13000\node_modules\ts-node\src\index.ts:1056:23)       
        at Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
        at Object.require.extensions.<computed> [as .ts] (C:\Users\Mahesh\AppData\Roaming\npm-cache\_npx\13000\node_modules\ts-node\src\index.ts:1059:12)
        at Module.load (internal/modules/cjs/loader.js:928:32)
        at Function.Module._load (internal/modules/cjs/loader.js:769:14)
        at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
    Aye aye!
    

    您可以假设程序可能的工作方式与在 asyncFn 内部放置 trycatch 相同,但实际上,它将表现得与没有任何错误处理一样。这是因为我们没有在 try 块内等待函数。

顶层 await

顶层 await 是一个允许在模块级别(任何函数之外)使用 await 关键字的功能。这允许一些有趣的模式,例如在尝试使用它之前,通过调用异步函数等待依赖项完全加载。总有一天,顶层 await 可能会支持一些非常令人兴奋的函数式编程范式,但在写作时,它仍然处于技术预览模式,因此尚未准备好广泛使用。您可能是在顶层 await 广泛可用和支持的时候阅读这本书,如果是这样,您绝对应该看看它!

使用顶层 await 编写代码非常简单。以下是一个非常简短的程序,它试图利用它:

export const fn = async () => {
  return 'awaited!';
};
console.log(await fn());

这看起来没问题。现在让我们看看当我们尝试执行它时会发生什么:

⨯ Unable to compile TypeScript:
src/top-level-await.ts:5:13 - error TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'esnext' or 'system', and the 'target' option is set to 'es2017' or higher.
5 console.log(await fn());
              ~~~~~

这不被支持,但它给了我一些提示。我们如何才能让它工作?

顶层 await 需要 NodeJS 14.8 或更高版本。这个版本的 NodeJS 在 2020 年 10 月进入了 LTS(长期服务)阶段,因此在写作时仍然很新。您可以在命令行中使用 node -v 检查您的 NodeJS 版本。如果您没有运行 14.8 或更高版本,有一些很好的工具如 nvmn 可以让您轻松切换版本。

然而,这并没有解决问题。看起来我需要将我的 tsconfig.jsontarget 属性更改为 es2017 或更高版本,并将 module 属性设置为 esnext。添加 module 属性意味着我想使用 ES 模块,这是一种相对较新的处理模块的方式,超出了本书的范围。为了启用 ES 模块,我需要在 package.json 文件中设置 type 属性为 module

现在,我已经更新了几个 JSON 文件,准备再次尝试:

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /workshop/async-chapter/src/top-level-await.ts
    at Loader.defaultGetFormat [as _getFormat] (internal/modules/esm/get_format.js:65:15)
    at Loader.getFormat (internal/modules/esm/loader.js:113:42)
    at Loader.getModuleJob (internal/modules/esm/loader.js:243:31)
    at Loader.import (internal/modules/esm/loader.js:177:17)

这仍然不起作用。我还需要做一件事才能让它工作,那就是在 Node.js 中启用实验性功能,并指导 TS Node 允许 ES 模块esm)。这需要更长的命令:

node --loader ts-node/esm.mjs top.ts
(node:91445) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
awaited! 

但它确实工作了。顶层 await 很可能在接下来的几个月和几年中变得更加容易和直观,所以请确保检查你运行时的最新文档。

承诺方法

除了承诺(promises)暴露的常规 nextcatch 方法之外,还有一些其他便利方法,例如 allallSettledanyrace,这些方法使得与承诺(promises)一起工作更加愉快。它们如何在 async/await 世界中使用?实际上,它们可以非常和谐地一起工作。例如,这里是一个使用 Promise.all 的例子,它使用了 thencatch。给定三个承诺,p1p2p3

Promise.all([p1, p2, p3])
  .then(values => console.log(values))
  .catch(e => console.error(e));

没有任何 awaitAll 操作符,所以如果我们想并行执行我们的承诺,我们仍然需要使用 Promise.all,但如果我们选择的话,可以避免链式使用 thencatch

try {
  const values = await Promise.all([p1, p2, p3]);
  console.log(values);
} catch (e) {
  console.error(e);
}

在这种情况下,我们可能会觉得添加 await 并没有提高代码的质量,因为我们实际上已经将代码从三行扩展到了六行。有些人可能会觉得这种形式更易于阅读。像往常一样,这取决于个人或团队的偏好。

练习 13.07:Express.js 中的 async/await

在这个练习中,我们将使用流行的 Express 框架构建一个小型 Web 应用程序。虽然 Express 是为 JavaScript 语言编写的,但已经为其发布了类型定义,因此它可以完全与 TypeScript 一起使用。Express 是一个无意见、极简主义的 Web 应用程序构建框架。它是目前使用最久和最受欢迎的框架之一。

对于我们的简单应用程序,我们将在端口 8888 上启动一个 Web 服务器并接受 GET 请求。如果该请求的查询字符串中有一个 name 参数,我们将将其记录在名为 names.txt 的文件中。然后我们将问候用户。如果没有查询字符串中的名称,我们将不记录任何内容并打印出 Hello World!

注意

这个练习的代码文件可以在以下链接找到:packt.link/cG4r8

让我们从安装 Express 框架和类型定义开始吧。

  1. 输入 npm i express 安装 Express 作为依赖项,并输入 npm i -D @types/express @types/node 安装我们需要的类型定义。

    记住 -D 标志表示这是一个 devDependency,它可以与生产依赖项不同地管理,尽管它的使用是可选的。

  2. 在安装了依赖项之后,让我们创建一个名为 express.ts 的文件。首先,我们需要导入 express,创建应用程序,添加一个简单的处理程序,并监听端口 8888

    import express, { Request, Response } from 'express';
    const app = express();
    app.get('/', (req: Request, res: Response) => {
      res.send('OK');
    });
    app.listen(8888);
    

    这看起来非常像你的标准 Express 入门应用程序,除了我们为 RequestResponse 对象提供了类型。这已经非常有用了,因为我们能够使用 IntelliSense 并确定可以调用这些对象上的哪些方法,而无需查找它们。

    我们的要求是,我们需要监听查询字符串中的 name 参数。我们可能会看到一个看起来像 http://localhost:8888/?name=Matt 的请求,对此我们应该响应 Hello Matt!

    查询字符串位于 Request 对象中。如果我们深入研究类型定义,它被标记如下:

    interface ParsedQs { [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[] }
    

    这基本上意味着它是一个键/值对的哈希表和嵌套键/值对的哈希表。在我们的情况下,我们预计会看到一个查询对象,其外观类似于 { name: 'Matt' }。因此,我们可以通过使用 const { name } = req.query; 来获取 name 属性。然后我们可以用类似 res.send(Hello ${name ?? 'World'}!); 的方式响应请求。在这种情况下,我们使用了空值合并运算符 (??) 来表示如果 name 变量具有空值(null 或 undefined),我们将回退到 World 字符串。我们也可以使用回退或逻辑 OR 运算符 ||

  3. 更新后的代码现在看起来是这样的:

    import express, { Request, Response } from 'express';
    const app = express();
    app.get('/', (req: Request, res: Response) => {
      const { name } = req.query;
      res.send(`Hello ${name ?? 'World'}!`);
    });
    app.listen(8888);
    
  4. 还有一个要求尚未满足。如果存在,我们需要将名称记录到文件中。为此,我们需要使用 Node.js 的 fs 库。我们还将使用 path 库来解析要写入的文件的路径。首先,添加新的导入:

    import { promises } from 'fs';
    import { resolve } from 'path';
    
  5. 现在,我们将使用 fspromises API 以异步方式将内容写入我们的日志文件。由于这是一个日志,我们希望每次请求时都追加到它,而不是覆盖它。我们将使用 appendFile 并写入名称以及换行符。我们希望在返回之前重复此操作:

      if (name) {
        await promises.appendFile(resolve(__dirname, 'names.txt'), `${name}\n`);
      }
    

    这几乎就完成了,但现在我们应该已经意识到我们的处理函数没有正确地使用异步。我们只需要将其添加到 async 关键字。

  6. 完成的代码如下所示:

    import express, { Request, Response } from 'express';
    import { promises } from 'fs';
    import { resolve } from 'path';
    const app = express();
    app.get('/', async (req: Request, res: Response) => {
      const { name } = req.query;
      if (name) {
        await promises.appendFile(resolve(__dirname, 'names.txt'), `${name}\n`);
      }
      res.send(`Hello ${name ?? 'World'}!`);
    });
    app.listen(8888);
    
  7. 使用 npx ts-node express.ts 运行程序,并尝试多次点击 http://localhost:8888?name=your_name 的 URL。尝试使用不同的名称点击该 URL,并观察你的日志文件递增。以下是一些示例。

  8. 以下是为 your_name 生成的浏览器输出:![图 13.1:名称为 your_name 的浏览器消息 图 B14508_13_01.jpg

    图 13.1:名称为 your_name 的浏览器消息

  9. 以下是为 Matt 生成的浏览器输出:![图 13.2:名称为 Matt 的浏览器消息 图 B14508_13_02.jpg

    图 13.2:名称为 Matt 的浏览器消息

  10. 以下是为阿尔伯特·爱因斯坦生成的浏览器输出:

![图 13.3:名称为 Albert Einstein 的浏览器消息图 B14508_13_03.jpg

图 13.3:名称为 Albert Einstein 的浏览器消息

names.txt 文件将按以下方式递增:

![图 13.4:日志文件图 B14508_13_04.jpg

图 13.4:日志文件

练习 13.08:NestJS

与 Express 相比,NestJS 是一个高度意见化且功能齐全的框架,用于构建 TypeScript 应用程序。NestJS 可以快速启动应用程序。它提供了中间件、GraphQL 和 Websockets 的开箱即用支持。它附带 ESLint、依赖注入框架、测试框架以及许多其他有用的东西。一些开发者非常喜欢使用这样一个功能齐全的框架,而另一些开发者则觉得所有样板代码都令人压抑,更愿意使用更裸骨的工具,如 Express:

注意

该练习的代码文件可以在以下位置找到:packt.link/blRq3

让我们启动一个新的 NestJS 应用程序,并对其进行更详细的了解。

  1. NestJS 应用程序可以通过 npm 生成。全局安装该包:

    npm i -g @nestjs/cli
    
  2. 当我们使用 CLI 时,它会在我们输入命令的目录内创建一个新的目录来生成项目,因此你可能想要将目录更改为你存储项目的地方。然后,生成项目:

    nest new async-nest
    

    这里项目被命名为 async-nest。你可以将其命名为其他名称。NestJS 将自动安装所有依赖项并启动一个裸骨应用。

  3. 切换到你的新应用程序目录,并开始查看代码。如果你打开 main.ts,你会看到已经使用了 async/await。该模块看起来可能像这样:

    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      await app.listen(3000);
    }
    bootstrap();
    

    NestJS 是建立在 Express 之上的。这段代码将创建一个新的 Express 应用程序。Express 的内部结构在编写 NestJS 代码时不会暴露给你,但如果你需要 NestJS 不支持的功能,你始终可以选择降级到它们。

    让我们回顾一些你可以立即开始使用的有用命令。如果你输入 npm test(或 npm t),Jest 框架将启动一个测试运行。这个测试将启动你的应用程序的一个实例,调用它,并在验证预期的响应被接收后关闭它。NestJS 随带提供了一些固定值,允许测试你的应用程序的轻量级版本。

    在你开发应用程序的过程中,继续添加单元和集成测试是一个很好的主意。TypeScript 可以帮助你确保代码的正确性,但只有测试才能保证你的应用程序按预期运行。

    另一个有用的命令是 npm run lint。这将检查你的代码风格,并通过使用流行的 ESLint 库通知你任何与之相关的问题。

  4. 最后,你可以输入 npm run start:dev 来以监视模式运行开发服务器,这意味着每次你更改文件时,服务器都会重新启动。

  5. 现在尝试运行它,并导航到 http://localhost:3000,你将看到Hello World消息。如果你打开名为 app.service.ts 的文件并更改那里返回的消息,你只需刷新浏览器,就应该能看到消息已更改。

    现在我们已经看到了在两个非常不同的框架中完成的简单的 Hello World 应用程序,让我们添加与在 练习 13.07:Express.js 中的 async/await 中所做的相同的问候和日志记录功能。

  6. 为了根据查询参数添加自定义问候语,让我们打开两个文件,app.controller.tsapp.service.ts。注意 app.service.ts 实现了一个返回字符串 "Hello World!" 的 getHello 函数。我们需要将这个函数更改为接受一个 name 参数。

  7. name 参数以 string 类型添加到函数的参数列表中,然后将返回值更改为字符串模板并说“Hello”。你将得到类似这样的结果:

    export class AppService {
      getHello(name: string): string {
        return `Hello ${name}!`;
      }
    }
    

    这是一个简单的重构。如果我们检查 app.controller.ts,我们会看到我们的 IDE 现在告诉我们 getHello 需要一个参数,我们还没有完成。

    在 Express 应用程序中,我们在内置的 Request 对象上找到了我们的查询参数。你同样可以在 NestJS 中做同样的事情,但更常见且更受欢迎的做法是使用装饰器。装饰器是特殊的函数,它们封装其他函数。它们有时被称为高阶函数,类似于 Java 等语言的一些特性。

    我们想要使用的装饰器是 @Query,它接受一个参数,即查询参数的名称,然后将该参数绑定到我们的函数参数之一。

  8. 我们可以从 @nestjs/common 中导入那个装饰器。然后我们将函数参数添加到 getHello 中,并将其传递给服务调用。还有一件事值得注意,那就是设置一个默认值,这样我们就可以保持向后兼容性,并且在我们未能提供参数时不会打印出 Hello undefined。添加默认值可能会提示你不再需要类型注解,因为它可以从默认类型中轻易推断出来。如果你喜欢,可以将其删除:

    import { Controller, Get, Query } from '@nestjs/common';
    import { AppService } from './app.service';
    @Controller()
    export class AppController {
      constructor(private readonly appService: AppService) {}
      @Get()
      getHello(@Query('name') name: string = 'World'): string {
        return this.appService.getHello(name);
      }
    }
    
  9. 开发服务器应该会重新启动,现在,如果我们浏览到 http://localhost:3000/?name=Matt,我们会看到 Hello Matt!:![图 13.5:name = Matt 的浏览器消息

    ![图片 B14508_13_05.jpg]

    ![图 13.5:name = Matt 的浏览器消息

  10. 现在,让我们添加我们在 Express 中实现的相同日志功能。

    在一个完整规模的应用程序中,我们可能想要构建一个单独的日志服务类。为了我们的目的,我们可以将其实现为一个单独的 async 方法。将方法添加到 app.service.ts 中,并从 getHello 中使用 await 调用它。测试以确保它正确工作。

    这里有几个需要注意的问题。一个是 NestJS 会自动将代码从名为 dist 的文件夹中转换并提供服务,所以一旦你开始记录名称,你会在那里找到你的 names.txt 文件。但更大的技巧是,为了等待记录,我们需要将 app.service.ts 中的 getHello 方法改为 async 方法。这反过来又意味着 app.controller.ts 中的 getHello 也必须是 async。将这些方法改为 async 会对我们应用程序产生什么影响?没有!NestJS 已经知道如何在返回请求之前解决承诺。

  11. 在这个练习中,还有一件事需要检查,那就是单元测试。由于我们已为 name 属性设置了一个默认值,测试应该仍然有效,对吧?实际上,它不起作用。尝试运行 npm test,你会看到问题。问题是测试没有期望 getHello 是异步的。没关系。我们可以通过使测试回调 async 来修复它,如下所示:

      describe('root', () => {
        it('should return "Hello World!"', async () => {
          expect(await appController.getHello()).toBe('Hello World!');
        });
      });
    

    测试现在应该通过。尝试添加另一个带有参数的测试。

练习 13.09:TypeORM

TypeORM 是一个用 TypeScript 编写并针对 TypeScript 设计的对象关系映射器。TypeORM 支持许多流行的数据库,例如 MySQL、Postgres、SQL Server、SQLite,甚至 MongoDB 和 Oracle。TypeORM 经常用于 NestJS 应用程序中,因此在这个练习中,我们将添加一个本地的内存 SQLite 数据库来与我们的 NestJS 应用程序一起工作。

在这个练习中,你将构建另一个 REST 服务,帮助我们跟踪我们做出的承诺。由于 Promise 是 TypeScript 中内置对象的名称,让我们使用“pledge”这个术语,这样我们就可以区分领域概念和语言抽象:

注意

这个练习的代码文件可以在以下位置找到:packt.link/ZywYh

  1. 要开始,让我们启动一个新的 NestJS 项目:

    nest new typeorm-nest
    
  2. NestJS 有一个强大的模块系统,允许我们将应用程序的不同功能区域构建成连贯的块。让我们为承诺创建一个新的模块:

    nest g module pledge
    

    这个命令将在 /pledge 子目录下生成一个新的模块。

  3. 我们还需要为承诺 API 创建一个控制器和服务,所以让我们使用 NestJS CLI 生成它们:

    nest g controller pledge
    nest g service pledge
    
  4. 最后,我们需要安装 typeorm 库、SQLite3 和 NestJS 集成:

    npm i @nestjs/typeorm sqlite3 typeorm
    

    TypeORM 通过在普通对象上的装饰器将数据库表映射到 TypeScript 实体。

  5. 让我们在 /pledge 下创建 pledge.entity.ts 并创建我们的第一个实体:

    import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
    @Entity()
    export class Pledge {
      @PrimaryGeneratedColumn()
      id: number;
      @Column()
      desc: string;
      @Column()
      kept: boolean;
    }
    

    对于这个实体,我们使用了几个专门的装饰器,例如 PrimaryGeneratedColumn。这些装饰器可能非常强大,但通常依赖于底层数据库功能。因为 SQLite 可以为我们生成表 ID,TypeORM 能够通过装饰器以声明式的方式暴露这一点,但如果它不能这样做,那么这就不起作用了。在开始新的实现之前检查文档总是好的。

    现在我们有一个实体,我们需要向 TypeORM 提供配置,说明我们的数据库是什么以及在哪里可以找到它,以及我们想要映射哪些实体。对于 MySQL 和 Postgres 等数据库,这可能包括 URI 以及数据库凭据。由于 SQLite 是基于文件的数据库,我们只需提供我们想要写入的文件名。

    注意,生产数据库凭据应始终安全处理,而这样做最佳实践超出了本书的范围,但可以说,它们不应该被提交到版本控制中。

  6. 让我们配置我们的应用程序以使用 SQLite。我们希望在应用程序的根目录下配置 TypeORM,所以让我们将模块导入到 app.module.ts

        TypeOrmModule.forRoot({
          type: 'sqlite',
          database: 'db',
          entities: [Pledge],
          synchronize: true,
        }),
    
  7. 做这件事需要在模块顶部进行几个额外的导入:

    import { TypeOrmModule } from '@nestjs/typeorm';
    import { Pledge } from './pledge/pledge.entity';
    

    我们让 NestJS 知道我们的应用程序将使用 SQLite 数据库并管理 Pledge 实体。通过设置 synchronize: true,我们告诉 TypeORM 在应用程序启动时自动创建数据库中尚不存在的任何实体。这个设置不应该在生产环境中使用,因为它可能会导致数据丢失。TypeORM 支持迁移,用于在生产环境中管理数据库,这是本书范围之外的另一个主题。

  8. 如果我们现在使用 npm run start:dev 启动应用程序,它将启动,我们会得到一个名为 db 的新二进制文件(SQLite 数据库)。

  9. 在我们可以在新模块中使用 Pledge 实体之前,我们需要做一些额外的样板代码。打开 pledge.module.ts 并添加一个导入,使模块看起来像这样:

    import { Module } from '@nestjs/common';
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { PledgeController } from './pledge.controller';
    import { Pledge } from './pledge.entity';
    import { PledgeService } from './pledge.service';
    @Module({
      controllers: [PledgeController],
      imports: [TypeOrmModule.forFeature([Pledge])],
      providers: [PledgeService],
    })
    export class PledgeModule {}
    

    这将允许 Pledge 实体被 pledge.service.ts 使用。再次强调,NestJS 有很多样板代码,这可能会让习惯于无意见的 ExpressJS 工作流程的开发者感到不适。这个模块系统可以帮助我们将应用程序隔离到功能区域。在决定是否将 NestJS 这样的框架用于您的应用程序或团队之前,了解结构化应用程序的好处是一个好主意。

    我们现在可以开始构建我们的 Pledge 服务了。TypeORM 支持两种模式:Active Record,其中实体本身具有读取和更新的方法,以及 Data Mapper,其中此类功能委托给 Repository 对象。在这个练习中,我们将遵循 Data Mapper 模式。

  10. 首先,我们将在 Pledge 服务中添加一个构造函数并注入存储库,将其暴露为类的私有成员。一旦我们这样做,我们就可以开始访问一些存储库方法了:

    import { Injectable } from '@nestjs/common';
    import { Pledge } from './pledge.entity';
    import { InjectRepository } from '@nestjs/typeorm';
    import { Repository } from 'typeorm';
    @Injectable()
    export class PledgeService {
      constructor(
        @InjectRepository(Pledge)
        private pledgeRepository: Repository<Pledge>,
      ) {}
      findAll(): Promise<Pledge[]> {
        return this.pledgeRepository.find();
      }
    }
    

    我们现在公开了一个 findAll 方法,它将查询数据库中的所有 Pledge 实体并将它们作为一个数组返回。

  11. 在生产应用程序中,实现分页通常是一个好主意,但这对我们的目的来说已经足够了。让我们实现一些其他方法:

    import { Injectable } from '@nestjs/common';
    import { InjectRepository } from '@nestjs/typeorm';
    import { DeleteResult, Repository } from 'typeorm';
    import { Pledge } from './pledge.entity';
    @Injectable()
    export class PledgeService {
      constructor(
        @InjectRepository(Pledge)
        private pledgeRepository: Repository<Pledge>,
      ) {}
      delete(id: number): Promise<DeleteResult> {
        return this.pledgeRepository.delete(id);
      }
      findAll(): Promise<Pledge[]> {
        return this.pledgeRepository.find();
      }
      findOne(id: number): Promise<Pledge> {
        return this.pledgeRepository.findOne(id);
      }
      save(pledge: Pledge): Promise<Pledge> {
        return this.pledgeRepository.save(pledge);
      }
    }
    

    我们可以使用存储库方法走得很远,这些方法会为我们生成 SQL 查询,但也可以使用 TypeORM 的 SQL 或查询构建器。

  12. 在服务中实现这些方法不会将它们暴露给我们的 API,因此我们需要在 pledge.controller.ts 中添加匹配的控制器方法。每个控制器方法将委托给服务方法,NestJS 将负责将这些部分粘合在一起:

    import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
    import { DeleteResult } from 'typeorm';
    import { Pledge } from './pledge.entity';
    import { PledgeService } from './pledge.service';
    @Controller('pledge')
    export class PledgeController {
      constructor(private readonly pledgeService: PledgeService) {}
      @Delete(':id')
      deletePledge(@Param('id') id: number): Promise<DeleteResult> {
        return this.pledgeService.delete(id);
      }
      @Get()
      getPledges(): Promise<Pledge[]> {
        return this.pledgeService.findAll();
      }
      @Get(':id')
      getPledge(@Param('id') id: number): Promise<Pledge> {
        return this.pledgeService.findOne(id);
      }
      @Post()
      savePledge(@Body() pledge: Pledge): Promise<Pledge> {
        return this.pledgeService.save(pledge);
      }
    }
    

    这个控制器将自动注入服务,然后可以使用装饰器和依赖注入轻松地将服务方法映射到 API 端点。

  13. 由于我们使用 npm run start:dev 运行应用程序,它应该会通过所有这些更改进行热重载。

  14. 检查控制台,确保没有错误。如果我们的代码是正确的,我们可以使用 Postman 这样的 REST 客户端开始向我们的服务发送请求。如果我们向 http://localhost:3000/pledge 发送包含如下负载的 POST 请求:{"desc":"Always lint your code", "kept": true},我们将得到一个 201 Created 的 HTTP 响应。然后我们可以发出 GET 请求到 http://localhost:3000/pledgehttp://localhost:3000/pledge/1,以查看存储在 SQLite 中的记录。

在这个练习中,我们使用了 NestJS 和 TypeORM 来构建一个真实的网络 API,该 API 可以从 SQLite 数据库中创建和检索记录。这样做与使用像 MySQL 或 PostgreSQL 这样的真实生产级数据库并没有太大的区别。

活动 13.01:将链式承诺重构为使用 await

在这个活动中,我们将重构一个将承诺链在一起的函数,以使用 await。你将得到一个入门程序,旨在模拟为网站创建 DOM 元素并依次渲染它们。在现实中,大多数网站都希望并行渲染,但可能一个组件的信息可能会影响另一个组件的渲染。在任何情况下,这对于示例来说都是足够的:

注意

这个活动的代码文件可以在以下链接找到:packt.link/L5r76

  1. 首先,使用 npx ts-node src/refactor.ts 运行程序。你会按顺序得到每条消息。

  2. 现在,将 renderAll 函数重构为使用 async/await。你不需要修改代码的其他部分来实现这一点。当你完成重构后,再次运行程序并验证输出是否已更改。

入门程序的代码(refactor.ts)如下:

export class El {
  constructor(private name: string) {}
  render = () => {
    return new Promise((resolve) =>
      setTimeout(
        () => resolve(`${this.name} is resolved`),
        Math.random() * 1000
      )
    );
  };
}
const e1 = new El('header');
const e2 = new El('body');
const e3 = new El('footer');
const renderAll = () => {
  e1.render().then((msg1) => {
    console.log(msg1);
    e2.render().then((msg2) => {
      console.log(msg2);
      e3.render().then((msg3) => {
        console.log(msg3);
      });
    });
  });
};
renderAll();

运行程序后,你应该得到以下输出:

header is resolved
body is resolved
footer is resolved

注意

这个活动的解决方案可以通过这个链接找到。

摘要

在过去的 10 年里,异步编程已经取得了长足的进步,而 async/await 的引入继续推动其发展。尽管它并不适合每个用例,但这种语法糖在 TypeScript 社区中非常受欢迎,并在流行的库和框架中得到了广泛接受。

在本章中,我们介绍了 async/await 语法,它是如何成为语言的一部分,以及这种语法的使用实际上是如何与承诺(promises)相辅相成的。然后,我们游览了几个 TypeScript 开发者常用的流行框架,以了解应用开发者如何使用承诺和异步编程来开发强大的 Web 应用程序。

这本书对语言特性的研究到此结束。下一章将探讨使用 TypeScript 构建用户界面的 React。

第十五章:14. TypeScript 和 React

概述

在本章中,我们将介绍 React 库以及如何使用 TypeScript 构建增强的用户界面。我们将探讨 React 应用程序的状态管理解决方案和样式解决方案。然后,我们将使用无服务器后端 Firebase 来构建一个 Hacker News 风格的应用程序。到本章结束时,您将能够使用 Create React App 命令行界面启动 React 应用程序。

简介

React 是网络和移动用户界面开发中的主导力量。尽管它自称是“用于构建用户界面的 JavaScript 库”,但我们通常所认为的 React 不仅限于核心库,还包括一个广泛的插件、组件和其他工具的生态系统。许多开发者选择专注于 React,并且它是代码学院的热门话题。与 Angular 不同,React 并非专为使用 TypeScript 而开发,实际上还有一些其他的一些开发者与 React 一起使用的类型系统。然而,React 和 TypeScript 的流行使得将两者结合在一起变得不可避免,使用 TypeScript 编写 React 已经成为用户界面开发的标准方法。

React 是由 Facebook 内部开发用于自身使用的,并于 2013 年开源。与一些功能更全面的框架相比,React 始终将自己定位为一个视图库,并且它依赖于其他库来实现必要的功能,例如状态管理、路由和 Web 请求。

React 使用声明式、基于组件的方法。开发者构建代表不同 UI 元素的组件。这些组件通常是可重用的,并且可以以不同的方式组装来构建 Web 视图。组件可以由其他组件组成,并且每个单独的组件应该相对简单。以小型、可重用组件为前提思考有助于 React 开发者编写干净、可维护的代码,并遵循 不要重复自己DRY)原则。

为 React 类型化

在 TypeScript 的流行度急剧上升之前,React 程序员要么没有使用任何类型的系统,要么使用 Flow 或 PropTypes 这样的库。

Flow 是 Facebook 开发的另一个库,旨在为 JavaScript 添加类型。它具有与 TypeScript 类似的目标,但采取了不同的途径来实现。Flow 不是一个 JavaScript 的超集,它使用由语言服务器检查的注释和类型注解,然后由 Babel 这样的转换器移除。由于这两个库都是由 Facebook 开发的,因此通常将它们一起使用,但随着 TypeScript 成为网络开发者的首选类型系统,Flow 的流行度已经下降。

PropTypes 是另一个用于强制类型检查的库。在这种情况下,该库专门用于与 React 一起使用,并且其焦点是检查 React 的“props”,即与组件一起传递的参数。

React 中的 TypeScript

虽然在技术上可以使用这些库与 TypeScript 一起使用,但这并不是一个好主意,因为它们本质上都在试图解决相同的问题。如果你正在使用 TypeScript,最好避免使用 Flow 和 PropTypes。

TypeScript 为 React 程序员提供了许多好处。我们可以通过使用接口来为 props 类型化,从而实现与 PropTypes 库相同的目标,并且我们还能获得完整的 IntelliSense 体验,这将让我们更深入地了解组件及其生命周期,甚至可以阅读开发者的注释、弃用通知等等。

TypeScript 将有助于确保我们组件的正确使用,并给我们提供那种使开发变得容易的早期反馈循环。

嗨,React

仅关于 React 的书籍就有很多。这本书关于 TypeScript 的这一章节无法涵盖与 React 相关的所有主题。对于那些已经不熟悉 React 但希望专业地使用 React 的读者,应该寻求本书以外的资源。尽管如此,为了简要概述 React 的工作原理,组件是用某种编译到 JavaScript 的语言编写的,例如 TypeScript、ReasonML,甚至是 JavaScript。编译后的脚本将被嵌入到网页中,连接到页面上的一个元素,例如 div

import React from 'react';
import ReactDOM from 'react-dom';
export interface HelloProps {
  name: string;
}
class HelloComponent extends React.Component<HelloProps, {}> {
  render() {
    return <div>Hello {this.props.name}</div>;
  }
}
ReactDOM.render(
  <HelloTypeScript name="Matt" />,
  document.getElementById('root')
);

此脚本将被加载到一个具有 root ID 的元素所在的页面上,然后会打印出 Hello Matt。React 应用程序的结构有很多种不同的方式。通常,它们将由许多组件组成,每个组件都放在一个单独的文件中。

React 通过在内存中保留一份 文档对象模型DOM),即将 JavaScript 代码转换为渲染的浏览器页面的对象树,来工作。这个虚拟 DOM 会频繁更新,并将更改有选择地应用到实际网页的渲染中。虚拟 DOM 允许进行性能优化,并设计用来防止缓慢的渲染或低效的重渲染。

组件

继承模式自 JavaScript 诞生以来就存在了,最初是以原型继承的形式,然后是 ES2015 以来的类语法。一些编程范式建议利用继承作为构建复杂应用程序的主要工具。例如,如果我们正在构建一个包含宠物小猫个人主页的网站,你可能会考虑设置一个继承链,如KittenProfilePage extends FelineProfilePage extends PetProfilePage extends ProfilePage extends Page。确实,一些 UI 框架试图实现类似这样的模型。然而,在实践中,这种思维方式很快就会暴露出过于僵化、难以适应变化的需求,并迫使你进入奇怪的模式。例如,如果我们已经在FelineProfilePage中实现了whiskerCount,我们现在正在实现RodentProfilePage,我们是复制粘贴吗?RodentProfilePage是否从FelineProfilePage继承?我们应该在我们的模型中引入WhiskeredPetProfilePage到链中以共享whiskerCount吗?

这并不是说现代 Web 框架和库不使用继承。它们确实使用了!但通常,我们是从库提供的通用基组件继承,并且我们的继承链非常短。我们不是关注继承,而是关注组合。组合是从许多可重用组件中构建,其中大多数具有更通用目的的实践。这并不意味着每个组件都必须使用多次,但它们是以一种可以重复使用的方式构建的。

这种方法被 React 完全接受。任何 React 应用程序的基本构建块是组件。React 组件有几种类别。

有状态组件

有状态组件跟踪它们自己的状态。考虑一个跟踪是否打开并相应渲染的下拉菜单。有状态组件可能使用this关键字或包含其他变量以保持状态。在 React 中,有状态组件可以使用setState方法。有状态组件的状态可以在生命周期事件中设置。

通常,关于组件应该如何显示的信息可以保存在该组件内部。然而,更复杂的数据,如用户个人资料,通常需要一个超出组件范围的状态管理解决方案。请参阅本章后面的React 中的状态管理

无状态组件

无状态组件从不使用this关键字或调用setState。它们可能根据传入的属性重新渲染,但不会跟踪任何数据。所有正常的生命周期方法都是可用的,无状态组件的声明方式与有状态组件相同,只是没有任何可能改变状态的内容。

如果我们决定在中央位置管理该状态,下拉或手风琴组件甚至可以是状态化的。我们通常不会为简单组件这样做,但可能会有某些原因,例如展开/折叠全部功能。

纯组件

纯组件是 React 的一个特殊优化。在如何使用它们方面,它们与无状态组件非常相似,但它们的声明方式不同(通过扩展 PureComponent)。纯组件只有在它们的 state 或 props 发生变化时才会重新渲染。这与大多数组件不同,这些组件在父组件重新渲染时也会重新渲染。

尝试使用纯组件是个好主意。它们可以显著加快 React 应用的渲染速度,但可能会给不习惯与它们一起工作的开发者带来一些意外的行为。

高阶组件

高阶组件HOCs)不是一个库结构,而是一种将一个组件包裹在另一个组件中而不修改被包裹组件的模式。一个 HOC 的好例子是要求用户在交互我们的组件之前进行认证。

考虑一个只有单个登录页面和 99 页敏感信息的网站。按照组合模型,我们该如何实现呢?我们不希望将我们的认证细节注入到我们构建的每个组件中。这样做会显得杂乱无章且不切实际。我们不想不得不将每个渲染都包裹在 isUserAuthenticated 中。很容易遗漏一个。解决这个问题的一个更好的方案是使用 HOC。现在我们的组件可以独立于我们的认证模型来编写。

HOCs 经常被描述为 PureComponent 是 React 库的实际一部分。

HOCs 是组合优于继承概念的一个很好的例子。回到认证的例子,继承模型可能会让我们构建继承自 RequiresAuthenticationComponent 的组件,这是一个内置了我们的认证模型的基组件。然而,使用组合,我们可以独立于我们的认证系统构建我们的组件,然后在他们周围应用 HOC。许多程序员会认为这是一种更好的关注点分离。

JSX 和 TSX

JSX 是 Facebook 的另一项创新。它指的是增强 XML 的 JavaScript,实际上它是在其中嵌入 HTML 模板的 JavaScript。以下是其使用的一个示例:

render() {
  return <div>Hello {this.props.name}</div>;
}

这是一个返回 HTML 模板的函数。我们必须使用 JSX 来做这件事。通常,这会导致语法错误,因为这既不是引号字符串,也不是 TypeScript 中可识别的对象或语法。JSX 允许我们将 HTML 模板与我们的代码混合。一些早期的视图库会使用一个文件用于源代码,另一个用于模板。这对程序员来说通常很令人困惑,因为他们需要在两个文件之间来回切换。

有可能在不使用 JSX 的情况下编写 React,但这很少见,本书也不会涉及这一点。其他一些语言,如 Vue,也使用 JSX。

当我们想在 JSX 中编写 TypeScript 时,我们使用 .tsx 文件扩展名而不是 .jsx。技术上,这仍然是 JSX。为了在 JSX 中包含 TypeScript,我们只需要相应地设置文件扩展名,并在我们的 tsconfig.json 文件中将 jsx 属性设置为让 TypeScript 知道我们正在使用 JSX。该属性的合法值是 reactreact-nativepreserve。前两个用于针对网络浏览器或移动应用程序,最后一个意味着其他转换步骤将处理 JSX。

JSX 不是 JavaScript 或 TypeScript 语言的一部分,而是一种需要被转换的语言扩展。你无法在大多数网络浏览器中运行 JSX。

练习 14.01:使用 Create React App 搭建

Create React App (create-react-app) 是来自 Facebook 的一个库,它帮助开发者快速搭建新的 React 应用程序。它包含一个名为 react-scripts 的库,该库帮助抽象化了许多已成为网络开发标准的工具,如代码检查器、测试框架和打包器(webpack)。所有这些依赖项都由 Create React App 和 react-scripts 管理。

在这个练习中,我们将使用 Create React App 搭建一个新的 React 应用程序。我们将运行应用程序,检查开发者体验,进行一些小的编辑,然后看到组件重新加载。我们将查看生产构建,并了解它与开发构建的不同之处。然后我们将检查内置的测试:

注意

这个练习的代码可以在以下链接找到:packt.link/hMs3v

  1. Create React App 自带了一些选项,并且自 2018 年以来就包含了 TypeScript 选项。创建新应用程序非常简单。我们甚至不需要安装任何东西,只需使用 npx 运行最新版本的 Create React App 并启动应用程序。进入命令行,找到你想要创建应用程序的目录,并输入以下命令:

    npx create-react-app my-app --template typescript
    
  2. Create React App 将从互联网上下载并设置你的应用程序,然后安装依赖项。如果你已经安装了 yarn 包管理器(也来自 Facebook),Create React App 将使用 yarn,否则它将使用 npm。对于本书的目的,你使用哪一个没有太大区别,因为它们提供了相同的功能。如果你安装了旧的 yarn 版本,你可能需要更新它(npm i -g yarn)。如果你不希望使用 yarn,所有这些练习在没有它的前提下也应该能正常工作:

    npx create-react-app my-app --template typescript
    npx: installed 67 in 4.26s
    Creating a new React app in /Users/mattmorgan/mine/The-TypeScript-Workshop/Chapter14/Exercise01/my-app. 
    Installing packages. This might take a couple of minutes.
    Installing react, react-dom, and react-scripts with cra-template-typescript...
    yarn add v1.22.10
    [1/4] 🔍  Resolving packages...
    [2/4] 🚚  Fetching packages...
    // […]
    warning " > @testing-library/user-event@12.6.2" has unmet peer dependency "@testing-library/dom@>=7.21.4".
    success Uninstalled packages.
    ✨  Done in 10.28s.
    Success! Created my-app at /Users/mattmorgan/mine/The-TypeScript-Workshop/Chapter15/Exercise15.01/my-app
    Inside that directory, you can run several commands:
      yarn start - Starts the development server.
      yarn build - Bundles the app into static files for production.
      yarn test -    Starts the test runner.
      yarn eject    Removes this tool and copies build dependencies, configuration files and scripts into the app directory. If you do this, you can't go back!
    We suggest that you begin by typing:
      cd my-app
      yarn start
    Happy hacking!
    

    注意

    为了便于展示,这里只显示了输出的一部分。

  3. npx create-react-app 的输出将告诉你下一步该做什么。进入创建的目录,并输入 yarn startnpm start。你的应用程序将自动在一个浏览器窗口中打开:

    cd my-app
    yarn start
    

    你将看到以下输出:

    Compiled successfully! 
    You can now view my-app in the browser.
    Local:            http://localhost:3000
    On Your Network:  http://192.168.7.92:3000
    Note that the development build is not optimized.
    To create a production build, use yarn build.
    
  4. 如果你导航到 http://localhost:3000,你会看到以下内容:![图 14.1:浏览器中的 my-app 截图 图片

    图 14.1:浏览器中的 my-app 截图

  5. 检查在你最喜欢的集成开发环境(IDE)中生成的源代码。你可以找到一个名为 index.tsx 的文件,它将 React 应用程序附加到一个 dom 节点,以及一个 App.tsx 文件,这是你应用程序到目前为止的主组件。尝试添加一条新消息或创建一些新的组件,如下所示:![图 14.2:添加 App.tsx 后的 my-app 截图 图片

    图 14.2:添加 App.tsx 后的 my-app 截图

  6. 当你输入 npm start 时,你的应用程序将以开发模式运行,带有热重载(这意味着当你做出更改时页面会刷新)。对于在生产环境中运行,这显然是不必要的。你可以通过运行 yarn buildnpm run build 来查看生产构建的样子。你会看到一些输出,告诉你确切发生了什么,并且转译后的 JavaScript 将被放在 build 目录中。打开该目录并查看那里的文件。这就是生产 React 应用程序的样子。

  7. 使用 Ctrl + C 停止你的本地服务器,然后尝试 yarn buildnpm run build 来运行生产构建。

  8. 生产 React 应用程序通常运行在静态服务器上,但它们也可以运行在 Web 服务器上。React 中的服务器端渲染概念超出了本书的范围,但这也是你可能感兴趣的一个主题。你的构建应该生成一个缩短的 URL,该 URL 将带你到一个包含更多关于将 React 应用程序部署到生产环境的信息的文章:

    yarn build
    yarn run v1.22.10
    react-scripts build
    Creating an optimized production build...
    Compiled successfully.
    

    gzip 压缩后的文件大小:

      41.2 KB  build/static/js/2.311d60e9.chunk.js
      1.39 KB  build/static/js/3.73a1c5a5.chunk.js
      1.17 KB  build/static/js/runtime-main.f12bc2d0.js
      615 B    build/static/js/main.fe0fc6c6.chunk.js
      531 B    build/static/css/main.8c8b27cf.chunk.css
    

    项目假设托管在 /。你可以通过 package.json 中的 homepage 字段来控制这一点。

  9. build 文件夹已准备好部署。你可以使用静态服务器来提供服务:

    yarn global add serve
    serve -s build
    Find out more about deployment here:https://cra.link/deployment
    ✨  Done in 7.88s.
    
  10. 输入 yarn testnpm t(即 npm test)。Jest 框架将对你的应用程序运行一个测试。这个测试非常简单,但可以让你开始编写更多的测试。为你的组件编写测试是一个好主意,因为测试会给你信心,确保你的应用程序正在工作。编写可测试的代码可以培养良好的编程习惯:

    PASS  src/App.test.tsx
      ✓ renders learn react link (23 ms)
    Test Suites: 1 passed, 1 total
    Tests:       1 passed, 1 total
    Snapshots:   0 total
    Time:        2.295 s
    Ran all test suites related to changed files.
    Watch Usage
     › Press a to run all tests.
     › Press f to run only failed tests.
     › Press q to quit watch mode.
     › Press p to filter by a filename regex pattern.
     › Press t to filter by a test name regex pattern.
     › Press Enter to trigger a test run.
    

    有了这些,我们已经涵盖了 Create React App 的基础知识。我们学习了如何快速启动一个新应用程序,了解了带有热重载的开发者体验,以及如何运行生产构建和测试。

虽然 Create React App 给你提供了很多东西,但实际上这只是我们将在接下来的章节中看到的内容的冰山一角。例如,我们的应用程序没有处理不同类型请求或不同页面的方法。我们没有路由。我们也没有存储数据的地方,也没有与任何后端交互的方式。我们将在接下来的章节中深入探讨这些概念。

路由

React 默认不包含路由解决方案。这是因为它的核心是一个视图库。一些应用程序可能不需要路由,但大多数至少会想要渲染多个页面的能力。一些应用程序可能有复杂的路由需求,可能涉及“深度链接”或直接链接到特定文档。URL 中的请求或查询变量可能包含一些与特定用户记录相关联的标识符。

虽然有一些替代方案,但大多数使用路由的 React 应用程序都使用 React-Router,这是官方的 Facebook 解决方案。

练习 14.02:React Router

在这个练习中,我们将使用 Create React App 启动另一个应用程序,然后使用 React Router 来增强它,以便能够支持多个视图并在它们之间进行导航:

注意

这个练习的代码可以在以下链接找到:packt.link/EYBcF

  1. 首先,转到可以创建另一个应用程序的命令行:

    npx create-react-app router-app --template typescript
    cd router-app
    
  2. 要添加 React Router,让我们安装库和类型定义。如果你不使用 yarn,则可以将 yarn add 命令替换为 npm install

    yarn add react-router-dom
    yarn add -D @types/react-router-dom
    % yarn add react-router-dom
    yarn add v1.22.10
    [1/4] 🔍  Resolving packages...
    [2/4] 🚚  Fetching packages...
    [3/4] 🔗  Linking dependencies...
    warning " > @testing-library/user-event@12.6.2" has unmet peer dependency "@testing-library/dom@>=7.21.4".
    [4/4] 🔨  Building fresh packages...
    success Saved lockfile.
    success Saved 8 new dependencies.
    info Direct dependencies
    └─ react-router-dom@5.2.0
    info All dependencies
    ├─ hoist-non-react-statics@3.3.2
    ├─ mini-create-react-context@0.4.1
    ├─ path-to-regexp@1.8.0
    ├─ react-router-dom@5.2.0
    ├─ react-router@5.2.0
    ├─ resolve-pathname@3.0.0
    ├─ tiny-warning@1.0.3
    └─ value-equal@1.0.1
    ✨  Done in 4.86s.
    % yarn add -D @types/react-router-dom
    yarn add v1.22.10
    [1/4] 🔍  Resolving packages...
    [2/4] 🚚  Fetching packages...
    [3/4] 🔗  Linking dependencies...
    warning " > @testing-library/user-event@12.6.2" has unmet peer dependency "@testing-library/dom@>=7.21.4".
    [4/4] 🔨  Building fresh packages...
    success Saved lockfile.
    success Saved 2 new dependencies.
    info Direct dependencies
    └─ @types/react-router-dom@5.1.7
    info All dependencies
    ├─ @types/react-router-dom@5.1.7
    └─ @types/react-router@5.1.11
    ✨  Done in 4.59s.
    

    现在,我们可以使用 yarn startnpm start 启动应用程序。当我们添加这些路由并编辑文件时,我们的应用程序将自动重新启动,这为开发者提供了一个很好的体验。

    我们可以开始添加路由器,但我们目前没有任何路由目标,所以让我们先添加一些新的组件。由于组件是 React 应用程序的构建块,一个组件可以是一个页面。相同的组件也可以是另一个页面的组成部分。

  3. 让我们在应用程序中创建一个 /src/pages 子目录来存放新的页面组件。在 pages 子目录中,创建 Add.tsxHome.tsxSignIn.tsxSignup.tsx

    首先,我们将创建一些非常简单的组件以在它们之间进行路由。在本章的后续部分,我们将讨论函数组件的创建。

  4. 使用以下代码创建 Add.tsx:

    import React from 'react';
    const Add = () => <div>Add a new story</div>;
    export default Add;
    
  5. 使用以下代码创建 Home.tsx:

    import React from 'react';
    const Home = () => <div>You are home!</div>;
    export default Home;
    
  6. 使用以下代码创建 SignIn.tsx:

    import React from 'react';
    const SignIn = () => <div>Sign in here</div>;
    export default SignIn;
    
  7. 使用以下代码创建 SignUp.tsx:

    import React from 'react';
    const SignUp = () => <div>Sign up here</div>;
    export default SignUp;
    

    这些基本组件只返回一些 JSX,但它们足以进行路由。请注意,如果没有路由器,我们可以在我们的主 App.tsx 中包含这些组件,但在传统网络应用程序的意义上,我们无法在页面之间进行导航。这是路由器的责任。

  8. 因此,到目前为止,我们有一些我们尚不能与之交互的组件。让我们给我们的 App.tsx 添加路由。

    React Router 公开了几种不同的路由类型,它们大多有特定的用途。我们将关注 BrowserRouter。为了开始,我们将向 App.tsx 添加一些导入:

    import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
    

    按照惯例,我们在导入时将 BrowserRouter 重命名为 Router。我们还将使用 Switch,它为我们提供了一个基于路由在不同组件之间切换的声明式方法,以及 Route,它允许我们定义组件路由。

    添加第一个(默认)路由非常简单。在这样做之前,请确保您的本地开发环境正在使用 npm start 运行。您应该在运行在 http://localhost:3000 的浏览器中看到一个旋转的 React 标志。

  9. 现在让我们使用其他组件来构建第一个路由。我们将移除 App.tsx 组件当前返回的所有 JSX,并用路由替换它:

    function App() {
      return (
        <Router>
          <Switch>
            <Route exact path="/" component={Home} />
          </Switch>
        </Router>
      );
    }
    

    您需要导入 Home 组件:

    import Home from './pages/Home';
    

    您的 IDE 可能会在您输入时提示您自动导入 Home

  10. 如果一切设置正确,您的视图将刷新,您会看到 React 标志被替换为 您已到家!

    让我们添加一些额外的路由:

    <Route path="/add" component={Add} />
    <Route path="/signin" component={SignIn} />
    <Route path="/signup" component={SignUp} />
    
  11. 我们的 Home 路由设置了 exact 属性。React 中的路由使用正则表达式从路径的最左侧部分开始匹配路径。这允许匹配变量查询和路由参数。exact 属性强制进行精确匹配,并确保 "/add" 不会匹配到 "/"

  12. 现在我们可以测试路由了。在浏览器中输入 http://localhost:3000/add。你应该会看到消息 添加一个新故事。尝试访问其他路由。

  13. 当然,期望用户手动在浏览器中输入所有 URL 来导航您的网站并不自然。让我们添加一些链接。我们可以从 react-router 中导入 Link。这个组件将创建连接到您的应用程序路由的导航链接。因此,Link 必须始终在 Router 内部使用。

    Link 包裹一些文本,并有一个 to 属性,该属性应该有您想要链接到的路由:

    <Link to="/">home</Link>
    

    这样一来,添加一些导航元素就非常简单了:

    <nav>
      <ul>
        <li>
          <Link to="/">home</Link>
        </li>
        <li>
          <Link to="add">add</Link>
        </li>
        <li>
          <Link to="signin">signin</Link>
        </li>
        <li>
          <Link to="signup">signup</Link>
        </li>
      </ul>
    </nav>
    

    这应该会给我们一种在页面之间移动的好方法。然而,将大量额外的 JSX 粘贴到 App.tsx 中并不是编写 React 的好方法,所以让我们编写一个 NavBar 组件。

  14. src 下添加一个 components 目录。我们将使用这个目录来存放与路由无关的组件:

    import React from 'react';
    import { Link } from 'react-router-dom';
    const NavBar = () => (
      <nav>
        <ul>
          <li>
            <Link to="/">home</Link>
          </li>
          <li>
            <Link to="add">add</Link>
          </li>
          <li>
            <Link to="signin">signin</Link>
          </li>
          <li>
            <Link to="signup">signup</Link>
          </li>
        </ul>
      </nav>
    );
    export default NavBar;
    
  15. 现在,我们可以在 App.tsx 中简单地使用这个组件。这是完成的组件:

    import './App.css';
    import React from 'react';
    import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
    import NavBar from './components/NavBar';
    import Add from './pages/Add';
    import Home from './pages/Home';
    import SignIn from './pages/SignIn';
    import SignUp from './pages/SignUp';
    function App() {
      return (
        <Router>
          <NavBar />
          <Switch>
            <Route exact path="/" component={Home} />
            <Route path="/add" component={Add} />
            <Route path="/signin" component={SignIn} />
            <Route path="/signup" component={SignUp} />
          </Switch>
        </Router>
      );
    }
    export default App;
    
  16. 现在检查您的浏览器,你应该会看到简单的导航,并能够用它来切换视图:![图 14.3:完成组件中的文件夹列表 图片

图 14.3:完成组件中的文件夹列表

在 React 应用程序中添加路由很容易。在这个练习中,我们展示了如何添加路由、在它们之间导航,以及如何共享一个通用的组件跨多个路由。React 的一个真正优势是能够在其他组件之间共享组件,并创建重用模式,这使得组合应用程序的构建块变得容易。

路由还可以包括路径和查询参数。请务必阅读 React Router 文档,了解如何向您的路由添加参数。

React 组件

现在我们来深入探讨这些组件是如何工作的。在 React 中声明组件有几种不同的方式。你甚至可以选择是否使用 JSX。本书将专注于使用函数表达式创建组件,但我们也会简要介绍其他一些模式,这样你就能在看到它们时识别出来。

类组件

这种组件风格遵循经典的(即,与编程概念中的类相关)组件声明模式。许多较老的例子会使用类组件,但由于它们比其他模式更冗长,所以已经很大程度上过时了。要创建一个类组件,我们需要从 React 中导入Component类并在创建自己的类时扩展它:

import React, { Component } from 'react';
interface Comp1Props {
  text: string;
}
export default class Comp1 extends Component<Comp1Props> {
  render() {
    const { text } = this.props;
    return <div>{text}</div>;
  }
}

为属性创建自己的接口是个好主意,同样也可以为状态做同样的事情——例如:

import React, { Component } from 'react';
interface Comp1Props {
  text: string;
}
interface Comp1State {
  value: boolean
}
export default class Comp1 extends Component<Comp1Props, Comp1State> {
  render() {
    ...
}

属性通过this.props访问,状态通过this.statethis.setState访问。这种编程风格对于那些有 Java 或 C++背景的人来说可能感觉舒适和熟悉,但this关键字在 TypeScript 中可能会带来麻烦,并且以类为中心的声明风格与 React 中的一些函数式编程概念不太匹配,因此近年来其他模式已经变得更加流行。有关this关键字的更多信息,请参阅第三章函数

函数组件(函数声明)

在 React 组件中,以函数组件的形式编写更为常见。上一节中的相同简单组件,重写为函数组件,可能看起来像这样:

import React from 'react';
interface Comp2Props {
  text: string;
}
export default function Comp2({ text }: Comp2Props) {
  return <div>{text}</div>;
}

我们已经精简了几行代码,使其更接近函数式编程风格。在使用函数组件时,你不太会用到this,实际上也不需要真正导入Component类。属性只是传递给函数的参数。状态在这里不能直接处理,但下一节关于 React Hooks 的内容中我们会看到如何管理状态。

函数组件(使用箭头函数的表达式)

本书更倾向于这种模式,因为它是一种非常直观和声明式的创建组件的方式。你甚至可以创建一行代码的纯函数组件。首先,让我们再次编写相同的组件:

import React from 'react';
interface Comp3Props {
  text: string;
}
const Comp3 = ({ text }: Comp3Props) => <div>{text}</div>;
export default Comp3;

作用域规则不允许constdefault关键字在同一行上(避免像export default const a=1, b=2, c=3;这样的荒谬代码,否则这是允许的),因此我们需要在单独的一行上导出组件。

如果我们真的想进一步精简代码,可以写成这样:

import React from 'react';
export const Comp3 = ({ text }:{ text: string }) => <div>{text}</div>;

这是一个无状态的纯函数组件,没有其他副作用。大多数程序员更喜欢使用属性接口,因为它有助于提高可读性,但如果我们真的想使组件更小,可以像前面的片段({ text: string })那样内联声明。

无 JSX

上述任何一种方法都可以使用createElement。这里有一个快速示例,说明你可能不想使用它的原因:

import { createElement } from 'react';
interface Comp4Props {
  text: string;
}
const Comp4 = ({ text }: Comp4Props) => createElement('div', null, text);
export default Comp4;

createElement 的参数是要创建的元素标签、其属性和其子元素。很快就会意识到,使用 createElement 创建嵌套元素会比使用 JSX 难得多,所以 JSX 几乎总是被使用。如果我们决定不使用 JSX,我们可以使用 .ts 文件扩展名而不是 .tsx。这是一个非常小的好处!

函数组件中的状态

本书推荐使用函数组件而不是类组件。在函数组件中,我们不能直接访问状态,也没有可调用的 setState 方法。然而,我们确实可以访问优秀的 useState,所以我们几乎不会错过 thissetState

useState 是 React Hooks 的一部分,自 React 版本 16.8 以来可用。React Hooks 引入了几种函数,这些函数极大地增强了与函数组件一起工作的能力。让我们从一个使用类构造函数、thissetState 的简单组件开始:

import React, { Component } from 'react';
interface Comp1Props {
  text: string;
}
interface Comp1State {
  clicks: number;
}
export default class Comp1 extends Component<Comp1Props, Comp1State> {
  constructor(props: Comp1Props) {
    super(props);
    this.state = { clicks: 0 };
  }
  handleClick = () => {
    this.setState({ clicks: this.state.clicks + 1 });
  };
  render() {
    const { text } = this.props;
    return (
      <div>
        {text}
        <div>
          <button onClick={this.handleClick}>{this.state.clicks} clicks</button>
        </div>
      </div>
    );
  }
}

我们已经为属性、状态以及用于计数点击的事件处理程序定义了接口。我们正在使用 setState 在状态中增加计数器。handleClick 使用箭头函数而 render 不使用箭头函数,看起来有点奇怪,但它们都引用了 this。这是由于 TypeScript 中解释 this 引用时的奇怪之处。如果没有箭头函数,handleClick 在访问 this 时将找不到我们的组件,而会得到一个 undefined 引用。这种问题已经让许多开发者浪费了很多时间,因此框架作者寻求了简单避免许多开发者认为令人困惑的语言结构的解决方案。让我们将此组件重写为函数组件:

import React, { useState } from 'react';
interface Comp2Props {
  text: string;
}
export default function Comp2({ text }: Comp2Props) {
  const [clicks, setClicks] = useState(0);
  const handleClick = () => setClicks(clicks + 1);
  return (
    <div>
      {text}
      <div>
        <button onClick={handleClick}>{clicks} clicks</button>
      </div>
    </div>
  );
}

这个函数组件与类组件执行完全相同的功能。让我们看看它们之间的差异。首先,我们在代码行数方面开始看到实质性的节省。函数组件有 18 行,而类组件有 30 行。

接下来,我们避免了麻烦的 this 关键字。我们还避免了需要为状态定义接口的需要。这看起来可能有些反直觉,但实际上这是一个好事。在类组件中,状态作为一个单一的对象,可能经常将几个不相关的东西组合成一个状态。状态实际上只是一个任何和所有局部变量的地方。通过独立声明这些变量,我们可以建立更好的编程范式。

useState 函数接受一个参数,即默认状态,并返回一个指向值和用于更新状态的方法的 const 数组。状态值是 const 的,因为它不能在不重新渲染我们的组件的情况下更新。如果我们调用 setClicks,组件将重新渲染,并使用新初始化的 const clicks。你可以在单个函数组件中调用多个 useState。每个 useState 都独立管理其状态的一部分。

当使用useState时,您的状态仍然可以是强类型的。在我们的例子中,TypeScript 根据我们如何用数字初始化它来推断点击的类型。然而,如果我们想的话,我们可以添加一个类型提示,例如useState<number>(0)useState<MyType>(0)来处理更复杂类型。

React 中的状态管理

在 UI 开发领域,“状态”这个词有点被过度使用了。到目前为止,我们所说的状态是组件内部的一个局部状态。回到点击的例子,虽然这个值可以通过通常的方式(作为一个 prop)传递给子组件,但没有简单的方法将这个值传递给父组件或 DOM 树中其他地方的某个“远亲”组件。

全局状态的管理是一个比 React 更老的问题。创建某种可以内部管理其自身数据的组件通常是非常简单的,但当引入新的要求将组件的数据与其他应用程序的部分连接起来时,这个组件会变得极其复杂。通常,应用程序是以命令式的方式编写的,使用手写的“事件”来尝试通过全局作用域传播数据。这种方法可能可行,但错误是常见的,管理变化可能非常困难。对状态管理采取临时方法的一个可能结果是难以维护的意大利面代码。

React 确实有一种“暴力”式的状态管理方法,即所有数据都存储在某个父组件中,并通过(连同更新数据所需的所有必要方法)传递给所有子组件及其所有后代。在复杂的应用程序中这样做可能会非常具有挑战性,需要传递的长列表的 props 总是必须传递。因此,大多数开发者会选择另一种解决方案。

Redux 是一个在 2015 年引入的流行库,旨在通过引入函数式编程概念(如 reducer)来解决状态管理问题。Redux 背后的概念是,不可变状态存储在应用程序的某个地方。应用程序的不同部分可以发出动作,这些动作将产生一个新的状态来替换旧的状态。因为每个不可变状态版本都可以存储并且是不可变的(这意味着框架外部的任何东西都不能改变它),所以可以穿越不同的应用程序状态,这在开发中非常有用,也可能在生产应用程序中有用,例如“撤销”功能。Redux 几乎可以与任何 Web 应用程序一起使用,并且不绑定到 React,但通常在应用程序中会发现两者同时存在。

Redux 功能强大,但因其过于复杂和大量使用样板代码而受到批评。它通常还需要额外的库(如redux-sagaredux-thunk)来对后端服务器进行异步调用。所有这些库都可能对新手来说非常令人畏惧,甚至对经验丰富的程序员来说使用起来也可能具有挑战性。

React Hooks 提供了一种更简单的方式,使用 React 上下文。React 上下文允许我们设置根级数据存储和操作,并将它们提供给 DOM 树中深处的组件,而无需将 props 传递到整个路径(有时称为“prop 传递”)。Redux 和上下文之间的区别相当于从类组件和 setState 转到函数组件和 useState。像先前的例子一样,我们是从一个单一的状态对象及其复杂性管理到多个可以更简单地管理的上下文。

练习 14.03:React 上下文

让我们通过上下文获得一些经验。对于这个练习,你可以启动一个新的 create-react-app 实例,或者使用上一节中的实例。在这个练习中,我们将创建两个新组件和一个提供者。技术上,提供者也是组件,但它们实际上是专门的 HOC:

注意

本练习的代码可以在以下链接找到:packt.link/rUfr4.

  1. 让我们从提供者开始。在您的 /src 目录下创建 /components/providers 子目录。在 /providers 目录下,创建一个名为 ClickProvider.tsx 的文件。此组件将管理我们的点击并提供其上下文给后代。

  2. 与大多数组件不同,提供者将导出一个上下文和一个提供者。一些指南会创建 Context 然后导出 ConsumerProvider。我们不会使用 Consumer,而是使用另一个 React Hook,即 useContext。当使用 useContext 时,不会直接引用 Consumer 对象:

    export const ClickContext = createContext();
    
  3. 这是创建 Context 的基本签名。我们需要添加一个类型提示和一个默认值。让我们在添加 Provider 之后稍后再来处理这个问题:

    export const ClickProvider = ({ children }) => {
      const [clicks, setClicks] = useState(0);
      return (
        <ClickContext.Provider value={{ clicks, setClicks }}>
          {children}
        </ClickContext.Provider>
      );
    };
    

    此组件接受一些属性,即子节点。它使用 useState 创建一个 clicks 值和一个 update 函数,然后返回带有值和函数的 Provider

  4. 这是我们需要的基本提供者,但它还不是好的 TypeScript。我们需要添加一些更多的类型:

    interface Clicks {
      clicks: number;
      setClicks: Dispatch<SetStateAction<number>>;
    }
    interface ContextProps {
      children: ReactNode;
    }
    
  5. ClickContext 将是 Provider 返回的值的类型,而 ContextProps 作为任何具有子组件的 HOC 的基本 prop 类型。有了这些类型,我们可以填写 Provider 的其余部分:

    import React, {
      createContext,
      Dispatch,
      ReactNode,
      SetStateAction,
      useState,
    } from 'react';
    interface Clicks {
      clicks: number;
      setClicks: Dispatch<SetStateAction<number>>;
    }
    interface ContextProps {
      children: ReactNode;
    }
    export const ClickContext = createContext<Clicks>({
      clicks: 0,
      setClicks: () => {},
    });
    export const ClickProvider = ({ children }: ContextProps) => {
      const [clicks, setClicks] = useState(0);
      return (
        <ClickContext.Provider value={{ clicks, setClicks }}>
          {children}
        </ClickContext.Provider>
      );
    };
    
  6. 现在,让我们在 components 目录下添加 Clicker.tsxDisplay.tsx

    import React, { useContext } from 'react';
    import { ClickContext } from '../providers/ClickProvider';
    const Clicker = () => {
      const { clicks, setClicks } = useContext(ClickContext);
      const handleClick = () => setClicks(clicks + 1);
      return <button onClick={handleClick}>Add a click</button>;
    };
    export default Clicker;
    
  7. 此组件渲染一个按钮并使用 Provider 中的 setClicks 方法:

    import React, { useContext } from 'react';
    import { ClickContext } from '../providers/ClickProvider';
    const Display = () => {
      const { clicks } = useContext(ClickContext);
      return <div>{clicks}</div>;
    };
    export default Display;
    

    Display.tsx 只是从上下文中获取 clicks 值并显示它。

  8. 现在我们有几个与我们的提供者一起工作的简单组件,让我们将它们添加到 App.tsx 中,看看我们的应用看起来如何。

  9. App.tsx 中删除默认代码,并用 Provider 和新组件替换:

    import './App.css';
    import React from 'react';
    import Clicker from './components/Clicker';
    import Display from './components/Display';
    import { ClickProvider } from './providers/ClickProvider';
    function App() {
      return (
        <ClickProvider>
          <Clicker />
          <Display />
        </ClickProvider>
      );
    }
    export default App;
    

    运行应用并多次点击按钮。计数器将增加。在网站上制作计数器增加并不那么令人惊叹,但我们的组件是很好地解耦的,并且这种方法可以很好地扩展到更大的应用:

    ![图 14.4:显示点击计数的应用 图片 B14508_14_04.jpg

图 14.4:显示点击计数的应用

在这个练习中,我们使用了 React 上下文来管理应用中的状态。我们展示了不同的组件如何与状态交互,以及如何在不需要嵌套属性的情况下将状态传递给组件。

React 应用可以包含多个上下文或单一的数据树。React 上下文甚至可以在开发模式下,当修改的组件重新加载时保持当前状态,这样你就可以在不中断应用流程的情况下继续编码。

Firebase

Firebase 是由谷歌拥有的移动和 Web 开发平台。Firebase 包括一个 Web API,因此你可以向 Web 应用添加身份验证、分析、数据库等。Firebase 可以用作现代 Web 应用的后端,让开发者能够专注于用户体验。它包括一个免费层,我们将在以下练习中使用。

练习 14.04:开始使用 Firebase

在这个练习中,我们将使用 Firebase 设置数据库和身份验证。我们需要注册一个免费账户。我们还将获取在本书后面部分使用 Firebase 完成活动所需的必要有效载荷:

注意

这个练习的代码文件可以在以下链接找到:packt.link/bNMr5

  1. Firebase 需要谷歌账户,但使用它不需要信用卡或任何支付。要开始,导航到firebase.google.com/并点击开始使用

    你应该会出现在 Firebase 控制台中。点击添加项目并完成向导。你可以给你的项目命名任何你喜欢的名字——如果你不指定,Firebase 会自动生成一个唯一的名字。

  2. 除非你已经有一个想要使用的账户,否则不要启用 Google Analytics。

    你需要等待一分钟,然后你将发现自己处于项目仪表板上。在那里,你会找到几个可以部署以帮助构建应用的服务。我们将只关注身份验证和 Firestore。

  3. 首先,转到身份验证并点击开始使用。选择电子邮件/密码并启用它。所有其他身份验证方法都需要额外的设置步骤。如果你喜欢,可以继续完成这些步骤。Firebase 网站上的文档应该足够了。保存你的更改。

  4. 现在点击Firestore 数据库创建数据库。选择以测试模式开始选项,然后选择一个部署区域。区域实际上并不重要,但你可能希望选择一个离你较近的区域以获得更快的响应。完成创建数据库。

  5. 在 Firebase 控制台中,我们最后需要做的一件事是找到我们的应用程序配置。Firebase 的工作方式是,一个包含多个 ID 的配置对象将存在于您的应用程序中,并管理对 Firebase 后端的连接;然而,控制哪些用户可以影响哪些数据的权限规则都是在控制台(或本书未涉及的 CLI)中设置的。这个配置实际上并不保密,因为如果您的应用程序设置正确,恶意用户无法执行您未允许的操作。

  6. 要获取您的应用程序配置,您首先必须注册您的应用程序。您可以从“项目概览”(</> 符号)或通过“项目概览”旁边的齿轮添加应用程序。添加一个网络应用程序,您可以给它起任何名字,并跳过网络托管选项。进入您的应用程序配置(齿轮图标)并找到配置。使用通过 CDN(内容分发网络)的配置,您会找到类似以下的内容:

    const firebaseConfig = {
      apiKey: "abc123",
      authDomain: "blog-xxx.firebaseapp.com",
      projectId: "blog-xxx",
      storageBucket: "blog-xxx.appspot.com",
      messagingSenderId: "999",
      appId: "1:123:web:123abc"
    };
    

    保留这个配置。我们稍后会用到它,但现在我们在 Firebase 控制台中已经完成了。您可能希望稍后返回查看您的数据库、管理您的用户,甚至升级或删除您的应用程序和项目,但在这个章节中您不需要再次这样做。

开始使用 Firebase 非常简单。我们将能够使用 Firebase 来注册、验证用户、跟踪用户并存储数据,而无需编写自己的后端服务。

样式化 React 应用

当谈到样式化应用程序时,现代 UI 开发者有很多不同的选择。创建几个 层叠样式表CSS)文件并包含它们的传统方法对于扩展或构建统一的表示层来说并不理想。特别是现代网络应用程序和 React 提供了如此多的样式化选项,我们无法期望涵盖所有这些选项。以下是一些流行的技术。

主样式表

我们有一个包含所有样式的 styles.css 文件。样式是全局的,将影响所有组件。这对于小型应用程序来说可能效果很好,但随着您添加更多样式和组件,会存在一些严重的扩展问题。当添加新样式时,我们可能会看到现有组件因受新样式的影响而开始出现故障。

组件作用域样式

采用这种方法,我们为每个需要样式的组件创建一个样式,并使用 import 关键字将样式添加到您的组件中。像 webpack 这样的构建系统将为所有样式名称添加前缀,这样它们就不会“污染”全局作用域,并最终影响其他组件。这是 Create React App 默认提供的解决方案,它内部使用 webpack。

如果您能够有效地使用纯 CSS 或像 Sass 这样的样式表编译器,这种方法效果很好。一些开发者不喜欢它,因为显示元素分散在 CSS 和 JSX 文件中。

CSS-in-JS

CSS-in-JS 是一种产生了诸如 Styled Components 和 Emotion 等流行库的方法。这种方法简单来说就是我们在 JavaScript(或 TypeScript,在我们的例子中,因为大多数这些库都发布了类型定义)中编写我们的 CSS,从而将我们的样式与显示层结合起来。

这种方法非常适合那些创建大量自定义组件的团队。但缺点是又增加了一个需要维护的构建依赖。

组件库

组件库提供完全可用的组件,可以直接插入到应用程序中。组件库非常适合快速构建一个外观漂亮的应用程序。许多组件库已经存在了很长时间。一些组件库的例子包括 Twitter Bootstrap、Semantic UI 和 Material-UI。所有这些库都发布了旨在与流行的 Web 系统(如 Angular、Vue 以及当然还有 React)一起工作的版本。

与组件库一起工作与使用自己的组件非常相似。你导入组件,就像使用任何其他组件一样使用它们。这样做可以真正加快你的开发周期,因为你有了现成的常用组件。有些团队发现组件库中的组件太不灵活,不喜欢处理额外的依赖。

即将进行的活动将使用 Material-UI 进行快速且吸引人的构建。

活动 14.01:博客

现在我们对 create-react-app 和 Firebase 有了一些经验,让我们创建一个博客!在这个活动中,我们将使用本章前面覆盖的所有工具和技术。我们将使用 create-react-app 快速创建一个 React 项目。我们将使用 Material-UI 设计一个吸引人的应用程序,并编写一些我们自己的函数组件。我们将使用 react-router 在应用程序的不同页面之间启用路由。我们将使用 React 上下文 API 来管理状态。最后,我们将使用 Firebase 来有一个后端服务,我们可以用它来验证用户并在博客访问之间保存和共享数据。

让我们通过创建这个博客的高级步骤来了解一下。听起来好像有很多步骤,但如果我们将其分解为单个任务,就不会那么具有挑战性:

注意

这个活动的代码文件可以在以下位置找到:packt.link/qqIXz

  1. 使用 create-react-app 创建一个新的 React 应用程序,正如本章前面所述。你甚至可以重用本章前面开始的应用程序。启动你的应用程序,以便你可以亲眼看到实现过程。

  2. 请参考 第 14.04 节,Firebase 入门 中的 Firebase 应用程序,或者如果你还没有完成,请完成该练习。在 Firebase 中找到你的配置数据,然后按照说明将 firebase 依赖项添加到你的 React 应用程序中,然后添加 Firebase 控制台中的应用程序特定配置。

  3. 实现 Firebase 的 authfirestore 服务,然后为每个服务添加 React 上下文和提供者以维护状态。

    安装 react-routermaterial-ui 来构建一些 UI 组件并创建一些路由。首先创建一个注册路由和页面:

    图 14.5:首页

    图 14.5:首页

    图 14.6:注册页面

    图 14.6:注册页面

  4. 创建一个添加页面的路由,并添加 UI 组件以便能够设置新故事的标题和链接:图 14.7:添加故事页面

    图 14.7:添加故事页面

  5. 使用你的 React 上下文和提供者以及 Firebase Firestore,将数据持久化到云端并实现其他功能,例如评论:图 14.8:评论功能

图 14.8:评论功能

图 14.9:发表评论

图 14.9:发表评论

如果这项活动花费了一些时间,请不要担心。如果你需要在 GitHub 上检查解决方案,也无需担心。这个活动尤其具有挑战性,因为它包含了如此多的不同部分,但如果你设法将它们全部整合到一个可工作的应用中,那是一个巨大的进步。你已经构建了一个具有吸引力的 UI、身份验证和数据库的全栈应用。

注意

这个活动的解决方案可以通过这个链接找到。

摘要

TypeScript 正在成为编写 Web 应用的流行工具,尽管它过去在 React 中并不总是普遍使用,但现在它得到了很好的支持。开发者不再需要仅仅为 props 添加类型,而可以在应用的所有部分工作中获得类型安全和智能感知的好处。

React 拥有一个非常丰富和多样化的生态系统,但许多 TypeScript 兼容的解决方案,如 React Hooks 和 React 上下文,正成为保持应用简单但强大的首选。由于 create-react-app 支持 TypeScript,因此开始使用非常简单,你可以在几分钟内开始构建你的应用。

想要了解更多关于 React 的开发者需要的不只是这本书,但这一章节旨在展示为什么当你使用 React 编写应用时,你想要坚持使用 TypeScript。

附录

1. TypeScript 基础

活动一.01:创建字符串操作库

解决方案:

这里是帮助你创建活动问题陈述中列出的所有函数的步骤。

toTitleCase

toTitleCase 函数将处理一个字符串,并将每个单词的首字母转换为大写,但将所有其他字母转换为小写。

此函数的测试用例如下:

"war AND peace" => "War And Peace"
"Catcher in the Rye" => "Catcher In The Rye"
 "tO kILL A mOCKINGBIRD" => "To Kill A MockingBird"

这里是帮助你编写这个函数的步骤:

  1. 此函数将接受一个字符串参数并返回一个字符串:

    function toTitleCase (input:string) : string {
    
  2. 首先,我们将使用字符串分割方法将输入分割成字符串数组。我们将根据每个空格字符进行分割:

        // split the string into an array on every occurrence of 
        //  the space character     const words = input.split(" ");
    
  3. 接下来,我们将定义一个新的数组,用于存储我们将要转换为大写形式的每个单词,并使用 for..of 循环遍历单词数组:

        const titleWords = [];    // loop through each word     for (const word of words) {
    
  4. 对于每个单词,我们将使用字符串切片方法提取第一个字符和其余字符。我们将首字母转换为大写,其余字符转换为小写。然后,我们将它们重新组合成一个完整的单词,并将结果推送到存储数组中:

        // take the first character using `slice` and convert it to uppercase     const initial = word.slice(0, 1).toLocaleUpperCase();    // take the rest of the character using `slice` and convert them to lowercase     const rest = word.slice(1).toLocaleLowerCase();    // join the initial and the rest and add them to the resulting array     titleWords.push(`${initial}${rest}`);
    
  5. 最后,我们将所有处理过的单词连接起来,用空格分隔,我们就得到了结果:

        // join all the processed words     const result = titleWords.join(" ");    return result;}
    
  6. 接下来,我们可以测试该函数是否对给定的测试输入给出了预期的结果:

    console.log(`toTitleCase("war AND peace"):`);console.log(toTitleCase("war AND peace")); console.log(`toTitleCase("Catcher in the Rye"):`);console.log(toTitleCase("Catcher in the Rye"));console.log(`toTitleCase("tO kILL A mOCKINGBIRD"):`);console.log(toTitleCase("tO kILL A mOCKINGBIRD"));
    
  7. 我们应该收到以下结果:

    toTitleCase("war AND peace"):War And Peace toTitleCase("Catcher in the Rye"):Catcher In The Rye toTitleCase("tO kILL A mOCKINGBIRD"):To Kill A Mockingbird
    

countWords

这里是帮助你编写这个函数的步骤:

  1. countWords 函数将计算字符串中不同单词的数量。单词由空格、破折号(-)或下划线(_)分隔。此函数的测试用例如下:

    "War and Peace" => 3 
    "catcher-in-the-rye" => 4 
    "for_whom the-bell-tolls" => 5
    
  2. 使用以下代码创建 countWords 函数:

    function countWords (input: string): number {
    
  3. 使用匹配空格、下划线或破折号字符的正则表达式分割单词:

        const words = input.split(/[ _-]/);
    
  4. 返回分割结果数组的长度:

        return words.length;
    }
    
  5. 测试该函数并输出控制台结果:

    console.log(`countWords("War and Peace"):`);
    console.log(countWords("War and Peace"));
    
    console.log(`countWords("catcher-in-the-rye"):`);
    console.log(countWords("catcher-in-the-rye"));
    console.log(`countWords("for_whom the-bell-tolls"):`);
    console.log(countWords("for_whom the-bell-tolls"));
    

toWords

toWords 函数将返回字符串中的所有单词。单词由空格、破折号(-)或下划线(_)分隔。

此函数的测试用例如下:

"War and Peace" => [War, and, peace]
"catcher-in-the-rye" => [catcher, in, the, rye]
"for_whom the-bell-tolls" => [for, whom, the, bell, tolls]

此函数与我们之前开发的函数非常相似。主要区别是我们需要返回的不仅是单词的数量,还有实际的单词本身。因此,此函数将返回一个字符串数组,而不是一个数字:

  1. 这里是创建此函数的代码:

    function toWords (input: string): string[] {
    
  2. 再次,我们需要使用字符串分割方法将输入分割成字符串数组,使用正则表达式 [ _-]。使用匹配空格、下划线或破折号字符的正则表达式分割单词:

       const words = input.split(/[ _-]/);
    
  3. 一旦我们有了单词,我们就可以直接返回它们:

        // return the words that were split     return words;}
    
  4. 接下来,我们可以测试该函数是否对给定的测试输入给出了预期的结果:

    console.log(`toWords("War and Peace"):`);console.log(toWords("War and Peace")); console.log(`toWords("catcher-in-the-rye"):`);console.log(toWords("catcher-in-the-rye"));console.log(`toWords("for_whom the-bell-tolls"):`);console.log(toWords("for_whom the-bell-tolls"));
    
  5. 我们应该收到以下结果:

    toWords("War and Peace"):[ 'War', 'and', 'Peace' ]toWords("catcher-in-the-rye"):[ 'catcher', 'in', 'the', 'rye' ]toWords("for_whom the-bell-tolls"):[ 'for', 'whom', 'the', 'bell', 'tolls' ]
    

repeat

repeat 将接受一个字符串和一个数字,并返回该字符串重复指定次数的结果。

此函数的测试用例如下:

„War", 3 => „WarWarWar"
„rye", 1 => „rye"
„bell", 0 => „"

这里有一些步骤可以帮助你编写这个函数:

  1. 这个函数将接受两个参数,一个是字符串,另一个是数字,并返回一个字符串:

    function repeat (input: string, times: number): string {
    

    实现这个函数有很多种方法,我们将展示一种方法。我们可以创建一个具有所需元素数量的数组,然后使用数组的 fill 方法用字符串的值填充它。这样,我们将有一个包含 times 个元素的数组,每个元素都将具有 input 值:

        // create a new array that with length of `times`    // and set each element to the value of the `input` string     const instances = new Array(times).fill(input);
    
  2. 接下来,我们只需要使用空字符串作为分隔符,将所有实例连接起来。这样,我们确保字符串之间没有插入空格或逗号:

        // join the elements of the array together     const result = instances.join("");    return result;}
    
  3. 接下来,我们可以测试该函数对于给定的测试输入是否给出预期的结果:

    console.log(`repeat("War", 3 ):`);console.log(repeat("War", 3 )); console.log(`repeat("rye", 1):`);console.log(repeat("rye", 1));console.log(`repeat("bell", 0):`);console.log(repeat("bell", 0));
    
  4. 我们应该收到以下结果:

    repeat("War", 3 ):WarWarWar repeat("rye", 1):rye repeat("bell", 0):
    

isAlpha

isAlpha 如果字符串只包含字母字符(即字母),则返回 true。这个函数的测试用例如下:

"War and Peace" => false
"Atonement" => true
"1Q84" => false

这里有一些步骤可以帮助你编写这个函数:

  1. 这个函数将接受一个字符串参数并返回一个布尔值:

    function isAlpha (input: string): boolean {
    
  2. 为了使这个函数工作,我们需要检查每个字符是下划线还是大写字母。确定这一点的一个最好的方法就是使用检查它的正则表达式。特别是,字符组 [a-z] 将检查单个字符,如果我们使用星号量词 (*),我们可以告诉正则表达式检查所有字符。我们可以添加 i 修饰符到正则表达式,使其匹配不区分大小写,这样我们就不必担心字母的大小写:

    // regex that will match any string that only has upper and  //lowercase letters     const alphaRegex = /^[a-z]*$/i
    
  3. 接下来,我们需要实际测试我们的输入。由于我们只需要知道字符串是否匹配,我们可以使用正则表达式的测试方法并返回其结果:

        // test our input using the regex     const result = alphaRegex.test(input);    return result;}
    
  4. 接下来,我们可以测试该函数对于给定的测试输入是否给出预期的结果:

    console.log(`isAlpha("War and Peace"):`);console.log(isAlpha("War and Peace")); console.log(`isAlpha("Atonement"):`);console.log(isAlpha("Atonement"));console.log(`isAlpha("1Q84"):`);console.log(isAlpha("1Q84"));
    
  5. 我们应该收到以下结果:

    isAlpha("War and Peace"):false isAlpha("Atonement"):true isAlpha("1Q84"):false
    

isBlank

isBlank 如果字符串为空白,即只包含空白字符,则返回 true

这个函数的测试用例如下:

"War and Peace" => false
"       " => true
"" => true

这里有一些步骤可以帮助你编写这个函数:

  1. 这个函数将接受一个字符串参数并返回一个布尔值:

    function isBlank (input: string): boolean {
    
  2. 为了使这个函数工作,我们需要检查字符串中的每个字符是否为空白字符。我们可以使用正则表达式来确定这一点,或者我们可以使用某种循环结构来遍历字符串。一种方法就是测试第一个字符是否为空格,如果是,就将其切掉:

    // test if the first character of our input is an empty space     while (input[0] === " ") {// successively slice off the first character of the input         input = input.slice(1);    }
    
  3. 这个循环将一直执行,直到遇到一个非空白字符。如果没有遇到,它将仅在字符串没有第一个元素时停止,也就是说,当字符串为空字符串时。如果是这种情况,我们的原始输入只包含空白字符,我们可以返回 true。否则,我们应该返回 false:

    // the loop will stop on the first character that is not a //space.// if we're left with an empty string, we only have spaces in // the input     const result = input === "";    return result;
    
  4. 接下来,我们可以测试该函数对于给定的测试输入是否给出预期的结果:

    console.log(`isBlank("War and Peace"):`);console.log(isBlank("War and Peace")); console.log(`isBlank("       "):`);console.log(isBlank("       "));console.log(`isBlank(""):`);console.log(isBlank(""));
    
  5. 我们应该收到以下结果:

    isBlank("War and Peace"):false isBlank("       "):true isBlank(""):true
    

    注意,实现所有前面的函数有多种方式。显示的代码只是实现它们的一种方式,这些实现主要是为了说明目的。例如,一个合适的字符串实用程序库将需要更强大和广泛的测试套件。

2. 声明文件

活动 2.01:构建热图声明文件

解决方案:

在这个活动中,我们将构建一个名为 heat map log system 的 TypeScript 应用程序,该程序将跟踪棒球投球数据并确保数据的完整性。按照以下步骤实现此活动:

  1. 访问以下 GitHub 仓库 [https://packt.link/dqDPk],下载包含规范和配置元素的作业项目。

  2. 打开 Visual Studio Code 编辑器,然后打开终端。

  3. 在终端或命令提示符中切换到 activity-starter 目录,编写以下命令:

    cd activity-starter
    
  4. 运行以下命令来安装依赖项:

    npm install
    

    你现在将在 activity-starter 目录中看到以下文件:

    图 2.19:启动项目文件

    图 2.19:启动项目文件

  5. types/ 目录中的 HeatMapTypes.d.ts 声明文件中,定义一个名为 HeatMapTypes 的模块,并导出一个名为 Pitcher 的接口。为 Pitcher 定义三个属性:batterHotZonespitcherHotZonescoordinateMap。所有三个属性的数据结构应该相同,为 Array<Array<number>>,但 coordinateMap 应该是可选的。编写以下代码来完成此操作:

    declare module "HeatMapTypes" {
      export interface Pitcher {
        batterHotZones: Array<Array<number>>;
        pitcherHotZones: Array<Array<number>>;
        coordinateMap?: Array<any>;
      }
    }
    

    编辑器中前面的代码看起来是这样的:

    图 2.20:创建投手界面

    图 2.20:创建投手界面

  6. 打开 heat_map_data.ts 并导入声明文件。创建并导出一个名为 datalet 变量,并将其分配给 Pitcher 类型。你需要导入在最初运行 npm install 时安装的 lodash 库。编写以下代码来完成此操作:

    /// <reference path="./types/HeatMapTypes.d.ts"/>
    import hmt = require('HeatMapTypes');
    import _ = require('lodash');
    export let data: hmt.Pitcher;
    
  7. 将符合声明规则的值添加到 data 变量中。将嵌套数组作为值分配给 batterHotZonespitcherHotZones 属性。添加以下代码来完成此操作:

    data = {
      batterHotZones: [[12.2, -3], [10.2, -5], [3, 2]],
      pitcherHotZones: [[3, 2], [-12.2, 3], [-10.2, 5]],
    };
    
  8. 创建一个名为 findMatch() 的新函数,该函数接受 batterHotZonespitcherHotZones 数组,并使用 lodash 函数 intersectionWith() 返回相同的嵌套数组。最后,将 findMatch() 函数的值存储在声明文件中定义的 coordinateMap 属性中。编写以下代码来完成此操作:

    export const findMatch = (batterHotZones, pitcherHotZones) => {
      return _.intersectionWith(batterHotZones, pitcherHotZones, _.isEqual);
    };
    data.coordinateMap = findMatch(data.batterHotZones, data.pitcherHotZones);
    console.log(data.coordinateMap);
    
  9. 现在,在终端中,键入以下命令以生成 JavaScript 代码并运行它:

    tsc heat_map_data.ts
    node heat_map_data.js
    

    一旦我们运行前面的命令,终端会显示以下输出:

    [[3,2]]
    

    在前面的输出中,从两个属性中获取了公共值,然后打印出来。在这种情况下,公共值是 [3, 2]

  10. 现在,更改这两个属性的值。编写以下代码:

    data = {
      batterHotZones: [[12.2, -3], [10.2, -5], [3, 2]],
      pitcherHotZones: [[3, 2], [-12.2, 3], [10.2, -5]],
    };
    
  11. 现在,在终端中输入以下命令以生成 JavaScript 代码并运行:

    tsc heat_map_data.ts
    node heat_map_data.js
    

    运行前面的命令后,终端显示以下输出:

    [[10.2, -5], [3, 2]]
    

在前面的输出中,公共值是 [10.2, -5][3, 2]。最后,我们构建了一个热图日志系统,该系统将跟踪棒球投球数据并确保数据的完整性。

3. 函数

活动 3.01:使用函数构建航班预订系统

解决方案:

  1. 从代码示例中提供的存根开始。我们有三个文件:index.tsbookings.tsflights.tsindex.ts 文件相对抽象,将仅代表我们推入系统的一些交易。bookings.ts 处理用户界面的预订管理活动,而 flights.ts 是后台,负责填充航班并确保每个人都有座位。

  2. 除非你想修改它并添加一些新场景,否则 index.ts 文件不会发生变化。让我们不加任何代码运行它:

    npx ts-node index.ts Not implemented!
    

    因此我们还有工作要做。几个函数尚未实现。让我们先看看 flights.ts。那里有一个部分实现,因为我们有一个名为 Flights 的接口,它描述了航班的属性,一个实现了该接口的可用航班列表,甚至还有一个名为 getDestinations 的获取航班的方法。我们需要实现逻辑来检查我们想要预订的座位是否仍然可用,在确认预订时保留座位,以及在我们处理付款后,将这些保留的座位转换为已预订座位。

  3. 为了检查可用性,我们应该查看我们请求的座位数量是否超过了剩余座位数量,同时保留任何已保留的座位。我们可以将这个表达式表示为 seatsRequested <= seatsRemaining - seatsHeld,这是一个布尔表达式,可以由函数返回。这可以在 flights.ts 文件中写成箭头函数:

    export const checkAvailability = (
      flight: Flight,
      seatsRequested: number
    ): boolean => seatsRequested <= flight.seatsRemaining - flight.seatsHeld;
    
  4. holdSeats 函数应该确认请求的座位是否可用,如果可用则保留它们。如果剩余座位不足,我们需要抛出一个错误并中断流程:

    export const holdSeats = (flight: Flight, seatsRequested: number): Flight => {
      if (flight.seatsRemaining - flight.seatsHeld < seatsRequested) {
        throw new Error('Not enough seats remaining!');
      }
      flight.seatsHeld += seatsRequested;
      return flight;
    };
    
  5. 为了完善 flights.ts,我们有 reserveSeats 函数。这个函数与 holdSeats 函数类似,但它确认我们想要预留的座位已被保留,然后通过增加 seatsHeld 属性并减少相同数量的 seatsRemaining 属性,将它们转换为已预订座位:

    export const reserveSeats = (
      flight: Flight,
      seatsRequested: number
    ): Flight => {
      if (flight.seatsHeld < seatsRequested) {
        throw new Error('Seats were not held!');
      }
      flight.seatsHeld -= seatsRequested;
      flight.seatsRemaining -= seatsRequested;
      return flight;
    };
    

    这样 flights.ts 就完成了。然而,我们的程序仍然无法运行,直到我们实现了 bookings.ts

  6. 首先,我们将使用工厂模式来创建预订。这意味着我们将有一个返回函数以创建预订的函数。我们将使用柯里化来创建闭包,以便我们可以用 bookingNumber 初始化 createBooking 函数,从而为每个预订提供一个唯一的标识符。工厂可能看起来像这样:

    const bookingsFactory = (bookingNumber: number) => (
      flight: Flight,
      seatsHeld: number
    ): Booking => ({
      bookingNumber: bookingNumber++,
      flight,
      paid: false,
      seatsHeld,
      seatsReserved: 0,
    });
    

    我们的工厂接受 bookingNumber 作为参数来初始化这个值,然后在每次创建新的预订时增加这个数字。我们为预订分配一些默认值,以符合模块中已经提供的 Booking 接口。

  7. 要调用工厂并获取一个已经将 bookingNumber 预先柯里化的 createBooking 函数,我们可以简单地写出以下代码:

    const createBooking = bookingsFactory(1);
    
  8. 我们还没有编写开始预订过程、处理支付和完成预订的函数,因此还没有预订座位。要开始预订,我们需要根据我们请求的座位数检查我们选择航班的可用性。如果这成功了,我们可以创建预订并保留座位。否则,我们可以抛出一个错误来提醒用户预订无法完成:

      export const startBooking = (
      flight: Flight,
      seatsRequested: number
    ): Booking => {
      if (checkAvailability(flight, seatsRequested)) {
        holdSeats(flight, seatsRequested);
        return createBooking(flight, seatsRequested);
      }
      throw new Error('Booking not available!');
    };
    
  9. 为了检查航班可用性和保留座位,我们需要从 flights.ts 中导入这些函数。这已经在 bookings.ts 模块的顶部完成。这些模块中使用了 export 关键字,以便将函数提供给其他模块。一些函数缺少 export 关键字,因此只能在模块内部调用,实际上使它们成为私有的。

  10. 由于我们还没有实现支付系统,我们的 processPayment 函数会有一点欺骗性。我们只是将预订标记为已支付并返回它:

    export const processPayment = (booking: Booking): Booking => {
      booking.paid = true;
      return booking;
    };
    
  11. 要完成预订,我们在 flights 模块中调用 reserveSeats,然后更新我们的计数:

    export const completeBooking = (booking: Booking): Booking => {
    reserveSeats(booking.flight, booking.seatsHeld);
    booking.seatsReserved = booking.seatsHeld;
    booking.seatsHeld = 0;
    return booking;
    };
    
  12. 在所有函数实现之后,我们可以再次调用我们的程序并查看输出:

    npx ts-node index.ts
    Booked to Lagos {
      bookingNumber: 1,
      flight: {
        destination: 'Lagos',
        flightNumber: 1,
        seatsHeld: 0,
        seatsRemaining: 29,
        time: '5:30'
      },
      paid: true,
      seatsHeld: 0,
      seatsReserved: 1
    }
    //...
    Istanbul flight {
      destination: 'Istanbul',
      flightNumber: 7,
      seatsHeld: 0,
      seatsRemaining: 0,
      time: '14:30'
    }
    Booking not available!
    

活动三.02:编写单元测试

解决方案:

  1. describe 块中,获取此场景的目的地,并将第一个目的地缓存为 flight。现在,我们可以编写一个简单的测试来测试是否返回了正确数量的目的地:

      test('get destinations', () => {
        expect(destinations).toHaveLength(7);
      });
    

    我们还可以测试每个单独的目的地及其属性。

  2. 检查几个目的地的可用性。我们可以引入各种场景。以下是一些例子:

      test('checking availability', () => {
        const destinations = getDestinations();
        expect(checkAvailability(destinations[0], 3)).toBeTruthy();
        expect(checkAvailability(destinations[1], 5)).toBeFalsy();
        expect(checkAvailability(destinations[2], 300)).toBeFalsy();
        expect(checkAvailability(destinations[3], 3)).toBeTruthy();
      });
    

    第一个目的地至少有三个座位可用。第二个目的地没有五个座位可用,以此类推。

  3. 在下一个测试中尝试保留一些座位。我们应该测试成功和失败的场景:

      test('hold seats', () => {
        expect.assertions(3);
        flight = holdSeats(flight, 3);
        expect(flight.seatsHeld).toBe(3);
        flight = holdSeats(flight, 13);
        expect(flight.seatsHeld).toBe(16);
        try {
          holdSeats(flight, 15);
        } catch (e) {
          expect(e.message).toBe('Not enough seats remaining!');
        }
      });
    

    注意,为了确保 catch 块被到达,我们在这个测试中期望有三个断言。如果没有这些断言,即使由于某种原因 holdSeats 的最后调用没有抛出错误,测试仍然会变成绿色。

  4. 使用单元测试完成航班测试以预订座位:

      test('reserve seats', () => {
        expect.assertions(3);
        flight = reserveSeats(flight, 3);
        expect(flight.seatsRemaining).toBe(27);
        flight = reserveSeats(flight, 13);
        expect(flight.seatsRemaining).toBe(14);
        try {
          reserveSeats(flight, 1);
        } catch (e) {
          expect(e.message).toBe('Seats were not held!');
        }
      });
    

    此测试运行了几个场景,包括另一个错误条件。在某些情况下,将错误条件放在单独的测试中可能是合适的。这个规则的一个好方法是,每个测试都应该易于理解和维护。如果任何模块或函数变得太大,只需将其拆分。

  5. 现在,使用相同的原则为预订编写一些测试:

    describe('bookings tests', () => {
      test('create a booking', () => {
        const booking = startBooking(destinations[0], 3);
        expect(booking).toEqual({
          bookingNumber: 1,
          flight: destinations[0],
          paid: false,
          seatsHeld: 3,
          seatsReserved: 0,
        });
      });
      test('pay for a booking', () => {
        let booking = startBooking(destinations[0], 3);
        booking = processPayment(booking);
        expect(booking.paid).toBe(true);
      });
      test('complete a booking', () => {
        let booking = startBooking(destinations[0], 3);
        booking = processPayment(booking);
        booking = completeBooking(booking);
        expect(booking.paid).toBe(true);
        expect(booking.seatsReserved).toBe(3);
      });
    });
    
  6. 现在我们尝试运行测试,看看结果如何:

    npm test
    > jest --coverage --testRegex="^((?!-solution).)*\\.test\\.tsx?$"
     PASS  ./bookings.test.ts
     PASS  ./flights.test.ts
    -------------|---------|----------|---------|---------|-------------------
    File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
    -------------|---------|----------|---------|---------|-------------------
    All files    |   97.14 |    83.33 |     100 |   96.97 |
     bookings.ts |   94.74 |       50 |     100 |   94.44 | 34
     flights.ts  |     100 |      100 |     100 |     100 |
    -------------|---------|----------|---------|---------|-------------------
    Test Suites: 2 passed, 2 total
    Tests:       7 passed, 7 total
    Snapshots:   0 total
    Time:        1.782 s
    Ran all test suites.
    

    测试通过了!但我们还没有达到 100% 的行覆盖率。实际上,我们可以打开覆盖率报告,该报告位于项目根目录下的 coverage/lcov-report 目录中。与 Jest 一起捆绑的覆盖率工具(Istanbul)将生成一个 HTML 报告,我们可以在任何浏览器中打开它。这将显示未覆盖的确切代码片段:

    ![Figure 3.2:由工具生成的 HTML 报告

    ![img/B14508_03_02.jpg]

    图 3.2:由工具生成的 HTML 报告

  7. 我们遗漏了一个错误场景。让我们添加一个新的 describe 块以避免进一步复杂化我们已编写的测试:

    describe('error scenarios', () => {
      test('booking must have availability', () => {
        expect.assertions(1);
        try {
          startBooking(destinations[6], 8);
        } catch (e) {
          expect(e.message).toBe('Booking not available!');
        }
      });
    });
    

    没有特别需要一个新的 describe 块,但在这个情况下,它可能会使代码更整洁。使用 describe 和测试块来提高可读性和维护性。

  8. 现在我们再次运行测试:

    npm test
    > jest --coverage --testRegex="^((?!-solution).)*\\.test\\.tsx?$" PASS  ./bookings-solution.test.ts
     PASS  ./flights-solution.test.ts
    -------------|---------|----------|---------|---------|-------------------
    File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
    -------------|---------|----------|---------|---------|-------------------
    All files    |     100 |      100 |     100 |     100 |
     bookings.ts |     100 |      100 |     100 |     100 |
     flights.ts  |     100 |      100 |     100 |     100 |
    -------------|---------|----------|---------|---------|-------------------
    Test Suites: 2 passed, 2 total
    Tests:       8 passed, 8 total
    Snapshots:   0 total
    Time:        0.694 s, estimated 1 s
    Ran all test suites.
    

    我们达到了 100% 行覆盖率的目标!

4. 类和对象

活动四.01:使用类、对象和接口创建用户模型

解决方案:

在这个活动中,我们将构建一个用户认证系统,该系统将登录数据传递到后端 API 以注册和登录我们的棒球比分卡应用程序。按照以下步骤实现此活动:

  1. 访问以下 GitHub 仓库并下载包含规范和配置元素的活动项目:packt.link/oaWbW

    activity-solution 目录包含完成后的解决方案代码,而 activity-starter 目录提供了基本起始代码以供工作使用。

  2. 打开 Visual Studio Code 编辑器,然后打开终端。在终端或命令提示符中切换到 activity-starter 目录,并运行以下命令来安装依赖项:

    npm install
    

    您现在将在 activity-starter 目录中看到以下文件:

    ![Figure 4.10:活动项目文件

    ![img/B14508_04_10.jpg]

    图 4.10:活动项目文件

  3. 打开 activity-starter 文件夹内的 auth.ts 文件,创建一个名为 ILogin 的接口,包含两个字符串属性,即 emailpassword。编写以下代码来完成此操作:

    interface ILogin{
        email: string;
        password:string;
    }
    
  4. 创建一个 Login 类,它接受一个包含字符串属性 emailpassword 的对象。同时,将 ILogin 接口作为参数传递给 Login 类内部的 constructor 函数:

    export class Login{
        email: string;
        password: string;
        constructor(args: ILogin){
            this.email = args.email;
            this.password = args.password;
        }
    }
    
  5. 创建一个名为 IAuth 的接口,包含两个属性,usersource。在这里,user 属性的类型将是 Login,而 source 属性的类型将是 string。编写以下代码以实现此功能:

    interface IAuth{
        user: Login;
        source: string;
    } 
    
  6. 创建一个 Auth 类,它接受一个包含 usersource 属性的对象。同时,创建一个 constructor 函数,它将 IAuth 接口作为参数。编写以下代码以完成此操作:

    export default class Auth{
        user: Login;
        source: string;
        constructor(args: IAuth){
            this.user = args.user;
            this.source = args.source;
        }
    }
    
  7. 接下来,我们将在 Auth 类中添加一个 validUser() 方法,该方法在 email 等于 admin@example.compassword 等于 secret123 时返回表示用户已认证的字符串。如果这些值中的任何一个不匹配,函数将返回表示用户未认证的字符串。编写以下代码以定义此函数:

    validUser(): string{
        const { email, password } = this.user;
        if(email === "admin@example.com"       && password === "secret123"){
            return `Validating user…User is authenticated: true`;
        } else {
            return `Validating user…User is authenticated: false`;
        }
    }
    
  8. 创建两个 Login 类的实例,分别命名为 goodUserbadUser。对于 goodUser 对象,将 email 值设置为 admin@example.com,将 password 设置为 secret123。对于 badUser 对象,将 email 值设置为 admin@example.com,将 password 设置为 whoops。编写以下代码以完成此操作:

    const goodUser = new Login({
        email: "admin@example.com",
        password: "secret123"
    });
    const badUser = new Login({
        email: "admin@example.com",
        password: "whoops"
    });
    
  9. 创建两个 Auth 类的实例,分别命名为 authAttemptFromGoodUserauthAttemptFromBadUser。对于第一个对象,将 Login 类的 goodUser 对象分配给 user 属性,将 Google 分配给 source 属性。对于第二个对象,将 Login 类的 badUser 对象分配给 user 属性,将 Google 分配给 source 属性。一旦创建了这两个对象,就调用 Auth 类的 validUser() 函数,并在终端中打印结果。编写以下代码以完成此操作:

    const authAttemptFromGoodUser = new Auth({
        user: goodUser,
        source: "Google"
    });
    console.log(authAttemptFromGoodUser.validUser());
    const authAttemptFromBadUser = new Auth({
        user: badUser,
        source: "Google"
    });
    console.log(authAttemptFromBadUser.validUser());
    
  10. 现在,在终端中输入以下命令以生成 JavaScript 代码并运行它:

    tsc auth.ts
    node auth.js
    

    一旦运行前面的命令,终端将显示以下输出:

    Validating user…User is authenticated: true
    Validating user…User is authenticated: false
    

在前面的输出中,当传递正确的 userpassword 详细信息时,validUser() 函数返回 true 值。当传递错误的信息时,函数返回 false 值。

5. 接口和继承

活动 5.01:使用接口构建用户管理组件

解决方案

  1. 创建一个具有以下属性的用户对象接口:email : stringloginAt : numbertoken : string。将 loginAttoken 设置为可选:

    interface UserObj {
        email: string
        loginAt?: number
        token?: string
    }
    
  2. 构建一个具有全局属性 user 的类接口,并使用在 步骤 1 中创建的接口应用用户对象规则。你需要定义一个 getUser 方法,该方法返回用户对象。使用接口确保返回的对象是用户对象。最后,定义一个 login 方法,该方法接受一个 user 对象和 password(type string) 作为参数。使用用户对象接口作为 user 参数类型:

    interface UserClass {
        user: UserObj
        getUser(): UserObj
        login(user: UserObj, password: string):UserObj
    }
    
  3. 声明一个名为UserClass的类,该类实现了步骤 2中的类接口。你的登录方法应将本地函数的user参数分配给全局用户属性并返回全局用户。getUser方法应返回全局用户:

    class User implements UserClass {
    
        user:UserObj
        getUser(): UserObj {
            return this.user
        }
        login(user:  UserObj, password: string): UserObj {
            // set props user object
            return this.user = user
        }
    }
    
  4. 创建一个实例,如步骤 2中声明的类:

    const newUserClass:UserClass = new User()
    
  5. 创建一个用户对象实例:

    const newUser: UserObj = {
        email: "home@home.com",
        loginAt: new Date().getTime(),
        token: "123456"
    }
    
  6. 输出我们的方法以确保它们按预期工作:

    console.log(
        newUserClass.login(newUser, "password123")
    )
    console.log(
        newUserClass.getUser()
    )
    

    预期输出如下:

    { email: 'home@home.com', loginAt: 1614068072515, token: '123456' }
    { email: 'home@home.com', loginAt: 1614068072515, token: '123456' }
    

    这个用户管理类是一个中心位置,你可以将所有应用程序的用户相关功能和规则隔离在这里。通过使用接口实现你的代码所制定的规则将确保你的代码得到更好的支持,更容易操作,并且无错误。

活动 5.02:使用继承在 TypeScript 中创建一个用于车辆展示厅的原型 Web 应用程序

解决方案:

  1. 创建一个包含所有基础车辆公共方法和属性的父类,定义一个构造函数,允许你初始化这个类的基属性,并添加一个返回你的属性作为对象的方法。如果需要,添加访问修饰符来控制对属性和类方法的访问:

    class Motor {
        private name: string
        wheels: number
        bodyType: string
        constructor(name: string, wheels: number, bodyType: string) {
            this.name = name
            this.wheels = wheels
            this.bodyType = bodyType
        }
        protected getName(): string {
            return this.name
        }
        buildMotor() {
            return {
                wheels: this.wheels,
                bodyType: this.bodyType,
                name: this.name
            }
        }
    }
    
  2. 从你的父类派生两个子类,它们是车辆类型,例如CarTruck。覆盖你的构造函数,根据车辆类型向子类添加一些独特的属性:

    class Car extends Motor {
        rideHeight: number
        constructor(name: string, wheels: number, bodyType: string, rideHeight: number) {
            super(name, wheels, bodyType)
            this.rideHeight = rideHeight
        }
        _buildMotor() {
            return {
                ...super.buildMotor,
                rideHeight: this.rideHeight
            }
        }
    }
    class Truck extends Motor {
        offRoad: boolean
        constructor(name: string, wheels: number, bodyType: string, offRoad: boolean) {
            super(name, wheels, bodyType)
            this.offRoad = offRoad
        }
        _buildMotor() {
            return {
                wheels: this.wheels,
                bodyType: this.bodyType,
                offRoad: this.offRoad
            }
        }
    }
    
  3. 步骤 3中创建的子类之一派生一个类,例如Suv,它将具有卡车可能具有的一些东西,因此扩展Truck是合理的:

    class Suv extends Truck {
        roofRack: boolean
        thirdRow: boolean
        constructor(name: string, wheels: number, bodyType: string, 
            offRoad: boolean, roofRack: boolean, thirdRow: boolean) {
            super(name, wheels, bodyType, offRoad)
            this.roofRack = roofRack;
            this.thirdRow = thirdRow
        }
    }
    
  4. 实例化你的子类:

    const car: Car = new Car('blueBird', 4, 'sedan', 14)
    const truck: Truck = new Truck('blueBird', 4, 'sedan', true)
    const suv: Suv = new Suv('xtrail', 4, 'box', true, true, true)
    
  5. 输出我们的子类实例:

    console.log(car)
    console.log(truck)
    console.log(suv)
    

    你将获得以下输出:

    Car { name: 'blueBird', wheels: 4, bodyType: 'sedan', rideHeight: 14 }
    Truck { name: 'blueBird', wheels: 4, bodyType: 'sedan', offRoad: true }
    Suv {
      name: 'xtrail',
      wheels: 4,
      bodyType: 'box',
      offRoad: true,
      roofRack: true,
      thirdRow: true
    }
    

    在这个活动中,你创建了我们需要用于 Web 应用程序的最基本的类。我们已经展示了如何使用 TypeScript 中的继承来构建复杂性、重用和扩展应用程序代码。

6. 高级类型

活动 6.01:交集类型

解决方案:

  1. 创建一个Motor类型,它将包含一些你可以单独或与其他类型组合使用的常见属性,以描述车辆对象。你可以使用以下属性作为起点:colordoorswheelsfourWheelDrive

    type Motor = {
        color: string;
        doors: number;
        wheels: number;
        fourWheelDrive: boolean;
    }
    
  2. 创建一个具有卡车常见属性的Truck类型,例如doubleCabwinch

    type Truck = {
        doubleCab: boolean;
        winch: boolean;
    } 
    
  3. 交集这两个类型以创建一个PickUpTruck类型:

    type PickUpTruck = Motor & Truck;
    
  4. 构建TruckBuilder函数,它返回我们的PickUpTruck类型,并接受PickUpTruck作为参数:

    function TruckBuilder (truck: PickUpTruck): PickUpTruck {
        return truck
    }
    const pickUpTruck: PickUpTruck = {
        color: 'red',
        doors: 4,
        doubleCab: true,
        wheels: 4,
        fourWheelDrive: true,
        winch: true
    }
    
  5. 输出函数返回值:

    console.log (
        TruckBuilder(pickUpTruck)
    )
    

    一旦运行文件,你应该看到以下输出:

    {
      color: 'red',
      doors: 4,
      doubleCab: true,
      wheels: 4,
      fourWheelDrive: true,
      winch: true
    }
    

活动 6.02:联合类型

解决方案:

  1. 构建LandPackAirPack类型。确保有一个文字来识别包裹类型:

    type LandPack = {
        height: number,
        weight: number,
        type: "land",
        label?: string };
    type AirPack = {
        height: number,
        weight: number,
        type : "air",
        label?: string };
    
  2. 构造一个联合类型ComboPack,可以是LandPackAirPack

    type ComboPack = LandPack | AirPack
    
  3. 创建一个Shipping类来处理你的包裹。确保使用你的文字来识别你的包裹类型,并使用正确的标签修改你的包裹类型:

    class Shipping {
        Process(pack: ComboPack) {
            // check package type
            if(pack.type === "land") {
                return this.ToLand(pack);
            } else {
                return this.ToAir(pack);
            }
        }
        ToAir(pack: AirPack): AirPack {
            pack.label = "air cargo"
            return pack;
        }
        ToLand(pack: LandPack): LandPack {
            pack.label = "land cargo"
            return pack;
        }
    }
    
  4. 创建两个 AirPackLandPack 类型的包装对象。然后,实例化您的 Shipping 类,处理您的新对象,并输出修改后的对象:

    const airPack: AirPack = {
        height: 5,
        weight: 10,
        type: "air",
    };
    const landPack: LandPack = {
        height: 5,
        weight: 10,
        type: "land",
    };
    const shipping = new Shipping;
    console.log(
        shipping.Process(airPack)
    );
    console.log(
        shipping.Process(landPack)
    );
    

    一旦运行文件,您将获得以下输出:

    { height: 5, weight: 10, type: 'air', label: 'air cargo' }
    { height: 5, weight: 10, type: 'land', label: 'land cargo' } 
    

活动六.03:索引类型

解决方案:

  1. 使用具有 status 属性为 string 类型、值为 Boolean 类型的接口构建 PackageStatus 索引类型:

    interface PackageStatus { 
        [status: string]: boolean;}
    
  2. 创建一个包含 PackageStatus 类型属性和一些典型包装的常见属性的 Package 类型:

    type Package = {
        packageStatus: PackageStatus,
        barcode:  number,
        weight: number
    }
    
  3. 创建一个处理 Package 类型的类,该类在初始化时接受 Package 类型,有一个返回 packageStatus 属性的方法,以及一个更新并返回 packageStatus 属性的方法:

    class PackageProcess {
    
        pack: Package
        constructor(pack: Package) {
            this.pack = pack;
        }
    
        Status () {
            return this.pack.packageStatus;
        }
        UpdateStatus(status: string, state: boolean) {
            this.pack.packageStatus[status] = state;
            return this.Status();}
    }
    
  4. 创建一个名为 packPackage 对象:

    const pack: Package = {
        packageStatus: {"shipped": false, "packed": true, "delivered": true},
        barcode: 123456,
        weight: 28 
    };
    
  5. 使用您新的 pack 对象实例化您的 PackageProcess 类:

    const processPack = new PackageProcess(pack)
    
  6. 输出您的 pack 状态:

    console.log(processPack.Status());
    
  7. 更新您的 pack 状态,并输出您的新 pack 状态:

    console.log(
        processPack.UpdateStatus("shipped", true)
    );
    

    一旦运行文件,你应该获得以下输出:

    { shipped: false, packed: true, delivered: true }
    { shipped: true, packed: true, delivered: true }
    

    前面的输出中的第一行显示原始的 pack 状态,而第二行显示更新的 pack 状态。

7. 装饰器

活动七.01:创建用于调用计数的装饰器

解决方案:

  1. 创建一个名为 Person 的类,具有公共属性 firstNamelastNamebirthday

  2. 添加一个构造函数,通过构造函数参数初始化属性:

    class Person {
             constructor (public firstName: string, 
                         public lastName: string, 
                         public birthDate: Date) {
             }
    }
    
  3. 添加一个名为 _title 的私有字段,并通过 gettersetter 作为名为 title 的属性公开它:

        private _title: string;
        public get title() {
            return this._title;
        }
        public set title(value: string) {
            this._title = value;
        }
    
  4. 添加一个名为 getFullName 的方法,该方法将返回人的全名:

        public getFullName() {
            return `${this.firstName} ${this.lastName}`;
        }
    
  5. 添加一个名为 getAge 的方法,该方法将返回人的当前年龄(通过从当前年份减去生日):

        public getAge() {
            // only sometimes accurate
            const now = new Date();
            return now.getFullYear() – this.birthDate.getFullYear();
        }
    
  6. 创建一个名为 count 的全局对象,并将其初始化为空对象:

    const count = {};
    
  7. 创建一个名为 CountClass 的构造函数包装装饰器工厂,该工厂将接受一个名为 counterName 的字符串参数:

    type Constructable = { new (...args: any[]): {} };
    function CountClass(counterName: string) {
        return function <T extends Constructable>(constructor: T) {
            // wrapping code here
        }
    }
    
  8. 在包装代码内部,将 count 对象的 counterName 参数定义的属性增加 1,然后设置包装构造函数的原型链:

        const wrappedConstructor: any = function (...args: any[]) {
            const result = new constructor(...args);
            if (count[counterName]) {
                count[counterName]+=1;
            } else {
                count[counterName]=1;
            }
            return result;
        };
        wrappedConstructor.prototype = constructor.prototype;
        return wrappedConstructor;
    
  9. 创建一个名为 CountMethod 的方法包装装饰器工厂,该工厂将接受一个名为 counterName 的字符串参数:

    function CountMethod(counterName: string) {
        return function (target: any, propertyName: string, 
                         descriptor: PropertyDescriptor) {
            // method wrapping code here
        }
    }
    
  10. 添加对描述符参数是否具有 valuegetset 属性的检查:

        if (descriptor.value) {
            // method decoration code
        }
        if (descriptor.get) {
            // get property accessor decoration code
        }
        if (descriptor.set) {
            // set property accessor decoration code
        }
    
  11. 在每个相应的分支中,添加包装方法的代码:

        // method decoration code
        const original = descriptor.value;
        descriptor.value = function (...args: any[]) {
            // counter management code here
            return original.apply(this, args);
        }
        // get property accessor decoration code
        const original = descriptor.get;
        descriptor.get = function () {
            // counter management code here
            return original.apply(this, []);
        }
        // set property accessor decoration code
        const original = descriptor.set;
        descriptor.set = function (value: any) {
            // counter management code here
            return original.apply(this, [value]);
        }
    
  12. 在包装代码内部,将 count 对象的 counterName 参数定义的属性增加 1:

            // counter management code
            if (count[counterName]) {
                count[counterName]+=1;
            } else {
                count[counterName]=1;
            }          
    
  13. 使用 CountClass 装饰器装饰类,使用 person 参数:

    @CountClass('person')
    class Person{
    
  14. 使用 CountMethod 装饰器装饰 getFullNamegetAgetitle 属性获取器,分别使用 person-full-nameperson-ageperson-title 参数:

        @CountMethod('person-full-name')
        public getFullName() {
        @CountMethod('person-age')
        public getAge() {
        @CountMethod('person-title')
        public get title() {
    
  15. 在类外部编写代码,实例化三个 person 对象:

    const first = new Person("Brendan", "Eich", new Date(1961,6,4));
    const second = new Person("Anders", "Hejlsberg ", new Date(1960,11,2));
    const third = new Person("Alan", "Turing", new Date(1912,5,23));
    
  16. 编写代码以调用对象的 getFullNamegetAge 方法:

    const fname = first.getFullName();
    const sname = second.getFullName();
    const tname = third.getFullName();
    const fage = first.getAge();
    const sage = second.getAge();
    const tage = third.getAge();
    
  17. 编写代码检查 title 属性是否为空,如果为空则设置它:

    if (!first.title) {
        first.title = "Mr."
    }
    if (!second.title) {
        second.title = "Mr."
    }
    if (!third.title) {
        third.title = "Mr."
    }
    
  18. 编写代码将 count 对象记录到控制台:

    console.log(count);
    

    一旦运行文件,你将在控制台获得以下输出:

    {
      person: 3,
      'person-full-name': 3,
      'person-age': 3,
      'person-title': 6
    }
    

活动 7.02:使用装饰器应用横切关注点

解决方案:

  1. 创建 BasketBallGame 类的代码:

        interface Team {
            score: number;
            name: string;
        }
        class BasketBallGame {
            private team1: Team;
            private team2: Team;
            constructor(teamName1: string, teamName2: string) {
                this.team1 = { score: 0, name: teamName1 };
                this.team2 = { score: 0, name: teamName2 };
            }
            getScore() {
                return `${this.team1.score}:${this.team2.score}`;
            }
            updateScore(byPoints: number, updateTeam1: boolean) {
                if (updateTeam1) {
                    this.team1.score += byPoints;
                } else {
                    this.team2.score += byPoints;
                }
            }
        }
    
  2. 创建一个名为 Authenticate 的类装饰器工厂,它将接受一个 permission 参数并返回一个具有构造函数包装的类装饰器。类装饰器应加载 permissions 元数据属性(字符串数组),然后检查传递的参数是否为数组的元素。如果传递的参数不是数组的元素,则类装饰器应抛出错误;如果存在,则应继续进行类创建:

        type Constructable = { new (...args: any[]): {} };
        function Authenticate(permission: string) {
            return function <T extends Constructable>(constructor: T) {
                const wrappedConstructor: any = function (...args: any[]) {
                    if (Reflect.hasMetadata("permissions", wrappedConstructor)) {
                        const permissions = Reflect.getMetadata("permissions", 
                                                         wrappedConstructor) as string[];
                        if (!permissions.includes(permission)) {
                            throw Error(`Permission ${permission} not present`);
                        }
                    }
                    const result = new constructor(...args);
                    return result;
                };
                wrappedConstructor.prototype = constructor.prototype;
                return wrappedConstructor;
            };
        }
    
  3. 定义 BasketBallGame 类的一个名为 permissions 的元数据属性,其值为 ["canUpdateScore"]:

        Reflect.defineMetadata("permissions", ["canUpdateScore"], BasketBallGame);
    
  4. BasketBallGame 类上应用类装饰器工厂,参数值为 "canUpdateScore":

        @Authenticate("canUpdateScore")
        class BasketBallGame {
    
  5. 创建一个名为 MeasureDuration 的方法装饰器,它将使用方法包装在方法体执行前启动计时器,并在完成后停止。你需要计算持续时间并将其推送到名为 durations 的方法元数据属性中:

        function MeasureDuration() {
            return function (target: any, propertyName: string, 
                                         descriptor: PropertyDescriptor) {
                if (descriptor.value) {
                    const original = descriptor.value;
                    descriptor.value = function (...args: any[]) {
                        const start = Date.now();
                        const result = original.apply(this, args);
                        const end = Date.now();
                        const duration = end-start;
                        if (Reflect.hasMetadata("durations", target, propertyName)) {
                          const existing = Reflect.getMetadata("durations", 
                                                       target, propertyName) as number[];
                          Reflect.defineMetadata("durations", existing.concat(duration),
                                                       target, propertyName);
                        } else {
                          Reflect.defineMetadata("durations", [duration], 
                                                       target, propertyName)
                        }
                        return result;
                    }
                }
            }
        }
    
  6. updateScore 方法上应用 MeasureDuration 方法装饰器:

        @MeasureDuration()
        updateScore(byPoints: number, updateTeam1: boolean) {
    
  7. 创建一个名为 Audit 的方法装饰器工厂,它将接受一个消息参数并返回一个方法装饰器。该方法装饰器应使用方法包装来获取方法的参数和返回值。在原始方法成功执行后,它应在控制台显示审计日志:

        function Audit(message: string) {
            return function (target: any, propertyName: string, 
                                          descriptor: PropertyDescriptor) {
              if (descriptor.value) {
                const original = descriptor.value;
                descriptor.value = function (...args: any[]) {
                  const result = original.apply(this, args);
                  console.log(`[AUDIT] ${message} (${propertyName}) called with:`)
                  console.log("[AUDIT]", args);
                  console.log("[AUDIT] and returned result:")
                  console.log("[AUDIT]", result);
                  return result;
                }
              }
           }
        }
    
  8. updateScore 方法上应用 Audit 方法装饰器工厂,参数值为 Updated score

        @MeasureDuration()
        @Audit("Updated score")
        updateScore(byPoints: number, updateTeam1: boolean) {
    
  9. 创建一个名为 OneTwoThree 的参数装饰器,它将添加到 one-two-three 元数据属性中:

        function OneTwoThree(target: any, propertyKey: string, 
                                          parameterIndex: number) {
            if (Reflect.hasMetadata("one-two-three", target, propertyKey)) {
                const existing = Reflect.getMetadata("one-two-three",
                                               target, propertyKey) as number[];
                Reflect.defineMetadata("one-two-three", 
                           existing.concat(parameterIndex), target, propertyKey);
            } else {
                Reflect.defineMetadata("one-two-three", 
                                          [parameterIndex], target, propertyKey);
            }
        }
    
  10. 创建一个名为 Validate 的方法装饰器,它将使用方法包装来加载 one-two-three 元数据属性的所有值,并检查所有标记参数的值。如果值为 1、2 或 3,则应继续执行原始方法。如果不是,则应停止执行并显示错误:

        function Validate() {
          return function (target: any, propertyKey:string, 
                                          descriptor: PropertyDescriptor) {
                const original = descriptor.value;
                descriptor.value = function (...args: any[]) {
                    // validate parameters
                    if (Reflect.hasMetadata("one-two-three", 
                                            target, propertyKey)) {
                        const markedParams = Reflect.getMetadata("one-two-three",
                                            target, propertyKey) as number[];
                        for (const marked of markedParams) {
                            if (![1,2,3].includes(args[marked])) {
                                throw Error(`The parameter at position ${marked} can only be 1, 2 or 3`);
                            }
                        }
                    }
                    return original.apply(this, args);
                }
            }
        }
    
  11. OneTwoThree 装饰器应用于 updateScorebyPoints 参数,并将 Validate 装饰器应用于 updateScore 方法。

        @MeasureDuration()
        @Audit("Updated score")
        @Validate()
        updateScore(@OneTwoThree byPoints: number, updateTeam1: boolean) {
    
  12. 创建一个 game 对象并更新其分数几次:

    const game = new BasketBallGame("LA Lakers", "Boston Celtics");
    game.updateScore(3, true);
    game.updateScore(2, false);
    game.updateScore(2, true);
    game.updateScore(2, false);
    game.updateScore(2, false);
    game.updateScore(2, true);
    game.updateScore(2, false);
    

    当你运行文件时,控制台应反映所有装饰器的应用:

    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 3, true ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 2, false ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 2, true ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 2, false ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 2, false ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 2, true ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    [AUDIT] Updated score (updateScore) called with arguments:
    [AUDIT] [ 2, false ]
    [AUDIT] and returned result:
    [AUDIT] undefined
    7:8
    

8. TypeScript 中的依赖注入

活动 8.01:基于 DI 的计算器

解决方案:

在这个活动中,我们将构建一个基本的计算器,该计算器利用依赖注入(DI)来评估数学表达式,并将输出记录到控制台或文件中:

  1. 首先,定义我们计算器的基本构建块——一个操作符。这是通过一个接口定义的,实际的实现可以依赖于它:

    export interface Operator {
        readonly symbol: string;
        evaluate(a: number, b: number): number;
    }
    

    您需要在src/interfaces文件夹中创建这个文件,并将其保存为operator.interface.ts

  2. 接下来,实现第一个操作符——加法操作符。这将是一个实现Operator接口的类:

    import { Operator } from '../interfaces/operator.interface';
    export class AddOperator implements Operator {
        readonly symbol = '+';
        public evaluate(a: number, b: number) {
        return a + b;
        }
    }
    

    上述代码需要在一个名为add.operator.ts的文件中编写,位于src\operators文件夹中。

  3. 通过在类中添加@injectable装饰器,使这个操作符可供 InversifyJS 注入:

    import { injectable } from 'inversify';
    import { Operator } from '../interfaces/operator.interface';
    @injectable()
    export class AddOperator implements Operator {
        readonly symbol = '+';
        public evaluate(a: number, b: number) {
            return a + b;
        }
    }
    
  4. 接下来,由于接口在运行时不存在,我们需要为AddOperator的抽象创建一些运行时表示。这通常是通过符号完成的,并将由 InversifyJS 在运行时用来理解需要注入的内容。我们将在TYPES常量下定义它,我们将在以后添加其他符号:

    export const TYPES = {
        AddOperator: Symbol.for('AddOperator'),
    };
    

    这段代码需要在一个新的文件中编写,并保存到src\types\文件夹中。我们给这个文件命名为index.ts

  5. 现在,为我们的计算器构建一个初步草案,它将使用AddOperator通过 DI:

    import { injectable, inject } from 'inversify';
    import { TYPES } from '../types';
    import { AddOperator } from '../operators/add.operator';
    @injectable()
    export class Calculator {
        constructor(@inject(TYPES.AddOperator) private addOperator: AddOperator) {}
        evaluate(expression: string) {
            const expressionParts = expression.match(/[\d\.]+|\D+/g);
            if (expressionParts === null) return null;
            // for now, we're only going to support basic expressions: X+Y
            const [operandA, operator, operandB] = expressionParts;
            if (operator !== this.addOperator.symbol) {
                throw new Error(`Unsupported operator. Expected ${this.addOperator.symbol}, received: ${operator}.`);
            }
            const result = this.addOperator.evaluate(Number(operandA), Number(operandB));
            return result;
        }
    }
    

    在这里,我们实现一个Calculator类,它只有一个方法——evaluate,该方法接收一个字符串形式的表达式,并返回该表达式的结果。这段代码需要在一个名为index.ts的新文件中编写,并保存在src/calculator文件夹中。

    注意

    当前实现仅支持 X+Y 形式的表达式(其中 X 和 Y 可以是任何数字)。我们将在活动后期修复这个问题。

    计算器通过 DI 获取AddOperator,为了评估表达式,它首先通过正则表达式按数字分割它,然后解构结果数组。最后,它使用AddOperatorevaluate方法执行最终的表达式评估。

    这意味着计算器的职责仅仅是将表达式解构为其各个部分,然后将其传递给AddOperator来处理数学评估逻辑。这展示了使用 DI 如何帮助保持 SOLID 原则中的单一职责原则。

  6. 配置 IoC 容器(在src/ioc.config.ts文件中),以便当Calculator请求TYPES.AddOperator时,可以接收AddOperator

    import { Container } from 'inversify';
    import { Calculator } from './calculator/index';
    import { Operator } from './interfaces/operator.interface';
    import { AddOperator } from './operators/add.operator';
    import { TYPES } from './types';
    export const container = new Container();
    container.bind<Operator>(TYPES.AddOperator).to(AddOperator);
    container.bind(Calculator).toSelf();
    
  7. 最后,我们的主文件(src/main.ts),当运行应用程序时启动,如下所示:

    import 'reflect-metadata';
    import { Calculator } from './calculator/index';
    import { container } from './ioc.config';
    const calculator = container.get(Calculator);
    try {
        const result = calculator.evaluate('13+5');
        console.log('result is', result);
    } catch (err) {
        console.error(err);
    }
    

    这只是使用我们之前定义的 IoC 容器并请求一个Calculator实例。这是我们如何在 InversifyJS 的命令式 API 中显式请求符号实例的方式,在这里我们需要这样做,因为我们想启动一些事情。由于 InversifyJS 是创建Calculator的,它也会查看其构造函数,并看到我们请求了TYPES.AddOperator,然后它再次在 IoC 容器中查找以解析,并将其提供给Calculator的构造函数。

    一旦运行此文件,您应该获得以下输出:

    result is 18
    

    注意,您可以通过在activity-starter文件夹中执行npm start或通过在src文件夹中执行npx ts-node main.ts来运行代码。

    注意

    如果 AddOperator 类也需要使用 @inject 来依赖注入,那么上述过程将再次重复以获取它们,依此类推,直到所有依赖项都得到解决。

  8. 接下来,我们可以实现其他操作符,类似于我们实现 AddOperator 的方式——只需将符号替换为相关的符号(-*/)并将评估方法的实现替换为相关的数学运算:

  9. 下面是 SubtractOperator 的代码 (subtract.operator.ts):

    // operators/subtract.operator.ts
    import { injectable } from 'inversify';
    import { Operator } from '../interfaces/operator.interface';
    @injectable()
    export class SubtractOperator implements Operator {
        readonly symbol = '-';
        public evaluate(a: number, b: number) {
            return a - b;
        }
    }
    
  10. 下面是 MultiplyOperator 的代码 (multiply.operator.ts):

    // operators/multiply.operator.ts
    import { injectable } from 'inversify';
    import { Operator } from '../interfaces/operator.interface';
    @injectable()
    export class MultiplyOperator implements Operator {
        readonly symbol = '*';
        public evaluate(a: number, b: number) {
            return a * b;
        }
    }
    
  11. 下面是 DivideOperator 的代码 (divide.operator.ts):

    // operators/divide.operator.ts
    import { injectable } from 'inversify';
    import { Operator } from '../interfaces/operator.interface';
    @injectable()
    export class DivideOperator implements Operator {
        readonly symbol = '/';
        public evaluate(a: number, b: number) {
            return a / b;
        }
    }
    

    现在,我们不再为每个 Operator 创建一个注入令牌,将每个注入到 Calculator 中,然后对每个进行操作,而是可以借助 @multiInject 装饰器创建一个更通用的 Calculator 实现。这个装饰器允许指定一个注入令牌,并获取为该令牌注册的所有实现数组。这样,Calculator 就不再耦合到任何特定操作符的抽象,而只得到一个动态的操作符列表,只要它们符合 Operator 接口,就可以有任意的实现。

  12. 更新 types/index.ts 文件,代码如下:

    export const TYPES = {
        Operator: Symbol.for('Operator'),
    };
    

    这将替换我们之前使用的 AddOperator 符号,使用一个更通用的符号。

  13. 更新计算器应用程序代码 (src/calculator/index.ts):

    import { injectable, tryParseNumberString and tryParseOperatorSymbol. Both these functions are created in the math.ts file placed in the src/utils folder.
    
  14. 更新 ioc.config.ts 文件:

    import { Container } from 'inversify';
    import { Calculator } from './calculator';
    import { Operator } from './interfaces/operator.interface';
    import { AddOperator } from './operators/add.operator';
    import { DivideOperator } from './operators/divide.operator';
    import { MultiplyOperator } from './operators/multiply.operator';
    import { SubtractOperator } from './operators/subtract.operator';
    import { TYPES } from './types';
    export const container = new Container();
    container.bind<Operator>(TYPES.Operator).to(AddOperator);
    container.bind<Operator>(TYPES.Operator).to(SubtractOperator);
    container.bind<Operator>(TYPES.Operator).to(MultiplyOperator);
    container.bind<Operator>(TYPES.Operator).to(DivideOperator);
    container.bind(Calculator).toSelf();
    
  15. 接下来,修复 Calculator 的简单 evaluate 方法,使其更加通用。首先,不再依赖于特定的标记,而是自己实现 tryParseNumberStringtryParseOperatorSymbol。然而,你可以参考 utils/math.ts 来帮助你完成这一步。

  16. 然后,减少这个数组以获得我们的最终结果:

    evaluate(expression: string) {
        // ...
        const { result } = parsedExpressionParts.reduce<{ result: number; queuedOperator: Operator | null }>((acc, part) => {
            if (typeof part === 'number') {
                // this is the first number we've encountered, just set the result to that.
                if (acc.queuedOperator === null) {
                    return { ...acc, result: part };
                }
                // there's a queued operator – evaluate the previous result with this and
                // clear the queued one.
                return {
                    queuedOperator: null,
                    result: acc.queuedOperator.evaluate(acc.result, part),
                  };
            }
            // this is an operator – queue it for later execution
            return {
                ...acc,
                queuedOperator: part,
            };
        }, { result: 0, queuedOperator: null });
        return result;
    }
    
  17. 通过利用 barrels 进一步简化 ioc.config.ts 文件中的代码。创建 operator/index.ts,代码如下:

    // operators/index.ts
    export * from './add.operator';
    export * from './divide.operator';
    export * from './multiply.operator';
    export * from './subtract.operator';
    
  18. 更新 ioc.config.ts 文件:

    // ioc.config.ts
    import { Container } from 'inversify';
    import { Calculator } from './calculator';
    import { Operator } from './interfaces/operator.interface';
    import * as Operators from './operators';
    import { TYPES } from './types';
    export const container = new Container();
    Object.values(Operators).forEach(Operator => {
        container.bind<Operator>(TYPES.Operator).to(Operator);
    });
    container.bind(Calculator).toSelf();
    

    这意味着我们现在从 barrel 文件中导入一个 Operators 对象,它包含了那里暴露的所有内容。我们取这个 barrel 对象的值,并将每个值绑定到 TYPES.Operator,实现通用化。

    这意味着添加另一个 Operator 对象只需要我们创建一个新的类来实现 Operator 接口,并将其添加到我们的 operators/index.ts 文件中。其余的代码应该无需任何更改即可正常工作。

  19. 我们的 main.ts 文件被修改为一个稍微复杂一些的表达式:

    import 'reflect-metadata';
    import { Calculator } from './calculator';
    import { container } from './ioc.config';
    const calculator = container.get(Calculator);
    try {
        const result = calculator.evaluate('13*10+20');
        console.log('result is', result);
    } catch (err) {
        console.error(err);
    }
    

    当你运行 main.ts 文件(使用 npx ts-node main.ts)时,你应该获得以下输出:

    result is 150
    

Bonus:

  1. 作为奖励,假设我们想要对计算器中执行的操作进行一些报告。我们可以非常容易地添加日志记录,而无需太多更改。我们将创建两个报告实现,一个输出到控制台,另一个输出到文件系统:

    注意

    文件系统实现仅在 Node.js 环境中工作,因为它将使用仅对该环境可用的某些模块。

  2. 定义 Logger 接口:

    export interface Logger {
        log(message: string, ...args: any[]): void;
        warn(message: string, ...args: any[]): void;
        error(message: string, ...args: any[]): void;
    }
    

    这将作为想要使用日志记录器的消费者可以使用的公共 API,并且我们的实现需要遵循。

  3. 首先创建基于控制台的Logger实现:

    import { injectable } from 'inversify';
    import { Logger } from '../interfaces/logger.interface';
    @injectable()
    export class ConsoleLogger implements Logger {
        log(message: string, ...args: any[]) {
            console.log('[LOG]', message, ...args);
        }
        warn(message: string, ...args: any[]) {
            console.warn('[WARN]', message, ...args);
        }
        error(message: string, ...args: any[]) {
            console.error('[ERROR]', message, ...args);
        }
    }
    

    这是一个围绕浏览器引擎和 Node.js 中内置的console对象构建的简单包装类。它遵循我们的Logger接口,因此允许消费者依赖它。为了示例,我们还将在实际输出的开头添加消息类型。

  4. 接下来,为它创建一个注入令牌,并在我们的容器中注册它。types/index.ts文件的更新代码如下:

    // types/index.ts
    export const TYPES = {
        Operator: Symbol.for('Operator'),
        Logger: Symbol.for('Logger'),
    };
    

    src/ioc.config.ts文件的更新代码如下:

    // ioc.config.ts
    import { Container } from 'inversify';
    import { Calculator } from './calculator';
    import { Logger } from './interfaces/logger.interface';
    import { Operator } from './interfaces/operator.interface';
    import { ConsoleLogger } from './logger/console.logger';
    import * as Operators from './operators';
    import { TYPES } from './types';
    export const container = new Container();
    Object.values(Operators).forEach(Operator => {
        container.bind<Operator>(TYPES.Operator).to(Operator);
    });
    container.bind(Calculator).toSelf();
    container.bind<Logger>(TYPES.Logger).to(ConsoleLogger);
    
  5. 最后,在我们的Calculator类中使用该日志记录器:

    import { injectable, multiInject, inject, optional } from 'inversify';
    import { Operator } from '../interfaces/operator.interface';
    import { TYPES } from '../types';
    import { tryParseNumberString, tryParseOperatorSymbol } from '../utils/math';
    import { Logger } from '../interfaces/logger.interface';
    @injectable()
    export class Calculator {
        constructor(
            @multiInject(TYPES.Operator) private operators: Operator[],
            @inject(TYPES.Logger) @optional() private logger?: Logger
        ) {}
        evaluate(expression: string) {
            // ...
            const { result } = parsedExpressionParts.reduce<{ result: number; queuedOperator: Operator | null }>( ... );
            this.logger && this.logger.log(`Calculated result of expression: ${expression} to be: ${result}`);
            return result;
        }
    }
    

    注意,我们使用@optional装饰器来告知 InversifyJS,Calculator不需要Logger来操作,但如果它有一个可以注入的LoggerCalculator可以使用它。这也是为什么它被标记为构造函数中的可选参数,以及为什么我们需要在调用log方法之前检查它是否存在。

    运行时的控制台输出应如下所示:

    [LOG] Calculated result of expression:13*10+20 is 150
    

    现在,假设我们想要将我们的基于控制台的日志记录器替换为基于文件的,这样它就可以在运行之间持久化,以便我们可以跟踪计算器的评估历史。

  6. 创建一个实现LoggerFileLogger类:

    import fs from 'fs';
    import { injectable } from 'inversify';
    import { Logger } from '../interfaces/logger.interface';
    @injectable()
    export class FileLogger implements Logger {
        private readonly loggerPath: string = '/tmp/calculator.log';
        log(message: string, ...args: any[]) {
            this.logInternal('LOG', message, args);
        }
        warn(message: string, ...args: any[]) {
            this.logInternal('WARN', message, args);
        }
        error(message: string, ...args: any[]) {
            this.logInternal('ERROR', message, args);
        }
        private logInternal(level: string, message: string, ...args: any[]) {
            fs.appendFileSync(this.loggerPath, this.logLineFormatter(level, message, args));
        }
        private logLineFormatter(level: string, message: string, ...args: any[]) {
            return `[${level}]: ${message}${args}\n`;
        }
    }
    
  7. 最后,为了将我们的基于控制台的日志记录器替换为基于文件的,我们只需要在 IoC 容器配置中进行单行更改。

    对于基于控制台的日志记录,使用以下命令:

    container.bind<Logger>(TYPES.Logger).to(ConsoleLogger);
    

    对于基于文件的日志记录,使用以下命令:

    container.bind<Logger>(TYPES.Logger).to(FileLogger);
    

    确保在ioc.config.ts文件中正确导入此日志记录器。

    最终的文件输出如下:

    图 8.8:在 activity-starter/src//tmp/calculator.log 中基于文件的日志记录器的最终输出,在将应用程序更改为使用它之后

图 8.8:在 activity-starter/src//tmp/calculator.log 中基于文件的日志记录器的最终输出,在将应用程序更改为使用它之后

9. 泛型和条件类型

活动 9.01:创建 DeepPartial类型

解决方案:

让我们逐步构建这个类型:

  1. 首先,让我们创建一个PartialPrimitive类型:

    type PartialPrimitive = string | number | boolean | symbol | bigint | Function | Date;
    
  2. 然后,让我们首先定义一个基本的DeepPartial<T>类型:

    type DeepPartial<T> = T extends PartialPrimitive ? T : Partial<T>;
    

    接下来,我们需要处理更复杂的数据结构,例如数组、集合和映射。这些需要使用infer关键字,并且对于这些类型中的每一个,还需要进行一些“手动连接”。

  3. 让我们从添加对Array类型的处理开始:

    type DeepPartial<T> =
         T extends PartialPrimitive
         ? T
         : T extends Array<infer U>
         ? Array<DeepPartial<U>>
         : Partial<T>;
    

    这本来可以工作,但由于在编写时 TypeScript 的限制,由于DeepPartial<T>循环引用自身,因此无法编译:

    图 9.17:当前 TypeScript 版本的限制不允许使用泛型    类型可以引用自身

    interface DeepPartialArray<T> extends Array<DeepPartial<T>> {}
    type DeepPartial<T> =
         T extends PartialPrimitive
         ? T
         : T extends Array<infer U>
         ? DeepPartialArray<U>
         : Partial<T>;
    

    这解决了问题,并且编译良好。

  4. 接下来,为了支持 Set,我们需要采用与上一步类似的方法,因此我们将创建一个 interface 作为构建整个泛型类型的“中间人”:

    interface DeepPartialArray<T> extends Array<DeepPartial<T>> {}
    interface DeepPartialSet<T> extends Set<DeepPartial<T>> {}
    type DeepPartial<T> = T extends PartialPrimitive
        ? T
        : T extends Array<infer U>
        ? DeepPartialArray<U>
        : T extends Set<infer U>
        ? DeepPartialSet<U>
        : Partial<T>;
    
  5. 与数组和集合类似,映射也需要创建一个 interface 作为构建整个泛型类型的“中间人”:

    interface DeepPartialArray<T> extends Array<DeepPartial<T>> {}
    interface DeepPartialSet<T> extends Set<DeepPartial<T>> {}
    interface DeepPartialMap<K, V> extends Map<DeepPartial<K>, DeepPartial<V>> {}
    type DeepPartial<T> = T extends PartialPrimitive
        ? T
        : T extends Array<infer U>
        ? DeepPartialArray<U>
        : T extends Map<infer K, infer V>
        ? DeepPartialMap<K, V>
        : T extends Set<infer U>
        ? DeepPartialSet<U>
        : Partial<T>;
    

    注意

    由于 TypeScript 3.7 的更新,此解决方案不再需要。

  6. 最后,让我们让我们的 DeepPartial<T> 类型也支持对象:

    type DeepPartial<T> = T extends PartialPrimitive
        ? T
        : T extends Array<infer U>
        ? DeepPartialArray<U>
        : T extends Map<infer K, infer V>
        ? DeepPartialMap<K, V>
        : T extends Set<infer U>
        ? DeepPartialSet<U>
        : T extends {}
        ? { [K in keyof T]?: DeepPartial<T[K]> }
        : Partial<T>;
    

    这完成了 DeepPartial<T> 的实现。

    DeepPartial<T> 类型的优秀用例是在服务器端的 PATCH 方法处理器中,它使用新数据更新给定的资源。在 PATCH 请求中,所有字段通常是可选的:

    import express from 'express';
    const app = express();
    app.patch('/users/:userId', async (req, res) => {
        const userId = req.params.userId;
        const userUpdateData: DeepPartial<User> = req.body;
        const user = await User.getById(userId);
        await user.update(userUpdateData);
        await user.save();
        res.status(200).end(user);
    });
    

    注意,我们在将请求体传递给 update 方法之前,使用 DeepPartial<User> 正确地类型化请求体:

![图 9.18:正确类型化的请求体

![img/B14508_09_18.jpg]

图 9.18:正确类型化的请求体

如前图所示,由于使用了 DeepPartial<T>,请求的体被正确类型化,使得所有字段都是可选的,包括嵌套字段。

10. 事件循环和异步行为

活动 10.01:使用 XHR 和回调的影片浏览器

解决方案:

  1. script.ts 文件中,找到 search 函数并验证它接受一个字符串参数,并且其体为空。

  2. 构造一个新的 XMLHttpRequest 对象:

        const xhr = new XMLHttpRequest();
    
  3. 使用 getSearchUrl 方法构造一个新的搜索结果 URL:

        const url = getSearchUrl(value);
    
  4. 调用 xhr 对象的 opensend 方法:

        xhr.open('GET', url);    xhr.send();
    
  5. xhr 对象的 onload 事件添加事件处理器。将响应解析为 JSON 对象,并将其存储在 SearchResultApi 接口类型的变量中。这些数据将在 results 字段中包含我们的搜索结果。如果没有结果,这意味着我们的搜索失败:

        xhr.onload = function() {        const data = JSON.parse(this.response) as SearchResultApi;    }
    
  6. 如果搜索没有返回结果,调用 clearResults 方法:

        if (data.results.length === 0) {        clearResults(value);    } 
    
  7. 如果搜索返回了一些结果,只需取第一个并将其存储在一个变量中,忽略其他结果:

        else {        const resultMovie = data.results[0];    }
    
  8. onload 处理器内部,在成功的搜索分支中,创建一个新的 XMLHttpRequest 对象:

        const movieXhr = new XMLHttpRequest();
    
  9. 使用 getMovieUrl 方法构造一个新的搜索结果 URL:

        const movieUrl = getMovieUrl(resultMovie.id);
    
  10. 再次调用构造的 xhr 对象的 opensend 方法:

        movieXhr.open('GET', movieUrl);    movieXhr.send();
    
  11. xhr 对象的 onload 事件添加事件处理器。将响应解析为 JSON 对象,并将其存储在 MovieResultApi 接口类型的变量中。此响应将包含我们电影的通用数据,具体来说,是除了参与电影的人之外的所有内容。我们将需要另一个 API 调用来获取关于人的数据:

        movieXhr.onload = function () {        const movieData: MovieResultApi = JSON.parse(this.response);
    
  12. onload 处理器内部,创建一个新的 XMLHttpRequest 对象:

        const peopleXhr = new XMLHttpRequest();
    
  13. 使用 getPeopleUrl 方法构造一个新的搜索结果 URL:

        const peopleUrl = getPeopleUrl(resultMovie.id);
    
  14. 调用构造的 xhr 对象的 opensend 方法:

        peopleXhr.open('GET', peopleUrl);    peopleXhr.send();
    
  15. xhr对象的onload事件添加事件处理器。获取响应,并将其解析为 JSON 对象。将结果存储在PeopleResultApi接口的变量中。此响应将包含有关参与电影的人员的数据:

        const data = JSON.parse(this.response) as PeopleResultApi;
    
  16. 现在我们实际上已经拥有了所有需要的数据,因此我们可以在人员onload处理器中创建自己的对象,该处理器位于电影onload处理器中,而电影onload处理器又位于搜索onload处理器中。

    人员数据具有castcrew属性。我们只取前六个演员,所以首先根据演员的order属性对cast属性进行排序。然后从第一个六个演员中切出新的数组:

        data.cast.sort((f, s) => f.order - s.order);    const mainActors = data.cast.slice(0, 6);
    
  17. 将演员数据(CastResultApi对象)转换为您的Character对象。我们需要将CastResultApicharacter字段映射到Charactername字段,将name字段映射到演员名字,将profile_path字段映射到image属性:

        const characters: Character[] = mainActors.map(actor => ({        name: actor.character,        actor: actor.name,        image: actor.profile_path     }))
    
  18. 从人员数据的crew属性中,我们只需要导演和编剧。由于可能有多个导演和编剧,我们将分别获取所有导演和编剧的名字并将它们连接起来。对于导演,从crew属性中筛选出具有Directing部门和Director职位的员工。对于这些对象,取name属性,并用&连接起来:

        const directors = data.crew         .filter(person => person.department === "Directing" && person.job === "Director")        .map(person => person.name)    const directedBy = directors.join(" & ");
    
  19. 对于编剧,从crew属性中筛选出具有Writing部门和Writer职位的员工。对于这些对象,取name属性,并用&连接起来:

        const writers = data.crew         .filter(person => person.department === "Writing" && person.job === "Writer")        .map(person => person.name);    const writtenBy = writers.join(" & ");
    
  20. 创建一个新的Movie对象(使用对象字面量语法)。使用我们迄今为止准备的电影和人员响应中的数据填写Movie对象的全部属性:

        const movie: Movie = {        id: movieData.id,        title: movieData.title,        tagline: movieData.tagline,        releaseDate: new Date(movieData.release_date),        posterUrl: movieData.poster_path,        backdropUrl: movieData.backdrop_path,        overview: movieData.overview,        runtime: movieData.runtime,
            characters: characters,        directedBy: directedBy,        writenBy: writtenBy     }
    
  21. 使用我们构建的电影调用showResults函数:

        showResults(movie);
    
  22. 在您的父目录(在这种情况下为Activity01)中,使用npm i安装依赖项。

  23. 使用tsc ./script.ts ./interfaces.ts ./display.ts编译程序。

  24. 验证编译是否成功结束。

  25. 使用您选择的浏览器打开index.html

    您应该在浏览器中看到以下内容:

![图 10.5:最终网页img/B14508_10_04.jpg

图 10.5:最终网页

活动十.02:使用 fetch 和 Promises 的影片浏览器

解决方案:

  1. script.ts文件中,找到search函数并验证它接受一个字符串参数,并且其主体为空。

  2. search函数上方创建一个名为getJsonData的辅助函数。此函数将使用fetch API 从端点获取数据并将其格式化为 JSON。它应接受一个名为url的单个字符串参数,并且它应该返回一个 promise:

    const getJsonData = (url: string):Promise<any> => {}
    
  3. getJsonData函数的主体中,添加调用带有url参数的fetch函数的代码,然后调用返回响应的json方法:

    const getJsonData = (url: string):Promise<any> => {    return fetch(url)        .then(response => response.json());}
    
  4. search 方法中,使用 getSearchUrl 方法为搜索结果 URL 构造一个新的字符串:

        const searchUrl = getSearchUrl(value);
    
  5. 使用 searchUrl 作为参数调用 getJsonData 函数:

        return getJsonData(searchUrl)
    
  6. 在从 getJsonData 返回的承诺中添加一个 then 处理器。该处理器接受一个类型为 SearchResultApi 的单个参数:

        return getJsonData(url)        .then((data:SearchResultApi) => {        }
    
  7. 在处理器的主体中,检查是否有任何结果,如果没有,则抛出错误。如果有结果,则返回第一个项目。请注意,处理器返回一个具有 idtitle 属性的对象,但实际上 then 方法返回的是该数据的承诺。这意味着在处理器之后,我们可以链式调用其他 then 调用:

        .then((data:SearchResultApi) => {        if (data.results.length === 0) {            throw Error("Not found");        }        return data.results[0];    })
    
  8. 在先前的处理器中添加另一个 then 调用。这个处理器将接受一个包含电影 idtitlemovieResult 参数。使用 id 属性调用 getMovieUrlgetPeopleUrl 方法,分别获取电影详情和演员阵容及制作团队的正确 URL:

        })    .then(movieResult => {        const movieUrl = getMovieUrl(movieResult.id);        const peopleUrl = getPeopleUrl(movieResult.id);    })
    
  9. 在获取到 URL 之后,使用这两个 URL 调用 getJsonData 函数,并将结果值赋给变量。请注意,getJsonData(movieUrl) 调用将返回 MovieResultApi 的承诺,而 getJsonData(peopleUrl) 将返回 PeopleResultApi 的承诺。将这些结果值赋给名为 dataPromisepeoplePromise 的变量:

        const movieUrl = getMovieUrl(movieResult.id);    const peopleUrl = getPeopleUrl(movieResult.id);    const dataPromise: Promise<MovieResultApi> = getJsonData(movieUrl);    const peoplePromise: Promise<PeopleResultApi> = getJsonData(peopleUrl);
    
  10. 使用 dataPromisepeoplePromise 作为参数调用静态 Promise.all 方法。这将基于这两个值创建另一个承诺,并且只有当包含在内的所有承诺都成功解决时,这个承诺才会成功解决。它的返回值将是一个包含结果的数组承诺:

        const resultPromise = Promise.all([dataPromise, peoplePromise]);
    
  11. 从处理器返回由 Promise.all 调用生成的承诺:

            return resultPromise;    })
    
  12. 在链中添加另一个 then 处理器。这个处理器将接受 Promise.all 返回的数组作为单个参数:

        })    .then(dataResult => {    });
    
  13. 将参数解构为两个变量。数组中的第一个元素应该是 movieData 变量,其类型为 MovieResultApi,而数组的第二个元素应该是 peopleData 变量,其类型为 PeopleResultApi

        const [movieData, peopleData] = dataResult // we can actually let TypeScripts type inference pick out the types
    
  14. 人物数据有 castcrew 属性。我们只取前六个演员,所以首先根据演员成员的 order 属性对 cast 属性进行排序。然后从数组中截取前六个演员成员到一个新的数组中:

        peopleData.cast.sort((f, s) => f.order - s.order);    const mainActors = peopleData.cast.slice(0, 6);
    
  15. 将演员数据(即 CastResultApi 对象)转换为我们自己的 Character 对象。我们需要将 CastResultApicharacter 字段映射到 Charactername 字段,将 name 字段映射到演员名字,将 profile_path 字段映射到 image 属性:

        const characters :Character[] = mainActors.map(actor => ({        name: actor.character,        actor: actor.name,        image: actor.profile_path     }))
    
  16. 从人员数据的crew属性中,我们只需要导演和编剧。由于可能有多个导演和编剧,我们将分别获取所有导演和编剧的名字并将它们连接起来。对于导演,从crew属性中筛选出具有departmentDirectingjobDirector的人员。对于这些对象,取其name属性,并用&连接起来:

        const directors = peopleData.crew         .filter(person => person.department === "Directing" && person.job === "Director")        .map(person => person.name)    const directedBy = directors.join(" & ");
    
  17. 对于编剧,从crew属性中筛选出具有departmentWritingjobWriter的人员。对于这些对象,取其name属性,并用&连接起来:

        const writers = peopleData.crew         .filter(person => person.department === "Writing" && person.job === "Writer")        .map(person => person.name);    const writtenBy = writers.join(" & ");
    
  18. 创建一个新的Movie对象(使用对象字面量语法)。使用到目前为止我们从电影和人员响应中准备的数据填写Movie对象的全部属性:

        const movie: Movie = {        id: movieData.id,        title: movieData.title,        tagline: movieData.tagline,        releaseDate: new Date(movieData.release_date),        posterUrl: movieData.poster_path,        backdropUrl: movieData.backdrop_path,        overview: movieData.overview,        runtime: movieData.runtime,        characters: characters,        directedBy: directedBy,        writenBy: writtenBy     }
    
  19. 从处理程序中返回Movie对象:

            return movie;    });
    

    注意,在我们的代码中我们没有进行任何 UI 交互。我们只是接收了一个字符串,进行了一些 promise 调用,并返回了一个值。现在可以在面向 UI 的代码中完成 UI 工作。在这种情况下,那就是在search按钮的click事件处理程序中。我们应该简单地向search调用添加一个then处理程序,该处理程序将调用showResults方法,并添加一个catch处理程序,该处理程序将调用clearResults方法:

        search(movieTitle)        .then(movie => showResults(movie))        .catch(_ => clearResults(value));
    

输出应该与上一个活动相同。

活动十点零三:使用 fetch 和 async/await 的影片浏览器

解决方案:

  1. script.ts文件中,定位到search函数并验证它是否接受一个字符串参数,并且其主体为空。注意,这个函数现在被标记为async关键字,这允许我们使用await运算符:

    const getJsonData = (url: string):Promise<any> => {}
    
  2. getJsonData函数的主体中,添加调用并await``fetch函数的url参数的代码,然后调用返回响应的json方法:

    const getJsonData = (url: string):Promise<any> => {    const response = await fetch(url);    return response.json();}
    
  3. search方法中,使用getSearchUrl方法构造一个新的搜索结果 URL 字符串:

        const url = getSearchUrl(value);
    
  4. 使用searchUrl作为参数调用getJsonData函数,并await结果。将结果放置在SearchResultApi变量中:

        const data: SearchResultApi = await getJsonData(url);
    
  5. 检查是否有任何结果,如果没有,则抛出错误。如果有结果,将result属性中的第一个项目设置到一个名为movieResult的变量中。这个对象将包含电影的idtitle属性:

        if (data.results.length === 0) {        throw Error("Not found");    }    const movieResult = data.results[0];
    
  6. 使用id属性调用getMovieUrlgetPeopleUrl方法,分别获取电影详情和演员及工作人员的正确 URL:

        const movieUrl = getMovieUrl(movieResult.id);    const peopleUrl = getPeopleUrl(movieResult.id);
    
  7. 获取到 URL 后,调用getJsonData函数并将它们作为参数传递,并将结果值分配给变量。注意,getJsonData(movieUrl)调用将返回一个MovieResultApi的 promise,而getJsonData(peopleUrl)将返回一个PeopleResultApi的 promise。将这些结果值分配给名为dataPromisepeoplePromise的变量:

        const dataPromise: Promise<MovieResultApi> = getJsonData(movieUrl);    const peoplePromise: Promise<PeopleResultApi> = getJsonData(peopleUrl);
    
  8. 使用 dataPromisepeoplePromise 作为参数调用静态的 Promise.all 方法。这将基于这两个值创建另一个基于它们的承诺,并且只有当包含在内的所有承诺都成功解决时,这个承诺才会成功解决。它的返回值将是一个包含结果的数组承诺。await 这个承诺,并将其结果放入一个数组类型的变量中:

        const dataArray = await Promise.all([dataPromise, peoplePromise]);
    
  9. 将数组拆分为两个变量。数组中的第一个元素应该是 movieData 变量,其类型为 MovieResultApi,第二个元素是 peopleData 变量,其类型为 PeopleResultApi

         const [movieData, peopleData] = dataArray;
    
  10. 人物数据有 castcrew 属性。我们只取前六个演员,所以首先根据演员的 order 属性对 cast 属性进行排序。然后从第一个六位演员中切出一个新的数组:

        peopleData.cast.sort((f, s) => f.order - s.order);    const mainActors = peopleData.cast.slice(0, 6);
    
  11. 将演员数据(CastResultApi 对象)转换为我们的 Character 对象。我们需要将 CastResultApicharacter 字段映射到 Charactername 字段,将 name 字段映射到演员名字,将 profile_path 字段映射到 image 属性:

        const characters :Character[] = mainActors.map(actor => ({        name: actor.character,        actor: actor.name,        image: actor.profile_path     }))
    
  12. 从人物数据的 crew 属性中,我们只需要导演和编剧。由于可能有多个导演和编剧,我们将获取所有导演和编剧的名字,并分别连接它们。对于导演,从 crew 属性中,过滤出具有 Directing 部门和 Director 职位的个人。对于这些对象,取 name 属性,并用 & 连接起来:

        const directors = peopleData.crew         .filter(person => person.department === "Directing" && person.job === "Director")        .map(person => person.name)    const directedBy = directors.join(" & ");
    
  13. 对于编剧,从 crew 属性中,过滤出具有 Writing 部门和 Writer 职位的个人。对于这些对象,取 name 属性,并用 & 连接起来:

        const writers = peopleData.crew         .filter(person => person.department === "Writing" && person.job === "Writer")        .map(person => person.name);    const writtenBy = writers.join(" & ");
    
  14. 创建一个新的 Movie 对象(使用对象字面量语法)。使用我们迄今为止准备的电影和人物响应数据,填写 Movie 对象的所有属性:

        const movie: Movie = {        id: movieData.id,        title: movieData.title,        tagline: movieData.tagline,        releaseDate: new Date(movieData.release_date),        posterUrl: movieData.poster_path,        backdropUrl: movieData.backdrop_path,        overview: movieData.overview,        runtime: movieData.runtime,        characters: characters,        directedBy: directedBy,        writenBy: writtenBy     }
    
  15. 从函数中返回 Movie 对象:

        return movie; 
    
  16. 注意,我们在代码中没有进行任何 UI 交互。我们只接收了一个字符串,进行了一些承诺调用,并返回了一个值。现在可以在面向 UI 的代码中完成 UI 工作。在这种情况下,是在 search 按钮的 click 事件处理器中。我们应该简单地 await search 调用的结果,然后用它调用 showResults 方法。我们可以使用标准的 catch 表达式来处理任何错误:

        try {        const movie = await search(movieTitle);        showResults(movie);    } catch {        clearResults(movieTitle);    }
    

输出应该与上一个活动相同。

11. 高阶函数和回调

活动 11.01:高阶管道函数

解决方案:

在这个活动中,我们将构建一个高阶 pipe 函数,它接受函数作为参数,并从左到右组合它们,返回一个接受第一个函数参数并返回最后一个函数类型的函数。当返回的函数运行时,它遍历给定的函数,将每个函数的返回值传递给下一个函数:

  1. 让我们先定义一个类型定义,用于组合支持的函数,一个接受类型为 T 的一个参数并返回类型为 R 的函数:

    type UnaryFunction<T, R> = T extends void ? () => R : (arg: T) => R;
    

    如前所述,我们将只支持接受最多一个参数的函数,以简化问题。

    注意,为了处理 0 个参数的特殊情况,我们需要检查 T extends void 并返回一个无参数函数。

  2. 接下来,让我们先编写一个简单的 pipe 函数实现,它只支持单个函数,使其本质上是一个恒等函数:

    function pipe<R>(fn: UnaryFunction<void, R>): UnaryFunction<void, R>;
    function pipe<T, R = T>(fn: UnaryFunction<T, R>): UnaryFunction<T, R> {
        return fn;
    }
    

    注意,我们需要为函数提供两个重载,一个用于没有参数的特殊情况,另一个用于单参数函数。

  3. 让我们通过添加另一个重载来扩展这个功能,以支持两个函数:

    function pipe<R>(fn: UnaryFunction<void, R>): UnaryFunction<void, R>;
    function pipe<T, R = T>(fn: UnaryFunction<T, R>): UnaryFunction<T, R>;
    function pipe<T, A, R>(fn1: UnaryFunction<T, A>, fn2: UnaryFunction<A, R>): UnaryFunction<T, R>;
    function pipe<T, A, R>(fn1: UnaryFunction<T, A>, fn2?: UnaryFunction<A, R>) {
      // TODO: Support two functions
    }
    

    前一个实现不再有效,因为我们需要同时支持单个函数和多个函数,所以我们不能再只返回 fn。我们将现在添加一个简单的实现,并在下一步扩展到一个更通用的解决方案。

  4. 支持两个函数的简单实现是简单地检查 fn2 是否为 undefined – 如果是,我们只有一个函数在手,可以简单地返回 fn1。否则,我们需要返回一个函数,该函数在给定的参数上组合 fn1fn2

    function pipe<R>(fn: UnaryFunction<void, R>): UnaryFunction<void, R>;
    function pipe<T, R = T>(fn: UnaryFunction<T, R>): UnaryFunction<T, R>;
    function pipe<T, A, R>(fn1: UnaryFunction<T, A>, fn2: UnaryFunction<A, R>): UnaryFunction<T, R>;
    function pipe<T, A, R>(fn1: UnaryFunction<T, A>, fn2?: UnaryFunction<A, R>) {
      if (fn2 === undefined) {
        return fn1;
      }
      return (arg: T) => {
        return fn2(fn1(arg));
      };
    }
    
  5. 我们可以继续使用前面的方法,但这很繁琐,支持更多函数意味着要改变实现。相反,我们可以让实际实现接受一个函数数组并对其进行归约,从 arg 作为初始值开始,并在累加器(前一个结果)上运行当前函数 fn。让我们这样做,同时仍然只支持最多两个函数:

    function pipe<R>(fn: UnaryFunction<void, R>): UnaryFunction<void, R>;
    function pipe<T, R = T>(fn: UnaryFunction<T, R>): UnaryFunction<T, R>;
    function pipe<T, A, R>(fn1: UnaryFunction<T, A>, fn2: UnaryFunction<A, R>): UnaryFunction<T, R>;
    function pipe<T>(...fns: UnaryFunction<any, any>[]): UnaryFunction<any, any> {
      return (arg: T) => {
        return fns.reduce((prev, fn) => fn(prev), arg);
      };
    }
    
  6. 最后,我们可以通过仅通过添加另一个具有正确类型的重载来更改函数声明来扩展我们对更多函数的支持:

    在三个函数的情况下:

    function pipe<T, A, B, R>(
      fn1: UnaryFunction<T, A>,
      fn2: UnaryFunction<A, B>,
      fn3: UnaryFunction<B, R>,
    ): UnaryFunction<T, R>;
    

    在四个函数的情况下:

    function pipe<T, A, B, C, R>(
      fn1: UnaryFunction<T, A>,
      fn2: UnaryFunction<A, B>,
      fn3: UnaryFunction<B, C>,
      fn4: UnaryFunction<C, R>,
    ): UnaryFunction<T, R>;
    

    在五个函数的情况下:

    function pipe<T, A, B, C, D, R>(
      fn1: UnaryFunction<T, A>,
      fn2: UnaryFunction<A, B>,
      fn3: UnaryFunction<B, C>,
      fn4: UnaryFunction<C, D>,
      fn5: UnaryFunction<D, R>,
    ): UnaryFunction<T, R>;
    

    在每个重载中,我们都有第一个泛型 T – 这是返回函数将具有的参数类型,以及 R – 返回函数的返回类型。它们之间是 ABC 等等,作为第二、第二到最后一个函数的中间返回类型/参数类型。对于所有前面的步骤,确保通过在 function 关键字之前添加 export 来导出函数。

    最后,我们可以使用我们的 pipe 函数来组合任何我们想要的函数,同时保持完全的类型安全:

    const composedFn = pipe(
      (x: string) => x.toUpperCase(),
      x => [x, x].join(','),
      x => x.length,
      x => x.toString(),
      x => Number(x),
    );
    console.log('result is:', composedFn('hello'))
    

    运行此代码应产生以下输出:

    result is: 11
    

12. TypeScript 中 Promise 的指南

活动 12.01:构建 Promise 应用程序

解决方案:

  1. 我们可以像从 GitHub 上的示例开始构建我们的 API 一样开始:

    npm i
    

    我们在这里使用的唯一依赖项是http-server,用于为我们的 Web 应用程序提供动力,以及typescript,用于将我们的代码转换为 JavaScript。现在我们的项目已经设置好了,让我们快速创建一个index.html文件:

    <html>
      <head>
        <title>The TypeScript Workshop - Activity 12.1</title>
        <link href="styles.css" rel="stylesheet"></link>
      </head>
      <body>
        <input type="text" placeholder="What promise will you make?" id="promise-input"> <button id="promise-save">save</button>
        <div>
            <table id="promise-table"></ul>
        </div>
      </body>
      <script type="module" src="img/app.js"></script>
    </html>
    
  2. 然后是一个styles.css文件:

    body {
      font-family: Arial, Helvetica, sans-serif;
      font-size: 12px;
    }
    input {
      width: 200;
    }
    table {
      border: 1px solid;
    }
    td {
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
    }
    

    现在,我们将创建一个app.ts文件,并创建一个非常粗糙的客户端库,该库实现了类似于我们在第三章,函数中创建的fetch抽象。由于 TypeScript 不能在 Web 浏览器中本地运行,我们需要使用tsc将我们的 TypeScript 代码转换为 JavaScript。有一些高级工具,如 webpack 和 Parcel,可以帮助完成这项工作,但那些超出了本章的范围,所以我们将保持简单,只使用一个app.ts文件。

  3. 我们将在我们的 Web 应用程序中再次使用PromiseModel接口,并使用柯里化创建一个fetchClient函数:

    interface PromiseModel {
      id?: number;
      desc: string;
    }
    const fetchClient = (url: string) => (resource: string) => (method: string) => (
      body?: PromiseModel
    ) => {
      return fetch(`${url}/${resource}`, {
        body: body && JSON.stringify(body),
        headers: { "Content-Type": "application/json" },
        method,
      });
    };
    
  4. 在柯里化fetch函数的模式基础上,让我们创建一些资源:

    const api = fetchClient("http://localhost:3000");
    const resource = api("promise");
    const getAction = resource("get");
    const postAction = resource("post");
    
  5. 这些函数处理调用资源和更新页面元素:

    const deleteItem = (id: number) => {
      const resource = api(`promise/${id}`);
      resource("delete")().then(loadItems);
    };
    const loadItems = () => {
      getAction().then((res) => res.json().then(renderList));
    };
    const saveItem = () => {
      const input = document.getElementById("promise-input") as HTMLInputElement;
      if (input.value) {
        postAction({ desc: input.value }).then(loadItems);
        input.value = "";
      }
    };
    
  6. 最后,我们将进行一些丑陋的 HTML 操作来更新 UI:

    const renderList = (data: PromiseModel[]) => {
      const table = document.getElementById("promise-table");
      if (table) {
        table.innerHTML = "";
        let tr = document.createElement("tr");
        ["Promise", "Delete"].forEach((label) => {
          const th = document.createElement("th");
          th.innerText = label;
          tr.appendChild(th);
        });
        table.appendChild(tr);
        data.forEach((el) => {
          table.appendChild(renderRow(el));
        });
      }
    };
    const renderRow = (el: PromiseModel) => {
      const tr = document.createElement("tr");
      const td1 = document.createElement("td");
      td1.innerHTML = el.desc;
      tr.appendChild(td1);
      const td2 = document.createElement("td");
      const deleteButton = document.createElement("button");
      deleteButton.innerText = "delete";
      deleteButton.onclick = () => deleteItem(el.id!);
      td2.appendChild(deleteButton);
      tr.appendChild(td2);
      return tr;
    };
    document.getElementById("promise-save")?.addEventListener("click", saveItem);
    loadItems();
    
  7. 总的来说,app.ts文件看起来是这样的:

    interface PromiseModel {
      id?: number;
      desc: string;
    }
    const fetchClient = (url: string) => (resource: string) => (method: string) => (
      body?: PromiseModel
    ) => {
      return fetch(`${url}/${resource}`, {
        body: body && JSON.stringify(body),
        headers: { "Content-Type": "application/json" },
        method,
      });
    };
    const api = fetchClient("http://localhost:3000");
    const resource = api("promise");
    const getAction = resource("get");
    const postAction = resource("post");
    const deleteItem = (id: number) => {
      const resource = api(`promise/${id}`);
      resource("delete")().then(loadItems);
    };
    const loadItems = () => {
      getAction().then((res) => res.json().then(renderList));
    };
    const saveItem = () => {
      const input = document.getElementById("promise-input") as HTMLInputElement;
      if (input.value) {
        postAction({ desc: input.value }).then(loadItems);
        input.value = "";
      }
    };
    const renderList = (data: PromiseModel[]) => {
      const table = document.getElementById("promise-table");
      if (table) {
        table.innerHTML = "";
        let tr = document.createElement("tr");
        ["Promise", "Delete"].forEach((label) => {
          const th = document.createElement("th");
          th.innerText = label;
          tr.appendChild(th);
        });
        table.appendChild(tr);
        data.forEach((el) => {
          table.appendChild(renderRow(el));
        });
      }
    };
    const renderRow = (el: PromiseModel) => {
      const tr = document.createElement("tr");
      const td1 = document.createElement("td");
      td1.innerHTML = el.desc;
      tr.appendChild(td1);
      const td2 = document.createElement("td");
      const deleteButton = document.createElement("button");
      deleteButton.innerText = "delete";
      deleteButton.onclick = () => deleteItem(el.id!);
      td2.appendChild(deleteButton);
      tr.appendChild(td2);
      return tr;
    };
    document.getElementById("promise-save")?.addEventListener("click", saveItem);
    loadItems();
    

    很容易看出为什么视图框架很受欢迎;然而,这应该足以组合一个全栈应用程序。

  8. 现在,让我们编译并运行我们的 Web 应用程序。在一个命令提示符窗口中,输入以下内容:

    npx tsc -w. 
    

    这将在监视模式下转换 TypeScript 代码,以便在做出更改时重新启动。

  9. 在另一个窗口中使用npx http-server . -c-1启动 HTTP 服务器,就像我们在练习 12.03,Promise.allSettled中所做的那样。

    现在,导航到 Web 浏览器中的http://localhost:8080/。你应该看到以下表单:

    图 12.10:初始加载

    图 12.10:初始加载

    注意

    如果你没有看到“Promise Delete”,那么可能是你的 API(在练习 6,使用 sqlite 实现 RESTful API)没有运行。返回该练习并遵循那里的步骤。

    你可以添加和删除承诺。以下是一些示例:

  10. 添加承诺“始终检查我的代码”并保存。你应该看到以下内容:图 12.11:一个承诺已创建

    图 12.11:一个承诺已创建

  11. 添加承诺“永远不要阻塞事件循环”并保存:图 12.12:输入的文本

图 12.12:输入的文本

你应该看到以下承诺已保存:

图 12.13:文本已保存

图 12.13:文本已保存

图 12.14图 12.15展示了更多示例:

图 12.14:另一个承诺已保存

图 12.14:另一个承诺已保存

图 12.15:另一个承诺已保存

图 12.15:另一个承诺已保存

尝试向应用程序添加内容,并使用 API 获取单个承诺或更新承诺。

13. TypeScript 中的 Async/Await

活动 13.01:将链式承诺重构为使用 await

解决方案:

让我们回顾一下为了使这个工作需要更改的内容:

  1. 首先,await 关键字只能用于 async 函数中,因此我们必须将此关键字添加到函数声明中:

    const renderAll = async () => {
    
  2. 现在我们必须将 then 替换为 await。让我们再次看看 render 函数做了什么。在我们的简单情况下,它只是返回一个解析为字符串的承诺,但在现实世界中,它会在网页浏览器中渲染某些内容,然后解析为字符串。由于我们想要记录这个字符串,我们实际上可以在 console.log 语句中解析承诺。尽管 console.log 是一个同步操作,但在其中放置 await 将导致函数打印出解析的承诺值,正如我们所希望的那样。

    优化后的程序比原来短六行,并消除了嵌套:

    export class El {
      constructor(private name: string) {}
      render = () => {
        return new Promise((resolve) =>
          setTimeout(
            () => resolve(`${this.name} is resolved`),
            Math.random() * 1000
          )
        );
      };
    }
    const e1 = new El('header');
    const e2 = new El('body');
    const e3 = new El('footer');
    const renderAll = async () => {
      console.log(await e1.render());
      console.log(await e2.render());
      console.log(await e3.render());
    };
    renderAll();
    
  3. 使用 npx ts-node refactor.ts 运行文件。你应该得到以下输出:

    header is resolved
    body is resolved
    footer is resolved
    

14. TypeScript 和 React

活动十四点零一:博客

解决方案:

  1. 按照本章前面概述的步骤创建一个新的 React 应用程序。

  2. 根据第 14.04 节“在 Firebase 中入门”中概述的步骤,在 Firebase 上准备一个具有身份验证的 Firestore 数据库。

  3. 使用 npm i firebase 安装 Firebase 客户端。Firebase 包含类型定义,因此我们不需要单独安装它们。

  4. src 目录下创建一个名为 services 的目录,并在其中创建一个名为 firebase.ts 的文件。Firebase 集成可以相当基础:

    import firebase from 'firebase';const config = {  apiKey: 'abc123',  authDomain: 'blog-xxx.firebaseapp.com',  projectId: 'https://blog-xxx.firebaseio.com',  storageBucket: 'blog-xxx.appspot.com',  messagingSenderId: '999',  appId: '1:123:web:123abc',};firebase.initializeApp(config);export const auth = firebase.auth();export const db = firebase.firestore();
    
  5. 确保使用 Firebase 仪表板中的值。这将使 Firebase 的身份验证和数据库功能暴露给应用程序的其他部分。

  6. src/providers 下设置两个提供者,分别命名为 StoriesProvider.tsUserProvider.ts。现在,UserProvider.ts 将更简单,所以让我们先做这个。像第 14.03 节“React Context”中的练习一样,我们将使用 createContextuseState,但我们还需要 useEffect

    import firebase from 'firebase';
    import React, { createContext, ReactNode, useEffect, useState } from 'react';
    import { auth } from '../services/firebase';
    interface ContextProps {
      children: ReactNode;
    }
    export const UserContext = createContext<Partial<firebase.User | undefined>>(
      {}
    );
    export const UserProvider = (props: ContextProps) => {
      const [user, setUser] = useState<firebase.User>();
      useEffect(() => {
        auth.onAuthStateChanged((userAuth) => {
          setUser(userAuth ?? undefined);
        });
      });
      return (
        <UserContext.Provider value={user}>{props.children}</UserContext.Provider>
      );
    };
    
  7. StoriesProvider.ts 负责持久化故事(博客链接)和故事上的评论。为了实现这一点,首先为评论和故事创建接口。评论应属于故事。以下是一个示例,说明如何实现:

    export interface CommentModel {  comment: string;  timestamp: number;  user: string;}export interface StoryModel {  comments: CommentModel[];  id: string;  link: string;  title: string;  user: string;}
    

    创建了这些接口后,我们需要在我们的提供者中实现一些方法,即添加评论和故事的方法以及一个将获取所有故事的方法。为此,我们需要访问数据库中的集合。这可以通过一行代码完成:

    const storiesDB = db.collection('stories');
    

    此代码将在不存在的情况下创建集合。我们创建的 storiesDB 对象具有从集合中获取、添加和更新文档的方法。实现这些方法后,我们将我们的故事数据和处理数据的方法添加到我们的提供者值中。这意味着使用 StoriesContext 的组件将能够调用这些方法或访问这些数据。

    再次强调,这个相对复杂的提供者的解决方案可以在 GitHub 上找到。

  8. 原始文档数据有点难以处理,但 Firebase 有一个我们可以创建的转换器概念,这将告诉它如何将文档字段映射到我们的 TypeScript 对象。创建并导出一个实现 fromFirestoretoFirestore 方法的转换器。使用这些应该可以消除一些类型错误,并避免我们使用 any

  9. 安装 React Router (react-domreact-router-dom)。设置默认路由为主页。然后创建 AddSigninSignup 页面。将这些页面放在 src/pages 下。只需在基本函数组件上放置一些文本以验证路由是否按预期工作。

  10. 首先构建 Signup 页面,因为没有注册就无法登录。现在我们将使用 Material-UI。安装 @material-ui/core@material-ui/icons,然后我们可以开始构建组件。

  11. 我们的 Signup 页面可以使用 ContainerTextFieldButton 创建,这些都是 Material-UI 中的可用组件。页面最终的外观取决于你,但你需要两个 TextField 组件。其中一个应该有 typename 都为 "email" 的属性,另一个应该为这两个属性都设置 "password"

    我们将使用 useStateonChange 事件跟踪电子邮件和密码字段的状态。

    当按钮被点击时,我们应该调用我们之前从 Firebase 服务中导出的 auth 对象上的一个方法,使用给定的电子邮件地址和密码创建一个新用户。

  12. 登录成功后,让我们使用 useHistory React 钩子将用户送回主页。

  13. Signin 页面将与 Signup 页面非常相似。它也需要捕获用户的电子邮件地址和密码,并有一个提交表单的按钮。这次我们应该在 auth 上调用一个方法,通过电子邮件和密码来登录用户。

  14. 我们的 Add 页面为博客创建新帖子。我们将捕获帖子的标题和链接。如果你喜欢,可以添加额外的字段。这将与前面的两个页面类似,但现在我们将使用 StoriesContext 而不是 UserContext 来公开添加故事的方法。

  15. 对于主页,我们可以加载所有故事并将其作为 Material-UI 的 List 显示。直接输出 story 对象并用 HTML 标签包裹以使其看起来更美观是可能的,但更好的解决方案是创建一个 Story 组件,它可以更好地封装对象。将 Story 组件添加到 src/components 中,并用于故事显示。

  16. 为了管理评论,每个故事都应该有自己的评论。创建一个单独的组件,每个故事都将包含它是个好主意。Comments 组件可以包含每个单独评论的列表(另一个组件!)以及从 StoriesContext 获取添加评论方法的控件。

  17. 到目前为止,一切运行得相当顺利,但我们应该添加一些导航元素,这样用户就不必输入不同的路由。我们可以使用 Material-UI 中的AppBarToolbarMenuMenuItemButton组件来创建一些吸引人的导航选项。导航本身可以通过useHistory React Hook 来实现。

嘿!

我们是本书的作者本·格里豪斯(Ben Grynhaus)、乔丹·哈丁斯(Jordan Hudgens)、雷昂·亨特(Rayon Hunte)、马特·摩根(Matt Morgan)和韦科斯拉夫·斯坦福夫斯基(Wekoslav Stefanovski)。我们真心希望您喜欢阅读我们的书籍,并觉得它对学习 TypeScript 很有帮助。

如果您能在 Amazon 上留下对《TypeScript Workshop》的评论,分享您的想法,这将对我们(以及其他潜在读者!)非常有帮助。

访问链接 https://packt.link/r/1838828494。

或者

扫描二维码留下您的评论。

Barcode

您的评论将帮助我们了解本书哪些地方做得好,哪些地方可以改进以供未来版本使用,所以这真的非常感谢。

祝好,

本·格里豪斯、乔丹·哈丁斯、雷昂·亨特、马特·摩根和韦科斯拉夫·斯坦福夫斯基

Packt Logo

posted @ 2025-10-25 10:29  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报