原文:Pro TypeScript
协议:CC BY-NC-SA 4.0
二、代码组织
不是语言让程序看起来简单。是程序员让语言显得简单!——罗伯特·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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
可以将名称空间组织成层次结构:或者将它们嵌套在一起,或者在提供名称空间标识符时使用点语法。清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
如果您正在使用名称空间,混合分层名称空间组织的样式,以及在多个文件中添加名称空间,都会在修复错误或添加新行为时增加程序的心理复杂性。您创建的层次结构的主要驱动因素应该是借助自动完成功能来方便地定位功能。一个好的层次结构将使你的代码更容易被找到,并减少交叉引用文档的需要。
您可能已经注意到清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
导入别名也可以在模块外部使用,以提供短名称。
当从其他文件引用命名空间时,代码编辑器和集成开发环境之间存在一些差异。Visual Studio 会自动将项目中的所有 TypeScript 文件包含在编译中,这意味着在引用其他文件时,不需要特别引用它们。其他代码编辑器需要提示来帮助他们发现您所依赖的代码的源代码。这些提示的格式是引用注释,如清单 2-5 所示。无论您是否需要使用这些提示,您都将负责使代码在运行时可用。
///<reference path="Shipping.ts" />
Listing 2-5.Reference comments
- 1
- 2
- 3
如果使用 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
现在您已经理解了名称空间,暂时不要实现它们,因为有一种更好的方法来组织您的代码。名称空间限制了全局范围内的项数,而模块则更进一步,没有向全局范围添加任何东西。模块还提供了命名冲突的解决方案,因为每个文件都为标识符提供了新的上下文。模块还有一个杀手锏:可以按需异步加载。现在,让我们来看看模块。
模块
模块是组织代码的最佳方式。它们是服务器端 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
要使用模块,您可以使用多种导入样式之一来导入它。清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
您还可以有选择地导入模块成员,如清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
如果导入单个成员会导致命名冲突,那么可以用关键字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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
当您在 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
您可以使用模块重新导出将几个模块组合成一个包装模块。
默认导出
您可以将每个模块的一个成员标记为默认导出。默认导出可以是任何成员,如类、函数或值。清单 2-12 显示了一个默认的导出;也可以使用export default Yacht
在单独的行上表达。
export default class Yacht {
constructor(
public name: string,
public port: string,
public displacement: number) {
}
}
Listing 2-12.Default export
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
当您导入一个默认值时,您可以使用清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
如果没有充分的理由使用默认导出,最好避免使用它们。必须决定需要为一个模块编写哪种类型的导入语句是一种不必要的认知上的擦伤。在导入过程中默认的隐式重命名会增加额外的复杂性,尤其是在重命名重构过程中导入的名称没有改变的情况下。
导出对象
一些模块类型支持 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
当使用这种类型的模块时,您应该使用清单 2-15 中所示的导入/要求类型的导入。
import Ferry = require('./Listing-2-014');
const ferry = new Ferry('Dartmouth Ferry', 'Dartmouth', 580)
Listing 2-15.Importing an export object
- 1
- 2
- 3
- 4
- 5
- 6
根据编译时指定的模块种类,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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
动态模块加载强制您的代码比普通的导入更加具有模块意识,因此执行动态加载的代码会根据您的模块类型而变化。清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
在第六章中有关于使用 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
包内容在“files”元素中描述,它可以包含文件夹和单个文件,并在“main”元素中描述应用的入口点。使用 TypeScript 时,可以使用“types”元素为入口点提供类型定义。
要准备打包文件,只需将它们复制到分发文件夹中。这可以使用 Visual Studio 中的后期构建事件来完成,如清单 2-20 所示,或者使用任务运行器(如 Gulp)执行的任务来完成。
XCOPY $(ProjectDir)ScriptsTypeSpec*.d.ts $(ProjectDir)distsrc /y
XCOPY $(ProjectDir)ScriptsTypeSpec*.js $(ProjectDir)distsrc /y
XCOPY $(SolutionDir)README.md $(ProjectDir)dist /y
XCOPY $(ProjectDir)package.json $(ProjectDir)dist /y
Listing 2-20.Copy package contents
- 1
- 2
- 3
- 4
- 5
- 6
一旦文件准备好打包,您只需运行清单 2-21 中的 package 命令。这将生成一个包含您的包的归档文件。您可以使用归档阅读器来检查文件的内容,并确保所有内容都如预期的那样存在。
npm package
Listing 2-21.
Packaging
command
- 1
- 2
- 3
- 4
- 5
- 6
您可以私下或公开使用该软件包。这意味着您可以使用相同的机制来打包您的开源项目供全世界使用,或者将包添加到您的私有存储库中。发布 NPM 包的命令如清单 2-22 所示。首次运行此命令时,将提示您通过命令行添加凭据,因为您只能发布您拥有适当权限的包。
npm publish
Listing 2-22.
Publishing
command
- 1
- 2
- 3
- 4
- 5
- 6
虽然打包代码似乎有几个步骤,但是一旦设置好了,发布新版本就像更新版本号并重新运行 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
装饰器可以应用于以下任何一种情况:
- 班级
- 附件
- 性能
- 方法
- 因素
每种装饰器都需要不同的函数签名,因为根据装饰器的用途,为装饰器提供了不同的参数。这一节将提供几个实际的例子,可以作为你自己的装饰者的起点。
清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
这个基本的日志记录示例意味着 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
当使用可配置装饰器时,参数像函数调用一样传递。
班级装饰者
为了使日志装饰器适用于一个类,构造函数必须用日志构造函数包装。这比方法修饰器稍微复杂一点,但是清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
与方法装饰器一样,您可以选择修改、包装或替换类装饰器中传递的构造函数。替换构造函数时,必须保持原始原型,因为这不会自动完成。
财产装饰者
属性装饰器可以分成几个部分。在清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
每次调用属性的 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
虽然结构类型在有限的特殊情况下可能会造成困难,但它有很多优点。例如,引入兼容类型而不必更改现有代码要容易得多,并且可以创建无需从外部类继承就可以传递给外部代码的类型。在引入新的超类型而不改变新降级的子类型或与它们交互的代码的能力方面,它也优于名义类型。
结构类型化最重要的好处之一是它节省了无数显式的类型名修饰。无需添加特定的类型注释就可以实现接口,并且无需添加类型注释就可以创建匿名对象来匹配接口和类。如果属性和方法与所需类型属于同一类型或兼容类型,则可以使用这些对象。兼容类型可以是子类型、更窄的类型或结构相似的类型。
在结构化类型语言(如 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
尽管在编译期间执行了类型擦除和转换,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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
最佳常见类型
当推断类型信息时,在有限的几种情况下,必须确定最佳的通用类型。清单 3-6 展示了如何考虑数组中的值,以便在所有数组值之间生成最佳公共类型。
// number[]
let x = [0, 1, null];
// (string | number)[]
let y = [0, 1, null, 'a'];
Listing 3-6.Best common types
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
确定最佳公共类型的过程不仅仅用于数组文字表达式;它们还用于确定多个值具有不同类型的任何情况,例如包含多个返回语句的函数或方法的返回类型。
上下文类型
上下文类型是高级类型推理的一个很好的例子。当编译器将其类型基于表达式的位置时,就会发生上下文类型化。在清单 3-7 中,事件参数的类型由window.onclick
定义的已知签名决定。推论不仅仅局限于参数;由于对window.onclick
签名的现有了解,可以推断出包括返回值在内的整个签名。
window.onclick = function(event) {
var button = event.button;
};
Listing 3-7.Contextual types
- 1
- 2
- 3
- 4
- 5
如果检查清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
何时添加注释
因为类型推断从第一天起就是 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
环境声明在本章后面会有更详细的解释,但是这种技术在变量声明前没有关键字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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
类型检查
一旦从程序中收集了类型的计划,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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
随着类型变得越来越复杂,类型检查需要对对象进行更深入的检查。检查对象时,会测试对象的每个成员。公共属性必须具有相同的名称和类型;公共方法必须有相同的签名。检查对象的成员时,如果属性引用嵌套对象,检查将继续深入到该对象以检查兼容性。
清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
这个例子值得注意的部分包括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
- 1
- 2
- 3
- 4
- 5
- 6
所有环境声明都以关键字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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
当使用这个更新的环境声明时,自动完成提供类型提示,如图 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 库或框架编写环境声明,您应该首先查看是否有人已经通过访问环境声明的在线库完成了这项艰苦的工作,明确键入:
http://definitelytyped.org/
由 Boris Yankov 发起的明确类型化项目包含了无数流行 JavaScript 项目的定义,包括 Angular、Backbone、Bootstrap、Breeze、D3、Ember、jQuery、Knockout、Node、下划线等等。甚至还有 Jasmine、Mocha 和 qUnit 等单元测试框架的声明。其中一些外部来源非常复杂,因此使用现有的声明可以节省大量时间。Microsoft 现在支持该存储库。您可以在以下位置搜索正确的定义名称和安装说明:
https://aka.ms/types
将现有类型定义引入项目的最简单方法是使用 NPM,如清单 3-15 所示。
npm install --save @types/jquery
Listing 3-15.installing type definitions
- 1
- 2
- 3
摘要
在 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
这个开放式递归的例子可以总结如下:
- 当你创建一个新的
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
封装是一种工具,它可以帮助你防止程序中大量的重复代码,但它并不能神奇地做到这一点。您应该使用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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
为了在这个类变成一个更大的问题之前修复它,这两个问题可以在负责电影相关行为的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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
如果您记住了单一责任原则,那么关注类级别的责任通常是简单的,但是在方法级别它可能更加重要,确保每个方法只执行一个任务,并且以揭示方法预期行为的方式命名。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)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
通过遵循 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
LSP 通过确保当新的行为被添加到程序中时,新的代码可以被用来代替旧的代码来支持 OCP。如果一个子类不能直接代替一个超类,那么添加一个新的子类将导致整个代码的改变,甚至可能导致程序流被基于对象类型的分支条件所控制。
接口隔离原则(ISP)
发现接口本质上只是整个类的描述是很常见的。这通常是在类之后编写接口的情况。清单 4-9 显示了一个打印机接口的简单例子,它可以复印、打印和装订文档。因为界面只是描述打印机所有行为的一种方式,它会随着新功能的增加而增长,例如,折叠、插入信封、传真、扫描和电子邮件可能最终会出现在Printer
界面上。
interface Printer {
copyDocument();
printDocument(document: Document);
stapleDocument(document: Document, tray: number);
}
Listing 4-9.
Printer interface
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
当您遵循 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
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)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
抽象工厂本身是一个描述每个具体工厂可以执行的操作的接口。在清单 4-16 中,ValetFactory
接口提供了方法签名,用于获取提供车轮清洁功能的类和提供车身清洁功能的类。需要清理车轮和车身的类可以依赖于这个接口,并与指定实际清理的类保持分离。
interface ValetFactory {
getWheelCleaning() : WheelCleaning;
getBodyCleaning() : BodyCleaning;
}
Listing 4-16.Abstract factory
- 1
- 2
- 3
- 4
- 5
- 6
在清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
混入类
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
一旦在程序中的某个地方添加了这个函数,就可以开始使用 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
就其本身而言,这些类太小而没有用,但是它们非常严格地遵守单一责任原则。您并不局限于单个方法,而是每个类代表一个行为,您可以在类名中对其进行总结。为了使这些混合有用,您需要将它们组合成可用的扩充类。
在 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
如果属性不需要绑定到实例,您可以使用静态属性,因为这些属性在从 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
真正的混音
真正的混合为使用混合支持合成提供了更可靠的机制。清单 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.
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
为了展示创建一个具有多个混音的混音类的比较案例,清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
真正混合的好处包括消除了忘记调用应用混合函数的可能性,以及对所有成员的支持。语法需要做一些工作,但是一旦有了构造函数类型,剩下的就相当简单了。
摘要
面向对象的所有构造块都存在于 TypeScript 中。语言工具可以将您在使用其他语言时学到的所有面向对象的原则和实践应用到您的程序中,使用可靠的原则来指导您的写作,并将设计模式作为常见问题的成熟解决方案的参考。
面向对象本身并不能解决编写和维护解决复杂问题的程序的问题。使用面向对象编写糟糕的代码就像在任何其他编程范例中编写糟糕的代码一样;这就是模式和原则如此重要的原因。本章中的面向对象元素补充了第十章中的测试技术。
您可以使用编码卡塔练习和提高您的面向对象设计技能以及单元测试技能。这些在附录 4 中有描述,有一些例子供你尝试。
要点
- TypeScript 拥有编写面向对象程序所需的所有工具。
- 坚实的原则旨在保持代码的可延展性,防止它腐烂。
- 设计模式是对常见问题的现有的、众所周知的解决方案。
- 您不必完全按照描述来实现设计模式。
- 混合蛋白为合成提供了另一种机制。