TypeScript

了解 TypeScript

Peter Vogel

在许多方面,考虑 TypeScript 自身的优点非常有用。TypeScript 语言规范将 TypeScipt 称为“JavaScript 的语法修饰”。这是事实,并且可能是到达此语言目标受众(目前使用 JavaScript 的客户端开发人员)的重要步骤。

您需要先了解 JavaScript,才能了解 TypeScript。事实上,语言规范(请访问 bit.ly/1xH1m5B 阅读此文章)通常按照生成的 JavaScript 代码来描述 TypeScript 构造。但是,将 TypeScript 看作是一种与 JavaScript 共享功能的独立语言同样非常有用。

例如,与 C# 一样,TypeScript 是一种数据类型化的语言,可提供 IntelliSense 支持和编译时检查功能等很多功能。与 C# 一样,TypeScript 包括泛型和 lambda 表达式(或其等效表达式)。

但 TypeScript 当然不是 C#。了解 TypeScript 的独特功能和了解 TypeScript 与您当前正在使用的服务器端语言所共享的内容同样重要。TypeScript 类型系统不同于 C#(比 C# 更简单)。TypeScript 以独特的方式利用其对其他对象模型的了解,并以不同于 C# 的方式执行继承。此外,与 C# 不同的是,由于 TypeScript 编译为 JavaScript,TypeScript 与 JavaScript 共享很多基础知识。

接下来的问题是,“您愿意用该语言还是用 JavaScript 来编写客户端代码?”

TypeScript 为数据类型

TypeScript 并没有很多可用于声明变量的内置数据类型,只有字符串、数字和布尔。这三种类型都是 any 类型(any 类型在声明变量时也可以使用)的子类型。您可以针对 null 或未定义的类型来设置或测试通过这四种类型声明的变量。您还可以将这些方法声明为 void,来指明他们未返回值。

此示例将一个变量声明为字符串:

var name: string;

您可以使用枚举值和四种对象类型来扩展此简单类型系统:接口、类、数组和函数。例如,以下代码使用名称 ICustomerShort 定义了一个接口(一种对象类型)。该接口包含两个成员:属性 Id 和方法 CalculateDiscount:

interface ICustomerShort
{
  Id: number;
  CalculateDiscount(): number;
}

正如在 C# 中一样,在声明变量时,您可以使用接口并返回类型。此示例将变量 cs 声明为类型 ICustomerShort:

var cs: ICustomerShort;

您还可以将对象类型定义为类,类不同于接口,可以包含可执行代码。此示例使用一个属性和一个方法定义了 CustomerShort 类:

class CustomerShort
{
  FullName: string;
  UpdateStatus( status: string ): string
  {
    ...manipulate status... 
    return status;
  }
}

类似于 C# 的较新版本,在定义属性时,不需要提供实现代码。此名称和类型的简单声明就足够了。类可以实现一个或多个接口,如图 1 中所示,通过其属性将我的 ICustomerShort 接口添加到我的 CustomerShort 类。

图 1 将接口添加到类

class CustomerShort implements ICustomerShort
{
  Id: number;
  FullName: string;
  UpdateStatus(status: string): string
  {
    ...manipulate status...
    return status;
  }
  CalculateDiscount(): number
  {
    var discAmount: number;
    ...calculate discAmount...
    return discAmount;
  }
}

图 1 所示,在 TypeScript 中实现接口的语法与在 C# 中的实现一样简单。要实现此接口的成员,您只需添加带有相同名称的成员,而无需将接口名称绑定到相关类成员。在此示例中,我只将 Id 和 CalculateDiscount 添加到了类以实现 ICustomerShort。TypeScript 还允许您使用对象类型文字。此代码将变量 cst 设置为包含一个属性和一个方法的对象文字:

var csl = {
            Age: 61,
            HaveBirthday(): number
          {
            return this.Age++;
          }
        };

此示例使用对象类型指定 UpdateStatus 方法的返回值:

UpdateStatus( status: string ): { 
  status: string; valid: boolean }
{
  return {status: "New",
          valid: true
         };
}

除了对象类型(类、接口、文字和数组)之外,您还可以定义描述函数签名的函数类型。以下代码对我的 CustomerShort 类的 CalculateDiscount 进行重写以接受名为 discountAmount 的单个参数:

interface ICustomerShort
{
  Id: number;
  CalculateDiscount( discountAmount:
    ( discountClass: string, 
      multipleDiscount: boolean ) => number): number
}

使用接受两个参数(一个字符串参数、一个布尔参数)的函数类型对该参数进行定义,并返回一个数字。如果您是 C# 开发人员,您可能会发现此语法看上去像是 lambda 表达式。

实现此接口的类如图 2 所示。

图 2 此类实现适当的接口

class CustomerShort implements ICustomerShort
{
  Id: number;
  FullName: string;
  CalculateDiscount( discountedAmount:
    ( discountClass: string, 
      multipleDiscounts: boolean ) => number ): number
  {
    var discAmount: number;
    ...calculate discAmount...
    return discAmount;
  }
}

类似于 C# 的较新版本,TypeScript 还可以从初始化变量的值,推断出此变量的数据类型。在此示例中,TypeScript 假设变量 myCust 为 CustomerShort:

var myCust= new CustomerShort();
myCust.FullName = "Peter Vogel";

类似于 C#,您可以使用接口来声明变量,然后将此变量设置为实现此接口的对象:

var cs: ICustomerShort;
cs = new CustomerShort();
cs.Id = 11;
cs.FullName = "Peter Vogel";

最后,您还可以使用类型参数(看起来很像 C# 中的泛型)来使调用代码指定要使用的数据类型。此示例允许创建类的代码设置 Id 属性的数据类型:

class CustomerTyped<T>
{
  Id: T;
}

此代码先将 Id 属性的数据类型设置为字符串,然后才使用它:

var cst: CustomerTyped<string>;
cst = new CustomerTyped<string>();
cst.Id = "A123";

要隔离类、接口和其他公共成员并避免名称冲突,您可以在与 C# 命名空间类似的模块中声明这些构造。您必须使用导出关键字标记这些您希望提供给其他模块使用的项。图 3 中的模块导出两个接口和一个类。

图 3 导出两个接口和一个类

module TypeScriptSample
{
  export interface ICustomerDTO
  {
    Id: number;
  }
  export interface ICustomerShort extends ICustomerDTO
  {
    FullName: string;
  }
  export class CustomerShort implements ICustomerShort
  {
    Id: number;
    FullName: string;
  }

要使用导出的组件,您可以使用模块名称作为此组件名称的前缀,如以下示例中所示:

var cs: TypeScriptSample.CustomerShort;

或者,您可以使用 TypeScript 导入关键字来建立此模块的快捷方式:

import tss = TypeScriptSample;
...
var cs:tss.CustomerShort;

TypeScript 在数据类型化方面非常灵活

如果您是 C# 程序员,除了可能的变量声明反转(变量名称在前,数据类型在后)和对象文字之外,您对这一切应该非常熟悉。然而,TypeScript 中几乎所有数据类型化都是可选的。此规范将数据类型描述为“注释”。如果您省略数据类型(并且 TypeScript 无法推断出此数据类型),则数据类型默认为 any 类型。

TypeScript 也不要求数据类型严格匹配。TypeScript 使用规范称为“结构子类型化”的功能来确定兼容性。这与通常称为“鸭子类型化”的功能类似。在 TypeScript 中,如果两个类拥有具有相同类型的成员,就将他们视为相同。例如,以下是一个实现 ICustomerShort 接口的 Customer­Short 类:

interface ICustomerShort
{
  Id: number;
  FullName: string;
}
class CustomerShort implements ICustomerShort
{
  Id: number;
  FullName: string;
}

以下是一个与我的 CustomerShort 类相似的 CustomerDeviant 类:

class CustomerDeviant
{
  Id: number;
  FullName: string;
}

借助结构子类型化,我可以使用带有使用我的 CustomerShort 类或 ICustomerShort 接口定义的变量的 CustomerDevient。这些示例将 CustomerDeviant 与声明为 CustomerShort 或 ICustomerShort 的变量互换使用:

var cs: CustomerShort;
cs = new CustomerDeviant
cs.Id = 11;
var csi: ICustomerShort;
csi = new CustomerDeviant
csi.FullName = "Peter Vogel";

这种灵活性使您可以将 TypeScript 对象文字分配给声明为类或接口的变量,假设它们在结构上兼容,如以下示例所示:

var cs: CustomerShort;
cs = {Id: 2,
      FullName: "Peter Vogel"
     }
var csi: ICustomerShort;
csi = {Id: 2,
       FullName: "Peter Vogel"
      }

这会引入导致可分配性常见问题的有关明显类型、父类型和子类型的 TypeScript 特定功能,本文将跳过这部分内容。例如,这些功能将允许 CustomerDeviant 拥有 CustomerShort 中不存在的成员,而不会导致我的示例代码失败。

TypeScript 拥有类

TypeScript 规范将此语言称作实现“在面向对象的继承机制上[使用]原型链来实现许多变体的类模式”。实际上,这意味着 TypeScript 不只是数据类型化,而是高效地面向对象的。

与 C# 接口可以从基接口进行继承一样,TypeScript 接口也可以采用相同的方式扩展另一个接口,即使另一个接口是在其他模块中定义的。此示例扩展了 ICustomerShort 接口并创建了名为 ICustomerLong 的新接口:

interface ICustomerShort
{
  Id: number;
}
interface ICustomerLong extends ICustomerShort
{
  FullName: string;
}

ICustomerLong 接口将拥有两个成员:FullName 和 Id。在合并的接口中,先显示来自此接口的成员。因此,我的 ICustomerLong 接口相当于此接口:

interface ICustomerLongPseudo
{
  FullName: string;
  Id: number;
}

实现 ICustomerLong 的类将需要这两个属性:

class CustomerLong implements ICustomerLong
{
  Id: number;
  FullName: string;
}

类可以用接口扩展另一个接口的方式来扩展其他类。图 4 中的类扩展了 CustomerShort,并将新属性添加到定义中。它使用显式 getter 和 setter 来定义属性(尽管这不是一个特别有用的方法)。

图 4 使用 getter 和 setter 定义的属性

class CustomerShort
{
  Id: number;
}
class CustomerLong extends CustomerLong
{
  private id: number;
  private fullName: string;
  get Id(): number
  {
    return this.id
  }
  set Id( value: number )
  {
    this.id = value;
  }
  get FullName(): string
  {
    return this.fullName;
  }
  set FullName( value: string )
  {
    this.fullName = value;
  }
}

TypeScript 采用通过引用此类 (this) 访问内部字段(如 id 和 fullName)的最佳做法。这些类还可以具有包含 C# 刚采用的功能的构造函数:自动定义字段。TypeScript 类中的构造函数必须命名为构造函数,其公共参数会自动定义为属性,并从传递给这些属性的值进行初始化。在此示例中,构造函数将接受字符串类型的名为 Company 的单个参数:

export class CustomerShort implements ICustomerShort
{
  constructor(public Company: string)
  {       }

由于 Company 参数定义为公共,此类还获取一个从传递给构造函数的值初始化而来的名为 Company 的公共属性。借助该功能,变量 comp 将设置为“PH&VIS”,如该示例所示:

var css: CustomerShort;
css = new CustomerShort( "PH&VIS" );
var comp = css.Company;

将构造函数的参数声明为私有会创建一个内部属性,此内部属性只能通过关键字 this 从类成员内部的代码进行访问。如果此参数没有声明为公共或私有,则不会生成属性。

您的类必须拥有一个构造函数。正如在 C# 中一样,如果您未提供一个构造函数,系统将向您提供一个。如果您的类扩展了其他类,则您创建的任何构造函数必须包含对 super 的调用。将调用正在扩展的类上的构造函数。此示例包括带有 super 调用的构造函数,该调用向基类的构造函数提供参数:

class MyBaseClass
{
  constructor(public x: number, public y: number ) { }   
}
class MyDerivedClass extends MyBaseClass
{
  constructor()
  {
    super(2,1);
  }
}

TypeScript 以不同的方式继承

同样,如果您是 C# 程序员,除了一些有趣的关键字 (extends) 之外,您对这一切都会感到熟悉。但是同样,扩展类或接口与 C# 中的继承机制并不完全相同。TypeScript 规范针对被扩展的类(“基类”)和扩展它的类(“派生类”)使用常规术语。但是,例如,此规范将一个类称为“遗产规范”,而没有使用单词“继承”。

首先,在定义基类时,TypeScript 的选项要少于 C# 的选项。您无法将类或成员声明为不能替代的、抽象的或虚拟的(尽管接口提供了很多虚拟基类所提供的功能)。

无法防止一些成员不能被继承。派生类继承基类的所有成员,包括公共和私有成员(基类的所有公共成员都可替代,而私有成员都不可替代)。要替代公共成员,只需在派生类中使用相同签名定义一个成员。虽然您可以使用 super 关键字来从派生类访问公共方法,但无法使用 super 访问基类中的属性(尽管您可以替代此属性)。

TypeScript 允许您通过简单地使用相同名称和新成员声明接口来增加一个接口。这允许您扩展现有 JavaScript 代码,而无需创建新的命名类型。图 5 中的示例通过两个单独的接口定义来定义 ICustomerMerge 接口,然后在类中实现该接口。

图 5 通过两个接口定义来定义的 ICustomerMerge 接口

interface ICustomerMerge
{
  MiddleName: string;
}
interface ICustomerMerge
{
  Id: number;
}
class CustomerMerge implements ICustomerMerge
{
  Id: number;
  MiddleName: string;
}

这些类还可以扩展其他类,但不能扩展接口。在 TypeScript 中,接口也可以扩展类,但只能以涉及继承的方式实现。当接口扩展类时,接口包括所有类成员(公共和私有),但不包括此类的实现。在图 6 中,ICustomer 接口将拥有私有成员 id、公共成员 Id 以及公共成员 MiddleName。

图 6 带有所有成员的扩展类

class Customer
{
  private id: number;
  get Id(): number
  {
    return this.id
  }
  set Id( value: number )
  {
    this.id = value;
  }
}
interface ICustomer extends Customer
{
  MiddleName: string;
}

ICustomer 接口具有很大的限制,您只能将其与扩展该接口所扩展的类(在本示例中,为 Customer 类)的类结合使用。TypeScript 需要您在要从此接口扩展的类上继承的接口中(而不是在派生类中重新实现的接口上)包括私有成员。例如,使用 ICustomer 接口的新类需要为 MiddleName 提供一个实现(因为 MiddleName 只在此接口中指定)。使用 ICustomer 的开发人员可以选择从 Customer 类继承或替代公共方法,但无法替代私有 id 成员。

此示例展示了根据需要实现 ICustomer 接口并扩展 Customer 类的类(称为 NewCustomer)。在本示例中,NewCustomer 从 Customer 继承 Id 的实现,并为 MiddleName 提供一个实现:

class NewCustomer extends Customer implements ICustomer
{
  MiddleName: string;
}

这一接口、类、实现和扩展的组合为您进行定义用于扩展在其他对象模型中定义的类的类提供了一种受控的方式(有关详细信息,请查看语言规范 7.3 部分,“扩展类的接口”)。再加上 TypeScript 使用其他 JavaScript 库相关信息的能力,TypeScript 可以让您编写与在那些库中定义的对象配合使用的 TypeScript 代码。

TypeScript 了解您的库

除了了解在您的应用程序中定义的类和接口,您可以向 TypeScript 提供有关其他对象库的信息。可以通过 TypeScript 声明关键字进行处理。这就创建了规范所指的“环境声明”。您可能从来不需要亲自使用声明关键字,因为您可以在 DefinitelyTyped 站点 (definitelytyped.org) 上找到大多数 JavaScript 库的定义文件。通过这些定义文件,TypeScript 可以高效地“阅读有关您需使用的库的文档”。

当然,在使用构成该库的对象时,“阅读文档”意味着您将获取数据类型化的 IntelliSense 支持和编译时检查。在某些情况下,这还允许 TypeScript 从使用变量的上下文中推断出变量的类型。借助于随 TypeScript 提供的 lib.d.ts 定义文件,TypeScript 假设在以下代码中变量定位标记的类型为 HTMLAnchorElement:

var anchor = document.createElement( "a" );

当 createElement 方法传递字符串“a”时,此定义文件指定这就是由此方法所返回的结果。例如,知道定位标记是 HTMLAnchorElement,意味着 TypeScript 知道定位标记变量将支持 addEventListener 方法。

TypeScript 数据类型接口还适用于参数类型。例如,addEventListener 方法接受两个参数。第二个为函数,addEventListener 在其中传递了类型 PointerEvent 的对象。TypeScript 知道这一点,并支持访问此函数中 PointerEvent 类的 cancelBubble 属性:

span.addEventListener("pointerenter", function ( e )
{
  e.cancelBubble = true;
}

其他 JavaScript 的定义文件以 lib.d.ts 提供 HTML DOM 相关信息的方式提供类似的功能。例如,在将 backbone.d.ts 文件添加到我的项目之后,我可以声明一个类,该类扩展了 Backbone Model 类并通过如下代码实现了我的接口:

class CustomerShort extends bb.Model implements ICustomerShort
{
}

如果您对如何将 TypeScript 与 Backbone、Knockout 结合使用的详细信息感兴趣,请查阅我的实用 TypeScript 专栏 (bit.ly/1BRh8NJ)。在新的一年中,我将详细研究如何将 TypeScript 与 Angular 结合使用。

TypeScript 的功能远不止此。TypeScript 版本 1.3 将来必定包括 union 数据类型(例如,以便支持返回特定类型列表的函数)和聚合。TypeScript 团队正在与将数据类型化应用于 JavaScript(Flow 和 Angular)的其他团队合作,以确保 TypeScript 可以与尽可能多的 JavaScript 库结合使用。

如果您需要执行 JavaScript 支持而 TypeScript 不允许的操作,则可以始终集成您的 JavaScript 代码,因为 TypeScript 是 JavaScript 的超集。因此,接下来的问题是,您更喜欢使用哪种语言来编写客户端代码?


Peter Vogel 是 PH&V Information Services 的负责人,专门从事 Web 开发,擅长领域为 SOA、客户端开发和 UI 设计。PH&V 客户包括 Canadian Imperial Bank of Commerce、Volvo 和 Microsoft。他还教授和编写 Learning Tree International 的课程,并为 VisualStudioMagazine.com 撰写实用 .NET 专栏。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Ryan Cavanaugh