Skip to content

Latest commit

 

History

History
1350 lines (975 loc) · 71.7 KB

3.md

File metadata and controls

1350 lines (975 loc) · 71.7 KB

三、接口、类和泛型

我们已经看到了 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类型。然后,我们比较complexTypecomplexType_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接口只提到了idname属性——所以只要我们有这些属性,这个对象就被称为实现了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");

这个版本的代码将idname属性作为类构造函数的一部分传递。然而,我们的类定义需要包含一个新的函数,命名为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作为参数。这些参数分别被强类型化为numberstring类型。然后将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 行构成了我们现有的界面定义,包括idname属性以及我们一直使用到现在的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,因此它实际上有两个属性:idnameDerivedClass的类定义实现了这个IDerivedFromBase接口,因此必须同时包括idname属性——以便成功实现IDerivedFromBase接口的所有属性。虽然我们在这个例子中只显示了属性,但是同样的规则也适用于函数。

类继承

类也可以像接口一样使用继承。使用我们对IBaseIDerivedFromBase接口的定义,下面的代码显示了一个类继承的例子:

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属性的定义。

函数和构造函数重载用 super

使用继承时,经常需要用定义的构造函数创建一个基类。然后,在任何派生的类的构造函数中,我们需要调用基类构造函数并传递这些参数。这被称为构造函数重载。换句话说,派生类的构造函数重载或“取代”基类的构造函数。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的变量,并传递了idname的必需参数。然后我们简单地将调用getProperties函数的结果记录到控制台上。这段代码片段将产生以下控制台输出:

_name:name,_id:1

结果显示myDerivedClass变量的getProperties函数将调用基类getProperties函数,正如预期的那样。

JavaScript 闭包

在继续本章的之前,让我们快速了解一下 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 项目中使用接口和类,我们将快速了解一个非常著名的面向对象设计模式——工厂设计模式。

业务需求

举个例子,让我们假设我们的业务分析师给了我们以下要求:

您需要根据出生日期对人员进行分类,并使用truefalse标志指出他们是否达到签署合同的法定年龄。未满 2 岁的人被视为婴儿。婴儿不能签合同。未满 18 岁的人被视为儿童。孩子也不能签合同。一个人超过 18 岁就被认为是成年人,只有成年人才能签合同。

工厂设计模式是做什么的

工厂设计模式使用工厂类,根据提供给它的信息,返回几个可能的类之一的实例。

这种模式的本质是将创建什么类型的类的决策逻辑放在一个单独的类中——工厂类。工厂类将返回几个类中的一个,这些类都是彼此微妙的变体,它们将根据各自的专业做一些稍微不同的事情。为了使我们的逻辑能够工作,任何使用这些类之一的代码都必须有一个公共契约(或者属性和方法的列表),一个类的所有变体都可以实现这个契约。这是界面的完美场景。

为了实现我们所需的业务功能,我们将创建一个Infant类、Child类和一个Adult类。当被问及是否可以签约时,InfantChild班将返还false,而Adult班将返还true

IPerson 接口和 Person 基类

根据我们的要求,工厂返回的类实例必须能够做两件事:以所需的格式打印该人的类别,并告诉我们他们是否可以签订合同。为了完整起见,我们将包括打印出生日期的第三个函数。让我们定义一个接口来满足这个需求:

interface IPerson {
    getPersonCategory(): string;
    canSignContracts(): boolean;
    getDateOfBirth(): string;
}

我们的IPerson接口有一个getPersonCategory方法,它将返回他们类别的字符串表示:或者是“婴儿”、“儿童”或者是“成人”。canSignContracts方法将返回truefalse,而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类。我们的InfantChildAdult类没有指定constructor方法,而是从它们的基类Person继承这个constructor。每个类都实现了IPerson接口,因此必须提供IPerson接口定义所需的所有三个功能的实现。然而getDateOfBirth函数是在Person基类中定义的,所以这些派生类中的每一个只需要实现getPersonCategorycanSignContracts函数就有效了。我们可以看到我们的InfantChild类返回falsecanSignContracts,我们的Adult类返回true

工厂班

现在,让我们进入工厂类本身。这个类负责保存做出决策所需的所有逻辑,并返回一个InfantChildAdult类的实例:

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变量然后被用来计算另外两个变量,dateTwoYearsAgodateEighteenYearsAgo。然后决策逻辑接管,将输入的dateOfBirth变量与这些日期进行比较。这个逻辑满足了我们的要求,并根据它们的出生日期返回一个新的InfantChildAdult类的新实例。

使用工厂类

为了说明如何使用这个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的接口定义调用相关函数进行打印。该代码的输出如下:

Using the Factory class

我们已经满足了我们的业务需求,同时实现了一个非常通用的设计模式。如果你发现自己在许多地方重复着同样的逻辑,试图弄清楚一个对象是否属于一个或多个类别,那么你有可能重构你的代码来使用工厂设计模式——并且避免在你的代码中重复同样的决策逻辑。

类别修饰符

正如我们在第一章中简要讨论的,TypeScript 引入了publicprivate访问修饰符来将变量和函数标记为公共或私有。传统上,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接受一个传入的idname参数,并分别为_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。但是,请注意idpublicnameprivate访问修饰符。这个简写会自动在ClassWithAutomaticProperties类上创建一个公共id属性,以及一个私有name属性。

第 4 行的print功能在console.log功能中使用这些自动属性。我们指的是console.log函数中的this.idthis.name,就像我们之前的代码示例一样。

这种简写语法仅在constructor函数中可用。

我们可以在第 9 行看到,我们已经创建了一个名为myAutoClass的变量,并为其分配了一个ClassWithAutomaticProperties类的新实例。一旦这个类被实例化,它自动具有两个属性:类型号为publicid属性;和类型字符串的name属性,即private。然而,编译前面的代码会产生一个 TypeScript 编译错误:

error TS2107: Build: 'ClassWithAutomaticProperties.name' is inaccessible.

这个错误告诉我们自动属性name被声明为private,因此不能用于类本身之外的代码。

虽然这种创建自动成员变量的速记技术是可用的,但我认为它会使代码更难阅读。就我个人而言,我更喜欢不使用这种速记技术的更冗长的类定义。在类的顶部有一个属性列表,阅读代码的人可以立即看到这个类使用什么变量,以及它们是public还是private。使用构造函数的自动属性语法在某种程度上隐藏了这些参数,迫使开发人员有时重读代码来理解它。然而,无论你选择哪种语法,试着将其作为编码标准,并在你的代码库中使用相同的语法。

类属性访问器

ECMAScript 5 引入了属性访问器的概念。这允许调用代码将一对getset函数(具有相同的函数名)视为简单属性。这个概念最好通过一些简单的代码示例来理解:

class SimpleClass {
    public id: number;
}

var mySimpleClass = new SimpleClass();
mySimpleClass.id = 1;

在这里,我们有一个名为SimpleClass的类,它有一个单一的公共id属性。当我们创建这个类的实例时,我们可以直接修改这个id属性。现在让我们使用 ECMAScript 5 getset函数来实现相同的结果:

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类的一个实例。任何使用这个类的实例的人都不会看到两个单独的名为getset的函数。他们只会看到一处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开始,它有一个单一的函数printOneprintOne功能除了将字符串"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 >语法是用来表示一个泛型类型的语法,在我们剩下的代码中这个泛型类型的名字是TconcatenateArray函数也使用这个泛型类型语法,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。我们的最后一个示例显示了通用类型TIPerson接口的使用。

如果我们使用这个简单的替换原则,那么对于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);

我们的前两行定义了保存相关数组的stringArraynumberArray变量。然后我们将stringArray变量传递给stringConcatenator函数——没有问题。在我们的下一条线路上,我们通过numberArraynumberConcatenator—仍然可以。

然而,当我们试图将一个数字数组传递给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 型

当我们使用泛型时,需要注意的是,泛型类或泛型函数定义中的所有代码都必须尊重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 的类型

当使用泛型时,有时希望将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界面定义的功能。这些包括功能getDateOfBirthgetPersonCategory。为了生成语法正确的句子,我们引入了另一个名为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将返回一个InfantChildAdult类的实例,并且这些类中的每一个都实现了IPerson接口。因此,我们知道PersonFactory返回的任何类都将被personPrinter泛型类实例接受。

接下来,我们实例化名为childadultinfant的变量,并依靠PersonFactory根据它们的出生日期返回给我们正确的类。该示例的最后三行简单地将personPrinter泛型类实例生成的句子记录到控制台。

该代码的输出如我们所料:

Constraining the type of T

泛型人员工厂输出

通用接口

我们也可以使用带有泛型语法的接口。对于我们的PersonPrinter类,匹配的接口定义将是:

interface IPersonPrinter<T extends IPerson> {
    print(arg: T) : void;
    getPermissionString(arg: T): string;
}

这个接口看起来和我们的类定义一样,唯一的区别是printgetPermissionString函数没有实现。我们保留了使用< 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();

这里,我们有两个类定义,FirstClassSecondClassFirstClass刚好有一个公共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)的类定义作为我们的第一个参数。尝试在浏览器中运行这段代码,看看会发生什么。正如我们所料,泛型类实际上将创建类型为FirstClassSecondClass的新对象。

运行时类型检查

虽然 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 接口是被编译掉的。

倒影

其他静态类型的语言允许运行时引擎查询对象,确定对象的类型,并查询对象实现的接口。这个过程叫做反射。

正如我们已经看到的,使用typeofinstanceof JavaScript 函数,我们可以从运行时收集一些关于对象的信息。除了这些能力之外,我们还可以使用getPrototypeOf函数返回一些关于类构造函数的信息。getPrototypeOf函数返回一个字符串,因此我们可以解析这个字符串来确定类名。不幸的是,getPrototypeOf函数的实现返回的字符串略有不同,这取决于所使用的浏览器。它也只在 ECMAScript 5.1 及以上版本中实现,同样,在旧浏览器或移动浏览器上运行时可能会带来问题。

另一个我们可以用来查找对象运行时信息的 JavaScript 函数是hasOwnProperty函数。自 ECMAScript 3 以来,这一直是 JavaScript 的一部分,因此几乎与所有浏览器兼容,包括桌面和移动浏览器。hasOwnProperty功能将返回truefalse,指示一个对象是否具有您正在寻找的属性。

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的静态函数,它将返回truefalseimplementsFunction函数接受名为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类的内部实例。

这段代码的最后一行只是向控制台记录一条truefalse消息。该行的输出将是:

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 库(定义文件)集成的机制。