我们已经看到了 TypeScript 如何使用基本类型、推断类型和函数签名为 JavaScript 带来强类型的开发体验。TypeScript 还引入了从其他面向对象语言借来的三个概念:接口、类和泛型。在本章中,我们将研究这些面向对象的概念,它们在 TypeScript 中是如何使用的,以及它们给 JavaScript 程序员带来了什么好处。
本章的第一部分面向首次使用 TypeScript 的读者,从头开始介绍接口、类和继承。本章的第二部分建立在这些知识的基础上,并展示了如何创建和使用工厂设计模式。本章的第三节讨论泛型。
如果您有使用 TypeScript 的经验,正在积极使用接口和类,理解继承,并且对应用于this
参数的词法范围规则感到满意,那么您可能对工厂设计模式或泛型的后续部分更感兴趣。
本章将涵盖以下主题:
- 接口
- 班级
- 遗产
- 关闭
- 工厂设计模式
- 类修饰符、静态函数和属性
- 无商标消费品
- 运行时类型检查
接口为我们提供了一种定义对象必须实现哪些属性和方法的机制。如果一个对象附着在一个接口上,就说这个对象实现了这个接口。如果对象没有正确地实现接口,TypeScript 将在我们的代码中产生编译错误。接口也是定义自定义类型的另一种方式,除了别的以外,它给我们一个早期的指示——在我们构造一个对象的时候——对象没有我们需要的属性和方法。
考虑以下 TypeScript 代码:
interface IComplexType {
id: number;
name: string;
}
var complexType : IComplexType =
{ id: 1, name: "firstObject" };
var complexType_2: IComplexType =
{ id: 2, description: "myDescription"};
if (complexType == complexType_2) {
console.log("types are equal");
}
我们从一个名为IComplexType
的接口开始,它有一个id
和一个name
属性。id
属性被强类型化为number
类型,name
属性为string
类型。然后我们创建一个名为complexType
的变量,并使用:
类型语法来指示该变量属于IComplexType
类型。名为complexType_2
的下一个变量也将该变量强类型化为IComplexType
类型。然后,我们比较complexType
和complexType_2
变量,如果这些对象相同,就向控制台记录一条消息。然而,这段代码将产生一个编译错误:
error TS2012: Build: Cannot convert
'{ id: number; description: string; }' to 'IComplexType':
这个编译错误告诉我们complexType_2
变量必须符合IComplexType
接口。complexType_2
变量有id
属性,但没有name
属性。为了修复这个错误,并确保变量实现IComplexType
接口,我们只需要添加一个name
属性,如下所示:
var complexType_2: IComplexType = {
id: 2,
name: "secondObject",
description: "myDescription"
};
即使我们有一个额外的description
属性,IComplexType
接口只提到了id
和name
属性——所以只要我们有这些属性,这个对象就被称为实现了IComplexType
接口。
接口是 TypeScript 的编译时语言功能,编译器不会从您包含在 TypeScript 项目中的接口生成任何 JavaScript 代码。接口仅由编译器在编译步骤中用于类型检查。
在本书中,我们将坚持一个简单的接口命名约定,那就是在接口名称前加上字母I
。当处理代码分布在多个文件中的大型项目时,使用这种命名方案很有帮助。在你的代码中看到任何前缀为I
的有助于你立即将它作为一个接口来区分。但是,您可以将您的接口称为任何东西。
一个类是一个对象的定义,它包含什么数据,它可以执行什么操作。类和接口构成了面向对象编程原则的基石,并且经常在设计模式中协同工作。设计模式是一种简单的编程结构,已经被证明是处理特定编程任务的最佳方式。稍后将详细介绍设计模式。
让我们使用类重新创建之前的代码示例:
interface IComplexType {
id: number;
name: string;
print(): string;
}
class ComplexType implements IComplexType {
id: number;
name: string;
print(): string {
return "id:" + this.id + " name:" + this.name;
}
}
var complexType: ComplexType = new ComplexType();
complexType.id = 1;
complexType.name = "complexType";
var complexType_2: ComplexType = new ComplexType();
complexType_2.id = 2;
complexType_2.name = "complexType_2";
window.onload = () => {
console.log(complexType.print());
console.log(complexType_2.print());
}
首先,我们有我们的接口定义(IComplexType
),它有一个id
和一个name
属性,以及一个 print
函数。然后我们定义了一个名为ComplexType
的类,它实现了IComplexType
接口。换句话说,ComplexType
的类定义必须匹配IComplexType
接口定义。请注意,类定义并不创建变量,它只是定义了类的结构。然后我们创建一个名为complexType
的变量,然后给这个变量分配一个ComplexType
类的新实例。据说这一行创建了类的一个实例。一旦我们有了类的实例,我们就可以设置类属性的值。代码的最后一部分只是在一个window.onload
函数中调用每个类的print
函数。该代码的输出如下:
id:1 name:complexType
id:2 name:complexType_2
类可以在初始构造期间接受参数。如果我们看一下前面的代码示例,我们调用创建一个ComplexType
类的实例,然后设置它的属性,可以简化为一行代码:
var complexType = new ComplexType(1, "complexType");
这个版本的代码将id
和name
属性作为类构造函数的一部分传递。然而,我们的类定义需要包含一个新的函数,命名为constructor
,以便接受这个语法。我们更新后的类定义将变成:
class ComplexType implements IComplexType {
id: number;
name: string;
constructor(idArg: number, nameArg: string) {
this.id = idArg;
this.name = nameArg;
}
print(): string {
return "id:" + this.id + " name:" + this.name;
}
}
注意constructor
功能。是一个正常函数定义,但是使用constructor
关键字,接受一个idArg
,和nameArg
作为参数。这些参数分别被强类型化为number
和string
类型。然后将ComplexType
类的内部id
属性分配给idArg
参数值。请注意用于引用id
属性的语法:this.id
。类使用与对象访问内部属性相同的this
语法。如果我们试图在不使用this
关键字的情况下使用内部类属性,TypeScript 将生成编译错误。
一个类中的所有函数都遵循语法和规则,我们在前面的函数一章中已经介绍过了。作为这些规则的复习,所有类函数都可以:
- 打字很强
- 使用
any
关键字放松强打字 - 有可选参数
- 有默认参数
- 使用参数数组或其余参数语法
- 允许函数回调并指定函数回调签名
- 允许函数重载
让我们修改我们的ComplexType
类定义,并包含这些规则的一个例子:
class ComplexType implements IComplexType {
id: number;
name: string;
constructor(idArg: number, nameArg: string);
constructor(idArg: string, nameArg: string);
constructor(idArg: any, nameArg: any) {
this.id = idArg;
this.name = nameArg;
}
print(): string {
return "id:" + this.id + " name:" + this.name;
}
usingTheAnyKeyword(arg1: any): any {
this.id = arg1;
}
usingOptionalParameters(optionalArg1?: number) {
if (optionalArg1) {
this.id = optionalArg1;
}
}
usingDefaultParameters(defaultArg1: number = 0) {
this.id = defaultArg1;
}
usingRestSyntax(...argArray: number []) {
if (argArray.length > 0) {
this.id = argArray[0];
}
}
usingFunctionCallbacks( callback: (id: number) => string ) {
callback(this.id);
}
}
首先要注意的是constructor
功能。我们的类定义是对constructor
函数使用函数重载,允许使用一个number
和一个string
或者两个字符串来构造类。下面的代码展示了我们如何使用这些constructor
定义:
var complexType: ComplexType = new ComplexType(1, "complexType");
var complexType_2: ComplexType = new ComplexType("1", "1");
var complexType_3: ComplexType = new ComplexType(true, true);
complexType
变量使用构造函数的number,
string
变量,complexType_2
变量使用string,string
变量。complexType_3
变量将产生编译错误,因为我们不允许构造函数使用boolean,boolean
变量。然而,你可能会争辩说,最后一个构造函数指定了一个any,any
变量,这应该考虑到我们的boolean,boolean
用法。请记住,在使用构造函数重载时,实际的构造函数实现必须使用与构造函数重载的任何变体兼容的类型。因此,我们的构造函数实现必须使用any,any
变量。然而,因为我们使用的是构造函数重载,这个any,any
变量被编译器隐藏了,以利于我们的重载签名。
下面的代码示例显示了如何使用我们为这个类定义的其余函数。先说usingTheAnyKeyword
功能:
complexType.usingTheAnyKeyword(true);
complexType.usingTheAnyKeyword({id: 1, name: "test"});
这个示例中的第一个调用是使用布尔值调用usingTheAnyKeyword
函数,第二个调用是使用任意对象。这两个函数调用都有效,因为参数arg1
是用any
类型定义的。接下来,usingOptionalParameters
功能:
complexType.usingOptionalParameters(1);
complexType.usingOptionalParameters();
这里我们先用单个参数调用usingOptionalParameters
函数,然后不用任何参数。同样,这些调用是有效的,因为optionalArg1
参数被标记为可选的。现在为usingDefaultParameters
功能:
complexType.usingDefaultParameters(2);
complexType.usingDefaultParameters();
对usingDefaultParameters
函数的这两次调用都有效。第一次调用将覆盖默认值 0,第二次调用(不带参数)将使用默认值 0。接下来是usingRestSyntax
功能:
complexType.usingRestSyntax(1, 2, 3);
complexType.usingRestSyntax(1, 2, 3, 4, 5);
我们的 rest 函数usingRestSyntax
可以用任意数量的参数调用,因为我们使用 rest 参数语法将这些参数保存在一个数组中。这两个调用都有效。最后,我们来看看usingFunctionCallbacks
功能:
function myCallbackFunction(id: number): string {
return id.toString();
}
complexType.usingFunctionCallbacks(myCallbackFunction);
这个片段显示了名为myCallbackFunction
的函数的定义。它匹配usingFunctionCallbacks
函数所需的回调签名,允许我们将myCallbackFunction
作为参数传递给usingFunctionCallbacks
函数。
请注意,如果您在理解这些不同的函数签名时遇到任何困难,请重新查看第 2 章、类型、变量和函数技术中关于函数的相关部分,其中详细解释了这些概念。
接口和类一样,在处理函数时遵循相同的规则。为了更新我们的IComplexType
接口定义以匹配ComplexType
类定义,我们需要为每个新函数编写一个函数定义,如下所示:
interface IComplexType {
id: number;
name: string;
print(): string;
usingTheAnyKeyword(arg1: any): any;
usingOptionalParameters(optionalArg1?: number);
usingDefaultParameters(defaultArg1?: number);
usingRestSyntax(...argArray: number []);
usingFunctionCallbacks(callback: (id: number) => string);
}
第 1 行到第 4 行构成了我们现有的界面定义,包括id
和name
属性以及我们一直使用到现在的print
功能。第 5 行显示了如何为usingTheAnyKeyword
函数定义函数签名。它看起来惊人地像我们实际的类函数,但是没有函数体。第 6 行显示了如何使用usingOptionalParameters
功能的可选参数。然而,第 7 行与我们对usingDefaultParameters
函数的类定义略有不同。请记住,接口定义了类或对象的形状,因此不能包含变量或值。因此,我们将defaultArg1
参数定义为可选参数,并将默认值的分配留给类实现本身。第 8 行显示了包含 rest 参数语法的usingRestSyntax
函数的定义,第 9 行显示了带有回调函数签名的usingFunctionCallbacks
函数的定义。它们与类函数签名非常相似。
这个界面唯一缺少的就是constructor
函数的签名。如果我们在接口中包含constructor
签名,TypeScript 将会产生错误。假设我们要在IComplexType
界面中包含constructor
函数的定义:
interface IComplexType {
constructor(arg1: any, arg2: any);
}
然后,TypeScript 编译器会生成一个错误:
Types of property 'constructor' of types 'ComplexType' and 'IComplexType' are incompatible
这个错误告诉我们当我们使用constructor
函数时,构造函数的返回类型是由 TypeScript 编译器隐式键入的。因此,IComplexType
构造函数的返回类型为IComplexType
,而ComplexType
构造函数的返回类型为ComplexType
。即使ComplexType
函数实现了IComplexType
接口,它们实际上是两种不同的类型——因此constructor
签名总是不兼容的——因此产生了编译错误。
继承是另一种范式,是面向对象编程的基石之一。继承是指一个对象使用另一个对象作为其基类型,从而“继承”基对象的所有特征,包括属性和功能。接口和类都可以使用继承。从“继承”而来的接口或类称为基接口或基类,进行继承的接口或类称为派生接口或派生类。TypeScript 使用extends
关键字实现继承。
作为接口继承的例子,考虑下面的 TypeScript 代码:
interface IBase {
id: number;
}
interface IDerivedFromBase extends IBase {
name: string;
}
class DerivedClass implements IDerivedFromBase {
id: number;
name: string;
}
我们从一个名为IBase
的接口开始,它定义了一个类型号为id
的属性。我们的第二个接口定义IDerivedFromBase
,从IBase
扩展(或继承),因此自动包含id
属性。然后,IDerivedFromBase
界面定义了一个字符串类型的name
属性。由于IDerivedFromBase
接口继承自IBase
,因此它实际上有两个属性:id
和name
。DerivedClass
的类定义实现了这个IDerivedFromBase
接口,因此必须同时包括id
和name
属性——以便成功实现IDerivedFromBase
接口的所有属性。虽然我们在这个例子中只显示了属性,但是同样的规则也适用于函数。
类也可以像接口一样使用继承。使用我们对IBase
和IDerivedFromBase
接口的定义,下面的代码显示了一个类继承的例子:
class BaseClass implements IBase {
id : number;
}
class DerivedFromBaseClass
extends BaseClass
implements IDerivedFromBase
{
name: string;
}
第一个类名为BaseClass
,实现了IBase
接口,因此只需要定义一个id
的属性,类型为number
。第二个类DerivedFromBaseClass
继承了BaseClass
类(使用extends
关键字),但也实现了IDerivedFromBase
接口。由于BaseClass
已经定义了IDerivedFromBase
接口所需的id
属性,所以DerivedFromBaseClass
类需要实现的唯一其他属性是name
属性。因此,我们只需要在DerivedFromBaseClass
类中包含name
属性的定义。
使用继承时,经常需要用定义的构造函数创建一个基类。然后,在任何派生的类的构造函数中,我们需要调用基类构造函数并传递这些参数。这被称为构造函数重载。换句话说,派生类的构造函数重载或“取代”基类的构造函数。TypeScript 包含super
关键字,用于调用具有相同名称的基类的函数。最好用下面的代码片段来解释这一点:
class BaseClassWithConstructor {
private _id: number;
constructor(id: number) {
this._id = id;
}
}
class DerivedClassWithConstructor extends BaseClassWithConstructor {
private _name: string;
constructor(id: number, name: string) {
this._name = name;
super(id);
}
}
在这段代码片段中,我们定义了一个名为BaseClassWithConstructor
的类,它持有私有的_id
属性。这个类有一个需要id
参数的constructor
函数。我们的第二个类名为DerivedClassWithConstructor
,继承或扩展了BaseClassWithConstructor
类。DerivedClassWithConstructor
的构造函数接受一个id
参数和一个name
参数,但是它需要将id
参数传递给基类。这就是super
来电的地方。super
关键字调用基类中与派生类中的函数同名的函数。DerivedClassWithConstructor
构造函数的最后一行显示了使用super
关键字的调用,将它接收到的id
参数传递给基类构造函数。
这种技术被称为函数重载。换句话说,派生类有一个与基类函数同名的函数名,它“重载”了这个函数定义。我们可以在类中的任何函数上使用这种技术,而不仅仅是在构造函数上。考虑以下代码片段:
class BaseClassWithConstructor {
private _id: number;
constructor(id: number) {
this._id = id;
}
getProperties(): string {
return "_id:" + this._id;
}
}
class DerivedClassWithConstructor extends BaseClassWithConstructor {
private _name: string;
constructor(id: number, name: string) {
this._name = name;
super(id);
}
getProperties(): string {
return "_name:" + this._name + "," + super.getProperties();
}
}
BaseClassWithConstructor
类现在有一个名为getProperties
的函数,它只返回该类属性的字符串表示。然而,我们的DerivedClassWithConstructor
类还包括一个名为getProperties
的函数。该函数是getProperties
基类函数的函数覆盖。为了将调用到基类函数,我们需要包含super
关键字,如对super
的调用所示。getProperties()
。
下面是前面代码的用法示例:
window.onload = () => {
var myDerivedClass = new DerivedClassWithConstructor(1, "name");
console.log(
myDerivedClass.getProperties()
);
}
这段代码创建了一个名为myDerivedClass
的变量,并传递了id
和name
的必需参数。然后我们简单地将调用getProperties
函数的结果记录到控制台上。这段代码片段将产生以下控制台输出:
_name:name,_id:1
结果显示myDerivedClass
变量的getProperties
函数将调用基类getProperties
函数,正如预期的那样。
在继续本章的之前,让我们快速了解一下 TypeScript 如何通过一种称为闭包的技术在生成的 JavaScript 中实现类。正如我们在第 1 章、TypeScript-工具和框架选项中提到的,闭包是一个引用自变量的函数。这些变量本质上记住了它们被创建的环境。考虑以下 JavaScript 代码:
function TestClosure(value) {
this._value = value;
function printValue() {
console.log(this._value);
}
return printValue;
}
var myClosure = TestClosure(12);
myClosure();
这里,我们有一个名为TestClosure
的函数,它接受一个名为value
的参数。函数的主体首先将value
参数分配给名为this._value
的内部属性,然后定义名为printValue
的内部函数,该函数将this._value
属性的值记录到控制台。有趣的是TestClosure
函数的最后一行——我们正在返回printValue
函数。
现在看看代码片段的最后两行。我们创建了一个名为myClosure
的变量,并将调用TestClosure
函数的结果赋给它。注意,因为我们是从TestClosure
函数内部返回printValue
函数,这本质上也使得myClosure
变量成为一个函数。当我们在代码片段的最后一行执行这个函数时,它将执行内部的printValue
函数,但是记住在创建myClosure
变量时使用的12
的初始值。最后一行代码的输出将把12
的值记录到控制台。
这是闭包的本质。闭包是一种特殊的对象,它将函数与创建它的初始环境相结合。在前面的示例中,由于我们将通过value
参数传入的任何内容存储到名为this._value
的局部变量中,JavaScript 记住了创建闭包的环境,换句话说,在创建时分配给this._value
属性的任何内容都将被记住,并且可以在以后重用。
考虑到这一点,让我们来看看由 TypeScript 编译器为我们刚刚使用的BaseClassWithConstructor
类生成的 JavaScript:
var BaseClassWithConstructor = (function () {
function BaseClassWithConstructor(id) {
this._id = id;
}
BaseClassWithConstructor.prototype.getProperties = function () {
return "_id:" + this._id;
};
return BaseClassWithConstructor;
})();
我们的结束从第一行的function () {
开始,到最后一行的}
结束。这个闭包首先定义了一个用作构造函数的函数:BaseClassWithConstructor(id)
。请记住,当构造一个 JavaScript 对象时,它会将原始对象的prototype
属性继承或复制到新实例中。在我们的示例中,使用BaseClassWithConstructor
函数创建的任何对象都将继承getProperties
函数,因为它是prototype
属性的一部分。此外,因为在prototype
属性上定义的函数也在闭包内,所以它们会记住原始的执行环境和变量值。
然后,这个闭包在第一行用左括号(
包围,在最后一行用右括号)
包围——定义了所谓的 JavaScript 函数表达式。然后这个函数表达式被最后两个大括号();
立即执行。这种立即执行函数的技术被称为立即调用函数表达式(life)。然后,上面的我们的生活被分配给一个名为BaseClassWithConstructor
的变量,使其成为一个一流的 JavaScript 对象,并且可以用new
关键字创建。这就是 TypeScript 在 JavaScript 中实现类的方式。
TypeScript 用于类定义的底层 JavaScript 代码的实现实际上是一个众所周知的 JavaScript 模式——被称为模块模式。它使用闭包来捕获执行环境,并且还提供了一种公开类的公共 API 的方法,如使用prototype
属性所见。
好消息是,关于闭包、如何编写闭包以及如何使用模块模式来定义类的深入知识都将由 TypeScript 编译器来处理,这使得我们可以专注于面向对象的原则,而不必使用这种样板代码来编写 JavaScript 闭包。
为了说明我们如何在大型 TypeScript 项目中使用接口和类,我们将快速了解一个非常著名的面向对象设计模式——工厂设计模式。
举个例子,让我们假设我们的业务分析师给了我们以下要求:
您需要根据出生日期对人员进行分类,并使用true
或false
标志指出他们是否达到签署合同的法定年龄。未满 2 岁的人被视为婴儿。婴儿不能签合同。未满 18 岁的人被视为儿童。孩子也不能签合同。一个人超过 18 岁就被认为是成年人,只有成年人才能签合同。
工厂设计模式使用工厂类,根据提供给它的信息,返回几个可能的类之一的实例。
这种模式的本质是将创建什么类型的类的决策逻辑放在一个单独的类中——工厂类。工厂类将返回几个类中的一个,这些类都是彼此微妙的变体,它们将根据各自的专业做一些稍微不同的事情。为了使我们的逻辑能够工作,任何使用这些类之一的代码都必须有一个公共契约(或者属性和方法的列表),一个类的所有变体都可以实现这个契约。这是界面的完美场景。
为了实现我们所需的业务功能,我们将创建一个Infant
类、Child
类和一个Adult
类。当被问及是否可以签约时,Infant
和Child
班将返还false
,而Adult
班将返还true
。
根据我们的要求,工厂返回的类实例必须能够做两件事:以所需的格式打印该人的类别,并告诉我们他们是否可以签订合同。为了完整起见,我们将包括打印出生日期的第三个函数。让我们定义一个接口来满足这个需求:
interface IPerson {
getPersonCategory(): string;
canSignContracts(): boolean;
getDateOfBirth(): string;
}
我们的IPerson
接口有一个getPersonCategory
方法,它将返回他们类别的字符串表示:或者是“婴儿”、“儿童”或者是“成人”。canSignContracts
方法将返回true
或false
,而getDateOfBirth
方法将返回他们出生日期的可打印版本。为了简化我们的代码,我们将创建一个名为Person
的基类来实现这个接口,并将处理所有类型Person
的公共数据和函数:存储和返回出生日期。我们的基类定义如下:
class Person {
_dateOfBirth: Date
constructor(dateOfBirth: Date) {
this._dateOfBirth = dateOfBirth;
}
getDateOfBirth(): string {
return this._dateOfBirth.toDateString();
}
}
这个Person
类定义是我们每个专家类型的人的基础类。由于我们的每个专家类都需要一个getDateOfBirth
函数,我们可以将这个公共代码提取到一个基类中。构造函数需要一个日期,存储在内部变量_dateOfBirth
中,getDateOfBirth
函数返回这个转换成字符串的_dateOfBirth
。
现在为三类专科班:
class Infant extends Person implements IPerson {
getPersonCategory(): string {
return "Infant";
}
canSignContracts() { return false; }
}
class Child extends Person implements IPerson {
getPersonCategory(): string {
return "Child";
}
canSignContracts() { return false; }
}
class Adult extends Person implements IPerson
{
getPersonCategory(): string {
return "Adult";
}
canSignContracts() { return true; }
}
这个片段中的所有类都使用继承来扩展Person
类。我们的Infant
、Child
和Adult
类没有指定constructor
方法,而是从它们的基类Person
继承这个constructor
。每个类都实现了IPerson
接口,因此必须提供IPerson
接口定义所需的所有三个功能的实现。然而getDateOfBirth
函数是在Person
基类中定义的,所以这些派生类中的每一个只需要实现getPersonCategory
和canSignContracts
函数就有效了。我们可以看到我们的Infant
和Child
类返回false
为canSignContracts
,我们的Adult
类返回true
。
现在,让我们进入工厂类本身。这个类负责保存做出决策所需的所有逻辑,并返回一个Infant
、Child
或Adult
类的实例:
class PersonFactory {
getPerson(dateOfBirth: Date): IPerson {
var dateNow = new Date();
var dateTwoYearsAgo = new Date(dateNow.getFullYear()-2,
dateNow.getMonth(), dateNow.getDay());
var dateEighteenYearsAgo = new Date(dateNow.getFullYear()-18,
dateNow.getMonth(), dateNow.getDay());
if (dateOfBirth >= dateTwoYearsAgo) {
return new Infant(dateOfBirth);
}
if (dateOfBirth >= dateEighteenYearsAgo) {
return new Child(dateOfBirth);
}
return new Adult(dateOfBirth);
}
}
PersonFactory
类只有一个函数getPerson
,它返回一个类型为IPerson
的对象。此功能创建一个名为dateNow
的变量,设置为当前日期。这个dateNow
变量然后被用来计算另外两个变量,dateTwoYearsAgo
和dateEighteenYearsAgo
。然后决策逻辑接管,将输入的dateOfBirth
变量与这些日期进行比较。这个逻辑满足了我们的要求,并根据它们的出生日期返回一个新的Infant
、Child
或Adult
类的新实例。
为了说明如何使用这个PersonFactory
类,我们将使用下面的代码,包装在一个window.onload
函数中,这样我们就可以在浏览器中运行它:
window.onload = () => {
var personFactory = new PersonFactory();
var personArray: IPerson[] = new Array();
personArray.push(personFactory.getPerson(
new Date(2014, 09, 29))); // infant
personArray.push(personFactory.getPerson(
new Date(2000, 09, 29))); // child
personArray.push(personFactory.getPerson(
new Date(1950, 09, 29))); // adult
for (var i = 0; i < personArray.length; i++) {
console.log(" A person with a birth date of :"
+ personArray[i].getDateOfBirth()
+ " is categorised as : "
+ personArray[i].getPersonCategory()
+ " and can sign : "
+ personArray[i].canSignContracts());
}
}
在第 2 行,我们从创建一个变量personFactory
开始,来保存PersonFactory
类的一个新实例。第 4 行创建了一个名为personArray
的新数组,该数组是强类型的,只保存实现IPerson
接口的对象。然后,第 5 行到第 7 行使用PersonFactory
类的getPerson
函数向该数组添加值,传递出生日期。请注意,PersonFactory
类将根据我们传入的出生日期来决定返回哪种类型的对象。
第 8 行开始一个for
循环,循环通过personArray
数组,第 9 行到第 14 行使用IPerson
的接口定义调用相关函数进行打印。该代码的输出如下:
我们已经满足了我们的业务需求,同时实现了一个非常通用的设计模式。如果你发现自己在许多地方重复着同样的逻辑,试图弄清楚一个对象是否属于一个或多个类别,那么你有可能重构你的代码来使用工厂设计模式——并且避免在你的代码中重复同样的决策逻辑。
正如我们在第一章中简要讨论的,TypeScript 引入了public
和private
访问修饰符来将变量和函数标记为公共或私有。传统上,JavaScript 程序员使用一个简单的命名约定,在变量前面加上下划线(_
)来表示它们是私有变量。然而,这种命名约定并不能阻止任何人无意中修改这些变量。
让我们看一下 TypeScript 代码示例来说明这一点:
class ClassWithModifiers {
private _id: number;
private _name: string;
constructor(id: number, name: string) {
this._id = id;
this._name = name;
}
modifyId(id: number) {
this._id = id;
this.updateNameFromId();
}
private updateNameFromId() {
this._name = this._id.toString() + "_name";
}
}
var myClass = new ClassWithModifiers(1, "name");
myClass.modifyId(2);
myClass._id = 2;
myClass.updateNameFromId();
我们从一个名为ClassWithModifiers
的类开始,它有两个属性,_id
和_name
。我们已经用private
关键字标记了这些属性,以防止它们被错误修改。我们的constructor
接受一个传入的id
和name
参数,并分别为_id
和_name
的内部私有属性赋值。我们定义的下一个函数叫做modifyId
,它将允许我们用一个新值更新内部_id
变量。modifyId
函数然后调用名为updateNameFromId
的内部函数。这个函数被标记为private
,因此只允许在类定义的主体内调用它。updateNameFromId
功能只是使用新的_id
值来设置私有的_name
值。
最后四行代码向我们展示了如何使用这个类。第一行创建一个名为myClass
的变量,并将其分配给ClassWithModifiers
类的一个新实例。第二行是合法的,调用modifyId
函数。然而,第三和第四行将产生编译时错误:
error TS2107: Build: 'ClassWithModifiers._id' is inaccessible.
error TS2107: Build: 'ClassWithModifiers.updateNameFromId' is inaccessible.
TypeScript 编译器警告我们,_id
属性和updateNameFromId
函数都是不可访问的——换句话说,private
——并且不是为在类定义之外使用而设计的。
类函数默认为public
。不为属性或功能指定private
的访问修饰符将导致其访问级别默认为public
。
TypeScript 还引入了以前构造函数的速记版本,允许您直接在构造函数中使用访问修饰符指定参数。这最好用代码来描述:
class ClassWithAutomaticProperties {
constructor(public id: number, private name: string) {
}
print(): void {
console.log("id:" + this.id + " name:" + this.name);
}
}
var myAutoClass = new ClassWithAutomaticProperties(1, "name");
myAutoClass.id = 2;
myAutoClass.name = "test";
这段代码片段定义了一个名为ClassWithAutomaticProperties
的类。constructor
函数使用两个参数-一个是number
类型的id
,一个是string
类型的name
。但是,请注意id
的public
和name
的private
访问修饰符。这个简写会自动在ClassWithAutomaticProperties
类上创建一个公共id
属性,以及一个私有name
属性。
第 4 行的print
功能在console.log
功能中使用这些自动属性。我们指的是console.log
函数中的this.id
和this.name
,就像我们之前的代码示例一样。
这种简写语法仅在constructor
函数中可用。
我们可以在第 9 行看到,我们已经创建了一个名为myAutoClass
的变量,并为其分配了一个ClassWithAutomaticProperties
类的新实例。一旦这个类被实例化,它自动具有两个属性:类型号为public
的id
属性;和类型字符串的name
属性,即private
。然而,编译前面的代码会产生一个 TypeScript 编译错误:
error TS2107: Build: 'ClassWithAutomaticProperties.name' is inaccessible.
这个错误告诉我们自动属性name
被声明为private
,因此不能用于类本身之外的代码。
虽然这种创建自动成员变量的速记技术是可用的,但我认为它会使代码更难阅读。就我个人而言,我更喜欢不使用这种速记技术的更冗长的类定义。在类的顶部有一个属性列表,阅读代码的人可以立即看到这个类使用什么变量,以及它们是public
还是private
。使用构造函数的自动属性语法在某种程度上隐藏了这些参数,迫使开发人员有时重读代码来理解它。然而,无论你选择哪种语法,试着将其作为编码标准,并在你的代码库中使用相同的语法。
ECMAScript 5 引入了属性访问器的概念。这允许调用代码将一对get
和set
函数(具有相同的函数名)视为简单属性。这个概念最好通过一些简单的代码示例来理解:
class SimpleClass {
public id: number;
}
var mySimpleClass = new SimpleClass();
mySimpleClass.id = 1;
在这里,我们有一个名为SimpleClass
的类,它有一个单一的公共id
属性。当我们创建这个类的实例时,我们可以直接修改这个id
属性。现在让我们使用 ECMAScript 5 get
和set
函数来实现相同的结果:
class SimpleClassWithAccessors {
private _id: number;
get id() {
return this._id;
}
set id(value: number) {
this._id = value;
}
}
var mySimpleAccClass = new SimpleClassWithAccessors();
mySimpleClass.id = 1;
console.log("id has the value of " + mySimpleClass.id);
这个类有一个私有的_id
属性和两个函数,都叫做id
。这些函数中的第一个以get
关键字作为前缀,并简单地返回内部_id
属性的值。第二个函数以set
关键字为前缀,并接受一个value
参数。然后将内部_id
属性设置为该value
参数。
在类定义的底部,我们创建了一个名为mySimpleAccClass
的变量,它是SimpleClassWithAccessors
类的一个实例。任何使用这个类的实例的人都不会看到两个单独的名为get
和set
的函数。他们只会看到一处id
房产。当我们给这个属性赋值时,ECMAScript 5 运行时会调用set id(value)
函数,当我们检索这个属性时,运行时会调用get id()
函数。
有些浏览器不支持 ECMAScript 5(如 Internet Explorer 8),运行此代码时会导致 JavaScript 运行时错误。
静态函数是可以在类上调用的函数,而不必首先创建类的实例。这些函数本质上几乎是全局的,但是必须通过在函数名前面加上类名来调用。考虑以下 TypeScript 代码:
class ClassWithFunction {
printOne() {
console.log("1");
}
}
var myClassWithFunction = new ClassWithFunction();
myClassWithFunction.printOne();
我们从一个简单的类ClassWithFunction
开始,它有一个单一的函数printOne
。printOne
功能除了将字符串"1"
记录到控制台外,并没有真正做任何有用的事情。然而,为了使用这个函数,我们需要首先创建一个类的实例,将其分配给一个变量,然后调用这个函数。
然而,对于静态函数,我们可以直接调用函数或属性:
class StaticClass {
static printTwo() {
console.log("2");
}
}
StaticClass.printTwo();
StaticClass
的类定义包括一个名为printTwo
的单一函数,标记为static
。从代码的最后一行可以看出,我们可以调用这个函数,而无需“新建”一个StaticClass
类的实例。我们可以直接调用这个函数,只要在它前面加上类名。
类的函数和属性都可以标记为静态的。
当在你的代码库中处理所谓的“魔法字符串”时,静态属性就派上用场了。如果您依赖一个字符串在代码的不同部分包含一个特定的值,那么是时候用一个静态属性来替换这个“神奇的字符串”了。在我们前面讨论的工厂设计模式中,我们创建了专门的Person
对象,这些对象返回“婴儿”、“儿童”或“成人”作为字符串值。如果我们稍后编写代码来检查返回的字符串是否等于“婴儿”或“儿童”,如果我们将“婴儿”拼错为“因丰”,我们可能会无意中打破我们的逻辑:
if (value === "Infant") {
// do something with an infant.
}
下面是一个静态属性的例子,我们可以用它来代替那些“神奇的字符串”:
class PersonType {
static INFANT: string = "Infant";
static CHILD: string = "Child";
static ADULT: string = "Adult";
}
然后,在我们的代码基础中,我们不是对照字符串“婴儿”来检查值,而是对照静态属性来比较它们:
if (value === PersonType.INFANT) {
// do something with an infant.
}
这段代码不再依赖于“魔法弦”。弦乐“婴儿”现在被记录在一个地方。只要所有代码都使用静态属性PersonType.Infant
,就会更稳定,更抗变化。
泛型是一种编写代码的方式,可以处理任何类型的对象,但仍然保持对象类型的完整性。到目前为止,我们已经使用了接口、类和 TypeScript 的基本类型来确保我们的示例中的强类型(并且不容易出错)代码。但是,如果一个代码块需要处理任何类型的对象,会发生什么呢?
举个例子,假设我们想编写一些代码,可以迭代对象数组并返回它们的值的串联。所以,给定一个数字列表,比如[1,2,3]
,它应该返回字符串"1,2,3"
。或者,给定一个字符串列表,说["first","second","third"]
,返回一个字符串"first,second,third"
。我们可以编写一些接受类型any
值的代码,但是这可能会在我们的代码中引入错误——还记得旧金山国际机场吗?我们希望确保数组的所有元素都是相同的类型。这就是泛型发挥作用的地方。
让我们编写一个名为Concatenator
的类,它可以处理任何类型的对象,但仍然确保类型完整性保持不变。所有的 JavaScript 对象都有一个toString
函数,只要运行时需要一个字符串,这个函数就会被调用,所以让我们使用这个toString
函数来创建一个泛型类,输出数组中的所有值。
这个Concatenator
类的一般实现如下:
class Concatenator< T > {
concatenateArray(inputArray: Array< T >): string {
var returnString = "";
for (var i = 0; i < inputArray.length; i++) {
if (i > 0)
returnString += ",";
returnString += inputArray[i].toString();
}
return returnString;
}
}
我们首先注意到的是类声明的语法。这个< T >
语法是用来表示一个泛型类型的语法,在我们剩下的代码中这个泛型类型的名字是T
。concatenateArray
函数也使用这个泛型类型语法,Array < T >
。这表明inputArray
参数必须是最初用于构造此类实例的类型的数组。
要使用这个泛型类的实例,我们需要构造这个类,并通过< >
语法告诉编译器T
的实际类型是什么。在这种通用语法中,我们可以为T
的类型使用任何类型,包括基本 JavaScript 类型、TypeScript 类,甚至 TypeScript 接口:
var stringConcatenator = new Concatenator<string>();
var numberConcatenator = new Concatenator<number>();
var personConcatenator = new Concatenator<IPerson>();
注意我们用来实例化Concatenator
类的语法。在我们的第一个示例中,我们创建了一个Concatenator
泛型类的实例,并指定它应该在代码中使用T
的每个地方用类型string
替换泛型类型T
。类似地,第二个示例创建了一个Concatenator
类的实例,并指定只要代码遇到泛型类型T
,就应该使用类型number
。我们的最后一个示例显示了通用类型T
的IPerson
接口的使用。
如果我们使用这个简单的替换原则,那么对于stringConcatenator
实例(使用字符串),inputArray
参数必须是类型Array<string>
。类似地,这个泛型类的numberConcatenator
实例使用数字,因此inputArray
参数必须是数字数组。为了测试这个理论,让我们生成一个字符串数组和一个数字数组,看看如果我们试图打破这个规则,编译器会说什么:
var stringArray: string[] = ["first", "second", "third"];
var numberArray: number[] = [1, 2, 3];
var stringResult = stringConcatenator.concatenateArray(stringArray);
var numberResult = numberConcatenator.concatenateArray(numberArray);
var stringResult2 = stringConcatenator.concatenateArray(numberArray);
var numberResult2 = numberConcatenator.concatenateArray(stringArray);
我们的前两行定义了保存相关数组的stringArray
和numberArray
变量。然后我们将stringArray
变量传递给stringConcatenator
函数——没有问题。在我们的下一条线路上,我们通过numberArray
到numberConcatenator
—仍然可以。
然而,当我们试图将一个数字数组传递给stringConcatenator
时,我们的问题就开始了,它被配置为只使用字符串。同样,如果我们试图将字符串数组传递给numberConcatenator
,而numberConcatenator
被配置为只允许数字,TypeScript 将产生如下错误:
Types of property 'pop' of types 'string[]' and 'number[]' are incompatible.
Types of property 'pop' of types 'number[]' and 'string[]' are incompatible.
pop
属性是string[]
和number[]
之间的第一个不匹配属性,所以很明显,我们试图在应该使用字符串的地方传递一个数字数组,反之亦然。同样,编译器警告我们没有正确使用代码,并强迫我们在继续之前解决这些问题。
泛型上的这些约束是 TypeScript 的一个只在编译时的特性。如果我们查看生成的 JavaScript,我们将看不到任何一大堆代码来确保这些规则被传递到生成的 JavaScript 中。所有这些类型约束和泛型语法都被简单地编译掉了。在泛型的情况下,生成的 JavaScript 实际上是我们代码的一个非常简化的版本,看不到类型约束。
当我们使用泛型时,需要注意的是,泛型类或泛型函数定义中的所有代码都必须尊重T
的属性,就像它是任何类型的对象一样。让我们从这个角度来仔细看看concatenateArray
功能的实现:
class Concatenator< T > {
concatenateArray(inputArray: Array< T >): string {
var returnString = "";
for (var i = 0; i < inputArray.length; i++) {
if (i > 0)
returnString += ",";
returnString += inputArray[i].toString();
}
return returnString;
}
}
concatenateArray
函数强类型化inputArray
参数,因此它应该是Array <T>
类型。这意味着任何使用inputArray
参数的代码都只能使用所有数组共有的函数和属性,不管数组是什么类型。在这个代码示例中,我们在两个地方使用了inputArray
。
首先,在我们的 for 循环中,注意我们在哪里使用了属性。所有数组都有一个length
属性来指示数组有多少项,所以使用inputArray.length
可以在任何数组上工作,不管数组包含什么类型的对象。其次,当我们使用inputArray[i]
语法时,我们引用数组中的一个对象。这个引用实际上返回了一个类型为T
的对象。请记住,每当我们在代码中使用T
时,我们必须只使用那些对任何类型的对象T
通用的函数和属性。幸运的是,我们只使用了toString
函数,所有的 JavaScript 对象,不管是什么类型,都有一个有效的toString
函数。因此这个通用代码块将干净地编译。
让我们通过创建一个我们自己的类进入Concatenator
类来测试这种类型T
理论:
class MyClass {
private _name: string;
constructor(arg1: number) {
this._name = arg1 + "_MyClass";
}
}
var myArray: MyClass[] = [new MyClass(1), new MyClass(2), new MyClass(3)];
var myArrayConcatentator = new Concatenator<MyClass>();
var myArrayResult = myArrayConcatentator.concatenateArray(myArray);
console.log(myArrayResult);
这个例子从一个名为MyClass
的类开始,这个类有一个接受数字的constructor
。然后,它将一个名为_name
的内部变量赋值为arg1
,并与"_MyClass"
字符串连接在一起。接下来,我们创建一个名为myArray
的数组,并在这个数组中构造一些MyClass
的实例。然后我们创建一个Concatenator
类的实例,指定这个通用实例将只处理类型为MyClass
的对象。然后我们调用concatenateArray
函数,并将结果存储在名为myArrayResult
的变量中。最后,我们在控制台上打印结果。在浏览器中运行此代码将产生以下输出:
[object Object],[object Object],[object Object]
嗯,不完全是我们所期待的!这个奇怪的输出是因为一个对象的字符串表示——它不是基本的 JavaScript 类型之一——解析为[object type]
。您编写的任何自定义对象都可能需要覆盖toString
函数来提供人类可读的输出。通过在我们的类中提供一个toString
函数的覆盖,我们可以非常容易地修复这段代码,如下所示:
class MyClass {
private _name: string;
constructor(arg1: number) {
this._name = arg1 + "_MyClass";
}
toString(): string {
return this._name;
}
}
在上面的代码中,我们用我们自己的实现替换了所有 JavaScript 对象继承的默认toString
函数。在这个函数中,我们简单地返回了_name
私有变量的值。现在运行该示例会产生预期的结果:
1_MyClass,2_MyClass,3_MyClass
当使用泛型时,有时希望将T
的类型限制为仅特定的类型或类型的子集。在这些情况下,我们不希望我们的通用代码适用于任何类型的对象,我们只希望它适用于特定的对象子集。TypeScript 使用继承来用泛型实现这一点。举个例子,让我们重构我们早期的工厂设计模式代码,使用一个通用的PersonPrinter
类,这个类是专门为实现IPerson
接口的类而设计的:
class PersonPrinter< T extends IPerson> {
print(arg: T) {
console.log("Person born on "
+ arg.getDateOfBirth()
+ " is a "
+ arg.getPersonCategory()
+ " and is " +
this.getPermissionString(arg)
+ "allowed to sign."
);
}
getPermissionString(arg: T) {
if (arg.canSignContracts())
return "";
return "NOT ";
}
}
在这段代码中,我们定义了一个名为PersonPrinter
的类,它使用了泛型语法。请注意,T
泛型类型来自于IPerson
界面,如< T extents IPerson >
中的extends
关键字所示。这表明任何类型的使用T
将替代接口IPerson
,因此,仅允许在使用T
的地方使用在IPerson
接口中定义的功能或属性。print
函数接受名为arg
的参数,该参数的类型为T
。使用我们的泛型规则,我们知道变量arg
的任何使用都只允许使用IPerson
接口的可用函数。
print
功能建立字符串登录控制台,只使用IPerson
界面定义的功能。这些包括功能getDateOfBirth
和getPersonCategory
。为了生成语法正确的句子,我们引入了另一个名为getPermissionString
的函数,它接受类型为T
的参数,或者IPerson
接口。该功能只需使用IPerson
界面的canSignContracts()
功能返回空白字符串或字符串"NOT "
。
为了说明这个类的用法,请考虑下面的代码:
window.onload = () => {
var personFactory = new PersonFactory();
var personPrinter = new PersonPrinter<IPerson>();
var child = personFactory.getPerson(new Date(2010, 0, 21));
var adult = personFactory.getPerson(new Date(1969, 0, 21));
var infant = personFactory.getPerson(new Date(2014, 0, 21));
console.log(personPrinter.print(adult));
console.log(personPrinter.print(child));
console.log(personPrinter.print(infant));
}
首先,我们创建一个PersonFactory
类的新实例。然后我们创建一个泛型PersonPrinter
类的实例,并将参数T
的类型设置为IPerson
类型。这意味着任何传递到PersonPrinter
实例的类都必须实现IPerson
接口。我们从前面的例子中知道PersonFactory
将返回一个Infant
、Child
或Adult
类的实例,并且这些类中的每一个都实现了IPerson
接口。因此,我们知道PersonFactory
返回的任何类都将被personPrinter
泛型类实例接受。
接下来,我们实例化名为child
、adult
和infant
的变量,并依靠PersonFactory
根据它们的出生日期返回给我们正确的类。该示例的最后三行简单地将personPrinter
泛型类实例生成的句子记录到控制台。
该代码的输出如我们所料:
泛型人员工厂输出
我们也可以使用带有泛型语法的接口。对于我们的PersonPrinter
类,匹配的接口定义将是:
interface IPersonPrinter<T extends IPerson> {
print(arg: T) : void;
getPermissionString(arg: T): string;
}
这个接口看起来和我们的类定义一样,唯一的区别是print
和getPermissionString
函数没有实现。我们保留了使用< T >
的泛型类型语法,并进一步规定类型T
必须实现IPerson
接口。为了将该接口用于PersonPrinter
类,我们修改了类定义,如下所示:
class PersonPrinter<T extends IPerson> implements IPersonPrinter<T> {
}
这个语法看起来很简单。正如我们之前看到的,我们在类定义后面使用implements
关键字,然后使用接口名称。但是,请注意,我们将类型T
作为泛型类型IPersonPrinter<T>
传递到IPersonPrinter
的接口定义中。这满足了IPersonPrinter
通用接口定义。
定义泛型类的接口进一步保护我们的代码不被无意中修改。作为一个例子,假设我们试图重新定义PersonPrinter
的类定义,使得T
不被约束为IPerson
类型:
class PersonPrinter<T> implements IPersonPrinter<T> {
}
在这里,我们已经为PersonPrinter
类移除了类型T
上的约束。TypeScript 将自动生成一个错误:
Type 'T' does not satisfy the constraint 'IPerson' for type parameter 'T extends IPerson'.
这个错误把我们引向了错误的类定义;代码(PersonPrinter<T>
)中使用的类型T
必须使用从IPerson
延伸的类型T
。
有时,泛型类可能需要创建一个作为泛型类型T
传入的类型的对象。考虑以下代码:
class FirstClass {
id: number;
}
class SecondClass {
name: string;
}
class GenericCreator< T > {
create(): T {
return new T();
}
}
var creator1 = new GenericCreator<FirstClass>();
var firstClass: FirstClass = creator1.create();
var creator2 = new GenericCreator<SecondClass>();
var secondClass : SecondClass = creator2.create();
这里,我们有两个类定义,FirstClass
和SecondClass
。FirstClass
刚好有一个公共id
物业,SecondClass
有一个公共name
物业。然后我们有一个泛型类,它接受一个类型T
并且有一个单一的函数,命名为create
。该create
函数试图创建类型T
的新实例。
示例的最后四行向我们展示了如何使用这个泛型类。creator1
变量使用创建类型为FirstClass
的变量的正确语法创建GenericCreator
类的新实例。creator2
变量是GenericCreator
类的新实例,但这次使用的是SecondClass
。不幸的是,前面的代码将生成一个 TypeScript 编译错误:
error TS2095: Build: Could not find symbol 'T'.
根据 TypeScript 文档,为了使泛型类能够创建类型为T
的对象,我们需要通过其constructor
函数引用类型T
。我们还需要传入类定义作为参数。create
功能需要改写如下:
class GenericCreator< T > {
create(arg1: { new(): T }) : T {
return new arg1();
}
}
让我们把这个create
功能分解成它的组成部分。首先,我们传递一个参数,命名为arg1
。这个论点然后被定义为类型{ new(): T }
。这就是让我们通过constructor
功能来参考T
的小技巧。我们正在定义一个新的匿名类型,它重载new()
函数并返回一个类型T
。这意味着arg1
参数是一个强类型的函数,具有一个返回类型T
的单个constructor
。这个函数的实现只是返回arg1
变量的一个新实例。使用此语法可以消除我们之前遇到的编译错误。
但是,这种变化意味着我们必须将类定义传递给 create 函数,如下所示:
var creator1 = new GenericCreator<FirstClass>();
var firstClass: FirstClass = creator1.create(FirstClass);
var creator2 = new GenericCreator<SecondClass>();
var secondClass : SecondClass = creator2.create(SecondClass);
注意第 2 行和第 5 行create
功能用法的变化。现在,我们需要传入类型为T
: create(FirstClass)
和create(SecondClass)
的类定义作为我们的第一个参数。尝试在浏览器中运行这段代码,看看会发生什么。正如我们所料,泛型类实际上将创建类型为FirstClass
和SecondClass
的新对象。
虽然 TypeScript 编译器会为错误键入的代码生成编译错误,但这种类型检查是在生成的 JavaScript 中编译掉的。这意味着 JavaScript 运行时引擎对 TypeScript 接口或泛型一无所知。那么我们如何在运行时判断一个类是否实现了接口呢?
JavaScript 有一些我们在处理对象时可以使用的函数,这些函数会告诉我们一个对象是什么类型,或者一个对象是否是另一个对象的实例。对于类型信息,我们可以使用 JavaScript typeof
关键字,对于实例信息,我们可以使用instanceof
。给定一些简单的 TypeScript 类,让我们看看这些函数返回了什么,看看我们是否可以用这些来判断一个类是否实现了一个接口。
首先,一个简单的基类:
class TcBaseClass {
id: number;
constructor(idArg: number) {
this.id = idArg;
}
}
这个TcBaseClass
类有一个id
属性和一个constructor
根据传递给它的参数设置这个属性。
然后,一个从TcBaseClass
派生的类:
class TcDerivedClass extends TcBaseClass {
name: string;
constructor(idArg: number, nameArg: string) {
super(idArg);
this.name = name;
}
print() {
console.log(this.id + " " + this.name);
}
}
这个TcDerivedClass
类从TcBase
类派生(或扩展),并添加了一个name
属性和一个print
函数。这个派生类的构造函数必须调用基类的构造函数,通过super
函数传入idArg
参数。
现在,让我们构造一个名为base
的变量,它是TcBaseClass
的新实例,然后构造一个名为derived
的变量,它是TcDerivedClass
的新实例,如下所示:
var base = new TcBaseClass(1);
var derived = new TcDerivedClass(2, "second");
现在进行一些测试;让我们看看typeof
函数为每个类返回了什么:
console.log("typeof base: " + typeof base);
console.log("typeof derived: " + typeof derived);
该代码将返回:
typeof base: object
typeof derived: object
这告诉我们,JavaScript 运行时引擎将类的实例视为对象。
现在让我们切换到instanceof
关键字,并使用它来检查一个对象是否是从另一个导出的:
console.log("base instance of TcBaseClass : " + (base instanceof TcBaseClass));
console.log("derived instance of TcBaseClass: " + (derived instanceof TcBaseClass));
该代码将返回:
base instance of TcBaseClass : true
derived instance of TcBaseClass: true
目前为止一切顺利。现在让我们看看typeof
关键字在类的属性上使用时会返回什么:
console.log("typeof base.id: " + typeof base.id);
console.log("typeof derived.name: " + typeof derived.name);
console.log("typeof derived.print: " + typeof derived.print);
该代码将返回:
typeof base.id: number
typeof derived.name: string
typeof derived.print: function
正如我们所看到的,JavaScript 运行时正确地将我们的基本类型的id
属性标识为数字,name
属性标识为字符串,print
属性标识为函数。
那么我们如何在运行时告诉一个物体的类型是什么呢?简单的答案就是我们不容易分辨。我们只能判断一个对象是否是另一个对象的实例,或者一个属性是否是基本的 JavaScript 类型之一。如果我们试图使用instanceof
函数来实现一个类型检查算法,我们将需要对照我们的对象树中的每个已知类型来检查传入的对象,这肯定是不理想的。我们也不能用instanceof
来检查一个类是否实现了一个接口,因为 TypeScript 接口是被编译掉的。
其他静态类型的语言允许运行时引擎查询对象,确定对象的类型,并查询对象实现的接口。这个过程叫做反射。
正如我们已经看到的,使用typeof
或instanceof
JavaScript 函数,我们可以从运行时收集一些关于对象的信息。除了这些能力之外,我们还可以使用getPrototypeOf
函数返回一些关于类构造函数的信息。getPrototypeOf
函数返回一个字符串,因此我们可以解析这个字符串来确定类名。不幸的是,getPrototypeOf
函数的实现返回的字符串略有不同,这取决于所使用的浏览器。它也只在 ECMAScript 5.1 及以上版本中实现,同样,在旧浏览器或移动浏览器上运行时可能会带来问题。
另一个我们可以用来查找对象运行时信息的 JavaScript 函数是hasOwnProperty
函数。自 ECMAScript 3 以来,这一直是 JavaScript 的一部分,因此几乎与所有浏览器兼容,包括桌面和移动浏览器。hasOwnProperty
功能将返回true
或false
,指示一个对象是否具有您正在寻找的属性。
TypeScript 编译器帮助我们使用接口以面向对象的方式编程 JavaScript,但是这些接口是“编译掉”的,不会出现在生成的 JavaScript 中。作为一个例子,让我们看看下面的 TypeScript 代码:
interface IBasicObject {
id: number;
name: string;
print(): void;
}
class BasicObject implements IBasicObject {
id: number;
name: string;
constructor(idArg: number, nameArg: string) {
this.id = idArg;
this.name = nameArg;
}
print() {
console.log("id:" + this.id + ", name" + this.name);
}
}
这是一个定义接口并在类中实现它的简单示例。IBasicObject
界面有一个number
类型的id
,一个string
类型的name
,以及一个print
功能。类定义BasicObject
实现所有需要的属性和参数。现在让我们来看看 TypeScript 生成的已编译的 JavaScript:
var BasicObject = (function () {
function BasicObject(idArg, nameArg) {
this.id = idArg;
this.name = nameArg;
}
BasicObject.prototype.print = function () {
console.log("id:" + this.id + ", name" + this.name);
};
return BasicObject;
})();
TypeScript 编译器没有为IBasicObject
接口包含任何 JavaScript。我们这里只有BasicObject
类定义的闭包模式。IBasicObject
接口虽然由 TypeScript 编译器使用,但在生成的 JavaScript 中并不存在。因此,我们说它已被“编走”。
因此,当在 JavaScript 中实现类似反射的功能时,这给我们带来了一些问题:
- 我们无法在运行时判断一个对象是否实现了 TypeScript 接口,因为 TypeScript 接口是被编译掉的
- 我们不能在旧的 ECMAScript 3 浏览器上使用
getOwnPropertyNames
函数来循环一个对象的属性 - 我们不能在旧的 ECMAScript 3 浏览器上使用
getPrototypeOf
函数来确定类名 getPrototypeOf
功能的实现在不同浏览器之间并不一致- 我们不能在不与已知类型进行比较的情况下使用
instanceof
关键字来确定类类型
那么我们如何在运行时判断一个对象是否实现了接口呢?
在他们的书中, Pro JavaScript 设计模式(http://jsdesignpatterns.com/)、罗斯·哈姆斯和达斯汀·迪亚兹讨论了这个困境,并提出了一个相当简单的解决方案。我们可以使用包含函数名的字符串在对象上调用一个函数,然后检查结果是否有效,或者undefined
。在他们的书中,他们利用这个原理构建了一个实用函数,在运行时检查一个对象是否有一组定义好的属性和方法。这些定义的属性和方法作为简单的字符串数组保存在 JavaScript 代码中。因此,这些字符串数组充当我们代码的对象“元数据”,然后我们可以将其传递给函数检查实用程序。
他们的FunctionChecker
实用程序类可以用如下的 TypeScript 编写:
class FunctionChecker {
static implementsFunction(
objectToCheck: any, functionName: string): boolean
{
return (objectToCheck[functionName] != undefined &&
typeof objectToCheck[functionName] == 'function');
}
}
这个FunctionChecker
类有一个名为implementsFunction
的静态函数,它将返回true
或false
。implementsFunction
函数接受名为objectToCheck
的参数和名为functionName
的字符串。注意objectToCheck
的类型专门设置为any
。这是使用any
类型实际上是正确的 TypeScript 类型的罕见情况之一。
在implementsFunction
函数中,我们使用一种特殊的 JavaScript 语法,它从对象中读取函数本身,在对象的一个实例上使用[ ]
语法,并通过名称引用它:objectToCheck[functionName]
。如果我们询问的对象有这个属性,那么调用它将返回除undefined
以外的东西。然后我们可以使用typeof
关键字来检查属性的类型。如果typeof
实例返回“函数”,那么我们知道这个对象实现了这个函数。让我们来看看一些快速用法:
var myClass = new BasicObject(1, "name");
var isValidFunction = FunctionChecker.implementsFunction(
myClass, "print");
console.log("myClass implements the print() function :" + isValidFunction);
isValidFunction = FunctionChecker.implementsFunction(
myClass, "alert");
console.log("myClass implements the alert() function :" + isValidFunction);
第 1 行,简单地创建一个BasicObject
类的实例,并将其分配给myClass
变量。第 2 行然后调用我们的implementsFunction
函数,传入类的实例和字符串“print”。第 3 行将结果记录到控制台。第 4 行和第 5 行重复该过程,但检查myClass
实例是否实现了功能“alert”。该代码的结果如下:
myClass implements the print() function :true
myClass implements the alert() function :false
这个implementsFunction
功能可以让我们通过名称来询问一个对象,检查它是否有特定的功能。稍微扩展一下这个概念,我们可以得到一种执行运行时类型检查的简单方法。我们所需要的是一个 JavaScript 对象应该实现的函数(或属性)列表。这个函数(或属性)列表可以被描述为类“元数据”。
罗斯和达斯汀描述的这种保存关于接口的“元数据”信息的技术,很容易在 TypeScript 中实现。如果我们为我们的每个接口定义保存这种“元数据”的类,那么我们可以在运行时使用它们来检查对象。让我们将一个接口放在一起,该接口包含一个用来检查对象的方法名数组,以及一个属性名列表。
interface IInterfaceChecker {
methodNames?: string[];
propertyNames?: string[];
}
这个IInterfaceChecker
界面非常简单——可选的methodNames
数组,可选的propertyNames
数组。现在让我们实现这个接口来描述 TypeScript IBasicObject
接口的必要属性和方法:
class IIBasicObject implements IInterfaceChecker {
methodNames: string[] = ["print"];
propertyNames: string[] = ["id", "name"];
}
我们从实现IInterfaceChecker
接口的类定义开始。这个类被命名为IIBasicObject
,类名中有一个双I
前缀。这是一个简单的命名约定,表明IIBasicObject
类保存了我们之前定义的IBasicObject
接口的“元数据”。methodNames
数组指定该接口必须实现print
方法,propertyNames
数组指定该接口还包括一个id
和一个name
属性。
这种为对象定义元数据的方法是解决我们问题的一个非常简单的方法,并且与浏览器无关,也与 ECMAScript 版本无关。虽然这可能需要我们将“元数据”对象与 TypeScript 接口保持同步,但我们现在有了检查对象是否实现了定义的接口所需的东西。
我们还可以使用我们所知道的泛型来实现一个InterfaceChecker
类,该类使用这些对象“元数据”类:
class InterfaceChecker<T extends IInterfaceChecker> {
implementsInterface(
classToCheck: any,
t: { new (): T; }
): boolean
{
var targetInterface = new t();
var i, len: number;
for (i = 0, len = targetInterface.methodNames.length; i < len; i++) {
var method: string = targetInterface.methodNames[i];
if (!classToCheck[method] ||
typeof classToCheck[method] !== 'function') {
console.log("Function :" + method + " not found");
return false;
}
}
for (i = 0, len = targetInterface.propertyNames.length; i < len; i++) {
var property: string = targetInterface.propertyNames[i];
if (!classToCheck[property] ||
typeof classToCheck[property] == 'function') {
console.log("Property :" + property + " not found");
return false;
}
}
return true;
}
}
var myClass = new BasicObject(1, "name");
var interfaceChecker = new InterfaceChecker();
var isValid = interfaceChecker.implementsInterface(myClass, IIBasicObject);
console.log("myClass implements the IIBasicObject interface :" + isValid);
我们从一个名为InterfaceChecker
的泛型类开始,它接受任何实现IInterfaceChecker
类的对象T
。同样,IInterface
类的定义只是一组methodNames
和一组propertyNames
。这个类只有一个名为implementsInterface
的函数,它返回一个布尔值——如果这个类实现了所有的属性和方法,则为 true,否则为 false。第一个参数classToCheck
是我们针对接口“元数据”询问的类实例。我们的第二个参数使用我们之前讨论过的通用语法,以便能够创建类型T
的新实例,在本例中,该类型是实现IInterfaceChecker
接口的任何类型。
代码的主体是我们前面讨论的FunctionChecker
类的扩展。我们首先需要创建类型T
的实例,它被分配给变量targetInterface
。然后我们简单地遍历methodNames
数组中的所有字符串,并检查我们的classToCheck
对象是否实现了这些功能。
然后我们重复这个过程,检查propertyNames
数组中的给定字符串。
这个代码示例的最后几行向我们展示了如何使用这个InterfaceChecker
类。首先,我们创建一个BasicObject
的实例,并将其分配给变量myClass
。然后我们创建一个InterfaceChecker
类的实例,并将其分配给变量interfaceChecker
。
这个片段的最后一行调用implementsInterface
函数,传入myClass
实例和IIBasicObject
。请注意,我们不是在传递IIBasicObject
类的实例,我们只是在传递类定义。我们的通用代码将创建一个IIBasicObject
类的内部实例。
这段代码的最后一行只是向控制台记录一条true
或false
消息。该行的输出将是:
myClass implements the IIBasicObject interface :true
现在让我们用一个无效的对象运行代码:
var noPrintFunction = { id: 1, name: "name" };
isValid = interfaceChecker.implementsInterface(
noPrintFunction, IIBasicObject);
console.log("noPrintFunction implements the IIBasicObject interface:" + isValid);
变量noPrintFunction
有一个id
和一个name
属性,但是它没有实现print
函数。该代码的输出将是:
Function :print not found
noPrintFunction implements the IIBasicObject interface :false
我们现在有一种方法可以在运行时确定一个对象是否实现了一个已定义的接口。这种技术可以用在您无法控制的外部 JavaScript 库上,甚至可以用在更大的团队中,在编写库之前,原则上同意特定库的 API。在这些情况下,一旦交付了库的新版本,消费者就可以快速轻松地确保 API 符合设计规范。
接口用于许多设计模式,即使我们可以使用 TypeScript 实现这些模式,我们也可能希望通过对对象的接口进行运行时检查来进一步固化我们的代码。这种技术也为在 TypeScript 中编写控制反转 ( IOC )容器或者域事件模式的实现提供了可能。我们将在第 8 章、带 TypeScript 的面向对象编程中更详细地探讨这两种设计模式。
在本章中,我们探讨了接口、类和泛型的面向对象概念。我们讨论了接口继承和类继承,并使用我们关于接口、类和继承的知识在 TypeScript 中创建了一个工厂设计模式实现。然后,我们继续讨论泛型及其语法、泛型接口和泛型构造函数。我们以对反射的讨论结束了这一章,并使用泛型实现了一个InterfaceChecker
模式的 TypeScript 版本。在下一章中,我们将研究 TypeScript 用来与现有 JavaScript 库(定义文件)集成的机制。