TypeScript-高级教程-全-
TypeScript 高级教程(全)
一、TypeScript 语言功能
如果我们可以用大规模应用开发所缺少的东西来增强 JavaScript,比如静态类型、类[和]模块,会怎么样...这就是 TypeScript 的作用。—安德斯·海尔斯伯格
TypeScript 是 JavaScript 的超集。这意味着 TypeScript 语言包括了整个 JavaScript 语言以及一些有用的附加特性。这与 JavaScript 的各种子集和各种林挺工具形成对比,这些工具试图减少可用的功能,以创建一种更小的语言,减少惊喜。本章将向您介绍额外的语言特性,从简单的类型注释开始,逐步发展到更高级的特性和 TypeScript 的结构元素。本章并没有涵盖 ECMAScript 语言规范中包含的所有特性,所以如果您需要复习 JavaScript,可以看看附录 1。
需要记住的一件重要事情是,JavaScript 中的所有标准控制结构都可以在 TypeScript 程序中立即使用。这包括以下内容:
- 控制流程
- 数据类型
- 经营者
- 子程序
程序的基本构建块将来自 JavaScript,包括 if 语句、switch 语句、循环、算术、逻辑测试和函数。这是 TypeScript 的主要优势之一——它基于一种已经为大量不同的程序员所熟悉的语言(和一个语言家族)。JavaScript 不仅在 ECMA-262 规范中有完整的文档,在书籍、开发者门户网站、论坛和问答网站上也有。当特性被添加到 JavaScript 中时,它们也会出现在 TypeScript 中。
TypeScript 编译器通常会在其规范的早期更新新的 JavaScript 特性。大多数功能在浏览器支持它们之前都是可用的。在许多情况下,您可以在 TypeScript 程序中使用这些功能,因为编译器会将它们转换为针对 ECMAScript 标准的旧版本的代码。
本章中讨论的每一种语言特性都有简短、独立的代码示例,将特性放在上下文中。出于介绍和解释特性的目的,示例简短扼要;这样就可以从头到尾阅读这一章。然而,这也意味着你可以在以后参考这一章。一旦你读完了这一章,你应该知道理解本书其余部分所描述的更复杂的例子所需要的一切。
JavaScript 是有效的类型脚本
在我们了解更多关于 TypeScript 语法的内容之前,有必要强调一个重要的事实:所有的 JavaScript 都是有效的 TypeScript。您不需要放弃任何 JavaScript 知识,因为它们都可以直接转移到您的 TypeScript 代码中。您可以获取现有的 JavaScript 代码,将其添加到 TypeScript 文件中,所有语句都将有效。在 TypeScript 中,有效代码和无错代码有细微的区别;因为,尽管您的代码可能工作,但 TypeScript 编译器会警告您它检测到的任何潜在问题。发现微妙的和以前未被发现的错误是向 TypeScript 过渡的程序员的共同经历。
如果将 JavaScript 列表转移到 TypeScript 文件中,即使代码被认为是有效的,也可能会收到错误或警告。一个常见的例子来自于 JavaScript 中的动态类型系统,在这个系统中,在同一个变量的生命周期中为其分配不同类型的值是完全可以接受的。TypeScript 检测这些赋值并生成错误,以警告您变量的类型已被赋值所更改。因为这是程序中常见的错误原因,所以可以通过创建单独的变量、执行类型断言或使变量动态化来更正错误。本章后面有关于类型注释的更多信息,类型系统将在第三章中详细讨论。
不像某些编译器只创建未检测到编译错误的输出,TypeScript 编译器仍然会尝试生成合理的 JavaScript 代码。清单 1-1 中显示的 TypeScript 代码产生了一个错误,但是仍然产生了 JavaScript 输出。这是一个令人钦佩的特性,但正如编译器警告和错误一样,您应该纠正源代码中的问题,获得干净的编译。如果您经常忽略这些消息,您的程序最终会表现出意想不到的行为。在某些情况下,您的清单可能包含非常严重的错误,以至于 TypeScript 编译器无法生成 JavaScript 输出。
Caution
“所有 JavaScript 都是有效的类型脚本”规则的唯一例外是with
语句和特定于供应商的扩展,直到它们被正式添加到 ECMAScript 规范中。从技术上讲,您仍然可以使用with
语句,但是块中的所有语句都不会被检查。
清单 1-1 中的 JavaScript with
语句展示了同一个例程的两个例子。虽然第一个显式调用了Math.PI
,但是第二个使用了一个with
语句,将Math
的属性和功能添加到当前范围。嵌套在with
语句中的语句可以省略Math
前缀,直接调用属性和函数,例如PI
属性或floor
函数。
在with
语句的末尾,原始的词法范围被恢复,因此在with
块之外的后续调用必须使用Math
前缀。
// Not using with
const radius1 = 4;
const area1 = Math.PI * radius1 * radius1;
// Using with
const radius2 = 4;
with (Math) {
const area2 = PI * radius2 * radius2;
}
Listing 1-1.Using JavaScript’s “with” statement
ECMAScript 5 和更高版本的 ECMAScript 在严格模式下不允许使用with
语句,默认情况下,对于类和模块使用严格模式。TypeScript 将with
语句视为错误,并将with
语句中的所有类型视为动态类型。这是由于以下原因:
- 事实上它在严格模式下是不允许的。
- 普遍认为
with
声明是危险的。 - 在编译时确定范围内的标识符的实际问题。
因此,记住这些规则的小例外,您可以将任何有效的 JavaScript 放入 TypeScript 文件,它将是有效的 TypeScript。作为一个示例,下面是传输到 TypeScript 文件的面积计算脚本。
Note
ECMAScript 6 规范,也称为“ES6 Harmony”,代表了 JavaScript 语言的重大变化。该规范已被划分为年度块,发布为 ECMAScript 2015、ECMAScript 2016 等。
const radius = 4;
const area = Math.PI * radius * radius;
Listing 1-2.Transferring JavaScript in to a TypeScript file
在清单 1-2 中,语句只是普通的 JavaScript,但是在 TypeScript 中,变量radius
和area
都将受益于类型推断。因为radius
是用值4
初始化的,所以可以推断出radius
的类型是number
。只需稍微增加努力,将已知为number
的Math.PI
乘以已经被推断为number
的radius
变量,就可以推断出area
的类型也是number
。
使用类型推断,可以检查赋值的类型安全性。图 1-1 显示了当一个字符串被赋值给半径变量时,不安全赋值是如何被检测到的。在第三章中有更详细的类型推断解释。就目前而言,请放心,类型推断是一件好事,它会节省您很多精力。
图 1-1。
Static type checking
变量
TypeScript 变量必须遵循 JavaScript 命名规则。用于命名变量的标识符必须满足以下条件。
第一个字符必须是下列字符之一:
- 大写字母
- 小写字母
- 下划线
- 美元符号
- 类别中的 Unicode 字符-大写字母(Lu)、小写字母(Ll)、标题大写字母(Lt)、修饰字母(Lm)、其他字母(Lo)或字母数字(Nl)
后续字符遵循相同的规则,此外还允许以下内容:
- 数字
- 类别中的 Unicode 字符-非空格标记(Mn)、空格组合标记(Mc)、十进制数字(Nd)或连接符标点(Pc)
- Unicode 字符 U+200C(零宽度非连接符)和 U+200D(零宽度连接符)
您可以使用 Mathias Bynens 的 JavaScript 变量名验证器来测试变量标识符是否符合命名规则。
http://mothereff.in/js-variables
Note
一些更奇特的字符的可用性可以允许一些有趣的标识符。您应该考虑这种变量名引起的问题是否多于它解决的问题。例如,这是有效的 JavaScript: const
= 'Dignified';
用const
或let
声明的变量是块范围的,而用旧的var
关键字声明的变量是函数范围的。如果您省略了这些关键字,您就隐式地(也许是意外地)在全局范围内声明了该变量。建议减少添加到全局范围的变量数量,因为它们有名称冲突的风险。您可以通过声明局部变量来避免全局范围,例如在函数、模块、命名空间、类或一组简单的花括号中(如果您使用的是块范围的关键字)。
当你限制一个变量的作用域时,意味着它不能在它被创建的作用域之外被操作。作用域遵循嵌套规则,该规则允许变量在当前作用域和内部嵌套作用域中使用,但不能在外部使用。换句话说,您可以使用当前作用域中声明的变量和更大作用域中的变量。参见清单 1-3 。
let globalScope = 1;
{
let blockScope = 2;
// OK. This is from a wider scope
globalScope = 100;
// Error! This is outside of the scope the variable is declared in
nestedBlockScope = 300;
{
let nestedBlockScope = 3;
// OK. This is from a wider scope
globalScope = 1000;
// OK. This is from a wider scope
blockScope = 2000;
}
}
Listing 1-3.Block scope
TypeScript 捕捉范围冲突,并在您试图访问在较窄范围内声明的变量时发出警告。您可以帮助编译器帮助您避免在不同的范围内重用名称的有效但经常是偶然的编码风格。在清单 1-4 中,日志记录语句工作正常,两个firstName
变量被分开保存。这意味着原始变量不会被同名的嵌套变量覆盖。
let firstName = 'Chris';
{
let firstName = 'Tudor';
console.log('Name 1: ' + firstName);
}
console.log('Name 2: ' + firstName);
// Output:
// Name 1: Tudor
// Name 2: Chris
Listing 1-4.Name reuse with let
如果使用了关键字var
来代替关键字let
,两个日志记录语句都将显示名称“Tudor”,如清单 1-5 所示。尽管这两个变量看起来是单独的声明,但只有一个名为firstName
的变量存在,并且被嵌套的作用域覆盖。
var firstName = 'Chris';
{
var firstName = 'Tudor';
console.log('Name 1: ' + firstName);
}
console.log('Name 2: ' + firstName);
// Output:
// Name 1: Tudor
// Name 2: Tudor
Listing 1-5.Name reuse with var
基于这个例子,您可以决定您是想要 let 风格的作用域,还是 var 风格的作用域用于您重用其名称的变量;或者您可以使用更好的变量名来避免依赖任何一种行为。
常数
常量是遵循let
关键字范围规则的变量,但不能被重新分配。当你用关键字const
声明一个变量时,你不能在以后给这个变量赋值。值得注意的是,这并没有使变量成为不可变的,正如你在清单 1-6 中看到的。不允许在声明常数后直接赋值,但是可以改变常数中的值,例如,通过对已经赋值的值调用方法,或者在数组的情况下通过添加项
const name = 'Lily';
// Error! Cannot assign to name because it is a constant
name = 'Princess Sparkles';
const digits = [1, 2, 3];
// Mutable - this changes the value of digits without using an assignment
digits.push(4, 5, 6);
Listing 1-6.Constants
推荐的编码风格是从对所有变量使用const
关键字开始,如果你决定允许的话,用let
关键字打开一个要重新分配的变量。常数通过遵循最小特权原则降低了代码的复杂性,因为您不需要继续扫描程序来查看后面的赋值是否会改变值;但是请注意,变量不是不可变的,可以通过其他方式进行更改。
类型
TypeScript 是可选的静态类型;这意味着会自动检查类型,以防止意外分配无效值。可以通过声明动态变量来退出。静态类型检查减少了因意外误用类型而导致的错误。你也可以创建类型来替换原始类型,以防止参数排序错误,如第三章所述。最重要的是,静态类型允许开发工具提供智能自动完成。
图 1-2 显示了知道变量类型的自动完成,并提供了相关的选项列表。它还显示了关于自动完成列表中的属性和方法的扩展信息。上下文自动完成对于原始类型足够有用,但是大多数合理的集成开发环境甚至可以在 JavaScript 文件中复制简单的推理。然而,在具有许多自定义类型、模块和类的程序中,TypeScript 语言服务的深度类型知识意味着您将在整个程序中拥有明智的自动完成功能。
图 1-2。
TypeScript autocompletion
键入注释
尽管 TypeScript 语言服务擅长自动推断类型,但有时它无法理解您的意图。有时,为了安全起见,您可能希望将类型显式化,或者缩小类型的范围。在这些情况下,可以使用类型批注来指定类型。
对于变量,类型注释在标识符之后,冒号之前。图 1-3 显示了产生类型化变量的组合。这些组合按优先顺序显示,第一个组合是最理想的。最不可取的是添加类型注释和赋值的最冗长的方式。虽然这种风格在本章的很多例子中都有出现,但实际上这是你用得最少的一种。
图 1-3。
Typed variable combinations
为了演示代码中的类型注释,清单 1-7 展示了一个变量的例子,它有一个显式的类型注释,将变量标记为一个字符串。基本类型是最简单的类型注释形式,但是您并不局限于这种简单的类型。
const name: string = 'Steve';
Listing 1-7.Explicit type annotation
用于指定注释的类型可以是基元类型、数组类型、函数签名、类型别名或任何您想要表示的复杂结构,包括您创建的类和接口的名称。您还可以通过允许多种类型中的一种(联合类型)来实现宽松,或者通过限制允许值的范围来实现更严格的限制(文字类型)。如果您想退出静态类型检查,您可以使用特殊的any
类型,它将变量的类型标记为动态。不对动态类型进行检查。清单 1-8 展示了一系列类型注释,涵盖了这些不同的场景。
// primitive type annotation
const name: string = 'Steve';
const heightInCentimeters: number = 182.88;
const isActive: boolean = true;
// array type annotation
const names: string[] = ['James', 'Nick', 'Rebecca', 'Lily'];
// function annotation with parameter type annotation and return type annotation
let sayHello: (name: string) => string;
// implementation of sayHello function
sayHello = function (name) {
return 'Hello ' + name;
};
// object type annotation
let person: { name: string; heightInCentimeters: number; };
// Implementation of a person object
person = {
name: 'Mark',
heightInCentimeters: 183
};
Listing 1-8.
Type annotations
Note
尽管许多语言在标识符之前指定类型,但是在标识符之后的 TypeScript 中放置类型批注有助于强调类型批注是可选的。它还允许您使用所有的变量语句,包括const
和let
。这种类型注释的风格也受到了类型理论的启发。
如果类型批注变得太复杂,可以创建一个接口或类型别名来表示类型,以简化批注。清单 1-9 演示了如何简化person
对象的类型注释,这在清单 1-8 中的前一个例子的末尾显示过。如果您打算重用该类型,这种技术尤其有用,因为它提供了可重用的定义。接口和类型别名不限于描述对象类型;它们足够灵活,可以描述你可能遇到的任何结构。本章后面将更详细地讨论接口。
在选择是使用接口还是类型别名时,有必要了解接口可以做而类型别名不能做的事情。接口可以在 extends 或 implements 子句中使用,这意味着您可以在定义其他接口和类时显式使用它们。接口也可以接受类型参数,使接口成为泛型。类型别名不能做到这两点。
// Interface
interface PersonInterface {
name: string;
heightInCentimeters: number;
}
const sherlock: PersonInterface = {
name: 'Bendict',
heightInCentimeters: 183
}
// Type Alias
type PersonType = {
name: string;
heightInCentimeters: number;
};
const john: PersonType = {
name: 'Martin',
heightInCentimeters: 169
}
Listing 1-9.Interface and type alias
原始类型
TypeScript 中的基本类型非常基本,但是通过类型系统的工具,您可以组合它们、扩大它们、缩小它们来表示程序中的概念。这些类型直接表示底层 JavaScript 类型,并遵循为这些类型设置的标准:
string
–一系列 UTF-16 代码单元boolean
–对或错number
–双精度 64 位浮点值symbol
–唯一的、不可变的符号,可替代字符串作为对象键
没有特殊类型来表示整数或数字类型的其他特定变体,因为执行静态分析来确保所有可能的赋值都有效是不切实际的。
类型系统还包含几个表示特殊值的类型:
undefined
类型是尚未赋值的变量值。null
类型可以用来表示对象值的有意缺失。例如,如果您有一个方法来搜索一个对象数组以找到匹配,它可以返回null
来表明没有找到匹配。void
类型用于表示没有值的情况,例如,表示函数不返回任何值。never
类型代表一段不可到达的代码,例如一个抛出异常的函数的返回类型是never
。
对象和动态类型
TypeScript 中所有不是原始类型的都是object
类型的子类。程序中的大多数类型都可能属于这个定义。
我们类型中的最后一项是动态any
类型,它可以用来表示任何类型。使用动态类型时,没有针对该类型的编译器类型检查。
在不能自动推断类型的情况下,编译器也使用any
类型,尽管您可以使用编译器标志禁止这些隐式的any
类型(请参考附录 2,了解您可以为编译指定的选项的完整列表)。您还可以在不希望编译器检查类型的情况下使用它,这样您就可以访问 JavaScript 语言的所有动态特性。
既然已经描述了所有的基本类型,下一节将介绍一些特殊的机制,用于以缩小或扩大允许值范围的方式组合类型。
列举
枚举是最简单的收缩类型之一。枚举表示命名元素的集合,您可以使用它来避免用硬编码的值破坏您的程序。默认情况下,枚举是从零开始的,尽管您可以通过指定第一个值来更改这一点,在这种情况下,数字将从您设置的值开始递增。如果愿意,您可以选择为所有标识符指定值。
在清单 1-10 中,VehicleType 枚举可用于在整个程序中使用良好命名的标识符来描述车辆类型。指定标识符名称时传递的值是代表标识符的数字,例如,在清单 1-10 中使用的车辆类型。卡车标识符导致数字 5 存储在类型变量中。通过将枚举视为数组,也可以从枚举中获取标识符名称。
enum VehicleType {
PedalCycle,
MotorCycle,
Car,
Van,
Bus,
Lorry
}
const type = VehicleType.Lorry;
const typeName = VehicleType[type]; // 'Lorry'
Listing 1-10.Enumerations
在 TypeScript 中,枚举是开放式的。这意味着在一个公共根中具有相同名称的所有声明都将贡献给一个类型。当跨多个块定义枚举时,第一个声明之后的后续块必须指定用于继续序列的数值,如清单 1-11 所示。对于从第三方、环境声明和标准库中扩展代码来说,这是一种有用的技术。
enum BoxSize {
Small,
Medium
}
//...
enum BoxSize {
Large = 2,
XLarge,
XXLarge
}
Listing 1-11.Enumeration split across multiple
blocks
在多个块中声明的枚举的消费者可以看出在一个块中声明的枚举没有区别,如图 1-4 所示。
图 1-4。
Using a multi-block enumeration Note
术语公共根来自图论。在 TypeScript 中,该术语与程序中模块树的特定位置相关。每当考虑合并声明时,它们必须具有相同的完全限定名,这意味着在树的同一级别上具有相同的名称。
位标志
您可以使用枚举来定义位标志。位标志允许通过打开和关闭序列中的各个位来选择或取消选择一系列项目。为了确保枚举中的每个值都与一位相关,编号必须遵循二进制序列,即每个值都是 2 的幂,例如:
1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1,024, 2,048, 4,096, and so on
清单 1-12 显示了一个使用位标志枚举的例子。默认情况下,当您创建一个变量来存储状态时,所有项目都被关闭。要打开一个选项,只需将它赋给变量。要打开多个项目,可以用按位 OR 运算符(|)将项目组合起来。如果您碰巧使用按位“或”运算符多次包含项,则这些项将保持打开状态。按位标志在附录 3 中有详细解释。
enum DiscFlags {
None = 0,
Drive = 1,
Influence = 2,
Steadiness = 4,
Conscientiousness = 8
}
// Using flags
var personality = DiscFlags.Drive | DiscFlags.Conscientiousness;
// Testing flags
// true
var hasD = (personality & DiscFlags.Drive) == DiscFlags.Drive;
// false
var hasI = (personality & DiscFlags.Influence) == DiscFlags.Influence;
// false
var hasS = (personality & DiscFlags.Steadiness) == DiscFlags.Steadiness;
// true
var hasC = (personality & DiscFlags.Conscientiousness) == DiscFlags.Conscientiousness;
Listing 1-12.Flags
分配给枚举中每一项的值可以是常数,也可以是计算值。常量值是可以由类型系统解释的任何表达式,如文字值、计算和二元运算符。计算值是编译器无法有效解释的表达式,如分配字符串长度或调用方法。这里的术语严重超载;当考虑“常量与计算值”时,术语“常量”不应与关键字const
混淆,关键字可用于变量和一种特殊的枚举,称为常量枚举。
常数枚举
可以使用关键字const
创建一个常量枚举,如清单 1-13 所示。与普通枚举不同,常量枚举在编译期间被擦除,引用它的所有代码都被替换为硬编码值。
const enum VehicleType {
PedalCycle,
MotorCycle,
Car,
Van,
Bus,
Lorry
}
const type = VehicleType.Lorry;
Listing 1-13.Constant enumeration
清单 1-14 显示了这个例子编译后的 JavaScript 输出。整个枚举都消失了,引用卡车车辆类型的代码被替换为值 5。为了帮助理解,插入了注释来描述文字值。
var type = 5 /* Lorry */;
Listing 1-14.JavaScript output of a constant enumeration
为了使值的内联成为可能,常量枚举不允许有计算成员。
工会类型
联合类型通过指定值可以属于多种类型来扩展允许的值。像 jQuery 这样的库通常会公开一些函数,例如,允许您传递一个 jQuery 对象或一个字符串选择器;而联合类型允许您将可能的值限制为这两种类型(而不是求助于完全动态的类型)。
如清单 1-15 所示,创建一个类型或者是Boolean
或者是number
是可能的。联合类型使用管道分隔符来分隔每种可能的类型,您可以将它们理解为“OR”试图提供与联合中的某个类型不匹配的值会导致错误。
// Type annotation for a union type
let union: boolean | number;
// OK: number
union = 5;
// OK: boolean
union = true;
// Error: Type "string" is not assignable to type 'number | boolean'
union = 'string';
// Type alias for a union type
type StringOrError = string | Error;
// Type alias for union of many types
type SeriesOfTypes = string | number | boolean | Error;
Listing 1-15.Union Types
创建联合类型时,可以考虑使用类型别名来减少程序中定义的重复,并为概念命名。可以使用程序中可用的任何类型来创建联合,而不仅仅是基元类型。
文字类型
文字类型可用于将允许值的范围缩小到该类型的一个子集,例如将一个字符串缩减为一组特定的值。清单 1-16 显示了一种类型的Kingdom
,它具有生物体的六个分类部门作为可能的值。类型为Kingdom
的变量只能使用文字类型中包含的特定值。
type Kingdom = 'Bacteria' | 'Protozoa' | 'Chromista' | 'Plantae' | 'Fungi' | 'Animalia';
let kingdom: Kingdom;
// OK
kingdom = 'Bacteria';
// Error: Type 'Protista' is not assignable to type 'Kingdom'
kingdom = 'Protista';
Listing 1-16.String literal type
文字类型实际上只是由特定值组成的联合类型,因此您也可以使用相同的语法创建数字文字类型或联合/文字混合类型。
// Number literal type
type Fibonacci = 1 | 2 | 3 | 5 | 8 | 13;
let num: Fibonacci;
// OK
num = 8;
// Error: Type '9' is not assignable to type 'Fibonacci'
num = 9;
// Hybrid union/literal type
type Randoms = 'Text' | 10 | false;
let random: Randoms;
// OK
random = 'Text';
random = 10;
random = false;
// Error: Not assignable.
random = 'Other String';
random = 12;
random = true;
Listing 1-17.Number literal types and hybrid union/literal types
文字类型的行为类似于枚举的行为,因此如果您在文字类型中只使用数字,请考虑枚举在您的程序中是否更具表达性。
交叉点类型
交集类型将几个不同的类型组合成一个超类型,该超类型包括所有参与类型的成员。在联合类型为“A 型或 B 型”的情况下,交叉类型为“A 型和 B 型”。清单 1-18 显示了滑雪者和射击者的两个接口,这两个接口组合成了 biathelete 交叉类型。交集类型使用“与”字符,您可以将其读作“与”
interface Skier {
slide(): void;
}
interface Shooter {
shoot(): void;
}
type Biathelete = Skier & Shooter;
Listing 1-18.Intersection Types
为了查看交集类型的结果,图 1-5 显示了为 biathelete 类型显示的自动完成,同时显示了 shoot 和 slide 方法。自动完成还显示成员的原始类型。
图 1-5。
Intersection type members
交集类型对于处理 mixins 很有用,你会在第四章读到。
数组
TypeScript 数组的内容具有精确的类型。要指定数组类型,只需在类型名后添加方括号。这适用于所有类型,无论它们是基元类型还是自定义类型。当您将项目添加到数组时,将检查其类型以确保它是兼容的。当您访问数组中的元素时,您将获得高质量的自动完成,因为每一项的类型是已知的。清单 1-19 展示了每一种类型检查。
在清单 1-19 中有一些有趣的观察结果。当声明了monuments
变量时,Monument
对象数组的类型注释可以是简写的:Monument[]
或手写的:Array<Monument>
——这两种样式在含义上没有区别。因此,你应该选择你认为可读性更好的。注意,数组是在等号后面使用空数组文字([]
)实例化的。您还可以用值来实例化它,方法是将它们添加到括号中,用逗号分隔。
使用monuments.push(...)
添加到数组的对象不是明确的Monument
对象。这是允许的,因为它们与Monument
接口兼容。如果您遗漏了一个属性,将会警告您该类型不兼容;如果您添加一个额外的成员,您也会收到一个警告,这有助于捕捉拼写错误的成员名称。
使用monuments.sort(...)
对数组进行排序,它采用一个函数来比较值。当比较是数字时,comparer 函数可以简单地返回a - b
,在其他情况下,您可以编写自定义代码来执行比较,并返回一个正数或负数用于排序(如果值相同,则返回零)。
interface Monument {
name: string;
heightInMeters: number;
}
// The array is typed using the Monument interface
const monuments: Monument[] = [];
// Each item added to the array is checked for type compatibility
monuments.push({
name: 'Statue of Liberty',
heightInMeters: 46
});
monuments.push({
name: 'Peter the Great',
heightInMeters: 96
});
monuments.push({
name: 'Angel of the North',
heightInMeters: 20
});
function compareMonumentHeights(a: Monument, b: Monument) {
if (a.heightInMeters > b.heightInMeters) {
return -1;
}
if (a.heightInMeters < b.heightInMeters) {
return 1;
}
return 0;
}
// The array.sort method expects a comparer that accepts two Monuments
const monumentsOrderedByHeight = monuments.sort(compareMonumentHeights);
// Get the first element from the array, which is the tallest
const tallestMonument = monumentsOrderedByHeight[0];
// Peter the Great
console.log(tallestMonument.name);
Listing 1-19.Typed arrays
使用索引来访问数组中的元素。索引是从零开始的,所以monumentsOrderedByHeight
数组中的第一个元素是monumentsOrderedByHeight[0]
。当从数组中访问一个元素时,会为name
和heightInMeters
属性提供自动完成功能。
要了解更多关于使用数组和循环的信息,请参考附录 1。
元组类型
元组类型使用数组,并根据元素的位置指定元素的类型。清单 1-20 显示了一个元组类型,其中数组中的三个项被类型化。
let poem: [number, boolean, string];
// OK
poem = [1, true, 'love'];
// Error: 'string' is not assignable to 'number'
poem = ['my', true, 'love'];
Listing 1-20.Tuple types
当通过索引访问类型时,类型是已知的,并将被编译器检查。自动完成列表也是特定类型的,如图 1-6 所示,其中第三项(在索引 2 处)是一个字符串。
图 1-6。
Autocompletion members for tuple types
元组以它们定义的项数命名(大于 7 项的元组称为 n 元组)。
- 配对:2 个项目
- 三件套:3 件
- 四倍:4 项
- 五个:5 个项目
- 六:6 项
- 9 月:7 项
元组的常见用例是能够从一个方法返回多个值,而不必定义更复杂的结构。从概念上讲,只要数据是相关的,并且它的生命周期很短,元组就是有用的。
字典类型
您可以使用索引类型在 TypeScript 中表示字典,如清单 1-21 所示。索引类型在方括号中指定键及其类型,然后以类型注释的形式指定值的类型。cephalopod 字典是一个具有动态键的对象,但是 TypeScript 将确保键和值的类型是正确的。
interface Cephalopod {
hasInk: boolean;
arms: number;
tentacles: number;
}
interface CephalopodDictionary {
[index: string]: Cephalopod;
}
let dictionary: CephalopodDictionary = {};
dictionary['octopus vulgaris'] = { hasInk: true, arms: 8, tentacles: 0 };
dictionary['loligo vulgaris'] = { hasInk: true, arms: 8, tentacles: 2 };
// Error. Not assignable to type 'Cephalopod'
dictionary[0] = { hasInk: true };
const octopus = dictionary['octopus vulgaris'];
// 0 (The common octopus has no tentacles)
console.log(octopus.tentacles);
// Remove item
delete dictionary['octopus vulgaris'];
Listing 1-21.Indexed types
字典可以用来映射语言翻译,或者在任何需要根据唯一键查找条目的地方使用。
映射类型
为了减少创建仅在可选性或可读性方面不同的相似类型所需的工作量,映射类型允许您在单个表达式中创建现有类型的变体。映射类型使用keyof
关键字,这是一个索引类型查询,它收集程序中某个类型允许的属性名列表。
在清单 1-22 中,选项类型被手动重复为只读类型、可选类型和可空类型。options 接口上只有两个成员,但这已经是冗长的代码了。
interface Options {
material: string;
backlight: boolean;
}
// Manually created readonly interface
interface ManualReadonlyOptions {
readonly material: string;
readonly backlight: boolean;
}
// Manually created optional interface
interface ManualOptionalOptions {
material?: string;
backlight?: string;
}
// Manually created nullable interface
interface ManualNullableOptions {
material: string | null;
backlight: string | null;
}
Listing 1-22.Manual type variations
清单 1-23 显示了所有这些重复的替代方案。创建了三种可重用的类型,可以用一行代码生成只读、可选或可空的类型变体。这三种命名类型创建了这些选项界面的变体。
interface Options {
material: string;
backlight: boolean;
}
// Mapped types
type ReadOnly<T> = { readonly [k in keyof T]: T[k]; }
type Optional<T> = {[k in keyof T]?: T[k]; }
type Nullable<T> = {[k in keyof T]: T[k] | null; }
// Creating new types from mapped types
type ReadonlyOptions = Readonly<Options>;
type OptionalOptions = Optional<Options>;
type NullableOptions = Nullable<Options>;
Listing 1-23.Mapped types
您用映射类型创建的类型可以用在类型注释中来代替原始接口,如清单 1-24 所示,每个类型的行为都会相应地更新。
// Read-only type
const options1: ReadonlyOptions = {
backlight: true,
material: 'plastic'
};
// Error. Property is read-only
options1.backlight = false;
// Optional type
const options2: OptionalOptions = {
// All members are optional
};
// Nullable type
const options3: NullableOptions = {
backlight: null,
material: null
};
Listing 1-24.Using mapped types
清单 1-23 中的三种映射类型可以直接转移到您的程序中,以便您在需要时快速创建自己的类型变体。
类型断言
如果 TypeScript 确定某个赋值无效,但您知道您正在处理一种特殊情况,则可以使用类型断言重写该类型。当您使用类型断言时,您是在从编译器那里承担责任,并且必须确保赋值是有效的。如果你犯了一个错误,你的程序可能无法正常工作。类型断言在语句之前,如清单 1-25 所示。property
变量可能是一所房子或一座大厦,因此对声明为Mansion
的变量的后续赋值将会失败。因为我们知道变量与Mansion
接口兼容(它拥有满足接口所需的所有三个属性),所以类型断言<Mansion>
向编译器确认了这一点。
interface House {
bedrooms: number;
bathrooms: number;
}
interface Mansion {
bedrooms: number;
bathrooms: number;
butlers: number;
}
function getProperty() : House | Mansion {
// ...
}
const property = getProperty();
// OK as the property is on both House and Mansion
const bedroomCount = property.bedrooms;
// Errors: Property 'butlers' does not exist on type 'House | Mansion'
const butlerCount = property.butlers;
// OK with type assertion
const workingButlerCount = (<Mansion>property).butlers;
Listing 1-25.Type assertions
尽管就编译器而言,类型断言会重写类型,但在断言类型时,仍然会执行检查。通过在您想要使用的实际类型和变量的标识符之间添加一个额外的<any>
类型断言,可以强制一个类型断言,如清单 1-26 所示。当您强制类型断言时,您就承担了额外的责任,因为编译器认为您的代码不会工作。
const name: string = 'Avenue Road';
// Error: Type 'string' cannot be converted to type 'number'
const bedroomCount: number = <number>name;
// Works
const workingBedroomCount: number = <number><any>name;
Listing 1-26.Forced type assertions
防护类型
在处理宽类型定义时,您可能会发现需要缩小它的范围,以便使用一个在其他情况下不可用的成员。清单 1-27 显示了一个带有参数的函数,参数可以是字符串或数字。除非使用类型保护,否则尝试对数字使用字符串中的长度成员或 toFixed 方法将会导致错误。类型保护是一个导致类型变窄的语句,例如,示例中的 if 语句检查变量的类型是字符串,这意味着编译器知道 if 分支和 else 分支中的类型(因为如果它不是字符串,它一定是数字)。
function typeGuardExample(stringNumber: string | number) {
// Error: Property does not exist
const a = stringNumber.length;
const b = stringNumber.toFixed();
// Type guard
if (typeof stringNumber === 'string') {
// OK
return stringNumber.length;
} else {
// OK
return stringNumber.toFixed();
}
}
Listing 1-27.Type Guard
标准型护罩包括typeof
和instanceof
;但是如果这还不能解决您的问题,您可以编写自己的自定义类型保护。清单 1-28 显示了一个定制类型的保护,它决定一个提供的对象是否满足SpeedControllable
接口的要求。自定义类型保护函数使用特殊的类型注释treadmill is SpeedControllable
,这告诉编译器该函数可以作为类型保护的一部分用于缩小类型。自定义类型保护函数返回一个布尔值,表明该类型是否是速度可控的。
interface SpeedControllable {
increaseSpeed(): void;
decreaseSpeed(): void;
stop(): void;
}
interface InclineControllable {
lift(): void;
drop(): void;
}
function isSpeedControllable(treadmill: SpeedControllable | any)
: treadmill is SpeedControllable {
if (treadmill.increaseSpeed
&& treadmill.decreaseSpeed
&& treadmill.stop) {
return true;
}
return false;
}
function customTypeGuardExample(treadmill: SpeedControllable | InclineControllable) {
// Error: Property does not exist
const a = treadmill.increaseSpeed();
const b = treadmill.lift();
// Type guard
if (isSpeedControllable(treadmill)) {
// OK
treadmill.increaseSpeed();
} else {
// OK
treadmill.lift();
}
}
Listing 1-28.Custom Type Guard
结果与标准类型防护相同,跑步机参数的类型在 if 分支和 else 分支中是已知的。当您对许多类型(而不是两个)的联合使用类型保护时,您可能需要使用额外的类型保护来进一步缩小剩余的类型。
歧视工会
有区别的联合(或标记的联合)允许您组合联合类型、类型别名和类型保护,以获得完整的自动完成并检查具有公共字符串文字属性的类型。构成区别联合的组件如下:
- 几种类型共享一个共同的字符串文字属性,称为判别式。
- 这些类型的联合的类型别名,称为联合。
- 检查判别式的一种类型保护装置。
在清单 1-29 中,立方体和长方体类型共享判别式“种类”这些类型被添加到 prism union 中,这导致了 volume 函数中 switch 语句的完全自动完成。switch 语句充当类型保护,意味着在每种情况下,棱镜的类型都是正确的缩小类型。
默认情况像安全网一样使用,以确保不存在被 switch 语句排除在外的可区分联合的类型。例如,如果您要将一个三棱柱添加到有区别的并集,您将收到一个警告“类型为‘triangular prism’的参数不可分配给类型为‘never’的参数”,这告诉您它不是由 volume 方法处理的。
interface Cube {
kind: 'cube'; // Discriminant
size: number;
}
interface Cuboid {
kind: 'cuboid'; // Discriminant
width: number;
depth: number;
height: number;
}
// Union
type Prism = Cube | Cuboid;
function volume(prism: Prism): number {
// Type Guard
switch (prism.kind) {
case 'cube':
return prism.size * prism.size * prism.size;
case 'cuboid':
return prism.width * prism.depth * prism.height;
default:
assertNever(prism);
break;
}
}
function assertNever(arg: never): never {
throw new Error("Possible new tagged type: " + arg);
}
Listing 1-29.
Discriminated
union
如果您遵循面向对象的方法,您可能不会从这个特性中获得太多好处,但是它是函数式编程的一个常见用例。
经营者
所有标准的 JavaScript 操作符都可以在您的 TypeScript 程序中使用。JavaScript 操作符在附录 1 中有描述。本节描述由于类型限制或影响类型而在 TypeScript 中具有特殊意义的运算符。
增量和减量
增量(++
)和减量(--
)运算符只能应用于any
、number
或enum
类型的变量。这主要用于增加循环中的索引变量或者更新程序中的计数变量,如清单 1-30 所示。在这些情况下,您通常会使用number
类型。操作符处理any
类型的变量,因为不对这些变量进行类型检查。
let counter = 0;
do {
++counter;
} while (counter < 10);
// 10
alert(counter);
Listing 1-30.Increment and decrement
当递增或递减枚举时,数字表示被更新。清单 1-31 展示了递增size
变量如何导致枚举中的下一个元素,递减size
变量如何导致枚举中的前一个元素。使用此方法时要小心,因为您可以增加和减少超出枚举范围的值。
enum Size {
S,
M,
L,
XL
}
var size = Size.S;
++size;
console.log(Size[size]); // M
var size = Size.XL;
--size;
console.log(Size[size]); // L
var size = Size.XL;
++size;
console.log(Size[size]); // undefined
Listing 1-31.Increment and decrement
of enumerations
二元运算符
下表中的运算符设计用于处理两个数字。在 TypeScript 中,对类型为number
或any
的变量使用运算符是有效的。当你使用一个any
类型的变量时,你应该确保它包含一个数字。这个列表中的运算结果总是一个数字。
二元运算符:- * / % << >> >>> & ^ |
加号(+
)操作符不在这个列表中,因为它是一个特例:一个数学加法操作符和一个连接操作符。选择加法还是连接取决于运算符两边变量的类型。如清单 1-32 所示,这是 JavaScript 程序中的一个常见问题,其中一个有意的加法导致两个值的连接,从而产生一个意外的值。如果您试图将一个字符串赋给一个number
类型的变量,或者试图为一个被注释为返回一个number
的函数返回一个字符串,这将在 TypeScript 程序中被捕获。
确定由加法运算产生的类型的规则如下:
- 如果任一参数的类型为
string
,则结果总是为string
。 - 如果两个参数的类型都是
number
或enum
,则结果是一个number
。 - 如果其中一个参数的类型是
any
,而另一个参数不是string
,则结果是any
。 - 在任何其他情况下,操作员都不允许。
// 6: number
const num = 5 + 1;
// '51': string
const str = 5 + '1';
Listing 1-32.Binary plus operator
当加号运算符仅与单个参数一起使用时,它充当数字的快速转换。清单 1-33 展示了加号运算符的一元用法。一元减运算符也将类型转换为number
并改变其符号。
const str: string = '5';
// 5: number
const num = +str;
// -5: number
const negative = -str;
Listing 1-33.
Unary plus and minus operators
按位运算符
TypeScript 中的位运算符接受所有类型的值。运算符将表达式中的每个值视为 32 位序列,并返回一个数字。正如前面关于枚举的部分和附录 3 中所讨论的,位运算符对于处理标志很有用。
按位运算符的完整列表如表 1-1 所示。
表 1-1。
Bitwise Operators
| 操作员 | 名字 | 描述 | | --- | --- | --- | | `&` | 和 | 当两个输入都为 1 时,返回每个位置都为 1 的结果。 | | `|` | 运筹学 | 在每个输入为 1 的位置返回一个为 1 的结果。 | | `^` | 异或 | 返回每个位置都为 1 的结果,其中恰好有一个输入为 1。 | | `<<` | 左移 | 左侧参数中的位向左移动右侧参数中指定的位数。从左边移出的位被丢弃,而在右边加零。 | | `>>` | 右移 | 左侧参数中的位按照右侧参数中指定的位数向右移动。移出右侧的位被丢弃,匹配最左侧位的数字被添加到左侧。 | | `>>>` | 零填充右移 | 左侧参数中的位按照右侧参数中指定的位数向右移动。从右边移出的位被丢弃,左边加零。 | | `∼` | 不 | 接受单个参数并反转每个位。 |逻辑运算符
逻辑运算符通常用于测试布尔变量或将表达式转换为布尔值。本节解释如何在 TypeScript 中为此目的使用逻辑运算符,以及如何在布尔类型的上下文之外使用逻辑 and 和逻辑 or 运算符。
“非”算符
NOT ( !
)运算符的常见用法是反转一个布尔值:例如,如果isValid
变量是false
,则if (!isValid)
有条件地运行代码。以这种方式使用运算符不会影响类型系统。
NOT 运算符可以以影响类型的方式在 TypeScript 中使用。同样,一元加号操作符可以作为一种将任何类型的变量转换为数字的简便方法,NOT 操作符可以将任何变量转换为Boolean
类型。这可以通过使用两个一元 NOT 运算符(!!
)的序列来完成,而不需要反转变量的真值。两者都在清单 1-34 中进行了说明。传统上,单个!
用于反转语句以减少代码中的嵌套,而双!!
将类型转换为布尔值。
const truthyString = 'Truthy string';
let falseyString: string;
// False, it checks the string but inverts the truth
const invertedTest = !truthyString;
// True, the string is not undefined or empty
const truthyTest = !!truthyString;
// False, the string is empty
const falseyTest = !!falseyString;
Listing 1-34.NOT operator
当使用这种技术转换为布尔值时,JavaScript 类型杂耍规则适用。因此,有必要熟悉一下适用于这种操作的“真”和“假”的概念。术语 falsey 适用于在逻辑运算中使用时等同于false
的某些值。其他都是“真”,相当于true
。以下值为“falsey”并被评估为false
undefined
null
false: boolean
'': string
(空字符串)0: number
NaN
(JavaScript 不是数字值)
所有其他值被评估为true
。令人惊讶的例子包括:
'0': string
'False': string
这种检查方式不同于其他语言,但允许对变量进行相当强大的速记测试,如清单 1-35 所示。假设变量可以是undefined
或null
,并且您可能不想同时检查两者,这是一个有用的特性。如果您想执行类型安全的检查而不使用杂耍,您可以使用三字符操作符===
或!==
;例如,if (myProperty === false)
测试比较两边的类型是否相同,它们的值是否相同。
var myProperty;
if (myProperty) {
// Reaching this location means that...
// myProperty is not null
// myProperty is not undefined
// myProperty is not boolean false
// myProperty is not an empty string
// myProperty is not the number 0
// myProperty is not NaN
}
Listing 1-35.Shorthand Boolean test
逻辑积算符
逻辑 AND 运算符(&&
)的常见用法是断言一个逻辑表达式的两边都为真,例如if (isValid && isRequired)
。如果表达式的左侧为 false(或者为 false,表示可以转换为 false),则求值结束。否则,将计算表达式的右侧。
AND 运算符也可以在逻辑上下文之外使用,因为表达式的右边只有在左边为真时才计算。在清单 1-36 中,只有定义了控制台对象时才会调用console.log
函数。在第二个例子中,只有在已经有一个player1
值的情况下,才会设置player2
变量。当表达式的结果被赋值给一个变量时,该变量将总是具有右边表达式的类型。
// longhand
if (console) {
console.log('Console Available');
}
// shorthand
console && console.log('Console Available');
const player1 = 'Martin';
// player2 is only defined if player1 is defined
const player2 = player1 && 'Dan';
// 'Dan'
alert(player2);
Listing 1-36.AND operator
OR 运算符
逻辑 OR ( ||
)运算符的常见用途是测试表达式的两边之一是否为真。首先评估左侧,如果左侧为真,则评估结束。如果左边不为真,则计算表达式的右边。
OR 运算符不太常见的用法是合并两个值,在左边的值为 falsey 的情况下,用右边的值替换左边的值。清单 1-37 说明了这种用法。结果具有表达式中两种类型之间的最佳公共类型。最佳常见类型在第三章中有更详细的解释。
// Empty strings are falsey
let errorMessages = '';
// result is 'Saved OK'
let result = errorMessages || 'Saved OK';
// Filled strings are truthy
errorMessages = 'Error Detected';
// result is 'Error Detected'
result = errorMessages || 'Saved OK';
let undefinedLogger;
// if the logger isn't initialized, substitute it for the result of the right-hand expression
const logger = undefinedLogger || { log: function (msg: string) { alert(msg); } };
// alerts 'Message'
logger.log('Message');
Listing 1-37.OR operator
短路评估
逻辑 AND 运算符和逻辑 or 运算符都受益于短路评估。这意味着,一旦语句可以被逻辑地回答,求值就停止。虽然这节省了第二个语句的处理,但真正的好处是,这意味着您可以确保在使用值之前对其进行定义。
在清单 1-38 中,在不支持短路计算的语言中,if 语句会失败,因为正在访问 caravan 变量的一个属性,这是未定义的。因为一个未定义的变量是 false,只需要对表达式的左边求值就可以知道整个表达式是 false,所以caravan.rooms
属性永远不会被访问。
interface Caravan {
rooms: number;
}
let caravan: Caravan;
if (caravan && caravan.rooms > 5) {
//...
}
Listing 1-38.Short-circuit evaluation
条件运算符
当你写一个 if-else 语句导致不同的值被赋给同一个变量时(如清单 1-39 所示),你可以使用一个条件操作符来缩短你的代码,尽管一致地使用对称的 if-else 语句有一些好处;其中之一是它使得代码重复更容易被发现。
const isValid = true;
let message: string;
// Long-hand equivalent
if (isValid) {
message = 'Okay';
} else {
message = 'Failed';
}
Listing 1-39.The If-statement
条件操作符是一种基于逻辑测试分配两个值之一的简便方法,如清单 1-40 所示。当在 TypeScript 中使用条件运算符时,结果具有两个可能值之间的最佳公共类型。第三章描述了最常见的类型。
const isValid = true;
// Conditional operator
const message = isValid ? 'Okay' : 'Failed';
Listing 1-40.Conditional operator
类型运算符
在 JavaScript 中处理对象时,有一组操作符可以帮助您。诸如typeof
、instanceof
、in
和delete
的操作符与类的工作特别相关;你会在本章后面的类章节中找到更多关于这些操作符的信息。
解构
析构允许你将一个数组或对象解包到命名变量中。清单 1-41 显示了一个三角形数组,该数组被分解成两个变量,这两个变量捕获数组中的第一项和第二项。一旦数组被析构,命名变量就包含了值。原始数组不受析构的影响。
const triangles = [1, 3, 6, 10, 15, 21];
// Destructuring
const [first, second] = triangles;
// 1
console.log(first);
// 3
console.log(second);
Listing 1-41.
Array
destructuring
析构时也可以使用 rest 参数。休止符参数前面有三个点(...)前缀,在其他领域也会出现,比如函数。rest 参数必须出现在列表的最后,它将接收命名参数解包后剩下的所有值。清单 1-42 向三角形编号示例添加了一个 rest 参数。
const triangles = [1, 3, 6, 10, 15, 21];
// Destructuring with a rest argument
const [first, second, ...remaining] = triangles;
// 1
console.log(first);
// 3
console.log(second);
// [6, 10, 15, 21]
console.log(remaining);
Listing 1-42.Array destructuring
with a rest parameter
您可以通过在逗号之间留出空格来跳过数组中的项目,如清单 1-43 所示,其中第三个项目被跳过。只要不指定变量名,就可以跳过任意多的项目。
const triangles = [1, 3, 6, 10, 15, 21];
// Skipping third item
const [first, second, , fourth] = triangles;
// 1
console.log(first);
// 3
console.log(second);
// [10]
console.log(fourth);
Listing 1-43.
Skipping
items
析构的一个创造性应用是交换变量值而不引入中间变量。清单 1-44 使用析构赋值一次传递值。
let a = 3;
let b = 5;
// Swapping
[a, b] = [b, a];
// 5
console.log(a);
// 3
console.log(b);
Listing 1-44.
Variable
swapping
还可以使用析构来解包对象。对象析构的语法略有不同,如清单 1-45 所示。表达式的左侧看起来像一个对象文字,但其中的值实际上是新的变量,这些变量将被赋予出现在它们左侧的值。您可以将析构视为提取数据的数组文字和提取数据的对象文字。
const highSchool = { school: 'Central High', team: 'Centaurs' };
// Object destructuring
const { school: s, team: t } = highSchool;
// 'Central High'
console.log(s);
// 'Centaurs'
console.log(t);
Listing 1-45.
Object
destructuring
如果使用与属性名匹配的变量名,也可以自动解包对象。在清单 1-46 中,变量使用与高中对象成员相同的名称:学校和团队。
const highSchool = { school: 'Central High', team: 'Centaurs' };
// Auto-unpacking
const { school, team } = highSchool;
// 'Central High'
console.log(school);
// 'Centaurs'
console.log(team);
Listing 1-46.
Auto-unpacking
当您在对象析构中使用 rest 参数时,它将导致对象包含您没有显式解包的所有属性。清单 1-47 演示了对象析构 rest 参数。
const pets = { cat: 'Pickle', dog: 'Berkeley', hamster: 'Hammy'}
// Object destructuring
const { dog, ...others } = pets;
// 'Berkeley'
console.log(dog);
// Object { cat: 'Pickle', hamster: 'Hammy'}
console.log(others);
Listing 1-47.
Object destructuring
with rest parameter
如果您的析构超过了可用的值,结果将是未定义的,如清单 1-48 所示,其中第四个变量将是未定义的,因为数组只有三项。
const triangles = [1, 3, 6];
// Destructuring past available values
const [first, second, third, fourth] = triangles;
// undefined
console.log(fourth);
Listing 1-48.
Undefined
result
为了减少未定义的值,您可以提供默认值作为析构表达式的一部分。清单 1-49 显示了第三和第四个变量的默认值。因为数组中有三项,所以第三个变量的值是 6,但是第四个变量的值是默认值-1,而不是未定义的。
const triangles = [1, 3, 6];
// Destructuring past available values
const [first, second, third = -1, fourth = -1] = triangles;
// 6
console.log(third);
// -1
console.log(fourth);
Listing 1-49.
Default values
元组和析构是一个强大的组合。如果您有一个返回元组的方法,您可以立即将其析构为命名变量。这使元组类型的生命周期尽可能短,并通过使每个变量显式化来更好地表达返回值。清单 1-50 展示了 tuple/destructing 组合的作用。
// Returning a tuple
function getThreeLandmarks(): [string, string, string] {
return ['Golden Gate Bridge', 'Palace of Westminster', 'Colosseum '];
}
// Destructuring the tuple into named variables
const [sanFrancisco, london, rome] = getThreeLandmarks();
Listing 1-50.
Tuples
and Destructuring
当析构使你的代码更可读时,它是有价值的。虽然你可以用析构做一些聪明的事情,但是聪明的做法是判断它是否产生了更好地表达意图的代码,或者它是否只是使代码变得混乱。本节中的例子都是对该特性的非常保守的使用,虽然简洁,但是可读性很好。
传播算子
spread 运算符的作用与析构相反,可用于使用浅表副本打包数组和对象。spread 运算符可以处理属性,但很遗憾不能处理方法。清单 1-51 显示了数组扩展,导致值被打包到新数组中。spread 操作符再次重用 rest 参数语法。
const squares = [1, 4, 9, 16, 25];
const powers = [2, 4, 8, 16, 32];
// Array spreading
const squaresAndPowers = [...squares, ...powers];
// [1, 4, 9, 16, 25, 2, 4, 8, 16, 32]
console.log(squaresAndPowers);
Listing 1-51.Array spreading
对象扩展的语法几乎相同,如清单 1-52 所示,结果是一个对象包含两个输入对象的所有成员。如果相同的成员出现在两个对象上,则最后一个赋值获胜并覆盖任何以前的值。
const emergencyService = {
police: 'Chase',
fire: 'Marshall',
};
const utilityService = {
recycling: 'Rocky',
construction: 'Rubble'
};
// Object spreading
const patrol = { ...emergencyService, ...utilityService };
// { police: 'Chase', fire: 'Marshall', recycling: 'Rocky', construction: 'Rubble' }
console.log(patrol);
Listing 1-52.Object spreading
您甚至可以对函数参数使用 spread 运算符;清单 1-53 中的代码使用六边形数组中提供的数字调用函数。
function add(a: number, b: number, c: number) {
return a + b + c;
}
const hexagons = [1, 6, 15];
// Spread operator in function call
const result = add(...hexagons);
// 22
console.log(result);
Listing 1-53.
Spread operator
in function call
spread 操作符的一个主要优点是,在许多情况下,它不再需要循环语法,这使得您的代码更具可读性和表达性。
功能
现在您已经理解了类型的细节,您已经准备好将这些知识应用到类型脚本程序的核心主题:函数。虽然使用类、名称空间和模块有一些有趣的代码组织选项,但是函数是可读、可维护和可重用代码的构建块。
在 TypeScript 中,您可能会发现大多数函数都被编写为属于某个类的方法。使用模块和类将代码组织成逻辑单元是有意义的。无论您是否选择使用这些结构元素,函数都可以通过几个 TypeScript 语言特性得到改进。
对于变量,类型注释只有一个位置,就在标识符后面。对于函数,有几个地方可以用类型信息进行注释。在清单 1-54 中,你会看到每个参数都可以被赋予一个类型注释。在清单 1-54 的例子中,getAverage
函数接受三个参数,每个参数可以有不同的类型。调用该函数时,会检查传递给该函数的每个参数的类型。函数中的类型也是已知的,这允许合理的自动完成建议和函数体内的类型检查。
括号外还有一个附加的类型注释,用于指示返回类型。在清单 1-54 中,函数返回一个字符串。根据该注释检查每个 return 语句,以确保返回值与返回类型兼容。您可以使用void
类型来指示函数不返回值。这将防止函数内部的代码返回值,并停止调用代码将函数的结果赋给变量。
function getAverage(a: number, b: number, c: number): string {
const total = a + b + c;
const average = total / 3;
return 'The average is ' + average;
}
const result = getAverage(4, 3, 8); // 'The average is 5'
Listing 1-54.Function type annotations
函数上的类型注释是显式添加注释带来好处的少数地方之一。如果不注释返回类型,在不同分支返回不同类型的情况下,TypeScript 可能会推断出联合类型。参数的注释保持内联调用代码,并确保在函数中强制类型。
可选参数
在 JavaScript 中,可以在不提供任何参数的情况下调用函数,即使函数指定了参数。在 JavaScript 中,传递的参数甚至可能比函数需要的多。在 TypeScript 中,编译器会检查每个调用,如果参数在数量或类型上与所需的参数不匹配,就会发出警告。
因为参数被彻底检查,所以您需要注释可选参数,以通知编译器调用代码省略参数是可以接受的。为了使参数可选,在标识符后面加上一个问号,如清单 1-55 所示,这是getAverage
函数的更新版本,接受两个或三个参数。
可选参数必须位于参数列表中任何必需参数之后。例如,如果第三个参数是必需的,则第二个参数不能是可选的。
function getAverage(a: number, b: number, c?: number): string {
let total = a;
let count = 1;
total += b;
count++;
if (typeof c !== 'undefined') {
total += c;
count++;
}
const average = total / count;
return 'The average is ' + average;
}
// 'The average is 5'
const result = getAverage(4, 6);
Listing 1-55.Optional paramters
当使用可选参数时,必须检查该值以查看它是否已被初始化。typeof
检查是该检查的常用模式。如果您使用速记检查of (b)
,您会发现空字符串和数字零会被视为变量未定义。更长的表达式if (typeof b === 'undefined')
通过彻底检查类型和值来避免这种情况。
默认参数
默认参数是可选参数的补充。无论何时考虑使用可选参数,都应该考虑使用默认参数作为替代设计。当您指定一个默认参数时,它允许通过调用代码省略该参数,并且在没有传递该参数的情况下,将使用默认值。
要为参数提供默认值,请在函数声明中赋值,如清单 1-56 所示。
function concatenate(items: string[], separator = ',', beginAt = 0, endAt = items.length) {
let result = '';
for (let i = beginAt; i < endAt; i++) {
result += items[i];
if (i < (endAt - 1)) {
result += separator;
}
}
return result;
}
const items = ['A', 'B', 'C'];
// 'A,B,C'
const result = concatenate(items);
// 'B-C'
const partialResult = concatenate(items, '-', 1);
Listing 1-56.
Default parameters
默认参数生成的 JavaScript 代码包含一个type of
检查,就像清单 1-55 中为可选参数手工编写的检查一样。这意味着默认参数会导致函数体内部的检查,如果没有传递参数,该检查会分配默认值。但是,对于默认参数,这些检查只出现在输出中,这使得 TypeScript 代码清单简短明了。因为检查是在函数体内进行的,所以您可以使用各种各样的运行时值作为缺省值——不像在其他语言中那样局限于编译时常量。默认值可以通过计算得出,甚至可以从其中一个参数中获得(如清单 1-56 中的参数endAt
的情况),或者引用任何属性、变量、常量或其他可以从函数体内访问的值。
休息参数
Rest 参数允许调用代码指定零个或多个指定类型的参数。为了正确传递参数,rest 参数必须遵循以下规则
- 只允许一个 rest 参数。
- rest 参数必须出现在参数列表的最后。
- rest 参数的类型必须是数组类型。
要声明一个 rest 参数,在标识符前面加上三个句点,并确保类型注释是一个数组类型,如清单 1-57 所示。
function getAverage(...a: number[]): string {
let total = 0;
let count = 0;
for (let i = 0; i < a.length; i++) {
total += a[i];
count++;
}
const average = total / count;
return 'The average is ' + average;
}
// 'The average is 6'
const result = getAverage(2, 4, 6, 8, 10);
Listing 1-57.Rest Parameters
您的函数应该可以接收任意数量的参数,包括零个。在编译后的 JavaScript 代码中,您会看到编译器已经添加了代码,将参数列表映射到方法体中的数组变量。
Note
如果您要求至少传递一个参数,那么您需要在 rest 参数之前添加一个必需的参数来强制执行这个最低要求。这将是清单 1-57 中getAverage
函数的正确签名,以避免潜在的被零除错误。
霸王
在介绍函数重载之前,我特意介绍了联合类型,以及可选、默认和 rest 参数;在大多数情况下,您可以使用这些语言功能编写方法,避免编写重载。在这不可能的地方,你应该考虑编写单独的、命名良好的函数,使它们不同的意图显而易见。这并不是说函数重载没有有效的用途,如果你已经考虑了其他选择,选择使用重载是一个完全合理的选择。
在许多语言中,每个重载都有自己的实现,但是在 TypeScript 中,重载都修饰一个实现,如清单 1-58 中突出显示的。函数的实际签名最后出现,并被重载隐藏。这个最终签名被称为实现签名。实现签名必须定义与所有前面的签名兼容的参数和返回值。这意味着,每个重载的返回类型可以不同,参数列表不仅在类型上不同,而且在参数数量上也不同。如果重载指定的参数少于实现签名,则实现签名必须将额外的参数设置为可选、默认或 rest 参数。
当调用定义了重载的函数时,编译器会构造一个签名列表,并尝试确定与函数调用匹配的签名。如果没有匹配的签名,调用将导致错误。如果一个或多个签名匹配,则最早的匹配签名(按照它们在文件中出现的顺序)确定返回类型。
function getAverage(a: string, b: string, c: string): string;
function getAverage(a: number, b: number, c: number): string;
// implementation signature
function getAverage(a: any, b: any, c: any): string {
const total = parseInt(a, 10) + parseInt(b, 10) + parseInt(c, 10);
const average = total / 3;
return 'The average is ' + average;
}
// The average is 5
const result = getAverage(4, 3, 8);
Listing 1-58.
Overloads
重载给函数带来了负担,因为可能需要测试或转换类型,并且它们可能会导致函数中出现多个逻辑分支。如果类型兼容并且函数中不需要编写额外的代码,重载允许在多种情况下使用单个函数。
对于清单 1-58 ,更简单的解决方案是使用带有单个函数签名的string | number
的联合类型。联合类型可能会消除函数重载的大多数情况,剩下的情况通常可以用可选的或默认的参数来替换。如果你的程序中有很多重载,你可能会错过使用这些语言特性的机会。
Note
使用重载时,不能直接调用实现签名,因此所有调用都必须与重载之一兼容。
专门的霸王签名
专用重载签名是指 TypeScript 中基于字符串常量创建重载的能力。重载不是基于不同的参数,而是基于一个参数的字符串值,如清单 1-59 所示。这允许在许多情况下重用函数的单个实现,而不需要调用代码来转换类型。
使用专用重载签名时,有一些规则要遵循
- 必须至少有一个非专门化的签名。
- 每个专门化签名必须返回一个非专门化签名的子类型。
- 实现签名必须与所有签名兼容。
class HandlerFactory {
getHandler(type: 'Random'): RandomHandler;
getHandler(type: 'Reversed'): ReversedHandler;
getHandler(type: string): Handler; // non-specialized signature
getHandler(type: string): Handler { // implementation signature
switch (type) {
case 'Random':
return new RandomHandler();
case 'Reversed':
return new ReversedHandler();
default:
return new Handler();
}
}
}
Listing 1-59.
Specialized overload signatures
专门化签名最常见的情况是,非专门化签名返回一个超类,每个重载返回一个继承(或在结构上兼容)该超类的更专门化的子类。这就是在早期版本的 TypeScript 标准库中声明文档对象模型(DOM)方法getElementsByTagName
的经典定义的方式,这意味着根据传递给函数的 HTML 标记名,您可以得到一个适当类型的NodeList
。清单 1-60 中显示了这个方法签名的摘录(标准库的当前版本使用了稍微不同的机制)。
当您编写满足这些规则的签名时,您可能会发现您的实现签名与您的非专门化签名是相同的。请记住,实现签名对调用代码是隐藏的,因此尽管它看起来像重复,但却是必要的。清单 1-59 说明了这一点,它展示了专门化的子类是如何被注释为返回类型的,其中一个特定的值在类型参数中传递。
// This example does not list all variations...
getElementsByTagName(name: "a"): NodeListOf<HTMLAnchorElement>;
getElementsByTagName(name: "blockquote"): NodeListOf<HTMLQuoteElement>;
getElementsByTagName(name: "body"): NodeListOf<HTMLBodyElement>;
getElementsByTagName(name: "button"): NodeListOf<HTMLButtonElement>;
getElementsByTagName(name: "form"): NodeListOf<HTMLFormElement>;
getElementsByTagName(name: "h1"): NodeListOf<HTMLHeadingElement>;
getElementsByTagName(name: string): NodeList; // Non-specialized signature
getElementsByTagName(name: string): NodeList { // implementation signature
return document.getElementsByTagName(name);
}
Listing 1-60.
getElementsByTagName
这是一种不常见的技术,但是对于定义 web 浏览器的行为来说是必要的。专用重载检查被传递的值并基于该值选择重载,例如,如果您传递一个带有值"blockquote"
的name
参数,清单 1-60 中的第二个签名将被匹配,返回类型为NodeListOf<HTMLQuoteElement>
。
箭头功能
TypeScript 为定义函数提供了速记语法。arrow 函数的灵感来自 ECMAScript 标准的新增内容。箭头函数允许您省去function
关键字,并以一种超紧凑的方式定义您的函数。当您以 ECMAScript 3 或 5 为目标时,清单 1-61 中的所有函数在输出中都产生相同的 JavaScript 函数。
Note
TypeScript 编译器可以选择 ECMAScript 规范的版本 3 和版本 5,并支持新的年度版本,如 ECMA2015 及以后的版本。ECMAScript 规范的第 4 版被放弃了,所以从技术上讲它并不存在。
const shortAddNumbers = (a: number, b: number) => a + b;
const mediumAddNumbers = (a: number, b: number) => {
return a + b;
}
const longAddNumbers = function (a: number, b: number) {
return a + b;
}
Listing 1-61.Arrow functions
清单 1-61 中的每个函数变体都定义了一个接受两个数并返回这两个数之和的函数。在最短的例子中,虽然没有return
关键字,但是编译器会返回单个表达式的结果。如果你想写多个表达式,你需要用括号把函数括起来,并使用return
关键字。
有时,箭头函数返回的单个表达式将是一个对象,例如,{ firstName: 'Mark', lastName: 'Rendle' }
。对象声明周围的大括号混淆了 TypeScript 编译器,所以你需要用圆括号将它标记为表达式,如清单 1-62 所示。
const makeName = (f: string, l: string) => ({ first: f, last: l });
Listing 1-62.Wrapping an object in parentheses
您还可以使用箭头语法来保留关键字this
的词法范围。这在处理回调、承诺或事件时特别有用,因为这些表示您可能会失去当前范围的情况。这将在本章后面的类一节中详细讨论,但它在类之外也很有用,如清单 1-63 所示。
ScopeLosingExample
对象使用标准语法创建函数,当定时器到期时调用该函数。当函数被计时器调用时,this
的范围丢失,所以this.text
的值是undefined
,因为我们不再在对象上下文中。在ScopePreservingExample
中,唯一的变化是使用了 arrow 语法,它修复了范围问题,并允许获得正确的值。
const scopeLosingExample = {
text: "Property from lexical scope",
run: function () {
setTimeout(function () {
alert(this.text);
}, 1000);
}
};
// alerts undefined
scopeLosingExample.run();
const scopePreservingExample = {
text: "Property from lexical scope",
run: function () {
setTimeout(() => {
alert(this.text);
}, 1000);
}
};
// alerts "Property from lexical scope"
scopePreservingExample.run();
Listing 1-63.Preserving scope with arrow syntax
在后台,TypeScript 编译器在定义 arrow 函数之前创建一个名为_this
的变量,并将其值设置为当前值this
。它还用新引入的_this
变量替换函数中this
的任何用法,因此 JavaScript 输出中的语句现在显示为_this.text
。在函数中使用_this
变量会在变量周围创建一个闭包,这将保留变量和函数的上下文。您可以自己遵循这种模式,如果您既需要this
的原始含义,也需要this
的功能范围含义,比如在处理事件时,这种模式会很有用。
函数 Currying
Currying 是一个过程,在这个过程中,一个具有多个参数的函数被分解成多个函数,每个函数只有一个参数。由此产生的函数链可以分阶段调用,部分应用的阶段成为组合函数和值的可重用实现。
可以使用箭头函数进行函数 currying,如清单 1-64 所示。第一个函数接受一个名为a
的参数,并返回另一个获取了原始参数值的函数。返回的函数接受一个名为b
的单参数,被调用时返回 a 和 b 的乘积,你可以抓住第一次函数调用的结果多次使用,这是 currying 的关键点之一。
// Currying
const multiply = (a: number) => (b: number) => a * b;
// Pass both arguments in sequence: 30
const numA = multiply(5)(6);
// Pass just the first argument and re-use
const orderOfMagnitude = multiply(10);
// 10
const deca = orderOfMagnitude(1);
// 100
const hecta = orderOfMagnitude(deca);
// 1,000
const kilo = orderOfMagnitude(hecta);
Listing 1-64.Currying with arrow functions
如果你发现第一个函数调用链很奇怪,你可以实现一个可以用一两个参数调用的函数,如清单 1-65 所示。此示例使用一个重载来允许单个参数或两个参数变量,并使用一个 guard 子句将代码分支为返回一个函数或一个数字。这使得“普通”调用可以与两个参数相乘,并支持函数 currying。
function multiply(a: number): (b: number) => number;
function multiply(a: number, b: number): number;
function multiply(a: number, b: number = null) {
if (b === null) {
return (b: number) => a * b;
}
return a * b;
}
// Pass both arguments "normally": 30
const numA = multiply(5, 6);
// Pass just the first argument and re-use
const orderOfMagnitude = multiply(10);
// 10
const deca = orderOfMagnitude(1);
// 100
const hecta = orderOfMagnitude(deca);
// 1,000
const kilo = orderOfMagnitude(hecta);
Listing 1-65.
Currying
with function overloads
如果您正在努力想出这个特性的一个实际应用,清单 1-66 中显示了一个实际的例子,其中代替重复日志记录函数的第一个参数,它可以被指定一次并在每次调用日志记录函数时被应用。这减少了重复,提高了可读性。
const log = (source: string) => (message: string) => console.log(source, message);
const customLog = log('Custom Log:');
// Custom Log: Message One
customLog('Message One');
// Custom Log: Message Two
customLog('Message Two');
Listing 1-66.
Practical
currying
如果您发现自己用相似的参数多次调用一个函数,那么您可以应用 currying 来清理函数调用。
接口
TypeScript 接口可以用于多种目的。如您所料,接口可以用作抽象类型,由具体的类实现,但它们也可以用于定义您的 TypeScript 程序中的任何结构。接口也是定义契约的构造块,这些契约由不是用 TypeScript 编写的第三方库和框架提供。在第九章中有更多关于编写环境声明来定义外部代码的细节。
接口是用关键字interface
声明的,并且包含一系列注释来描述它们所代表的契约。正如您所料,注释描述了属性和函数,以及构造函数和索引器,这些在其他语言中并不常见。当编写接口来描述您打算在程序中实现的类时,您不需要定义构造函数或索引器。包含这些特性是为了帮助您用可能不类似于类的结构来描述外部代码。这将在第四章中讨论。
清单 1-67 展示了一组描述车辆、乘客、位置和目的地的接口。属性和方法是用本章中常见的类型注释来声明的。使用关键字new
声明构造函数。
interface Point {
// Properties
x: number;
y: number;
}
interface Passenger {
// Properties
name: string;
}
interface Vehicle {
// Constructor
new(): Vehicle;
// Properties
currentLocation: Point;
// Methods
travelTo(point: Point): void;
addPassenger(passenger: Passenger): void;
removePassenger(passenger: Passenger): void;
}
Listing 1-67.Interfaces
接口不会导致任何编译的 JavaScript 代码;这是由于第三章中描述的类型擦除。接口在设计时用于提供自动完成,在编译时用于提供类型检查。
就像枚举一样,接口保持开放,所有具有公共根的声明都合并到一个结构中。这意味着您必须确保组合接口是有效的;您不能在同一个接口的多个块中声明相同的属性(您将收到“重复标识符”错误),并且您不能定义相同的方法(尽管您可以向现有方法添加重载)。
图 1-7。
The native NodeList
在编写自己的程序时,在几个块中声明一个接口并不是一个特别有价值的特性,但是在扩展内置定义或外部代码时,这个特性是无价的。例如,图 1-7 显示了NodeList
上可用的项目:item
方法和length
属性。NodeList
的内置接口定义如清单 1-68 所示;属性、item
方法和索引器都包括在内。
interface NodeList {
readonly length: number;
item(index: number): Node;
[index: number]: Node;
}
Listing 1-68.Built-in NodeListOf<T> interface
如果接口是封闭的,您将被限制在 TypeScript 附带的标准库中定义的契约,但是在清单 1-69 中,一个额外的接口块扩展了内置的NodeList
接口,以添加一个本地不可用的onclick
属性。该实现没有包含在这个示例中——它可能是一个新的 web 标准,还没有进入 TypeScript 的标准库,或者是一个添加了额外功能的 JavaScript 库。就编译器而言,标准库中定义的接口和 TypeScript 文件中定义的接口是一个接口。你可以在第五章中找到更多关于扩展现有对象的信息,在第六章中找到关于专门扩展本地浏览器功能的信息。
interface NodeList {
onclick: (event: MouseEvent) => any;
}
const nodeList = document.getElementsByTagName('div');
nodeList.onclick = function (event: MouseEvent) {
alert('Clicked'
};
Listing 1-69.Extending the NodeList interface
还可以用接口描述混合类型,比如函数/对象混合类型。清单 1-70 展示了一个非常简短的混合类型,它可能是 jQuery 的一个虚构的早期版本,这可能是世界上最著名的混合类型实现。
// Hybrid type
interface SimpleDocument {
(selector: string): HTMLElement;
notify(message: string): void;
}
// Implementation
const prepareDocument = function (): SimpleDocument {
let doc = <SimpleDocument>function (selector: string) {
return document.getElementById(selector);
};
doc.notify = function (message: string) {
alert(message);
}
return doc;
}
const $ = prepareDocument();
// Call $ as a function
const elem = $('myId');
// Use $ as an object
$.notify(elem.id);
Listing 1-70.Hybrid types
值得重申的是,接口不仅仅是用来描述你打算在一个类中实现的契约,它们还可以用来描述你在程序中可以想到的任何结构,无论是函数、变量、对象还是它们的组合。当方法接受 options 对象作为参数时,这在 jQuery 等 JavaScript 框架中很常见,可以使用接口为复杂对象参数提供自动完成功能。
还有一个与 TypeScript 中的接口相关的稍微模糊的特性值得记住。接口可以从类继承,就像子类可以从超类继承一样。当您这样做时,接口继承该类的所有成员,但没有任何实现。添加到类中的任何东西也将被添加到接口中。你会发现这个特性在与泛型结合使用时特别有用,这将在本章后面解释。
班级
关于 TypeScript 语言的大多数前述信息都涉及用类型信息注释代码的各种方法。正如你将在第三章中读到的,尽管理解各种类型注释很重要,但 TypeScript 有强大的类型推断功能,可以为你做很多工作。另一方面,结构元素将成为熟悉的工具,塑造成你的手的形状。当涉及到组织你的程序时,类是最基本的结构元素。
当使用类时,有相当多的方面需要学习,但是如果你以前有任何基于类的面向对象的经验,许多特性将是可识别的,即使细节或语法是新的。
构造器
无论是否指定构造函数,TypeScript 中的所有类都有一个构造函数。如果省略构造函数,编译器会自动添加一个。对于不从另一个类继承的类,自动构造函数将是无参数的,并将初始化任何类属性。当该类扩展另一个类时,自动构造函数将匹配超类签名,并在初始化它自己的任何属性之前将参数传递给超类。
清单 1-71 显示了两个拥有手工编写的构造函数的类。与本章中的许多其他代码清单相比,它是一个稍微长一点的例子,但是在解释每个方面之前,它值得通读一遍。
class Song {
constructor(private artist: string, private title: string) {
}
play() {
console.log('Playing ' + this.title + ' by ' + this.artist);
}
}
class Jukebox {
constructor(private songs: Song[]) {
}
play() {
const song = this.getRandomSong();
song.play();
}
private getRandomSong() {
const songCount = this.songs.length;
const songIndex = Math.floor(Math.random() * songCount);
return this.songs[songIndex];
}
}
const songs = [
new Song('Bushbaby', 'Megaphone'),
new Song('Delays', 'One More Lie In'),
new Song('Goober Gun', 'Stereo'),
new Song('Sohnee', 'Shatter'),
new Song('Get Amped', 'Celebrity')
];
const jukebox = new Jukebox(songs);
jukebox.play();
Listing 1-71.
Constructors
这个例子首先给你的印象是构造函数参数没有映射到成员变量。如果您在构造函数参数前加上一个访问修饰符,比如private
,它将自动为您映射。您可以引用这些构造函数参数,就像它们被声明为类的属性一样;例如,this.title
,可以在Song
类中的任何地方使用,以获得该实例上的歌曲标题。清单 1-72 显示了手动映射参数的等价代码,但这是为了说明这会产生大量冗余代码,您应该避免这种方法。
class Song {
private artist: string;
private title: string;
constructor(artist: string, title: string) {
// Don't do this!
this.artist = artist;
this.title = title;
}
play() {
console.log('Playing ' + this.title + ' by ' + this.artist);
}
}
Listing 1-72.Manually mapped constructor
parameters
访问修饰符
访问修饰符改变类成员的可见性。TypeScript 中有三种可用的访问修饰符:
- 私人的
- 保护
- 公众的
private 修饰符将可见性仅限于同一个类。当您在成员上使用 private 修饰符时,它不会出现在自动完成中(这是您不能使用它的强烈暗示),并且试图从类外部访问该成员将导致编译器错误。你甚至不能从子类中访问成员。
protected 修饰符允许成员在同一个类和子类中使用。不允许从任何地方访问。
public 修饰符是类成员的默认值,允许从所有位置访问。除非您想显式地使用 public 关键字,否则没有必要在属性或方法上指定该关键字,但是您需要将它添加到构造函数参数中,以使它们自动映射到属性。
当考虑类成员的可见性时,值得从最不可见的访问修饰符开始,它是私有的。当一个成员是私有的时,你可以对它进行修改,知道它只在你要修改的类中使用。当您增加成员的可见性时,您也增加了更改的复杂性,并且当您试图重构代码时,您的血压会升高。访问修饰符是封装的关键组成部分。当您要将私有成员变为公共成员时,请考虑是否应该将代码移到该类中。
访问修饰符在编译期间被移除,因此访问在运行时不受控制,但在编译期间以逻辑方式强制执行。
属性和方法
实例属性通常在 TypeScript 类中的构造函数之前声明。属性定义由三部分组成:可选的访问修饰符、标识符和类型注释。例如:public name: string;.
你也可以用一个值来初始化属性:public name = 'Jane';
当你的程序被编译时,属性初始化器被移到构造函数的顶部,所以它们在你放入构造函数的任何代码之前运行。可以使用this
关键字从类内部访问实例属性。如果该属性是公共的,则可以使用实例名来访问它。
您还可以将静态属性添加到您的类中,静态属性的定义方式与实例属性相同,但是在访问修饰符(如果指定了的话)和标识符之间使用了static
关键字。使用清单 1-73 中所示的类名访问静态属性,其中使用Playlist.maxSongCount
访问静态maxSongCount
属性——甚至在类的方法中;这是因为属性不是在每个实例上定义的。
您可以将静态和实例属性都设为只读,以防止值被覆盖。静态属性maxSongCount
已被标记为readonly
以防止该值从 30 改变。
class Playlist {
private songs: Song[] = [];
static readonly maxSongCount = 30;
constructor(public name: string) {
}
addSong(song: Song) {
if (this.songs.length >= Playlist.maxSongCount) {
throw new Error('Playlist is full');
}
this.songs.push(song);
}
}
// Creating a new instance
const playlist = new Playlist('My Playlist');
// Accessing a public instance property
const name = playlist.name;
// Calling a public instance method
playlist.addSong(new Song('Therapy?', 'Crooked Timber'));
// Accessing a public static property
const maxSongs = Playlist.maxSongCount;
// Error: Cannot assign to a readonly property
Playlist.maxSongCount = 20;
Listing 1-73.Properties and methods
清单 1-73 也展示了一个典型的方法定义。方法的定义很像函数,但是它们省略了关键字function
。您可以用前面函数一节中讨论的所有参数和返回值类型注释来注释方法。您可以用访问修饰符作为方法名的前缀来控制其可见性,默认情况下这是公共的。就像实例属性一样,可以使用this
关键字从类内部访问方法;如果它们是公共的,可以使用实例名在类外访问它们。
您可以通过在方法名前面加上关键字static
来创建静态方法。即使没有创建类的实例,并且程序中只存在每个静态成员的一个实例,也可以调用静态成员。所有静态成员都是通过类名而不是实例名来访问的,静态成员不能访问非静态属性或方法。
如果您的目标是 ECMAScript 5 或更高版本,TypeScript 支持属性 getters 和 setters。这些方法的语法与下面描述的方法签名相同,除了它们以get
或set
关键字为前缀。如清单 1-74 所示,属性获取器和设置器允许你用一个方法来包装属性访问,同时为调用代码保留一个简单属性的外观。
interface StockItem {
description: string;
asin: string;
}
class WarehouseLocation {
private _stockItem: StockItem;
constructor(public aisle: number, public slot: string) {
}
get stockItem() {
return this._stockItem;
}
set stockItem(item: StockItem) {
this._stockItem = item;
}
}
const figure = { asin: 'B001TEQ2PI', description: 'Figure' };
const warehouseSlot = new WarehouseLocation(15, 'A6');
warehouseSlot.stockItem = figure;
Listing 1-74.Property getters and setters
阶级遗产
TypeScript 中有两种类型的类继承。一个类可以使用关键字implements
实现一个接口,一个类可以使用关键字extends
从另一个类继承。
当您实现一个接口时,由于 TypeScript 中的结构类型,implements
声明完全是可选的。如果您使用关键字implements
指定接口,您的类将被检查以确保它符合接口承诺的契约。清单 1-75 展示了Song
类如何实现Audio
接口。play
方法必须在Song
类中实现,其签名必须与Audio
接口声明兼容。一个类可以实现多个接口,每个接口用逗号分隔,例如:implements Audio, Video
。
interface Audio {
play(): any;
}
class Song implements Audio {
constructor(private artist: string, private title: string) {
}
play(): void {
console.log('Playing ' + this.title + ' by ' + this.artist);
}
static Comparer(a: Song, b: Song) {
if (a.title === b.title) {
return 0;
}
return a.title > b.title ? 1 : -1;
}
}
class Playlist {
constructor(public songs: Audio[]) {
}
play() {
var song = this.songs.pop();
song.play();
}
sort() {
this.songs.sort(Song.Comparer);
}
}
class RepeatingPlaylist extends Playlist {
private songIndex = 0;
constructor(songs: Song[]) {
super(songs);
}
play() {
this.songs[this.songIndex].play;
this.songIndex++;
if (this.songIndex >= this.songs.length) {
this.songIndex = 0;
}
}
}
Listing 1-75.Class heritage
Note
类中的方法可以有比接口指定的更少的参数。这允许一个类忽略执行该方法不需要的参数。指定的任何参数都必须与接口中的参数匹配。
您使用关键字extends
继承了一个类,如清单 1-75 所示。一个extends
子句使你的类成为一个派生类,它将获得它所继承的基类的所有属性和方法。通过添加与基类成员具有相同名称和种类的成员,可以重写基类的公共成员。RepeatingPlaylist
继承自Playlist
类,并通过this.songs
使用基类的songs
属性,但是用一个专门的实现覆盖了play
方法,该实现在重复循环中播放下一首歌曲。
清单 1-75 中显示的RepeatingPlaylist
类上的constructor
可以省略,因为将生成的自动构造函数会与它完全匹配。
如果子类接受额外的参数,你需要遵循一些规则。对基类的super
调用必须是子类构造函数中的第一个语句,你不能为子类中的参数指定比基类中的参数更严格的访问修饰符。
继承时必须遵循一些规则:
- 一个类只能从一个超类继承。
- 类不能直接或通过继承链从自身继承。
可以创建一个从另一个类继承并实现多个接口的类。在这种情况下,该类必须是基类以及每个接口的子类型。
抽象类
抽象类可以用作基类,但不能直接实例化。抽象类可以包含实现的方法,也可以包含抽象方法,抽象方法没有实现,必须由任何子类实现。
清单 1-76 包含一个带有抽象notify
方法的抽象记录器类,以及一个实现的受保护的getMessage
方法。每个子类都必须实现notify
方法,但是可以共享基类的getMessage
实现。
// Abstract class
abstract class Logger {
abstract notify(message: string): void;
protected getMessage(message: string): string {
return `Information: ${new Date().toUTCString()} ${message}`;
}
}
class ConsoleLogger extends Logger {
notify(message) {
console.log(this.getMessage(message));
}
}
class InvasiveLogger extends Logger {
notify(message) {
alert(this.getMessage(message));
}
}
let logger: Logger;
// Error. Cannot create an instance of an abstract class
logger = new Logger();
// Create an instance of a sub-class
logger = new InvasiveLogger();
logger.notify('Hello World');
Listing 1-76.Abstract classes
抽象类类似于接口,因为它们包含一个可能没有实现的契约。他们可以用实现代码来添加,并且可以针对成员指定访问修饰符:这是接口不能做的两件事。
范围
如果从事件中调用类方法,或者将其用作回调,则该方法的原始上下文可能会丢失,这会导致使用实例方法和实例属性时出现问题。当上下文改变时,this
关键字的值被替换。
清单 1-77 显示了一个丢失上下文的典型例子。如果直接针对clickCounter
实例调用registerClick
方法,它将按预期工作。当registerClick
方法被分配给onclick
事件时,上下文丢失,this.count
在新的上下文中为undefined
。
class ClickCounter {
private count = 0;
registerClick() {
this.count++;
alert(this.count);
}
}
const clickCounter = new ClickCounter();
document.getElementById('target').onclick = clickCounter.registerClick;
Listing 1-77.Lost context
有几种技术可以用来保存上下文以实现这一点,您可以在不同的场景中选择使用不同的方法。
属性和箭头函数
您可以用一个属性替换该方法,并使用箭头函数初始化该属性,如清单 1-78 所示。如果您知道该类将被事件或回调所消耗,这是一种合理的技术,但是如果您的类不知道何时何地可能会被调用,这就不是一种选择。
class ClickCounter {
private count = 0;
registerClick = () => {
this.count++;
alert(this.count);
}
}
Listing 1-78.Preserving context with a property and an arrow function
调用点的函数包装
如果您想保持您的类不变,您可以将对实例方法的调用包装在一个函数中,以创建一个闭包,使上下文与函数保持一致。清单 1-79 演示了这一点,它允许在registerClick
方法中使用this
,而无需将方法转换为属性。这比搞乱班级更可取。
document.getElementById('target').onclick = function () {
clickCounter.registerClick();
};
Listing 1-79.Preserving context with a closure
ECMAScript 5 绑定函数
另一种不影响原始类的技术是使用 JavaScript 的bind
函数,该函数在 ECMAScript 5 及更高版本中可用。bind 函数设置方法的上下文。它可以更普遍地用于永久替换上下文,但是在清单 1-80 中,它用于将registerClick
方法的上下文固定为clickCounter
实例。
const clickHandler = clickCounter.registerClick.bind(clickCounter);
document.getElementById('target').onclick = clickHandler;
Listing 1-80.Preserving context with bind
事件捕获
如果需要捕获事件参数,最简单的方法是使用箭头函数,如清单 1-81 所示。registerClick 方法已更新为采用标识符,该标识符是使用事件目标(或旧版本的 Internet Explorer 中的 source 元素)获得的。这保留了上下文并在一个简洁的语句中捕获了事件信息。
class ClickCounter {
private count = 0;
registerClick(id: string) {
this.count++;
alert(this.count);
}
}
const clickCounter = new ClickCounter();
document.getElementById('target').onclick = (e) => {
const target = <Element>e.target || e.srcElement;
clickCounter.registerClick(target.id);
};
Listing 1-81.Preserving context and capturing the event
选择解决方案
有几种技术可以用来确保在对回调和事件使用类实例方法时保留上下文。关于哪一个是正确使用的,没有固定的规则;这要看具体用途和你自己的设计喜好。
如果你的目标是稍微老一点的浏览器,bind
功能可能不是一个选项,但是如果老浏览器不是问题,你可能会发现它比闭包更优雅,而且它肯定会让你的意图更清晰。属性和箭头函数技术是一个巧妙的技巧,但是可能会让太多关于方法调用位置的知识泄露到您的类中。
设计程序时要考虑的一件事是运行时创建的每个类的实例数量。如果您正在创建成百上千个实例,那么使用普通的实例方法比使用分配给属性的箭头函数更有效。这是因为普通的实例方法只定义一次,由所有实例使用。如果使用属性和箭头函数,它将在每个实例上复制。当创建大量实例时,这种重复会成为很大的开销。
如果你想在责任之间保持一个清晰的划分,遵循这个指导方针;当您需要保留回调的范围时,最好在设置回调时保留它,而不是通过调整类本身。
类型信息
在运行时获取类型是一个需要小心对待的话题。如果你的程序通过测试类型来控制程序的流程,你应该已经听到警钟了。检查类型和基于类型的不同方向的分支是一个强烈的信号,表明您已经破坏了封装。考虑到这一点,下一节将描述如何在运行时检查类型并获取类型名。
要测试一个类实例的类型,可以使用instanceof
操作符。操作符被放置在实例和您想要测试的类型之间,如清单 1-82 所示。如果您有指定类的实例,或者如果指定类出现在继承链中的任何位置,测试返回 true。在所有其他情况下,它返回 false。
class Display {
name: string = '';
}
class Television extends Display {
}
class HiFi {
}
const display = new Display();
const television = new Television();
const hiFi = new HiFi();
let isDisplay;
// true
isDisplay = display instanceof Display;
// true (inherits from Display)
isDisplay = television instanceof Display;
// false
isDisplay = hiFi instanceof Display;
Listing 1-82.Using the instanceof operator
您还可以使用in
关键字测试特定属性的存在。扩展清单 1-82 中的前一个例子,我们可以测试清单 1-83 中所示的name
属性的存在。如果该类具有该属性,或者该类继承自具有该属性的类,那么in
操作符将返回 true。
let hasName;
// true
hasName = 'name' in display;
// true
hasName = 'name' in television;
// false
hasName = 'name' in hiFi;
Listing 1-83.The in property
值得注意的是,由于 TypeScript 编译器中的代码生成,未初始化的属性将不会被检测到,因为除非该属性有值,否则它不会出现在编译的 JavaScript 代码中。在清单 1-84 中,hasName
属性将是false
,因为尽管声明了一个name
属性,但是name
属性从未被初始化。如果name
属性被赋值,hasName
将会是true
。
class Display {
name: string;
}
const display = new Display();
// false
const hasName = 'name' in display;
Listing 1-84.Uninitialized property
Note
当使用in
关键字时,不要忘记属性名的引号,因为您将需要传递一个字符串。如果没有引号,您将测试一个变量的值,而这个变量可能还没有定义。
如果您想在运行时获得类型名,您可能会尝试使用typeof
操作符。不幸的是,这将返回所有类的类型名“object”。这意味着您需要检查实例的构造函数来找到类型名。这可以如清单 1-85 所示完成。
const tv = new Television();
const radio = new HiFi();
// Television
const tvType = tv.constructor.name;
// HiFi
const radioType = radio.constructor.name;
Listing 1-85.Obtaining runtime types
如果你使用一个缩小器来压缩你编译的 JavaScript 文件,这种工具通常会改变你的类的名字,例如,Television 类可能被简化为简单的“x”。如果您打算检查构造函数名,您需要在缩小工具中禁用函数名的压缩。
无商标消费品
泛型编程允许以允许稍后指定类型的方式编写算法。这允许以相同的方式处理类型,而不会牺牲类型安全性,也不需要单独的算法实例来处理每种类型。通过指定类型约束,可以约束算法使用的可能类型。
在 TypeScript 中,可以创建泛型函数,包括泛型方法、泛型接口和泛型类。
通用函数
要使一个函数成为泛型,您可以在函数名之后立即添加一个用尖括号(< >)括起来的类型参数。然后,类型参数可用于注释函数参数、返回类型或函数中使用的类型(或其任意组合)。清单 1-86 对此进行了说明。
调用泛型函数时,可以通过将类型参数放在函数名后面的尖括号中来指定类型参数。如果可以推断出类型(例如,通过检查传递给函数的自变量的类型),则类型自变量变成可选的。
function reverse<T>(list: T[]) : T[] {
const reversedList: T[] = [];
for (let i = (list.length - 1); i >= 0; i--) {
reversedList.push(list[i]);
}
return reversedList;
}
const letters = ['a', 'b', 'c', 'd'];
// d, c, b, a
const reversedLetters = reverse<string>(letters);
const numbers = [1, 2, 3, 4];
// 4, 3, 2, 1
const reversedNumbers = reverse<number>(numbers);
Listing 1-86.Generic functions
Tip
在清单 1-86 的两个例子中,类型参数都可以省略,因为编译器能够根据传递给函数的参数来推断类型。
通用接口
要创建泛型接口,类型参数直接放在接口名称之后。清单 1-87 显示了一个通用的Repository
接口,它有两个类型参数,分别代表域对象的类型和该域对象的 ID 类型。这些类型参数可以在接口声明中的任何地方用作注释。
当CustomerRepository
类实现通用接口时,它提供具体的Customer
和CustomerId
类型作为类型参数。检查CustomerRepository
类的主体以确保它实现了基于这些类型的接口。
class CustomerId {
constructor(private customerIdValue: number) {
}
get value() {
return this.customerIdValue;
}
}
class Customer {
constructor(public id: CustomerId, public name: string) {
}
}
interface Repository<T, TId> {
getById(id: TId): T;
persist(model: T): TId;
}
class CustomerRepository implements Repository<Customer, CustomerId> {
constructor(private customers: Customer[]) {
}
getById(id: CustomerId) {
return this.customers[id.value];
}
persist(customer: Customer) {
this.customers[customer.id.value] = customer;
return customer.id;
}
}
Listing 1-87.Generic interfaces
通用类
如果泛型接口可以在代码中节省一些重复,那么泛型类可以通过提供一个实现来服务许多不同类型的场景,从而节省更多。类型参数跟在类名后面,用尖括号括起来。type 参数可用于在类中批注方法参数、属性、返回类型和局部变量。
清单 1-88 使用一个泛型类为一个域模型中所有命名的 ID 类型提供一个单一的实现。这允许命名所有的 id,而不需要每个命名类型的单独实现。这是 P. J. Plauger(有意编程,Prentice Hall,1993)描述的防止值的意外替换的常见模式。这种技术可以在 TypeScript 中使用,尽管在实现这种技术时需要记住一些细节;这些将在第三章中讨论。
class DomainId<T> {
constructor(private id: T) {
}
get value(): T {
return this.id;
}
}
class OrderId extends DomainId<number> {
constructor(orderIdValue: number) {
super(orderIdValue);
}
}
class AccountId extends DomainId<string> {
constructor(accountIdValue: string) {
super(accountIdValue);
}
}
// Examples of compatibility
function onlyAcceptsOrderId(orderId: OrderId) {
// ...
}
function acceptsAnyDomainId(id: DomainId<any>) {
// ...
}
const accountId = new AccountId('GUID-1');
const orderId = new OrderId(5);
// Error: Argument of type 'AccountId' is not assignable to parameter of type 'OrderId'
onlyAcceptsOrderId(accountId);
// OK
onlyAcceptsOrderId(orderId);
// OK
acceptsAnyDomainId(accountId);
Listing 1-88.Generic classes
类型约束
类型约束可用于限制泛型函数、接口或类可以操作的类型。清单 1-89 展示了如何使用一个接口来指定一个契约,所有类型都必须满足这个契约才能用作类型参数。使用extends
关键字指定类型约束,无论约束是接口、类还是描述约束的类型注释。
如果指定的类型参数不满足约束,编译器将发出错误。该约束还允许 TypeScript 语言服务为一般类型成员提供自动完成建议。
interface HasName {
name: string;
}
class Personalization {
static greet<T extends HasName>(obj: T) {
return 'Hello ' + obj.name;
}
}
Listing 1-89.Type constraints
在类型约束中只能指定一个类。虽然不能在类型约束中指定多个类,但可以创建一个扩展多个类的接口,并将该接口用作约束,这样可以获得相同的结果。与约束一起使用的任何类型都需要满足已经组合到单个接口中的所有类签名。
TypeScript 期货
有计划要给 TypeScript 语言添加更多的特性。从短期来看,大多数语言的改变将会使 TypeScript 与 ECMAScript 规范的发展保持同步。这种语言不会受到 ECMAScript 发展的限制,路线图目前包含了一系列正在考虑实现的特性,以支持更丰富的类型系统和改进的工具。
摘要
本章介绍了使用 TypeScript 编写大型应用所需的所有语言特性。如果您需要参考这些特性,可以重新阅读本章。现在,您应该已经牢牢掌握了类型注释,能够使用运算符来执行速记类型转换,在类内部创建例程,并利用泛型来避免近乎重复的实现。
下一章提供了关于组织你的程序的更多信息,第三章深入研究了类型系统,如果你的背景是名义类型语言,这一点尤其重要。
要点
- 所有 JavaScript 都是有效的类型脚本。
- 基本类型与 JavaScript 基本类型紧密相关。
- 类型是在 TypeScript 中推断出来的,但是您可以提供批注来使类型显式化或处理编译器无法处理的情况。
- 接口可以用来描述复杂的结构,使类型注释更短。
- 所有 TypeScript 数组都是泛型的。
- 您可以使用枚举作为位标志。
- 存在应用类型强制的特殊情况,但在大多数情况下,类型检查会因类型的无效使用而生成错误。
- 您可以向函数和方法添加可选、默认和 rest 参数。
- 箭头函数为声明函数提供了一个简短的语法,但也可以用来保留词法范围。
- 枚举、接口和模块是开放的,因此在同一个公共根中具有相同名称的多个声明将产生一个定义。
- 类为您的 TypeScript 程序带来了结构,并使使用通用设计模式成为可能。
- 您可以在运行时获取类型信息,但是应该负责任地使用它。
二、代码组织
不是语言让程序看起来简单。是程序员让语言显得简单!——罗伯特·C·马丁
本章讨论代码组织。该主题涉及名称空间、模块、模块加载和打包。本章最关心的是帮助你把你的程序分割成易于查找、维护和使用的块。
自从 TypeScript 首次公开以来,术语发生了一些变化,因此为了澄清术语,下面是对主要元素的描述:
- 命名空间(以前称为内部模块)为标识符创建上下文,减少程序中的命名冲突,并提供一种将代码组织到逻辑方案中的机制。命名空间仅向全局范围添加一项;这个项目提供了一个层次化的机制来访问命名空间中公开的所有内容。
- 模块(以前称为外部模块)是一个完全隔离的上下文,它不向全局范围添加任何项。模块可以导入其他模块,并导出可以在模块外部使用的成员。模块由模块加载器支持,有各种选项可用于在浏览器中加载模块,并且有运行 Node 的 web 服务器上的 CommonJS 加载器。
- 包是一种机制,用于交付大量代码文件以供另一个程序使用。大多数包管理器都有一个包含代码和元数据的结构化归档文件夹。这个包既可以在公共存储库(许多开源项目都是这样)上可用,也可以在私有存储库(比如公司范围的包存储库)上可用。
一个命名空间可以分成许多文件,每个文件为命名空间贡献额外的成员。一个模块完全等同于单个文件。我在这一章的目标之一是说服你更喜欢模块而不是名称空间,因为当你的程序增长时,它们会立即提供一些好处。
名称空间是一种简单的机制,非常适合捆绑。当您将代码输出到单个文件时,例如,通过使用--outFile
编译器标志编译您的 TypeScript 代码。随着您引入更多的名称空间,全局范围内的项目数量也会增加。当程序发展到一定规模时,跟踪组件之间的依赖关系变得很困难,输出文件也变得难以处理。虽然您可以异步加载一个大文件,但整个程序必须在运行前加载。名称空间可能是一个危险的陷阱,因为当您第一次开始编写 TypeScript 程序时,它们可能感觉没有摩擦;但从长远来看,它们会成为你后悔的决定。
尽管有这个警告,我仍将在本章中解释如何使用名称空间,但这并不是对该特性的认可。任何关于 TypeScript 的书如果没有对名称空间的解释都是不完整的,即使我设法说服您避免使用它们,您也几乎肯定会在其他代码库中遇到它们,并且您会想知道它们是如何工作的。
命名空间
名称空间可以用来将相关的特性组合在一起。每个名称空间都是一个单独的实例,其所有内容都包含在名称空间的范围内。通过将变量、函数、对象、类和接口分组到命名空间中,您可以将它们保持在全局范围之外并避免命名冲突,尽管每个命名空间的根都被添加到全局范围中。
名称空间是开放的,在一个公共根中具有相同名称的所有声明都构成一个名称空间。这允许在多个文件中描述名称空间,并允许您将每个文件保持在可维护的大小。
清单 2-1 显示了两个名称空间,都有一个名为Example
的类。每个名称空间创建一个单独的上下文,以允许类具有相同的名称。每个类都是作为命名空间的成员来访问的,这使得预期的类变得明确。
namespace First {
export class Example {
log() {
console.log('Logging from First.Example.log()');
}
}
}
namespace Second {
export class Example {
log() {
console.log('Logging from Second.Example.log()');
}
}
}
const first = new First.Example();
// Logging from First.Example.log()
first.log();
const second = new Second.Example();
// Logging from Second.Example.log()
second.log();
Listing 2-1.Namespaces
可以将名称空间组织成层次结构:或者将它们嵌套在一起,或者在提供名称空间标识符时使用点语法。清单 2-2 中显示了这两个备选方案,层次结构中的第一层和第二层是嵌套的,第三层是使用点符号添加的。实际上,所有这些声明都添加到了同一个层次结构中,如调用代码所示。
namespace FirstLevel {
export namespace SecondLevel {
export class Example {
}
}
}
namespace FirstLevel.SecondLevel.ThirdLevel {
export class Example {
}
}
const nested = new FirstLevel.SecondLevel.Example();
const dotted = new FirstLevel.SecondLevel.ThirdLevel.Example();
Listing 2-2.Nested and dotted
hierarchies
如果您正在使用名称空间,混合分层名称空间组织的样式,以及在多个文件中添加名称空间,都会在修复错误或添加新行为时增加程序的心理复杂性。您创建的层次结构的主要驱动因素应该是借助自动完成功能来方便地定位功能。一个好的层次结构将使你的代码更容易被找到,并减少交叉引用文档的需要。
您可能已经注意到清单 2-2 中的嵌套模块有一个 export 关键字。该关键字将命名空间成员标记为 public。如果没有 export 关键字,则只能从命名空间内部访问命名空间成员(包括对同一命名空间的后续添加)。这不同于默认情况下成员是公共的类。清单 2-3 是应用这些不同可见性级别的真实例子,首先出现的是导出的公共成员,接下来是仅在名称空间内使用的变量和类。
namespace Shipping {
// Available as Shipping.Ship
export interface Ship {
name: string;
port: string;
displacement: number;
}
// Available as Shipping.Ferry
export class Ferry implements Ship {
constructor(
public name: string,
public port: string,
public displacement: number) {
}
}
// Only available inside of the Shipping module
const defaultDisplacement = 4000;
class PrivateShip implements Ship {
constructor(
public name: string,
public port: string,
public displacement: number = defaultDisplacement) {
}
}
}
const ferry = new Shipping.Ferry('Assurance', 'London', 3220);
Listing 2-3.Public and private
members
export 关键字的反义词是 import。您可以使用 import 语句为另一个名称空间中的项目起别名,如清单 2-4 所示。进口声明提及装运。Ship 类,并赋予它别名“Ship”这个别名可以在整个Docking
名称空间中作为一个简称使用,所以无论Ship
出现在名称空间中的什么地方,它都指的是Shipping.Ship
。如果你的程序有很长的名字或者很深的嵌套,这是非常有用的,因为它允许你减少注释的长度。这减少了代码中重复命名空间可能导致的噪音。您可以在导入别名中使用任何名称,尽管使用与导入成员相似的名称会使您的代码更容易理解。
namespace Docking {
import Ship = Shipping.Ship;
export class Dock {
private dockedShips: Ship[] = [];
arrival(ship: Ship) {
this.dockedShips.push(ship);
}
}
}
const dock = new Docking.Dock();
Listing 2-4.
Import
alias
导入别名也可以在模块外部使用,以提供短名称。
当从其他文件引用命名空间时,代码编辑器和集成开发环境之间存在一些差异。Visual Studio 会自动将项目中的所有 TypeScript 文件包含在编译中,这意味着在引用其他文件时,不需要特别引用它们。其他代码编辑器需要提示来帮助他们发现您所依赖的代码的源代码。这些提示的格式是引用注释,如清单 2-5 所示。无论您是否需要使用这些提示,您都将负责使代码在运行时可用。
///<reference path="Shipping.ts" />
Listing 2-5.Reference comments
如果使用 TypeScript 编译器将项目编译成单个文件,引用注释还有一个额外的作用,即帮助编译器根据依赖项对输出进行正确排序。因为代码通常需要在被引用之前定义,所以这种排序对于运行时的程序来说是至关重要的。您可以在附录 2 中阅读更多关于使用 TypeScript 编译器生成合并的单个输出文件的信息。
名称空间的最后一个特性是声明合并。简单来说,任何跨越多个块的声明都是声明合并的一种情况,比如两个同名的接口块合并成一个接口。名称空间通过允许与类、函数和枚举合并而超越了这种简单的合并。这些混合体可能看起来很奇怪,但却代表了一种非常常见的 JavaScript 模式。
清单 2-6 演示了名称空间和类之间的声明合并。类和命名空间具有相同的名称,这导致了合并。这允许代码实例化类或命名空间中成员的新实例。
// Class/Namespace Merging
class Car {
}
namespace Car {
export class Engine {
}
export class GloveBox {
}
}
const car = new Car();
const engine = new Car.Engine();
const gloveBox = new Car.GloveBox();
Listing 2-6.
Namespace/class
merging
现在您已经理解了名称空间,暂时不要实现它们,因为有一种更好的方法来组织您的代码。名称空间限制了全局范围内的项数,而模块则更进一步,没有向全局范围添加任何东西。模块还提供了命名冲突的解决方案,因为每个文件都为标识符提供了新的上下文。模块还有一个杀手锏:可以按需异步加载。现在,让我们来看看模块。
模块
模块是组织代码的最佳方式。它们是服务器端 TypeScript 的标准机制,并作为 ECMAScript 规范的一部分在浏览器中使用。在下一节中,我希望说服您选择模块而不是名称空间。模块在各方面都优于名称空间。名称空间减少了添加到全局范围的项目数量,但是模块是完全独立的,不会在全局范围内放置任何东西。命名空间在设计时组织代码,而模块在设计时和运行时组织代码。名称空间将把你的程序扩展到数千行代码,但是模块将带你越过百万行。模块是扩展真正大型程序的关键。尽管您可以组合和缩小所有的 JavaScript 文件来压缩程序的大小,但最终这不会永远扩展下去。
为了进一步组织您的程序,您可以使用文件夹结构来管理您的模块。您只能在一个import
语句中声明这个完整路径,所以长度应该不成问题。引用外部模块的所有其他代码将通过在import
语句中给出的别名来引用它。
./Transport/Maritime/Shipping
./Transport/Maritime/Docking
./Transport/Railways/Ticketing
除了所有这些好处,模块非常容易使用。一旦将导入或导出语句添加到 TypeScript 文件中,它就成为一个模块。清单 2-7 显示了一个样本运输模块。模块内部的所有内容都是模块范围的一部分,在模块外部是不可见的,除非它们被导出。Ship 接口和 Ferry 类使用关键字export
公开提供。导出的成员可以是变量、函数、类、接口或者任何你可以命名的东西。
export interface Ship {
name: string;
port: string;
displacement: number;
}
export class Ferry implements Ship {
constructor(
public name: string,
public port: string,
public displacement: number) {
}
}
const defaultDisplacement = 4000;
class PrivateShip implements Ship {
constructor(
public name: string,
public port: string,
public displacement: number = defaultDisplacement) {
}
}
Listing 2-7.
Modules
要使用模块,您可以使用多种导入样式之一来导入它。清单 2-8 中的 import 语句导入整个模块,并给它分配别名“Shipping”模块成员可以通过Shipping
变量访问。
// Import entire module
import * as Shipping from './Listing-2-007';
export class Dock {
private dockedShips: Shipping.Ship[] = [];
arrival(ship: Shipping.Ship) {
this.dockedShips.push(ship);
}
}
Listing 2-8.
Importing
modules
您还可以有选择地导入模块成员,如清单 2-9 所示。通过用大括号命名成员列表,如果有多个成员,则用逗号分隔,可以使用成员的短名称。这样就不需要每次访问成员时都指定模块别名。
// Import a single export from a module
import { Ship } from './Listing-2-007';
export class Dock {
private dockedShips: Ship[] = [];
arrival(ship: Ship) {
this.dockedShips.push(ship);
}
}
Listing 2-9.Importing named module
members
如果导入单个成员会导致命名冲突,那么可以用关键字as
为成员指定一个别名,如清单 2-10 所示。在 import 语句后的其余代码中,您可以通过别名来引用该成员。
// Import using an alias
import { Ship as Boat } from './Listing-2-007';
export class Dock {
private dockedShips: Boat[] = [];
arrival(ship: Boat) {
this.dockedShips.push(ship);
}
}
Listing 2-10.Imported members with an alias
当您在 TypeScript 中使用模块导入时,您可以使用--module
编译器标志来定位不同的模块加载器。有针对 CommonJS (Node)、AMD (RequireJS)、ESNext (native browser modules)或其他一些模块样式的选项。附录 2 中有关于 TypeScript 编译器的更多细节。
模块重新导出
重新导出允许您重新公开另一个模块或另一个模块的一部分,而无需在本地使用它。清单 2-11 展示了如何导出另一个模块的部分,如何为导出的成员引入别名,以及如何导出整个模块。
// Re-export with an alias
export { Ship as Boat } from './Listing-2-007';
// Re-export an entire module
export * from './Listing-2-008';
Listing 2-11.Re-exporting
您可以使用模块重新导出将几个模块组合成一个包装模块。
默认导出
您可以将每个模块的一个成员标记为默认导出。默认导出可以是任何成员,如类、函数或值。清单 2-12 显示了一个默认的导出;也可以使用export default Yacht
在单独的行上表达。
export default class Yacht {
constructor(
public name: string,
public port: string,
public displacement: number) {
}
}
Listing 2-12.Default export
当您导入一个默认值时,您可以使用清单 2-13 中所示的简写语句。如果模块没有默认导出,编译器会警告您不要使用这种导入方式。您可以在 import 语句中有效地使用任何名称;不一定要和原来的名字一样。
// Import a default export
import Yacht from './Listing-2-012';
// Error: Module has no default export
import Ship from './Listing-2-007';
const yacht = new Yacht('The Sea Princess', 'Tadley', 150);
Listing 2-13.
Importing
a default
如果没有充分的理由使用默认导出,最好避免使用它们。必须决定需要为一个模块编写哪种类型的导入语句是一种不必要的认知上的擦伤。在导入过程中默认的隐式重命名会增加额外的复杂性,尤其是在重命名重构过程中导入的名称没有改变的情况下。
导出对象
一些模块类型支持 exports 对象,该对象包装所有被导出的成员。这种模式是默认导出的前身,在 CommonJS 和 AMD 模块系统中都很常见。您可以使用清单 2-14 中所示的语法和一个export =
语句来使用这种模式。
class Ferry {
constructor(
public name: string,
public port: string,
public displacement: number) {
}
}
export = Ferry;
Listing 2-14.Export object
当使用这种类型的模块时,您应该使用清单 2-15 中所示的导入/要求类型的导入。
import Ferry = require('./Listing-2-014');
const ferry = new Ferry('Dartmouth Ferry', 'Dartmouth', 580)
Listing 2-15.Importing an export object
根据编译时指定的模块种类,TypeScript 将为这些语句生成不同的输出。您可以在附录 2 中阅读更多关于 TypeScript 编译器的内容。
模块加载
虽然有几种模块加载器,但它们都负责获取您所依赖的模块,并在加载后运行您的代码。因为 TypeScript 知道所有主要的模块样式,所以您可以编写标准的导入和导出语句,并让编译器来处理差异。这也意味着您可以针对不同的模块系统编译相同的类型脚本代码。
最受欢迎的模块种类如下所述:
- 本机 ECMAScript 模块。这些格式在语法上与 TypeScript 格式相同,并且在所有主流浏览器的最新版本中都得到了实验性的支持。在等待广泛的工作支持时,RequireJS 或 SystemJS 都可以用于在浏览器中加载模块。
- AMD 模块。除了管理模块加载,异步模块定义风格还允许同时加载多个模块,即使它们相互依赖。RequireJS 是 AMD 最常见的实现。
- CommonJS 模块。这是 NodeJS 流行的模块加载方式,默认情况下受支持。
- UMD 模块。通用模块定义是适用于 AMD 和 CommonJS 模块的标准。这允许 RequireJS 和 NodeJS 使用相同的输出,而无需重新编译。
- 系统模块。这种模块风格可以在浏览器和 NodeJS 上使用,并且对循环依赖有标准化的处理。
如果您是第一次选择模块系统,两个最灵活的选项是 UMD 或系统,因为它们可以在浏览器和服务器上使用。
动态模块加载
在许多情况下,您只希望在某些情况下加载一个模块。使用动态模块加载可以避免不必要的网络调用和文件系统访问。要按需加载模块,您需要编写一个普通的 import 语句,但是如果您的条件得到满足,则添加一个附加的条件语句来实际加载模块。
清单 2-16 有获取模块的导入语句,但是这不会导致任何代码被发出。在 if 语句中调用 require 会导致模块被加载,并且只有在条件为真时才会执行。为了获得所有正常的类型检查和自动完成,一个typeof
注释设置了 ferry 变量的类型。包含类型的是这个附加变量,而不是 import 语句中的 Ferry 别名。
// Declaration for the require function (Node)
declare function require(moduleName: string): any;
// Import - doesn't actually emit code
import { Ferry } from './Listing-2-007';
const condition = true;
if (condition) {
// Only imports if the condition is true
const ferry: typeof Ferry = require("./Listing-2-007");
const myFerry = new ferry('', '', 0);
}
Listing 2-16.Dynamic module
loading
动态模块加载强制您的代码比普通的导入更加具有模块意识,因此执行动态加载的代码会根据您的模块类型而变化。清单 2-18 显示了与清单 2-17 等价的动态加载代码,但是这次是针对系统模块。因为您可以在浏览器和服务器上使用 SystemJS,所以如果您计划跨平台运行代码,这可能是您的最佳选择。
// Declaration for the require function (System JS)
declare const System: { import(module: string): Promise<any>; };
// Import - doesn't actually emit code
import { Ferry } from './Listing-2-007';
const condition = true;
if (condition) {
// Only imports if the condition is true
System.import('./Listing-2-007').then((ferry: typeof Ferry) => {
const myFerry = new ferry('', '', 0);
});
}
Listing 2-17.Dynamic module loading
System modules
在第六章中有关于使用 AMD 在网络浏览器中加载模块的详细信息,在第七章中有关于使用 CommonJS 在服务器上加载模块的详细信息。
混合命名空间和模块
由于在过去的五年中,我收到了许多关于这个主题的问题,所以我把这一部分包括在内。因为这么多程序员都有 C#或 Java 背景,这个问题完全可以理解。在这样的语言中,您在文件中物理地组织代码,但是通过名称空间在逻辑上导航它;文件结构除了作为帮助程序员在项目中查找代码的工具之外,没有什么特别的意义。一些语言中有关于保持文件结构和名称空间相似的指导原则,但是文件层次结构和名称空间层次结构并不构成单一的组织元素。
如果您使用的是 TypeScript 模块,那么文件系统将成为名称空间,您可以使用针对文件系统的自动完成功能来导航名称空间,如图 2-1 所示。大多数支持 TypeScript 的开发工具会在每个导航级别提供提示来帮助您定位所需的模块,尽管这些提示的有效性与您组织和命名文件层次结构的能力直接相关。
图 2-1。
TypeScript Module Navigation
将名称空间与模块一起使用绝对没有任何好处。当您使用模块时,您从名称空间获得的所有好处都被超越了。名称空间的主要目的是提供范围、命名上下文和可发现性,但这已经由模块来处理了:
- 范围。模块不会给全局范围增加任何东西,所以增加名称空间不会改善范围管理。
- 命名冲突。模块已经为每个模块的名称提供了上下文,所以名称空间也没有改善这一点。
- 可发现性。模块已经提供了可发现性,向其中添加命名空间会削弱成员的可发现性。
虽然将一种在另一种语言中运行良好的实践移植到另一种语言中很有诱惑力,但是不应该盲目地去做。TypeScript 的早期目标之一是让有基于类的语言经验的程序员更容易使用 JavaScript 进行面向对象的编程;但是 TypeScript 现在是一种成熟的语言,有自己的习惯用法,所以当你考虑从另一种语言移植思想时,权衡一下好处是值得的。将名称空间与模块混合没有好处,而且有几个缺点,所以要避免这样做。
包装
无论您是划分自己的私有代码库以供重用,还是让他人使用,打包代码现在都是一项基本的类型脚本技能。在这一节中,我将向您展示创建包含代码的 NPM 包所需的所有元素。NPM,或节点包管理器,是世界上最大的软件注册中心,是 JavaScript 和 TypeScript 事实上的包管理风格。这些例子来自真实的 TypeSpec 项目,这是一个行为驱动的 TypeScript 开发工具,它解析纯文本业务语言(使用 Gherkin 语法)并执行测试步骤来验证程序。
完整的项目可以在 GitHub 上找到: https://github.com/Steve-Fenton/TypeSpec
Note
包管理取代了在线搜索库、下载源代码、解压缩内容以及手动将源文件添加到您自己的项目中的传统工作流程。不仅可以在一个步骤中获得包并将其添加到您的项目中,您还可以明确您的依赖项,以便其他项目可以使用您的代码,而不必首先获得您的代码工作所需的许多库。您还可以管理您所依赖的版本,例如,通过将依赖项升级到最新的稳定版本。
要遵循本节中的所有步骤,您需要安装 NodeJS,它包括 NPM。NodeJS 是一个服务器端 JavaScript 平台,但是您可以在本地使用它来执行许多任务,从而提高您的工作效率。你可以从 https://nodejs.org/
下载 NodeJS
要创建一个 NPM 包,你需要三样东西:
- readme . MD–描述您的项目的文件,以 markdown 格式编写。
- package . json–使用 JSON 结构描述包的文件。
- 源代码–需要包含在包中以便在其他程序中使用的文件。
当您编写包含 TypeScript 代码的包时,最好不要包含 TypeScript 源文件。相反,您应该打包编译后的 JavaScript 代码,以及自动生成的类型定义。这允许 JavaScript 程序使用您的包,也意味着您的 TypeScript 源代码不需要被任何用户重新编译。
生成包的最简单方法是将编译后的输出复制到一个单独的目录结构中,这样就可以很容易地打包,而不会出现不必要的文件(另一种方法是花更多的时间来指定应该包含和不应该包含哪些文件)。典型的目录结构如图 2-2 所示,在 dist 文件夹中有 README.md 和 package.json 文件,而。js 和. d.ts 文件被复制到 src 文件夹中。
图 2-2。
Directory structure for packaging
清单 2-18 显示了一个 README.md 文件的简短版本,带有简单的标题和描述,以及如何安装包的说明。通常,该文件应该包含一些基本的使用说明和指向更详细文档的链接。这个文件的目标应该是通过回答第一次使用你的包的人可能会有的问题来减少摩擦。
# TypeSpec
A TypeScript BDD framework.
npm install typespec-bdd
The aim is to properly separate the business specifications from the code,
but rather than code-generate (like Java or C# BDD tools), the tests will be
loaded and executed on the fly without converting the text into an
intermediate language or framework. This should allow tests to be written using any
unit testing framework - or even without one.
Listing 2-18.README.md
package.json 文件更加结构化,许多项目是必需的。清单 2-19 显示了一个完整的包描述,其中包括关于包、作者以及文档和问题日志的位置的信息。许可证应以软件包数据交换(SPDX)格式指定。
{
"author": "Steve Fenton",
"name": "typespec-bdd",
"description": "BDD framework for TypeScript.",
"keywords": [
"typespec",
"typescript",
"bdd",
"behaviour",
"driven"
],
"version": "0.0.1",
"homepage": "https://github.com/Steve-Fenton/TypeSpec",
"bugs": "https://github.com/Steve-Fenton/TypeSpec/issues",
"license": "(Apache-2.0)",
"files": [
"src/"
],
"repository": {
"url": "https://github.com/Steve-Fenton/TypeSpec"
},
"main": "src/TypeSpec.js",
"types": "src/TypeSpec.d.ts",
"dependencies": { },
"devDependencies": { },
"optionalDependencies": { },
"engines": {
"node": "*"
}
}
Listing 2-19.package.json
包内容在“files”元素中描述,它可以包含文件夹和单个文件,并在“main”元素中描述应用的入口点。使用 TypeScript 时,可以使用“types”元素为入口点提供类型定义。
要准备打包文件,只需将它们复制到分发文件夹中。这可以使用 Visual Studio 中的后期构建事件来完成,如清单 2-20 所示,或者使用任务运行器(如 Gulp)执行的任务来完成。
XCOPY $(ProjectDir)Scripts\TypeSpec\*.d.ts $(ProjectDir)dist\src\ /y
XCOPY $(ProjectDir)Scripts\TypeSpec\*.js $(ProjectDir)dist\src\ /y
XCOPY $(SolutionDir)README.md $(ProjectDir)dist\ /y
XCOPY $(ProjectDir)package.json $(ProjectDir)dist\ /y
Listing 2-20.Copy package contents
一旦文件准备好打包,您只需运行清单 2-21 中的 package 命令。这将生成一个包含您的包的归档文件。您可以使用归档阅读器来检查文件的内容,并确保所有内容都如预期的那样存在。
npm package
Listing 2-21.
Packaging
command
您可以私下或公开使用该软件包。这意味着您可以使用相同的机制来打包您的开源项目供全世界使用,或者将包添加到您的私有存储库中。发布 NPM 包的命令如清单 2-22 所示。首次运行此命令时,将提示您通过命令行添加凭据,因为您只能发布您拥有适当权限的包。
npm publish
Listing 2-22.
Publishing
command
虽然打包代码似乎有几个步骤,但是一旦设置好了,发布新版本就像更新版本号并重新运行 publish 命令一样简单。
装修工
当谈到组织代码时,大多数组织技术都是水平或垂直对齐的。一种常见的水平组织技术是 n 层体系结构,其中程序被分成处理用户界面、业务逻辑和数据访问的层。一种快速发展的垂直组织技术是微服务,其中每个垂直切片代表一个有界的上下文,例如“支付”、“客户”或“用户”。
装饰者与水平和垂直架构都相关,但是在垂直架构的上下文中尤其有价值。Decorators 可以用来处理横切关注点,比如日志、授权或验证。当正确使用时,这种面向方面的编程风格可以最小化满足这些共享职责所需的代码。
TypeScript decorators 可以用于面向方面编程(AOP)和元编程,但是我们将从 AOP 开始,因为它提供了 decorator 在现实世界中使用的一个可靠示例。
Note
TypeScript decorators 仍然是试验性的,在成为一个稳定的特性之前可能会发生变化。根据您的 TypeScript 版本,您可能需要传递 experimentalDecorators 编译器标志来允许此功能。
decorators 的语法很简单。清单 2-23 展示了一个装饰器函数,以及装饰器对square
方法的使用,装饰器是使用@符号应用的。
// Decorator Function
function log(target: any, key: string, descriptor: any) {
// square
console.log(key);
}
class Calculator {
// Using the decorator
@log
square(n: number) {
return n * n;
}
}
Listing 2-23.Decorators
装饰器可以应用于以下任何一种情况:
- 班级
- 附件
- 性能
- 方法
- 因素
每种装饰器都需要不同的函数签名,因为根据装饰器的用途,为装饰器提供了不同的参数。这一节将提供几个实际的例子,可以作为你自己的装饰者的起点。
清单 2-24 显示了一个更完整的属性装饰器示例。除了在key
参数中传递方法名之外,属性描述符也在descriptor
参数中传递。属性描述符是一个包含原始方法和一些元数据的对象。方法本身可以在描述符的value
属性中找到。当方法装饰器返回值时,该值将被用作描述符。这意味着你可以选择观察、修改或替换原来的方法。
在日志方法装饰器的情况下,描述符中的原始方法用日志函数包装,该函数记录方法被调用的事实以及传递的参数和返回值。每次调用方法时,都会记录信息。
function log(target: any, key: string, descriptor: any) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
// Call the original method
const result = original.apply(this, args);
// Log the call, and the result
console.log(`${key} with args ${JSON.stringify(args)} returned ${JSON.stringify(result)}`);
// Return the result
return result;
}
return descriptor;
}
class Calculator {
// Using the decorator
@log
square(num: number) {
return num * num;
}
}
const calculator = new Calculator();
// square with args [2] returned 4
calculator.square(2);
// square with args [3] returned 9
calculator.square(3);
Listing 2-24.Logging Method Decorator
这个基本的日志记录示例意味着 calculator 类中的代码不需要知道程序中的日志记录。日志逻辑可以与程序中的其他代码完全分离,日志代码将单独负责记录信息的时间和位置。
可配置装饰者
通过将装饰器函数转换成装饰器工厂,可以使装饰器可配置。装饰工厂是一个返回装饰函数的函数。工厂可以有任意数量的参数,这些参数可以在装饰器的创建中使用。
在清单 2-25 中,日志装饰器已经被转换为装饰器工厂。工厂接受添加到日志消息前面的标题。
function log(title: string) {
return (target: any, key: string, descriptor: any) => {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
// Call the original method
const result = original.apply(this, args);
// Log the call, and the result
console.log(`${title}.${key}
with args ${JSON.stringify(args)}
returned ${JSON.stringify(result)}`);
// Return the result
return result;
}
return descriptor;
};
}
class Calculator {
// Using the configurable decorator
@log('Calculator')
square(num: number) {
return num * num;
}
}
const calculator = new Calculator();
// Calculator.square with args [2] returned 4
calculator.square(2);
// Calculator.square with args [3] returned 9
calculator.square(3);
Listing 2-25.Configurable decorators
当使用可配置装饰器时,参数像函数调用一样传递。
班级装饰者
为了使日志装饰器适用于一个类,构造函数必须用日志构造函数包装。这比方法修饰器稍微复杂一点,但是清单 2-26 中的例子可以很快地从日志修改到其他目的。
类装饰器只被传递了一个参数,代表被装饰类的构造函数。
function log(target: any) {
const original = target;
// Wrap the constructor with a logging constructor
const constr: any = (...args) => {
console.log(`Creating new ${original.name}`);
const c: any = () => {
return original.apply(null, args);
}
c.prototype = original.prototype;
return new c();
}
constr.prototype = original.prototype;
return constr;
}
@log
class Calculator {
square(n: number) {
return n * n;
}
}
// Creating new Calculator
var calc1 = new Calculator();
// Creating new Calculator
var calc2 = new Calculator();
Listing 2-26.Class decorators
与方法装饰器一样,您可以选择修改、包装或替换类装饰器中传递的构造函数。替换构造函数时,必须保持原始原型,因为这不会自动完成。
财产装饰者
属性装饰器可以分成几个部分。在清单 2-27 中,getter 和 setter 都被日志实现所取代。为此,在使用原始名称添加替换属性之前,会删除原始属性。
function log(target: any, key: string) {
let value = target[key];
// Replacement getter
const getter = function () {
console.log(`Getter for ${key} returned ${value}`);
return value;
};
// Replacement setter
const setter = function (newVal) {
console.log(`Set ${key} to ${newVal}`);
value = newVal;
};
// Replace the property
if (delete this[key]) {
Object.defineProperty(target, key, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
}
}
class Calculator {
@log
public num: number;
square() {
return this.num * this.num;
}
}
const calc = new Calculator();
// Set num to 4
calc.num = 4;
// Getter for num returned 4
// Getter for num returned 4
calc.square();
Listing 2-27.Property decorators
每次调用属性的 getter 或 setter 时,都会记录访问。如果您只对修饰 getter 或 setter 感兴趣,您可以将访问器修饰器单独应用于 getter 或 setter。
摘要
如果您在使用 TypeScript 时对选项的数量感到困惑,您可以通过坚持以下推荐的标准设置来减少过载。除非你有很好的理由不这样做,否则使用模块而不是名称空间;千万不要把两者混为一谈。使用一致的导入风格来简化依赖关系管理,并避免默认导出。
通过使用文件系统作为命名空间的一种形式,你的模块将更容易被找到和使用;所以要注意文件和文件夹的命名。
在打包应用时,自动生成分发文件夹,以包含程序的已编译 JavaScript 和 TypeScript 类型定义。这使您可以轻松地从分发文件夹发布,而不必过滤包内容。
Decorators 允许您实现面向方面的编程,并为元编程提供了一种机制。
要点
- 内部模块现在被称为“名称空间”
- 外部模块现在简称为“模块”
- 不要混合模块和名称空间。
- 有几个模块加载器,它们都有稍微不同的语法;如果你还没有选择,可以考虑使用 SystemJS,因为它在任何地方都适用。UMD 是一个很好的选择,因为它与 AMD 和 CommonJS 模块系统。
- 您可以将代码打包,以便在其他程序中重用。
三、类型系统
类型理论解决的基本问题是确保程序有意义。由类型理论引起的根本问题是有意义的程序可能没有赋予它们的意义。对更丰富类型系统的追求源于这种紧张。—马克·马纳塞
在本章中,您将了解 TypeScript 类型系统,包括它与您以前可能遇到过的其他类型系统的一些重要区别。由于 TypeScript 从一系列语言中汲取了灵感,所以理解这些微妙的细节是值得的,因为依赖于您对其他类型系统的现有知识可能会导致一些令人讨厌的意外。通过比较结构类型系统和名义类型系统,并通过查看可选静态类型、类型擦除和 TypeScript 语言服务提供的强大类型推理的详细信息,来探究这些详细信息。
虽然许多 TypeScript 功能与 ECMAScript 规范的功能和建议的功能一致,但类型系统是 TypeScript 独有的。目前还没有在 ECMAScript 标准中添加类型注释或任何复杂的类型相关特性的计划。
在这一章的最后是关于环境声明的一节,它可以用来填充没有用 TypeScript 编写的代码的类型信息。这允许您使用带有类型检查和自动完成功能的外部代码,无论它是您已经拥有的旧 JavaScript 代码、运行时平台的补充,还是您在程序中使用的第三方库和框架。
类型系统
类型系统起源于类型理论,这归功于伯特兰·罗素,他在 20 世纪早期发展了该理论,并将其纳入他的三卷本《数学原理》(怀特黑德和罗素,剑桥大学出版社,1910 年)。类型理论是一个系统,其中每个术语都被赋予一个类型,并且基于类型来限制操作。TypeScript 的注释和类型理论积木的风格惊人的相似,如图 3-1 所示。
在类型理论中,符号用类型进行注释,就像用 TypeScript 类型注释一样。这方面唯一的区别是 type theory 省略了const
关键字,使用了nat
类型(自然数)而不是 TypeScript 中的 number 类型。函数注释也是可识别的,类型理论省略了括号,这可以提高 TypeScript 示例的可读性。
图 3-1。
Type theory and TypeScript similarities
通常,类型系统为系统中的每个变量、表达式、对象、函数、类或模块分配一个类型。这些类型与一组旨在暴露程序中错误的规则一起使用。这些检查可以在编译时(静态检查)或运行时(动态检查)执行。典型的规则包括确保赋值中使用的值与被赋值的变量类型相同,或者确保函数调用根据函数签名提供正确类型的参数。
在一个类型系统中使用的所有类型都作为契约,声明系统中所有不同组件之间可接受的交互。基于这些类型检测到的错误种类取决于类型系统中的规则和检查的复杂程度。
可选静态类型
JavaScript 是动态类型的;变量没有固定的类型,因此没有类型限制可以应用于操作。您可以将一种类型的值赋给一个变量,然后将完全不同类型的值赋给同一个变量。您可以使用两个不兼容的值执行运算,并获得不可预知的结果。如果你调用一个函数,没有必要强制你传递正确类型的参数,你甚至可以提供太多或太少的参数。这些在清单 3-1 中进行了演示。
因此,JavaScript 类型系统非常灵活,但有时这种灵活性会带来问题。
// Assignment of different types
let dynamic = 'A string';
dynamic = 52;
// Operations with different types
const days = '7';
const hours = 24;
// 168 (luckily, the hours string is coerced)
const week = days * hours;
// 77 (concatenate 7 and 7)
const fortnight = days + days;
// Calling functions
function getVolume(width, height, depth) {
return width * height * depth;
}
// NaN (10 * undefined * undefined)
const volumeA = getVolume(10);
// 32 (the 8 is ignored)
const volumeB = getVolume(2, 4, 4, 8);
Listing 3-1.JavaScript dynamic types
TypeScript 提供了一个推断和指定类型的系统,但允许类型是可选的。可选性很重要,因为这意味着您可以选择何时强制类型以及何时允许动态类型。除非您选择退出类型检查,否则使用any
类型,编译器将尝试确定您的程序中的类型,并将检查推断类型以及您使用类型注释指定的显式类型。类型注释在第一章中描述。
所有的检查都在编译时执行,这使得 TypeScript 成为静态类型。编译器负责构建所有类型的调度,根据这些类型检查表达式,并在将代码转换为有效的 JavaScript 时删除所有类型信息。
如果您将清单 3-1 中的 Ja vaScript 代码粘贴到一个 TypeScript 文件中,您将会收到示例中所有类型错误的错误。你可以在下面的图 3-2 中的类型脚本列表中看到被标记的错误。
图 3-2。
TypeScript compiler errors Note
TypeScript 的类型系统中的一个要点是类型的可选性。这实际上意味着您不局限于静态类型,并且可以在任何需要的时候选择使用动态行为。
结构分型
TypeScript 具有结构化类型系统;这与大多数类 C 语言形成对比,后者通常是主格的。命名类型系统依赖于明确命名的注释来确定类型。在一个名义上的系统中,一个类只有用接口的名字来修饰时,才会被认为是实现了一个接口(也就是说,它必须显式地声明它实现了这个接口)。在结构类型系统中,不需要显式修饰,只要其结构与所需类型的规范相匹配,值就是可接受的。
名义类型系统旨在防止意外的类型等价——仅仅因为某些东西具有相同的属性并不意味着它是有效的——但是由于 TypeScript 在结构上是类型化的,所以意外的类型等价是可能的,并且是可取的。
在一个名义类型系统中,你可以使用命名类型来确保正确的参数被传递,例如,你可以创建一个CustomerId
类型来包装标识符的值,并使用它来防止普通的number
、ProductId
、CustomerTypeId
或任何其他类型的赋值。不接受具有相同属性但不同名称的类型。在一个结构类型系统中,如果CustomerId
包装了一个名为value
的包含 ID 号的公共属性,那么任何其他类型,只要有一个具有等价类型的value
属性,都是可以接受的。
如果您希望在 TypeScript 中使用自定义类型来实现这种类型安全,则必须通过使这些类型在结构上唯一来确保它们不会意外地等效。在一个类上使用私有成员来使它在结构上不匹配是可能的,但是尽管关于对名义类型的一些支持的讨论还在继续,创建名义类型的最可读的技术是清单 3-2 中所示的技术。
在清单 3-2 中的示例类中,第一个方法避免了意外等价,并且将只接受一个CustomerId
,但是第二个方法通过接受任何数字在某种程度上允许它。要调用第二个方法,必须传递标识符的value
属性。
类型可以用来包装当前形式的任何数字标识。使用一个类型和一个工厂方法创建新的实例来创建CustomerId
和ProductId
。试图将productId
实例传递给接受CustomerId
的方法会导致错误。
// DomainId type definition
type DomainId<T extends string> = {
type: T,
value: number,
}
// CustomerId
type CustomerId = DomainId<'CustomerId'>;
const createCustomerId = (value: number): CustomerId => ({ type: 'CustomerId', value });
// Product Id
type ProductId = DomainId<'ProductId'>;
const createProductId = (value: number): ProductId => ({ type: 'ProductId', value });
// Example class
class Example {
static avoidAccidentalEquivalence(id: CustomerId) {
// Implementation
}
static useEquivalence(id: number) {
// Implementation
}
}
var customerId = createCustomerId(1);
var productId = createProductId(5);
// Allowed
Example.avoidAccidentalEquivalence(customerId);
// Errors 'Supplied parameters do not match signature of call target'
Example.avoidAccidentalEquivalence(productId);
// Allowed
Example.useEquivalence(customerId.value);
// Allowed
Example.useEquivalence(productId.value);
Listing 3-2.Using and avoiding equivalence
虽然结构类型在有限的特殊情况下可能会造成困难,但它有很多优点。例如,引入兼容类型而不必更改现有代码要容易得多,并且可以创建无需从外部类继承就可以传递给外部代码的类型。在引入新的超类型而不改变新降级的子类型或与它们交互的代码的能力方面,它也优于名义类型。
结构类型化最重要的好处之一是它节省了无数显式的类型名修饰。无需添加特定的类型注释就可以实现接口,并且无需添加类型注释就可以创建匿名对象来匹配接口和类。如果属性和方法与所需类型属于同一类型或兼容类型,则可以使用这些对象。兼容类型可以是子类型、更窄的类型或结构相似的类型。
在结构化类型语言(如 TypeScript)中要避免的一件事是空结构。空接口或空类本质上是程序中几乎所有东西的有效超类型,这意味着任何对象都可以在编译时替换空结构,因为在类型检查期间没有契约要满足。
结构类型补充了 TypeScript 中的类型推理。有了这些特性,您可以将大部分工作留给编译器和语言服务,而不必在整个程序中显式地添加类型信息和类继承。
Note
在 TypeScript 程序中,不能依赖命名类型来创建限制,只能依赖唯一的结构。对于接口,这意味着使用唯一命名的属性或方法来创建唯一性。对于类,任何私有成员都会使结构唯一。
类型擦除
当你把你的 TypeScript 程序编译成普通的 JavaScript 时,生成的代码在两个方面是不同的:代码转换和类型擦除。代码转换将目标 JavaScript 版本中不可用的语言特性转换为有效的表示。例如,如果您的目标是 ECMAScript 5,其中没有可用的类,那么您的所有类都将被转换为立即调用的函数表达式,这些表达式使用 ECMAScript 5 中可用的原型继承创建适当的表示。类型删除是从代码中删除所有类型注释的过程,因为 JavaScript 不理解它们。
类型擦除移除类型批注、自定义类型和接口。这些仅在设计时和编译时需要,以便进行静态检查。运行时不检查类型,因此不需要类型信息。您在运行时应该不会遇到问题,因为已经检查了类型的逻辑使用,除非您通过使用any
类型选择退出。
class OrderedArray<T> {
private items: T[] = [];
constructor(private comparer?: (a: T, b: T) => number) {
}
add(item: T): void {
this.items.push(item);
this.items.sort(this.comparer);
}
getItem(index: number): T {
if (this.items.length > index) {
return this.items[index];
}
return null;
}
}
var orderedArray: OrderedArray<number> = new OrderedArray<number>();
orderedArray.add(5);
orderedArray.add(1);
orderedArray.add(3);
var firstItem: number = orderedArray.getItem(0);
alert(firstItem); // 1
Listing 3-3.TypeScript ordered array class
清单 3-3 显示了一个OrderedArray
类的示例脚本清单。该类是泛型的,因此可以替换数组中元素的类型。对于复杂类型,可以提供一个可选的自定义比较器来计算数组中的项,以便进行排序,但是对于简单类型,可以省略该比较器。下面是该类的一个简单演示。这段代码被编译成清单 3-4 中所示的 JavaScript。在编译后的输出中,所有的类型信息都消失了,该类被转换成一种常见的 JavaScript 模式,称为自执行匿名函数。
var OrderedArray = (function () {
function OrderedArray(comparer) {
this.comparer = comparer;
this.items = [];
}
OrderedArray.prototype.add = function (item) {
this.items.push(item);
this.items.sort(this.comparer);
};
OrderedArray.prototype.getItem = function (index) {
if (this.items.length > index) {
return this.items[index];
}
return null;
};
return OrderedArray;
}());
var orderedArray = new OrderedArray();
orderedArray.add(5);
orderedArray.add(1);
orderedArray.add(3);
var firstItem = orderedArray.getItem(0);
alert(firstItem); // 1
Listing 3-4.Compiled JavaScript code
尽管在编译期间执行了类型擦除和转换,JavaScript 输出与原始的 TypeScript 程序非常相似。几乎所有从 TypeScript 到 JavaScript 的转换都同样考虑到了原始代码。根据您所针对的 ECMAScript 版本,可能会有更多或更少的转换,例如,如果您在编译期间针对ESNext
,则 TypeScript 向下编译到 ECMAScript 3 和 5 的最新特性不需要转换。
类型推理
类型推断与类型删除截然相反。类型推断是在没有显式类型批注的情况下,在编译时确定类型的过程。
大多数类型推断的基本示例,包括本书中的早期示例,展示了一个简单的赋值,并解释了如何将赋值左边的变量类型自动设置为右边的文字值类型。对于 TypeScript 来说,这种类型推断实际上是“第一级”,它能够更复杂地确定所使用的类型。
TypeScript 执行深度检查以在程序中创建类型计划,并使用该类型计划比较赋值、表达式和操作。在这个过程中,当直接类型不可用时,会使用一些巧妙的技巧,允许间接找到该类型。其中一个技巧是上下文类型化,TypeScript 使用表达式的上下文来确定类型。
清单 3-5 展示了如何以更加间接的方式推断类型。add
函数的返回值是通过从 return 语句向后工作来确定的。return 语句的类型是通过评估表达式a + b
的类型找到的,而这又是通过检查单个参数的类型来完成的。
在清单 3-5 的最后一个表达式中,匿名函数中的result
参数类型可以使用声明该函数的上下文来推断。因为声明是被callsFunction
的执行使用,所以编译器可以看到它是要作为string
传递的;因此,结果参数将总是一个string
类型。第三个例子来自CallsFunction
的宣言;因为变量已经使用CallsFunction
接口进行了类型化,所以编译器根据接口推断出cb
参数的类型。
function add(a: number, b: number) {
/* The return value is used to determine
the return type of the function */
return a + b;
}
interface CallsFunction {
(cb: (result: string) => any): void;
}
// The cb parameter is inferred to be a function accepting a string
var callsFunction: CallsFunction = function (cb) {
cb('Done');
// Error: Argument of type '1' is not assignable to parameter of type 'string'
cb(1);
};
// The result parameter is inferred to be a string
callsFunction(function (result) {
return result;
});
Listing 3-5.Bottom-up and top-down inference
最佳常见类型
当推断类型信息时,在有限的几种情况下,必须确定最佳的通用类型。清单 3-6 展示了如何考虑数组中的值,以便在所有数组值之间生成最佳公共类型。
// number[]
let x = [0, 1, null];
// (string | number)[]
let y = [0, 1, null, 'a'];
Listing 3-6.Best common types
确定最佳公共类型的过程不仅仅用于数组文字表达式;它们还用于确定多个值具有不同类型的任何情况,例如包含多个返回语句的函数或方法的返回类型。
上下文类型
上下文类型是高级类型推理的一个很好的例子。当编译器将其类型基于表达式的位置时,就会发生上下文类型化。在清单 3-7 中,事件参数的类型由window.onclick
定义的已知签名决定。推论不仅仅局限于参数;由于对window.onclick
签名的现有了解,可以推断出包括返回值在内的整个签名。
window.onclick = function(event) {
var button = event.button;
};
Listing 3-7.Contextual types
如果检查清单 3-7 的类型信息,会发现event
参数是一个MouseEvent
,this
的范围是Window
,返回类型是a
ny
。
加宽类型
术语“加宽类型”指的是 TypeScript 中函数调用、表达式或赋值的类型为null
或undefined
的情况。在这些情况下,编译器推断出的类型将是加宽的any
类型。在清单 3-8 中,widened
变量的类型为any
。
function example() {
return null;
}
var widened = example
();
Listing 3-8.Widened types
何时添加注释
因为类型推断从第一天起就是 TypeScript 的关键特性,所以关于何时用类型注释显式化类型的讨论可以毫无争议地进行。对于后来决定添加某种类型推断支持的静态类型语言来说,这是一个棘手的话题。
关于添加到程序中的类型注释级别的最终决定应该由所有团队成员共同做出,但是您可能希望使用以下建议作为讨论的起点。
- 从不添加类型注释开始(完全推理。)
- 在推断类型为
any.
的地方添加类型注释 - 为公共方法返回类型添加类型批注。
- 为公共方法参数添加类型批注。
与类型保持健康关系的关键是使用尽可能少的类型注释。如果类型可以被推断出来,就允许它被推断出来。尽可能信任编译器,你很快就会发现你可以相信它会做得很好。您可以让编译器使用一个特殊的标志(--noImplicitAny
)来警告您无法找到类型的情况,该标志可以防止推断出any
类型。您可以在附录 2 中阅读更多关于代码质量标志的内容。
关于类型注释的最后一点是要求尽可能少。如果你只需要一个对象有一个name
成员,不要用更严格的类型来注释它,比如Customer
。具体说明返回类型,但尽可能接受最通用的参数。
重复标识符
总的来说,你应该尽力避免程序中的名字冲突。TypeScript 提供了一些工具,允许您将程序移出全局范围并移入模块,从而使名称冲突变得不必要。但是,TypeScript 中的标识符有一些有趣的特性,包括许多允许在相同范围内使用相同名称的情况。
在大多数情况下,在同一范围内使用现有的类或变量名将导致“重复标识符”错误。没有特定的结构得到优惠待遇;两个标识符中的后一个将是错误的来源。如果您在程序中多次重用一个名称空间,这不会导致重复标识符错误,因为所有单独的块在逻辑上都合并到一个名称空间中。
重复标识符的另一个有效用途是用于接口。再一次,编译器知道在运行时不会有重复的标识符,因为接口在编译期间被删除;它的标识符永远不会出现在 JavaScript 输出中。使用接口和变量的重复标识符是 TypeScript 库中的一种常见模式,其中标准类型是使用接口定义的,然后通过类型批注分配给变量声明。清单 3-9 显示了DeviceMotionEvent
的类型脚本库定义。DeviceMotionEvent
的接口后面紧跟着一个带有相同D
eviceMotionEvent
标识符的变量声明。
interface DeviceMotionEvent extends Event {
readonly acceleration: DeviceAcceleration | null;
readonly accelerationIncludingGravity: DeviceAcceleration | null;
readonly interval: number | null;
readonly rotationRate: DeviceRotationRate | null;
initDeviceMotionEvent(type: string, bubbles: boolean, cancelable: boolean, acceleration: DeviceAccelerationDict | null, accelerationIncludingGravity: DeviceAccelerationDict | null, rotationRate: DeviceRotationRateDict | null, interval: number | null): void;
}
declare var DeviceMotionEvent: {
prototype: DeviceMotionEvent;
new(typeArg: string, eventInitDict?: DeviceMotionEventInit): DeviceMotionEvent;
};
Listing 3-9.TypeScript DeviceMotionEvent
环境声明在本章后面会有更详细的解释,但是这种技术在变量声明前没有关键字declare
也一样有效。在标准库中使用接口是经过深思熟虑的选择。接口是开放的,因此可以在附加的接口块中扩展定义。如果发布了一个新的 web 标准,向DeviceMotionEvent
对象添加了一个motionDescription
属性,您就不必等待它被添加到 TypeScript 标准库中;你可以简单地将清单 3-10 中的代码添加到你的程序中来扩展接口定义。
来自同一个公共根的所有接口定义块被组合成一个单一类型,因此DeviceMotionEvent
仍然具有来自标准库的所有原始属性,并且还具有来自附加接口块的motionDescription
属性。
interface DeviceMotionEvent {
motionDescription: string;
}
// The existing DeviceMotionEvent has all of its existing properties
// plus our additional motionDescription property
function handleMotionEvent(e: DeviceMotionEvent) {
var acceleration = e.acceleration;
var description = e.motionDescription;
}
Listing 3-10.Extending the DeviceMotionEvent
类型检查
一旦从程序中收集了类型的计划,TypeScript 编译器就能够使用该计划来执行类型检查。最简单的方法是,编译器检查当调用一个接受类型为number
的参数的函数时;所有调用代码都传递一个与number
类型兼容的参数。
清单 3-11 显示了一系列对带有名为input
的参数的函数的有效调用,类型为number
。接受类型为number
、enum
、null
、undefined
或any
的参数。请记住,any
类型允许 TypeScript 中的动态行为,因此它代表了您对编译器的承诺,即这些值在运行时是可接受的。
function acceptNumber(input: number) {
return input;
}
// number
acceptNumber(1);
// enum
acceptNumber(Size.XL);
// null
acceptNumber(null);
Listing 3-11.Checking a parameter
随着类型变得越来越复杂,类型检查需要对对象进行更深入的检查。检查对象时,会测试对象的每个成员。公共属性必须具有相同的名称和类型;公共方法必须有相同的签名。检查对象的成员时,如果属性引用嵌套对象,检查将继续深入到该对象以检查兼容性。
清单 3-12 显示了三个不同命名的类和一个文字对象,显示了编译器所关心的所有兼容。
class C1 {
name: string;
show(hint?: string) {
return 1;
}
}
class C2 {
constructor(public name: string) {
}
show(hint: string = 'default') {
return Math.floor(Math.random() * 10);
}
}
class C3 {
name: string;
show() {
return <any> 'Dynamic';
}
}
var T4 = {
name: '',
show() {
return 1;
}
};
var c1 = new C1();
var c2 = new C2('A name');
var c3 = new C3();
// c1, c2, c3 and T4 are equivalent
var arr: C1[] = [c1, c2, c3, T4];
for (var i = 0; i < arr.length; i++) {
arr[i].show();
}
Listing 3-12.Compatible types
这个例子值得注意的部分包括name
属性和show
方法。对象上必须存在name
属性,它必须是公共的,并且必须是字符串类型。属性是否是构造函数属性并不重要。show
方法必须返回与number
兼容的类型。参数也必须兼容——在这种情况下,可选的hint
参数可以使用默认参数或完全省略该参数来匹配。如果一个类有一个强制的hint
参数,它将与清单 3-12 中的类型不兼容。如第四种类型所示,就编译器而言,文字对象可以与类兼容,只要它们通过了类型比较。
类型检查不仅限于正匹配,在正匹配中,提供的类型必须具有所需类型的结构。越来越多的否定检查被添加到编译器中,使其能够检测不同类别的错误。例如,过量属性警告将突出显示对象上的非预期属性,这是捕捉错误键入属性名称的情况的好方法。当您升级项目中使用的 TypeScript 版本时,这种检查可能会引入额外的错误,但是如果您坚持不懈地检查编译器发送给您的消息,您最终会发现编译器已经设法为您捕捉到的细微错误。
环境声明
环境声明可用于向现有的 JavaScript 代码添加类型信息。通常,这用于为您自己的现有代码添加类型信息,或者为您希望在 TypeScript 程序中使用的第三方库添加类型信息。
环境声明可以通过从一个简单的不精确的声明开始,并随着时间的推移逐渐增加细节来逐步构建。清单 3-13 展示了一个你可以为 jQuery 框架编写的最不精确的环境声明的例子。该声明只是通知编译器一个外部变量将在运行时存在,而没有提供外部变量结构的进一步细节。这将抑制$
变量的错误,但不会提供深度类型检查或有用的自动完成。
declare var $: any;
$('#id').html('Hello World');
Listing 3-13.Imprecise ambient declaration
所有环境声明都以关键字declare
开始。这告诉编译器下面的代码块只包含类型信息,不包含实现。使用declare
关键字创建的代码块将在编译期间被删除,并且不会产生 JavaScript 输出。在运行时,您负责确保代码存在,并且它与您的声明相匹配。
为了获得编译时检查的全部好处,您可以创建一个更详细的环境声明,覆盖您使用的外部 JavaScript 的更多特性。如果您正在构建环境声明,您可以选择包含您最常用的功能,或者您认为最有可能导致类型错误的高风险功能。这允许您投资定义类型信息,为您的时间投资提供最大回报。
在清单 3-14 中,jQuery 定义已经扩展到包含第一个例子中使用的两个元素:使用包含元素 id 的字符串查询选择元素,使用html
方法设置内部 HTML。在这个例子中,声明了一个名为jQuery
的类,这个类有接受字符串的html
方法。$
函数接受一个字符串查询并返回一个jQuery
类的实例。
declare class jQuery {
html(html: string): void;
}
declare function $(query: string): jQuery;
$('#id').html('Hello World');
Listing 3-14.Ambient class and function
当使用这个更新的环境声明时,自动完成提供类型提示,如图 3-3 所示。任何使用未声明的变量、函数、方法或属性的尝试都将导致编译器错误,并且所有参数和赋值都将被检查。
图 3-3。
Ambient declaration autocompletion
可以为变量、函数、类、枚举以及内部和外部模块创建环境声明。接口似乎从这个列表中消失了,但是接口已经类似于环境声明,因为它们描述了一个类型而没有产生任何编译的代码。这意味着您可以使用接口编写环境声明,但是您不能对接口使用declare
关键字。
Note
实际上,将 jQuery 声明为接口比声明为类更有意义,因为您不能使用var jq = new jQuery();
实例化 jQuery 的实例,您需要做的只是将declare class
关键字更改为interface
关键字,因为环境类和接口都不需要任何实现。
申报文件
尽管可以将环境声明放在任何 TypeScript 文件中,但对于只包含环境声明的文件,有一个特殊的命名约定。惯例是使用一个.d.ts
文件扩展名。文件中的每个模块、变量、函数、类和枚举都必须以declare
关键字开头,这是由 TypeScript 编译器强制执行的。
若要在程序中使用声明文件,可以像引用任何其他类型脚本文件一样引用该文件。您可以使用引用注释,或者将文件作为 import 语句的目标。当使用导入语句定位一个文件时,声明文件应该放在同一个文件夹中,并与 JavaScript 文件同名,如图 3-4 所示。
图 3-4。
Declarat ion files
绝对打字
如果您计划为任何常见的 JavaScript 库或框架编写环境声明,您应该首先查看是否有人已经通过访问环境声明的在线库完成了这项艰苦的工作,明确键入:
由 Boris Yankov 发起的明确类型化项目包含了无数流行 JavaScript 项目的定义,包括 Angular、Backbone、Bootstrap、Breeze、D3、Ember、jQuery、Knockout、Node、下划线等等。甚至还有 Jasmine、Mocha 和 qUnit 等单元测试框架的声明。其中一些外部来源非常复杂,因此使用现有的声明可以节省大量时间。Microsoft 现在支持该存储库。您可以在以下位置搜索正确的定义名称和安装说明:
将现有类型定义引入项目的最简单方法是使用 NPM,如清单 3-15 所示。
npm install --save @types/jquery
Listing 3-15.installing type definitions
摘要
在 TypeScript 类型系统中工作至少需要了解一下名义类型和结构类型之间的区别。结构类型化会使一些设计变得有点棘手,但是它不会阻止您使用任何您可能希望从名义类型化系统中转移的模式。类型推断允许您省去类型注释,而允许在整个程序中推断类型。
当您编译程序时,会根据显式和隐式类型检查类型,这样就可以尽早检测出一大类错误。使用any
类型,你可以选择退出程序特定部分的类型检查。
您可以通过创建或获取 JavaScript 代码的环境声明来添加 JavaScript 代码的类型信息。通常这些环境声明会存储在 JavaScript 文件旁边的声明文件中。
要点
- 静态类型检查是可选的。
- TypeScript 是结构化类型的。
- 所有类型信息都在编译过程中被移除。
- 您可以让编译器使用类型推断为您计算出类型。
- 环境声明将类型信息添加到现有的 JavaScript 代码中。
四、TypeScript 中的面向对象
构建软件设计有两种方式:一种是让它简单到没有明显缺陷,另一种是让它复杂到没有明显缺陷。第一种方法要困难得多。它需要同样的技能、投入、洞察力,甚至灵感,就像发现构成复杂自然现象基础的简单物理定律一样。—东尼·霍尔
面向对象编程允许用包含数据和相关行为的代码来表示现实世界中的概念。概念通常被建模为类,具有数据的属性和行为的方法,这些类的特定实例被称为对象。
这些年来已经有很多关于面向对象的讨论,我确信这场辩论在未来的许多年里仍然会很活跃。因为编程是一个启发式的过程,你很少会找到一个绝对的答案。这就是为什么你会在软件开发中经常听到“视情况而定”这句话。没有适合所有情况的编程范式,所以任何告诉你函数式编程、面向对象编程或其他编程风格是所有问题的答案的人都没有接触过足够多的复杂问题。正因为如此,编程语言变得越来越多元。
面向对象编程是计算机编程早期出现的许多良好实践的形式化。它提供了使这些良好实践更容易应用的概念。通过使用代码中的对象对问题领域中的真实世界对象进行建模,程序可以使用与它所服务的领域相同的语言。对象还允许封装或信息隐藏,这可以防止程序的不同部分修改程序的另一部分所依赖的数据。
支持面向对象等编程概念的最简单的解释不是来自软件世界,而是来自心理学。G. A .米勒发表了他的著名论文“神奇的数字七,正负二”(心理评论,1956 年),描述了我们在任何一个时间可以在短期记忆中保持的信息数量的限制。我们的信息处理能力受限于我们能同时掌握的五到九条信息。这是任何代码组织技术的关键原因,在面向对象中,它应该驱动你走向抽象层,允许你首先浏览高层次的想法,并在需要时进一步深入细节层次。如果组织得好,维护代码的程序员在试图理解你的程序时,只需要掌握较少的并发信息。
Robert c . Martin(Bob 叔叔)在一次小组重构会议上以稍微不同的方式提出了这个想法,他说写得好的“礼貌的”代码就像读报纸一样。你可以扫描程序中的高级代码,就像它们是标题一样。维护代码的程序员会浏览标题以找到代码中的相关区域,然后深入查找实现细节。这种想法的价值来自于包含类似抽象层次代码的小型可读函数。报纸的比喻提供了干净代码的清晰愿景,但是减少认知开销的原则仍然存在。
TypeScript 中的面向对象
TypeScript 提供了在程序中使用面向对象所需的所有关键工具。
- 班级
- 类的实例
- 方法
- 遗产
- 开放递归
- 包装
- 授权
- 多态性
第一章详细讨论了类、类的实例、方法和继承。这些是面向对象程序的组成部分,通过语言本身以一种简单的方式成为可能。对于每一个概念,你所需要的只是一两个语言关键词。
这个列表中的其他术语值得进一步解释,特别是关于它们如何在 TypeScript 类型系统中工作。下面几节详细阐述了开放递归、封装、委托和多态的概念,以及演示每个概念的代码示例。
Note
尽管本章详细讨论了面向对象,但是不要忘记 JavaScript 和 TypeScript 是一种多参数语言。特别是,即使您正在编写面向对象的代码,也不应该忽略一些优秀的函数编程特性。
开放递归
开放递归是递归和后期绑定的结合。当一个方法在一个类中调用它自己时,这个调用可以被转发到一个子类中定义的替换。清单 4-1 是一个读取目录内容的类的例子。FileReader
类根据提供的路径读取内容。任何文件都被添加到文件树中,但是在找到目录的地方,有一个对this.getFiles
的递归调用。这些调用将继续,直到整个路径,包括所有子文件夹,都被添加到文件树中。fs.
reaaddirSync
和fs.
statSync
方法属于 NodeJS,这在第七章中有更详细的介绍。
Note
我使用了 NodeJS 文件系统调用的同步版本,readdirSync
和statSync
,因为它们使示例更加简单。在一个真实的程序中,你应该考虑使用标准的等价物,readdir
和stat
,它们是异步的并且接受回调函数。
LimitedFileReader
是FileReader
类的子类。当你创建一个LimitedFileReader
类的实例时,你必须指定一个数字来限制这个类所代表的文件树的深度。这个例子展示了对this.getFiles
的调用如何使用开放递归。如果您创建了一个FileReader
实例,那么对this.getFiles
的调用就是一个简单的递归调用。如果您创建了一个LimitedFileReader
的实例,那么在FileReader.getFiles
方法中对this.getFiles
的调用实际上将被分派给LimitedFileReader.getFiles
方法。
import * as fs from 'fs';
interface FileItem {
path: string;
contents: string[];
}
class SyncFileReader {
getFiles(path: string, depth: number = 0) {
const fileTree = [];
const files = fs.readdirSync(path);
for (let file of files) {
const stats = fs.statSync(file);
let fileItem: FileItem;
if (stats.isDirectory()) {
// Add directory and contents
fileItem = {
path: file,
contents: this.getFiles(file, (depth + 1))
};
} else {
// Add file
fileItem = {
path: file,
contents: []
};
}
fileTree.push(fileItem);
}
return fileTree;
}
}
class LimitedFileReader extends SyncFileReader {
constructor(public maxDepth: number) {
super();
}
getFiles(path: string, depth = 0) {
if (depth > this.maxDepth) {
return [];
}
return super.getFiles(path, depth);
}
}
// instatiating an instance of LimitedFileReader
const fileReader = new LimitedFileReader(1);
// results in only the top level, and one additional level being read
const files = fileReader.getFiles('path');
Listing 4-1.
Open recursion
这个开放式递归的例子可以总结如下:
- 当你创建一个新的
SyncFileReader:
fileReader.getFiles
是对SyncFileReader.getFiles
的调用this``.getFiles``SyncFileReader
内是对SyncFileReader
.getFiles
的称呼
- 当你创建一个新的
LimitedFileReader
fileReader.getFiles
是对LimitedFileReader.getFiles
的调用super.getFiles
是对SyncFileReader.getFiles
的调用this``.getFiles``SyncFileReader
内是对LimitedFileReader
.getFiles
的称呼
开放递归的美妙之处在于原始类保持不变,并且不需要子类提供的专门化知识。子类可以重用超类的代码,这避免了重复。
包装
TypeScript 完全支持封装。类实例可以包含属性以及对这些属性进行操作的方法;这就是数据和行为的封装。还可以使用private
访问修饰符来隐藏属性,它对类实例之外的代码隐藏数据。
封装的一个常见用途是数据隐藏:防止从类外部访问数据,除非通过显式操作。清单 4-2 中的例子显示了一个具有private total
属性的Totalizer
类,该属性不能被Totalizer
类之外的代码修改。当外部代码调用在类上定义的方法时,属性可能会更改。这消除了以下风险
- 外部代码添加捐赠而不添加退税;
- 未能验证金额的外部代码是正数;
- 调用代码中多处出现的退税计算;
- 外部代码中多处出现的税率。
class Totalizer {
private total = 0;
private taxRateFactor = 0.2;
addDonation(amount: number) {
if (amount <= 0) {
throw new Error('Donation exception');
}
const taxRebate = amount * this.taxRateFactor;
const totalDonation = amount + taxRebate;
this.total += totalDonation;
}
getAmountRaised() {
return this.total;
}
}
const totalizer = new Totalizer();
totalizer.addDonation(100.00);
const fundsRaised = totalizer.getAmountRaised();
// 120
console.log(fundsRaised);
Listing 4-2.
Encapsulation
封装是一种工具,它可以帮助你防止程序中大量的重复代码,但它并不能神奇地做到这一点。您应该使用private
关键字隐藏您的属性,以防止外部代码更改该值或使用该值控制程序的流程。例如,最常见的一种复制是逻辑分支。if
和switch
语句,它们基于一个应该使用private
关键字隐藏的属性来控制程序。当您更改属性时,您需要搜索所有这些逻辑分支,这会在整个代码中产生令人担忧的变化。
封装的最大好处是它极大地简化了理解代码的任务。类中的私有成员允许您理解成员的确切用法,而无需查看该类之外的任何代码。您可以保证成员的每次使用都在您面前,如果没有成员的使用,您可以删除它,因为没有其他代码依赖于它。
一旦增加了成员的可见性,如果不查看更广泛的代码集合,就无法理解如何使用它。如果您正在创作一个在其他程序中使用的包,您不可能理解该成员的所有用途,因此事情比私有成员要复杂得多。
授权
就程序重用而言,最重要的概念之一是委托。委托描述了程序的一部分将任务移交给另一部分的情况。在真正的委托中,包装器将对自身的引用传递给委托,这允许委托回调原始包装器,例如,包装器类将调用委托类,将关键字this
传递给委托,允许委托调用包装器类上的公共方法。这允许包装类和委托类表现为子类和超类。
当包装器不传递对自身的引用时,这种操作在技术上被称为转发,而不是委托。在委托和转发中,你可以调用一个类上的方法,但是那个类把处理交给另一个类,如清单 4-3 所示。如果两个类之间的关系没有通过“是一个”测试,委托和转发通常是继承的好选择。
Note
面向对象中的“是一个”测试包括描述对象之间的关系,以验证子类确实是超类的特殊版本。例如,“猫是哺乳动物”,“储蓄账户是银行账户。”当这种关系无效时,通常是显而易见的,例如,“一辆汽车是一个底盘”不起作用,但“一辆汽车有一个底盘”起作用。“有”关系需要委托(或转发),而不是继承。
interface ControlPanel {
startAlarm(message: string): any;
}
interface Sensor {
check(): any;
}
class MasterControlPanel {
private sensors: Sensor[] = [];
constructor() {
// Instantiating the delegate HeatSensor
this.sensors.push(new HeatSensor(this));
}
start() {
for (let sensor of this.sensors) {
sensor.check();
}
window.setTimeout(() => this.start(), 1000);
}
startAlarm(message: string) {
console.log('Alarm! ' + message);
}
}
class HeatSensor {
private upperLimit = 38;
private sensor = {
read: function() { return Math.floor(Math.random() * 100); }
};
constructor(private controlPanel: ControlPanel) {
}
check() {
if (this.sensor.read() > this.upperLimit) {
// Calling back to the wrapper
this.controlPanel.startAlarm('Overheating!');
}
}
}
const controlPanel = new MasterControlPanel();
controlPanel.start();
Listing 4-3.
Deleg
ation
清单 4-3 是委托的一个简单例子。ControlPanel
类将自己传递给HeatSensor
构造函数,这使得HeatSensor
类能够在需要时调用ControlPanel
上的startAlarm
方法。ControlPanel
可以协调任意数量的传感器,如果检测到问题,每个传感器都可以回调到ControlPanel
中发出警报。
可以对此进行扩展,以展示可以选择继承或委托的各种决策点。图 4-1 描述了汽车各部件之间的关系。底盘是建造汽车的普通骨架,是汽车的基本框架。当发动机、传动轴和变速器安装在底盘上时,这种组合称为滚动底盘。
图 4-1。
Encapsulation and inheritance
对于图表中的每种关系,试着阅读“是 a”和“有 a”选项,看看你是否同意所示的关系。在面向对象编程中,我们在这些检查期间暂停语法,所以您永远不需要使用“is an”或“has an”
多态性
在编程中,多态性指的是指定一个契约并让许多不同类型实现该契约的能力。使用实现某些约定的类的代码不需要知道具体实现的细节。在 TypeScript 中,可以使用几种不同的形式实现多态性:
- 由许多类实现的接口;
- 由许多对象实现的接口;
- 由许多函数实现的接口;
- 有许多专门子类的超类;
- 任何有许多相似结构的结构。
最后一点,“具有许多相似结构的任何结构”指的是 TypeScript 的结构类型系统,它将接受与所需类型兼容的结构。这意味着你可以用两个具有相同签名和返回类型的函数(或者两个具有兼容结构的类,或者两个具有相似结构的对象)实现多态性,即使它们没有显式地实现一个命名类型,如清单 4-4 所示。
interface Vehicle {
moveTo(x: number, y: number);
}
// Explicit interface implementation
class Car implements Vehicle {
moveTo(x: number, y: number) {
console.log('Driving to ' + x + ' ' + y);
}
}
class SportsCar extends Car {
}
// Doesn't explicitly implement the Vehicle interface
class Airplane {
moveTo(x: number, y: number) {
console.log('Flying to ' + x + ' ' + y);
}
}
class Satellite {
moveTo(x: number) {
console.log('Targeting ' + x);
}
}
function navigate(vehicle: Vehicle) {
vehicle.moveTo(59.9436499, 10.7167959);
}
const car = new SportsCar();
navigate(car);
const airplane = new Airplane();
navigate(airplane);
const satellite = new Satellite();
navigate(satellite);
Listing 4-4.Polymorphism
清单 4-4 展示了 TypeScript 中的多态性。navigate
函数接受与Vehicle
接口兼容的任何类型。具体来说,这意味着任何具有名为moveTo
的方法的类或对象,该方法最多接受两个类型为number
的参数。
Note
重要的是要记住,如果一个方法接受的参数较少,那么它与另一个方法在结构上是兼容的。在许多语言中,即使没有在方法体中使用冗余参数,您也会被迫指定该参数,但是在 TypeScript 中,您可以省略它。如果协定指定了参数,调用代码仍然可以传递它,这保留了多态性。
清单 4-4 中的navigate
函数将指定的Vehicle
发送到奥斯陆的挪威计算中心——多态是在奥斯陆由奥利·约翰·达尔和克利斯登·奈加特创建的。
示例中定义的所有类型都与Vehicle
定义兼容;Car
显式实现了接口,SportsCar
继承了Car
,所以它也实现了Vehicle
接口。Airplane
没有显式实现Vehicle
接口,但它有一个兼容的moveTo
方法,并将被navigate
函数接受。Satellite
类代表一辆具有固定“y”坐标的车辆,这意味着只能控制“x”坐标。此类型仍然兼容,因为 TypeScript 中允许具有较少参数的类型。基于兼容类型的结构接受兼容类型是 TypeScript 的结构类型系统的一个特性,这将在第三章中描述。
坚实的原则
与任何编程范例一样,面向对象并不能防止混乱或不可维护的程序。这就是五个启发式设计准则通常被称为坚实原则的原因。
这些坚实的原则被罗伯特·c·马丁编入目录,并在一系列在线文章和几本书( http://www.objectmentor.com/resources/articles/Principles_and_Patterns.pdf
)中进行了描述,2000;C#中的敏捷原则、模式和实践(Prentice Hall,2006)。一段时间后,Michael Feathers 发现了“SOLID”这个缩写词。幸运的是,原则的顺序并不重要,所以它们可以按照这种更容易记忆的形式进行排序。这些原则旨在成为支撑面向对象编程和设计的基本原则。一般来说,这些原则为创建可读和可维护的代码提供了指导。
重要的是要记住,软件设计是一个启发式的过程。不可能创建像清单一样可以遵循的规则。坚实的原则是帮助你从面向对象的角度考虑程序设计的指导方针,可以帮助你作出在你的特定环境下工作的明智的设计决定。这些原则还提供了一种共享语言,可以用来与其他程序员讨论设计。
这五个坚实的原则是:
- 单一责任原则——一个类应该有且只有一个改变的理由。
- 开放-封闭原则——应该可以扩展一个类的行为而不用修改它。
- 利斯科夫替换原则——子类应该可以替换它们的超类。
- 接口分离原则——许多小型的、特定于客户端的接口比一个通用接口要好。
- 依赖倒置原则——依赖抽象,而不是具体化。
这五个坚实的原则将在接下来的章节中分别讨论。
单一责任原则
SRP 要求一个类应该只有一个改变的理由。当设计你的类时,你应该把相关的特性放在一起,确保它们可能因为相同的原因而改变,如果它们因为不同的原因而改变,就把它们分开。遵循这一原则的程序有只执行一些相关任务的类。这样的计划很可能具有高度的凝聚力。
术语内聚性指的是一个类或模块中的特性的相关性的度量。如果特性是不相关的,那么这个类的内聚性就很低,并且可能会因为许多不同的原因而改变。SRP 的正确应用会产生高内聚力。
当你在程序中添加代码时,你需要有意识地决定它属于哪里。大多数违反这一原则的情况并不是来自方法与其封闭类明显不匹配的明显情况。对于一个类来说,在一段时间内,在许多不同的程序员的关注下,逐渐超越其最初的目的要常见得多。
当考虑 SRP 时,你不需要将你的思维局限于类,因为原则具有分形性。您可以将该原则应用于方法,确保它们只做一件事,因此只有一个理由进行更改。您还可以将这一原则应用到模块中,确保模块在总体上有一个单独的职责范围。
清单 4-5 显示了一个典型的违反 SRP 的情况。乍一看,所有的方法似乎都属于Movie
类,因为它们都使用电影的属性来执行操作。然而,持久性逻辑的出现模糊了将Movie
类用作对象和将其用作数据结构之间的界限。
class Movie {
private db: DataBase;
constructor(private title: string, private year: number) {
this.db = DataBase.connect('user:pw@mydb', ['movies']);
}
getTitle() {
return this.title + ' (' + this.year + ')';
}
save() {
this.db.movies.save({ title: this.title, year: this.year });
}
}
// Movie
const movie = new Movie('The Internship', 2013);
movie.save();
Listing 4-5.Single responsibility principle (SRP) violation
为了在这个类变成一个更大的问题之前修复它,这两个问题可以在负责电影相关行为的Movie
类和负责存储数据的MovieRepository
类之间进行划分,如清单 4-6 所示。如果特性被添加到Movie
类,那么MovieRepository
不需要任何改变。如果你要改变你的数据存储设备,Movie
类不需要改变。
class Movie {
constructor(private title: string, private year: number) {
}
getTitle() {
return this.title + ' (' + this.year + ')';
}
}
class MovieRepository {
private db: DataBase;
constructor() {
this.db = DataBase.connect('user:pw@mydb', ['movies']);
}
save(movie: Movie) {
this.db.movies.save(JSON.stringify(movie));
}
}
// Movie
const movie = new Movie('The Internship', 2013);
// MovieRepository
const movieRepository = new MovieRepository();
movieRepository.save(movie);
Listing 4-6.Separate reasons for change
如果您记住了单一责任原则,那么关注类级别的责任通常是简单的,但是在方法级别它可能更加重要,确保每个方法只执行一个任务,并且以揭示方法预期行为的方式命名。Bob 叔叔创造了一个短语“extract ' til you drop”,指的是重构你的方法,直到每个方法只有很少的几行,只能做一件事。这种广泛重构方法的实践很容易就值得重新设计。
开闭原则(OCP)
OCP 经常被总结为这样一句话:软件实体应该对扩展开放,但对修改关闭。从实用的角度来说,不管你预先设计了多少程序,几乎可以肯定的是,它不会完全被保护起来不被修改。但是,更改现有类的风险是,您会无意中引入行为更改,这会影响到依赖于该类的代码。自动化测试可以在一定程度上(但不是完全)减轻这种情况,这在第十章中有所描述。
为了遵循 OCP,你需要考虑你的程序中可能改变的部分。例如,您可以尝试识别任何包含将来可能要替换或扩展的行为的类。这种方法的一个小问题是,通常不可能预测未来,而且如果你引入的代码打算在以后得到回报,那么它几乎总是不会有回报。试图猜测可能会发生什么可能会很麻烦,要么是因为结果证明代码永远不需要,要么是因为真实的未来与预测不兼容。所以,你需要务实地对待这个原则,这有时意味着只有当你在现实生活中第一次遇到问题时,才引入代码来解决问题。
记住这些警告,遵循 OCP 的一个常见方法是用一个类替换另一个类以获得不同的行为。在大多数面向对象语言中,这是一件相当简单的事情,TypeScript 也不例外。清单 4-7 显示了一个名为RewardPointsCalculator
的奖励卡积分计算类。奖励积分的标准数字是“在商店消费的每一整美元获得四个积分。”当决定向一些 VIP 客户提供双倍积分时,不是在原来的RewardPointsCalculator
类中添加一个条件分支,而是创建一个名为DoublePointsCalculator
的子类来处理新的行为。在这种情况下,子类调用超类上的原始getPoints
方法,但是它也可以完全忽略原始类,按照自己希望的方式计算点数。
如果决定只对某些符合条件的购买给予奖励积分,那么在调用原始的RewardPointsCalculator
之前,一个类可以处理基于交易类型的过滤——同样,扩展应用的行为,而不是修改现有的RewardPointsCalculator
类。
class RewardPointsCalculator {
getPoints(transactionValue: number) {
// 4 points per whole dollar spent
return Math.floor(transactionValue) * 4;
}
}
class DoublePointsCalculator extends RewardPointsCalculator {
getPoints(transactionValue: number) {
const standardPoints = super.getPoints(transactionValue);
return standardPoints * 2;
}
}
const pointsCalculator = new DoublePointsCalculator();
// 800
alert(pointsCalculator.getPoints(100.99));
Listing 4-7.Open–closed principle (OCP)
通过遵循 OCP,程序更有可能包含可维护和可重用的代码。通过避免现有类中的返工,您还可以避免变更后可能在整个程序中回响的冲击波。已知有效的代码保持不变,并添加新的代码来处理新的需求。
利斯科夫替代原理(LSP)
在《数据抽象和层次》中,芭芭拉·利斯科夫( http://www.sr.ifes.edu.br/∼mcosta/disciplinas/20091/tpa/recursos/p17-liskov.pdf
,1988)写道,
What is needed here is a substitution attribute similar to the following: if there is a T-type object o2 for every S-type object o1, so that for all programs P defined by T, when o1 replaces o2, the behavior of P is unchanged, then S is a subtype of T. -Barbara Liskov
其本质是,如果用一个子类替换一个超类,使用该类的代码不需要知道替换已经发生。如果您发现自己在程序中测试一个对象的类型,那么很有可能您违反了 LSP。这个原则的具体需求将在后面描述,使用一个超级Animal
类的例子,以及从Animal
继承而来的Cat
的子类。
- 子类型中方法参数的矛盾:如果超类有一个接受
Cat
的方法,子类方法应该接受类型Cat
或Animal
的参数,这是Cat
的超类。 - 子类型中返回类型的协方差:如果超类有一个返回
Animal
的方法,子类方法应该返回一个Animal
,或者是Animal
的一个子类,比如Cat
。 - 子类型应该抛出与超类型相同的异常,或者抛出作为超类型异常的子类型的异常:在 TypeScript 中,不局限于使用异常类;您可以简单地指定一个字符串来抛出异常。可以为 TypeScript 中的错误创建类,如清单 4-8 所示。这里的关键是,如果调用代码有一个异常处理块,它不应该对子类抛出的异常感到惊讶。在第八章中有更多关于异常处理的信息。
class ApplicationError implements Error {
constructor(public name: string, public message: string) {
}
}
throw new ApplicationError('Example Error', 'An error has occurred');
Listing 4-8.Error classes
LSP 通过确保当新的行为被添加到程序中时,新的代码可以被用来代替旧的代码来支持 OCP。如果一个子类不能直接代替一个超类,那么添加一个新的子类将导致整个代码的改变,甚至可能导致程序流被基于对象类型的分支条件所控制。
接口隔离原则(ISP)
发现接口本质上只是整个类的描述是很常见的。这通常是在类之后编写接口的情况。清单 4-9 显示了一个打印机接口的简单例子,它可以复印、打印和装订文档。因为界面只是描述打印机所有行为的一种方式,它会随着新功能的增加而增长,例如,折叠、插入信封、传真、扫描和电子邮件可能最终会出现在Printer
界面上。
interface Printer {
copyDocument();
printDocument(document: Document);
stapleDocument(document: Document, tray: number);
}
Listing 4-9.
Printer interface
ISP 声明我们不应该创建这些大的接口,而是编写一系列更小、更具体的接口,由类来实现。每个接口将描述一组独立的行为,允许代码依赖于一个只提供所需行为的小接口。不同的类可以提供这些小接口的实现,而不必实现其他不相关的功能。
清单 4-9 中的Printer
接口使得实现一个可以打印和复制,但不能装订的打印机变得不可能——或者更糟的是,必须实现装订方法来抛出一个错误,表明操作无法完成。随着接口越来越大,打印机满足Printer
接口的可能性会随着时间的推移而降低,并且很难向接口添加新方法,因为它会影响多个实现。清单 4-10 显示了另一种方法,它将方法分组到更具体的接口中,这些接口描述了许多契约,这些契约可以由简单的打印机或简单的复印机单独实现,也可以由无所不能的超级打印机实现。
interface Printer {
printDocument(document: Document);
}
interface Stapler {
stapleDocument(document: Document, tray: number);
}
interface Copier {
copyDocument();
}
class SimplePrinter implements Printer {
printDocument(document: Document) {
//...
}
}
class SuperPrinter implements Printer, Stapler, Copier {
printDocument(document: Document) {
//...
}
copyDocument() {
//...
}
stapleDocument(document: Document, tray: number) {
//...
}
}
Listing 4-10.
Segregated interfaces
当您遵循 ISP 时,客户端代码不会被迫依赖于它不打算使用的方法。大型接口倾向于调用组织在类似大型 chun 中的代码,而一系列小型接口允许客户端实现小型的可维护适配器来与接口通信。
依赖性倒置原则
在传统的面向对象程序中,高层组件依赖于分层结构中的低层组件。组件之间的耦合导致了一个僵化的系统,它很难改变,并且在引入改变时会失败。重用一个模块也变得很困难,因为如果不带来一系列的依赖关系,它就不能被移动到一个新程序中。
清单 4-11 显示了一个简单的传统依赖的例子。高级别的LightSwitch
类依赖于低级别的Light
类。
class Light {
switchOn() {
//...
}
switchOff() {
//...
}
}
class LightSwitch {
private isOn = false;
constructor(private light: Light) {
}
onPress() {
if (this.isOn) {
this.light.switchOff();
this.isOn = false;
} else {
this.light.switchOn();
this.isOn = true;
}
}
}
Listing 4-11.High-level dependency on low-level class
DIP 简单地说明了高级模块不应该依赖于低级组件,而应该依赖于抽象。反过来,抽象不应该依赖于细节,而应该依赖于更多的抽象。简单地说,你可以通过依赖一个接口而不是一个类来满足这个需求。
清单 4-12 展示了 DIP 实践的第一步,简单地添加一个LightSource
接口来打破LightSwitch
和Light
类之间的依赖关系。我们可以通过将LightSwitch
抽象成Switch
接口来延续这种设计;Switch
接口将依赖于LightSource
接口,而不是底层的Light
类。
interface LightSource {
switchOn();
switchOff();
}
class Light implements LightSource {
switchOn() {
//...
}
switchOff() {
//...
}
}
class LightSwitch {
private isOn = false;
constructor(private light: LightSource) {
}
onPress() {
if (this.isOn) {
this.light.switchOff();
this.isOn = false;
} else {
this.light.switchOn();
this.isOn = true;
}
}
}
Listing 4-12.Implementing the dependency inversion principle (DIP)
DIP 扩展了 OCP 和 LSP 的概念。通过依赖抽象,代码与类的具体实现细节的联系就不那么紧密了。这个原则有很大的影响,但是它相对容易遵循,因为您需要做的只是提供一个抽象类或一个接口(或多个接口,记住接口分离原则)来依赖,而不是一个具体的类。
设计模式
在软件中,设计模式提供了一个已知问题的目录,以及针对所描述的每个问题的设计解决方案。这些模式并不过分规范;相反,它们提供了一套工具,您可以在每次使用它们时以不同的方式进行排列。最常见的设计模式的权威来源是“四人帮”的原著《设计模式:可重用面向对象软件的元素》(Gamma,Helm,Johnson & Vlissides,Addison Wesley,1995)。
正如 Diaz 和 Harmes(Pro JavaScript Design Patterns,Apress,2007)所示,可以将这些设计模式转换为 JavaScript,如果可以用普通 JavaScript 完成,也可以用 TypeScript 完成。由于 TypeScript 中提供的基于类的面向对象,从传统设计模式示例到 TypeScript 的转换在许多情况下更加自然。
TypeScript 是设计模式的天然选择,因为它提供了使用原始目录中的所有创建、结构和行为模式所需的所有语言构造,以及自那以后的许多文档。下一节将描述设计模式的一个小样本,以及 TypeScript 代码示例。
下面的例子演示了策略模式和抽象工厂模式。这些只是四人组原著中描述的 24 种模式中的两种。这些模式将在下面进行概述,然后用来改进一个小程序的设计。
Note
虽然您可能对设计模式有一个预先的想法,这可能会改进您的程序的设计,但更常见的是,当您的程序增长时,让模式浮现出来,这通常是更可取的。如果您预测可能需要的模式,那么您可能猜错了。如果在扩展时让代码暴露问题,就不太可能创建大量不必要的类,也不太可能在错误的设计中迷失方向。
战略模式
策略模式允许您封装不同的算法,使每一个算法都可以相互替代。在图 4-2 中,上下文类将依赖于策略,它为具体的实现提供了接口。任何实现该接口的类都可以在运行时传递给上下文类。
本节后面的实际例子中展示了一个策略模式的例子。
图 4-2。
The strategy pattern
抽象工厂模式
抽象工厂模式是一种创造性的设计模式。它允许您为相关对象的创建指定一个接口,而无需指定它们的具体类。这种模式的目的是让类依赖于抽象工厂的行为,抽象工厂将由不同的具体类来实现,这些具体类在编译时或运行时会发生变化。
图 4-3 和下文中的实际例子显示了抽象工厂模式的一个例子。
图 4-3。
The abst ract factory pattern
实际例子
为了说明策略和抽象工厂设计模式的使用,我们将使用一个洗车的例子。洗车场可以根据司机的花费进行不同等级的清洗。清单 4-13 说明了车轮清洁策略,它包括一个车轮清洁类的接口,以及两个提供基本或执行清洁的策略。
interface WheelCleaning {
cleanWheels(): void;
}
class BasicWheelCleaning implements WheelCleaning {
cleanWheels() {
console.log('Soaping Wheel');
console.log('Brushing wheel');
}
}
class ExecutiveWheelCleaning extends BasicWheelCleaning {
cleanWheels() {
super.cleanWheels();
console.log('Waxing Wheel');
console.log('Rinsing Wheel');
}
}
Listing 4-13.Wheel cleaning
清单 4-14 展示了清洁汽车车身的策略。这类似于清单 4-13 中的WheelCleaning
示例,但这不是必须的。当我们稍后将示例转换为使用抽象工厂模式时,WheelCleaning
和BodyCleaning
代码都不会改变。
interface BodyCleaning {
cleanBody(): void;
}
class BasicBodyCleaning implements BodyCleaning {
cleanBody() {
console.log('Soaping car');
console.log('Rinsing Car');
}
}
class ExecutiveBodyCleaning extends BasicBodyCleaning {
cleanBody() {
super.cleanBody();
console.log('Waxing car');
console.log('Blow drying car');
}
}
Listing 4-14.
Body cleaning
清单 4-15 显示了更新使用抽象工厂模式之前的CarWashProgram
类。这是一个知道的太多的阶层的典型例子。它与具体的清理类紧密耦合,负责根据所选的程序创建相关的类。
class CarWashProgram {
constructor(private washLevel: number) {
}
runWash() {
let wheelWash: WheelCleaning;
let bodyWash: BodyCleaning;
switch (this.washLevel) {
case 1:
wheelWash = new BasicWheelCleaning();
wheelWash.cleanWheels();
bodyWash = new BasicBodyCleaning();
bodyWash.cleanBody();
break;
case 2:
wheelWash = new BasicWheelCleaning();
wheelWash.cleanWheels();
bodyWash = new ExecutiveBodyCleaning();
bodyWash.cleanBody();
break;
case 3:
wheelWash = new ExecutiveWheelCleaning();
wheelWash.cleanWheels();
bodyWash = new ExecutiveBodyCleaning();
bodyWash.cleanBody();
break;
}
}
}
Listing 4-15.
CarWashProgram class
before the abstract factory pattern
抽象工厂本身是一个描述每个具体工厂可以执行的操作的接口。在清单 4-16 中,ValetFactory
接口提供了方法签名,用于获取提供车轮清洁功能的类和提供车身清洁功能的类。需要清理车轮和车身的类可以依赖于这个接口,并与指定实际清理的类保持分离。
interface ValetFactory {
getWheelCleaning() : WheelCleaning;
getBodyCleaning() : BodyCleaning;
}
Listing 4-16.Abstract factory
在清单 4-17 中,声明有三家混凝土工厂提供青铜级、白银级或黄金级清洗。每个工厂都提供适当的清洗等级,与所需的清洗等级相匹配。
class BronzeWashFactory implements ValetFactory {
getWheelCleaning() {
return new BasicWheelCleaning();
}
getBodyCleaning() {
return new BasicBodyCleaning();
}
}
class SilverWashFactory implements ValetFactory {
getWheelCleaning() {
return new BasicWheelCleaning();
}
getBodyCleaning() {
return new ExecutiveBodyCleaning();
}
}
class GoldWashFactory implements ValetFactory {
getWheelCleaning() {
return new ExecutiveWheelCleaning();
}
getBodyCleaning() {
return new ExecutiveBodyCleaning();
}
}
Listing 4-17.Concrete factories
清单 4-18 展示了使用抽象工厂模式更新的类。CarWashProgram
类不再知道将执行洗车操作的具体类。CarWashProgram
现在由适当的工厂构建,该工厂将提供执行清理的类。这可以通过编译时机制或动态运行时机制来完成。
class CarWashProgram {
constructor(private cleaningFactory: ValetFactory) {
}
runWash() {
const wheelWash = this.cleaningFactory.getWheelCleaning();
wheelWash.cleanWheels();
const bodyWash = this.cleaningFactory.getBodyCleaning();
bodyWash.cleanBody();
}
}
Listing 4-18.Abstract factory pattern in use
混入类
Mixins 提供了另一种编写应用的方法,这在设计模式的书籍中没有明确涉及。
Mixins 得名于一种可定制的冰淇淋甜点,这种甜点最早出现在马萨诸塞州萨默维尔的史蒂夫冰淇淋店里。混合甜点背后的想法是,你选择一个冰淇淋,并添加另一种产品来调味,例如,一个糖果棒。自 1973 年出现在史蒂夫·赫瑞尔的菜单上以来,混合冰淇淋的概念已经风靡全球。
在编程中,mixins 基于非常相似的概念。扩充类是通过将 mixin 类组合在一起而创建的,每个 mixin 类提供一个小的可重用行为。这些 mixin 类部分是接口,部分是实现。
类型脚本混合
TypeScript 中有两种混合样式:原始的简单混合样式和较新的真实混合样式。简单的混音是在一个执行连接的附加函数的帮助下实现的。应用混合的功能如清单 4-19 所示。这个函数遍历在baseCtors
数组中传递的每个 mixin 类的实例成员,并将它们添加到derivedCtor
类中。每当您想要将 mixins 应用到一个类时,您将使用这个函数,并且您将在本节的示例中看到这个函数的使用。
function applyMixins(derivedCtor: any, baseCtors: any[]) {
baseCtors.forEach(baseCtor => {
Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
derivedCtor.prototype[name] = baseCtor.prototype[name];
});
});
}
Listing 4-19.Mixin enabler function
一旦在程序中的某个地方添加了这个函数,就可以开始使用 mixins 了。在清单 4-20 中,定义了一系列小型的可重用 mixin 类。这些类没有特定的语法。在这个例子中,我们定义了一系列可能的行为,Sings
、Dances
和Acts
。这些类充当行为菜单,可以混合在一起创建由不同组合组成的不同风格。
class Sings {
sing() {
console.log('Singing');
}
}
class Dances {
dance() {
console.log('Dancing');
}
}
class Acts {
act() {
console.log('Acting');
}
}
Listing 4-20.
Reusable classes
就其本身而言,这些类太小而没有用,但是它们非常严格地遵守单一责任原则。您并不局限于单个方法,而是每个类代表一个行为,您可以在类名中对其进行总结。为了使这些混合有用,您需要将它们组合成可用的扩充类。
在 TypeScript 中,您使用implements
关键字,后跟一个逗号分隔的 mixins 列表来组成您的 mix 类。implements
关键字向 mixins 就像实现附带的接口这一事实致敬。您还需要提供与您组合的所有混音相匹配的临时属性,如清单 4-21 所示。当在类声明后直接调用applyMixins
函数时,这些属性将被替换。
没有任何东西可以确保您使用与您在implements
语句中列出的相同的类集合来调用applyMixins
函数。您负责保持两个列表同步。
class Actor implements Acts {
act: () => void;
}
applyMixins(Actor, [Acts]);
class AllRounder implements Acts, Dances, Sings {
act: () => void;
dance: () => void;
sing: () => void;
}
applyMixins(AllRounder, [Acts, Dances, Sings]);
Listing 4-21.
Composing classes
Actor
和AllRounder
类没有真正的实现,只有由 mixins 提供的实现的占位符。这意味着对于任何给定的行为,程序中只有一个地方需要改变。在你的程序中使用一个扩充类和使用任何其他类没有什么不同,如清单 4-22 所示。
const actor = new Actor();
actor.act();
const allRounder = new AllRounder();
allRounder.act();
allRounder.dance();
allRounder.sing();
Listing 4-22.Using the classes
Note
您可能已经发现 mixins 看起来有点像多重继承。TypeScript 中不允许多重继承。mixins 的关键是使用implements
关键字,而不是extends
关键字,这使得它们更像接口而不是超类。
何时使用 Mixins
Mixins 在 TypeScript 中已经有了一些支持——但是在使用它们的时候应该记住什么呢?首先,没有检查将实现添加到扩充类的机制,所以在使用正确的类名列表调用applyMixins
函数时必须非常小心。这是您想要充分测试以避免任何令人讨厌的意外的一个方面。
关于是使用混合还是经典继承的决定通常取决于类之间的关系。当在继承和委托之间做出决定时,通常使用“是一个”诗句“有一个”测试。如本章前面所述。
- 汽车有底盘。
- 滚动底盘是一种底盘。
只有在句子中“是”的关系起作用时,才使用继承,而在“有”更有意义时,才使用删除。对于 mixins,这种关系最好用“能做”关系来描述,例如:
- 演员可以做表演。或者
- 演员表演。
您可以通过用像Acting
或Acts
这样的名字来命名您的 mixins 来加强这种关系。这让你的课读起来像一句话,比如“演员实施表演。”
mixin 应该允许将小单元组合成更大的单元,所以下面的场景是使用 mixin 的好选择:
- 用可选的特性组成类,mixins 是选项。
- 在许多类中重用相同的行为。
- 基于相似的功能列表创建许多变体。
限制
不能对私有成员使用 mixins,因为如果成员没有在扩充类中实现,编译器将生成错误。如果 mixin 和 augmented 类都定义了同名的私有成员,编译器也会产生错误。
对 mixins 的另一个限制是,尽管方法实现被映射到扩充类,但属性值没有被映射;清单 4-23 展示了这一点。当你从 mixin 中实现一个属性时,你需要在扩展类中初始化它。为了避免混淆,最好在 mixin 中定义一个必需的属性,但是不要提供默认值。
class Acts {
public message = 'Acting';
act() {
console.log(this.message);
}
}
class Actor implements Acts {
public message: string;
act: () => void;
}
applyMixins(Actor, [Acts]);
const actor = new Actor();
// Logs 'undefined', not 'Acting'
actor.act();
Listing 4-23.Properties not mapped
如果属性不需要绑定到实例,您可以使用静态属性,因为这些属性在从 mixin 映射到 augmented 类的方法中仍然可用。清单 4-24 是对清单 4-23 的更新,它使用静态属性解决了这个问题。如果您确实需要每个实例有不同的值,那么应该在扩充的类中初始化实例属性。
class Acts {
public static message = 'Acting';
act() {
alert(Acts.message);
}
}
class Actor implements Acts {
act: () => void;
}
applyMixins(Actor, [Acts]);
const actor = new Actor();
// Logs 'Acting'
actor.act();
Listing 4-24.Static properties are available
真正的混音
真正的混合为使用混合支持合成提供了更可靠的机制。清单 4-25 显示了创建一个演员的等价混音。Constructor
类型是对象的通用类型,其构造函数接受零个或多个参数。mixin 是在Acts
函数中定义的,它用一个message
属性和一个act
方法扩展了任何提供的类。
要将 mixin 应用于一个类,只需调用Acts
函数,传递目标类。无论何时调用生成的 mix 类,它都会有其原始成员,以及 mixin 的附加成员。
type Constructor<T = {}> = new (...args: any[]) => T;
function Acts<TBase extends Constructor>(Base: TBase) {
return class extends Base {
message: string = 'Acting';
act() {
alert(this.message);
}
};
}
class Person {
constructor(private name: string) {
}
}
const Actor = Acts(Person);
const actor = new Actor('Alan');
// Acting
actor.act();
Listing 4-25.
Real
mixins.
为了展示创建一个具有多个混音的混音类的比较案例,清单 4-26 中显示了歌唱、舞蹈、表演简单混音的完整真实混音等价物。
type Constructor<T = {}> = new (...args: any[]) => T;
function Sings<TBase extends Constructor>(Base: TBase) {
return class extends Ba
se {
sing() {
alert('Singing');
}
};
}
function Dances<TBase extends Constructor>(Base: TBase) {
return class extends Base {
dance() {
alert('Dancing');
}
};
}
function Acts<TBase extends Constructor>(Base: TBase) {
return class extends Base {
act() {
alert('Acting');
}
};
}
class Person {
constructor(private name: string) {
}
}
const Actor = Acts(Person);
const AllRounder = Acts(Sings(Dances(Person)));
const actor = new Actor('Alan');
actor.act();
const allRounder = new AllRounder('Gene');
allRounder.act();
allRounder.dance();
allRounder.sing();
Listing 4-26.The full real mixins
真正混合的好处包括消除了忘记调用应用混合函数的可能性,以及对所有成员的支持。语法需要做一些工作,但是一旦有了构造函数类型,剩下的就相当简单了。
摘要
面向对象的所有构造块都存在于 TypeScript 中。语言工具可以将您在使用其他语言时学到的所有面向对象的原则和实践应用到您的程序中,使用可靠的原则来指导您的写作,并将设计模式作为常见问题的成熟解决方案的参考。
面向对象本身并不能解决编写和维护解决复杂问题的程序的问题。使用面向对象编写糟糕的代码就像在任何其他编程范例中编写糟糕的代码一样;这就是模式和原则如此重要的原因。本章中的面向对象元素补充了第十章中的测试技术。
您可以使用编码卡塔练习和提高您的面向对象设计技能以及单元测试技能。这些在附录 4 中有描述,有一些例子供你尝试。
要点
- TypeScript 拥有编写面向对象程序所需的所有工具。
- 坚实的原则旨在保持代码的可延展性,防止它腐烂。
- 设计模式是对常见问题的现有的、众所周知的解决方案。
- 您不必完全按照描述来实现设计模式。
- 混合蛋白为合成提供了另一种机制。
五、了解运行时
糟糕的程序员和优秀的程序员的区别在于理解。也就是说,差的程序员不懂自己在做什么,好的程序员懂。—马克斯·卡纳特-亚历山大
一旦你的 TypeScript 程序被编译成普通的 JavaScript,你可以在任何地方运行它。JavaScript 愉快地运行在浏览器或服务器上;您只需要记住,可用的特性根据代码运行的位置而有所不同。本章解释了浏览器运行时和服务器运行时之间的一些差异,还解释了所有运行时共有的一些重要概念,如事件循环、范围和事件。
运行时功能
即使是过时的浏览器也能让您访问文档对象模型(DOM)、鼠标和键盘事件、表单和导航。现代浏览器将添加离线存储、索引数据库、HTTP 请求、地理定位和设备传感器(如光线、加速度计和接近度)的应用编程接口(API)套件。JavaScript 不仅仅是网络浏览器中最常见的语言;自 20 世纪 90 年代初以来,它一直在服务器上运行。JavaScript 作为服务器端语言的突出地位真正受到了 NodeJS 的关注,NodeJS 是一种构建在 V8 JavaScript 引擎上的服务器技术。在服务器上运行可以让您访问数据库、文件系统、加密、域名解析、流和无数其他模块和实用程序。图 5-1 展示了浏览器或服务器提供的 API 如何使 JavaScript 语言变得强大。
除非您显式使用允许线程创建的 API,如 web workers 或子进程,否则程序中的语句将排队在单个线程上执行。在单线程上运行程序消除了许多由多个线程试图操纵同一状态而引起的麻烦,但这确实意味着您需要记住您的代码可能会被排队。长时间运行的事件处理程序可以阻止其他事件及时触发,队列的执行顺序也有细微的变化。队列通常按照先进先出的顺序进行处理,但是不同的运行时环境可能会在不同的时间重新访问队列。例如,一个环境可以仅在函数已经完成时返回队列,但是另一个环境可以在函数转移控制时重新访问队列,例如通过调用另一个函数。在后一种情况下,在调用第二个函数之前,可能会执行另一个语句。尽管这些潜在差异的性质令人担忧,但很少发现它们在实践中引起任何问题。
除了处理包含所有事件的队列之外,运行时可能还有其他需要在同一线程上处理的任务要执行;例如,浏览器可能需要重绘屏幕。如果有一个函数运行时间过长,可能会影响浏览器的重绘速度。要让浏览器每秒绘制 60 帧,您需要将任何函数的执行保持在 17 毫秒以内。在实践中,保持函数快速运行非常容易,除非您处理带有阻塞调用的 API,如localStorage
,或者您执行一个长时间运行的循环。
图 5-1。
JavaScript features in browser vs. server environments
单线程方法在运行时最常见的副作用之一是,时间间隔和计时器的执行时间可能会比指定的时间长。这是因为它们必须在队列中等待被执行。清单 5-1 显示了一个测试函数,它对延迟日志记录语句的执行进行计时。调用test
函数设置 50 毫秒定时器,并测量它需要多长时间启动。多次运行这段代码会显示您得到的结果在 50 到 52 毫秒之间,这是您所期望的。
function test() {
const testStart = performance.now();
window.setTimeout(function () {
console.log(performance.now() - testStart);
}, 50);
}
test();
Listing 5-1.
Queued timer
为了模拟长时间运行的流程,在清单 5-2 中的test
函数中添加了一个运行 100 毫秒的循环。这个循环在定时器设置好之后开始,但是因为在最初的test
函数完成之前没有任何东西被排队,所以定时器的执行比以前晚了很多。本例中记录的时间通常在 118 到 130 毫秒的范围内
function test() {
const testStart = performance.now();
window.setTimeout(function () {
console.log(performance.now() - testStart);
}, 50);
// Simulated long running process
const start = +new Date();
while (+new Date() - start < 100) {
// Delay for 100ms
}
}
test();
Listing 5-2.Queued timer, delayed, waiting for the te
st method to finish
Note
所有主流浏览器的最新版本都支持performance.now
高分辨率定时器。这种测量执行时间的方法比使用Date
对象更准确。日期基于系统时钟,系统时钟每 15 分钟同步一次。如果在计时操作时发生同步,将会影响结果。performance.now
值来自一个可以测量亚毫秒级时间间隔的高分辨率计时器,在页面开始加载时从0
开始,在同步过程中不进行调整。
范围
术语范围是指在给定的上下文中可以解析的一组可用标识符。在大多数类 C 语言中,标识符是块范围的,这意味着它们可以在定义它们的同一组花括号中使用。花括号内声明的变量在花括号外不可用,但花括号内的语句可以访问花括号外声明的变量。清单 5-3 展示了这种一般的类似 C 的作用域。
当您在 JavaScript 中使用var
关键字时,情况并非如此(因此,TypeScript 也是如此)。如果清单 5-3 中的代码是在 JavaScript 运行时中执行的,那么两条语句中记录的值将是相同的;特别是,您会看到“外部:2”,而不是“外部:1”。这是因为用var
关键字创建的变量范围是由函数定义的,而不是由块定义的。
var scope = 1;
{
var scope = 2;
// Inner: 2
console.log('Inner: ' + scope);
}
// Outer: 1
console.log('Outer: ' + scope);
Listing 5-3.
C-like scope
清单 5-4 显示了相同的例子,但是使用了一个函数来为内部变量提供范围。该函数创建一个新的上下文,使内部范围变量独立于外部范围变量。在这个例子中,日志语句像在其他类似 C 语言中一样工作。
var scope = 1;
(function () {
var scope = 2;
// Inner: 2
console.log('Inner: ' + scope);
}());
// Outer: 1
console.log('Outer: ' + scope);
Listing 5-4.
Functional scope
ECMAScript 规范中有两个新的变量声明,let
和const
。let
和const
都是块范围的,可以避免函数范围变量的许多陷阱。const
关键字还有一个额外的好处,就是防止重新赋值,这意味着变量不能被覆盖(尽管它的值可以改变)。
您可以在 TypeScript 代码中同时使用 let 和 const。如果您的目标是旧版本的 JavaScript,编译器将使用低级编译来重命名变量,以防止它们受到上下文变化的影响。清单 5-5 回顾了最初的例子,使用 const 关键字代替 var 关键字,导致正确记录“Inner: 2”和“Outer: 1”。
const scope = 1;
{
const scope = 2;
// Inner: 2
console.log('Inner: ' + scope);
}
// Outer: 1
console.log('Outer: ' + scope);
Listing 5-5.
Block-level scope
清单 5-5 的底层编译用var
关键字替换了const
关键字,但重命名了第二个变量scope_1
,如清单 5-6 所示;这可以防止内部变量覆盖外部变量。编译器足够聪明,可以避免命名冲突,所以如果您已经有了一个已经命名为scope_1
的变量,编译器会选择一个不同的名称。
var scope = 1;
{
var scope_1 = 2;
// Inner: 2
console.log('Inner: ' + scope_1);
}
// Outer: 1
console.log('Outer: ' + scope);
Listing 5-6.Down-level compilation of
block-scoped variables
因为无论目标运行时如何,您都可以在 TypeScript 中使用 b 锁范围的变量,所以没有理由在您的 TypeScript 程序中使用var
关键字。
Note
如第一章所述,推荐的编码风格是对所有变量使用const
关键字,如果你决定允许的话,只使用let
关键字打开一个变量进行重新赋值。
使用块作用域的变量还会阻止变量提升,这种提升会将所有变量视为在其作用域的顶部声明。这在技术上允许在声明变量的代码行之前使用变量,尽管它们的值是未定义的。清单 5-7 显示了一个 var 提升的例子。
function lemur() {
// undefined, but technically allowable
console.log(kind);
var kind = 'Ruffed Lemur';
}
lemur();
Listing 5-7.
Variable hoisting
如果您在声明块级变量之前访问它,TypeScript 编译器将发出警告,防止这种微妙的错误,以及清单 5-8 中更令人困惑的错误,其中您可能期望在 log 语句中使用全局变量。当使用关键字var
时,在更广的范围内巧合地重用一个名字导致了一些我职业生涯中调查过的最著名的棘手的错误。
var kind = 'Ring Tailed Lemur';
function lemur() {
// undefined, not 'Ring Tailed Lemur'
console.log(kind);
var kind = 'Ruffed Lemur';
}
lemur();
Listing 5-8.Variable hoistin
g and
global scope confusion
Note
在你的程序中,避免混乱的最好方法是尽可能避免增加全局范围。缺少全局变量意味着 TypeScript 编译器可以在声明变量之前警告您变量的用法,以及意外遗漏var
或let
关键字。
回收
几乎所有现代的 JavaScript APIs,包括提供对设备传感器读数的访问的新浏览器 API,都通过接受一个回调来避免阻塞,该回调将在操作完成后执行。回调只是一个作为参数传递的函数,当操作完成时调用它。
为了说明回调的好处,图 5-2 显示了等待阻塞传感器响应请求时的程序流程。因为请求在请求期间阻塞了主线程,所以不能执行其他语句。阻塞事件队列超过几毫秒是不可取的,对于长时间操作必须避免。涉及调用文件系统、硬件设备或通过网络连接调用的操作都有可能在不可接受的时间长度内阻塞您的程序。
图 5-2。
Blocking call
回调对于避免这些阻塞请求非常有用。图 5-3 展示了如何使用这种模式来避免在长时间运行的进程中阻塞主线程。当发出请求时,函数会随请求一起传递。然后,主线程能够正常处理事件队列。当长时间运行的进程结束时,回调函数被调用,并被传递任何相关的参数。这将回调添加到事件队列中,并依次执行。
图 5-3。
Using a callback
虽然回调通常用于避免在长时间运行的过程中阻塞程序,但是您可以在程序中的任何地方自由地将函数作为参数传递。清单 5-9 展示了这一点。go
函数接受一个函数参数。callback
参数有一个类型注释,它限制了只能传递给那些接受string
参数的函数。callbackFunction
满足这个类型注释。
在go
函数体中,回调是使用call
方法执行的,该方法在 JavaScript 中的所有函数上都可用。
function go(callback: (arg: string) => void) {
callback.call(this, 'Example Argument');
}
function callbackFunction(arg: string) {
alert(arg);
}
go(callbackFunction);
Listing 5-9.Passing a function as an argument
从go
函数中执行回调有三种常见的方法。在清单 5-9 中,使用了call
方法。当您使用call
时,您必须提供一个变量,该变量将用于设置回调中this
关键字的上下文。您可以在上下文参数后面加上任意数量的附加参数;这些将被传递到回调中。您还可以使用apply
方法,这与call
几乎相同,除了您将参数作为数组传递,如清单 5-10 所示。如果您的结果已经是一个数组,这将导致值被分解为单独的参数。
function go(callback: (arg: string) => void) {
callback.apply(this, ['Example Argument']);
}
function callbackFunction(arg: string) {
alert(arg);
}
go(callbackFunction);
Listing 5-10.Using apply
执行回调的第三种方法是简单地调用带括号的函数,如清单 5-11 所示。这种技术不允许设置上下文,因此根据上下文,范围可能与您的预期不同。
function go(callback: (arg: string) => void) {
callback('Example Argument');
}
function callbackFunction(arg: string) {
alert(arg);
}
go(callbackFunction);
Listing 5-11.Simple function call
在回调上下文之外,apply
方法还有一个额外的用途。因为它接受包含参数的数组,所以您可以使用apply
从数组中提取参数。清单 5-12 展示了这一点。要找到numbers
数组中的最大数,要么编写一个循环来测试每个值,要么使用每个索引将每个值单独传递给Math.max
函数。使用apply
方法意味着您可以简单地传递numbers
数组,并让apply
方法将数组转换成参数列表。因为没有使用apply
来修改范围,所以可以将null
作为第一个参数传递。
const numbers = [3, 11, 5, 7, 2];
// A fragile way of finding the maximum number
// const max = Math.max(numbers[0], numbers[1], numbers[2], numbers[3], numbers[4]);
// A solid way to find the maximum
const max = Math.max.apply(null, numbers);
// 11
console.log(max);
Listing 5-12.Using apply to convert array to arguments
使用回调的模式是函数作为参数传递的一个例子。下一节将描述这种语言特性有多强大,以及如何以其他方式使用它。
将函数作为参数传递
函数在 JavaScript 中是一等公民,这意味着它们可以作为参数传递,作为返回值从另一个函数返回,赋给变量,并作为属性存储在对象上。将函数作为参数传递是用于提供回调的机制。
您可以使用将函数作为参数传递的能力来创建 observer 模式的简单实现,从单个类中存储订阅者集合并向他们发布事件。这个简单的观察器设计如清单 5-13 所示。可以添加任意数量的订阅者,当发布者收到消息时,它会将其分发给所有订阅者。
interface Subscriber {
(message: string): void;
}
class Publisher {
private subscribers: Subscriber[] = [];
addSubscriber(subscriber: Subscriber) {
this.subscribers.push(subscriber);
}
notify(message: string) {
for (let subscriber of this.subscribers) {
subscriber(message);
}
}
}
const publisher = new Publisher();
// Using an arrow function
publisher.addSubscriber((message) => console.log('A: ' + message));
// Using an inline function
publisher.addSubscriber(function (message) {
console.log('B: ' + message);
});
// A: Test message
// B: Test message
publisher.notify('Test message');
Listing 5-13.Simple observer
Note
当你传递一个函数作为参数时,你必须省略括号;比如go(callbackFunction)
而不是go(callbackFunction())
;否则,将执行该函数,并传递返回值。
一级函数是任何语言中最强大的特性之一。您可以创建接受函数作为参数并返回函数作为结果的高阶函数;这使得您的程序具有更大的灵活性和粒度代码可重用性。你也可以参考第一章找到更多关于函数曲线和箭头函数的信息。
承诺
引入承诺是为了减少回调导致的一些问题。当使用回调链时,代码会变得嵌套很深,难以理解。当考虑错误处理时,回调经常重复错误处理代码,进一步增加了理解程序的认知开销。
本机 Promise 对象仅在版本 5 之后的 ECMAScript 版本中可用。如果您的目标是这些规范的最新版本,那么您可以使用 promises 的纯本地版本。否则,您需要应用聚合填充来添加所需的要素。
为了充分探索承诺,我们需要通过一个相当实际的例子。本练习结束时,你将理解如何消费承诺,以及如何创造承诺。尽管本节中的例子非常简单(它们只是将一些数据记录到控制台),但是您将能够看到回调链引起的问题,以及 promises 如何修复嵌套和可读性。
简单回调
第一个例子涉及简单的回调。此示例允许在异步操作完成后使用回调将控制权返回给调用代码。我们将很快改进这个程序来解决各种问题。
清单 5-14 是我们虚构的异步获取一些数据的 API 的起点。getData
方法接受一个 id 和一个回调,一旦数据可用,这个回调就会被执行。依赖数据的代码必须放在这个回调函数中。
interface FictitiousData {
id: number;
name: string;
}
class FictitiousAPI {
static data: { [index: number]: FictitiousData } = {
1: { id: 1, name: 'Aramis' },
2: { id: 2, name: 'Athos' },
3: { id: 3, name: 'Porthos' },
4: { id: 4, name: 'D\'Artagnan' }
};
static getData(id: number, callback: (data: FictitiousData) => void) {
// Simulating async data access with a timeout
window.setTimeout(() => {
const result = this.data[id];
if (typeof result == 'undefined') {
throw new Error('No matching record');
}
callback(result);
}, 200);
}
}
Listing 5-14.
Fictitious API v1.0
清单 5-15 显示了 API 的一个简单用法。一旦异步 getData 函数准备就绪,它就执行回调函数,将数据记录到控制台。
// Single call: 'Aramis'
FictitiousAPI.getData(1, function (data) {
console.log(data.name);
});
Listing 5-15.
Single call
与回调模式相关的一个问题是,不可能处理异步代码中发生的任何异常。无论您在哪里插入 try/catch 语句,您都无法处理这个版本的 API 中的错误。
// Error handling (doesn't work)
try {
FictitiousAPI.getData(5, function (data) {
console.log(data.name);
})
} catch (ex) {
console.log('This statement is not reached, the error is not caught!');
}
Listing 5-16.
Error Handling
当您需要使用回调来链接几个调用时,代码很快就会变成嵌套的,难以阅读。清单 5-17 是获取数据的类似调用的嵌套,但是当您需要使用几个不同的异步 API 时,嵌套回调也可能发生。
FictitiousAPI.getData(1, (data) => {
console.log(data.name);
FictitiousAPI.getData(2, (data) => {
if (data.name == 'Athos') {
console.log(data.id + ' ' + data.name);
} else {
console.log(data.name);
}
FictitiousAPI.getData(3, (data) => {
console.log(data.name);
FictitiousAPI.getData(4, (data) => {
console.log(data.name);
FictitiousAPI.getData(5, (data) => {
console.log(data.name);
})
});
});
});
});
Listing 5-17.
Nested callbacks
该程序的输出如下所示:
==== OUTPUT ====
Aramis
2 Athos
Porthos
D'Artagnan
Error: No matching record
发展这段代码的第一步是使处理 API 中发生的错误成为可能。在这之后,我们可以用承诺来改善它。
回调和错误处理
为了解决错误处理的问题,我们将为回调函数引入一个额外的参数。这是广泛使用回调的程序中的常见模式。通过将错误参数放在第一位,成功条件可以具有可变数量的参数,而不会影响调用代码应该在哪里找到有关问题的信息。
清单 5-18 显示了 API 的完整的第二个版本,现在包含了错误字符串作为第一个参数。出现问题时,错误消息不是引发错误,而是作为第一个参数传递。在成功的情况下,不会传递错误字符串。
interface FictitiousData {
id: number;
name: string;
}
class FictitiousAPI {
static data: { [index: number]: FictitiousData } = {
1: { id: 1, name: 'Aramis' },
2: { id: 2, name: 'Athos' },
3: { id: 3, name: 'Porthos' },
4: { id: 4, name: 'D\'Artagnan' }
};
static getData(id: number, callback: (error: string, data: FictitiousData) => void) {
// Simulating async data access with a timeout
window.setTimeout(() => {
const result = this.data[id];
if (typeof result == 'undefined') {
callback('No matching record', null);
return;
}
callback(null, result);
}, 200);
}
}
Listing 5-18.
Fictitious API v2.0
为了将这种新的错误处理模式付诸实践,清单 5-19 中的简单调用现在需要在使用数据参数之前测试错误。
// Single call: 'Aramis'
FictitiousAPI.getData(1, function (error, data) {
if (error) {
console.log('Caught ' + error);
return;
}
console.log(data.name);
});
Listing 5-19.Single call with error handling
同样的测试出现在清单 5-20 中,并成功处理了以前不可能处理的错误。我们现在可以对成功和错误采取不同的行动。
// Error handling
FictitiousAPI.getData(5, function (error, data) {
if (error) {
console.log('Caught ' + error);
return;
}
console.log(data.name);
});
Listing 5-20.Working error handling
这种模式的缺点是错误处理代码的激增,这使得我们的嵌套回调情况更加冗长,如清单 5-21 所示。尽管这个代码示例仍然满足一个非常基本的功能,但它现在是一个很难理解的复杂清单。
FictitiousAPI.getData(1, (error, data) => {
if (error) {
console.log('Caught ' + error);
return;
}
console.log(data.name);
FictitiousAPI.getData(2, (error, data) => {
if (error) {
console.log('Caught ' + error);
return;
}
if (data.name == 'Athos') {
console.log(data.id + ' ' + data.name);
} else {
console.log(data.name);
}
FictitiousAPI.getData(3, (error, data) => {
if (error) {
console.log('Caught ' + error);
return;
}
console.log(data.name);
FictitiousAPI.getData(4, (error, data) => {
if (error) {
console.log('Caught ' + error);
return;
}
console.log(data.name);
FictitiousAPI.getData(5, (error, data) => {
if (error) {
console.log('Caught ' + error);
return;
}
console.log(data.name);
})
});
});
});
});
Listing 5-21.Nested callbacks with error handling
该程序的输出与之前基本相似,如下所示,唯一的区别是现在错误得到了处理:
==== OUTPUT ====
Aramis
2 Athos
Porthos
D'Artagnan
Caught No matching record
进化这段代码的下一步是使用 promises 来极大地提高它的可读性。
承诺
将承诺引入 API 是一项非常简单的任务。在清单 5-22 中,getData
方法的签名已经通过移除所有为回调引入的参数而被清除。这使得签名更好地描述了执行操作所需的内容:在这种情况下,只有 id。
该方法的主体被包装在一个新的 promise 对象中,该对象总是有一个带有两个函数参数的签名。当请求成功时,第一个函数将用于履行承诺。第二个函数用于在出现错误时拒绝承诺。所有的承诺都有这个签名,但是在 TypeScript 中,您可以用更具体的类型信息来进一步增强这些功能。
在清单 5-22 中,fulfill
函数有一个类型化参数data
,它将包含FicitiousData
,reject
函数有一个字符串reason
。这里的类型注释将确保自动完成成员在使用该承诺的代码中是正确的。您还可以在创建承诺时使用类型参数来添加这种类型信息。
interface FictitiousData {
id: number;
name: string;
}
class FictitiousAPI {
static data: { [index: number]: FictitiousData } = {
1: { id: 1, name: 'Aramis' },
2: { id: 2, name: 'Athos' },
3: { id: 3, name: 'Porthos' },
4: { id: 4, name: 'D\'Artagnan' }
};
static getData(id: number) {
return new Promise((fulfil: (data: FictitiousData) => void, reject: (reason: string) => void) => {
// Simulating async data access with a timeout
window.setTimeout(() => {
const result = this.data[id];
if (typeof result == 'undefined') {
reject('No matching record');
}
fulfil(result);
}, 200);
});
}
}
Listing 5-22.
Fictitious API v3.0
当我们调用 getData 方法时,我们现在得到了一个承诺,如清单 5-23 所示。代替回调,promise 对象有一个接受函数的then
方法。在这个简单的例子中,承诺的主要好处是它分离了getData
签名和then
签名的关注点。更多实质性的好处还在后头。
// Single call: 'Aramis'
FictitiousAPI.getData(1)
.then(function (data) {
console.log(data.name);
});
Listing 5-23.
Single call with then
为了处理履行承诺时出现的错误,可以使用catch
方法,如清单 5-24 所示。我们现在有三个项目都处理单独的关注点,而不是以前的回调设计,其中关注点都是混合在一起的。
// Error handling (works)
FictitiousAPI.getData(5)
.then(function (data) {
console.log(data.name);
})
.catch(function (error) {
console.log('Caught ' + error);
})
Listing 5-24.Error handling with catch
为了更好地展示这些优势,完整的承诺链如清单 5-25 所示。代码是最低限度嵌套的(最多两层,相比之下,以前的版本使用带有错误处理的回调有五层)。每个 then 函数都比较容易理解,所有的异常处理都包含在一个 catch 中。
FictitiousAPI.getData(1)
.then((data) => {
console.log(data.name);
return FictitiousAPI.getData(2);
})
.then((data) => {
if (data.name == 'Athos') {
console.log(data.id + ' ' + data.name);
} else {
console.log(data.name);
}
return FictitiousAPI.getData(3);
})
.then((data) => {
console.log(data.name);
return FictitiousAPI.getData(4);
})
.then((data) => {
console.log(data.name);
return FictitiousAPI.getData(5);
})
.catch((error) => {
console.log('Caught ' + error);
});
Listing 5-25.Promise chain
更新后的程序的输出与之前的相同,但是程序更容易阅读。
==== OUTPUT ====
Aramis
2 Athos
Porthos
D'Artagnan
Caught No matching record
虽然此示例使用了单个 catch,但为了重现原始回调示例的行为,如果您希望以后继续处理该链,可以插入额外的 catch 块,以便在链的早期处理错误。在这方面,承诺是非常灵活的。
即使在嵌套和错误处理的情况下,承诺也不仅仅是回调的匹配,但是承诺还有一些其他的好处,这些好处是回调所不能提供的。
多重承诺
链接多个承诺的另一种方法是用一个包装承诺聚合它们的结果,该包装承诺负责从各个子承诺中获取值。promises 中内置了一种机制,允许通过简单地调用Promise.all
来实现这一点。
清单 5-26 显示了来自清单 5-25 的承诺链折叠成对 Promise.all 的调用。一旦所有的承诺都已解决,然后执行块的结果。如果有任何错误,将立即调用 catch 块;这是一种快速失效机制。这是一个很好的方式来表达你的代码需要所有的承诺来实现才能继续。
Promise.all([
FictitiousAPI.getData(1),
FictitiousAPI.getData(2),
FictitiousAPI.getData(3),
FictitiousAPI.getData(4)
]).then((values) => {
for (let val of values) {
console.log(val.name);
}
}).catch((error) => {
console.log('Caught ' + error);
});
Listing 5-26.Promise.all
无论每个单独的承诺需要多长时间来实现,在 then 块中传递的值都将按照承诺的顺序进行排序。在列表 5-26 的情况下,结果将总是按阿拉米斯、阿索斯、波尔多斯、达达尼昂的顺序排列,即使异步操作以不同的顺序成功。
最快的承诺
如果您正在调用几个异步操作,并且只对获得最快的结果感兴趣,那么您可以使用Promise.race
方法。
清单 5-27 显示了清单 5-25 中的承诺链,用于承诺竞赛。第一个解决的承诺导致比赛也以最快的承诺的实现值或拒绝原因来解决。即使第一个结果立即可用,在后台的其他操作继续,这意味着他们仍然消耗资源,即使你的比赛有一个赢家。
Promise.race([
FictitiousAPI.getData(1),
FictitiousAPI.getData(2),
FictitiousAPI.getData(3),
FictitiousAPI.getData(4)
]).then((data) => {
console.log(data.name);
}).catch((error) => {
console.log('Caught ' + error);
});
Listing 5-27.Fastest promise
Promises 为处理异步链、减少嵌套以及简化和标准化错误处理提供了一种更好的机制。还有一些有用的标准承诺组合,允许您启动许多异步操作,并在承诺全部实现时集中在一个 then 块上。
承诺将成为异步 API 的事实机制,浏览器将实现基于承诺的特性改进。例如,清单 5-28 中所示的 XMLHttpRequest 机制很可能被替换为一个 fetch API,该 API 使用承诺来执行相同的操作。
const request = new XMLHttpRequest();
request.onload = function() {
if (request.status !== 200) {
// Status code not likely to be usable, i.e. a redirect
console.log('Status Code:', request.status);
return;
}
const data = JSON.parse(request.responseText);
console.log(data);
};
request.onerror = (error) => {
// Network failure or status code is error
console.log('Error making request: ', error);
};
request.open('get', './api/musketeers.json', true);
request.send();
Listing 5-28.
XMLHttpRequest
清单 5-29 中显示了 fetch API 的等价物。虽然在撰写本文时这个特性还处于试验阶段,但是它的引入已经迫在眉睫。请注意,最终规格可能与此示例不同。
fetch('./api/musketeers.json')
.then((response) => {
if (response.status !== 200) {
// Status code not likely to be usable, i.e. a redirect or an error
console.log('Status Code:', response.status);
return;
}
return response.json();
}).then((data) => {
console.log(data);
})
.catch((error) => {
// i.e. network failure
console.log('Error making request', error);
});
Listing 5-29.
Fetch API
随着 promise 模式变得越来越熟悉,Fetch API 等变化将使交互变得更加熟悉和可预测。与 XMLHttpRequest 不同,XMLHttpRequest 几乎总是让程序员反复检查文档,您可以通过 Fetch API 遵循这种模式,因为它只是一个承诺链。任何提供异步操作的 API 都会有类似的变化。
事件
事件是 JavaScript 运行时中的一个基本概念,因此任何 TypeScript 程序员都对它们很感兴趣。事件侦听器通常附加到用户发起的事件,如触摸、单击、按键和网页上的其他交互,但事件也可以用作一种机制,用于分离需要触发处理的代码和承担工作的代码。
事件分两个不同的阶段处理——捕获和冒泡。
- 在捕获过程中,事件首先被发送到文档层次结构中最顶层的元素,然后被发送到嵌套更深的元素。
- 在冒泡期间,它首先被发送到目标元素,然后被发送到其祖先。
阶段作为事件参数的属性提供,可以使用e.eventPhase
访问,其中事件参数被命名为e
。
冒着夸大在单个线程上运行事件循环的风险,值得记住的是,附加到同一事件的多个事件侦听器将顺序执行,而不是并行执行,并且长时间运行的侦听器可能会延迟附加到同一事件的后续侦听器的执行。当一个事件被触发时,每个事件监听器按照它被附加的顺序排队;如果第一个侦听器花费 2 s 来运行,那么第二个侦听器将被阻塞至少 2 s,并且只有在到达事件队列顶部时才会执行。
class ClickLogger {
constructor() {
document.addEventListener('click', this.eventListener);
}
eventListener(e: Event) {
// 3 (Bubbling Phase)
const phase = e.eventPhase;
const tag = (<HTMLElement>e.target).tagName;
console.log(`Click event in phase ${phase} detected on element ${tag} by ClickLogger.`);
}
}
const clickLogger = new ClickLogger();
Listing 5-30.Event listeners
清单 5-30 展示了一个将它的方法之一eventListener
附加到文档上的click
事件的类。当与清单 5-31 中的 HTML 页面结合使用时;这个ClickLogger
类将根据点击的元素输出消息,例如:
- ClickLogger 在元素 DIV 上检测到 Click 事件。
- ClickLogger 在元素 P 上检测到 Click 事件。
- ClickLogger 在元素 BLOCKQUOTE 上检测到 Click 事件。
- ClickLogger 在元素页脚检测到 Click 事件。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Event Demo</title>
</head>
<body>
<div>
Clicking on different parts of this document logs appropriate messages.
<blockquote>
<p>
Any fool can write code that a computer can understand.
Good programmers write code that humans can understand.
</p>
<footer>
-Martin Fowler
</footer>
</blockquote>
</div>
</body>
</html>
Listing 5-31.Example document
Note
添加事件监听器的正确方法是addEventListener
调用。版本 9 之前的 Internet Explorer 版本使用另一种attachEvent
方法。您可以使用清单 5-32 中所示的自定义addEventCrossBrowser
函数来实现这两种附加事件的方法。该功能的改进版本出现在第五章中。
function addEventCrossBrowser(element, eventName, listener) {
if (element.addEventListener) {
element.addEventListener(eventName, listener, false);
} else if (element.attachEvent) {
element.attachEvent('on' + eventName, listener);
}
}
class ClickLogger {
constructor() {
addEventCrossBrowser(document, 'click', this.eventListener);
}
eventListener(e: Event) {
// 3 (Bubbling Phase)
const phase = e.eventPhase;
const tag = (<HTMLElement>e.target).tagName;
console.log('Click event detected on element ' + tag + ' by ClickLogger.');
}
}
const clickLogger = new ClickLogger();
Listing 5-32.
Cross-browser events
在任何给定的运行时,您都不会受限于受支持事件的有限列表;您也可以监听并发送您自己的自定义事件。
TypeScript 的自定义事件机制
清单 5-33 显示了自定义事件机制。在某些环境下,使用addEventListener
和dispatchEvent
就很简单。您可以将自定义数据作为事件的一部分进行传递,以便在侦听器中使用。
// Polyfill for CustomEvent:
// https://developer.mozilla.org/en/docs/Web/API/CustomEvent
(function () {
function CustomEvent(event, params) {
params = params || { bubbles: false, cancelable: false, detail: undefined };
const evt = <any>document.createEvent('CustomEvent');
evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);
return evt;
};
CustomEvent.prototype = (<any>window).Event.prototype;
(<any>window).CustomEvent = CustomEvent;
})();
// Fix for lib.d.ts
interface StandardEvent {
new(name: string, obj: {}): CustomEvent;
}
var StandardEvent = <StandardEvent><any>CustomEvent;
// Code for custom events is below:
enum EventType {
MyCustomEvent
}
class Trigger {
static customEvent(name: string, detail: {}) {
const event = new StandardEvent(name, detail);
document.dispatchEvent(event);
}
}
class ListeningClass {
constructor() {
document.addEventListener(
EventType[EventType.MyCustomEvent],
this.eventListener,
false);
}
eventListener(e: Event) {
console.log(EventType[EventType.MyCustomEvent] + ' detected by ClickLogger.');
console.log('Information passed: ' + (<any>e).detail.example);
}
}
var customLogger = new ListeningClass();
Trigger.customEvent(
EventType[EventType.MyCustomEvent],
{
"detail": {
"example": "Example Value"
}
}
);
Listing 5-33.Custom events
您可以选择使用事件或代码事件,比如清单 5-13 中的简单观察者,来在整个程序中分配工作。
事件阶段
事件沿着从文档根到目标元素的传播路径被分派到事件目标。沿着从根元素到目标元素的路径的每个进展都是事件的捕获阶段的一部分,并且阶段将是 1。然后事件到达事件目标,阶段变为目标阶段,即阶段 2。最后,在冒泡阶段,即阶段 3,事件以相反的方向从事件目标流回根。
这些事件阶段如图 5-4 所示。blockquote 中的附加元素不属于根和事件目标之间的层次结构,因此它们不包含在传播路径中。
图 5-4。
Event phases
事件为分离程序中的代码提供了强大的机制。如果您触发事件而不是直接调用代码来执行某个操作,那么将该操作划分为具有单一职责的小事件侦听器是一项简单的任务。以后添加额外的侦听器也是小事一桩。
扩展对象
JavaScript 中的几乎所有东西都是由一组属性组成的对象。每个属性都是一个键-值对,带有任意类型的字符串键和值,包括基本类型、对象和函数。如果值是一个函数,它通常被称为方法。每当您在 TypeScript 中创建一个类时,它都使用 JavaScript 对象来表示,但是也有许多您可以使用的内置对象。
本地对象都保持开放,这意味着您可以像扩展自己的对象一样轻松地扩展它们。出于以下原因,在扩展本机对象时需要小心:
- 如果每个人都扩展本机对象,那么扩展很可能会互相覆盖或者以不兼容的方式组合。
- 本机对象定义以后可能会与您的冲突,并且您的实现将隐藏本机实现。
因此,尽管扩展本机对象是可能的,但一般来说,它只是作为一种技术被推荐用作 p olyfill,这是一种向旧的运行时添加当前特性的方法。尽管您可能决定遵循限制较少的规则,但以 polyfill 的样式编写本机对象的扩展是值得的,这样您至少可以检测到下列情况之一何时发生:
- 添加的本机功能的名称与您的扩展冲突。
- 另一个程序员添加了另一个同名的扩展。
- 第三方库或框架添加了同名的扩展。
第三条特别建议,如果你打算把你的程序作为一个库供其他程序员使用,你就不应该编写本地对象扩展。如果库作者例行公事地扩展本地对象,那么冲突的可能性会很高,胜者将是最后加载的扩展,因为它将覆盖所有以前的扩展。
Note
术语 polyfill(以一种称为 poly fill 的墙壁平滑和裂缝填充水泥命名)是由 Remy Sharp (Remy Sharp 的博客, http://remysharp.com/2010/10/08/what-is-a-polyfill/
,2010 年)创造的,作为一个术语来描述一种用于添加缺失的本机行为的技术,当本机实现可用时,该技术会遵从本机实现。例如,您可能会尝试检测浏览器中的功能,并且只在它丢失时添加。
扩展原型
在清单 5-34 中,包含一个 HTML 元素列表的原生NodeList
被扩展为添加一个each
方法,为列表中的每个元素执行一个回调函数。扩展被添加到NodeList.prototype
,这意味着它将在所有NodeList
实例上可用。调用document.querySelectorAll
返回一个匹配元素的NodeList
,现在可以使用each
方法通过getParagraphText
函数显示每个元素的内容。使用each
方法意味着for
循环可以在一个地方定义。
call 方法用于将元素绑定到函数的上下文,而不是将每个元素作为参数传递给回调函数,这意味着getParagraphText
函数可以使用this
关键字来引用元素。
NodeList.prototype.each = function (callback) {
for (let node of this) {
callback.call(node);
}};
const getParagraphText = function () {
console.log(this.innerHTML);
};
const paragraphs = document.querySelectorAll('p');
paragraphs.each(getParagraphText);
Listing 5-34.Extending objects in JavaScript
当您将这段代码添加到 TypeScript 程序中时,将会生成错误,警告您在NodeList
接口上不存在each
方法。您可以通过在程序中添加接口来消除这些错误并获得智能自动完成,如清单 5-35 所示。额外的好处是,如果本机对象的更新方式与您的扩展冲突,TypeScript 编译器将警告您存在重复声明。
interface NodeList {
each(callback: () => any): void;
}
NodeList.prototype.each = function (callback) {
for (let node of this) {
callback.call(node);
}
};
const getParagraphText = function () {
console.log(this.innerHTML);
};
const paragraphs = document.querySelectorAll('p');
paragraphs.each(getParagraphText);
Listing 5-35.Extending objects in TypeScript
在这个例子中,each
方法中的this
关键字没有类型,因为它不能被推断出来。这可以改进,如清单 5-36 所示。通过将上下文关键字this
中的元素移入参数,程序中的自动完成和类型检查得到了改进。这也意味着可以更容易地重用该函数。NodeList
和通用NodeListOf
接口都被扩展,以提供尽可能严格的类型检查。
interface NodeList {
each(callback: (element: HTMLElement) => any): void;
}
interface NodeListOf<TNode extends Node> {
each(callback: (element: TNode) => any): void;
}
NodeList.prototype.each = function (callback: (elem: HTMLElement) => any) {
for (let node of this) {
callback.call(node, node);
}
};
const getParagraphText = function (elem: HTMLParagraphElement) {
console.log(elem.innerHTML);
};
const paragraphs = document.querySelectorAll('p');
paragraphs.each(getParagraphText);
Listing 5-36.Improved TypeScript object extensions
为了使这个解决方案更像一个 polyfill,代码应该在添加它之前检查是否存在each
方法。这就是如何添加一个已经计划好但在目标运行时上还不可用的临时特性。您可以在清单 5-37 中看到这一点。
if (!NodeList.prototype.each) {
NodeList.prototype.each = function (callback: (elem: HTMLElement) => any) {
for (let node of this) {
callback.call(node, node);
}
};
}
Listing 5-37.Turning an extension into a polyfill
通过原型扩展对象是一种可以在 TypeScript 中的任何对象上使用的技术,甚至是您自己的对象,除非它是密封的。扩展原型是向受您控制的对象添加行为的一种复杂方式。您可能会尝试使用该技术来扩展您所使用的库,因为它允许您在以后升级库时不会丢失您自己添加的内容。
密封物体
如果您担心您的代码被扩展,您可以通过使用Object.seal
来防止对您的实例进行扩展。清单 5-38 展示了其他人可能对你的代码进行的典型扩展,清单 5-39 展示了如何防止它。Object.seal
防止添加新属性,并将所有现有属性标记为不可配置。仍然可以修改现有属性的值。
class Lemur {
constructor(public name: string) {
}
}
const lemur = new Lemur('Sloth Lemur');
// new property
lemur.isExtinct = true;
// true
console.log(lemur.isExtinct);
Listing 5-38.Extended instance
class Lemur {
constructor(public name: string) {
}
}
const lemur = new Lemur('Sloth Lemur');
Object.seal(lemur);
// new property
lemur.isExtinct = true;
// undefined
console.log(lemur.isExtinct);
Listing 5-39.Sealing an instance
您可以使用Object.isSealed
方法检查一个对象是否被密封,传入您想要检查的对象。有一系列类似的操作可能是有用的——每一个都可以用在清单 5-38 中来代替Object.seal
调用,以获得下面示例中描述的结果。
Object.preventExtensions
/Object.isExtensible
是Object.seal
的一个更宽松的版本,允许属性被删除和添加到原型中。Object.freeze
/Object.isFrozen
是对Object.seal
的一个更严格的替代,它防止属性被添加或删除,也防止值被改变。
Mark Daggett (Apress,2013)的《专家 JavaScript》中有一篇关于创建、扩展和封装 JavaScript 对象的精彩概述。
扩展的替代方案
建议不要扩展原生对象而不提出问题的替代解决方案是有点不负责任的。本节展示了一个在现代 web 浏览器中 HTML 元素上可用的classList
属性的例子。显示了 polyfill,然后提供了一个替代解决方案,它使用一个外观来封送本机classList
或替代版本之间的调用。
清单 5-40 显示了一个从元素中获取类列表的调用,这个调用在旧浏览器中会失败。classList
API 实际上提供了添加、删除和切换类的选项——但是在这个例子中,只显示了对类名数组的检索。
const elem = document.getElementById('example');
console.log(elem.classList);
Listing 5-40.Using the native classList
解决这一潜在缺陷的一个常见方法是使用聚合填充物。清单 5-41 显示了一个简单的聚合填充,它测试classList
API 的存在,然后将它添加到HTMLElement
或Element
原型中。替换函数拆分类名字符串以创建一个数组,或者如果没有类名,它返回一个空数组。
if (typeof document !== "undefined" && !("classList" in document.documentElement)) {
const elementPrototype = (HTMLElement || Element).prototype;
if (elementPrototype) {
Object.defineProperty(elementPrototype, 'classList', {
get: function () {
const list = this.className ? this.className.split(/\s+/) : [];
console.log('Polyfill: ' + list);
}
});
}
}
const elem = document.getElementById('example');
console.log(elem.classList);
Listing 5-41.
ClasList Polyfill
虽然在这种特殊情况下使用 polyfill 是正确的解决方案(因为它与本机行为和安全检查非常匹配,确保它不会覆盖本机实现,如果它存在的话),但也值得考虑替代设计。在许多情况下,清单 5-42 中的解决方案是一个更稳定的选择,因为它不会与本机或库代码冲突。这种方法的缺点是必须修改调用代码来引用外观。
class Elements {
static getClassList(elem: HTMLElement) {
if ('classList' in elem) {
return elem.classList;
}
return elem.className ? elem.className.split(/\s+/) : [];
}
}
const elem = document.getElementById('example');
console.log(Elements.getClassList(elem));
Listing 5-42.ClassList Façade
除了比多面填充更好的隔离之外,立面选项还有一个主要的好处。这段代码的意图很清楚。当涉及到维护代码时,Elements
类中更简单明了的方法总是胜过 polyfill。干净且可维护的代码总是比聪明但复杂的解决方案更可取。
摘要
JavaScript 运行时以其古怪和令人惊讶而闻名,但总的来说,TypeScript 编译器将保护您免受大多数常见的失礼行为。使用块级变量声明并保持全局范围清晰将有助于编译器帮助您,因此值得使用 TypeScript 的结构特性(如类以及模块或命名空间)来封装函数和变量。
您的大部分代码将在单个线程上执行,回调和承诺都有助于避免在长时间运行的操作中阻塞该线程。承诺比回访更具可读性,有助于区分不同的关注点。保持函数简短不仅使您的程序更容易维护,还可以使您的程序响应更快,因为每次调用函数时,它都被添加到事件队列的后面,运行时有机会在队列中最早的条目过期之前处理它。
您可以侦听本机事件并创建自定义事件,也可以使用观察者模式在程序中调度和侦听自定义事件。
您可以扩展对象,包括本机对象,但是使用中介代码来封送调用以避免与其他库或将来对本机代码的扩展发生冲突通常更合适。您可以通过密封、冻结或防止扩展来防止自己的对象扩展。
要点
- 避免使用函数作用域的
var
关键字,因为 TypeScript 使得块级变量甚至可以用于旧版本的 JavaScript。 - 回调可以帮助避免阻塞主线程。
- 无论是使用本机事件还是您自己的发布者,事件都可以防止紧密耦合。
- 您可以扩展所有 JavaScript 对象,JavaScript 中的几乎所有内容都是对象。
- 您可以密封或冻结对象以防止进一步的更改。
- 您可以填充缺失的行为,使新功能在旧平台上可用。
六、在浏览器中运行 TypeScript
所有现代的网络浏览器——在台式机、游戏机、平板电脑和智能手机上——都包含 JavaScript 解释器,这使得 JavaScript 成为历史上最普遍的编程语言。—大卫·弗拉纳根
尽管有许多不同的环境可能是 TypeScript 程序的目标,但最广泛的运行时类别之一肯定是 web 浏览器。本章首先介绍 web 浏览器的一般设计,然后介绍与网页交互、向 web 服务器发出异步请求、在用户的本地计算机上存储数据以及访问硬件传感器的实际示例。在本章的最后,有关于模块化你的程序和按需加载模块的信息。
Note
本章中描述的一些功能是实验性的,对浏览器的支持有限。要了解哪些浏览器支持任何特定功能,请访问亚历克西斯·德弗里亚的“我可以使用吗”项目( http://caniuse.com/
,2014)。
网络浏览器的剖析
网络浏览器已经从 20 世纪 90 年代简单的文档显示迅速发展到今天成熟的应用环境和 3D 游戏显示。对插件、小程序和下载的依赖正在快速减少,因为视频、音频和游戏都将文档、图像和应用加入到 web 浏览器的 HTML 文档中。
如果你的程序要依靠浏览器来工作,了解一点关于浏览器的知识是值得的,但是如果浏览器的细节和一些重要特性的历史没有引起普遍的兴奋感,或者如果你已经知道了关于浏览器的所有知识,请随意跳到下一节,这是一个更实际的部分。如果你想知道更多关于网络浏览器的工作原理,请继续阅读。
网络浏览器通常由图 6-1 所示的组件组成。用户界面
- 浏览器引擎
- 渲染引擎
- Widget 引擎
- JavaScript 解释器
- 建立关系网
- 仓库
用户界面包括出现在所有 web 浏览器窗口上的所有按钮和文本框,例如地址栏、后退和前进按钮以及刷新按钮。浏览器引擎和渲染引擎处理内容显示,内容显示占据了 web 浏览器显示的主要区域。小部件引擎为用户界面和呈现引擎提供常见的用户控件,如文本输入、下拉列表和按钮。
图 6-1。
Web browser components
为了显示网页,浏览器引擎依赖呈现引擎来显示 HTML 以及在级联样式表(CSS)中定义的适当样式,或者如果它们覆盖页面样式,则由用户定义。渲染引擎依靠网络获取资源,如网页、样式表、JavaScript 文件和图像。每当需要用户交互组件(如文本框)时,就会使用小部件引擎。JavaScript 解释器运行下载的 JavaScript,该 JavaScript 又可以访问存储器、网络和任何其他可用的应用编程接口(API)。
总的来说,用户界面、浏览器引擎、渲染引擎和小部件引擎做得很好,你不需要知道所有的细节;一个例外是称为回流的过程,它会影响程序的感知性能。
每秒重排和帧数
每当 JavaScript 或 CSS 改变网页的布局时,该布局会被标记为无效,但不会立即更新。重排会重新计算文档中所有元素的大小和位置,通常发生在绘制页面之前。当布局具有无效标志时,如果 JavaScript 代码请求元素的大小或位置,则可以触发额外的回流。需要进行额外的回流,以确保为尺寸或位置提供的信息是最新的。
清单 6-1 显示了一个有典型回流问题的函数,两次使布局无效并导致两次回流。每次在文档上设置会影响布局的值时,布局都会被标记为无效。当布局无效时,每次从文档中检索值时,都会触发重排。虽然清单中的例子导致了两次重定位,但是如果错误重复,可能会导致更多的错误。重排会降低程序和页面的速度,需要等待重新呈现。
const image = document.getElementById('mainImage');
const container = document.getElementById('content');
function updateSizes() {
// Flags the layout as invalid
image.style.width = '50%';
// Causes a reflow to get the value
const imageHeight = image.offsetHeight;
// Flags the layout as invalid
container.classList.add('highlight');
// Causes a reflow to get the value
const containerHeight = container.offsetHeight;
return {
'imageHeight': imageHeight,
'containerHeight': containerHeight
};
}
const result = updateSizes();
Listing 6-1.Triggering multiple reflows
在尝试从文档中检索任何值之前,通过执行布局无效操作可以避免多次引用,如清单 6-2 所示。通过在函数开始时和需要回流的任何操作之前对使布局无效的操作进行分组,我们减少了函数期间需要回流的总次数。
const image = document.getElementById('mainImage');
const container = document.getElementById('content');
function updateSizes() {
// Operations that invalidate the layout
image.style.width = '50%';
container.classList.add('highlight');
// Operations that require a reflow
const imageHeight = image.offsetHeight;
const containerHeight = container.offsetHeight;
return {
'imageHeight': imageHeight,
'containerHeight': containerHeight
};
}
const result = updateSizes();
Listing 6-2.Triggering a single reflow
这两个例子的基本测试可以通过每 200 毫秒循环运行一次来完成。虽然它们之间几乎没有任何区别,但具有多次回流的第一个版本将刷新率降低到每秒 53 帧,第二个版本保持每秒 57 帧(在我用来测试这段代码的计算机上,静态网页以每秒 59 帧的速度渲染)。
唯一迫使您使用多次重排的情况是,您需要在进行更改后获得一个度量值:例如,在更改了元素的内容后找到元素的宽度,然后使用该宽度来重新定位元素,没有重排就无法完成。您仍然可以仔细计划您的操作,以尽可能减少回流的总次数。
当测量基于浏览器的程序时,每秒帧数是 web 应用响应度的一个很好的指标。此测量在您的浏览器工具中可用。
有趣的部分
就类型脚本而言,JavaScript 解释器以及网络和存储 API 是 web 浏览器中最有趣的组件。每一个都在下面的章节中有更详细的描述。
JavaScript 解释器
JavaScript 解释器,或者也称为 JavaScript 引擎,有很多工作要做。它不仅解析和执行 JavaScript 程序;它必须管理对象和内存,运行事件循环,并处理与 API(如存储、网络和传感器)的交互。
让浏览器中的 JavaScript 编程如此有趣(有时令人沮丧)的一个原因是,您会遇到许多不同的 JavaScript 解释器。在一些罕见的情况下,你甚至会遇到没有解释器的情况,你的程序无法运行。必须支持许多解释器会增加您需要执行的测试量,因为您需要检查您的程序在每个 web 浏览器中都工作。然而,翻译过剩也有好处。有利的一面是,浏览器供应商都希望能够宣称他们的 JavaScript 引擎的特定实现是最快的;结果,口译员在争夺第一名的时候速度快了许多倍。
当依赖这些不同的解释器来运行你的程序时,主要要注意以下几点:
- 它们可能只支持 ECMAScript 标准的旧版本。
- 它们可以实现 ECMAScript 规范之外的附加功能。
- 它们都以不同的速度运行不同的代码,尤其是在不同的操作系统上。
- 有时,您会遇到一个完全关闭了 JavaScript 的最终用户。
Note
所有主流浏览器的当前版本都支持 ECMAScript 5。ECMAScript 6(或 ECMAScript 2015)被所有领跑者支持,但你失去了 Internet Explorer 和许多边缘浏览器。ECMAScript 2016 在编写时才在 Firefox 中获得完全支持,Chrome、Opera 和 Edge 都满足 75–95%的标准。对下一组候选人推荐的支持非常少。请记住:TypeScript 的底层编译将允许您使用许多针对 ECMAScript 5 或 6 的新功能。
网络发展简史
网络浏览器中网络的发展可以通过几个阶段来跟踪。更新网页的一部分而不替换整个文档的最早机制是使用框架集。框架集是 HTML 3.0 规范的提案。网站通常有一个由三部分组成的框架集,分别包含标题、导航和内容。当在导航框架中选择一个链接时,内容框架将被替换为一个新的网页,而无需重新加载标题或导航。框架集有两个目的,一是允许部分显示独立更新,二是允许包含可重用的小部件,如标题和导航,而无需服务器端处理。
框架集的一个主要问题是,当用户导航时,页面的网址不会更新,因为用户仍然在查看框架集,而不管框架集中的框架中显示的是什么页面。当用户为某个页面添加书签,或者共享某个页面的链接时,它不会将用户带回到他们导航到的页面,而是简单地显示该网站的登录页面。此外,框架集给屏幕阅读器和文本浏览器带来了各种问题。
框架集的替代物是内嵌框架(iframe
元素)。内嵌框架被放在另一个文档的正文中,可以独立更新。使用 iframes 仍然有可能导致框架集出现类似的问题,但是它们确实提供了一个非常有用的新特性。
在用 JavaScript 建立网络之前,有事业心和创造力的程序员会使用 iframes 来提供实时更新的外观。例如,指向服务器生成的网页的隐藏 iframe 将使用计时器每 10 秒刷新一次。一旦页面加载完毕,JavaScript 将用于从 iframe 中获取新数据,并根据 iframe 中的隐藏页面更新可见页面的部分。该机制的架构如图 6-2 所示。
图 6-2。
Updating a web page by refreshing a second page in an iframe
正是这种对内嵌框架的创造性使用,将数据从服务器传输到网页,激发了XMLHTTP
通信(Internet Explorer 5)以及后来标准化的XmlHttpRequest
的发明。这些异步请求是革命性的,因为它们在支持基于 web 的应用方面发挥了作用。使用异步请求有各种各样的复杂性,这将在本章的后面详述,但是它们的重要性怎么强调都不过分。
冲击 web 浏览器的最新网络技术是 web sockets,它在浏览器和服务器之间提供持久的全双工通信。这允许双向同时通信。本章后面还将更详细地讨论 Web 套接字。
在客户端存储数据
很长一段时间以来,JavaScript 程序唯一可用的存储空间是 cookie 中的几千字节,它们随时会在没有警告的情况下消失。许多浏览器提供了一个设置,每次关闭浏览器时清除所有 cookies。最好的情况是,cookies 可以用来存储一个令牌,让用户在一段时间内保持登录,这确实是它对 web 应用唯一的主要用处。
在现代浏览器中,用户机器上的存储有几种选择,从简单的键/值本地存储到 NoSQL 索引数据库。甚至最初的几兆字节的限制也可以在用户允许的情况下增加。本章稍后将解释存储选项的具体示例。
在用户机器上存储合理数量的数据的能力允许在本地缓存数据。这可以加快您的程序并减少往返服务器的次数。它还允许您的 web 应用脱机运行,并在下次连接可用时与服务器同步。
文档对象模型
文档对象模型(DOM)是一个用于与 HTML 和 XML 文档交互的 web 浏览器界面。该接口允许您查找元素,获取和更新关于元素内容和属性的信息,并监听用户事件。如果您正在与程序中的网页进行交互,那么您正在使用 DOM。
本节中的所有例子都使用清单 6-3 中的 HTML 文档。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Running in a Browser</title>
<link rel="stylesheet" href="app.css" type="text/css" />
</head>
<body>
<h1>Running in a Browser</h1>
<div id="content"></div>
<script data-main="app" src="/Scripts/require.js"></script>
</body>
</html>
Listing 6-3.HTML document for DOM examples
该文档是一个 HTML5 网页,有一个一级标题和一个带有“内容”的分部。以下示例的目的是获取对该元素的引用,对其进行更改,并侦听其中生成的事件。
查找元素
与 DOM 最常见的交互之一是在文档中查找元素。有几种方法可以得到一个元素,如清单 6-4 所示。使用document.getElementById
长期以来一直是在网页上获取元素的标准方法,在 TypeScript 中,这将返回一个HTMLElement
类型的对象。尽管这是一种常见的查找元素的方法,但它只根据元素的id
属性来获取元素。
document.getElementById
的传统替代方案是document.getElementsByTagName
。而基于id
获取元素过于具体;通过标签名找到它们通常太笼统了。出于这个原因,选择器 API 规范中引入了document.querySelector
和document.querySelectorAll
方法,允许使用 CSS 查询选择器来查找元素。当有多个可能的匹配时,document.querySelector
返回第一个匹配元素,而document.querySelectorAll
返回所有匹配元素。
// HTMLElement
const a = document.getElementById('content');
// Element
const b = document.querySelector('#content');
// HTMLDivElement (due to type assertion)
const c = <HTMLDivElement>document.querySelector('#content');
Listing 6-4.Finding DOM elem
ents
当你使用getElementById,
获得元素时,它将返回一般的HTMLElement
类型。使用querySelector
会让你得到更普通的Element
类型。TypeScript 编译器无法确定返回的元素的确切种类。如果想使用特定类型元素的成员,可以使用类型断言来告诉编译器应该使用哪种元素类型。这不能保证类型在运行时是正确的;它只是给你正确的自动完成信息和类型检查。
使用document.getElementsByTagName
时不需要类型断言,因为 TypeScript 使用专门的重载签名根据您提供的标记名返回正确的类型。如清单 6-5 所示,其中NodeList
自动返回HTMLDivElement
类型的元素。
// NodeListOf<HTMLDivElement>
const elements = document.getElementsByTagName('div');
// HTMLDivElement
const a = elements[0];
Listing 6-5.Getting elements by HTML tag
您将遇到的最后一种类型是从document.querySelectorAll
方法返回的NodeListOf<Element>
,如清单 6-6 所示。尽管如此,您仍然可以使用类型断言来处理您选择的特定 HTML 元素。
// NodeListOf<Element>
var elements = document.querySelectorAll('#content');
// Element
var a = elements[0];
// HTMLDivElement
var b = <HTMLDivElement>elements[0];
Listing 6-6.Getting elements using CSS selectors
Note
您可能已经注意到,在 DOM 中查找元素的各种方法都返回不同类型的对象和不同的集合。这不是 TypeScript 的特性,而是代表了底层的 DOM 规范。在许多情况下,您对元素的了解要比编译器多,类型断言可以用来使类型更加具体。
如果您想避免类型断言的自由散布,您可以用通用函数包装您的调用,以将您的调用封送到 DOM API。清单 6-7 展示了允许你以泛型类型参数的形式提供类型提示的包装函数。
function QueryOf<T extends Element>(query: string) {
return <T>document.querySelector(query);
}
const elem = QueryOf<HTMLDivElement>('div#content');
function QueryAllOf<T extends Element>(query: string) {
return <NodeListOf<T>>document.querySelectorAll(query);
}
const elems = QueryAllOf<HTMLDivElement>('div');
Listing 6-7.Generic Wrapper Functions
当使用这些包装函数时,您将收到预期类型的返回值。您可以扩展这些函数来包含元素验证,以确保找到的元素是预期的类型。
改变元素
一旦找到了要更改的一个或多个元素,就有几个选项可供您更新每个元素的内容。
清单 6-8 显示了通过提供一个新的 HTML 字符串来简单替换元素的全部内容。元素的现有内容将被丢弃,以支持您提供的字符串。这种方法也有缺点;这不仅涉及到在程序中硬编码 HTML 字符串,而且如果使用这种方法插入用户生成的或第三方内容,还会有安全风险。从积极的方面来看,这是完全替换一个元素的全部内容的最简单的方法。
const element = <HTMLDivElement> document.querySelector('#content');
element.innerHTML = '<span>Hello World</span>';
Listing 6-8.
Updating
the element’s HTML
在许多情况下,您会希望在不丢失现有内容的情况下向元素中添加内容,而不是替换元素的全部内容。清单 6-9 显示了对内容部分的多次添加,这导致所有新元素被追加。清单还展示了使用document.createElement
方法生成元素,而不是使用字符串。
const element = <HTMLDivElement>document.querySelector('#content');
// Create and add the first element
const newElement1 = document.createElement('div');
newElement1.textContent = 'Hello World';
element.appendChild(newElement1);
// Create and add the second element
const newElement2 = document.createElement('div');
newElement2.textContent = 'Greetings Earth';
element.appendChild(newElement2);
Listing 6-9.Using appendChild
使用element.appendChild
时,最新的元素出现在最后。要将最新的元素添加到元素的顶部,可以使用清单 6-10 中所示的element.insertBefore
方法。传递给insertBefore
的第一个参数是新元素,第二个参数是用来定位新元素的元素。在示例中,当前的第一个子元素用于确保新元素首先出现,但是您可以使用相同的方法在 DOM 中的任何位置放置新元素。
const element = <HTMLDivElement>document.querySelector('#content');
const newElement2 = document.createElement('div');
newElement2.textContent = 'Greetings Earth';
element.insertBefore(newElement2, element.firstChild);
Listing 6-10.Using insertBefore
如果您计划创建一组嵌套的元素添加到页面中,那么在将它添加到 DOM 之前构建整个层次结构会更有效。这将确保您只使布局无效一次,这反过来意味着页面被重绘的次数更少,以反映您的更改。
事件
有许多不同的方法来附加事件侦听器,有些浏览器落后于添加侦听器的符合标准的方法。addEventListener
方法是为 DOM 事件添加事件监听器的符合标准的方式,尽管一些较老的浏览器仍然依赖于attachEvent
方法(这也要求事件名称以'on'
为前缀)。
为了解决跨浏览器兼容性的问题,Remy Sharp 创建了一个addEvent
方法,不仅缓解了浏览器的差异,还允许将元素集合作为参数传递,而不仅仅是单个元素。清单 6-11 是 Remy 原始脚本的改编版本,增加了方法的类型信息。
export const addEvent: (elem: Window | Document | Element | NodeListOf<Element>,
eventName: string, callback: Function) => void = (function () {
if (document.addEventListener) {
// Handles modern browsers
return function (elem, eventName, callback) {
if (elem && elem.addEventListener) {
// Handles a single element
elem.addEventListener(eventName, callback, false);
} else if (elem && elem.length) {
// Handles a collection of elements (recursively)
for (let i = 0; i < elem.length; i++) {
addEvent(elem[i], eventName, callback);
}
}
};
} else {
// Handles some old browsers
return function (elem, eventName, callback) {
if (elem && elem.attachEvent) {
// Handles a single element
elem.attachEvent('on' + eventName, function () {
return callback.call(elem, window.event);
});
} else if (elem && elem.length) {
// Handles a collection of elements (recursively)
for (let i = 0; i < elem.length; i++) {
addEvent(elem[i], eventName, callback);
}
}
};
}
})();
Listing 6-11.
Cross-Browser enhanced events
addEvent
方法的两个主要分支处理浏览器差异,每个分支内部都有一个检查,处理元素集合中的单个元素。当所有浏览器都支持addEventListener
方法时,方法的后半部分就变得多余了。
本章中任何需要事件的地方都会用到这个addEvent
方法。
框架和库
有许多框架和库可以帮助所有这些 DOM 交互。下面介绍了一些精选的,尽管还有更多可供选择。马丁·毕比简洁地总结了令人难以置信的图书馆选择。如果你选择一个名词并加上。js 或者。木卫一,你可能会得到一个图书馆。—马丁·毕比
尽管有时图书馆的种类繁多,但高质量的图书馆往往会浮到顶端,这要归功于一个有洞察力和直言不讳的社区。大多数可用的库都可以使用您喜欢的包管理器添加到您的程序中,比如 NPM;或者您可以下载脚本并手动添加它们。对于用普通 JavaScript 编写的第三方库,通常也可以找到匹配的类型定义,这要归功于明确类型化项目。
如果你使用的是 Visual Studio,那么 Mads Kristensen 的软件包安装程序是一个扩展,它可以加速你的软件包管理。图 6-3 显示了 RequireJS 的“快速安装包”对话框,图 6-4 显示了如何使用 NPM 上的@types 作用域获取类型定义。
图 6-4。
Installing type definitions in Visual Studio with the Package Manager Extension
图 6-3。
Installing libraries in Visual Studio with the Package Manager Extension
安装包和类型定义将导致 package.json 文件自动更新依赖项。您还可以直接编辑 package.json 文件,在 dependencies 或 development dependencies 部分添加包名。
如果您没有使用 Visual Studio,您也可以在命令行上使用命令npm install [library-name] --save
安装软件包,对于依赖项,或者使用命令npm install [library-name] --save-dev
,对于仅用于开发的依赖项。开发依赖项是针对代码下游消费者不需要的包的。
使用 NPM 获得 JavaScript 库和它们的类型定义是所有 TypeScript 项目的推荐设置,它与 Visual Studio 以及其他流行的编辑器集成得很好。
当您将此功能与来自服务器的实时数据相结合时,在网页上查找和更改元素的能力会变得更加强大。下一节将介绍如何向 web 服务器发出后台请求,以便在不重新加载整个 web 页面的情况下保存和检索信息。
网络
自 20 世纪 90 年代末发明以来,AJAX 一直主导着 web 浏览器中 JavaScript 的网络需求。尽管它占主导地位,但也有一些新进入网络空间的公司,它们对基于浏览器的应用很有用。本节介绍了从浏览器进行通信的三种主要技术,允许您挑选最适合您的程序的方法。
创建交互式、快速动态网页应用的网页开发技术
AJAX 代表异步 JavaScript 和 XML。这是一个糟糕的名字,因为 XML 不是唯一用于数据的格式,甚至可能不是最常用的格式。在浏览器中使用 JavaScript 启动 AJAX 请求。请求被发送到服务器,服务器发送一个 HTTP 响应,该响应可以包括纯文本、JSON、HTML、XML 甚至自定义格式的正文。
HTTP 请求和响应异步发生,这意味着它不会阻塞第五章中描述的 JavaScript 事件循环。
字符串
清单 6-12 展示了一个简单的Ajax
类,它有一个执行 HTTP GET 请求的公共方法。该方法创建一个新的XMLHttpRequest
对象,这是发出 AJAX 请求的标准方式。然后,回调被附加到请求上的onreadystatechange
事件。对于请求转换到的每个状态,都会调用这个函数,但是通常您主要对完成状态感兴趣。潜在的状态有
0
—未初始化1
—已建立,但未发送2
—已发送3
—飞行中4
—完成
清单 6-12 中的Ajax
类仅在状态为 4(完成)时解析,传递包含响应数据和元数据的 XMLHttpRequest。HTTP 状态代码可能是 W3C (1999)维护的 HTTP 规范中描述的任何代码。
open
方法接受请求和 URL 的 HTTP 动词。第三个参数设置请求是否是异步的。最后,在附加了状态更改监听器并使用 HTTP 动词和 URL 设置了请求之后,可以使用send
方法开始请求。
export class Ajax {
private readonly READY_STATUS_CODE = 4;
private isCompleted(request: XMLHttpRequest) {
return request.readyState === this.READY_STATUS_CODE;
}
httpGet(url: string) {
return new Promise<XMLHttpRequest>((resolve, reject) => {
// Create a request
const request = new XMLHttpRequest();
// Attach an event listener
request.onreadystatechange = () => {
if (this.isCompleted(request)) {
resolve(request);
}
};
// Specify the HTTP verb and URL
request.open('GET', url, true);
// Send the request
request.send();
});
}
}
Listing 6-12.
HTTP Get method
Note
您应该始终使 AJAX 请求异步,并使用回调来执行相关代码。虽然使请求同步看起来很方便,但是您将长时间地束缚事件循环,并且您的应用将看起来没有响应。这些例子中的 Ajax 类将本机 XMLHttpRequest 包装在一个类似 promise 的接口中。Fetch API 为异步 HTTP 请求引入了一个基于承诺的 API,如第五章所述。
提供直接提交
清单 6-13 中的示例代码是一个httpPost
方法,可以从清单 6-12 中添加到Ajax
类中。除了将 HTTP 动词改为'POST'
之外,还添加了内容类型请求头,并在请求体中发送数据。本例中的数据必须格式化为键/值对,例如'type=5&size=4'
。要发送包含数据的 JSON 字符串,您必须将内容类型设置为'application/json'
。
httpPost(url: string, data: string) {
return new Promise<XMLHttpRequest>((resolve, reject) => {
const request = new XMLHttpRequest();
request.onreadystatechange = () => {
if (this.isCompleted(request)) {
resolve(request);
}
};
request.open('POST', url, true);
request.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
request.send(data);
});
}
Listing 6-13.
HTTP Post method
通过指定适当的Content-type
,例如application/json
或application/xml
,并以适当的序列化格式传递数据,可以发送不同的数据格式。您只受到您的服务器端程序所接受的内容的限制。
每当需要发出 HTTP 请求时,您都可以调用Ajax
类,清单 6-14 中显示了一个调用示例。您还可以扩展Ajax
类来处理其他 HTTP 请求,比如PUT
和DELETE
。
import { Ajax } from './Listing-6-013';
var ajax = new Ajax();
// Making a GET request
ajax.httpGet('data.html')
.then((request) => {
document.getElementById('content').innerHTML = request.responseText;
});
Listing 6-14.Using the Ajax class
如果您试图向不同的域发出 AJAX 请求,您会发现该请求被现代浏览器中的跨源安全特性所阻止。你会遇到这种情况,甚至跨子域在同一网站上,或之间的 HTTP 和 HTTPS 网页。如果您想要启用跨源请求共享(CORS),并且如果服务器支持的话,您可以向 AJAX 请求添加一个额外的头,如清单 6-15 所示。该报头导致发送预检选项请求,以询问服务器是否将接受实际请求,如果服务器确认它将接受跨原点通信,则随后发送该请求。
const request = new XMLHttpRequest();
request.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
// ...
Listing 6-15.Allowing CORS, client side
虽然服务器配置超出了本章的范围,但是对于支持 CORS 的服务器,它必须接受并响应在实际跨原点请求之前发出的带有Access-Control-Allow-Origin
响应头的预检OPTIONS
请求。此标头指示服务器愿意与之通信的域。这充当客户端和服务器之间的握手,以验证跨域通信可以继续进行。
求转发到
AJAX 最常见的用途之一是轮询服务器以检查更新。一个特殊的实现是长轮询;发出 AJAX 请求,但是服务器延迟响应请求,直到有更新要发送。长轮询实现必须处理超时问题和并发请求限制。在某些服务器上,长轮询还会导致问题,在这些服务器上,等待响应的客户机数量会占用大量的请求线程。
WebSocket 规范通过在服务器和客户端之间建立一个持久的双向通信通道来解决这个问题,该通道可用于双向发送消息。这意味着您可以在任何时候发送信息,而不必重新建立连接,并且您可以用同样的方式接收信息。清单 6-16 是一个简单的例子,它使用ws://
协议与服务器建立通信,监听消息,并向服务器发送消息。
const webSocket = new WebSocket('ws://localhost:8080/WS');
webSocket.onmessage = (message: MessageEvent) => {
// Log message from server
console.log(message.data);
}
webSocket.send('Message To Server');
Listing 6-16.Establishing a WebSocket connection
当您完成一个 WebSocket 连接时,您可以通过调用webSocket.close()
来结束通信。如果你想了解更多关于 web sockets 的知识,你可以阅读王、Salim 和 Moskovits 的《HTML5 WebSockets 定义指南》(Apress,2013)。
实时通信
网络通信的下一个发展是实时点对点音频和视频流。W3C (2013)正在起草的 WebR TC 规范允许在浏览器之间进行流式传输,而无需浏览器插件或额外安装的软件。尽管该规范目前支持有限,但该技术的潜力是不可思议的。浏览器之间的视频和音频通话将成为可能,而不需要中间的通信提供商。
WebRTC 在几个处于试验状态的浏览器中得到支持,大多数浏览器都使用可能会更改的前缀版本来提供该功能。要在 TypeScript 中使用 WebRTC,您需要扩展库定义以包含这些过渡浏览器实现。
WebRTC 的完整实现超出了本书的范围,但是清单 6-17 展示了如何捕获包含视频和音频的媒体流,并将该流回放到 HTML 页面上的视频元素中。
const constraints = {
audio: true,
video: {
width: 1280,
height: 720
}
};
const videoElement = document.createElement('video');
videoElement.setAttribute('width', Math.floor(constraints.video.width / 2).toString());
videoElement.setAttribute('height', Math.floor(constraints.video.height / 2).toString());
document.body.appendChild(videoElement);
navigator.mediaDevices.getUserMedia(constraints)
.then(function (mediaStream) {
const video = document.querySelector('video');
video.srcObject = mediaStream;
video.onloadedmetadata = function (e) {
video.play();
};
})
.catch(function (error) {
console.log(error.name, error.message);
});
Listing 6-17.Display a video stream
getUserMedia
方法接受约束,这允许您指定想要捕获的流的特性。这个脚本测试的结果通常是在网页上向他们展示程序员的笑脸。
获取视频和音频是建立点对点流的第一步,如果你对这项技术感兴趣,有很多书专门介绍这个有趣的主题。尽管是 WebRTC 规范的一部分,getUserMedia
API 在点对点通信之外还有其他潜在的用途。您可能想要从视频流中抓取一个图像以在您的程序中使用,或者甚至以更传统的方式使用该流来发送到服务器。
网络提供了从本地浏览器与服务器或远程对等机通信所需的工具。下一节将介绍本地存储数据,这样即使网络不可用,程序也能继续工作。
仓库
自 cookies 以来,用户机器上的存储已经有了很大的发展,但有其大小限制和糟糕的 API。根据您的需要,有几种具有不同生命周期、软限制和 API 的可用存储选项,您可以使用它们在本地保存数据。
会话存储和本地存储都有相同的 API,但是它们提供不同的生命周期。但是,IndexedDB 提供了一种更高级的数据存储机制。下面描述了所有这三个存储 API。
会话存储
会话存储附加到页面会话。页面会话在页面打开时开始,即使页面在浏览器选项卡中重新加载或恢复,会话也会继续。在单独的选项卡或浏览器窗口中打开同一页面会导致新的页面会话。
清单 6-18 展示了会话存储 API 有多简单,允许用setItem
方法存储一个简单的键/值对。键和值都必须是字符串,因此对象需要序列化为字符串才能存储。
const storageKey = 'Example';
// null the first time, 'Stored value' each subsequent time
console.log(sessionStorage.getItem(storageKey));
sessionStorage.setItem(storageKey, 'Stored value');
Listing 6-18.Session storage
为了演示这个存储机制的生命周期,在设置项目之前调用了getItem
方法;当页面第一次加载时,null
值被记录,但是在随后的刷新中,存储的值被记录。如果在新选项卡中打开页面,将再次记录空值。如果您查看该页面,在同一个选项卡中访问一个完全独立的页面,然后再次加载原始页面,您会看到该值已被保留。只要选项卡打开,会话就会保持,即使选项卡中加载了其他页面,浏览器甚至可能支持在重新启动后恢复会话。
// Remove an item using a key
sessionStorage.removeItem(storageKey);
// Clear all items
sessionStorage.clear();
Listing 6-19.Removing and clearing
session storage
清单 6-19 显示了根据键删除一个项目以及从页面的会话存储中清除所有项目的方法。这些方法与前面描述的其他会话存储方法遵循相同的范围和生命周期。
局部存储器
本地存储 API 与会话存储 API 相同,但存储会一直存在,直到被用户删除或出于隐私原因被清除。还可以从同一域的多个页面以及多个浏览器和选项卡中访问本地存储。
因为本地存储项目是跨页面、选项卡和浏览器共享的,所以它可以用来存储数据缓存,以减少网络流量。它还可以用于在没有连接时存储用户输入的数据,或者存储从不需要传输的数据,如临时应用状态。
清单 6-20 包含一个脚本,它在本地存储中存储一个包含当前日期和时间的值。一个事件监听器被附加到storage
事件,每当在另一个选项卡或窗口中发生变化时,该事件就会被触发。
import { addEvent } from './Listing-6-011';
const storageKey = 'Example';
localStorage.setItem(storageKey, 'Stored value ' + Date.now());
addEvent(window, 'storage', (event: StorageEvent) => {
console.log(`${event.key} "${event.oldValue}" changed to "${event.newValue}"`);
});
Listing 6-20.Local storage and events
如果在多个浏览器选项卡中运行此脚本,除了启动更改的选项卡之外,每个选项卡都将在本地存储中记录更改。这使您可以用任何其他选项卡中的数据更改来更新所有选项卡。
存储限制
对于会话存储和本地存储,浏览器可能会遵循 Web 存储规范中描述的一系列限制和配置,该规范再次由 W3C (2014)维护。
最初,浏览器可能会限制页面可用的存储量,以防止恶意尝试耗尽用户的磁盘空间。该限制适用于所有子域,当达到该限制时,将向用户显示一个提示,要求允许增加分配的存储空间。在提示用户许可之前,建议的存储限制是 5mb。
为了保护用户隐私,浏览器很可能会阻止第三方访问存储。这意味着您将只能访问存储时所在的同一域中的存储。浏览器可以根据用户的偏好(例如,每次关闭浏览器或当浏览器达到一定年龄时)清除存储,并且还将有可供用户查看和清除存储以及白名单或黑名单站点的选项。黑名单上的站点甚至有可能在整个社区共享,允许根据黑名单上的用户数量自动阻止某个域的存储。
出于安全原因,您应该考虑存储特定信息是否合适,并且在共享域上工作时应该避免使用存储,因为共享域上的其他页面也可以使用存储。您不能通过路径限制对存储的访问。例如,以下两种路径都可以访问同一个存储:
indexeddb
虽然会话存储和本地存储是在键/值存储中存储少量数据的简单而方便的方法,但 IndexedDB 允许以结构化的方式存储大量数据,从而允许使用索引进行快速搜索。
IndexedDB 被设计为异步工作,这意味着当操作完成时,您为 API 上执行的每个方法提供一个回调。IndexedDB 的同步版本有一个规范,但是目前没有浏览器实现这种风格的 API。通常最好使用异步 API 来避免阻塞事件循环在主线程上运行,因此学习使用 IndexedDB 的异步版本是值得付出额外努力的。
使用清单 6-21 中所示的Product
类演示了 IndexedDB API。Product
类有两个公共属性用于productId
和name
。productId
将被用作存储在数据库中的条目的关键字。
export class Product {
constructor(public productId: number, public name: string) {
}
}
Listing 6-21.Product.ts
清单 6-22 显示了一个空的ProductDatabase
类。这将扩展到执行数据库操作,如存储、检索和删除产品。这个类还将减少程序代码中对 IndexedDB API 的依赖。
import { Product } from './Listing-6-021';
export class ProductDatabase {
constructor(private name: string, private version: number) {
}
}
Listing 6-22.Empty ProductDatabase.ts
P
roductDatabase
构造器接受数据库name
和version
号。版本号用于检测本地存储的数据库是否需要升级到新版本。每次更改模式时,都应该增加版本号。版本号必须是整数,即使 JavaScript 或 TypeScript 中没有本机整数类型。
需要升级
数据库升级是通过比较本地版本号和程序中的版本号来确定的。如果程序版本号大于本地版本号,则触发onupgradeneeded
事件。如果没有本地数据库,也会触发该事件。您可以指定在升级时执行的方法,该方法处理模式更改并添加任何必需的数据。
清单 6-23 包含了一个为ProductDatabase
类更新的构造函数,它发出一个打开数据库的请求,并为onupgradeneeded
事件添加一个监听器。如果需要升级,就调用update
方法。
import { Product } from './Listing-6-021';
export class ProductDatabase {
constructor(private name: string, private version: number) {
const openDatabaseRequest = indexedDB.open(this.name, this.version);
openDatabaseRequest.onupgradeneeded = this.upgrade;
}
upgrade(event: any) {
const db = event.target.result;
// The keyPath specifies the property that contains the id
const objectStore = db.createObjectStore("products", { keyPath: 'productId' });
objectStore.createIndex('name', 'name', { unique: false });
objectStore.transaction.oncomplete = () => {
// Example static data
const products = [
new Product(1, 'My first product'),
new Product(2, 'My second product'),
new Product(3, 'My third product')
];
// Add records
const productStore = db.transaction('products', 'readwrite').objectStore('products');
for (let product of products) {
productStore.add(product);
}
}
}
}
Listing 6-23.ProductDatabase supp
orting upgrades
本例中的update
方法使用createObjectStore
创建一个产品表。options 参数指定了一个keyPath
,它告诉数据库存储的对象将有一个productId
属性,该属性应该用作惟一键。通过传递值为true
的autoIncrement
选项,而不是传递keyPath
属性,您可以选择自动为您创建一个密钥。
createIndex
方法为name
属性添加了一个索引,以加快按名称搜索的速度。可以使一个索引是唯一的,尽管在这个例子中通过将unique
设置为false
来允许重复。如果数据库已经包含重复项,尝试创建唯一索引将会失败。
最后,在 products 对象存储上创建一个事务,用于将产品添加到数据库中。如果您需要用静态数据作为数据库的种子,这一步非常有用。
import { ProductDatabase } from './Listing-6-023';
const versionNumber = 1;
const db = new ProductDatabase('ExampleDatabase', versionNumber);
Listing 6-24.Instantiating a ProductDatabase
清单 6-24 显示了实例化ProductDatabase
类实例的代码。尽管构造函数为onupgradeneeded
事件分配了事件处理程序,但构造函数会在事件触发前完成。
查询数据库
因为 IndexedDB 被设计为异步工作,所以有些操作似乎比您想象的需要更多的努力。尽管如此,利用异步还是值得的——即使这些操作的同步版本最终由浏览器实现。如果回调链冒犯了您,您可以使用诸如 Dexie 或 ZangoDB 之类的库将 IndexedDB 包装在基于承诺的接口中,这要干净得多。你可以在第五章读到基于承诺的界面的好处。本章演示了完整的回调链版本,因为这是本机浏览器 API。
清单 6-25 显示了ProductDatabase
类的getProduct
方法,它处理数据库打开请求、事务和查询。这允许调用代码简单地传递productId
和callback
来处理结果。
getProduct(productId: number, callback: (result: Product) => void) {
// Open the database
const openDatabaseRequest = indexedDB.open(this.name, this.version);
openDatabaseRequest.onsuccess = () => {
// The database is open
const db = openDatabaseRequest.result;
// Start a transaction on the products store
const productStore = db.transaction(['products']).objectStore('products');
// Request the query
const query = productStore.get(productId);
query.onsuccess = () => {
callback(query.result);
};
};
}
Listing 6-25.
getProduct method
getProduct
方法创建一个打开数据库的请求,提供一个回调来创建一个事务,并在连接成功打开时运行查询。您还可以提供一个要执行的回调on error
,如果数据库无法打开,就会调用这个回调。查询请求还带有一个传递给查询结果的回调。
要使用产品数据库,清单 6-26 包含一个简单的 HTML 页面,供用户输入产品 ID 并查看从数据库获得的结果。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>IndexedDB</title>
<link rel="stylesheet" href="app.css" type="text/css" />
</head>
<body>
<h1>IndexedDB</h1>
<div>
<label>Product Id: <input type="number" id="productId" /></label>
</div>
<div id="content"></div>
<script data-main="app" src="/Scripts/require.js"></script>
</body>
</html>
Listing 6-26.
HTML page
收集用户输入的数据并调用ProductDatabase
类的代码如清单 6-27 所示。使用keyup
事件收集输入到输入中的产品 ID,并将其传递给getProduct
方法,如果有匹配的记录,还会有一个回调函数在 web 页面上显示结果。
import { addEvent } from './Listing-6-011';
import { Product } from './Listing-6-021';
import { ProductDatabase } from './Listing-6-025';
const db = new ProductDatabase('ExampleDatabase', 1);
// Wait for entry in the productId input
addEvent(document.getElementById('productId'), 'keyup', function () {
// Get the id entered by the user, convert to number
const productId = +this.value;
// Search the database with the id
db.getProduct(productId, (product) => {
document.getElementById('content').innerHTML = product ?
`The result for product id: ${product.productId} is: ${product.name}` :
'No result';
});
});
Listing 6-27.Calling getProduct
运行这个例子将证实,尽管有些代码看起来有点复杂,但是记录的检索非常快,因为不需要网络往返。数据也可以脱机使用,这意味着您的程序可以在没有连接的情况下继续工作。
添加新记录
如前一节所示,向数据库中添加一条新记录比用查询获得一条记录稍微简单一些,因为添加一条记录需要少一次回调。一般模式是相同的,如清单 6-28 所示,请求一个连接并在成功回调中启动一个事务。
然后使用add
方法存储产品,该方法接收产品对象,并根据清单 6-23 中的数据库配置自动找到productId
属性作为惟一键。
addProduct(product: Product) {
// Open the database
const openDatabaseRequest = indexedDB.open(this.name, this.version);
openDatabaseRequest.onsuccess = () => {
// The database is open
const db = openDatabaseRequest.result;
// Start a transaction on the products store
const productStore = db.transaction('products', 'readwrite').objectStore('products');
// Add the product
productStore.add(product);
};
}
Listing 6-28.addProduct method
调用addProduct
方法的代码如清单 6-29 所示。因为ProductDatabase
类已经处理了连接请求,所有调用代码需要做的就是提供将要存储的新产品。
import { Product } from './Listing-6-021';
import { ProductDatabase } from './Listing-6-028';
const db = new ProductDatabase('ExampleDatabase', 1);
const newProduct = new Product(4, 'Newly added product');
db.addProduct(newProduct);
Listing 6-29.Calling addProduct
因为数据库可以脱机使用,所以可以在没有网络连接的情况下存储记录,然后在有连接时将记录同步到服务器。您可以为要同步的记录使用一个保持表,或者标记记录以显示它们是否同步。
删除记录
从数据库中删除记录的方法如清单 6-30 所示。唯一键用于标识要删除的记录。再次需要打开数据库并在产品商店上打开一个事务。
deleteProduct(productId: number) {
// Open the database
const openDatabaseRequest = indexedDB.open(this.name, this.version);
openDatabaseRequest.onsuccess = (event: any) => {
// The database is open
const db = openDatabaseRequest.result;
// Start a transaction on the products store
const productStore = db.transaction('products', 'readwrite').objectStore('products');
// Add the product
const deleteRequest = productStore.delete(productId);
};
}
Listing 6-30.deleteProduct method
删除产品的调用代码如清单 6-31 所示,这就像用产品的惟一键调用deleteProduct
一样简单。
import { Product } from './Listing-6-021';
import { ProductDatabase } from './Listing-6-030';
const db = new ProductDatabase('ExampleDatabase', 1);
db.deleteProduct(4);
Listing 6-31.Calling deleteProduct
IDBRequest 接口
IDBRequest
在 IndexedDB 模型中很普遍。您针对数据库创建的任何请求都支持这个接口,无论它是indexedDB.open
、objectStore.get
、objectStore.add
还是objectStore.delete
。
这种约定的美妙之处在于,您可以向任何这些操作添加一个侦听器来处理成功和错误事件。在事件处理程序中,您可以访问原始请求对象,该对象包含以下信息:
result
—请求的结果,如果可用的话。error
—错误信息,如果有的话。source
—索引或对象存储,如果适用于请求的话。transaction
—请求的事务,如果请求在事务内;您可以通过调用transaction.abort().
来撤销交易中的更改readyState
—pending
或done.
在所有这些例子中,事件处理程序可以如清单 6-32 所示提供。如果您正在编写一个使用 IndexedDB 的健壮程序,您应该使用这些事件来确保数据库操作成功并检测任何错误。
deleteProduct(productId: number) {
// Open the database
const openDatabaseRequest = indexedDB.open(this.name, this.version);
openDatabaseRequest.onsuccess = (event: any) => {
// The database is open
const db = openDatabaseRequest.result;
// Start a transaction on the products store
const productStore = db.transaction('products', 'readwrite').objectStore('products');
// Add the product
const deleteRequest = productStore.delete(productId);
deleteRequest.onsuccess = () => {
console.log('Deleted OK');
}
deleteRequest.onerror = () => {
console.log('Failed to delete: ' + deleteRequest.error.name);
}
};
}
Listing 6-32.IDBRequest convention
本节中的示例都使用了 TypeScript arrow 函数语法。这不是为了保留关键字this
的含义,而是为了减少代码中可能出现的许多嵌套函数声明的干扰。
存储综述
本节介绍了浏览器中的几个存储选项。尽管这涉及了很多例子,但它实际上只是描述了您可能使用的存储机制的最常见的方面。
无论您使用何种存储机制,您都无法保证您存储的数据能够长期保存。所有存储规范都描述了数据可能被删除的情况,包括用户选择手动清除数据的情况。因此,浏览器提供的任何存储都应被视为潜在的易失性存储。
使用浏览器存储时的另一个考虑是,许多用户可能使用不同的设备来访问基于浏览器的应用。因此,如果您希望他们的体验在这些设备上持续,就需要与您的服务器同步。
地理定位
地理定位 API 提供了用于获得用户位置的单一机制,无论用户的设备是否支持使用全球定位系统或基于网络的推断来确定实际位置的定位。
只有当用户授予您的应用访问信息的权限时,您才能获得用户的位置,因此您需要提供一种回退机制来处理被拒绝的请求以及较旧的浏览器和失败的查找。当地理定位失败时,获取位置的通常机制是允许用户输入搜索词来找到他们的位置。
Note
大多数 web 浏览器只允许在安全的上下文中调用地理位置 API,当页面不安全时,甚至不会提示用户是否允许。
清单 6-33 显示了使用getCurrentPosition
方法的一次性位置查找。如果请求被批准并成功,那么将调用success
回调函数,并带有一个包含位置信息的参数。位置对象包含纬度和经度,如果可用,还可以包含关于高度、方向和速度的附加数据。清单 6-33 的输出假设用户位于伦敦眼的脚下。
function success(pos: Position) {
console.log('You are here: Lat=' + pos.coords.latitude +
' Long=' + pos.coords.longitude +
' Altitude=' + pos.coords.altitude +
' (Accuracy=' + pos.coords.altitudeAccuracy + ')' +
' Heading=' + pos.coords.heading +
' Speed=' + pos.coords.speed);
}
navigator.geolocation.getCurrentPosition(success);
// You are here: Lat = 51.5033 Long = 0.1197
// Altitude = 15 (Accuracy = 0)
// Heading = 0 Speed = 0
Listing 6-33.Geolocation getCurrentPosition
除了获得用户位置的单一读数,您还可以使用watchPosition
方法观察位置的变化。清单 6-34 重用前一个例子中的成功回调函数来监听用户位置的变化。此示例的输出假设用户在一秒钟内快速从伦敦眼顶部移动到小黄瓜顶部,导致记录的速度为每秒 3,379 米。航向用度数表示,北 0 度,东 90 度,南 180 度,西 270 度。
function success(pos: Position) {
console.log('You are here: Lat=' + pos.coords.latitude +
' Long=' + pos.coords.longitude +
' Altitude=' + pos.coords.altitude +
' (Accuracy=' + pos.coords.altitudeAccuracy + ')' +
' Heading=' + pos.coords.heading +
' Speed=' + pos.coords.speed);
}
const watch = navigator.geolocation.watchPosition(success);
// You are here: Lat = 51.5033 Long = 0.1197
// Altitude = 135 (Accuracy = 15)
// Heading = 0 Speed = 0
// You are here: Lat = 51.5144 Long = 0.0803
// Altitude = 180 (Accuracy = 15)
// Heading = 60 Speed = 3379
Listing 6-34.Geolocation watchPosition
如果想停止跟踪用户的位置,可以调用clearWatch
方法,传入对原始watchPosition
请求的引用,以结束监听位置的变化。清单 6-35 中的代码结束了前一个例子中的手表。
navigator.geolocation.clearWatch(watch);
Listing 6-35.Clearing a watch
如果您需要知道对用户位置的请求已被拒绝或失败,您可以传递一个额外的回调,以便在请求失败时调用。清单 6-36 显示了对watchPosition
的更新调用,该调用传递了额外的错误函数。您也可以使用错误回调来调用getCurrentPosition
。
function success() {
console.log('Okay');
}
function error() {
console.log('Position information not available.');
}
const watch = navigator.geolocation.watchPosition(success, error);
Listing 6-36.Failing to obtain the location
地理定位通常用于根据用户的当前位置定制页面,或者在用户执行发布消息等操作时将位置存储为元数据。一旦用户授予您的网站权限,浏览器可能会存储该权限,以避免用户每次使用 web 应用时都提示用户。大多数浏览器的默认行为是记住通过安全连接提供的页面的权限,但不记住不安全页面的权限。
传感器
有几个已经发布的 API 可以在浏览器中使用传感器。这在一定程度上要归功于 Mozilla 和诺基亚(以及其他公司)等组织对智能手机功能的推动,以及 HTML、CSS 和 JavaScript 在移动平台上的吸引力。
尽管受到移动设备的影响,这些 API 的标准正在通过万维网联盟(W3C)发布,这意味着它们与现有的 Web 标准并存,并且可以在浏览器中实现,而不管设备是否被认为是便携式的。发布的 API 可能会比本节中介绍的更多,但是您会从给出的示例中注意到,传感器 API 的实现有一个独特的模式。
本节中介绍的许多 API 最初是 W3C (2014)提出的通用系统信息 API 的一部分,但编辑们决定为每个 API 制定单独的规范,以加快编写标准的过程。例如,如果振动 API 和电池状态 API 都是同一规范的一部分,那么它们之间的分歧可能会延迟电池状态 API 的发布。
Note
您可以在具有适当传感器的设备和实现了标准的浏览器上尝试设备传感器事件和 API。例如,你可以在运行 Firefox 浏览器的 Google Pixel 上尝试运动和方向、光线和接近度。
电池状态
为了获得电池状态 API 的自动完成和类型检查,您需要提供一个包含两个接口的类型定义。这些接口如清单 6-37 所示。B
atteryManager
接口包含组成电池状态 API 的属性和事件。Navigator
接口扩展了 TypeScript 库中现有的接口,添加了电池属性。
interface BatteryManager {
charging: boolean;
chargingTime: number;
dischargingTime: number;
level: number;
onchargingchange: () => any;
onchargingtimechange: () => any;
ondischargingtimechange: () => any;
onlevelchange: () => any;
}
interface Navigator {
battery: BatteryManager;
mozBattery: BatteryManager;
webkitBattery: BatteryManager;
}
Listing 6-37.Type definitions for battery status
要从电池 API 获取信息,首先需要在调用电池管理器上的属性之前检测该特性的存在。清单 6-38 是一个使用电池管理器在网页上显示信息的完整例子。
电池level
以 0 到 1.0 之间的值提供,因此您可以通过将该值乘以 100 来获得充电百分比。电池信息中给出的所有时间都以秒为单位,您可以根据需要将其转换为分钟或小时。charging
标志表示电池当前是否连接到电源。
const battery: BatteryManager = (<any>navigator).battery
|| (<any>navigator).mozBattery
|| (<any>navigator).webkitBattery;
if (battery) {
const output = document.getElementById('content');
function updateBatteryStatus() {
// Gets the battery charge level
const charge = Math.floor(battery.level * 100) + '%';
// Detects whether the battery is charging
const charging = battery.charging ? ' charging' : ' discharging';
// Gets the time remaining based on charging or discharging
const timeLeft = battery.charging ?
`Time until charged (${Math.floor(battery.chargingTime / 60)} mins)` :
`Time umtil empty (${Math.floor(battery.dischargingTime / 60)} mins)`;
output.innerHTML = charge + timeLeft + charging;
}
// Update the display when plugged in or unplugged
battery.onchargingchange = updateBatteryStatus;
// Update the display when the charging time changes
battery.onchargingtimechange = updateBatteryStatus;
// Update the display when the discharging time changes
battery.ondischargingtimechange = updateBatteryStatus;
// Update the display when the battery level changes
battery.onlevelchange = updateBatteryStatus;
}
Listing 6-38.
Battery status
您可以订阅四个事件来检测电池状态的变化。你可能只对一种或几种感兴趣。例如,虽然使用这个 API 最有可能的情况是显示获得的信息,如示例所示,但是您可以使用onchargingchange
事件在设备停止充电时发出警报,或者警告用户,或者作为检测设备被盗的基本安全机制。您还可以使用电池信息来对低电量情况保持敏感——也许是通过在电池电量低于 20%时抑制您的应用。
Note
电池传感器 API 正在休假,而一些隐私问题得到解决。
接近传感器
接近传感器是一个非常简单的 API,用于确定用户是否离设备非常近。通常,传感器位于移动电话的顶部,靠近电话扬声器。当用户将手机放在耳边时,API 会检测到扬声器附近有东西。当手机移开时,该设备检测到用户不再在附近。
该传感器的主要目的是在用户打电话时隐藏屏幕并禁用触摸,然后在用户将手机从耳边移开时重新显示屏幕。尽管接近传感器的用途很简单,但你可以在你的程序中为它确定一个更有创意的用途。
proximity API 允许两种不同类型的事件:一种是用户接近事件,它提供一个属性来说明用户是否在附近;另一种是设备接近事件,它提供一个范围内的测量值。设备接近事件信息将基于具体的实现而不同。
import { addEvent } from './Listing-6-011';
interface ProximityEvent {
min: number;
max: number;
value: number;
near: boolean;
}
const output = document.getElementById('content');
function sensorChange(proximity: ProximityEvent) {
const distance =
(proximity.value ? proximity.value + ' ' : '') +
(proximity.near ? 'near' : 'far');
output.innerHTML = distance;
}
// Near or far
addEvent(window, 'userproximity', sensorChange);
// Measurement within a range
addEvent(window, 'deviceproximity', sensorChange);
Listing 6-39.Proximity events
与电池传感器不同,电池传感器为管理器提供可以随时测试的属性,邻近 API 基于userproximity
和deviceproximity
事件,它们传递包含数据的事件参数。如果传感器不可用或者设备上不支持 API,这些事件永远不会触发;否则,只要邻近状态发生变化,就会调用事件处理程序。
光敏感元件
环境光线传感器提供单一读数,该读数代表以勒克斯为单位测量的当前环境光线。勒克斯单位代表每平方米一流明,这是人眼看到的光强度的合理表示。晴朗夜晚的满月能提供高达 1 勒克斯的光。办公室照明通常在 300 到 500 勒克斯之间,而电视演播室可能使用 1000 勒克斯。阳光直射可以达到 32,000 到 100,000 勒克斯的范围。
光线传感器 API 有一个devicelight
事件,它提供一个单一的值,如清单 6-40 所示。
import { addEvent } from './Listing-6-011';
const output = document.getElementById('content');
function sensorChange(data: DeviceLightEvent) {
output.innerHTML = 'Ambient light reading: ' + data.value;
}
addEvent(window, 'devicelight', sensorChange);
Listing 6-40.Ambient light sensor
尽管本例中的devicelight
事件提供了最大的粒度级别,但是还有一个lightlevel
事件根据环境光线返回更抽象的枚举值dim
、normal
或bright
。
运动和方向
motion and orientation API 已经包含在 TypeScript 标准库中,因此不需要在现有的DeviceMotionEvent
类型之上声明额外的类型。
清单 6-41 中的示例获得了以米每秒平方为单位的加速度测量的motion
和以度为单位的rotation
。
import { addEvent } from './Listing-6-011';
const output = document.getElementById('content');
function sensorChange(event: DeviceMotionEvent) {
var motion = event.acceleration;
var rotation = event.rotationRate;
output.innerHTML = '<p>Motion :<br />' +
motion.x + '<br />' +
motion.y + '<br />' +
motion.z + '</p>' +
'<p>Rotation:<br />' +
rotation.alpha + '<br />' +
rotation.beta + '<br />' +
rotation.gamma + '</p>';
}
addEvent(window, 'devicemotion', sensorChange);
Listing 6-41.Motion and orientation
acceleration
属性被规范化以去除重力的影响。这种标准化只能在具有陀螺仪的设备上进行。在没有陀螺仪的情况下,名为accelerationIncludingGravity
的附加属性是可用的,它包括在当前面向上/下的轴上的 9.81 的附加测量(或者如果设备处于没有单个轴直接指向上/下的角度,则在多个轴之间展开)。例如,如果设备是平的,屏幕朝上,您将得到以下值:
acceleration: { x: 0, y: 0, z: 0 }
accelerationInclud
ingGravity: { x: 0, y: 0, z: 9.81 }
其他设备传感器
正如您可能已经注意到的,在前面的例子中,传感器提供单个值,您使用 API 的方式有一个独特的模式。特别是,您可以简单地通过更改sensorApiName
变量来更新清单 6-42 中的代码,使其适用于光、温度、噪声或湿度传感器 API。
import { addEvent } from './Listing-6-011';
const sensorApiName = 'devicetemperature';
const output = document.getElementById('content');
addEvent(window, sensorApiName, (data) => {
output.innerHTML = sensorApiName + ' ' + data.value;
});
Listing 6-42.The device API pattern
本例中的sensorApiName
可以更改为以下任何一个事件名称,以及遵循该实现模式的任何未来事件名称。
devicehumidity
—该值为湿度百分比。devicelight
—该值是以勒克斯为单位的环境光。devicenoise
—该值为噪声级,单位为分贝(dBA)。devicetemperature
—该值是以摄氏度为单位的温度。
传感器综述
设备传感器 API 显示了网页、web 应用和本机设备之间的界限是如何逐渐消失的。这组设备 API 的实验性质使得进展非常缓慢,特别是自从 Mozilla 放弃了他们的 Firefox OS 项目,这是一个基于 HTML 和 JavaScript 的原生操作系统。
API 中使用的模式——监听窗口对象上触发的特定事件——意味着您甚至不需要在使用它之前测试该特性。如果 API 不可用,事件将永远不会触发。
简而言之,传感器可以用来为用户提供测量数据,但只要稍加创新,它们就可以用来提供有趣的用户交互、自适应界面或创新游戏。也许你会选择根据环境光线来改变页面的主题,使用运动或旋转来控制页面元素,甚至使用光线、运动和噪声传感器的组合来记录用户的睡眠质量。
网络工作者
JavaScript 被设计成在单线程上运行事件循环,这是您通常应该采用的模型。如果遇到需要额外线程的情况,可以使用 web workers。Web workers 允许脚本在后台线程上运行,它有一个单独的全局上下文,可以使用事件与产生线程的任务通信。
要创建一个新的 worker,在后台线程上运行的代码必须包含在一个单独的 JavaScript 文件中。清单 6-43 中的代码显示了worker.ts
中的代码,这些代码将被编译成worker.js
文件,该文件将在后台线程上生成。
declare function postMessage(message: any): void;
let id = 0;
self.setInterval(() => {
id++;
var message = {
'id': id,
'message': 'Message sent at ' + Date.now()
};
postMessage(message);
}, 1000);
Listing 6-43.worker.ts
本例中的setInterval
方法不是在window
上调用,而是在self
上调用。这反映了一个事实,即 worker 在一个单独的上下文中运行,有自己的作用域。postMessage
事件是将信息从工作线程发送回主线程的机制,传递给工作线程或从工作线程传递来的任何对象都是复制的,而不是共享的。
清单 6-44 中显示了创建工人并监听消息的代码。使用包含工作者代码的 JavaScript 文件的路径来实例化工作者。workerMessageReceived
函数附属于message
事件,每当工作人员发布消息时就会被调用。
const worker = new Worker('/Listing-6-043.js');
function workerMessageReceived(event) {
const response = event.data;
console.log(response.id, response.message);
};
worker.addEventListener('message', workerMessageReceived);
Listing 6-44.Creating and using a web worker
如果您运行这个例子足够多次,您将在这个实现中遇到一个弱点:worker 开始立即在后台运行,这意味着它可能在添加消息事件处理程序之前就开始发布消息。这个问题在 JavaScript 中通常不会发生,因为在函数完成之前,主线程无法处理事件循环中的其他项目。
如果需要避免在设置工作线程时可能出现的争用情况,可以将工作线程内部的代码包装在一个函数中,并发布一条消息来告诉工作线程,您已经设置了事件侦听器,并准备好开始处理它。更新后的工人代码如清单 6-45 所示。最初的setInterval
调用被包装在一个函数中,当工作者接收到开始消息时调用该函数。
declare function postMessage(message: any): void;
let id = 0;
function start() {
self.setInterval(() => {
id++;
const message = {
'id': id,
'message': 'Message sent at ' + Date.now()
};
postMessage(message);
}, 1000);
}
self.onmessage = (event) => {
if (event.data === 'Start') {
start();
} else {
console.log(event.data);
}
}
Listing 6-45.Worker that waits for a start signal
当 worker 被创建时,它将不再运行消息传递代码,直到它接收到'Start'
消息。将开始消息传递给工作线程使用了工作线程用来与主线程通信的相同的postMessage
机制。通过在添加事件处理程序后放置开始消息,可以防止出现竞争情况。
const worker = new Worker('/Listing-6-045.js');
function workerMessageReceived(event) {
const response = event.data;
console.log(response.id, response.message);
};
worker.addEventListener('message', workerMessageReceived);
worker.postMessage('Start');
Listing 6-46.Signaling the worker to start
Web workers 提供了一种更简单的机制,用于在后台线程上处理代码,并提供了一种模式,用于在线程之间安全地传递消息。尽管很简单,但是如果您发现自己经常启动 web worker,那么您可能出于错误的原因使用了它们,特别是考虑到长时间运行的操作通常要么遵循回调模式,要么使用承诺,而不需要 web worker。
如果您发现自己正在执行一个长时间运行的流程或计算,web worker 可以允许事件循环在主线程上继续处理,而数字处理在后台进行。
打包您的程序
本节从有趣的 API 的实际例子中抽出一点时间来讨论如何打包您的 TypeScript 程序。
当您从 JavaScript 切换到 TypeScript 时,很容易将现有的打包策略转移到您的 TypeScript 程序中。经常可以看到人们切换到 TypeScript,使用名称空间来组织他们的程序,并添加一个构建步骤来将代码合并到一个文件中,并在最终程序中包含它之前将其缩小。这种策略适用于达到一定规模的程序,但是如果程序继续增长,这种打包程序的方法就不能无限扩展。这就是为什么 TypeScript 对模块加载有一流的支持。
如果您使用模块而不是名称空间来组织程序,您可以使用模块加载器在需要时获取依赖项,只加载您需要的程序部分。这种按需加载意味着,尽管您的程序可能有成千上万行代码,但您可以只加载执行当前操作所需的组件,并在需要时加载其他模块。
当您确定您的程序仍然很小时,捆绑和缩小策略可能是正确的选择,但是您仍然可以使用模块编写您的程序,并使用诸如 RequireJS optimizer 之类的工具来组合输出,而不会限制您将来的选择。
在第二章中有更多关于模块装载和包装的信息。
摘要
这一章讲述了一些多样但有趣的网络浏览器特性,从浏览器本身到许多允许你创建有趣且有创造性的应用的 API。虽然有很多关于很多特性的信息,但是你可以在以后回到这一章来刷新你的记忆。
要点
- 通过避免不必要的重流,你的程序会显得更加灵敏。
- 有多种方法可以在网页上找到元素。虽然您可以使用类型断言来更改类型,但它们都返回不同的类型。
- 在将元素添加到页面之前构建一组嵌套的元素比依次添加每个元素更有效。
- AJAX 允许对服务器的异步调用,并允许多种不同格式的数据。
- WebSockets 提供双向通信的持久连接,WebRTC 允许实时音频和视频流。
- 您可以使用会话存储、本地存储或 IndexedDB 将数据存储在本地计算机上。但是,不能保证数据会持续存在。
- 您可以在用户允许的情况下获取他们的位置,浏览器将使用最准确的可用方法来查找位置。
- 您可以访问许多传感器,它们都有相似的实现模式。
- Web Workers 在一个单独的线程中运行,消息在主线程和 worker 线程之间传递,反之亦然。
七、在服务器上运行 TypeScript
如果你觉得 Node 通过一次只运行一段代码来实现并行很奇怪,那是因为它确实如此。这是我称之为落后主义的一个例子。—吉姆·r·威尔逊
在服务器上运行 JavaScript 并不是一个新概念——Netscape Enterprise Server 早在 1994 年就提供了这个特性。目前有大量 JavaScript 的服务器端实现运行在超过六种不同的脚本引擎上。除了这些纯 JavaScript 实现之外,JavaScript 还可以在任何拥有脚本主机的平台上运行。
尽管 JavaScript 语言对于所有这些实现都是通用的,但每一种实现都将提供不同的 API、框架、模块或基本类库来执行基于浏览器的 JavaScript 程序中通常不可用的操作。服务器端实现中可用模块的范围至关重要,这就是 Node 如此成功的原因(也是它被选入本章的原因)。
Node 不仅有超过 475,000 个可用的模块,从简单的助手到整个数据库服务器,还可以通过一个简单的命令将它们添加到您的程序中,这要归功于 Node Package Manager (NPM)。这意味着您可以简单地通过在命令窗口中键入npm install MongoDB
来添加一个数据库模块,比如 MongoDB。Node 是跨平台的,提供 Windows、Mac OSX、Linux、Docker 和 SunOS 的安装程序。
为了演示如何在 TypeScript 中使用 Node,本章逐步将一个简单的应用发展成一个使用多个模块的应用。这演示了代码以及添加包和类型定义的过程。虽然示例显示了来自 Visual Studio 和 Windows 命令提示符的屏幕截图,但您可以轻松地将您所学到的一切转移到其他开发工具和操作系统,例如,OSX 或 WebStorm 上的 Sublime Text 2 和 terminal 以及 Linux 上的 Terminal。如果您希望在不同的机器上获得类似的体验,组合是多种多样的,几个集成开发环境是跨平台的(Visual Studio Code、Cloud9、Eclipse、Sublime Text 2、Vim 和 WebStorm 都运行在 Windows、OSX 和 Linux 上)。
安装节点
您可以从 NodeJS 网站下载适用于您所选平台的安装程序
https://nodejs.org/en/download/
创建新项目
示例程序将从一个完全空的项目开始。图 7-1 显示了示例项目和解决方案的开始状态,其中包含一个空的server.ts
文件。
如果您使用的是 Visual Studio 2017,可以通过使用 TypeScript 语言创建一个新的空白 Node.js 应用来复制它。如果您正在使用不同的开发环境,您可以简单地启动一个新的项目或文件夹并添加一个空的server.ts
文件。
图 7-1。
Empty TypeScript project
要对节点进行自动完成和类型检查,您需要一个描述标准节点 API 的类型定义。最快的方法是与 NPM 合作。
新公共管理理论
您可以使用 NPM 来处理生产和开发依赖性。生产依赖项是应用运行时必须存在的模块。开发依赖项包括工具和类型定义,它们只在编码时需要,在运行应用时不需要。
在添加包之前,您需要一个名为 package.json 的特殊文件,如清单 7-1 所示。该文件描述了您的程序,包括它所拥有的所有依赖项。每当您添加生产或开发依赖项时,该文件都会更新。
{
"name": "NodeApp"
}
Listing 7-1.The empty package.json file
Note
这还不是一个有效的 package.json 文件,但是有效的版本将在本章的稍后部分显示。这个超级简洁的版本将使 NPM 的例子更容易理解。
一旦有了 package.json 文件,添加依赖项唯一需要知道的就是它的名称。对于下面所有的例子,我们都使用 Express,这是一个轻量级的节点 web 框架。
安装包的第一个机制是 Visual Studio 包搜索,通过右键单击解决方案资源管理器的“npm”分支可以找到它。package explorer UI 的左侧允许您搜索软件包,并且您可以使用右侧底部的表单安装选定的软件包。标准选项会将最新版本的包添加到项目中。
图 7-2。
Install ing NPM packages via Visual Studio
您可以通过运行清单 7-2 中所示的命令来获得相同的结果。这恰好是 UI 发出的原始命令。NPM 和 NodeJS 都很容易从您喜欢的命令窗口中使用。
npm install express --save
Listing 7-2.Installing NPM packages via a command
无论您使用 UI 还是直接键入命令,install 命令中的 save 标志都会导致 package.json 文件被更新以显示依赖关系,如清单 7-3 所示。
{
"name": "NodeApp",
"dependencies": {
"express": "⁴.15.4"
}
}
Listing 7-3.
Updated package.json
这就引出了添加包的第三种方法,即直接更新 package.json 文件。如果您在 Visual Studio 中这样做,您将获得包名和版本号的自动完成;这在图 7-3 中有所展示。
图 7-3。
Installing NPM packages via package.json
对于类型定义,只有在开发时才需要依赖项,而在生产中不需要。您可以通过在 package explorer UI 中选择“development”依赖类型,或者在运行 install 命令时使用 save 标志的--save-dev
变体,将一个包标记为开发依赖。当通过 package.json 文件添加开发依赖时,它应该进入单独的devDependencies
字典。
{
"name": "NodeApp",
"dependencies": {
"express": "⁴.15.4"
},
"devDependencies": {
"@types/node": "⁸.0.26"
}
}
Listing 7-4.Updated package.json with node type definition
现在你是一名 NPM 专家,你将能够添加创建一个工作节点 web 应用所需的任何依赖项,这是本章的主题。
简单节点程序
既然项目已经建立,就可以使用一个运行 web 服务器并响应请求的简单程序来演示 Node 了。HTTP 服务器只是将所有请求传递给您提供的回调函数。没有内置的工具来处理不同的请求或路由,或者帮助格式化响应(如果您需要这些,它们在中间件中是可用的,比如 Express,这将在本章后面介绍)。
清单 7-5 显示了完整的程序,它创建了一个监听端口 8080 的 http 服务器。所有请求都被传递给requestListener
函数,该函数对所有请求给出标准的文本响应。requestListener
函数被传递给代表request
和response
的两个参数。可以从request
参数中获得信息,比如方法、请求头和请求体。您可以在response
参数的头和体中添加内容。你必须通过调用response.end()
来表明你已经完成;否则,HTTP 服务器不向客户端发送响应,让客户端等待响应,直到超时。
引用注释用于指示节点类型定义的位置。这允许import
语句引用http
模块,该模块在项目中不作为外部模块出现——http
模块将在运行时由 Node 提供。
/// <reference path="./node_modules/@types/node/index.d.ts" />
import * as http from 'http';
const portNumber = 8080;
function requestListener(request: http.ServerRequest, response: http.ServerResponse) {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.write('Response Text Here');
response.end();
}
http.createServer(requestListener).listen(portNumber);
console.log('Listening on localhost:' + portNumber);
Listing 7-5.A simple Node server
如果您将这个示例代码放在一个名为 app.ts 的文件中,那么您可以通过从包含源代码的文件夹中运行命令node app.js
来运行这个清单中的 http 服务器(注意,您在这个命令中传递的是编译后的 JavaScript 文件,而不是 TypeScript 文件)。如图 7-4 所示,你会在命令窗口中看到消息Listening on localhost:8080
。只要命令窗口保持打开,服务器就会运行。
图 7-4。
Running the program
要向服务器发出请求,请打开 web 浏览器并在地址栏中输入localhost:8080
。您应该会收到如图 7-5 所示的Response Text Here
消息。因为所有对服务器的请求都被发送到同一个requestListener
方法,所以您可以在localhost:8080
输入任何地址并接收相同的消息,例如localhost:8080/Some/Path/Here/
或localhost:8080/?some=query&string=here
。
图 7-5。
Calling the program from a browser
请求信息
能够如此容易地启动 web 服务器是很棒的,但是几乎可以肯定的是,您将希望从请求中获得信息,以提供与所请求的信息相匹配的响应。清单 7-6 展示了如何获取请求方法和关于所请求 URL 的信息,这些信息可用于路由请求或获取用于查找与请求匹配的数据的数据。
/// <reference path="./node_modules/@types/node/index.d.ts" />
import * as http from 'http';
const portNumber = 8080;
function requestListener(request: http.ServerRequest, response: http.ServerResponse) {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.write('Method: ' + request.method + '\n');
response.write('Url: ' + request.url + '\n');
response.write('Response Text Here');
response.end();
}
http.createServer(requestListener).listen(portNumber);
console.log('Listening on localhost:' + portNumber);
Listing 7-6.Getting more information from the request
在本例中,从请求中获得的信息被简单地附加到响应中,以便在发出请求时在浏览器中显示这些信息。在地址栏输入http://localhost:8080/Customers/Smith/John
后,响应如图 7-6 所示。您可以使用请求的属性来决定如何处理请求。
图 7-6。
Displaying information about the request
虽然您可以使用这些信息编写自己的框架来处理对节点服务器的请求,但是路由请求和从请求中获取信息的工作已经很好地完成了,并且可以作为一个模块与 NPM 一起安装。除非您想使用请求信息做一些不寻常的事情,否则您可能会发现,使用一个为您的程序提供框架的现有模块将会节省时间和精力,并涵盖您没有计划的场景。
下一节描述如何使用 Express 模块构建应用,Express 模块是一个轻量级的 web 框架,用于 Node,它不指定授权、持久性或模板的细节。
使用 Express 编写应用
在 Node 中处理原始请求和响应允许访问 HTTP 通信的底层细节;但大多数情况下,你不会有兴趣自己处理所有的细节。Express 模块提供了一个轻量级框架,允许您将精力集中在应用上,而不是路由和 HTTP 通信上。Express 既是一种快速入门的方式,也是一个将您的程序整合在一起的健壮框架。
要使用 Express,您需要 Express 依赖项和 Express 类型定义开发依赖项。应用的 package.json 文件的更完整版本如清单 7-7 所示,其中包括这两个依赖项。
{
"name": "node-app",
"version": "0.0.1",
"description": "NodeApp",
"main": "server.js",
"author": {
"name": "Steve Fenton"
},
"dependencies": {
"express": "4.15.4"
},
"devDependencies": {
"@types/node": "8.0.26",
"@types/express": "4.0.37"
}
}
Listing 7-7.Example package.json
package.json 文件指出,我们程序的入口点将是名为 server.js 的文件,它包含 express 依赖项,以及来自@types 组织的节点和 Express 类型定义。
简单快速程序
清单 7-8 是基于 Express 的简单节点程序的更新版本,而不是基于http
模块。虽然一般的模式是相似的,但是requestListener
是专门添加到应用根地址的 HTTP GET 方法中的。
这意味着只有对http://localhost:8080/
的请求才会调用requestListener
函数。与前面的例子不同,不匹配路由的请求将会失败,例如,对http://localhost:8080/
Customers
的请求将会收到带有消息“Cannot GET /Customers/Smith/John
”的 404 响应。
import * as express from 'express';
const portNumber = 8080;
const app = express();
app.get('/', (request, response) => {
response.send('You requested ' + request.query.firstname + ' ' + request.query.lastname);
})
app.listen(portNumber, 'localhost', () => {
console.log('Listening on localhost:' + portNumber);
});
Listing 7-8.Using Express
这个例子的正确请求应该包括firstName
和lastName
查询字符串参数。Express 将查询字符串映射到request.query
属性。http://localhost:8080/?firstname=John&lastname=Smith
的完整示例请求地址将导致响应中返回消息You requested John Smith
,如图 7-7 所示。
图 7-7。
Calling the Ex press program
多条路线
您可以在您的程序中为不同的路线提供不同的处理程序,例如,清单 7-9 中的代码为http://localhost:8080/
One/
和http://localhost:8080/Two/
分别提供了一个处理程序。Express 为您处理所有路由,并确保正确的功能处理每个请求。
import * as express from 'express';
const portNumber = 8080;
const app = express();
app.get('/', (request, response) => {
response.send('You requested ' + request.query.firstname + ' ' + request.query.lastname);
})
app.get('/One/', (request, response) => {
response.send('You got handler One');
});
app.get('/Two/', (request, response) => {
response.send('You got handler Two');
});
app.listen(portNumber, 'localhost', () => {
console.log('Listening on localhost:' + portNumber);
});
Listing 7-9.Using multiple routes
您可以像以前一样在浏览器中测试这些路由,并获得适当的响应
http://localhost:8080/
One/
- >“你得到了一号处理者”http://localhost:8080/Two/
- >“你有二号处理员”
对尚未注册的路由器 e 的请求将导致 404 not found 响应。
处理错误
通过向app.use
方法提供一个接受四个参数的函数,可以为应用提供一个通用的错误处理程序。在清单 7-10 中,handler
函数被修改为抛出一个故意的错误。使用app.use
方法设置错误处理程序,并在返回 500 响应代码之前将错误记录到控制台。
import * as express from 'express';
const portNumber = 8080;
const app = express();
app.get('/', (request, response) => {
throw new Error('Deliberate Error!');
})
app.listen(portNumber, 'localhost', () => {
console.log('Listening on localhost:' + portNumber);
});
app.use(function (error, request, response, next) {
console.error(error.message);
response.status(500).send('An error has occurred.');
});
Listing 7-10.General error handler
当您向该版本的应用发出请求时,完整的错误堆栈将显示在命令窗口中,但 web 浏览器将显示一般错误消息,如图 7-8 所示。这允许您在不公开披露任何内容的情况下,使用完整的错误信息执行日志记录操作。
图 7-8。
General error in the browser
本例中的错误处理程序是 Express 中中间件的一个很好的演示,Express 是一组组织成一个责任链的功能。每个中间件都可以处理请求和响应,允许您分离横切关注点。在实践中,这意味着您可以避免使用负责日志记录、授权和错误处理的处理程序,而是为每项职责提供单独的中间件。
您可以通过调用 app.use 来添加中间件,其中 app 是您的 Express 应用的名称。您传递的用作中间件的函数必须遵循以下两个签名之一:
- 请求处理程序
(request: Request, response: Response, next: NextFunction) => void
; - 错误请求处理程序:
(error: any, request: Request, response: Response, next: NextFunction) => void;
每个中间件被依次调用,并且必须要么使用next()
函数调用下一个中间件,要么结束响应。如果没有中间件结束响应,结果是 404 错误。
快递图书项目
现在我们已经介绍了 Node 和 Express 的基本概念,我们将使用一个小的示例应用来将它们放在一起。为了跟进,你需要安装一些免费软件和一些 NPM 软件包。该应用将包含多条路线,收集用户输入的数据,并将信息存储在数据库中。
- 来自
www.mongodb.com/download-center
的 MongoDB 社区服务器; - 清单 7-11 中所示的 NPM 包。
为了创建这个应用,您将使用来自 NPM 的一些额外的包,这将使事情变得更快更容易。安装清单 7-11 中列出 package.json 文件的依赖项。额外的包帮助解析发送回服务器的表单,与 MongoDB 数据库对话,并解析我们的视图,这些视图是使用 Pug——一种简单的 HTML 模板语言——编写的。
{
"name": "pro-typescript-book-app",
"version": "0.0.1",
"description": "An example book application",
"main": "server.js",
"types": "server.d.ts",
"author": {
"name": "Steve.Fenton"
},
"dependencies": {
"body-parser": "1.17.2",
"express": "4.15.4",
"method-override": "2.3.9",
"mongoose": "4.11.9",
"pug": "2.0.0-rc.3"
},
"devDependencies": {
"@types/body-parser": "1.16.5",
"@types/express": "4.0.37",
"@types/method-override": "0.0.30",
"@types/mongoose": "4.7.21",
"@types/node": "8.0.26",
"@types/pug": "2.0.4"
}
}
Listing 7-11.Additional dependencies
应用的主要入口点将是服务器文件,如清单 7-12 所示。这个文件包含运行整个应用所需的所有依赖项,并且有一个单一的根来捕获对主页的请求。视图引擎也被设置为使用 Pug,模板位于“views”文件夹中。
import * as express from 'express';
import * as http from 'http';
import * as path from 'path';
import * as bodyParser from 'body-parser';
import * as methodOverride from 'method-override';
import * as routes from './routes/index';
const portNumber = 8080;
const app = express();
app.set('port', portNumber);
// Configure view templates
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
http.createServer(app).listen(app.get('port'), () => {
console.log('Express server listening on port ' + app.get('port'));
});
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(methodOverride());
// routes
app.get('/', routes.index);
// static files, such as .css files
app.use(express.static('.'));
Listing 7-12.
Server.ts
代码被放在一个模块中,该模块位于一个包含所有路由处理程序的文件夹中,而不是直接将处理主页的函数放在服务器文件中。随着应用的增长,以这种方式组织代码将使每个文件保持较小且易于维护。主页路由处理程序的内容如清单 7-13 所示,它有一个单独的导出函数,用一个小数据模型呈现响应。
import * as express from 'express';
/* GET home page. */
export function index(request: express.Request, response: express.Response) {
response.render('index', { title: 'Express' });
};
Listing 7-13./routes/index.ts
因为我们已经将 Pug 设置为我们的视图引擎,所以我们需要提供一个模板文件用于主页。当我们调用response.render
时,文件名应该与清单 7-13 中使用的名称相匹配。主页的 Pug 视图如清单 7-14 所示。该模板扩展了共享布局,可用于为所有页面或页面组提供一致的 HTML 布局。主页模板在共享布局的“内容”区域插入标题、段落和锚定标记。
extends layout
block content
h1= title
p Welcome to #{title}
a(href='/book') Books
Listing 7-14.Pug template for the home page
共享布局模板如清单 7-15 所示,它是一个简单的 HTML 文档,带有标题和样式表,以及可以由子模板填充的内容块。
doctype html
html
head
title= title
link(rel='stylesheet', href='/style.css')
body
block content
Listing 7-15.Shared layout for Pug templates
一个简单样式表的 CSS 如清单 7-16 所示。这可以放在名为 style.css 的根文件夹中的一个文件中。因为我们使用app.use(express.static('.'));
设置 Express 服务器来处理静态文件,所以对样式表的请求将由静态文件中间件来处理。如果没有这个中间件,对样式表的请求将导致 404 错误,即使文件确实存在于服务器上。
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}
Listing 7-16.Style Sheet
您可以使用清单 7-17 中所示的命令运行 Express 服务器。当服务器运行时,您可以通过导航到localhost:8080
在浏览器中查看。
node server.js
Listing 7-17.Run the Express server
您应该看到如图 7-9 所示的输出,这意味着路由、路由处理器、视图模板和样式表都成功加载。如果您单击 books 链接,您将收到一条错误消息,因为该路线还没有处理程序。
图 7-9。
Home Page
到目前为止,示例演示了如何将路由处理程序分离到一个单独的模块中,如何为视图使用 Pug 模板,以及如何通过中间件启用静态文件。接下来,我们将研究连接到数据库来存储和检索数据。
添加图书路线
为了管理图书列表,应用需要支持一个/book
路径。为了支持典型的路由,您需要添加一个路由处理程序模块、一个 Pug 模板,并在您的 Express 应用中引用路由处理程序。
清单 7-18 显示了book.ts
文件的起点,它将处理对/book
地址的请求。像所有请求处理程序一样,list 函数有request
和response
参数。处理程序对结果调用response.render
方法,传入视图名称和表示要显示的数据的模型对象。
import * as express from 'express';
declare var next: (error: any) => void;
/* GET /book */
export function list(request: express.Request, response: express.Response) {
response.render('book', { 'title': 'Books', 'books': [] });
};
Listing 7-18.The routes/book.ts file
要使该处理程序工作,views 文件夹中必须有一个具有指定名称的视图。清单 7-19 显示了book.pug
模板,它将呈现请求处理器提供的数据。该模板重用了layout.pug
文件,这是应用的默认布局,并从模型对象中呈现title
。
extends layout
block content
h1= title
p Welcome to the #{title} page.
p #{message}
Listing 7-19.The views/book.pug file
要在您的应用中注册这个路由,您需要修改server.ts
文件,以添加引用book.ts
文件的导入语句,并添加路由注册。清单 7-20 显示了将/book
地址链接到包含请求处理程序的book.ts
文件所需的另外两行。
import * as book from './routes/book';
// ...
// routes
app.get('/', routes.index);
app.get('/book', book.list);
Listing 7-20.The additions to the server.ts file
当您运行您的应用并在 web 浏览器中访问/book
地址时,您应该看到一个显示消息“欢迎来到图书页面”的页面如果没有进入新页面,请检查命令窗口以查看任何错误。最常见的错误是拼写错误,例如,不小心输入了'book
s
'
作为视图名称,而它应该是'book'
。
收集数据
存储数据的第一步是提供一个允许用户输入信息的表单。清单 7-21 展示了更新后的book.pug
模板,它现在有一个接受书名和作者以及可选 ISBN 标识符的表单。
HTML 属性通过将它们附加在元素名称的括号中来添加到元素中。每个输入的type
和name
属性以这种方式添加。清单中值得注意的属性是 ISBN 输入中的pattern
属性。ISBN 不是必需的,但是如果提供了它,它必须与该属性中提供的模式相匹配。
extends layout
block content
h1= title
p Welcome to the #{title} page.
p #{message}
form(method='post')
fieldset
legend Add a Book
div
label Title *
br
input(type='text', name='book_title', required)
div
label Author *
br
input(type='text', name='author', required)
div
label ISBN
br
input(type='text', name='book_isbn', pattern='(?:(?=.{17}$)97[89] -{2}[0-9]+[ -][0-9]|97[89][0-9]{10}|(?=.{13}$)(?:[0-9]+[ -]){2}[0-9]+[ -][0-9Xx]|[0-9]{9}[0-9Xx])')
div
button Save
Listing 7-21.Adding a form to the Pug view
如果您担心必须编写像上面这样接受各种 ISBN 格式的模式,不要担心,因为这个模式和许多其他模式可以在位于 http://html5pattern.com/
的 HTML5 模式库中找到。向 input 元素添加模式属性时,将验证输入的文本是否与表达式匹配。
为了在提交表单时对其进行处理,必须将处理表单 post 的函数添加到routes
目录中的book.ts
文件中。清单 7-22 显示了带有submit
功能的更新文件。在这个阶段,该函数只是向视图提供一条消息,确认没有保存任何内容,因为还没有数据库。数据库将在下一节中添加。
import * as express from 'express';
declare var next: (error: any) => void;
/* GET /book */
export function list(request: express.Request, response: express.Response) {
response.render('book', { 'title': 'Books', 'books': [] });
};
/* POST /book */
export function submit(request: express.Request, response: express.Response) {
const newBook = new Book({
title: request.body.book_title,
author: request.body.author,
isbn: request.body.book_isbn
});
response.render('book', { title: 'Books', 'books': [newBook] });
}
Listing 7-22.Adding a handler to the book.ts file
要将表单 post 发送到 submi t 函数,必须在app.ts
文件中注册路线。清单 7-23 显示了更新的路由,其中有新的 post 路由,它将转发由book.submit
函数处理的匹配请求。
// routes
app.get('/', routes.index);
app.get('/book', book.list);
app.post('/book', book.submit);
Listing 7-23.The updated routes in the app.ts file
如果您编译并运行更新后的应用并访问/book
地址,您应该会看到允许添加书籍的表单。只有在提供了标题和作者的情况下,您才能提交表单。如果您在可选的 ISBN 输入中输入任何值,它必须是有效的格式,例如 10 位数字的0-932633-42-0
或 13 位数字的9780932633422
。
图 7-10。
Book Page
当您成功提交表单时,结果尚未保存在数据库中。开发这个应用的下一步是持久化数据,以便以后可以可靠地检索它。
安装猫鼬
在节点应用中存储数据有许多选项。您可以使用文件系统、关系数据库(如 MySQL)或 NOSQL 数据库(如 MongoDB)。在这个例子中,MongoDB 将被 Mongoose 包装,为应用提供数据访问。Mongoose 可以简化验证、查询和类型转换等操作。
在执行本节中的代码之前,您需要设置您的数据库。要为您的平台下载 MongoDB,请访问 https://www.mongodb.com/download-center
。下载是一个简单的安装程序,适用于您选择的平台。
MongoDB 将您的数据存储在文件系统上,因此您需要设置一个文件夹用于存储。默认情况下,MongoDB 会查找一个c:\data\db
目录,因此您应该在继续之前添加这个目录。如果不添加这个文件夹,当您尝试启动 MongoDB 服务器时,它会立即停止。如果愿意,您可以将数据放在不同的目录中。在启动数据库服务器时,您还需要提供 MongoDB 的路径。现在,只需添加默认目录。
要启动 MongoDB 数据库服务器,请在命令窗口中运行清单 7-24 中所示的代码。这个命令是为 MongoDB 3.4 版编写的,所以如果您有不同的版本,您将需要更改路径。
C:\Program Files\MongoDB\Server\3.4\bin\mongod.exe
Listing 7-24.Running the database server
您应该会收到一条消息,说明服务器“正在等待[端口号]上的连接”。当您从应用连接到数据库时,将需要此消息中显示的号码(通常为 27017)。如果出现任何错误,请仔细检查是否已经设置了c:\data\db
目录。
Mongoose 模块和类型定义应该已经可用了,因为它是在本章前面添加到package.json
中的。现在,您应该已经准备好开始保存数据所需的一切。下一节将演示对 Express Book 项目的更改,以存储和检索用户输入的图书。
存储数据
为了存储用户提交新书时收到的数据,必须更改routes
目录中的book.ts
文件,以调用新安装的数据库。
清单 7-25 显示了更新后的处理程序、list
处理程序显示数据库中的书籍,而submit
处理程序保存新的提交。连接数据库的代码在函数之外,在函数之间共享。下面显示了所有更改的更详细的演练。
import * as express from 'express';
import * as mongoose from 'mongoose';
declare var next: (error: any) => void;
// MongoDB typically runs on port 27017
mongoose.connect('mongodb://localhost:27017/books', { useMongoClient: true });
// Defines a book
interface Book extends mongoose.Document {
title: string;
author: string;
isbn: string;
}
// Defines the book database schema
const bookSchema = new mongoose.Schema({
title: String, author: String, isbn: String
});
const Book = mongoose.model<Book>('Book', bookSchema);
/* GET /book */
export function list(request: express.Request, response: express.Response) {
Book.find({})
.then((res) => {
response.render('book', { 'title': 'Books', 'books': res });
})
.catch((err) => {
return next(err);
});
};
/* POST /book */
export function submit(request: express.Request, response: express.Response) {
const newBook = new Book({
title: request.body.book_title,
author: request.body.author,
isbn: request.body.book_isbn
});
newBook.save()
.then((res) => {
response.redirect('/book');
})
.catch((err) => {
return next(err);
});
}
Listing 7-25.The updated routes/book.ts file
使用mongoose.connect
调用建立数据库连接。示例中的连接字符串使用端口 27017;您应该使用启动MongoDB
服务器时显示的端口号。当你的应用连接到数据库时,每个连接都会被记录到MongoDB
命令窗口。
给变量bookSchema
分配一个新的 Mongoose 模式。该模式定义了要存储在集合中的文档的形状。Mongoose 为您设置了 MongoDB 集合,并可以处理默认值和验证。使用title
、author,
和ISBN
属性设置图书的模式,这些属性都被分配了类型String
。模式的定义与 TypeScript 类型注释惊人地相似。由于语句的上下文,TypeScript 编译器足够聪明,能够意识到它们不是类型批注;因此,它不使用类型擦除将它们从编译后的输出中删除。问题中的String
类型不是支持 TypeScript 中的string
类型注释的String
接口,而是一个mongoose.Schema.Types.String
。如果你不小心使用了小写的string
类型,编译器会对你的错误给出警告。
变量Book
被赋予一个 Mongoose 创建的模型对象。这让您不必自己编写一个Book
类的实现。您可以使用这个模型在任何需要的时候创建书籍的新实例,就像您编写自己的类一样。
虽然这需要几段文字来解释,但还是有必要重温一下代码清单,以确认可以连接到数据库,并在三行代码中为图书数据设置模式。这种设置用于处理请求的两个函数。
list
函数调用 Mongoose 提供的Book.find
方法来检索记录。您可以向用于过滤结果的find
方法提供一个对象。该对象可以是图书模式的部分匹配。例如,您可以使用{ author: 'Robert C. Martin' }
来检索 Bob 叔叔的所有书籍。在本例中,空对象表示您想要集合中的所有文档。
因为查询是异步执行的,所以依赖于结果的代码放在适当的then
块中。如果您忘记将代码放在then
块中,响应将在查询完成之前发送。使用catch
模块处理错误。books
集合被添加到传递给视图的模型对象中。
Note
尽管该示例使用一个空对象来查询集合并检索所有书籍,但是随着越来越多的书籍添加到数据库中,该查询会变得越来越慢。您可以使用 skip 和 limit 查询方法对结果进行分页。
submit
函数用用户提交的数据实例化一个新的Book
对象,然后调用 Mongoose 提供的save
方法。同样,数据库调用是异步的,一旦查询得到解决,承诺就会继续。在代码清单中,当记录保存成功时,响应只是重定向到list
动作。提交后重定向请求可以防止用户通过刷新浏览器意外地重新提交相同的数据。成功提交后重定向的模式称为 Post Redirect Get 模式。
现在,路由处理程序正在将图书数据传递给视图,可以更新视图以显示数据。添加表格的附加标记如清单 7-26 所示。
table
thead
tr
th Title
th Author
th ISBN
tbody
if books
each book in books
tr
td= book.title
td= book.author
td= book.isbn
Listing 7-26.The Pug table template
Pug 模板中的each
循环重复了books
集合中每一项的嵌套输出。表格单元格使用简写语法声明(元素名后跟一个=
)。这意味着数据变量不需要像标题一样包含在通常的#{}
分隔符中。
Pug 的each
循环将处理一个空数组,而不是一个未定义的值。示例中each
循环之前的if
语句防止未定义的值到达each
循环。
现在,您拥有了一个可以保存和显示书籍的全功能应用。您可以在表单中输入一本书,如图 7-11 所示。
图 7-11。
Completed Book Form
当您点击保存按钮时,您的图书将被保存,您将看到图 7-12 中的页面。您可以随时返回到您的应用,存储的图书仍然可用。
图 7-12。
Stored Book
存储在 MongoDB 中的数据是持久的,这意味着您可以重新启动机器,数据仍然会被存储。这为您提供了一个永久的存储,除非机器完全崩溃,在这种情况下,您可能需要为您的数据考虑一个合适的备份策略。您可以在 https://docs.mongodb.com/manual/core/backups/
.
了解更多关于 MongoDB 备份方法的信息
摘要
JavaScript 对于 web 服务器来说并不陌生,并且已经获得了巨大的吸引力,这要归功于 Node 和通过 Node Package Manager 提供的数以千计的模块。随着编写更大的程序在 Node 上运行,TypeScript 提供的语言特性和工具的价值迅速增加。大量的时间可能会浪费在简单的错误上,比如将依赖于异步调用的代码放在回调函数之外,或者在打算使用String
时使用string
,而 TypeScript 可以帮助您避免这些常见的错误。
Express framework 是一种快速入门的方式,它将为使用过 Sinatra(或 Nancy in)的程序员提供一些熟悉感。网)。即使对于那些不熟悉这种实现风格的人来说,路由处理器和视图的分离也是显而易见的。与在 Node 中处理低级别的 HTTP 请求和响应相比,使用 Express 将提高您的生产率。
Mongoose 为数据库扮演了类似的角色,提供了许多快捷方式来提高您的工作效率。如果您想降低一个级别,通过直接调用 MongoDB 来存储和检索数据,自己处理模型和验证,MongoDB 并不特别棘手。
尽管本章很高兴地保留了 Express 附带的许多默认设置,但您并不局限于使用这些默认设置。用一行代码替换模板引擎和中间件是微不足道的。
要点
- JavaScript 已经在 web 服务器上运行了 20 多年。
- Node 可以在任何平台上愉快地运行。
- 您可以从 NPM 的@types 组织获得节点和许多节点模块的类型信息。
- Express 提供了一个轻量级的、灵活的应用框架,比低级别的节点 HTTP 请求和响应更容易使用。
- Mongoose 和 MongoDB 通过异步 API 提供简单的持久性。
八、异常、内存和性能
异常处理程序的主要职责是让程序员摆脱错误,让用户大吃一惊。只要你牢记这条基本原则,你就不会错得太离谱。—Verity Stob
尽管缺乏语言特性或运行时环境的吸引力,理解异常和内存管理将有助于您编写更好的 TypeScript 程序。对于使用过 C#、Java、PHP 或许多其他语言的程序员来说,JavaScript 和 TypeScript 中的异常可能看起来很熟悉,但是有一些细微但重要的区别。异常处理和内存管理这两个主题有着千丝万缕的联系,因为它们共享一个语言特性,这将在本章的后面描述。
记忆管理的主题经常被民间传说、谎言和盲目应用的最佳实践所支配。本章讨论了内存管理和垃圾收集的事实,并解释了如何进行测量来测试您的优化想法,而不是应用一个可能影响很小或没有影响的实践(甚至比原始代码执行得更差)。这将简单地引出性能的主题。
例外
异常用于指示程序或模块无法继续处理。就其本质而言,它们只应在真正特殊的情况下提出。因此得名!通常,异常用于指示程序的状态是无效的,或者继续处理是不安全的。
虽然每当例程将不合意的值作为参数传递时就开始发出异常可能很诱人,但处理您可以预料到的输入而不引发异常通常会更好。
当您的程序遇到异常时,它将显示在控制台中,除非用代码进行处理。控制台允许程序员编写消息,它会自动记录运行程序时发生的任何异常。
您可以在所有现代 web 浏览器中检查控制台的异常。快捷键因浏览器而异,因平台而异,但如果CTRL + SHIFT + I
在您的 Windows 或 Linux 机器上无法工作,或者CMD + OPT + I
在您的 Mac 上无法工作,您可以尝试 F12 键,或者在浏览器菜单中找到列在“开发人员工具,浏览器控制台”或类似名称下的工具。对于 Node,错误和警告输出将出现在用于运行 HTTP 服务器的控制台窗口中。
抛出异常
要在 TypeScript 程序中引发异常,可以使用throw
关键字。尽管您可以对任何对象使用这个关键字,但是最好提供一个包含错误消息的字符串,或者包装错误消息的Error
对象的实例。
清单 8-1 显示了一个典型的异常被抛出以防止一个不可接受的输入值。当用数字调用errorsOnThree
函数时,它返回数字,除非用数字 3 调用,在这种情况下会引发异常。
function errorsOnThree(input: number) {
if (input === 3) {
throw new Error('Three is not allowed');
}
return input;
}
const result = errorsOnThree(3);
Listing 8-1.Using the throw keyword
本例中的一般Error
类型可以替换为自定义异常。您可以使用实现清单 8-2 中所示的Error
接口的类来创建一个定制异常。Error
接口确保你的类有一个name
和message
属性。
清单 8-2 中的toString
方法不是Error
接口所必需的,但是在很多情况下被用来获得错误的字符串表示。如果没有这个方法,来自Object
的toString
的默认实现将被调用,它将把[object Object]
写到控制台。通过将toString
方法添加到ApplicationError
类中,您可以确保当异常被抛出并被记录时显示一条适当的消息。
class ApplicationError implements Error {
public name = 'ApplicationError';
constructor(public message: string) {
if (typeof console !== 'undefined') {
console.log(`Creating ${this.name} "${message}"`);
}
}
toString() {
return `${this.name}: {this.message}`;
}
}
Listing 8-2.Custom error
您可以在throw
语句中使用自定义异常来对已经发生的错误进行分类。一种常见的异常模式是创建一个通用的ApplicationError
类,并从它继承来创建更多特定种类的错误。然后,任何处理异常的代码都能够根据抛出的错误类型采取不同的操作,这在后面的异常处理一节中有演示。
清单 8-3 显示了一个继承自ApplicationError
类的特定的InputError
类。errorsOnThree
函数使用InputError
异常类型来突出显示错误是对错误输入数据的响应。
class ApplicationError implements Error {
public name = 'ApplicationError';
constructor(public message: string) {
if (typeof console !== 'undefined') {
console.log(`Creating ${this.name} "${message}"`);
}
}
toString() {
return `${this.name}: {this.message}`;
}
}
class InputError extends ApplicationError {
}
function errorsOnThree(input: number) {
if (input === 3) {
throw new InputError('Three is not allowed');
}
return input;
}
Listing 8-3.Using inheritance to create special exception types
例子中的InputError
只是简单的扩展了ApplicationError
;它不需要实现任何属性或方法,因为它只是提供一类在程序中使用的异常。您可以创建异常类来扩展ApplicationError
,或者进一步专门化ApplicationError
的子类。
Note
你应该把本机类型视为神圣的,永远不要抛出这种类型的异常。通过创建自定义异常作为ApplicationError
类的子类,您可以确保Error
类型是为在真正的异常情况下在您的代码之外使用而保留的。
异常处理
当抛出异常时,除非异常得到处理,否则程序将被终止。要处理异常,可以使用 try-catch 块、try-finally 块,甚至 try-catch-finally 块。在任何一种情况下,可能导致引发异常的代码都被包装在 try 块中。
清单 8-4 显示了一个 try-catch 块,它处理前面部分中来自errorsOnThree
函数的错误。由catch
块接受的参数代表抛出的对象,例如,Error
实例或自定义ApplicationError
对象,这取决于您在throw
语句中使用的是哪一个。
try {
const result = errorsOnThree(3);
} catch (err) {
console.log('Error caught, no action taken');
}
Listing 8-4.Unconditional catch block
err
参数作用于catch
块,使其等同于用let
关键字声明的变量,而不是用var
关键字,如第四章所述。
在支持 try-catch 块的语言中,允许捕获特定的异常类型是很常见的。这使得catch
块只适用于特定类型的异常,对于其他类型的异常,就像没有 try-catch 块一样。建议使用这种技术,以确保您只处理您知道可以恢复的异常,留下真正意外的异常来终止程序,并防止状态的进一步恶化。
目前还没有符合标准的方法来有条件地捕捉异常,这意味着要么全部捕捉,要么一个都不捕捉。如果您只想处理特定类型的异常,您可以在catch
语句中检查类型,并重新抛出任何与类型不匹配的错误。
清单 8-5 显示了一个异常处理例程,它处理ApplicationError
自定义异常,但会抛出任何其他类型的异常。在 if 语句中,err
变量的类型被缩小为ApplicationError
类型。
try {
const result = errorsOnThree(3);
} catch (err) {
if (err instanceof ApplicationError) {
console.log('Error caught, no action taken');
}
throw err;
}
Listing 8-5.Checking the type of error
Note
通过只处理自定义异常,可以确保只处理已知可以恢复的异常类型。如果您使用默认的catch
块而没有instanceof
检查,那么您就要对程序中可能出现的每种类型的异常负责。
这个例子将允许 catch 块处理一个ApplicationError
,或者一个ApplicationError
的子类,比如本章前面描述的InputError
。为了说明在类层次结构的不同级别处理异常的效果,图 8-1 显示了一个更复杂的层次结构,它扩展了ApplicationError
和Input
类。
图 8-1。
Error class hierar chy
当您选择处理InputError
类异常时,您将处理如图 8-2 所示的四种异常:InputError
、BelowMinError
、AboveMaxError
和InvalidLengthError
。所有其他异常都将在调用堆栈中向上传递,就像它们未被处理一样。
图 8-2。
Handling InputError exceptions
如果您要处理App
licationError
类别的异常,那么您将处理如图 8-3 所示的层次结构中的所有七个自定义异常。
图 8-3。
Handling ApplicationError exceptions
一般来说,程序越深入,处理的异常就应该越具体。如果您在低级代码附近工作,您将处理非常特殊类型的异常。当您在更接近用户界面的地方工作时,您会处理更一般的异常。
随着对性能的讨论,异常很快会再次出现,因为在程序中创建和处理异常会产生性能成本。尽管如此,如果您只是用它们来表示例程无法继续,您就不应该担心它们的运行时开销。
记忆
当你用高级语言如 TypeScript 或 JavaScript 编写程序时,你将从自动内存管理中获益。您创建的所有变量和对象都将被管理,因此您永远不会超出边界,也不会处理悬空指针或损坏的变量。事实上,您可能遇到的所有可管理的内存问题都已经为您解决了。但是,有些内存安全类别不能自动处理,例如内存不足错误,它指示系统资源已经耗尽,无法继续处理。
本节涵盖了您可能会遇到的问题类型以及避免这些问题需要了解的内容。
释放资源
在 TypeScript 中,您不太可能遇到非托管资源。大多数 API 遵循异步模式,接受操作完成时将调用的方法参数。现代 API 将通过一个承诺来公开这一点。因此,您永远不会在程序中保存对非托管资源的直接引用。例如,如果您想使用 proximity API,它检测物体何时靠近传感器,您可以使用清单 8-6 中的代码。
const sensorChange = function (reading) {
const proximity = reading.near
? 'Near'
: 'Far';
alert(proximity);
}
window.addEventListener('userproximity', sensorChange, true);
Listing 8-6.Asynchronous pattern
异步模式意味着,尽管您可以从近程传感器获得信息,但您的程序从不负责资源或通信通道。如果您碰巧遇到这样的情况,您确实持有对必须管理的资源的引用,您应该使用 try-finally 块来确保资源被释放,即使发生错误也是如此。
清单 8-7 中的示例假设可以直接使用接近传感器来获取读数。
const sensorChange = function (reading) {
var proximity = reading.near ?
'Near' : 'Far';
alert(proximity);
}
const readProximity = function () {
const sensor = new ProximitySensor();
try {
sensor.open();
const reading = sensor.read();
sensorChange(reading);
} finally {
sensor.close();
}
}
window.setInterval(readProximity, 500);
Listing 8-7.Imaginary unmanaged proximity sensor
finally
块将确保传感器的close
方法被调用,该方法执行清理并释放任何资源。即使调用read
方法或sensorChange
函数时出现错误,finally
块也会执行。
清单 8-8 中显示了类似 promise 接口的等价示例。在处理承诺时,通常要么执行“then”块,要么在出现错误时执行“catch”块。在所有情况下都会调用 finally 块。
const sensorChange = function (reading) {
var proximity = reading.near ?
'Near' : 'Far';
alert(proximity);
}
const readProximity = function () {
const sensor = new ProximitySensor();
sensor.open()
.then(() => {
return sensor.read();
})
.then((reading) => {
sensorChange(reading);
})
.finally(() => {
sensor.close();
});
}
window.setInterval(readProximity, 500);
Listing 8-8.Umanaged proximity sensor with promise-like interface
在前两节中,我用“catch”介绍了异常处理,用“finally”介绍了资源管理在所有情况下,您都可以将两者结合起来执行异常和内存管理。
碎片帐集
当不再需要内存时,需要将其释放,以便分配给程序中的其他对象。用于确定是否可以释放内存的过程称为垃圾收集。根据运行时环境的不同,您会遇到几种垃圾收集方式。
旧的 web 浏览器可能使用引用计数垃圾收集器,当对一个对象的引用数达到零时释放内存。这在表 8-1 中进行了说明。这是一种非常快速的垃圾收集方式,因为引用计数一达到零就可以释放内存。但是,如果在两个或多个对象之间创建了循环引用,这些对象都不会被垃圾回收,因为它们的计数永远不会达到零。
现代 web 浏览器用标记和清除算法解决了这个问题,该算法检测所有从根可到达的对象,并对不能到达的对象进行垃圾收集。尽管这种垃圾收集方式可能需要更长的时间,但它不太可能导致内存泄漏。为了防止浏览器 UI 冻结,一些 JavaScript 引擎将垃圾收集偷偷放入空闲时间,这意味着它对浏览体验的影响较小。
表 8-1。
Reference counting garbage collection
| 目标 | 引用计数 | 内存取消分配 | | --- | --- | --- | | `Object A` | one | 不 | | `Object B` | one | 不 | | `Object C` | one | 不 | | `Object D` | one | 不 | | `Object E` | Zero | 是 |表 8-1 中的相同对象如图 8-4 所示。使用引用计数算法,对象 A 和对象 B 都保留在内存中,因为它们相互引用。这些循环引用是旧浏览器中内存泄漏的来源,但这个问题通过标记-清除算法得到了解决。对象 A 和对象 B 之间的循环引用不足以使对象在垃圾收集中幸存下来,因为只剩下可从根访问的对象。
图 8-4。
Mark and sweep
大多数现代垃圾收集器通过几代来提升对象,最频繁和最有效的收集是对短命对象进行的。随着对象生存时间的延长,它们通常被检查的频率会降低,收集的速度也会变慢。完整的垃圾收集还可以包括压缩步骤,以优化内存使用。
使用标记-清除垃圾收集算法意味着您很少需要担心 TypeScript 程序中的垃圾收集或内存泄漏。
表演
毫无疑问,追求效率会导致滥用。程序员浪费大量的时间去思考或担心他们程序中非关键部分的速度,当考虑到调试和维护时,这些提高效率的尝试实际上有很大的负面影响。我们应该忘记小的效率,比如说 97%的时候:过早的优化是万恶之源。然而,我们不应该错过这关键的 3%的机会。—唐纳德·克努特
这不是 Donald Knuth(1974 年《计算调查》中的结构化编程与 go to 语句)第一次被引用关于性能和优化,当然也不会是最后一次。他的话,至少在这方面,经受住了时间的考验(尽管它们来自一篇为后藤言论辩护的论文——随着时间的推移,这种情绪多少有些下降)。
如果性能问题出现在可测量的性能问题之前,您应该避免优化。有许多文章声称使用局部变量将比全局变量快,您应该避免闭包,因为它们很慢,或者对象属性比变量慢。虽然这些通常是正确的,但是把它们当作设计规则会导致糟糕的程序设计。
优化的黄金法则是,您应该衡量两种或更多种潜在设计之间的差异,并确定性能提升是否值得您为获得它们而必须做出的任何设计权衡。
Note
对于您的 TypeScript 程序,测量执行时间需要在多个平台上运行测试。否则,您可能会在一个浏览器中变得更快,但在另一个浏览器中变得更慢。
清单 8-9 中的代码将用于演示一个简单的性能测试。将测试轻量级CommunicationLines
类。该类包含一个方法,该方法接受一个teamSize
,并使用著名的 n(n–1)/2 算法计算团队成员之间的通信行数。名为testCommunicationLines
的函数实例化了该类,并成功测试了团队规模为 4 人和 10 人的两个案例,这两个案例分别有 6 条和 45 条通信线路。
class CommunicationLines {
calculate(teamSize: number) {
return (teamSize * (teamSize - 1)) / 2
}
}
function testCommunicationLines() {
const communicationLines = new CommunicationLines();
let result = communicationLines.calculate(4);
if (result !== 6) {
throw new Error('Test failed for team size of 4.');
}
result = communicationLines.calculate(10);
if (result !== 45) {
throw new Error('Test failed for team size of 10.');
}
}
testCommunicationLines();
Listing 8-9.Calculating lines of communication
清单 8-10 中的Performance
类在一个方法中包装了一个回调函数,该方法使用第四章中讨论的高保真定时器使用performance.now
方法来为操作计时。为了得到一个公平的度量,默认情况下,Performance
类运行代码 10,000 次,尽管这个数字可以在调用run
方法时被覆盖。
Performance
类的输出包括执行代码 10,000 次的总时间以及每次迭代的平均时间。
export class Performance {
constructor(private func: Function, private iterations: number) {
}
private runTest() {
if (!performance) {
throw new Error('The performance.now() standard is not supported in this runtime.');
}
const errors: number[] = [];
const testStart = performance.now();
for (let i = 0; i < this.iterations; i++) {
try {
this.func();
} catch (err) {
// Limit the number of errors logged
if (errors.length < 10) {
errors.push(i);
}
}
}
const testTime = performance.now() - testStart;
return {
errors: errors,
totalRunTime: testTime,
iterationAverageTime: (testTime / this.iterations)
};
}
static run(func: Function, iterations = 10000) {
const tester = new Performance(func, iterations);
return tester.runTest();
}
}
Listing 8-10.Performance.ts runner
要使用Performance
类来度量程序,必须导入代码,并通过将函数传递给Performance
类的run
方法来替换对testCommunicationLines
函数的调用,如清单 8-11 所示。
import { Performance } from './Listing-8-010';
class CommunicationLines {
calculate(teamSize: number) {
return (teamSize * (teamSize - 1)) / 2
}
}
function testCommunicationLines() {
const communicationLines = new CommunicationLines();
let result = communicationLines.calculate(4);
if (result !== 6) {
throw new Error('Test failed for team size of 4.');
}
result = communicationLines.calculate(10);
if (result !== 45) {
throw new Error('Test failed for team size of 10.');
}
}
const result = Performance.run(testCommunicationLines);
console.log(result.totalRunTime + ' ms');
Listing 8-11.Running the performance test
此代码的结果是控制台记录了 2.73 毫秒的总运行时间。这意味着 10,000 次迭代(对通信线路算法的 20,000 次调用)的整个运行时间不到 3 ms。在大多数情况下,这样的结果很好地表明您在错误的地方寻找优化机会。
通过调整清单 8-12 中所示的代码,有可能得到非常不同的结果。对代码所做的唯一更改是在 7 条通信线路中检查对团队规模为 4 的communicationLines.calculate
的调用结果。该测试将失败,并将引发异常。
import { Performance } from './Listing-8-010';
class CommunicationLines {
calculate(teamSize: number) {
return (teamSize * (teamSize - 1)) / 2
}
}
function testCommunicationLines() {
const communicationLines = new CommunicationLines();
let result = communicationLines.calculate(4);
if (result !== 7) {
throw new Error('Test failed for team size of 4.');
}
result = communicationLines.calculate(10);
if (result !== 45) {
throw new Error('Test failed for team size of 10.');
}
}
const result = Performance.run(testCommunicationLines);
console.log(result.totalRunTime + ' ms');
Listing 8-12.Running the performance test with exceptions
运行带有失败测试的代码以及异常的创建和处理导致总运行时间为 214.45 ms,比第一次测试慢 78 倍。可以使用这些数据来指导您的设计决策。您可能想要多次重复测试,或者尝试不同的迭代大小,以确保获得一致的结果。
下面是使用清单 8-10 中的Performance
类收集的一些数字,以证明本节开始时关于优化的声明。使用一个简单但有限的测试,每次迭代的基线时间为 0.74 毫秒,结果如下(其中数字越大表示执行时间越慢):
- 全局变量:0.80 毫秒(每次迭代慢 0.06 毫秒)
- 闭包:1.13 毫秒(每次迭代慢 0.39 毫秒)
- 属性:1.48 毫秒(每次迭代慢 0.74 毫秒)
超过 10,000 次执行后,您可以看到执行时间上的微小差异,但重要的是要记住,由于对象复杂性、嵌套深度、创建的对象数量以及许多其他因素的差异,您的程序将返回不同的结果。在进行任何优化之前,请确保您已经进行了测量,这样您就可以比较任何更改前后的性能,以确定它们是否产生了积极的影响。
摘要
本章涵盖了三个重要的主题,它们可能是任何用 TypeScript 编写的大型应用的基础。在大多数情况下,这三个领域很可能是跨领域的关注点,在您编写大量需要更改的代码之前,可能更容易考虑这些问题。
在程序中使用异常来处理真正的异常状态可以防止程序数据的进一步损坏。您应该创建自定义异常来帮助管理不同种类的错误,并测试您的catch
块中的类型,以便只处理您知道可以恢复的错误。
现代运行时都使用可靠的标记-清除算法来处理内存,这种算法不会像早期的引用计数垃圾收集器那样遭受循环引用内存泄漏。人们普遍认为程序员在编码时不需要考虑垃圾收集,但是如果您可以测量性能问题并发现垃圾收集是问题所在,您可能会决定通过创建更少的对象来帮助垃圾收集器进行管理。
无论何时进行优化,您都应该首先测量程序的性能,以证明在进行更改时您关于优化的假设是否正确。您应该在多个环境中测量您的变化,以确保您在所有环境中都提高了速度。
要点
- 您可以对任何对象使用
throw
关键字,但是最好使用自定义错误的子类。 - 您可以用 try-catch-finally 块处理异常,其中您必须指定一个
catch
或finally
块,或者两个都指定。 - 处理承诺时,可以使用 catch 块、finally 块,或者两者都用。
- 您不能可靠地只捕捉自定义异常,但是您可以在
catch
块中测试异常类型。 - 您遇到的大多数 API 将遵循异步或类似 promise 的模式,但是如果您发现必须管理资源,请使用 try-finally 块进行清理。
- 说到性能,您需要前后测量来备份您以优化的名义更改的任何代码。
九、使用 JavaScript 库
我并不是说使用现有的软件或库不好。我的意思是,总是要在一方面最小化工作和另一方面最小化冗余代码之间进行权衡。我的意思是,当您需要的现有库中的特性很少时(比如说少于 20%),您应该考虑编写自己的代码。也许永远背负额外的 80%是不值得的。—利·韦鲁
在编写框架、工具包、有用的函数和有用的代码片段方面,JavaScript 社区是最繁忙的社区之一。如果您搜索任何类型的框架或工具包,您可能会找到大量选项。事实上,选项的数量既是一件好事也是一件坏事,尽管你可以毫不费力地找到行为驱动的测试框架、单元测试框架、模型视图控制器(MVC)框架、模型视图视图模型(MVVM)框架、网络工具包、浏览器填充等等。从无数选项中选择一个来使用并不是一件容易的事情。
一旦你权衡了你的选择,你就可以开始在你的 TypeScript 程序中使用这个框架了。在运行时,你的程序和框架都是普通的 JavaScript,但是在设计时和编译时,你将把你的类型脚本代码和普通的 JavaScript 库混合在一起。因为 TypeScript 编译器不知道 JavaScript 文件中提供的操作,所以您需要以类型定义的形式提供提示,以获得与 TypeScript 库相同级别的工具支持。
编译器使用类型定义检查程序,语言服务使用类型定义在开发工具中提供自动完成功能。所有的类型定义都被编译器删除了,这意味着它们不会给你的产品代码增加任何负担。本章包括一个示例应用,演示当您需要在 TypeScript 程序中包含第三方 JavaScript 代码时,如何为其创建类型定义。
为了说明类型定义对现有代码的好处,让我们来看看在普通 JavaScript 中使用 jQuery 的典型开发工作流程。
- 键入一个选择器和方法,如
$('#elem').on(
- 在 jQuery 文档中搜索
on
方法的签名 - 在代码和文档之间切换,以完成代码行
这是 jQuery 中除了最常见的操作之外的所有操作的典型表示,我很大方地没有提到任何复制和粘贴示例的操作(我们都做过)。如果我们的开发人员工作流程甚至包括在文档和代码之间切换,那么一定有更好的方法。类型定义是更好的方法。
通过包含 jQuery 的类型定义,您将获得 jQuery 成员的智能自动完成,从而节省了编辑器和文档之间的切换。GitHub 上有一个官方的类型定义库(明确类型化)。所有这些定义都可以从 NPM 的类型组织中安装,如图 9-1 所示,或者通过运行清单 9-1 中的命令来安装。
图 9-1。
Adding the jQuery t ype definition
请注意,在这两种情况下,类型定义都保存为开发依赖项,因为它们在运行时是不需要的。
npm install @types/jquery --save-dev
Listing 9-1.NPM install command for type definitions
但是,在我们通过检索现有的类型定义走得太远之前,让我们从头开始,涵盖可能没有可用定义的场景。这意味着你可以处理你自己的 JavaScript 代码,或者还没有社区贡献的利基库,就像流行的框架一样容易。
创建类型定义
为了说明类型定义的创建,本章使用 Knockout 作为 JavaScript 库的例子。Knockout 是一个 MVVM 框架,它通过将模型映射到视图来简化动态用户界面,并在发生变化时保持两者同步。尽管 Knockout 用于演示从头创建类型定义的过程,但这种技术可以用来以 TypeScript 能够理解的方式描述任何 JavaScript 代码,甚至是您自己的遗留库。
当然,如果你要在你的程序中添加一个流行的库,比如 Knockout,很可能有人已经开始创建类型定义了。因此,在你花时间做一个你自己的之前,检查一下明确类型项目的清单或者通过 NPM(在那里你会找到@types/knockout)。
如果你正在使用一个没有列出的开源库,在你创建了一个类型定义之后,你可以把它提交给明确类型化项目,以便将来帮助其他程序员。
图 9-2 中显示了一个正在运行的应用示例。该应用允许您预订座位并从一系列食物选项中进行选择。
图 9-2。
Knockout applicat ion
使用挖空创建 TypeScript 应用
本章中的应用允许乘客在航空公司预订座位和机上餐食。该应用由一个 HTML 页面和一个包含将数据绑定到视图的剔除代码的app.ts
文件组成。清单 9-2 中显示的 HTML 页面提供了应用的视图,来自于
这个例子中有趣的部分是 Knockout 用来将视图模型绑定到 HTML 页面的data-bind
属性。每个data-bind
属性都有一个表达式,描述数据应该绑定到元素的什么位置,例如value
属性或内部text
,以及应该显示哪些数据。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Knockout App</title>
<link rel="stylesheet" href="app.css" type="text/css" />
</head>
<body>
<h1>Your seat reservations (<span data-bind="text: seats().length"></span>)</h1>
<table>
<thead>
<tr>
<th>Passenger name</th>
<th>Meal</th>
<th>Surcharge</th>
<th></th>
</tr>
</thead>
<tbody data-bind="foreach: seats">
<tr>
<td><input data-bind="value: name" /></td>
<td><select data-bind="options: $root.availableMeals, value: meal, optionsText: 'mealName'"></select></td>
<td data-bind="text: formattedPrice"></td>
<td><a href="#" data-bind="click: $root.removeSeat">Remove</a></td>
</tr>
</tbody>
</table>
<button data-bind="click: addSeat, enable: seats().length < 5">Reserve another seat</button>
<h2 data-bind="visible: totalSurcharge() > 0">
Total surcharge: $<span data-bind="text: totalSurcharge().toFixed(2)"></span>
</h2>
<script src="knockout.js"></script>
<script src="app.js"></script>
</body>
</html>
Listing 9-2.The HTML page
app.ts
文件包含使用 Knockout 将数据绑定到视图的代码,如清单 9-3 所示。在本节中,该文件不会被更改,但它将用于排除类型定义,这些定义是克服编译器错误并为剔除提供高质量的自动完成和类型检查所必需的。
// Class to represent a row in the seat reservations grid
function SeatReservation(name, initialMeal) {
var self = this;
self.name = name;
self.meal = ko.observable(initialMeal);
self.formattedPrice = ko.computed(function () {
var price = self.meal().price;
return price ? "$" + price.toFixed(2) : "None";
});
}
// Overall viewmodel for this screen, along with initial state
function ReservationsViewModel() {
var self = this;
// Non-editable catalog data - would come from the server
self.availableMeals = [
{ mealName: "Standard (sandwich)", price: 0 },
{ mealName: "Premium (lobster)", price: 34.95 },
{ mealName: "Ultimate (whole zebra)", price: 290 }
];
// Editable data
self.seats = ko.observableArray([
new SeatReservation("Steve", self.availableMeals[0]),
new SeatReservation("Bert", self.availableMeals[0])
]);
// Computed data
self.totalSurcharge = ko.computed(function () {
var total = 0;
for (var i = 0; i < self.seats().length; i++)
total += self.seats()[i].meal().price;
return total;
});
// Operations
self.addSeat = function () {
self.seats.push(new SeatReservation("", self.availableMeals[0]));
}
self.removeSeat = function (seat) { self.seats.remove(seat) }
}
ko.applyBindings(new ReservationsViewModel(), document.body);
Listing 9-3.The program in app.ts
如果您将这些文件放入您的开发环境中,由于 Knockout 的ko
变量未知,您将从 TypeScript 编译器收到许多错误。这些错误的一个例子如图 9-3 所示。
图 9-3。
The compiler errors
让编译器静音
如果您只是对让编译器沉默感兴趣,您只需要提供一个快速提示,告诉编译器您将对使用ko
变量导致所有错误的所有代码负责。提供这个提示的类型定义如清单 9-4 所示。
类型定义通常放在名为knockout.d.ts
的文件中,并在app.ts
中使用引用注释或导入语句进行引用。
declare var ko: any;
Listing 9-4.The quick type definition fix
当你使用这种类型定义时,你拒绝了编译器检查你的程序的提议,你将不会得到自动完成。尽管这是一个简单的快速解决方案,但是您可能想要编写一个更全面的类型定义。
图 9-4。
The first iteration
图 9-4 显示编辑器中的类型信息还没有用,但是所有的错误都消失了。
迭代改进类型定义
编写类型定义的一个好处是,您可以以很小的增量编写它们。这意味着您可以决定在类型定义上投入多少努力,以换取每次增量提供的类型检查和自动完成的好处。
清单 9-5 展示了敲除类型定义的一个小的增量改进。Knockout
接口为应用中使用的所有一级属性提供类型信息:applyBindings
、computed
、observable
和observableArray
。没有给出这四个属性的具体细节;它们被简单地指定为any
类型。
声明的ko
变量被更新以使用新的Knockout
接口,而不是用于使编译器静默的any
类型。
interface Knockout {
applyBindings: any;
computed: any;
observable: any;
observableArray: any;
}
declare var ko: Knockout;
Listing 9-5.First-level type definition
尽管这个更新的定义很简单,但它可以防止许多常见的错误,否则在应用中注意到不正确的行为之前,这些错误是不会被发现的。清单 9-6 显示了编译器根据这个一级类型定义捕捉到的两个错误示例。
// Spelling error caught by the compiler
self.meal = ko.observabel(initialMeal);
// Non-existent method caught by compiler
ko.apply(new ReservationsViewModel(), document.body);
Listing 9-6.
Compiler errors
for incorrect code
在应该使用observable
的地方拼错了observabel
,在应该使用applyBindings
的地方拼错了不存在的apply
调用,都会导致编译器错误。这是编译器所能做到的最大限度,因为在接口中只指定了名称,而没有指定方法签名。
图 9-5 显示了带有更新定义的改进的自动完成功能。虽然列出了成员,但尚未提供它们的类型信息。
图 9-5。
The second iteration
为了增加类型定义的细节,值得参考库的官方文档。通过在创建定义时引用它一次,您可以省去每次编写代码时都要做的工作。在applyBindings
的情况下,文档说明该方法可以接受以下一个或两个参数:
viewModel
—您希望与其激活的声明性绑定一起使用的视图模型对象。rootNode
(可选)—要在其中搜索数据绑定属性的文档部分。
换句话说,viewModel
是一个对象,必须被提供,而rootNode
是一个HTMLElement
,是可选的。清单 9-7 中显示了更新后的Knockout
界面,其中包含了额外的类型信息。
interface Knockout {
applyBindings(viewModel: {}, rootNode?: HTMLElement): void;
computed: any;
observable: any;
observableArray: any;
}
declare var ko: Knockout;
Listing 9-7.’applyBindings’ definition
Note
即使您还不知道库中函数或对象的确切签名,将类型限制为一般的Function
或Object
类型将防止许多可能的错误,例如简单类型的传递。
这个更新的类型定义提供了更全面的类型检查,确保至少有一个参数被传递给applyBindings
,并且所有传递的参数都是正确的类型。它还允许开发工具提供有用的类型提示和自动完成,如图 9-6 所示。
图 9-6。
Autocompletion for th e applyBindings
method
另一种扩展类型信息的技术是提供一个签名,您可以从自己对库的使用中推断出这个签名。应用中的两个实例ko.computed
都被传递了一个执行计算的函数。您可以更新类型定义来表明computed
方法期望提供一个函数,如清单 9-8 所示。
如果求值器的返回类型是固定的,可以在括号内的类型定义中指定。同样,如果需要使用从computed
方法返回的值,可以更新括号外的返回类型,以提供返回类型的详细信息。
interface Knockout {
applyBindings(viewModel: any, rootNode?: any): void;
computed: (evaluator: () => any) => any;
observable: any;
observableArray: any;
}
declare var ko: Knockout;
Listing 9-8.‘computed’ definition
您可以使用官方文档或通过基于示例推断类型来继续扩展定义,以创建清单 9-9 中所示的Knockout
接口。这既有一级类型信息,也有二级类型信息。
interface Knockout {
applyBindings(viewModel: {}, rootNode?: HTMLElement): void;
computed: (evaluator: () => any) => any;
observable: (value: any) => any;
observableArray: (value: any[]) => any;
}
declare var ko: Knockout;
Listing 9-9.Complete second-level definition
要完成类型定义,您需要重复将每次使用的any
转换成更详细的类型的过程,直到您不再依赖于隐藏动态类型的细节。每当一个定义扩展到不可管理的大小时,您可以使用一个额外的接口来划分它,以帮助限制定义中任何特定部分的复杂性。
清单 9-10 通过将applyBindings
方法的细节转移到一个单独的KnockoutApplyBindings
接口中,展示了“分而治之”的技术。然后在Knockout
接口中使用它将类型信息绑定到方法。
interface KnockoutApplyBindings {
(viewModel: {}, rootNode?: HTMLElement): void;
}
interface Knockout {
applyBindings: KnockoutApplyBindings;
computed: (evaluator: () => any) => any;
observable: (value: any) => any;
observableArray: (value: any[]) => any;
}
declare var ko: Knockout;
Listing 9-10.Dividing type definitions into interfaces
尽管这个 Knockout 的类型定义还远未完成,但它涵盖了运行示例应用所需的所有 Knockout 特性。您可以根据需要添加更多类型的信息,只有在获得合理回报时才进行投资。
转换 JavaScript 应用
如果您有一个现有的 JavaScript 应用,并且正在切换到 TypeScript,那么有三种潜在的策略来处理您的旧代码:
- 编写类型定义
- 将您的 JavaScript 文件添加到编译中
- 将 JavaScript 代码添加到 TypeScript 文件中
为您自己的 JavaScript 代码编写类型定义是最不理想的解决方案,因为您将重复或浪费大量的精力。允许编译器包含您的 JavaScript 代码允许您开始将代码迁移到 TypeScript 的过程;并将它移动到 TypeScript 文件中就完成了这个过程。
为了说明迁移 JavaScript 代码的过程,我们将使用清单 9-11 中所示的非常基本的 JavaScript 文件。这个文件包含一些我们想在新的 TypeScript 程序中使用的任意的旧处理代码。
function old_process(name) {
return name + ' processed';
}
Listing 9-11.An old JavaScript file
调用旧 JavaScript 的新代码如清单 9-12 所示。
class NewProcessor {
process(name: string) {
return old_process(name);
}
}
Listing 9-12.The new code
当您尝试编译 TypeScript 文件时,将会看到错误“mynewlib.ts(3,16):错误 TS2304:找不到名称“old_process”这是因为编译器看不到旧代码。
tsc mynewlib.ts --outDir ./dist
Listing 9-13.Compilation of just the new code
在编写类型定义或试图将旧库升级到 TypeScript 之前,可以使用包含的 JavaScript 重新编译。在编译中包含 JavaScript 文件时,最好将输出重定向到一个单独的文件夹,因为 JavaScript 输入文件不能被覆盖。清单 9-14 显示了允许编译 JavaScript 文件的编译器命令,并在编译中包括旧的 JavaScript 文件和新的 TypeScript 文件。
tsc myoldlib.js mynewlib.ts --allowJs --outDir ./dist
Listing 9-14.Compilation including JavaScript
通过在编译中包含 JavaScript 文件,编译器可以在许多情况下推断类型信息,现在编译器错误已经消失了。
您可以进一步改进这一点,如清单 9-15 所示,其中 JavaScript 依赖关系在 TypeScript 文件中是显式的。
///<reference path="myoldlib.js" />
class NewProcessor {
process(name: string) {
return old_process(name);
}
}
Listing 9-15.JavaScript dependency
有了引用注释,您不再需要在编译中指定 JavaScript 文件。此外,您的编辑器现在将提供推断的类型信息作为自动完成提示。
tsc mynewlib.ts --allowJs --outDir ./dist
Listing 9-16.TypeScript and JavaScript compilation with reference comment
为了进一步改善开发人员的体验,您可以开始将您的 JavaScript 移动到 TypeScript 文件中,这允许您添加类型注释来改进类型信息,并在编译器无法理解类型的地方为其提供帮助。如果您的 JavaScript 代码特别晦涩难懂,编译器可能无法推断出许多类型。
如果有大量 JavaScript 文件需要升级,可以先将低级依赖项升级到 TypeScript,而依赖于它们的其他 JavaScript 文件继续引用 TypeScript 文件的编译输出。在运行时,只要只添加类型注释而不重新构造程序,文件最初是用 TypeScript 还是 JavaScript 编写的都没有区别。
最好在用 TypeScript 编写整个程序之前保存任何重构工作,因为对 TypeScript 的重构支持更加智能。
摘要
几乎每一个流行的 JavaScript 库都已经有一个类型定义列在 NPM 的类型组织上,但是如果你遇到一个更奇特的库或者一个全新的库没有列出,你可以创建你自己的类型定义。使用迭代/增量方法编写类型定义可以让您获得投入的时间和精力的最佳回报,并且您可以使用库的文档来查找类型信息或通过阅读示例来推断它。
您可以使用创建类型定义的相同技术来重用您自己的 JavaScript 代码,但是简单地将您的 JavaScript 移动到一个 TypeScript 文件中并添加编译器无法为您推断的任何类型注释可能会花费较少的时间。
无论您是编写类型定义还是将 JavaScript 升级到 TypeScript,编译器都可能会发现您从来不知道存在的错误——您可能会对以前遗漏的内容感到惊讶。
要点
- 类型定义通常放在扩展名为
.d.ts
的文件中。 - 您可以增量地创建新的类型定义——您不需要花费时间一次为整个库生成类型信息。
- 您可以在 TypeScript 编译中包含 JavaScript 文件。
- 将文件从 JavaScript 升级到 TypeScript 通常比创建类型定义文件更容易。
- 因为 JavaScript 完全是动态的,所以当您升级到 TypeScript 时,您可能会发现并修复您不知道存在的错误。
十、自动化测试
我对任何领域的专家的定义是一个对真正发生的事情有足够了解的人。—P. J .普洛伊格
对于任何编写大规模应用的人来说,自动化测试都是一个重要的话题。通过自动化程序测试,开发人员可以花更多的时间在新特性上,花更少的时间修复缺陷。自动化测试对于重构也是必不可少的。没有任何一种测试方法能够独自提供足够高的缺陷检测率。这意味着在软件发布之前,需要几种测试的组合来检测合理数量的问题。
这可能令人惊讶,但是经验证据表明,对于不同种类的测试,您将实现以下缺陷检测率,如 Steve McConnell 在 Code Complete (Microsoft Press,2004)中所记录的:
- 单元测试可以检测出高达 50%的缺陷。
- 集成测试可以检测高达 40%的缺陷。
- 回归测试可以检测多达 30%的缺陷。
这些数字表明,随着测试在软件开发生命周期的后期进行,更多的缺陷会漏网。众所周知,缺陷越晚被发现,成本越高。记住这一点,也许测试优先编程提供了减少 bug 的最有效的方法之一(还有结对编程,因为协作工作方法已经被发现比任何类型的测试检测到更多的缺陷)。测试驱动设计(TDD)的支持者也会很快指出,测试是一种额外的东西,而不是 TDD 的主要目的,TDD 是一种帮助设计内聚的代码单元的工具。他们可能是对的,但是测试也很好!
本章的目的不是让你转向测试驱动的设计。无论您是选择在编码前编写测试,还是在编写完程序的一部分后编写测试,或者希望自动化测试而不是手动执行测试,本章中的信息都是有用的。
Note
缩写词 TDD 最初是为测试驱动开发创造的,但是对测试驱动设计的修改描述向这种实践在帮助形成程序设计中所扮演的角色致敬。
框架选择
有许多用 JavaScript 编写的高质量测试框架可以用来测试你的程序。这里列出了三个最流行的,但是还有很多没有列出来,你甚至不需要使用框架,因为测试也可以在普通的类型脚本代码中进行。
- 茉莉
- 摩卡
- 玩笑
本章中的例子是使用 Jest 编写的,Jest 是一个易于设置的测试框架,由于它与 React 框架的紧密联系,已经获得了一些关注。
示例中显示的代码涵盖了 FizzBuzz 编码形的前几个步骤。编码形是一种实践方法,涉及解决一个简单的问题,逐渐适应挑战你的设计。附录 4 解释了编码表。FizzBuzz 形是基于一种由一系列计数规则组成的儿童游戏。当你表演形时,你的目标是通过游戏中的下一个规则;避免提前思考的诱惑。随着您编写更多的代码,设计将会浮现出来,您可以重构您的程序(安全地知道,如果您的测试通过,您不会意外地改变行为)。
开玩笑的测试
Jest 是为了补充 React 框架而编写的,但它可以用来测试任何类型脚本或 JavaScript 程序。运行 Jest 最常见的方式是通过 Node。语法很简单,容易学习,过去用过 Jasmine 的人都很熟悉。
安装 Jest
将 Jest 添加到项目中的最简单方法是从 NPM 获取包和类型定义。您可以通过将这些包添加到您的开发依赖项中来做到这一点,如清单 10-1 所示。您还会注意到,我们使用 package.json 文件来告诉 Node 哪个框架将处理我们的测试运行。
{
"name": "fizzbuzz",
"version": "1.0.0",
"devDependencies": {
"@types/jest": "²¹.1.0",
"jest": "²¹.1.0"
},
"scripts": {
"test": "jest"
}
}
Listing 10-1.Package dependencies
一旦下载了这些包,就可以开始编写代码了。
第一个规范
将被测试的 FizzBuzz 类的一个简单实现如清单 10-2 所示。这个类的目的是在 FizzBuzz 游戏中给出一个数字时提供一个正确的答案。完整的实现将通过返回所玩的数字或者用诸如“Fizz”、“Buzz”或“FizzBuzz”之类的游戏词来替换该数字来做出响应,这取决于该数字是能被 3、5 还是 3 和 5 整除
Note
FizzBuzz 游戏通常是集体玩的。每个人依次从 1 开始说出下一个数字。如果这个数能被三整除,玩家应该说“嘶嘶”而不是这个数。如果这个数字能被 5 整除,玩家应该说“嗡嗡”,如果这个数字能被 3 和 5 整除,玩家应该说“嘶嘶嗡嗡”
规范被用来驱动编程任务,而不是一次实现所有这些逻辑。因此,该类在实现任何超出初始实现的行为之前等待 Jest 规范,该行为总是返回数字 1。
export class FizzBuzz {
generate(input: number) {
return 1;
}
}
Listing 10-2.FizzBuzz code in FizzBuzz.ts
清单 10-3 中显示了与此行为匹配的 Jest 测试。这个测试代表了你第一次向某人解释 FizzBuzz 的规则时,你和他对话中的第一句话。例如,“播放数字 1 时,您应该说‘1’。”
import { FizzBuzz } from './FizzBuzz';
describe('A FizzBuzz generator', () => {
it('should return the number 1 when 1 is played', () => {
const fizzBuzz = new FizzBuzz();
const result = fizzBuzz.generate(1);
expect(result).toBe(1);
});
});
Listing 10-3.Jest test in FizzBuzz.test.ts
describe
方法接受一套规范和一个将测试每一个规范的函数的名称。it
方法代表一个单一的规范。套件和规范中使用的语言旨在让人们能够读懂。在这种情况下,组合套件描述和规格说明文本如下,
When playing 1, the FizzBuzz generator should return the number 1.
通过仔细选择规范中的语言,您可以从您的测试套件中获得免费的文档。你甚至可以想出一个更好的描述方式,用更人性化的语言来描述这种行为。如果是这种情况,您应该更改描述以匹配您改进的措辞。在这些细节上花点心思是值得的,因为从长远来看,这会使规范更有价值。
通过实例化FizzBuzz
类、播放数字 1 并检查结果是否为 1,传递到规范中的函数与该声明相匹配。
Jest 在名为*.test.js 的文件中查找测试,不包括 node_modules 文件夹中的文件,因此命名很重要。如果您忘记了这一点,那么当您运行 Jest 时将找不到任何测试。
要运行 Jest,只需在项目文件夹中运行清单 10-4 中所示的命令。
npm test
Listing 10-4.Running Jest
该命令的输出如图 10-1 所示。
图 10-1。
The Jest test result
现在你已经做好了一切准备,可以开始测试了。接下来,您将使用额外的测试来驱动 FizzBuzz 程序的实现。
推动实施
现在测试自动化已经就绪,可以使用新的规范来驱动实现了。清单 10-5 显示了在 FizzBuzz 游戏中玩数字 2 时预期行为的第二个规范。
import { FizzBuzz } from './FizzBuzz';
describe('A FizzBuzz generator', () => {
it('should return the number 1 when 1 is played', () => {
const fizzBuzz = new FizzBuzz();
const result = fizzBuzz.generate(1);
expect(result).toBe(1);
});
it('should return the number 2 when 2 is played', () => {
const fizzBuzz = new FizzBuzz();
const result = fizzBuzz.generate(2);
expect(result).toBe(2);
});
});
Listing 10-5.Extending the specification
第二个规范将失败,因为 FizzBuzz 类被硬编码为无论播放哪个值都返回“1”。运行测试的结果如图 10-2 所示。
图 10-2。
The failing test result
失败消息表明测试“Expected value to be: 2, Received: 1,
”这意味着 Jest 没有通过测试,因为返回了“1”,而预期的是“2”。
为了通过测试,必须更新FizzBuzz
类,如清单 10-6 所示。返回输入的任何数字都将通过两个现有规范。虽然您可能知道您将很快添加更多的规范,而这些规范将不会包含在这个实现中,但是在编写代码之前等待一个失败的测试可以确保每个变体的测试都已编写完成,并且在您编写导致它们通过的代码之前失败。知道如果行为不正确,测试将会失败,这将给你以后重构程序的信心。
export class FizzBuzz {
generate(input: number) {
return input;
}
}
Listing 10-6.Updated FizzBuzz class
当您在此更改后重新运行规范时,所有规范都将通过。结果如图 10-3 所示。
图 10-3。
The passing test suite
清单 10-7 展示了驱动FizzBuzz
类实现的下一个规范。这个规范要求在播放数字三的时候,要用数字三代替“嘶嘶”这个词。
it('should return "Fizz" when 3 is played', () => {
const fizzBuzz = new FizzBuzz();
const result = fizzBuzz.generate(3);
expect(result).toBe('Fizz');
});
Listing 10-7.The Fizz specification
在首先检查规范失败后,您可以更新清单 10-8 中所示的实现。这个更新也是通过测试的最简单的代码。
class FizzBuzz {
generate(input: number) : string | number {
if (input === 3) {
return 'Fizz';
}
return input;
}
}
Listing 10-8.The updated
FizzBuzz class
此阶段运行规范的结果如图 10-4 所示。阅读测试输出,感受一下测试如何用业务领域的语言描述程序。
图 10-4。
The passing test suite of three tests
重构
既然已经编写了大量的规范,并且实现了通过这些规范的代码,那么重构程序是值得的。重构代码包括改变程序的结构和设计,而不改变行为。知道你真的在重构(而不是无意中改变程序的实际功能)的最简单的方法是进行自动化测试,突出任何偶然的改变。
同样值得强调的是,你的测试代码应该和你的产品代码一样好写和可维护,但是不那么抽象。出于这个原因,清单 10-9 展示了重构后的规范,其中 FizzBuzz 类的重复实例化被移到了一个beforeEach
方法中,Jest 将在每个规范之前自动运行。
import { FizzBuzz } from './FizzBuzz';
describe('A FizzBuzz generator', () => {
let fizzBuzz: FizzBuzz;
beforeEach(() => {
fizzBuzz = new FizzBuzz();
});
it('should return the number 1 when 1 is played', () => {
const result = fizzBuzz.generate(1);
expect(result).toBe(1);
});
it('should return the number 2 when 2 is played', () => {
const result = fizzBuzz.generate(2);
expect(result).toBe(2);
});
it('should return "Fizz" when 3 is played', () => {
const result = fizzBuzz.generate(3);
expect(result).toBe('Fizz');
});
});
Listing 10-9.Refactored specifications
无论何时重构代码,都应该重新运行所有的测试,以确保没有改变程序的行为。如果您的测试套件失败了,您可以简单地撤销您的更改并重新开始,而不是调试程序。测试运行之间的每一次迭代都应该小到足以丢弃。
export class FizzBuzz {
generate(input: number): string | number {
let output = '';
if (input % 3 === 0) {
output += 'Fizz';
}
if (input % 5 === 0) {
output += 'Buzz';
}
return output === '' ? input : output;
}
}
Listing 10-10.A working FizzBuzz class using conditional statements
清单 10-10 中的代码展示了FizzBuzz
类的一个工作版本,它涵盖了返回一个数字的默认规则以及Fizz
、Buzz
和FizzBuzz
的三种变体。在这一点上,尽管generate
方法仍然很短,但是可以从代码中看到替代的设计。特别是,随着新规则的增加(可能被 7 整除的数字应该返回‘Bazz’),您可能会决定引入和修改一个设计模式来捕获特定的规则。
Note
FizzBuzz 编码形式通常用一种叫做责任链的设计模式来解决,尽管还有其他可能的解决方案。
清单 10-11 中显示了为推动这一实现而创建的规范。现在总共有八种规格来涵盖四种可能的响应。
import { FizzBuzz } from './FizzBuzz';
describe('A FizzBuzz generator', () => {
let fizzBuzz: FizzBuzz;
const FIZZ = 'Fizz';
const BUZZ = 'Buzz'
const FIZZ_BUZZ = 'FizzBuzz';
beforeEach(() => {
fizzBuzz = new FizzBuzz();
});
it('should return the number 1 when 1 is played', () => {
const result = fizzBuzz.generate(1);
expect(result).toBe(1);
});
it('should return the number 2 when 2 is played', () => {
const result = fizzBuzz.generate(2);
expect(result).toBe(2);
});
it('should return "Fizz" when 3 is played', () => {
const result = fizzBuzz.generate(3);
expect(result).toBe(FIZZ);
});
it('should return "Fizz" when 6 is played', () => {
const result = fizzBuzz.generate(6);
expect(result).toBe(FIZZ);
});
it('should return "Buzz" when 5 is played', () => {
const result = fizzBuzz.generate(5);
expect(result).toBe(BUZZ);
});
it('should return "Buzz" when 10 is played', () => {
const result = fizzBuzz.generate(10);
expect(result).toBe(BUZZ);
});
it('should return "FizzBuzz" when 15 is played', () => {
const result = fizzBuzz.generate(15);
expect(result).toBe(FIZZ_BUZZ);
});
it('should return "FizzBuzz" when 30 is played', () => {
const result = fizzBuzz.generate(30);
expect(result).toBe(FIZZ_BUZZ);
});
});
Listing 10-11.The specifications for the working FizzBuzz class
除了测试 FizzBuzz 类,这些规范还为程序提供了准确的文档。输出如图 10-5 所示。您可能会注意到 Jest 已经标记了其中一个测试的执行时间(15 ms),这有助于您识别任何降低测试套件速度的测试。
图 10-5。
The passing test suite of three tests
这些测试是可执行规范的一种形式——一种活的文档形式,也证明了你的程序执行了文档记录的行为。
隔离依赖关系
有时你可能需要测试依赖于资源的部分代码,这使得你的测试变得脆弱。例如,它可能依赖于第三方 API 或处于特定状态的数据库。如果您需要在不依赖这些依赖项的情况下测试代码,您可以在使用本节中描述的技术进行测试时隔离它们。
在许多编程语言中,每当您需要提供一个测试 double 时,使用一个模仿框架已经变得很自然。然而,在 TypeScript 中,创建测试 doubles 非常简单,您可能永远都不需要搜索框架。
清单 10-12 显示了依赖于localStorage
的FizzBuzz
类的修改版本,它在 TypeScript 中实现了Storage
接口。constructor
接收存储对象,generate
函数使用它来获取在“嘶嘶”情况下显示的显示消息。
class FizzBuzz {
constructor(private storage: Storage) {
}
generate(input: number): string | number {
if (input === 3) {
return this.storage.getItem('FizzText');
}
return input;
}
}
Listing 10-12.A FizzBuzz class that relies on storage
您可以用清单 10-13 中所示的简单对象来满足这种依赖性。storage
变量与Storage
接口的匹配刚好足以通过测试。与其他编程语言不同,这种解决测试 double 问题的方法非常简单;你几乎不需要考虑用一个框架来解决问题。
describe('A FizzBuzz generator', () => {
it('should return "FakeFizz" when 3 is played', () => {
// Create a test double for storage
var storage: any = {
getItem: () => 'FakeFizz'
};
const fizzBuzz = new FizzBuzz(storage);
const result = fizzBuzz.generate(3);
expect(result).toBe('FakeFizz');
});
});
Listing 10-13.Using an object
总的来说,您应该坚持使用简单的对象作为测试替身,并且您的测试应该检查结果,而不是具体的实现细节。知道当你玩 3 时你得到“Fizz”是一个很强的行为测试,但是检查一个storage
对象是否被调用来提供一个匹配特定键的值根本不是一个好的测试,因为当你改变实现细节时这将失败。
摘要
希望自动化测试的价值已经在这一章得到了展示。然而,如果你仍然持怀疑态度,你可以尝试在有测试和没有测试的情况下运行编码卡塔,看看它是否有助于你下定决心。您可以在附录 4 中了解更多信息。
尽管本章所有的例子都使用了 Jest,但是使用 Mocha 或 Jasmine 也同样简单,并且两者都提供了同样简单的语法。无论您使用什么来运行测试,都要努力使输出看起来像人类可读的文档,因此如果有人需要文档,您可以简单地提供您的测试套件的输出。
我已经为 TypeSpec 的 TypeScript 创建了一个基于 Gherkin 语言的行为驱动框架的实现,您可以使用它来结合业务规范和测试自动化,但是我在本章中使用 Jest,因为我更希望程序员从更常用的工具开始。您可以在 GitHub 上找到更多关于 TypeSpec 的信息:
https://github.com/Steve-Fenton/TypeSpec
要点
- 自动化单元测试比集成测试或回归测试更有效(尽管一个好的策略是使用多种测试来获得最佳的缺陷检测率)。
- 有很多 JavaScript 和 TypeScript 的框架,但是如果你想缩小范围,可以看看 Jest、Jasmine 和 Mocha。
- 您可以使用 Jest 编写充当测试和文档的规范。
- 用规范驱动实现可以确保如果行为不正确,测试会失败。在实现之后编写测试并不保证测试会失败。
- 您应该重构生产代码和测试代码。
- 您可以使用简单的对象来隔离您的依赖项,这比那些可能将您的测试与实现捆绑得太紧的智能工具更好。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?