TypeScript

使用 TypeScript 增强 JavaScript 投入

Bill Wagner

下载代码示例

TypeScript 编程语言实际上是 JavaScript 的适当超集。如果您使用的是 JavaScript,就说明您已经在编写 TypeScript 了。不过,这并不意味着您编写的 TypeScript 不错,也不意味着您能利用它的所有功能,而是意味着您可以从现有的 JavaScript 投入顺利迁移到 TypeScript 代码库,从而利用 TypeScript 具有的新功能。

在本文中,我将就如何把应用程序从 JavaScript 迁移到 TypeScript 提出建议。您将了解如何从 JavaScript 迁移到 TypeScript,从而使用 TypeScript 类型系统来编写出更好的代码。通过 TypeScript 静态分析,您可以最大限度地减少错误并提高工作效率。通过遵循这些建议,您还可以最大限度地减少 TypeScript 类型系统在迁移过程中出现的错误和警告数量。

我将从管理通讯簿的应用程序示例入手。此为在客户端上使用 JavaScript 的单页应用程序 (SPA)。在本文中,我会尽量简化此应用程序,只添加用于显示联系人列表的部分。此应用程序使用 Angular 框架进行数据绑定和提供应用程序支持。Angular 框架处理数据绑定和模板化,以显示联系人信息。

此应用程序由 3 个 JavaScript 文件组成:app.js 文件包含用于启动应用程序的代码;contactsController.js 文件是一览页面的控制器;contactsData.js 文件包含要显示的联系人列表。控制器和 Angular 框架处理一览页面的行为。您可以对联系人进行排序,并能显示或隐藏任意一个联系人的联系人详细信息。contactsData.js 文件包含硬编码的一组联系人。在生产应用程序中,此文件会包含用于调用服务器和检索数据的代码。硬编码的联系人列表使得演示更加独立。

如果您没有太多的 Angular 使用经验,也不用担心。在我开始迁移应用程序时您就会发现它使用起来非常简单。此应用程序遵循 Angular 约定,此类约定在您将应用程序迁移到 TypeScript 时易于保留。

将应用程序迁移到 TypeScript 的最佳入手位置是控制器文件。由于所有有效的 JavaScript 代码同时也是有效的 TypeScript 代码,因此您只需将 contactsController.js 控制器文件的扩展名从 .js 更改为 .ts 即可。TypeScript 语言是 Visual Studio 2013 Update 2 中的顶级语言。如果您安装了 Web Essentials 扩展程序,则会在同一窗口中看到 TypeScript 来源和所生成的 JavaScript 输出(请参见图 1)。

The TypeScript Editing Experience in Visual Studio 2013 Update 2
图 1:Visual Studio 2013 Update 2 中的 TypeScript 编辑体验

由于 TypeScript 语言功能尚未使用,因此这两个视图大致相同。您可以在调试 TypeScript 应用程序时在末尾处的附加注释行中了解 Visual Studio 的相关信息。如果您使用的是 Visual Studio,则可以在 TypeScript 一级(而不是在生成的 JavaScript 来源一级)调试应用程序。

您会发现,尽管 TypeScript 编译器生成了有效的 JavaScript 输出,但还是会报告此应用程序的错误。这就是 TypeScript 语言的一项强大功能。这是以下规则的自然结果:TypeScript 是 JavaScript 的严格超集。我还没有在任何 TypeScript 文件中声明过符号 contactsApp。因此,TypeScript 编译器假设类型为 any,且该符号将在运行时引用对象。尽管会出现这些错误,我仍可以正常运行应用程序。

我可以继续更改应用程序中的所有 JavaScript 文件的扩展名。但我不建议现在就更改,因为这样做会出现更多错误。应用程序仍会正常运行,但出现这么多的错误会加大使用 TypeScript 系统编写更好代码的难度。我更倾向于一次处理一个文件,并在过程中向应用程序添加类型信息。这样,我需要一次性修复的类型系统错误就会变少。在进行无错误构建之后,我知道 TypeScript 编译器有助于避免这些错误。

为 contactsApp 声明外部变量是很简单的。默认情况下,它具有 any 类型:

declare var contactsApp:any;

虽然这样做可以修复此编译器错误,但并不有助于避免在调用 Angular 库中的方法时出现错误。any 类型正如其名:它可以是任意类型。在您访问 contactsApp 变量时,TypeScript 不会执行 any 类型检查。若要执行类型检查,则需要向 TypeScript 提供以下信息:contactsApp 的类型、在 Angular 框架中定义的类型。

通过类型定义功能,TypeScript 可以使现有 JavaScript 库具备类型信息。类型定义是一组不含实施的声明。它们向 TypeScript 编译器描述类型及其 API。GitHub 上的 DefinitelyTyped 项目具有多个常用 JavaScript 库(包括 Angular.js)的类型定义。我使用 NuGet 程序包管理器在项目中添加这些定义。

在将 Angular 库的类型定义包含在内之后,我便可以使用它们修复所看到的编译器错误。我需要引用刚刚添加到项目中的类型信息。下面的这条特殊注释指示 TypeScript 编译器引用类型信息:

/// <reference path="../Scripts/typings/angularjs/angular.d.ts" />

TypeScript 编译器现在可以解释类型定义文件 angular.d.ts 中定义的任何类型。现在是时候修复 contactsApp 变量的类型错误了。在 app.js 的 ng 命名空间中声明的 contactsApp 变量预期类型是 IModule:

declare var contactsApp:ng.IModule;

这样声明之后,只要在 contactsApp 后面输入句点,我就能获得 IntelliSense。此外,只要我错误键入或错误使用在 contactsApp 对象上声明的 API,我就会收到 TypeScript 编译器的错误报告。编译器错误已修复,我也已经添加了应用程序对象的静态类型信息。

contactsController 对象中的其余代码仍缺少类型信息。在您添加类型注释之前,TypeScript 编译器会假设所有变量的类型均为 any。contactsApp.controller 方法的第二个参数是一个函数,该函数的第一个参数 $scope 的类型为 ng.IScope。因此,我将把该类型添加到函数声明中(contactData 仍解释为 any 类型):

contactsApp.controller('ContactsController', function ContactsController($scope :ng.IScope, contactData) { $scope.sortOrder = 'last'; $scope.hideMessage = "Hide Details"; $scope.showMessage = "Show Details"; $scope.contacts = contactData.getContacts(); $scope.toggleShowDetails = function (contact) { contact.showDetails = !contact.showDetails; } });

这就引入了一组新的编译器错误。导致新错误出现的原因是 contactsController 函数内部的代码处理的属性不是 ng.IScope 类型的一部分。ng.IScope 是一个接口,而实际的 $scope 对象是用于实施 IScope 的应用程序专用类型。要处理的这些属性属于此类型。若要利用 TypeScript 静态类型化,我需要定义应用程序专用的此类型。我将它称为 IContactsScope:

interface IContactsScope extends ng.IScope { sortOrder:string; hideMessage:string; showMessage:string; contacts:any; toggleShowDetails:(contact:any) => boolean; }

定义接口之后,我便只需在函数声明中更改 $scope 变量的类型即可:

function ContactsController($scope :IContactsScope, contactData) {

进行这些更改之后,我便可以无错误地构建应用程序,并且应用程序也能正常运行。添加此接口时,需要注意几个重要概念。请注意,我并没有查找其他任何代码并声明任何特定类型实施 IContactsScope 类型。TypeScript 支持结构类型化(通俗地称为“鸭子类型化”)。也就是说,声明 IContactsScope 中已声明的属性和方法的任何对象都会实施 IContactsScope 接口,不论该类型是否声明实施 IContactsScope。

请注意,我使用的是 TypeScript 的 any 类型作为 IContactsScope 定义中的占位符。联系人属性表示联系人列表,但我还没有迁移联系人类型。我可以使用 any 作为占位符,但 TypeScript 编译器不会对这些值的访问执行 any 类型检查。这是一项在应用程序整个迁移过程中都很有用的技术。

any 类型表示我尚未从 JavaScript 迁移到 TypeScript 的任意类型。通过此类型,您可以更顺利地进行迁移,并能减少在每次循环访问中需要修复的 TypeScript 编译器错误。它还可以搜索声明为 any 类型的变量,并查找我仍需要执行的任务。“any”会指示 TypeScript 编译器不对该变量执行 any 类型检查。它可以是任意类型。编译器将假设您知道该变量可用的 API。这并不意味着每次使用“any”都无效。any 类型也具有有效用途,例如当 JavaScript API 旨在与其他类型的对象配合使用时。在迁移过程中将“any”用作占位符就是一种很好的形式。

最后,toggleShowDetails 的声明展示了函数声明在 TypeScript 中的表示方式:

toggleShowDetails:(contact:any) => boolean;

函数名称为 toggleShowDetails。冒号后面为参数列表。此函数包含一个参数,当前为 any 类型。名称“contact”是可选的。您可以使用此方法为其他程序员提供更多信息。粗箭头指向返回类型,即本示例中的布尔。

介绍 IContactScope 定义中的 any 类型旨在向您说明后续操作。TypeScript 有助于避免您在提供要处理的类型的详细信息时看到错误。我可以将此 any 替换为在联系人中的定义有所改进的以下类型:包括联系对象上的可用属性的 IContact 类型(请参见图 2)。

图 2:包括联系对象上的属性

interface IContact { first:string; last:string; address:string; city:string; state:string; zipCode:number; cellPhone:number; homePhone:number; workPhone:number; showDetails:boolean }

IContact 接口现已定义,我将在 IContactScope 接口中使用它:

interface IContactsScope extends ng.IScope { sortOrder:string; hideMessage:string; showMessage:string; contacts:IContact[]; toggleShowDetails:(contact:IContact) => boolean; }

我无需在 contactsController 函数中定义的 toggleShowDetails 函数定义中添加类型信息。由于 $scope 变量是 IContactsScope,因此 TypeScript 编译器知道分配给 toggleShowDetails 的函数必须与 IContactScope 中定义的函数原型相匹配,且参数必须为 IContact。

请参见图 3,了解针对 contactsController 的此版本生成的 JavaScript。请注意,我已经定义的所有接口类型都已从生成的 JavaScript 删除。类型注释为您和静态分析工具而保留。这些注释没有在生成的 JavaScript 中继续存在,因为并不需要这些注释。

图 3:控制器的 TypeScript 版本和生成的 JavaScript

/// reference path="../Scripts/typings/angularjs/angular.d.ts" var contactsApp:ng.IModule; interface IContact { first:string; last:string; address:string; city:string; state:string; zipCode:number; cellPhone:number; homePhone:number; workPhone:number; showDetails:boolean } interface IContactsScope extends ng.IScope { sortOrder:string; hideMessage:string; showMessage:string; contacts:IContact[]; toggleShowDetails:(contact:IContact) => boolean; } contactsApp.controller('ContactsController', function ContactsController($scope :IContactsScope, contactData) { $scope.sortOrder = 'last'; $scope.hideMessage = "Hide Details"; $scope.showMessage = "Show Details"; $scope.contacts = contactData.getContacts(); $scope.toggleShowDetails = function (contact) { contact.showDetails = !contact.showDetails; return contact.showDetails; } }); // 生成的 JavaScript /// 引用 path="../Scripts/typings/angularjs/angular.d.ts" var contactsApp; contactsApp.controller('ContactsController', function ContactsController($scope, contactData) { $scope.sortOrder = 'last'; $scope.hideMessage = "Hide Details"; $scope.showMessage = "Show Details"; $scope.contacts = contactData.getContacts(); $scope.toggleShowDetails = function (contact) { contact.showDetails = !contact.showDetails; return contact.showDetails; }; }); //# sourceMappingURL=contactsController.js.map

添加模块和类定义

通过向代码添加类型注释,您可以让静态分析工具查找并报告代码中可能已出现的错误。这涉及到各个方面,从 IntelliSense 和类似于 lint 的分析到编译时错误和警告。

与 JavaScript 相比,TypeScript 的另一大优势在于优化了作用域类型的语法。通过 TypeScript 模块关键字,您可以将类型定义放置在作用域内,并避免与其他模块中可能使用相同名称的类型相冲突。

作为示例的联系人应用程序并没有这么大,但仍最好将类型定义放置在模块中,以免发生冲突。此时,我将把已定义的 contactsController 和其他类型放置在 Rolodex 模块中:

module Rolodex { // 已省略 }

我没有在此模块中添加任何定义的导出关键字。也就是说,在 Rolodex 模块内部定义的类型只能从该模块内部引用。我将添加在此模块中定义的接口的导出关键字,并稍后在迁移 contactsData 代码时使用这些类型。我还将把 ContactsController 的代码从函数更改为类。此类需要使用构造函数进行自我初始化,而不是其他任何公共方法(请参见图 4)。

图 4:将 ContactsController 从函数更改为类

export class ContactsController { constructor($scope:IContactsScope, contactData:any) { $scope.sortOrder = 'last'; $scope.hideMessage = "Hide Details"; $scope.showMessage = "Show Details"; $scope.contacts = contactData.getContacts(); $scope.toggleShowDetails = function (contact) { contact.showDetails = !contact.showDetails; return contact.showDetails; } } }

创建此类型现在可以更改对 contactsApp.controller 的调用。第二个参数现在是类类型,而非之前定义的函数。控制器函数的第一个参数是控制器的名称。Angular 将控制器名称映射到构造函数。在 HTML 页中引用 ContactsController 类型的任何位置,Angular 都将调用 ContactsController 类的构造函数:

contactsApp.controller('ContactsController', Rolodex.ContactsController);

现在控制器类型已从 JavaScript 完全迁移到 TypeScript。新版本包含控制器中定义或使用的所有内容的类型注释。在 TypeScript 中,我无需在应用程序的其他部分进行更改,即可执行此操作。其他文件均没有受到影响。将 TypeScript 与 JavaScript 混合非常顺利,简化了将 TypeScript 添加到现有 JavaScript 应用程序的过程。TypeScript 类型系统依赖类型推理和结构类型化,可方便 TypeScript 与 JavaScript 进行交互。

现在,我将继续介绍 contactData.js 文件(请参见图 5)。此函数使用 Angular 工厂方法返回对象,此对象将返回联系人列表。与控制器类似,此工厂方法将名称 (contactData) 映射到能够返回服务的函数。此约定用于控制器的构造函数中。构造函数的第二个参数为 contactData。Angular 使用该参数名称映射到适当的工厂。如您所见,Angular 框架基于约定。

图 5:contactData 服务的 JavaScript 版本

'use strict'; contactsApp.factory('contactData', function () { var contacts = [ { first:"Tom", last:"Riddle", address:"66 Shack St", city:"Little Hangleton", state:"Mississippi", zipCode:54565, cellPhone:6543654321, homePhone:4532332133, workPhone:6663420666 }, { first:"Antonin", last:"Dolohov", address:"28 Kaban Ln", city:"Gideon", state:"Arkensas", zipCode:98767, cellPhone:4443332222, homePhone:5556667777, workPhone:9897876765 }, { first:"Evan", last:"Rosier", address:"28 Dominion Ave", city:"Notting", state:"New Jersey", zipCode:23432, cellPhone:1232343456, homePhone:4432215565, workPhone:3454321234 } ]; return { getContacts:function () { return contacts; }, addContact:function(contact){ contacts.push(contact); return contacts; } }; })

同样地,第一步只需将扩展名从 .js 更改为 .ts。它进行无错误编译,所生成的 JavaScript 与来源 TypeScript 文件最为匹配。接下来,我将把代码放置在同一 Rolodex 模块中的 contactData.ts 文件中。这包括该应用程序在同一逻辑分区中的所有代码。

接下来,我将把 contactData 工厂迁移到类中。将类声明为类型 ContactDataServer。我现在可以只将方法定义为属于 ContactDataServer 的对象,而不定义返回的对象包含两个是方法的属性的函数。初始数据现在是类型 ContactDataServer 的对象的数据成员。我还需要在调用 contactsApp.factory 时使用此类型:

contactsApp.factory('contactsData', () => new Rolodex.ContactDataServer());

第二个参数是返回新 Contact­DataServer 的函数。工厂将在我需要的时候创建对象。如果我尝试编译并运行此版本,则会看到编译器错误,因为 ContactDataServer 类型不是从 Rolodex 模块导出,而是在调用 contacts­App.factory 时进行了引用。这是 TypeScript 类型系统不太严格的又一示例,可方便您更轻松地执行迁移任务。通过将导出关键字添加到 ContactDataServer 类声明中,我可以轻松修复此错误。

有关最终版本,您可以参见图 6。请注意,我已经添加了联系人数组的类型信息和 addContact 方法的输入参数。类型注释是可选的。没有类型注释,TypeScript 也有效。然而,我要鼓励您将所有必要的类型信息都添加到 TypeScript 代码中,因为这样有助于避免 TypeScript 系统中出现错误,从而提高工作效率。

图 6:ContactDataServer 的 TypeScript 版本

/// reference path="../Scripts/typings/angularjs/angular.d.ts" var contactsApp:ng.IModule; module Rolodex { export class ContactDataServer { contacts:IContact[] = [ { first:"Tom", last:"Riddle", address:"66 Shack St", city:"Little Hangleton", state:"Mississippi", zipCode:54565, cellPhone:6543654321, homePhone:4532332133, workPhone:6663420666, showDetails:true }, { first:"Antonin", last:"Dolohov", address:"28 Kaban Ln", city:"Gideon", state:"Arkensas", zipCode:98767, cellPhone:4443332222, homePhone:5556667777, workPhone:9897876765, showDetails:true }, { first:"Evan", last:"Rosier", address:"28 Dominion Ave", city:"Notting", state:"New Jersey", zipCode:23432, cellPhone:1232343456, homePhone:4432215565, workPhone:3454321234, showDetails:true } ]; getContacts() { return this.contacts; } addContact(contact:IContact) { this.contacts.push(contact); return this.contacts; } } } contactsApp.factory('contactsData', () => new Rolodex.ContactDataServer());

现在,我已经创建了一个新的 ContactDataServer 类,可以对控制器进行最后一项更改了。请注意,contactsController 构造函数的第二个参数是数据服务器。我现在可以使类型更安全,方法是声明以下参数必须为 ContactDataServer 类型:

constructor($scope:IContactsScope, contactData:ContactDataServer) {

从 JavaScript 顺利迁移到 TypeScript

除了我在本文中介绍的功能之外,TypeScript 还具有其他许多功能。在使用 TypeScript 的过程中,您会欣然接受它的各种功能。您越多地将 TypeScript 扩展名用于 JavaScript,工作效率就会越高。请注意,TypeScript 类型注释旨在方便您从 JavaScript 顺利迁移到 TypeScript。最重要的是,请注意 TypeScript 是 JavaScript 的严格超集。也就是说,任何有效的 JavaScript 都是有效的 TypeScript。

此外,TypeScript 类型注释一点也不繁琐。类型注释在提供时即可检查,并且也没有强制要求要到处添加。在您从 JavaScript 迁移到 TypeScript 时,这将非常有用。

最后,TypeScript 类型系统支持结构类型化。当您为重要类型定义接口时,TypeScript 类型系统会假设包含这些方法和属性的任何对象均支持该接口。您无需在每个类定义中声明接口支持。匿名对象也可以支持使用此结构类型化功能的接口。

通过这些功能组合,您可以将代码库从 JavaScript 顺利迁移到 TypeScript。您的迁移路径越深入,从 TypeScript 静态代码分析获得的优势就越多。您的最终目标应该是尽可能安全地利用 TypeScript。在此过程中,您的现有 JavaScript 代码可用作不使用 TypeScript 类型注释的有效 TypeScript。这是一个几乎无阻力的过程。您没有任何理由不在当前的 JavaScript 应用程序中使用 TypeScript。

Bill Wagner 是畅销书《Effective C#》(2004 年版)的作者,此书现在发行了第二版《More Effective C#》(2008 年版),两版均由 Addison-Wesley Professional 出版。他还制作了 Pearson Education informIT 的视频“C# Async 基础知识联机课程”和“C# 疑难解答”。他的活跃度高的博客是 thebillwagner.com,可从 bill.w.wagner@outlook.com 访问。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Jonathan Turner
Jonathan Turner 是 Microsoft TypeScript 团队的项目经理,也是 TypeScript 语言的合作设计师。在加入 Microsoft 之前,他从事 Clang/LLVM 和 Chapel 编程语言相关的工作。