精通-TypeScript-全-

精通 TypeScript(全)

原文:zh.annas-archive.org/md5/EF6D1933EE7A1583ABD80988FCB79F1E

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自 2012 年底发布以来,TypeScript 语言和编译器已经取得了巨大的成功。它迅速在 JavaScript 开发社区中站稳了脚跟,并不断壮大。包括 Adobe、Mozilla 和 Asana 在内的许多大型 JavaScript 项目已经决定将它们的代码库从 JavaScript 切换到 TypeScript。最近,微软和谷歌团队宣布 Angular 2.0 将使用 TypeScript 开发,从而将 AtScript 和 TypeScript 语言合并为一种语言。

这种大规模的行业采用 TypeScript 显示了该语言的价值、编译器的灵活性以及使用其丰富的开发工具集可以实现的生产力增益。除了行业支持外,ECMAScript 6 标准也越来越接近发布,TypeScript 提供了一种在我们的应用程序中使用该标准特性的方法。

使用 TypeScript 社区构建的大量声明文件,使得使用 TypeScript 编写 JavaScript 单页面应用程序变得更加吸引人。这些声明文件无缝地将大量现有的 JavaScript 框架整合到 TypeScript 开发环境中,带来了增加的生产力、早期错误检测和高级的智能感知功能。

本书旨在成为有经验的 TypeScript 开发人员以及刚开始学习 TypeScript 的人的指南。通过专注于测试驱动开发、与许多流行的 JavaScript 库集成的详细信息,以及深入研究 TypeScript 的特性,本书将帮助您探索 JavaScript 开发的下一步。

本书内容

第一章,“TypeScript – 工具和框架选项”,为开始 TypeScript 开发铺平了道路,首先介绍了使用 TypeScript 的各种好处,然后讨论了如何设置开发环境。

第二章,“类型、变量和函数技术”,向读者介绍了 TypeScript 语言,从基本类型和类型推断开始,然后讨论了变量和函数。

第三章,“接口、类和泛型”,在前一章的基础上构建,并介绍了接口、类和继承的面向对象概念。然后介绍了 TypeScript 中泛型的语法和用法。

第四章,“编写和使用声明文件”,引导读者逐步构建现有 JavaScript 代码的声明文件,然后列出了编写声明文件时使用的一些最常见的语法。这些语法旨在成为声明文件语法的快速参考指南或备忘单。

第五章,“第三方库”,向读者展示了如何在开发环境中使用 DefinitelyTyped 存储库中的声明文件。然后,它继续向读者展示如何编写与三种流行的 JavaScript 框架—Backbone、Angular 和 ExtJs 兼容的 TypeScript。

第六章,“测试驱动开发”,从讨论什么是测试驱动开发开始,然后引导读者通过使用 Jasmine 库创建各种类型的单元测试,包括数据驱动和异步测试。本章最后讨论了集成测试、测试报告和使用持续集成构建服务器。

第七章,模块化,介绍了 TypeScript 编译器使用的两种模块生成类型:CommonJS 和 AMD。本章向读者展示了如何构建用于 Node 的 CommonJS 模块,然后讨论了使用 Require、Backbone、AMD 插件和 jQuery 插件构建 AMD 模块。

第八章, TypeScript 面向对象编程,讨论了高级面向对象设计模式,包括服务定位设计模式、依赖注入和领域事件设计模式。读者将了解每种模式的概念和思想,然后展示如何使用 TypeScript 实现这些模式。

第九章,让我们动手吧,从头开始使用 TypeScript 和 Marionette 构建单页面应用程序。本章首先讨论页面布局和转换,使用应用程序的仅 HTML 版本。然后,讨论、构建和测试将在应用程序中使用的基础数据模型和 Marionette 视图。最后,实现了状态和中介者设计模式来管理页面转换和图形元素。

您需要为本书做些什么

您将需要 TypeScript 编译器和某种编辑器。TypeScript 编译器可作为 Node.js 插件或 Windows 可执行文件使用;因此,它可以在任何操作系统上运行。第一章,TypeScript - 工具和框架选项描述了开发环境的设置。

这本书是为谁准备的

无论您是想学习 TypeScript 的 JavaScript 开发人员,还是想将自己的技能提升到更高水平的有经验的 TypeScript 开发人员,这本书都适合您。从基本到高级语言构造、测试驱动开发和面向对象技术,您将学会如何充分利用 TypeScript 语言和编译器。本书将向您展示如何将强类型、面向对象和设计最佳实践融入到您的 JavaScript 应用程序中。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些样式的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"这个GruntFile.js是设置所有 Grunt 任务所必需的。"

代码块设置如下:

class MyClass {
    add(x, y) {
        return x + y;
    }
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

class MyClass {
    add(x, y) {
        return x + y;
    }
}

任何命令行输入或输出都以以下方式编写:

tsc app.ts

新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中显示为:"选择名称并浏览目录后,单击确定将生成一个 TypeScript 项目。"

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧会出现在这样。

第一章:TypeScript - 工具和框架选项

JavaScript 是一种真正无处不在的语言。现代世界中您访问的几乎每个网站都会嵌入某种 JavaScript 组件,以使网站更具响应性、更易读,或者更具吸引力。想想您在过去几个月中访问过的最令人印象深刻的网站。它外观吸引人吗?它有某种巧妙的呈现方式吗?它是否通过为您提供全新的发现汽车保险、图片分享或新闻文章的方式来吸引您作为用户?

这就是 JavaScript 的力量。JavaScript 是互联网体验的点睛之笔,让全世界数百万人感到“哇,太酷了”。它也带来了收入。两个网站可能以相同的价格提供相同的产品,但是能够吸引客户并让他们享受网络体验的网站将吸引最多的追随者并获得最大的成功。如果这个网站还可以在台式机、手机或平板电脑上无缝重现,那么目标受众和目标收入可以成倍增加。

然而,JavaScript 也是互联网上讨厌的一面的原因。那些令人讨厌的广告,您必须等待 5 秒钟才能点击“跳过”按钮。或者在旧版浏览器上无法正常工作,或者在平板电脑和手机上无法正确渲染的网站。可以说,许多网站如果没有 JavaScript 会更好。

一个引人入胜的网络体验也可以在企业网络应用中产生巨大差异。笨重、难以使用和缓慢的网络应用会完全让企业用户对您的应用产生反感。请记住,您的典型企业用户正在将他们的工作体验与他们的日常网络体验进行比较 - 他们期望得到精心设计、响应迅速、直观的界面。毕竟,他们通常是最受欢迎的网站的用户,并期望在工作中得到同样的响应。

大部分这种增强的用户体验来自于 JavaScript 的有效使用。异步 JavaScript 请求允许您的网页在等待后端进程进行繁重、耗时的数据处理任务时更快地向用户呈现内容。

JavaScript 语言并不难学,但在编写大型、复杂程序时会带来挑战。作为一种解释性语言,JavaScript 没有编译步骤,因此是即时执行的。对于习惯于在更正式的环境中编写代码 - 使用编译器、强类型和成熟的编程模式的程序员来说,JavaScript 可能是一个完全陌生的环境。

TypeScript 弥合了这一差距。它是一种强类型、面向对象、编译语言,允许您作为程序员在 JavaScript 中重复使用成熟的面向对象语言的概念和思想。TypeScript 编译器生成的 JavaScript 遵循这些强类型、面向对象的原则 - 但同时又是纯粹的 JavaScript。因此,它将在 JavaScript 可以运行的任何地方成功运行 - 在浏览器、服务器或现代移动设备上。

本章分为两个主要部分。第一部分是对 TypeScript 为 JavaScript 开发体验带来的一些好处的快速概述。本章的第二部分涉及设置 TypeScript 开发环境。

如果您是一名有经验的 TypeScript 程序员,并且已经设置好了开发环境,那么您可能想跳过本章。如果您以前从未使用过 TypeScript,并且因为想了解 TypeScript 能为您做什么而拿起了这本书,那么请继续阅读。

本章将涵盖以下主题:

  • TypeScript 的好处

  • 编译

  • 强类型

  • 与流行的 JavaScript 库集成

  • 封装

  • 私有和公共成员变量

  • 设置开发环境

  • Visual Studio

  • WebStorm

  • 括号和 Grunt

什么是 TypeScript?

TypeScript 既是一种语言,也是一套生成 JavaScript 的工具。它是由微软的 Anders Hejlsberg(C#的设计者)设计的,作为一个开源项目,帮助开发人员编写企业规模的 JavaScript。JavaScript 已经被世界各地的程序员广泛采用,因为它可以在任何操作系统上的任何浏览器上运行。随着 Node 的创建,JavaScript 现在也可以在服务器、桌面或移动设备上运行。

TypeScript 生成 JavaScript——就是这么简单。TypeScript 生成的 JavaScript 可以重用所有现有的 JavaScript 工具、框架和丰富的库,而不需要完全新的运行时环境。然而,TypeScript 语言和编译器将 JavaScript 的开发更接近于更传统的面向对象的体验。

EcmaScript

JavaScript 作为一种语言已经存在很长时间,并且也受到语言特性标准的约束。在这个标准中定义的语言称为 ECMAScript,每个浏览器必须提供符合这个标准的功能和特性。这个标准的定义帮助了 JavaScript 和网络的增长,并允许网站在许多不同的操作系统上的许多不同的浏览器上正确呈现。ECMAScript 标准于 1999 年发布,被称为 ECMA-262 第三版。

随着语言的流行和互联网应用的爆炸性增长,ECMAScript 标准需要进行修订和更新。这个过程导致了 ECMAScript 的草案规范,称为第四版。不幸的是,这个草案提出了对语言的彻底改革,但并未受到良好的反响。最终,来自雅虎、谷歌和微软的领导人提出了一个另类提案,他们称之为 ECMAScript 3.1。这个提案被编号为 3.1,因为它是第三版的一个较小的功能集,并且位于标准的第 3 版和第 4 版之间。

这个提案最终被采纳为标准的第五版,并被称为 ECMAScript 5。ECMAScript 第四版从未出版,但决定将第四版和 3.1 功能集的最佳特性合并为第六版,命名为 ECMAScript Harmony。

TypeScript 编译器有一个参数,可以修改以针对不同版本的 ECMAScript 标准。TypeScript 目前支持 ECMAScript 3、ECMAScript 5 和 ECMAScript 6。当编译器运行在您的 TypeScript 上时,如果您尝试编译的代码不符合特定标准,它将生成编译错误。微软团队还承诺在 TypeScript 编译器的任何新版本中遵循 ECMAScript 标准,因此一旦采用新版本,TypeScript 语言和编译器也会跟进。

ECMAScript 标准的每个版本包含的细节超出了本书的范围,但重要的是要知道存在差异。一些浏览器版本不支持 ES5(IE8 就是一个例子),但大多数浏览器支持。在选择要为项目定位的 ECMAScript 版本时,您需要考虑要支持的浏览器版本。

TypeScript 的好处

为了让您了解 TypeScript 的好处(这绝不是完整列表),让我们快速看一下 TypeScript 带来的一些东西:

  • 编译步骤

  • 强类型或静态类型

  • 流行 JavaScript 库的类型定义

  • 封装

  • 私有和公共成员变量装饰器

编译

JavaScript 开发最令人沮丧的事情之一是缺乏编译步骤。JavaScript 是一种解释性语言,因此需要运行才能测试其有效性。每个 JavaScript 开发人员都会讲述关于花费数小时来查找代码中的错误的可怕故事,只是发现他们错过了一个多余的闭括号{,或者一个简单的逗号, - 或者甚至是一个双引号",而应该是单引号'。更糟糕的是,当你拼错属性名称或者无意中重新分配全局变量时,真正的头痛就来了。

TypeScript 将编译你的代码,并在发现这种类型的语法错误时生成编译错误。这显然非常有用,并且可以帮助在 JavaScript 运行之前突出显示错误。在大型项目中,程序员通常需要进行大规模的代码合并 - 而今天的工具可以自动合并 - 令人惊讶的是编译器经常会发现这些类型的错误。

虽然像 JSLint 这样的语法检查工具已经存在多年,但将这些工具集成到你的 IDE 中显然是有益的。在持续集成环境中使用 TypeScript 也将在发现编译错误时完全失败构建 - 进一步保护程序员免受这些类型的错误。

强类型

JavaScript 不是强类型的。它是一种非常动态的语言,因为它允许对象在运行时改变其属性和行为。举个例子,考虑以下代码:

var test = "this is a string";
test = 1;
test = function(a, b) {
    return a + b;
}

在这段代码片段的第一行,变量test绑定到一个字符串。然后它被赋一个数字,最后被重新定义为一个期望两个参数的函数。然而,传统的面向对象语言不允许变量的类型改变 - 因此它们被称为强类型语言。

虽然前面的所有代码都是有效的 JavaScript - 并且可以被证明是合理的 - 但很容易看出这可能在执行过程中导致运行时错误。想象一下,你负责编写一个库函数来添加两个数字,然后另一个开发人员无意中重新将你的函数重新分配为减去这些数字。

这些类型的错误可能在几行代码中很容易发现,但随着你的代码库和开发团队的增长,找到并修复这些错误变得越来越困难。

强类型的另一个特性是,你正在使用的 IDE 可以理解你正在处理的变量类型,并且可以提供更好的自动完成或智能提示选项。

TypeScript 的“语法糖”

TypeScript 引入了一种非常简单的语法来在编译时检查对象的类型。这种语法被称为“语法糖”,或者更正式地说,类型注解。考虑以下 TypeScript 代码:

var test: string = "this is a string";
test = 1;
test = function(a, b) { return a + b; }

在这段代码片段的第一行上,我们介绍了一个冒号:和一个string关键字,将我们的变量和它的赋值之间。这种类型注解语法意味着我们正在设置变量的类型为string类型,并且任何不将其用作字符串的代码都将生成一个编译错误。通过 TypeScript 编译器运行前面的代码将生成两个错误:

error TS2011: Build: Cannot convert 'number' to 'string'.
error TS2011: Build: Cannot convert '(a: any, b: any) => any' to 'string'.

第一个错误非常明显。我们已经指定变量test是一个string,因此尝试将一个数字赋给它将生成一个编译错误。第二个错误与第一个类似,本质上是在说我们不能将一个函数赋给一个字符串。

通过 TypeScript 编译器,你的 JavaScript 代码引入了强大的静态类型,给你所有强类型语言的好处。因此,TypeScript 被描述为 JavaScript 的“超集”。我们将在下一章更详细地探讨类型。

流行 JavaScript 库的类型定义

正如我们所见,TypeScript 有能力“注释”JavaScript,并为 JavaScript 开发体验带来强类型。但是我们如何为现有的 JavaScript 库提供强类型?答案出奇的简单:通过创建一个定义文件。TypeScript 使用扩展名为.d.ts的文件作为一种“头”文件,类似于 C++等语言,以在现有的 JavaScript 库上叠加强类型。这些定义文件包含描述库中每个可用函数和变量以及它们相关类型注释的信息。

让我们快速看一下定义会是什么样子。举个例子,考虑一个来自流行的 Jasmine 单元测试框架的函数describe

var describe = function(description, specDefinitions) {
  return jasmine.getEnv().describe(description, specDefinitions);
};

这个函数有两个参数,descriptionspecDefinitions。然而,仅仅阅读这个 JavaScript 并不能告诉我们这些参数应该是什么类型。specDefinitions参数是一个字符串,还是一个字符串数组,一个函数或者其他什么?为了弄清楚这一点,我们需要查看 Jasmine 文档,可以在jasmine.github.io/2.0/introduction.html找到。这个文档为我们提供了如何使用这个函数的有用示例:

describe("A suite", function () {
    it("contains spec with an expectation", function () {
        expect(true).toBe(true);
    });
});

从文档中,我们可以很容易地看出第一个参数是一个string,第二个参数是一个function。然而,在 JavaScript 语言中,并没有强制我们遵循这个 API。正如之前提到的,我们可以轻松地用两个数字调用这个函数,或者无意中交换参数,先发送一个函数,然后发送一个字符串。如果我们这样做,显然会开始出现运行时错误,但是 TypeScript 可以在我们尝试运行这段代码之前生成编译时错误,使用定义文件。

让我们来看一下jasmine.d.ts定义文件的一部分:

declare function describe(
    description: string, specDefinitions: () => void
): void;

这是描述函数的 TypeScript 定义。首先,declare function describe告诉我们可以使用一个名为describe的函数,但是这个函数的实现将在运行时提供。

显然,description参数被强类型为string类型,specDefinitions参数被强类型为返回voidfunction。TypeScript 使用双括号()语法声明函数,并使用箭头语法显示函数的返回类型。所以() => void是一个不返回任何东西的函数。最后,describe函数本身将返回void

如果我们的代码尝试将一个函数作为第一个参数传递,将一个字符串作为第二个参数传递(显然违反了这个函数的定义),如下例所示:

describe(() => { /* function body */}, "description");

TypeScript 编译器将立即生成以下错误:

error TS2082: Build: Supplied parameters do not match any signature of call target: Could not apply type "string" to argument 1 which is of type () => void

这个错误告诉我们,我们试图使用无效的参数调用describe函数。我们将在后面的章节中更详细地看定义文件,但是这个例子清楚地显示了如果我们尝试不正确地使用外部 JavaScript 库,TypeScript 将生成错误。

Definitely Typed

TypeScript 发布后不久,Boris Yankov 开始在 DefinitelyTyped(github.com/borisyankov/DefinitelyTyped)上创建了一个 GitHub 存储库,用于存放定义文件。这个存储库现在已经成为将外部库集成到 TypeScript 中的首选方法,并且目前保存了超过 500 个 JavaScript 库的定义。

封装

面向对象编程的一个基本原则是封装:将数据定义以及一组可以操作该数据的函数封装到一个单一的组件中。大多数编程语言都有类的概念,提供了一种定义数据和相关函数模板的方式。

让我们首先看一下一个简单的 TypeScript 类定义:

class MyClass {
    add(x, y) {
        return x + y;
    }
}

var classInstance = new MyClass();
console.log(classInstance.add(1, 2));

这段代码非常简单易懂。我们创建了一个名为MyClassclass,其中包含一个名为add的函数。要使用这个类,我们只需创建一个实例,并使用两个参数调用add函数。

不幸的是,JavaScript 没有class关键字,而是使用函数来复制类的功能。通过类实现封装可以通过使用原型模式或者使用闭包模式来完成。理解原型和闭包模式,并正确使用它们,被认为是编写企业级 JavaScript 时的基本技能。

闭包本质上是指引用独立变量的函数。这意味着在闭包函数内定义的变量会“记住”它们被创建的环境。这为 JavaScript 提供了一种定义局部变量和提供封装的方式。在前面的代码中使用 JavaScript 的闭包来编写MyClass定义会看起来像这样:

var MyClass = (function () {
    // the self-invoking function is the 
    // environment that will be remembered
    // by the closure
    function MyClass() {
        // MyClass is the inner function,
        // the closure
    MyClass.prototype.add = function (x, y) {
        return x + y;
    };
    return MyClass;
})();
var classInstance = new MyClass();
console.log("result : " + classInstance.add(1, 2));

我们从一个名为MyClass的变量开始,并将其分配给一个立即执行的函数——请注意代码片段底部附近的})();语法。这种语法是为了避免将变量泄漏到全局命名空间而常用的 JavaScript 编写方式。然后我们定义一个名为MyClass的新函数,并将这个新函数返回给外部调用函数。然后我们使用prototype关键字将一个新函数注入到MyClass定义中。这个函数名为add,接受两个参数,返回它们的和。

代码的最后两行展示了如何在 JavaScript 中使用这个闭包。创建一个闭包类型的实例,然后执行 add 函数。在浏览器中运行这个代码将会在控制台上记录result: 3,这是预期的结果。

提示

下载示例代码

您可以从您在www.packtpub.com的帐户中下载示例代码文件,这适用于您购买的所有 Packt Publishing 图书。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册以直接通过电子邮件接收文件。

通过比较 JavaScript 代码和 TypeScript 代码,我们可以很容易地看出 TypeScript 相对于等效的 JavaScript 来说是多么简单。还记得我们提到过 JavaScript 程序员很容易错放大括号{或者括号(吗?看一下闭包定义的最后一行:})();。弄错其中一个大括号或者括号可能需要花费数小时来调试。

TypeScript 类生成闭包

如前面的代码片段所示,TypeScript 类定义的实际输出是 JavaScript 闭包。因此 TypeScript 实际上为您生成了闭包。

注意

多年来,向 JavaScript 语言添加类的概念一直是人们讨论的话题,目前已经成为 ECMAScript 第六版(Harmony)标准的一部分,但这仍然是一个正在进行中的工作。微软已经承诺在 TypeScript 编译器中遵循 ECMAScript 标准,一旦这些标准发布,就会实现这些标准。

公共和私有访问器

封装中使用的另一个面向对象原则是数据隐藏的概念——即具有公共和私有变量的能力。私有变量应该对特定类的用户隐藏——因为这些变量只应该被类本身使用。意外地将这些变量暴露到类外部可能很容易导致运行时错误。

不幸的是,JavaScript 没有声明变量为私有的本地方法。虽然可以使用闭包来模拟这种功能,但很多 JavaScript 程序员简单地使用下划线字符_来表示私有变量。然而,在运行时,如果您知道私有变量的名称,您可以很容易地为它赋值。考虑以下 JavaScript 代码:

var MyClass = (function() {
    function MyClass() {
        this._count = 0;
    }
    MyClass.prototype.countUp = function() {
        this._count ++;
    }
    MyClass.prototype.getCountUp = function() {
        return this._count;
    }
    return MyClass;
}());

var test = new MyClass();
test._count = 17;
console.log("countUp : " + test.getCountUp());

MyClass变量实际上是一个闭包 - 具有构造函数、countUp函数和getCountUp函数。变量_count应该是一个私有成员变量,只在闭包范围内使用。使用下划线命名约定可以让这个类的用户知道这个变量是私有的,但是 JavaScript 仍然允许您操作变量_count。看一下代码片段的倒数第二行。我们明确地将假定的私有变量_count的值设置为 17 - 这是 JavaScript 允许的,但不是类的原始创建者所期望的。这段代码的输出将是countUp: 17

然而,TypeScript 引入了publicprivate关键字,可以用于类成员变量。尝试访问被标记为private的类成员变量将生成一个编译时错误。例如,上面的 JavaScript 代码可以在 TypeScript 中写成如下形式:

class MyClass {
    private _count: number;
    constructor() {
        this._count = 0;
    }
    countUp() {
        this._count++;
    }
    getCount() {
        return this._count;
    }
}

var classInstance = new MyClass();
console.log(classInstance._count);

在我们的代码片段的第二行,我们声明了一个名为_countprivate成员变量。同样,我们有一个构造函数、一个countUp和一个getCount函数。如果我们编译这个 TypeScript 代码,编译器将生成一个错误:

error TS2107: Build: 'MyClass._count' is inaccessible.

这个错误是因为我们试图在代码的最后一行访问私有变量_count

因此,TypeScript 编译器帮助我们遵守公共和私有访问者 - 当我们无意中违反这个规则时,它会生成一个编译错误。

注意

不过,请记住,这些访问者只是编译时的特性,不会影响生成的 JavaScript。如果您正在编写将被第三方使用的 JavaScript 库,您需要牢记这一点。即使存在编译错误,TypeScript 编译器仍会生成 JavaScript 输出文件。

TypeScript IDEs

本节的目的是让您快速上手使用 TypeScript 环境,以便您可以编辑、编译、运行和调试您的 TypeScript 代码。TypeScript 已经作为开源发布,并包括 Windows 版本和 Node 版本。这意味着编译器将在 Windows、Linux、OS X 和任何支持 Node 的其他操作系统上运行。

在 Windows 环境中,我们可以安装 Visual Studio - 这将在我们的C:\Program Files目录中注册tsc.exe(TypeScript 编译器),或者我们可以使用 Node。在 Linux 和 OS X 环境中,我们将需要使用 Node。无论哪种方式,启动命令提示符并输入tsc –v应该显示我们正在使用的编译器的当前版本。在撰写本文时,这个版本是 1.4.2.0。

在本节中,我们将看一下以下 IDE:

  • Visual Studio 2013

  • WebStorm

  • 括号

Visual Studio 2013

首先,让我们看一下微软的 Visual Studio 2013。这是微软的主要 IDE,有各种定价组合。最高端是 Ultimate,然后是 Premium,然后是 Professional,最后是 Express。Ultimate、Premium 和 Professional 都需要付费许可证,价格范围(撰写本文时)从 13000 美元到 1199 美元不等。好消息是,微软最近宣布了社区版,可以在非企业环境中免费使用。TypeScript 编译器包含在所有这些版本中。

Visual Studio 可以下载为 Web 安装程序或.ISO CD 映像。请注意,Web 安装程序在安装过程中需要互联网连接,因为它在安装步骤中下载所需的软件包。Visual Studio 还需要 Internet Explorer 10 或更高版本,但如果您尚未升级浏览器,它将在安装过程中提示您。如果您使用.ISO 安装程序,请记住,如果您已经有一段时间没有通过 Windows Update 更新系统,可能需要下载并安装额外的操作系统补丁。

创建 Visual Studio 项目

安装 Visual Studio 后,启动它并创建一个新项目(File | New Project)。在左侧的Templates部分下,您将看到一个 TypeScript 选项。选择此选项后,您将能够使用一个名为Html Application with TypeScript的项目模板。输入项目的名称和位置,然后单击OK生成一个 TypeScript 项目:

创建 Visual Studio 项目

Visual Studio - 选择 TypeScript 项目类型

注意

这不是唯一支持 TypeScript 的项目模板。任何 ASP.NET 项目类型都支持 TypeScript。如果您计划使用 Web API 提供 RESTful 数据控制器,那么您可能考虑从头开始创建一个 MVC Web 应用程序。然后,只需包含一个 TypeScript 文件,并在项目中指定.ts文件扩展名,Visual Studio 将自动开始编译您的 TypeScript 文件作为新项目的一部分。

默认项目设置

创建一个新的 TypeScript 项目后,注意项目模板会自动生成一些文件:

  • app.css

  • app.ts

  • index.html

  • web.config

如果我们现在编译然后运行这个项目,我们将立即拥有一个完整的、运行中的 TypeScript 应用程序:

默认项目设置

在 Internet Explorer 中运行的 Visual Studio index.html

让我们快速看一下生成的 index.html 文件及其内容:

<!DOCTYPE html>

<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>TypeScript HTML App</title>
    <link rel="stylesheet" href="app.css" type="text/css" />
    <script src="img/app.js"></script>
</head>
<body>
    <h1>TypeScript HTML App</h1>

    <div id="content"></div>
</body>
</html>

这是一个非常简单的 HTML 文件,包括app.css样式表,以及一个名为app.js的 JavaScript 文件。这个app.js文件是从app.ts TypeScript 文件生成的 JavaScript 文件,当项目被编译时。

注意

app.js文件不包括在Solution Explorer中 - 只有app.ts TypeScript 文件包括在内。这是有意设计的。如果您希望看到生成的 JavaScript 文件,只需点击Solution Explorer工具栏中的Show All Files按钮。

在 Visual Studio 中调试

Visual Studio 最好的功能之一是它真正是一个集成环境。在 Visual Studio 中调试 TypeScript 与调试 C#或 Visual Studio 中的任何其他语言完全相同,并包括通常的ImmediateLocalsWatchCall stack窗口。

要在 Visual Studio 中调试 TypeScript,只需在 TypeScript 文件中希望中断的行上设置断点(将鼠标移动到源代码行旁边的断点区域,然后单击)。在下面的图像中,我们在window.onload函数内设置了一个断点。

要开始调试,只需按下F5

在 Visual Studio 中调试

在 Visual Studio 中设置断点的 TypeScript 编辑器

当源代码行被黄色高亮显示时,只需将鼠标悬停在源代码中的任何变量上,或使用ImmediateWatchLocalsCall stack窗口。

注意

Visual Studio 只支持在 Internet Explorer 中调试。如果您的计算机上安装了多个浏览器,请确保在Debug工具栏中选择 Internet Explorer,如下面的截图所示:

在 Visual Studio 中调试

Visual Studio 调试工具栏显示浏览器选项

WebStorm

WebStorm 是 JetBrains(www.jetbrains.com/webstorm/)的一款流行的 IDE,可在 Windows、Mac OS X 和 Linux 上运行。价格从单个开发者的 49 美元到商业许可证的 99 美元不等。JetBrains 还提供 30 天的试用版本。

WebStorm 有一些很棒的功能,包括实时编辑和代码建议,或者智能感知。实时编辑功能允许您保持浏览器窗口打开,WebStorm 将根据您的输入自动更新 CSS、HTML 和 JavaScript 的更改。代码建议 - 这也是另一款流行的 JetBrains 产品 Resharper 提供的 - 将突出显示您编写的代码,并建议更好的实现方式。WebStorm 还有大量的项目模板。这些模板将自动下载并包含模板所需的相关 JavaScript 或 CSS 文件,例如 Twitter Bootstrap 或 HTML5 样板。

设置 WebStorm 就像从网站下载软件包并运行安装程序一样简单。

创建 WebStorm 项目

要创建一个新的 WebStorm 项目,只需启动它,然后点击文件 | 新建项目。选择名称位置项目类型。对于这个项目,我们选择了Twitter Bootstrap作为项目类型,如下面的屏幕截图所示:

创建 WebStorm 项目

WebStorm 创建新项目对话框

WebStorm 随后会要求您选择要开发的 Twitter Boostrap 版本。在本例中,我们选择了版本v3.2.0

创建 WebStorm 项目

WebStorm 选择 Twitter Boostrap 版本对话框

默认文件

WebStorm 方便地创建了一个cssfontsjs目录作为新项目的一部分 - 并为我们下载并包含了相关的 CSS、字体文件和 JavaScript 文件,以便开始构建基于 Bootstrap 的新网站。请注意,它没有为我们创建index.html文件,也没有创建任何 TypeScript 文件 - 就像 Visual Studio 一样。在使用 TypeScript 一段时间后,大多数开发人员都会删除这些通用文件。所以让我们创建一个index.html文件。

只需点击文件 | 新建,选择 HTML 文件,输入index作为名称,然后点击确定

接下来,让我们以类似的方式创建一个 TypeScript 文件。我们将把这个文件命名为app(或app.ts),与 Visual Studio 默认项目示例中的相同。当我们点击新的app.ts文件时,WebStorm 会在编辑窗口顶部弹出一个绿色栏,建议读取文件监视器'TypeScript'可用于此文件,如下面的屏幕截图所示:

默认文件

WebStorm 首次编辑 TypeScript 文件,显示文件监视器栏

WebStorm 的“文件监视器”是一个后台进程,将在您保存文件后立即执行。这相当于 Visual Studio 的保存时编译TypeScript 选项。正如 WebStorm 建议的那样,现在是激活 TypeScript 文件监视器的好时机。点击绿色栏中的添加监视器链接,并在下一个屏幕上填写详细信息。

我们可以暂时保持下一个屏幕上的默认设置不变,除了程序设置:

如果您在 Windows 上运行,并且已经安装了 Visual Studio,则应将其设置为tsc.exe可执行文件的完整路径,即C:\Program Files (x86)\Microsoft SDKs\TypeScript\1.0\tsc.exe,如下面的屏幕截图所示:

如果您在非 Windows 系统上运行,或者通过 Node 安装了 TypeScript,那么这个设置将只是tsc,没有路径。

默认文件

WebStorm 新文件监视器选项屏幕

现在我们已经为我们的 TypeScript 文件创建了一个文件监视器,让我们创建一个简单的 TypeScript 类,它将修改 HTML 的divinnerText。当您输入时,您会注意到 WebStorm 的自动完成或 Intellisense 功能,帮助您使用可用的关键字、参数、命名约定和其他语言特定信息。这是 WebStorm 最强大的功能之一,类似于 JetBrain 的 Resharper 工具中看到的增强 Intellisense。继续输入以下 TypeScript 代码,您将体验到 WebStorm 提供的自动完成功能。

class MyClass {
    public render(divId: string, text: string) {
        var el: HTMLElement = document.getElementById(divId);
        el.innerText = text;
    }
}

window.onload = () => {
    var myClass = new MyClass();
    myClass.render("content", "Hello World");
}

我们首先定义了MyClass类,它简单地有一个名为render的函数。这个render函数接受一个 DOM 元素名称和一个文本字符串作为参数。然后它简单地找到 DOM 元素,并设置innerText属性。请注意变量el的强类型使用-我们明确将其类型为HTMLElement类型。

我们还将一个函数分配给window.onload事件,这个函数将在页面加载后执行,类似于 Visual Studio 示例。在这个函数中,我们只是创建了MyClass的一个实例,并调用render函数,传入两个字符串参数。

如果您的 TypeScript 文件中有任何错误,这些错误将自动显示在输出窗口中,让您在输入时立即得到反馈。创建了这个 TypeScript 文件后,我们现在可以将其包含在我们的index.html文件中,并尝试一些调试。

打开index.html文件,并添加一个script标签来包含app.js JavaScript 文件,以及一个id"content"div。就像我们在 TypeScript 编辑中看到的一样,您会发现 WebStorm 在编辑 HTML 时也具有强大的 Intellisense 功能。

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title></title>
    <script src="img/app.js" type="application/javascript"></script>
</head>
<body>
    <h2>Index.html</h2>
    <div id="content"></div>
</body>
</html>

在上述代码中有几点要注意。我们正在包括一个app.js JavaScript 文件的脚本标签,因为这是 TypeScript 编译器将生成的输出文件。我们还创建了一个带有content id 的 HTML <div>MyClass类的实例将使用它来渲染我们的文本。

在 Chrome 中运行网页

在 WebStorm 中查看或编辑 HTML 文件时,您会注意到编辑窗口右上角会弹出一组小的浏览器图标。单击其中任何一个图标将使用所选的浏览器启动当前的 HTML 页面。

在 Chrome 中运行网页

WebStorm 编辑 HTML 文件显示弹出式浏览器启动图标

在 Chrome 中调试

正如我们在 Visual Studio 中看到的那样,在 WebStorm 中进行调试只是标记断点,然后按下Alt + F5。WebStorm 使用 Chrome 插件来启用在 Chrome 中进行调试。如果您没有安装这个插件,WebStorm 将在您第一次开始调试时提示您下载并启用 JetBrains IDE Support Chrome 插件。启用了这个插件后,WebStorm 有一套非常强大的工具来检查 JavaScript 代码,添加监视器,查看控制台等,都可以在 IDE 内部完成。

在 Chrome 中调试

WebStorm 调试会话显示调试器面板

Brackets

我们将在本章中看到的最后一个 IDE 实际上不是一个 TypeScript 的 IDE,它更像是一个具有 TypeScript 编辑功能的网页设计师 IDE。Brackets 是一个开源的代码编辑器,非常擅长帮助设计和样式网页。与 WebStorm 类似,它有一个实时编辑模式,您可以在输入时看到 HTML 或 CSS 在运行的网页上的更改。在我们的开发团队中,Brackets 已经成为快速原型设计 HTML 网页和 CSS 样式的非常受欢迎的编辑器。

在本章中包括 Brackets 有几个原因。首先,它是完全开源的,因此完全免费 - 并且可以在 Windows、Linux 和 Mac OS X 上运行。其次,使用 Brackets 环境可以展示一个多么简单的 TypeScript 环境会是什么样子,只需一个文本编辑器和命令行。最后,Brackets 显示了开源项目的语法高亮和代码补全能力可以和商业 IDE 一样好 - 如果不是更快。

安装括号

可以从brackets.io下载 Brackets 首选安装程序。安装完成后,我们需要安装一些扩展。Brackets 有一个非常简洁和简单的扩展管理器,易于使用,可以让我们轻松找到和安装可用的扩展。每当 Brackets 或已安装的扩展之一有更新时,Brackets 都会自动通知您。

要安装扩展,启动 Brackets,然后单击文件 | 扩展管理器,或单击右侧垂直侧边栏上的乐高图标。

首先,我们需要安装 TypeScript 扩展。在搜索栏中,键入brackets typescript,然后从Francois de Campredon那里安装Brackets TypeScript扩展。

如下截图所示,每个扩展都有一个更多信息…链接 - 这将带您到扩展主页。

安装括号

括号扩展管理器界面

除了Brackets TypeScript扩展之外,另一个有用的扩展是Patrick OladimejiCode Folding。这将允许您折叠或展开您正在编辑的任何文件中的代码部分。

另一个很棒的时间节省者是Sergey ChikujonokEmmet。 Emmet(以前称为 Zen Coding)使用类似于 CSS 的简写,而不是传统的代码片段,来生成 HTML。在本节中,我们将快速展示 Emmet 如何用于生成 HTML,就像一个预告片一样。所以继续安装 Emmet 扩展。

创建一个括号项目

括号本身并没有项目的概念,而是直接在根文件夹上工作。在文件系统上创建一个目录,然后在 Brackets 中打开该文件夹:文件 | 打开文件夹

现在让我们使用 Brackets 创建一个简单的 HTML 页面。选择文件 | 新建,或按Ctrl + N。在我们面前有一个空白文件时,我们将使用 Emmet 来生成我们的 HTML。输入以下 Emmet 字符串:

html>head+body>h3{index.html}+div#content

现在按下Ctrl + Alt + Enter,或从文件菜单中,选择Emmet | 展开缩写

哇!Emmet 在一毫秒内生成了以下 HTML 代码 - 对于一行源代码来说还不错。

<html>
<head></head>
<body>
    <h3>index.html</h3>
    <div id="content"></div>
</body>
</html>

按下Ctrl + S保存文件,并输入index.html

注意

只有在我们保存了文件之后,括号才会根据文件扩展名进行语法高亮。这对于任何括号文件都是真实的,所以一旦你创建了一个文件 - TypeScript,CSS 或 HTML,尽快将其保存到磁盘上。

回到 Emmet。

Emmet 使用>字符来创建子元素,使用+字符来表示兄弟元素。如果在元素旁边指定花括号{ },这将被用作文本内容。

我们之前输入的 Emmet 字符串基本上是这样说的:“创建一个带有子head标签的html标签。然后创建另一个名为bodyhtml标签的子标签,创建一个带有文本"index.html"的子h3标签,然后创建一个兄弟div标签作为body的子标签,其idcontent。”一定要前往emmet.io获取更多文档,并记得在学习 Emmet 字符串快捷方式时保持速查表方便(docs.emmet.io/cheat-sheet)。

现在让我们用一个app.js脚本来完成我们的index.html,以加载我们生成的 TypeScript JavaScript 文件。将光标移动到<head></head>标签之间,然后输入另一个 Emmet 字符串:

script:src

现在按下Ctrl + Alt + Enter,让 Emmet 生成一个<script src="img/code>标签,并方便地将光标放在引号之间,准备让您简单地填写空白。现在键入 JavaScript 文件名app.js

您完成的 index.html 文件现在应该如下所示:

<html>
<head>
    <script src="img/app.js"></script>
</head>
<body>
    <h3>index.html</h3>
    <div id="content"></div>
</body>
</html>

这就是我们样本 HTML 页面所需要的全部内容。

使用 Brackets 实时预览

在括号内,点击屏幕右侧的实时预览图标 - 它是电动的,就在乐高积木包图标的上方。这将启动 Chrome 并以实时预览模式渲染我们的index.html。为了展示 Brackets 可以用于实时预览,保持这个 Chrome 窗口可见,并导航回 Brackets。您应该能够同时看到两个窗口。

现在编辑index.html文件,在<div id="content"></div>元素下键入以下 Emmet 快捷方式:

ul>li.item$*5

再次按下Ctrl + Alt + Enter,注意生成的<ul><li>标签(共 5 个)如何自动显示在 Chrome 浏览器中。当您在源代码中上下移动光标时,注意 Chrome 中的蓝色轮廓如何显示网页中的元素。

使用 Brackets 实时预览

Brackets 在实时预览模式下运行 Chrome,显示突出显示的元素

我们不需要这些<ul> <li>标签用于我们的应用程序,所以简单地按下Ctrl + ZCtrl + Z来撤消我们的更改,或者删除这些标签。

创建一个 TypeScript 文件

要创建我们非常简单的 TypeScript 应用程序,按下Ctrl + N(新建文件),Ctrl + S(保存文件),并使用app.ts作为文件名。开始输入以下代码,并注意 Brackets 也会实时自动完成,或者类似于 Visual Studio 和 WebStorm 的智能感知功能:

class MyClass {
    render( elementId: string, text: string) {
        var el: HTMLElement = document.getElementById(elementId);
        el.innerHTML = text;
    }
}
window.onload = () => {
    var myClass = new MyClass();
    myClass.render("content", "Hello world!");
}

这是我们之前使用的相同代码,简单地创建了一个名为MyClass的 TypeScript 类,该类有一个render函数。这个render函数获取一个 DOM 元素,并修改它的innerHTML属性。window.onload函数创建了这个类的一个实例,然后使用适当的参数调用render函数。

如果您在任何阶段按下Ctrl + S保存文件,Brackets 将调用 TypeScript 语言引擎来验证我们的 TypeScript,并在底部窗格中呈现任何错误。在下面的截图中,我们可以清楚地看到我们缺少一个闭合大括号}

创建一个 TypeScript 文件

Brackets 编辑一个 TypeScript 文件并显示编译错误

Brackets 不会调用 TypeScript 编译器来生成app.js文件 - 它只是在这个阶段解析 TypeScript 代码,并突出显示任何错误。在TypeScript 问题窗格中双击错误将跳转到相关行。

编译我们的 TypeScript

在我们能够运行应用程序之前,我们需要通过调用 TypeScript 编译器将app.ts文件编译成一个app.js文件。打开命令提示符,切换到您的源目录,然后简单地输入:

**tsc app.ts** 

这个命令将调用tsc命令行编译器,并从我们的app.ts文件创建一个app.js文件。

现在我们在这个目录中有一个app.js文件,我们可以再次调用实时预览按钮,现在可以看到我们的 TypeScript 应用程序确实将Hello world!文本呈现为内容divinnerHTML

编译我们的 TypeScript

Brackets 实时预览运行我们的 TypeScript 应用程序

使用 Grunt

显然,每次我们进行更改时都必须切换到命令提示符并手动编译每个 TypeScript 文件将会非常乏味。Grunt 是一个自动化任务运行器(gruntjs.com),可以自动化许多乏味的编译、构建和测试任务。在本节中,我们将使用 Grunt 来监视 TypeScript 文件,并在保存文件时调用tsc编译器。这与我们之前使用的 WebStorm 文件监视功能非常相似。

Grunt 在 Node 环境中运行。Node 是一个开源的跨平台运行时环境,其程序是用 JavaScript 编写的。因此,要运行 Grunt,我们需要安装 Node。Windows、Linux 和 OS X 的安装程序可以在 Node 网站(nodejs.org/)上找到。安装 Node 后,我们可以使用npmNode 包管理器)来安装 Grunt 和 Grunt 命令行界面。

Grunt 需要作为项目的 npm 依赖项安装。它不能像大多数 npm 包那样全局安装。为了做到这一点,我们需要在项目的根目录中创建一个packages.json文件。打开命令提示符,并导航到 Brackets 项目的根目录。然后简单地输入:

**npm init** 

然后按照提示操作。您几乎可以将所有选项保留为默认设置,并始终返回编辑从此步骤创建的packages.json文件,以便在需要调整任何更改时进行编辑。完成包初始化步骤后,我们现在可以按照以下方式安装 Grunt:

**npm install grunt –save-dev** 

-save-dev 选项将在项目目录中安装 Grunt 的本地版本。这样做是为了确保您的计算机上的多个项目可以使用不同版本的 Grunt。我们还需要安装grunt-typescript包,以及grunt-contrib-watch包。这些可以使用以下 npm 命令安装:

**Npm install grunt-typescript –save-dev**
**Npm install grunt-contrib-watch –save-dev.** 

最后,我们需要一个GruntFile.js作为 Grunt 的入口点。使用 Brackets,创建一个新文件,保存为GruntFile.js,并输入以下 JavaScript。请注意,这里我们创建的是 JavaScript 文件,而不是 TypeScript 文件。您可以在本章附带的示例源代码中找到此文件的副本。

module.exports = function (grunt) {
    grunt.loadNpmTasks('grunt-typescript');
    grunt.loadNpmTasks('grunt-contrib-watch');
    grunt.initConfig({
        pkg: grunt.file.readJSON('package.json'),
        typescript: {
            base: {
                src: ['**/*.ts'],
                options: {
                    module: 'commonjs',
                    target: 'es5',
                    sourceMap: true
                }
            }
        },
        watch: {
            files: '**/*.ts',
            tasks: ['typescript']
        }
    });

   //grunt.registerTask('default', ['typescript']);
    grunt.registerTask('default', ['watch']);
}

这个GruntFile.js是设置所有 Grunt 任务所必需的。它是一个简单的函数,Grunt 用它来初始化 Grunt 环境,并指定 Grunt 命令。函数的前两行加载了grunt-typescriptgrunt-contrib-watch任务,然后运行了带有配置部分的grunt.initConfig函数。这个配置部分有一个pkg属性,一个typescript属性和一个watch属性。pkg属性是通过读取我们之前创建的package.json文件来设置的,这是 npm init 步骤的一部分。

typescript属性有一个base属性,在其中我们指定源代码应该是'**/*.ts' - 换句话说,任何子目录中的所有.ts文件。我们还指定了一些 TypeScript 选项 - 使用'commonjs'模块而不是'amd'模块,并生成 sourcemaps。

watch属性有两个子属性。files属性指定要监视源树中的任何.ts文件,tasks数组指定一旦文件发生更改,我们应该启动 TypeScript 命令。最后,我们调用grunt.registerTask,指定默认任务是监视文件更改。Grunt 将在后台运行,监视保存的文件,如果找到,将执行 TypeScript 任务。

现在我们可以从命令行运行 Grunt。确保您在 Brackets 项目的基本目录中,并启动 Grunt:

**Grunt** 

打开您的app.ts文件,进行一些小改动(添加一个空格或其他内容),然后按下Ctrl + S进行保存。现在检查 Grunt 命令行的输出。您应该会看到类似以下的内容:

**>> File "app.ts" changed.**
**Running "typescript:base" (typescript) task**
**2 files created. js: 1 file, map: 1 file, declaration: 0 files (861ms)**
**Done, without errors.**
**Completed in 1.665s at Fri Oct 10 2014 11:24:47 GMT+0800 (W. Australia Standard Time) - Waiting...** 

这个命令行输出证实了 Grunt watch 任务已经确认app.ts文件已经发生了变化,运行了 TypeScript 任务,创建了两个文件,现在正在等待下一个文件的变化。回到 Brackets,我们现在应该在 Brackets 文件窗格中看到 Grunt 创建的app.js文件。

在 Chrome 中调试

由于 Brackets 只是作为编辑器使用,我们需要使用标准的 Chrome 开发工具来调试我们的应用程序。我们在GruntFile.js中指定的一个选项是打开 sourcemap(options { sourceMap : true })。有了这个选项,Chrome - 和其他浏览器 - 可以将运行的 JavaScript 映射回源 TypeScript 文件。这意味着您可以在 TypeScript 文件中设置调试器断点,并在调试时遍历 TypeScript 文件。

要调试我们的示例应用程序,首先在实时预览模式下运行index.html页面,然后按下F12以打开开发工具。Chrome 为开发人员提供了许多工具,包括 Network、Console 和 Elements 来检查 DOM。点击Sources选项卡,按下Ctrl + P打开文件。滚动到app.ts,然后按下Enter。在第 9 行(var myClass = new MyClass())设置断点,然后重新加载页面。

Chrome 应该在调试器模式下暂停页面,方法如下:

在 Chrome 中调试

括号调试 TypeScript 使用 Chrome 开发工具。

现在您可以尽情使用所有 Chrome 调试工具。

总结

在本章中,我们快速了解了 TypeScript 是什么,以及它可以为 JavaScript 开发体验带来什么好处。我们还看了如何使用两种流行的商业 IDE 和一个开源开发环境来设置开发环境。现在我们已经设置好了开发环境,可以开始更详细地了解 TypeScript 语言。我们将从类型开始,然后转向变量,然后在下一章讨论函数。

为 Bentham Chang 准备,Safari ID bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需版权所有者事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第二章:类型,变量和函数技术

TypeScript 通过一种简单的语法引入了强类型到 JavaScript,安德斯·海尔斯伯格称之为“语法糖”。

这一章是对 TypeScript 语言中用于将强类型应用于 JavaScript 的语法的介绍。它适用于以前没有使用过 TypeScript 的读者,并涵盖了从标准 JavaScript 过渡到 TypeScript 的过程。如果您已经有了 TypeScript 的经验,并且对下面列出的主题有很好的理解,那么请快速阅读一下,或者跳到下一章。

我们将在本章中涵盖以下主题:

  • 基本类型和类型语法:字符串、数字和布尔值

  • 推断类型和鸭子类型

  • 数组和枚举

  • 任意类型和显式转换

  • 函数和匿名函数

  • 可选和默认函数参数

  • 参数数组

  • 函数回调和函数签名

  • 函数作用域规则和重载

基本类型

JavaScript 变量可以保存多种数据类型,包括数字、字符串、数组、对象、函数等。JavaScript 中对象的类型由其赋值确定——因此,如果一个变量被赋予了字符串值,那么它将是字符串类型。然而,这可能会在我们的代码中引入许多问题。

JavaScript 没有强类型

正如我们在第一章中看到的,TypeScript – 工具和框架选项,JavaScript 对象和变量可以在运行时更改或重新分配。例如,考虑以下 JavaScript 代码:

var myString = "test";
var myNumber = 1;
var myBoolean = true;

我们首先定义三个变量,名为myStringmyNumbermyBooleanmyString变量设置为字符串值"test",因此将是string类型。同样,myNumber设置为值1,因此是number类型,myBoolean设置为true,因此是boolean类型。现在让我们开始将这些变量相互赋值,如下所示:

myString = myNumber;
myBoolean = myString;
myNumber = myBoolean;

我们首先将myString的值设置为myNumber的值(即数字值1)。然后将myBoolean的值设置为myString的值(现在将是数字值1)。最后,我们将myNumber的值设置为myBoolean的值。这里发生的是,即使我们最初有三种不同类型的变量——字符串、数字和布尔值——我们仍然能够将其中任何一个重新分配给另一种类型。我们可以将数字赋给字符串,字符串赋给布尔值,或者布尔值赋给数字。

虽然在 JavaScript 中这种赋值是合法的,但它表明 JavaScript 语言并不是强类型的。这可能导致我们的代码出现意外的行为。我们的代码的某些部分可能依赖于一个特定变量保存一个字符串的事实,如果我们无意中将一个数字赋给这个变量,我们的代码可能会以意想不到的方式开始出现问题。

TypeScript 是强类型的

另一方面,TypeScript 是一种强类型语言。一旦你声明一个变量为string类型,你只能给它赋string值。所有进一步使用这个变量的代码必须将其视为string类型。这有助于确保我们编写的代码会按预期运行。虽然强类型在处理简单的字符串和数字时似乎没有任何用处,但当我们将相同的规则应用于对象、对象组、函数定义和类时,它确实变得重要。如果你编写了一个函数,期望第一个参数是string,第二个参数是number,如果有人用boolean作为第一个参数,另一个东西作为第二个参数调用你的函数,你是无法责怪的。

JavaScript 程序员一直严重依赖文档来理解如何调用函数,以及正确的函数参数的顺序和类型。但是,如果我们能够将所有这些文档包含在 IDE 中呢?然后,当我们编写代码时,我们的编译器可以自动指出我们错误地使用了对象和函数。这肯定会使我们更高效,更有生产力的程序员,使我们能够生成更少错误的代码。

TypeScript 确实做到了这一点。它引入了一种非常简单的语法来定义变量或函数参数的类型,以确保我们以正确的方式使用这些对象、变量和函数。如果我们违反了这些规则,TypeScript 编译器将自动生成错误,指出我们代码中的错误行。

这就是 TypeScript 得名的原因。它是带有强类型的 JavaScript - 因此是 TypeScript。让我们来看看这种非常简单的语言语法,它使 TypeScript 中的“类型”成为可能。

类型语法

声明变量类型的 TypeScript 语法是在变量名后面加上冒号(:),然后指定其类型。考虑以下 TypeScript 代码:

var myString : string = "test";
var myNumber: number = 1;
var myBoolean : boolean = true;

这段代码片段是我们前面的 JavaScript 代码的 TypeScript 等价物,并展示了为myString变量声明类型的 TypeScript 语法的示例。通过包括冒号和关键字string: string),我们告诉编译器myString变量是string类型。同样,myNumber变量是number类型,myBoolean变量是boolean类型。TypeScript 为每种基本 JavaScript 类型引入了stringnumberboolean关键字。

如果我们尝试将一个不同类型的值赋给一个变量,TypeScript 编译器将生成编译时错误。在前面代码中声明的变量的情况下,以下 TypeScript 代码将生成一些编译错误:

myString = myNumber;
myBoolean = myString;
myNumber = myBoolean;

类型语法

在分配不正确的类型时,TypeScript 生成构建错误

TypeScript 编译器正在生成编译错误,因为我们试图混合这些基本类型。第一个错误是由编译器生成的,因为我们不能将number值赋给string类型的变量。同样,第二个编译错误表示我们不能将string值赋给boolean类型的变量。同样,第三个错误是因为我们不能将boolean值赋给number类型的变量。

TypeScript 语言引入的强类型语法意味着我们需要确保赋值操作符(=)左侧的类型与赋值操作符右侧的类型相同。

要修复前面的 TypeScript 代码并消除编译错误,我们需要做类似以下的事情:

myString = myNumber.toString();
myBoolean = (myString === "test");
if (myBoolean) {
    myNumber = 1;
}

我们的第一行代码已更改为在myNumber变量(类型为number)上调用.toString()函数,以返回一个string类型的值。这行代码不会生成编译错误,因为等号两边的类型相同。

我们的第二行代码也已更改,以便赋值操作符的右侧返回比较的结果,myString === "test",这将返回一个boolean类型的值。因此,编译器将允许这段代码,因为赋值的两侧都解析为boolean类型的值。

我们代码片段的最后一行已更改为仅在myBoolean变量的值为true时将值1(类型为number)赋给myNumber变量。

Anders Hejlsberg 将这一特性描述为“语法糖”。通过在可比较的 JavaScript 代码上添加一些糖,TypeScript 使我们的代码符合了强类型规则。每当你违反这些强类型规则时,编译器都会为你的有问题的代码生成错误。

推断类型

TypeScript 还使用了一种叫做推断类型的技术,在你没有明确指定变量类型的情况下。换句话说,TypeScript 会找到代码中变量的第一次使用,找出变量最初初始化的类型,然后假定在代码块的其余部分中该变量的类型相同。举个例子,考虑以下代码:

var myString = "this is a string";
var myNumber = 1;
myNumber = myString;

我们首先声明了一个名为myString的变量,并给它赋了一个字符串值。TypeScript 确定这个变量被赋予了string类型的值,因此会推断出这个变量的任何进一步使用都是string类型。我们的第二个变量,名为myNumber,被赋予了一个数字。同样,TypeScript 推断出这个变量的类型是number。如果我们尝试在代码的最后一行将myString变量(类型为string)赋给myNumber变量(类型为number),TypeScript 将生成一个熟悉的错误消息:

error TS2011: Build: Cannot convert 'string' to 'number'

这个错误是由于 TypeScript 的推断类型规则所生成的。

鸭子类型

TypeScript 还对更复杂的变量类型使用了一种叫做鸭子类型的方法。鸭子类型意味着如果它看起来像鸭子,叫起来像鸭子,那么它很可能就是鸭子。考虑以下 TypeScript 代码:

var complexType = { name: "myName", id: 1 };
complexType = { id: 2, name: "anotherName" };

我们从一个名为complexType的变量开始,它被赋予了一个包含nameid属性的简单 JavaScript 对象。在我们的第二行代码中,我们可以看到我们正在重新分配这个complexType变量的值给另一个也有idname属性的对象。编译器将在这种情况下使用鸭子类型来判断这个赋值是否有效。换句话说,如果一个对象具有与另一个对象相同的属性集,那么它们被认为是相同类型的。

为了进一步说明这一点,让我们看看编译器在我们尝试将一个不符合鸭子类型的对象分配给我们的complexType变量时的反应:

var complexType = { name: "myName", id: 1 };
complexType = { id: 2 };
complexType = { name: "anotherName" };
complexType = { address: "address" };

这段代码片段的第一行定义了我们的complexType变量,并将一个包含idname属性的对象赋给它。从这一点开始,TypeScript 将在我们尝试分配给complexType变量的任何值上使用这个推断类型。在我们的第二行代码中,我们尝试分配一个具有id属性但没有name属性的值。在第三行代码中,我们再次尝试分配一个具有name属性但没有id属性的值。在代码片段的最后一行,我们完全错了。编译这段代码将生成以下错误:

error TS2012: Build: Cannot convert '{ id: number; }' to '{ name: string; id: number; }':
error TS2012: Build: Cannot convert '{ name: string; }' to '{ name: string; id: number; }':
error TS2012: Build: Cannot convert '{ address: string; }' to '{ name: string; id: number; }':

从错误消息中我们可以看到,TypeScript 使用鸭子类型来确保类型安全。在每条消息中,编译器都给出了关于有问题的代码的线索 - 明确说明了它期望的内容。complexType变量既有id属性,也有name属性。因此,要给complexType变量赋值,这个值将需要同时具有idname属性。通过处理每一个错误,TypeScript 都明确说明了每一行代码的问题所在。

请注意,以下代码不会生成任何错误消息:

var complexType = { name: "myName", id: 1 };
complexType = { name: "name", id: 2, address: "address" };

再次,我们的第一行代码定义了 complexType 变量,就像我们之前看到的那样,具有 idname 属性。现在,看一下这个例子的第二行。我们正在使用的对象实际上有三个属性:nameidaddress。即使我们添加了一个新的 address 属性,编译器只会检查我们的新对象是否同时具有 idname。因为我们的新对象具有这些属性,因此将匹配变量的原始类型,TypeScript 将允许通过鸭子类型进行此赋值。

推断类型和鸭子类型是 TypeScript 语言的强大特性——为我们的代码带来了强类型,而无需使用显式类型,即冒号 : 然后是类型说明符语法。

数组

除了基本的 JavaScript 类型字符串、数字和布尔值之外,TypeScript 还有两种其他数据类型:数组和枚举。让我们看一下定义数组的语法。

数组只是用 [] 符号标记,类似于 JavaScript,并且每个数组可以被强类型化以保存特定类型,如下面的代码所示:

var arrayOfNumbers: number[] = [1, 2, 3];
arrayOfNumbers = [3, 4, 5];
arrayOfNumbers = ["one", "two", "three"];

在这个代码片段的第一行,我们定义了一个名为 arrayOfNumbers 的数组,并进一步指定该数组的每个元素必须是 number 类型。然后,第二行重新分配了这个数组以保存一些不同的数值。

然而,这个片段的最后一行将生成以下错误消息:

error TS2012: Build: Cannot convert 'string[]' to 'number[]':

这个错误消息警告我们,变量 arrayOfNumbers 的强类型只接受 number 类型的值。我们的代码试图将一个字符串数组赋给这个数字数组,因此会生成一个编译错误。

任意类型

所有这些类型检查都很好,但 JavaScript 足够灵活,允许变量混合使用。以下代码片段实际上是有效的 JavaScript 代码:

var item1 = { id: 1, name: "item 1" };
item1 = { id: 2 };

我们的第一行代码将一个具有 id 属性和 name 属性的对象分配给变量 item1。然后,第二行将这个变量重新分配给一个只有 id 属性而没有 name 属性的对象。不幸的是,正如我们之前所见,TypeScript 会为前面的代码生成一个编译时错误:

error TS2012: Build: Cannot convert '{ id: number; }' to '{ id: number; name: string; }'

TypeScript 为这种情况引入了 any 类型。在本质上,指定对象的类型为 any 会放宽编译器的严格类型检查。以下代码显示了如何使用 any 类型:

var item1 : any = { id: 1, name: "item 1" };
item1 = { id: 2 };

注意我们的第一行代码已经改变。我们指定变量 item1 的类型为 : any,这样我们的代码就可以编译而不会出错。没有类型说明符 : any,第二行代码通常会生成一个错误。

显式转换

与任何强类型语言一样,总有一个时刻需要明确指定对象的类型。这个概念将在下一章中更加详细地展开,但在这里快速记录显式转换是值得的。可以使用 < > 语法将一个对象转换为另一个对象的类型。

注意

这不是严格意义上的转换;它更像是 TypeScript 编译器在运行时使用的断言。您使用的任何显式转换都将在生成的 JavaScript 中被编译掉,并且不会影响运行时的代码。

让我们修改之前的代码片段来使用显式转换:

var item1 = <any>{ id: 1, name: "item 1" };
item1 = { id: 2 };

请注意,在这段代码片段的第一行,我们现在已经用右边的<any>显式转换替换了赋值左边的: any类型指定符。这段代码片段告诉编译器显式地转换,或者显式地将右边的{ id: 1, name: "item 1" }对象作为any类型处理。因此,item1变量也因此具有any类型(由于 TypeScript 的推断类型规则)。这样就允许我们在代码的第二行将只有{ id: 2 }属性的对象赋值给变量item1。在赋值的右边使用< >语法的这种技术称为显式转换。

虽然any类型是 TypeScript 语言的一个必要特性,但它的使用应尽可能受到限制。它是一种确保与 JavaScript 兼容性的语言快捷方式,但过度使用any类型会很快导致难以发现的编码错误。与其使用any类型,不如尝试找出你正在使用的对象的正确类型,然后使用这种类型。我们在编程团队内使用一个缩写:S.F.I.A.T.(读作 sviat 或 sveat)。Simply Find an Interface for the Any Type。虽然这听起来有些愚蠢,但它强调了any类型应该总是被接口替换,所以只需找到它。接口是在 TypeScript 中定义自定义类型的一种方式,我们将在下一章中介绍接口。只需记住,通过积极尝试定义对象的类型应该是什么,我们正在构建强类型代码,因此保护自己免受未来的编码错误和错误的影响。

枚举

枚举是从其他语言(如 C#)借鉴过来的一种特殊类型,它提供了解决特殊数字问题的解决方案。枚举将人类可读的名称与特定数字关联起来。考虑以下代码:

enum DoorState {
    Open,
    Closed,
    Ajar
}

在这段代码片段中,我们定义了一个名为DoorStateenum,用于表示门的状态。这个门状态的有效值是OpenClosedAjar。在底层(在生成的 JavaScript 中),TypeScript 将为这些人类可读的枚举值分配一个数值。在这个例子中,DoorState.Open的枚举值将等于数值0。同样,枚举值DoorState.Closed将等于数值1,而DoorState.Ajar的枚举值将等于2。让我们快速看一下我们将如何使用这些枚举值:

window.onload = () => {
    var myDoor = DoorState.Open;
    console.log("My door state is " + myDoor.toString());
};

window.onload函数中的第一行创建了一个名为myDoor的变量,并将其值设置为DoorState.Open。第二行只是将myDoor的值记录到控制台。这个console.log函数的输出将是:

My door state is 0

这清楚地显示了 TypeScript 编译器已经用数值0替换了DoorState.Open的枚举值。现在让我们以稍微不同的方式使用这个枚举:

window.onload = () => {
    var openDoor = DoorState["Closed"];
    console.log("My door state is " + openDoor.toString());
};

这段代码片段使用字符串值"Closed"来查找enum类型,并将结果的枚举值赋给openDoor变量。这段代码的输出将是:

My door state is 1

这个示例清楚地显示了DoorState.Closed的枚举值与DoorState["Closed"]的枚举值相同,因为两种变体都解析为1的数值。最后,让我们看看当我们使用数组类型语法引用枚举时会发生什么:

window.onload = () => {
    var ajarDoor = DoorState[2];
    console.log("My door state is " + ajarDoor.toString());
};

在这里,我们将变量openDoor赋值为基于DoorState枚举的第二个索引值的枚举值。然而,这段代码的输出令人惊讶:

My door state is Ajar

您可能期望输出只是2,但这里我们得到的是字符串"Ajar" - 这是我们原始枚举名称的字符串表示。这实际上是一个巧妙的小技巧 - 允许我们访问枚举值的字符串表示。这种可能性的原因在于 TypeScript 编译器生成的 JavaScript。让我们看一下 TypeScript 编译器生成的闭包:

var DoorState;
(function (DoorState) {
    DoorState[DoorState["Open"] = 0] = "Open";
    DoorState[DoorState["Closed"] = 1] = "Closed";
    DoorState[DoorState["Ajar"] = 2] = "Ajar";
})(DoorState || (DoorState = {}));

这种看起来很奇怪的语法正在构建一个具有特定内部结构的对象。正是这种内部结构使我们能够以刚刚探索的各种方式使用这个枚举。如果我们在调试 JavaScript 时查询这个结构,我们将看到DoorState对象的内部结构如下:

DoorState
{...}
    [prototype]: {...}
    [0]: "Open"
    [1]: "Closed"
    [2]: "Ajar"
    [prototype]: []
    Ajar: 2
    Closed: 1
    Open: 0

DoorState对象有一个名为"0"的属性,其字符串值为"Open"。不幸的是,在 JavaScript 中,数字0不是有效的属性名称,因此我们不能简单地使用DoorState.0来访问此属性。相反,我们必须使用DoorState[0]DoorState["0"]来访问此属性。DoorState对象还有一个名为Open的属性,其值设置为数字0。在 JavaScript 中,Open是一个有效的属性名称,因此我们可以使用DoorState["Open"]或简单地DoorState.Open来访问此属性,这在 JavaScript 中等同于同一个属性。

尽管底层的 JavaScript 可能有点令人困惑,但我们需要记住的是,枚举是一种方便的方式,可以为特殊数字定义一个易于记忆和人类可读的名称。使用易于阅读的枚举,而不是在代码中散布各种特殊数字,也使代码的意图更加清晰。使用应用程序范围的值DoorState.OpenDoorState.Closed比记住为Open设置值为0Closed设置值为1ajar设置值为3要简单得多。除了使我们的代码更易读、更易维护外,使用枚举还可以在这些特殊数字值发生变化时保护我们的代码库,因为它们都在一个地方定义了。

关于枚举的最后一点说明 - 如果需要,我们可以手动设置数值:

enum DoorState {
    Open = 3,
    Closed = 7,
    Ajar = 10
}

在这里,我们已经覆盖了枚举的默认值,将DoorState.Open设置为3DoorState.Closed设置为7DoorState.Ajar设置为10

Const 枚举

随着 TypeScript 1.4 的发布,我们还可以定义const枚举如下:

const enum DoorStateConst {
    Open,
    Closed,
    Ajar
}

var myState = DoorStateConst.Open;

这些类型的枚举主要是出于性能原因引入的,由此产生的 JavaScript 将不包含我们之前看到的DoorStateConst枚举的完整闭包定义。让我们快速看一下从这个DoorStateConst枚举生成的 JavaScript:

var myState = 0 /* Open */;

请注意,我们根本没有完整的 JavaScript 闭包DoorStateConstenum。编译器只是将DoorStateConst.Open枚举解析为其内部值0,并完全删除了const enum定义。

因此,使用 const 枚举时,我们无法引用枚举的内部字符串值,就像我们在之前的代码示例中所做的那样。考虑以下示例:

// generates an error
console.log(DoorStateConst[0]);
// valid usage
console.log(DoorStateConst["Open"]);

第一个console.log语句现在将生成一个编译时错误 - 因为我们没有完整的闭包可用于我们的 const 枚举的[0]属性。然而,这个const枚举的第二个用法是有效的,并将生成以下 JavaScript:

console.log(0 /* "Open" */);

使用 const 枚举时,只需记住编译器将剥离所有枚举定义,并直接将枚举的数值替换到我们的 JavaScript 代码中。

函数

JavaScript 使用function关键字、一组大括号,然后是一组花括号来定义函数。典型的 JavaScript 函数将被编写如下:

function addNumbers(a, b) {
    return a + b;
}

var result = addNumbers(1, 2);
var result2 = addNumbers("1", "2");

这段代码很容易理解;我们定义了一个名为addNumbers的函数,它接受两个变量并返回它们的和。然后我们调用这个函数,传入12的值。变量result的值将是1 + 2,即3。现在看看代码的最后一行。在这里,我们调用addNumbers函数,传入两个字符串作为参数,而不是数字。变量result2的值将是一个字符串"12"。这个字符串值似乎可能不是期望的结果,因为函数的名称是addNumbers

将前面的代码复制到一个 TypeScript 文件中不会生成任何错误,但让我们在前面的 JavaScript 中插入一些类型规则,使其更加健壮:

function addNumbers(a: number, b: number): number {
    return a + b;
};

var result = addNumbers(1, 2);
var result2 = addNumbers("1", "2");

在这个 TypeScript 代码中,我们为addNumbers函数的两个参数ab添加了:number类型,并且在( )括号后面也添加了:number类型。在这里放置类型描述符意味着函数本身的返回类型被强制类型化为返回一个number类型的值。然而,在 TypeScript 中,代码的最后一行将导致编译错误:

error TS2082: Build: Supplied parameters do not match any signature of call target:

这个错误消息是由于我们明确声明了函数应该只接受number类型的两个参数ab,但在我们的错误代码中,我们传递了两个字符串。因此,TypeScript 编译器无法匹配一个名为addNumbers的函数的签名,该函数接受两个string类型的参数。

匿名函数

JavaScript 语言也有匿名函数的概念。这些是在定义时即时定义的函数,不指定函数名称。考虑以下 JavaScript 代码:

var addVar = function(a, b) {
    return a + b;
};

var result = addVar(1, 2);

这段代码定义了一个没有名称的函数,它添加了两个值。因为这个函数没有名称,所以它被称为匿名函数。然后将这个匿名函数分配给一个名为addVar的变量。然后,addVar变量可以作为一个函数调用,带有两个参数,并且返回值将是执行匿名函数的结果。在这种情况下,变量result将具有值3

现在让我们用 TypeScript 重写前面的 JavaScript 函数,并添加一些类型语法,以确保函数只接受两个number类型的参数,并返回一个number类型的值:

var addVar = function(a: number, b: number): number {
    return a + b;
}

var result = addVar(1, 2);
var result2 = addVar("1", "2");

在这段代码中,我们创建了一个匿名函数,它只接受类型为number的参数ab,并且返回类型为number的值。现在ab参数的类型,以及函数的返回类型,都使用了:number语法。这是 TypeScript 注入到语言中的另一个简单的“语法糖”的例子。如果我们编译这段代码,TypeScript 将拒绝最后一行的代码,在这里我们尝试用两个字符串参数调用我们的匿名函数:

error TS2082: Build: Supplied parameters do not match any signature of call target:

可选参数

当我们调用一个期望参数的 JavaScript 函数,并且我们没有提供这些参数时,函数内部的参数值将是undefined。作为这一点的例子,考虑以下 JavaScript 代码:

var concatStrings = function(a, b, c) {
    return a + b + c;
}

console.log(concatStrings("a", "b", "c"));
console.log(concatStrings("a", "b"));

在这里,我们定义了一个名为concatStrings的函数,它接受三个参数abc,并简单地返回这些值的总和。如果我们使用所有三个参数调用这个函数,就像在这个片段的倒数第二行中看到的那样,我们将在控制台中得到字符串"abc"。然而,如果我们只提供两个参数,就像在这个片段的最后一行中看到的那样,将在控制台中得到字符串"abundefined"。再次,如果我们调用一个函数并且不提供参数,那么这个参数,在我们的例子中是c,将简单地是undefined

TypeScript 引入了问号?语法来表示可选参数。考虑以下 TypeScript 函数定义:

var concatStrings = function(a: string, b: string, c?: string) {
    return a + b + c;
}

console.log(concatStrings("a", "b", "c"));
console.log(concatStrings("a", "b"));
console.log(concatStrings("a"));

这是原始concatStrings JavaScript 函数的强类型版本,我们之前使用过。请注意在第三个参数的语法中添加了?字符:c?: string。这表示第三个参数是可选的,因此,除了最后一行之外,所有前面的代码都将编译成功。最后一行将生成一个错误:

error TS2081: Build: Supplied parameters do not match any signature of call target.

这个错误是因为我们试图用只有一个参数调用concatStrings函数。然而,我们的函数定义要求至少有两个参数,只有第三个参数是可选的。

注意

可选参数必须是函数定义中的最后一个参数。只要非可选参数在可选参数之前,你可以有任意数量的可选参数。

默认参数

可选参数函数定义的微妙变体允许我们指定参数的默认值,如果它没有从调用代码中作为参数传递进来。让我们修改前面的函数定义来使用可选参数:

var concatStrings = function(a: string, b: string, c: string = "c") {
    return a + b + c;
}

console.log(concatStrings("a", "b", "c"));
console.log(concatStrings("a", "b"));

这个函数定义现在已经去掉了?可选参数的语法,而是给最后一个参数赋了一个值:"c:string = "c"。通过使用默认参数,如果我们没有为最后一个参数命名为c提供一个值,concatStrings函数将会用默认值"c"来替代。因此参数c将不会是undefined。最后两行代码的输出都将是"abc"。

注意

注意,使用默认参数语法将自动使参数变为可选。

参数变量

JavaScript 语言允许一个函数被调用时带有可变数量的参数。每个 JavaScript 函数都可以访问一个特殊的变量,名为arguments,它可以用来检索传递给函数的所有参数。例如,考虑以下 JavaScript 代码:

function testParams() {
    if (arguments.length > 0) {
        for (var i = 0; i < arguments.length; i++) {
            console.log("Argument " + i + " = " + arguments[i]);
        }
    }
}

testParams(1, 2, 3, 4);
testParams("first argument");

在这段代码中,我们定义了一个名为testParams的函数,没有任何命名参数。但请注意,我们可以使用特殊变量arguments来测试函数是否被调用了任何参数。在我们的示例中,我们可以简单地遍历arguments数组,并通过使用数组索引器arguments[i]将每个参数的值记录到控制台中。console.log 调用的输出如下:

Argument 0 = 1
Argument 1 = 2
Argument 2 = 3
Argument 3 = 4
Argument 0 = first argument

那么,在 TypeScript 中如何表示可变数量的函数参数呢?答案是使用所谓的剩余参数,或者三个点()的语法。下面是用 TypeScript 表达的等价testParams函数:

function testParams(...argArray: number[]) {
    if (argArray.length > 0) {
        for (var i = 0; i < argArray.length; i++) {
            console.log("argArray " + i + " = " + argArray[i]);
            console.log("arguments " + i + " = " + arguments[i]);
        }
    }

}

testParams(1);
testParams(1, 2, 3, 4);
testParams("one", "two");

请注意我们的testParams函数使用了…argArray: number[]的语法。这个语法告诉 TypeScript 编译器函数可以接受任意数量的参数。这意味着我们对这个函数的使用,即用testParams(1)testParams(1,2,3,4)调用函数,都将正确编译。在这个版本的testParams函数中,我们添加了两个console.log行,只是为了展示arguments数组可以通过命名的剩余参数argArray[i]或通过普通的 JavaScript 数组arguments[i]来访问。

在这个示例中,最后一行将会生成一个编译错误,因为我们已经定义了剩余参数只接受数字,而我们正试图用字符串调用这个函数。

注意

使用argArrayarguments的微妙差异在于参数的推断类型。由于我们明确指定了argArray的类型为number,TypeScript 将把argArray数组的任何项都视为数字。然而,内部的arguments数组没有推断类型,因此将被视为any类型。

我们还可以在函数定义中结合普通参数和剩余参数,只要剩余参数是参数列表中的最后一个定义,如下所示:

function testParamsTs2(arg1: string,
    arg2: number, ...ArgArray: number[]) {
}

在这里,我们有两个名为arg1arg2的普通参数,然后是一个argArray剩余参数。错误地将剩余参数放在参数列表的开头将生成一个编译错误。

函数回调

JavaScript 最强大的特性之一,事实上也是 Node 技术构建的基础,就是回调函数的概念。回调函数是传递到另一个函数中的函数。请记住 JavaScript 不是强类型的,所以变量也可以是一个函数。通过查看一些 JavaScript 代码来最好地说明这一点:

function myCallBack(text) {
    console.log("inside myCallback " + text);
}

function callingFunction(initialText, callback) {
    console.log("inside CallingFunction");
    callback(initialText);
}

callingFunction("myText", myCallBack);

在这里,我们有一个名为myCallBack的函数,它接受一个参数并将其值记录到控制台。然后我们定义了一个名为callingFunction的函数,它接受两个参数:initialTextcallback。这个函数的第一行只是将"inside CallingFunction"记录到控制台。callingFunction的第二行是有趣的部分。它假设callback参数实际上是一个函数,并调用它。它还将initialText变量传递给callback函数。如果我们运行这段代码,将会得到两条消息记录到控制台,如下所示:

inside CallingFunction
inside myCallback myText

但是,如果我们不将函数作为回调传递会发生什么?在前面的代码中没有任何信号告诉我们callingFunction的第二个参数必须是一个函数。如果我们无意中使用字符串而不是函数作为第二个参数调用callingFunction函数,如下所示:

callingFunction("myText", "this is not a function");

我们将得到一个 JavaScript 运行时错误:

0x800a138a - JavaScript runtime error: Function expected

然而,防御性的程序员首先会检查callback参数是否实际上是一个函数,然后再调用它,如下所示:

function callingFunction(initialText, callback) {
    console.log("inside CallingFunction");
    if (typeof callback === "function") {
        callback(initialText);
    } else {
        console.log(callback + " is not a function");
    }
}

callingFunction("myText", "this is not a function");

请注意此代码片段的第三行,我们在调用之前检查callback变量的类型。如果它不是一个函数,我们就会在控制台上记录一条消息。在此片段的最后一行,我们正在执行callingFunction,但这次将一个字符串作为第二个参数传递。

代码片段的输出将是:

inside CallingFunction
this is not a function is not a function

因此,当使用函数回调时,JavaScript 程序员需要做两件事;首先,了解哪些参数实际上是回调,其次,编写无效使用回调函数的代码。

函数签名

TypeScript 强制类型的“语法糖”不仅适用于变量和类型,还适用于函数签名。如果我们能够在代码中记录 JavaScript 回调函数,然后在用户传递错误类型的参数给我们的函数时警告他们,那该多好啊?

TypeScript 通过函数签名来实现这一点。函数签名引入了一个() =>的箭头语法,来定义函数的外观。让我们用 TypeScript 重新编写前面的 JavaScript 示例:

function myCallBack(text: string) {
    console.log("inside myCallback " + text);
}

function callingFunction(initialText: string,
    callback: (text: string) => void)
{
    callback(initialText);
}

callingFunction("myText", myCallBack);
callingFunction("myText", "this is not a function");

我们的第一个函数定义myCallBack现在将text参数强制类型为string类型。我们的callingFunction函数有两个参数;initialTextstring类型,callback现在具有新的函数签名语法。让我们更仔细地看一下这个函数签名:

callback: (text: string) => void

这个函数定义的意思是,callback参数被类型化(通过:语法)为一个函数,使用箭头语法() =>。此外,这个函数接受一个名为text的参数,类型为string。在箭头语法的右边,我们可以看到一个新的 TypeScript 基本类型,称为void。Void 是一个关键字,用于表示函数不返回值。

因此,callingFunction函数只会接受一个函数作为其第二个参数,该函数接受一个字符串参数并且不返回任何值。编译前面的代码将正确地突出显示代码片段的最后一行中的错误,即我们将一个字符串作为第二个参数传递,而不是一个回调函数:

error TS2082: Build: Supplied parameters do not match any signature of call target:
Type '(text: string) => void' requires a call signature, but type 'String' lacks one

鉴于回调函数的前面函数签名,以下代码也会生成编译时错误:

function myCallBackNumber(arg1: number) {
    console.log("arg1 = " + arg1);
}

callingFunction("myText", myCallBackNumber);

在这里,我们定义了一个名为myCallBackNumber的函数,它以一个数字作为唯一参数。当我们尝试编译这段代码时,我们将收到一个错误消息,指示callback参数,也就是我们的myCallBackNumber函数,也没有正确的函数签名。

Call signatures of types 'typeof myCallBackNumber' and '(text: string) => void' are incompatible.

myCallBackNumber的函数签名实际上应该是(arg1:number) => void,而不是所需的(text: string) => void,因此会出现错误。

注意

在函数签名中,参数名(arg1text)不需要相同。只需要函数的参数数量、它们的类型和函数的返回类型相同。

这是 TypeScript 的一个非常强大的特性——在代码中定义函数的签名,并在用户调用函数时警告他们是否使用了正确的参数。正如我们在 TypeScript 介绍中看到的,当我们使用第三方库时,这一点尤为重要。在我们能够在 TypeScript 中使用第三方函数、类或对象之前,我们需要定义它们的函数签名。这些函数定义被放入一种特殊类型的 TypeScript 文件中,称为声明文件,并以.d.ts扩展名保存。我们将在第四章中深入了解声明文件,编写和使用声明文件

函数回调和作用域

JavaScript 使用词法作用域规则来定义变量的有效作用域。这意味着变量的值由它在源代码中的位置来定义。嵌套函数可以访问在其父作用域中定义的变量。作为这一点的例子,考虑以下 TypeScript 代码:

function testScope() {
    var testVariable = "myTestVariable";
    function print() {
        console.log(testVariable);
    }
}

console.log(testVariable);

这段代码片段定义了一个名为testScope的函数。变量testVariable在这个函数内部定义。print函数是testScope的子函数,因此它可以访问testVariable变量。然而,代码的最后一行将生成一个编译错误,因为它试图使用testVariable变量,而这个变量在testScope函数体内部是有效的。

error TS2095: Build: Could not find symbol 'testVariable'.

简单吧?嵌套函数可以访问源代码中的变量,取决于它在源代码中的位置。这一切都很好,但在大型 JavaScript 项目中,有许多不同的文件,代码的许多部分都设计为可重用。

让我们看看这些作用域规则如何成为一个问题。对于这个示例,我们将使用一个典型的回调场景——使用 jQuery 执行异步调用来获取一些数据。考虑以下 TypeScript 代码:

var testVariable = "testValue";

function getData() {
    var testVariable_2 = "testValue_2";
    $.ajax(
        {
            url: "/sample_json.json",
            success: (data, status, jqXhr) => {
                console.log("success : testVariable is "
                    + testVariable);
                console.log("success : testVariable_2 is" 
                    + testVariable_2);
            },
            error: (message, status, stack) => {
                alert("error " + message);
            }
        }
   );
}

getData();

在这段代码片段中,我们定义了一个名为testVariable的变量并设置了它的值。然后我们定义了一个名为getData的函数。getData函数设置了另一个名为testVariable_2的变量,然后调用了 jQuery 的$.ajax函数。$.ajax函数配置了三个属性:urlsuccesserrorurl属性是一个简单的字符串,指向项目目录中的sample_json.json文件。success属性是一个匿名函数回调,简单地将testVariabletestVariable_2的值记录到控制台中。最后,error属性也是一个匿名函数回调,简单地弹出一个警告。

这段代码按预期运行,成功函数将把以下结果记录到控制台中:

success : testVariable is :testValue
success : testVariable_2 is :testValue_2

到目前为止一切都很好。现在,假设我们正在尝试重构前面的代码,因为我们正在做一些类似的$.ajax调用,并希望在其他地方重用success回调函数。我们可以很容易地切换掉这个匿名函数,并为我们的success回调创建一个命名函数,如下所示:

var testVariable = "testValue";

function getData() {
    var testVariable_2 = "testValue_2";
    $.ajax(
        {
            url: "/sample_json.json",
            success: successCallback,
            error: (message, status, stack) => {
                alert("error " + message);
            }
        }
   );
}

function successCallback(data, status, jqXhr) {
    console.log("success : testVariable is :" + testVariable);
    console.log("success : testVariable_2 is :" + testVariable_2);
}

getData();

在这个示例中,我们创建了一个名为successCallback的新函数,参数与之前的匿名函数相同。我们还修改了$.ajax调用,只需将这个函数作为success属性的回调函数传递进去:success: successCallback。如果我们现在编译这段代码,TypeScript 会生成一个错误,如下所示:

error TS2095: Build: Could not find symbol ''testVariable_2''.

由于我们改变了代码的词法作用域,通过创建一个命名函数,新的successCallback函数不再可以访问变量testVariable_2

注意

在一个简单的示例中很容易发现这种错误,但在更大的项目中,以及在使用第三方库时,这些错误变得更难追踪。因此,值得一提的是,在使用回调函数时,我们需要理解词法作用域。如果你的代码期望一个属性有一个值,在回调之后它没有一个值,那么记得查看调用代码的上下文。

函数重载

由于 JavaScript 是一种动态语言,我们经常可以用不同的参数类型调用同一个函数。考虑以下 JavaScript 代码:

function add(x, y) {
    return x + y;
}

console.log("add(1,1)=" + add(1,1));
console.log("add(''1'',''1'')=" + add("1", "1"));
console.log("add(true,false)=" + add(true, false));

在这里,我们定义了一个简单的add函数,返回其两个参数xy的和。这段代码片段的最后三行只是记录了add函数的不同类型的结果:两个数字、两个字符串和两个布尔值。如果我们运行这段代码,将会看到以下输出:

add(1,1)=2
add('1','1')=11
add(true,false)=1

TypeScript 引入了一种特定的语法来表示同一个函数的多个函数签名。如果我们要在 TypeScript 中复制上述代码,我们需要使用函数重载语法:

function add(arg1: string, arg2: string): string;
function add(arg1: number, arg2: number): number;
function add(arg1: boolean, arg2: boolean): boolean;
function add(arg1: any, arg2: any): any {
    return arg1 + arg2;
}

console.log("add(1,1)=" + add(1, 1));
console.log("add(''1'',''1'')=" + add("1", "1"));
console.log("add(true,false)=" + add(true, false));

这段代码片段的第一行指定了一个add函数的函数重载签名,接受两个字符串并返回一个string。第二行指定了另一个使用数字的函数重载,第三行使用布尔值。第四行包含了函数的实际体,并使用了any类型说明符。片段的最后三行展示了我们如何使用这些函数签名,与我们之前使用的 JavaScript 代码类似。

在上述代码片段中有三个值得注意的地方。首先,片段的前三行中的函数签名实际上都没有函数体。其次,最终的函数定义使用了any类型说明符,并最终包括了函数体。函数重载的语法必须遵循这个结构,包括函数体的最终函数签名必须使用any类型说明符,因为其他任何类型都会生成编译时错误。

第三点需要注意的是,我们通过使用这些函数重载签名,限制了add函数只接受两个相同类型的参数。如果我们尝试混合类型;例如,如果我们用一个boolean和一个string调用函数,如下所示:

console.log("add(true,''1'')", add(true, "1"));

TypeScript 会生成编译错误:

error TS2082: Build: Supplied parameters do not match any signature of call target:
error TS2087: Build: Could not select overload for ''call'' expression.

这似乎与我们最终的函数定义相矛盾。在原始的 TypeScript 示例中,我们有一个接受(arg1: any, arg2: any)的函数签名;因此,理论上当我们尝试将一个boolean和一个number相加时,应该调用这个函数。然而,TypeScript 的函数重载语法不允许这样做。请记住,函数重载的语法必须包括对函数体的any类型的使用,因为所有的重载最终都会调用这个函数体。然而,在函数体之上包含函数重载的部分告诉编译器,这些是调用代码可用的唯一签名。

联合类型

随着 TypeScript 1.4 的发布,我们现在可以使用管道符(|)来表示联合类型,将一个或两个类型组合起来。因此,我们可以将前面代码片段中的add函数重写为以下形式:

function addWithUnion(
    arg1: string | number | boolean,
    arg2: string | number | boolean
     ): string | number | boolean
    {
    if (typeof arg1 === "string") {
        // arg1 is treated as a string here
        return arg1 + "is a string";
    }
    if (typeof arg1 === "number") {
        // arg1 is treated as a number here
        return arg1 + 10;
    }
    if (typeof arg1 === "boolean") {
        // arg1 is treated as a boolean here
        return arg1 && false;
    }
}

这个名为addWithUnion的函数有两个参数,arg1arg2。这些参数现在使用联合类型语法来指定这些参数可以是stringnumberboolean。还要注意,我们函数的返回类型再次使用联合类型,这意味着函数也将返回其中的一个类型。

类型保护

在前面代码片段的addWithUnion函数体内,我们检查arg1参数的类型是否为字符串,语句为typeof arg1 === "string"。这被称为类型保护,意味着arg1的类型将在if语句块内被视为string类型。在下一个if语句的函数体内,arg1的类型将被视为数字,允许我们将10添加到它的值,在最后一个 if 语句的函数体内,编译器将把类型视为boolean

类型别名

我们还可以为类型、联合类型或函数定义定义别名。类型别名使用type关键字表示。因此,我们可以将前面的add函数写成如下形式:

type StringNumberOrBoolean = string | number | boolean;

function addWithAliases(
    arg1: StringNumberOrBoolean,
    arg2: StringNumberOrBoolean
     ): StringNumberOrBoolean {

}

在这里,我们定义了一个名为StringNumberOrBoolean的类型别名,它是stringnumberboolean类型的联合类型。

类型别名也可以用于函数签名,如下所示:

type CallbackWithString = (string) => void;

function usingCallback(callback: CallbackWithString) {
    callback("this is a string");
}

在这里,我们定义了一个名为CallbackWithString的类型别名,它是一个接受单个string参数并返回void的函数。我们的usingCallback函数在函数签名中接受这个类型别名作为callback参数的类型。

总结

在本章中,我们讨论了 TypeScript 的基本类型、变量和函数技术。我们看到 TypeScript 如何在普通 JavaScript 代码的基础上引入了“语法糖”,以确保强类型的变量和函数签名。我们还看到 TypeScript 如何使用鸭子类型和显式转换,并以 TypeScript 函数、函数签名和重载结束。在下一章中,我们将在此基础上继续学习,看看 TypeScript 如何将这些强类型规则扩展到接口、类和泛型中。

为 Bentham Chang 准备,Safari ID bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第三章:接口、类和泛型

我们已经看到 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的接口开始,该接口具有idname属性。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 关键字,并接受 idArgnameArg 作为参数。这些参数被强类型为 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);

我们的剩余函数usingRestSyntax可以使用任意数量的参数进行调用,因为我们使用剩余参数语法将这些参数保存在一个数组中。这两个调用都是有效的。最后,让我们看一下usingFunctionCallbacks函数:

function myCallbackFunction(id: number): string {
    return id.toString();
}
complexType.usingFunctionCallbacks(myCallbackFunction);

这段代码显示了一个名为myCallbackFunction的函数的定义。它匹配了usingFunctionCallbacks函数所需的回调签名,允许我们将myCallbackFunction作为参数传递给usingFunctionCallbacks函数。

请注意,如果您在理解这些不同的函数签名时遇到任何困难,请重新查看第二章中有关函数的相关部分,类型、变量和函数技术,其中详细解释了这些概念。

接口函数定义

接口与类一样,在处理函数时遵循相同的规则。要更新我们的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 行显示了包含剩余参数语法的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属性。我们的第二个接口定义IDerivedFromBaseIBase继承,并因此自动包含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接口,因此只需要定义一个类型为numberid属性。第二个类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 中实现类的。正如我们在第一章中提到的,闭包是指引用独立变量的函数。这些变量本质上记住了它们被创建时的环境。考虑以下 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 函数表达式的东西。然后,这个函数表达式立即被最后两个大括号();执行。这种立即执行函数的技术被称为立即调用函数表达式IIFE)。我们上面的 IIFE 然后被赋值给一个名为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方法,它将返回他们类别的字符串表示:"Infant""Child""Adult"canSignContracts方法将返回truefalsegetDateOfBirth方法将简单地返回他们的出生日期的可打印版本。为了简化我们的代码,我们将创建一个名为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类在canSignContracts上返回false,而我们的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接口定义来调用相关的打印函数。这段代码的输出如下:

使用 Factory 类

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

类修饰符

正如我们在开头章节简要讨论的那样,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函数使用两个参数——一个类型为numberid和一个类型为stringname。然而,请注意,id的访问修饰符是public,而name的访问修饰符是private。这个简写自动创建了ClassWithAutomaticProperties类的一个公共id属性和一个私有name属性。

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

注意

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

我们可以看到第 9 行我们创建了一个名为myAutoClass的变量,并将ClassWithAutomaticProperties类的一个新实例分配给它。一旦这个类被实例化,它就自动拥有两个属性:一个类型为数字的publicid属性;和一个类型为字符串的privatename属性。然而,编译前面的代码将产生一个 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,它有一个名为printOne的函数。printOne函数实际上并没有做任何有用的事情,除了将字符串"1"记录到控制台。然而,为了使用这个函数,我们需要首先创建一个类的实例,将其赋给一个变量,然后调用这个函数。

然而,使用静态函数,我们可以直接调用函数或属性:

class StaticClass {
    static printTwo() {
        console.log("2");
    }
}

StaticClass.printTwo();

StaticClass的类定义包括一个名为printTwo的函数,标记为static。从代码的最后一行可以看出,我们可以在不创建StaticClass类的实例的情况下调用这个函数。只要我们在函数前面加上类名,就可以直接调用这个函数。

注意

类的函数和属性都可以标记为静态的。

静态属性

静态属性在处理代码库中的所谓“魔术字符串”时非常方便。如果你在代码的各个部分依赖于一个字符串包含特定的值,那么现在是时候用静态属性替换这个“魔术字符串”了。在我们之前讨论的工厂设计模式中,我们创建了返回字符串值"Infant"、"Child"或"Adult"的专门的Person对象。如果我们后来编写的代码检查返回的字符串是否等于"Infant"或"Child",如果我们将"Infant"拼错成"Infent",就可能无意中破坏我们的逻辑:

if (value === "Infant") {
    // do something with an infant.
}

以下是我们可以使用的静态属性的示例,而不是那些“魔术字符串”:

class PersonType {
    static INFANT: string = "Infant";
    static CHILD: string = "Child";
    static ADULT: string = "Adult";
}

然后,在我们的代码库中,我们不再检查值是否等于字符串"Infant",而是将它们与静态属性进行比较:

if (value === PersonType.INFANT) {
    // do something with an infant.
}

这段代码不再依赖于“魔术字符串”。字符串"Infant"现在记录在一个地方。只要所有的代码都使用静态属性PersonType.Infant,它就会更加稳定,更加抗变化。

泛型

泛型是一种编写代码的方式,可以处理任何类型的对象,但仍然保持对象类型的完整性。到目前为止,我们已经在示例中使用了接口、类和 TypeScript 的基本类型来确保我们的代码是强类型的(并且更不容易出错)。但是如果一段代码需要处理任何类型的对象会发生什么呢?

举个例子,假设我们想要编写一些代码,可以迭代一个对象数组并返回它们值的连接。所以,给定一个数字列表,比如[1,2,3],它应该返回字符串"1,2,3"。或者,给定一个字符串列表,比如["first","second","third"],返回字符串"first,second,third"。我们可以编写一些接受any类型值的代码,但这可能会在我们的代码中引入错误 - 记得 S.F.I.A.T.吗?我们想要确保数组的所有元素都是相同类型。这就是泛型发挥作用的地方。

泛型语法

让我们编写一个名为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;
    }
}

我们注意到的第一件事是类声明的语法,Concatenator < T >。这个< 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。我们的最后一个示例展示了使用 IPerson 接口作为泛型类型 T

如果我们使用这个简单的替换原则,那么对于使用字符串的 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 函数——没有问题。在下一行,我们将 numberArray 传递给 numberConcatenator——仍然可以。

然而,当我们试图将一个数字数组传递给只能使用字符串的 stringConcatenator 时,问题就开始了。同样,如果我们试图将一个只允许数字的 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 循环中,注意我们使用了 inputArray.length 属性。所有数组都有一个 length 属性来表示数组有多少项,所以使用 inputArray.length 在任何数组上都可以工作,无论数组保存的是什么类型的对象。其次,当我们使用 inputArray[i] 语法引用数组中的对象时,我们实际上返回了一个类型为 T 的单个对象。记住,无论我们在代码中使用 T,我们只能使用所有类型为 T 的对象共有的函数和属性。幸运的是,我们只使用了 toString 函数,而所有 JavaScript 对象,无论它们是什么类型,都有一个有效的 toString 函数。所以这个泛型代码块将编译通过。

让我们通过创建一个自己的类来测试这个 T 类型理论,然后将其传递给 Concatenator 类:

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 使用继承来实现这一点。例如,让我们重构我们之前的工厂设计模式代码,使用一个特定设计为与实现IPerson接口的类一起工作的泛型PersonPrinter类:

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泛型类实例生成的句子记录到控制台上。

这段代码的输出和我们预期的一样:

限制 T 的类型

泛型 PersonFactory 输出

泛型接口

我们也可以使用泛型类型语法与接口一起使用。对于我们的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接口定义中作为泛型类型IPersonPrinter<T>。这满足了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'.

这个错误指向了我们错误的类定义;代码中使用的T类型(PersonPrinter<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类型的新实例。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 及以上版本中实现,这可能在旧版浏览器或移动浏览器上运行时引入问题。

我们可以使用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接口具有一个类型为numberid,一个类型为stringname,以及一个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类定义的闭包模式。虽然 TypeScript 编译器使用了IBasicObject接口,但在生成的 JavaScript 中并不存在。因此,我们说它已经被“编译掉”了。

因此,在 JavaScript 中实现类似反射的能力时,这给我们带来了一些问题:

  • 我们无法在运行时确定对象是否实现了 TypeScript 接口,因为 TypeScript 接口被编译掉了

  • 在旧的 ECMAScript 3 浏览器上,我们不能使用getOwnPropertyNames函数来循环遍历对象的属性

  • 我们不能在旧的 ECMAScript 3 浏览器上使用getPrototypeOf函数来确定类名

  • getPrototypeOf函数的实现在不同的浏览器中并不一致

  • 我们不能使用instanceof关键字来确定类类型,而不是与已知类型进行比较

检查对象是否具有一个函数

那么我们如何在运行时确定对象是否实现了一个接口?

在他们的书Pro JavaScript Design Patterns (jsdesignpatterns.com/)中,Ross Harmes 和 Dustin Diaz 讨论了这个困境,并提出了一个相当简单的解决方案。我们可以使用包含函数名称的字符串在对象上调用一个函数,然后检查结果是否有效,或者是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实例返回“function”,那么我们知道这个对象实现了这个函数。让我们来看一些快速的用法:

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数组指定了这个接口还包括idname属性。

为对象定义元数据的这种方法是我们问题的一个非常简单的解决方案,而且既不依赖于浏览器,也不依赖于 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)容器或领域事件模式的实现的可能性。我们将在第八章中更详细地探讨这两种设计模式,TypeScript 面向对象编程

摘要

在本章中,我们探讨了接口、类和泛型的面向对象概念。我们讨论了接口继承和类继承,并利用我们对接口、类和继承的知识在 TypeScript 中创建了工厂设计模式的实现。然后我们转向泛型及其语法,泛型接口和泛型构造函数。最后,我们在反射方面进行了讨论,并使用泛型实现了 TypeScript 版本的InterfaceChecker模式。在下一章中,我们将看一下 TypeScript 用于与现有 JavaScript 库集成的机制——定义文件。

为 Bentham Chang 准备,Safari ID bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第四章:编写和使用声明文件

JavaScript 开发最吸引人的一个方面是已经发布的大量外部 JavaScript 库,比如 jQuery、Knockout 和 Underscore。TypeScript 的设计者知道,向 TypeScript 语言引入“语法糖”将为开发人员带来一系列好处。这些好处包括 IDE 功能,如智能感知,以及详细的编译时错误消息。我们已经看到了如何将这种语法应用于大多数 TypeScript 语言特性,比如类、接口和泛型,但是我们如何将这种“糖”应用于现有的 JavaScript 库呢?答案相对简单——声明文件。

声明文件是 TypeScript 编译器使用的一种特殊类型的文件。它以.d.ts扩展名标记,然后在编译步骤中由 TypeScript 编译器使用。声明文件类似于其他语言中使用的头文件;它们只是描述可用函数和属性的语法和结构,但不提供实现。因此,声明文件实际上不会生成任何 JavaScript 代码。它们只是用来提供 TypeScript 与外部库的兼容性,或者填补 TypeScript 不了解的 JavaScript 代码的空白。为了在 TypeScript 中使用任何外部 JavaScript 库,您将需要一个声明文件。

在本章中,我们将探讨声明文件,展示它们背后的原因,并基于一些现有的 JavaScript 代码构建一个声明文件。如果您熟悉声明文件以及如何使用它们,那么您可能会对声明语法参考部分感兴趣。本节旨在作为模块定义语法的快速参考指南。由于编写声明文件只是 TypeScript 开发的一小部分,我们并不经常编写它们。声明语法参考部分展示了等效 JavaScript 语法的示例声明文件语法。

全局变量

大多数现代网站都使用某种服务器引擎来生成它们的网页 HTML。如果您熟悉微软技术栈,那么您会知道 ASP.NET MVC 是一个非常流行的服务器端引擎,用于基于主页面、部分页面和 MVC 视图生成 HTML 页面。如果您是 Node 开发人员,那么您可能正在使用其中一个流行的 Node 包来帮助您通过模板构建网页,比如 Jade 或嵌入式 JavaScript(EJS)。

在这些模板引擎中,您有时可能需要根据后端逻辑在 HTML 页面上设置 JavaScript 属性。举个例子,假设您在后端数据库中保存了一组联系人电子邮件地址,然后通过名为CONTACT_EMAIL_ARRAY的 JavaScript 全局变量将其呈现到前端 HTML 页面上。您的渲染的 HTML 页面将包含一个包含这个全局变量和联系人电子邮件地址的<script>标签。您可能有一些 JavaScript 代码来读取这个数组,然后在页脚中呈现这些值。以下 HTML 示例显示了 HTML 页面中生成的脚本可能看起来像什么:

<body>
    <script type="text/javascript">
        var CONTACT_EMAIL_ARRAY = [
            "help@site.com",
            "contactus@site.com",
            "webmaster@site.com"
        ];
    </script>
</body>

这个 HTML 文件有一个脚本块,在这个脚本块中有一些 JavaScript。JavaScript 只是一个名为CONTACT_EMAIL_ARRAY的变量,其中包含一些字符串。假设我们想编写一些 TypeScript 代码来读取这个全局变量。考虑以下 TypeScript 代码:

class GlobalLogger {
    static logGlobalsToConsole() {
        for (var i = 0; i < CONTACT_EMAIL_ARRAY.length; i++) {
            console.log("found contact : " + CONTACT_EMAIL_ARRAY[i]);
        }
    }
}

window.onload = () => {
    GlobalLogger.logGlobalsToConsole();
}

这段代码创建了一个名为GlobalLogger的类,其中包含一个名为logGlobalsToConsole的静态函数。该函数只是遍历CONTACT_EMAIL_ARRAY全局变量,并将数组中的项记录到控制台中。

如果我们编译这段 TypeScript 代码,将会生成以下错误:

error TS2095: Build: Could not find symbol 'CONTACT_EMAIL_ARRAY'.

这个错误表明 TypeScript 编译器对名为CONTACT_EMAIL_ARRAY的变量一无所知。它甚至不知道它是一个数组。由于这段 JavaScript 代码位于任何 TypeScript 代码之外,我们需要以与“外部”JavaScript 相同的方式处理它。

为了解决我们的编译问题,并使CONTACT_EMAIL_ARRAY变量对 TypeScript 可见,我们需要使用一个声明文件。让我们创建一个名为globals.d.ts的文件,并在其中包含以下 TypeScript 声明:

declare var CONTACT_EMAIL_ARRAY: string [];

首先要注意的是,我们使用了一个新的 TypeScript 关键字:declaredeclare关键字告诉 TypeScript 编译器,我们想要定义某个东西的类型,但这个对象(或变量或函数)的实现将在运行时解析。我们声明了一个名为CONTACT_EMAIL_ARRAY的变量,其类型为字符串数组。这个declare关键字为我们做了两件事:它允许在 TypeScript 代码中使用变量CONTACT_EMAIL_ARRAY,并且还将这个变量强类型为字符串数组。

注意

TypeScript 编译器的 1.0 版本及更高版本将扫描我们的源代码目录以寻找.d.ts文件,并自动包含它们在编译步骤中。在以前的版本中,需要包含一个注释作为对这些文件的引用,但现在不再需要这个引用注释行。

有了globals.d.ts文件,我们的代码可以正确编译。如果我们现在在浏览器中运行它,输出将如下所示:

found contact : help@site.com
found contact : contactus@site.com
found contact : webmaster@site.com

因此,通过使用名为globals.d.ts的声明文件,我们已经能够描述“外部”JavaScript 变量的结构给 TypeScript 编译器。这个 JavaScript 变量是在我们的任何 TypeScript 代码之外定义的,但我们仍然能够在 TypeScript 中使用这个变量的定义。

这就是声明文件的用途。基本上,我们告诉 TypeScript 编译器在编译步骤中使用声明文件中找到的定义,并且实际的变量本身只在运行时可用。

注意

定义文件还为我们的 IDE 带来了外部 JavaScript 库和代码的智能提示或代码补全功能。

在 HTML 中使用 JavaScript 代码块

我们刚刚看到的示例是在您的网页上生成的 HTML 内容(其中包含脚本块中的 JavaScript 代码)与实际运行的 JavaScript 之间紧密耦合的一个例子。然而,您可能会认为这是一个设计缺陷。如果网页需要一个联系人电子邮件数组,那么 JavaScript 应用程序应该简单地向服务器发送一个 AJAX 请求以获取相同的 JSON 格式信息。虽然这是一个非常合理的论点,但在某些情况下,将内容包含在呈现的 HTML 中实际上更快。

曾经有一个时代,互联网似乎能够在眨眼之间发送和接收大量信息。互联网的带宽和速度呈指数增长,台式机的内存和处理器速度也在不断提高。在互联网高速发展阶段,作为开发人员,我们不再考虑典型用户在其设备上拥有多少内存。我们也不再考虑我们通过网络发送了多少数据。这是因为互联网速度如此之快,浏览器处理速度似乎是无限的。

是的,然后移动电话出现了,感觉就像我们回到了 20 世纪 90 年代,互联网连接非常缓慢,屏幕分辨率很小,处理能力有限,内存很少(还有像Elevator Action这样的流行街机游戏,可以在archive.org/details/Elevator_Action_1985_Sega_Taito_JP_en找到)。这个故事的要点是,作为现代网页开发人员,我们仍然需要注意运行在移动电话上的浏览器。这些浏览器有时在非常有限的互联网连接上运行,这意味着我们必须仔细测量我们的 JavasScript 库、JSON 数据和 HTML 页面的大小,以确保我们的应用程序即使在移动浏览器上也是快速和可用的。

在渲染的 HTML 页面中包含 JavaScript 变量或较小的静态 JSON 数据的技术通常为我们提供了在旧浏览器或现代手机上快速渲染屏幕的最快方式。许多流行的网站使用这种技术在通过异步 JSON 请求传递主要内容之前,快速渲染页面的一般结构(标题、侧边栏、页脚等)。这种技术之所以有效,是因为它能更快地渲染页面,并为用户提供更快的反馈。

结构化数据

让我们用一些更相关的数据增强这个简单的联系人电子邮件数组。对于这些电子邮件地址中的每一个,我们现在想要包含一些文本,我们将在页面的页脚中渲染,以及电子邮件地址。考虑以下使用 JSON 结构的全局变量的 HTML 代码:

<script type="text/javascript">
    var CONTACT_DATA = [
        { DisplayText: "Help", Email: "help@site.com" },
        { DisplayText: "Contact Us", Email: "contactus@site.com" },
        { DisplayText: "Web Master", Email: "webmaster@site.com" }
    ];
</script>

在这里,我们定义了一个名为CONTACT_DATA的全局变量,它是一个 JSON 对象数组。每个 JSON 对象都有一个名为DisplayText和一个名为Email的属性。与以前一样,我们现在需要在globals.d.ts声明文件中包含这个变量的定义:

interface IContactData {
    DisplayText: string;
    Email: string;
}

declare var CONTACT_DATA: IContactData[];

我们从一个名为IContactData的接口定义开始,表示CONTACT_DATA数组中单个项目的属性。每个项目都有一个DisplayText属性,类型为string,以及一个Email属性,类型也为string。因此,我们的IContactData接口与 JSON 数组中单个项目的原始对象属性相匹配。然后,我们声明一个名为CONTACT_DATA的变量,并将其类型设置为IContactData接口的数组。

这允许我们在 TypeScript 中使用CONTACT_DATA变量。现在让我们创建一个处理这些数据的类,如下所示:

class ContactLogger {
    static logContactData() {
        for (var i = 0; i < CONTACT_DATA.length; i++) {
            var contactDataItem: IContactData = CONTACT_DATA[i];
            console.log("Contact Text : " + contactDataItem.DisplayText
                 + " Email : " + contactDataItem.Email
                );
        }
    }
}

window.onload = () => {
    ContactLogger.logContactData();
}

ContactLogger类有一个名为logContactData的静态方法。在这个方法中,我们循环遍历CONTACT_DATA数组中的所有项目,使用所有 JavaScript 数组中固有的length属性。然后,我们创建一个名为contactDataItem的变量,它的类型被强制为IContactData,并将当前数组项的值赋给它。作为IContactData类型,contactDataItem现在有两个属性,DisplayTextEmail。我们只需将这些值记录到控制台。这段代码的输出将是:

Contact Text : Help Email : help@site.com
Contact Text : Contact Us Email : contactus@site.com
Contact Text : Web Master Email : webmaster@site.com

编写自己的声明文件

在任何开发团队中,都会有一个时刻,你需要修复 bug 或增强已经编写的 JavaScript 代码。如果你处于这种情况,那么你会想尝试用 TypeScript 编写新的代码,并将其与现有的 JavaScript 代码集成。然而,为了这样做,你需要为任何需要重用的现有 JavaScript 编写自己的声明文件。这可能看起来是一项令人望而却步且耗时的任务,但当你面对这种情况时,只需记住采取小步骤,一次定义一小部分代码。你会惊讶地发现它实际上是多么简单。

在这一部分,让我们假设您需要集成一个现有的辅助类——一个在许多项目中重复使用、经过充分测试并且是开发团队标准的类。这个类已经被实现为一个 JavaScript 闭包,如下所示:

ErrorHelper = (function() {
    return {
        containsErrors: function (response) {
            if (!response || !response.responseText)
                return false;

            var errorValue = response.responseText;

            if (String(errorValue.failure) == "true"
                || Boolean(errorValue.failure)) {
                return true;
            }
            return false;
        },
        trace: function (msg) {
            var traceMessage = msg;
            if (msg.responseText) {
                traceMessage = msg.responseText.errorMessage;
            }
            console.log("[" + new Date().toLocaleDateString()
                + "] " + traceMessage);
        }
    }
})();

这段 JavaScript 代码片段定义了一个名为ErrorHelper的 JavaScript 对象,它有两个方法。containsErrors方法以一个名为response的对象作为参数,并测试它是否有一个名为responseText的属性。如果有,它然后检查responseText属性本身是否有一个名为failure的属性。如果这个failure属性是一个包含文本"true"的字符串,或者failure属性是一个值为true的布尔值,那么这个函数返回true;换句话说,我们正在评估response.responseText.failure属性。ErrorHelper闭包还有一个名为trace的函数,可以用一个字符串或类似containsErrors函数期望的响应对象来调用。

不幸的是,这个ErrorHelper函数缺少关键的文档部分。被传递到这两个方法中的对象的结构是什么,它有哪些属性?没有任何形式的文档,我们被迫反向工程代码来确定response对象的结构是什么样的。如果我们能找到ErrorHelper类的一些样本用法,这可能会帮助我们猜测这个结构。作为这个ErrorHelper的用法示例,考虑以下 JavaScript 代码:

   var failureMessage = {
        responseText: { 
            "failure": true,
            "errorMessage": "Unhandled Exception"
        }
    };
   var failureMessageString = {
        responseText: {
            "failure": "true",
            "errorMessage": "Unhandled Exception"
        }
   };
   var successMessage = { responseText: { "failure": false } };

   if (ErrorHelper.containsErrors(failureMessage))
        ErrorHelper.trace(failureMessage);
   if (ErrorHelper.containsErrors(failureMessageString))
        ErrorHelper.trace(failureMessageString);
   if (!ErrorHelper.containsErrors(successMessage))
        ErrorHelper.trace("success");

在这里,我们首先有一个名为failureMessage的变量,它有一个名为responseText的属性。responseText属性又有两个子属性:failureerrorMessage。我们的下一个变量failureMessageString具有相同的结构,但将responseText.failure属性定义为字符串,而不是布尔值。最后,我们的successMessage对象只定义了responseText.failure属性为false,但它没有errorMessage属性。

注意

在 JavaScript 的 JSON 格式中,属性名需要用引号括起来,而在 JavaScript 中这是可选的。因此,结构{"failure" : true}在语法上等同于结构{failure : true}

前面代码片段的最后几行显示了ErrorHelper闭包的使用方式。我们只需要用我们的变量调用ErrorHelper.containsErrors方法,如果结果是true,则通过ErrorHelper.trace函数将消息记录到控制台。我们的输出将如下所示:

编写自己的声明文件

ErrorHelper 控制台输出

模块关键字

为了使用 TypeScript 测试这个 JavaScript 的ErrorHelper闭包,我们需要一个包含ErrorHelper.js文件和 TypeScript 生成的 JavaScript 文件的 HTML 页面。假设我们的 TypeScript 文件叫做ErrorHelperTypeScript.ts,那么我们的 HTML 页面将如下所示:

<!DOCTYPE html>
<html >
<head>specify.
    <title></title>
    <script src="img/ErrorHelper.js"></script>
    <script src="img/ErrorHelperTypeScript.js"></script>
</head>
<body>

</body>
</html>

这个 HTML 非常简单,包括了现有的ErrorHelper.js JavaScript 文件,以及 TypeScript 生成的ErrorHelperTypeScript.js文件。

ErrorHelperTypeScript.ts文件中,让我们如下使用ErrorHelper

window.onload = () => {
    var failureMessage = {
        responseText: { "failure": true,
            "errorMessage": "Unhandled Exception" }
    };

    if (ErrorHelper.containsErrors(failureMessage))
        ErrorHelper.trace(failureMessage);

 }

这段代码片段展示了我们原始 JavaScript 样本的简化版本。实际上,我们可以直接将原始 JavaScript 代码复制粘贴到我们的 TypeScript 文件中。我们首先创建一个具有正确属性的failureMessage对象,然后简单地调用ErrorHelper.containsErrors方法和ErrorHelper.trace方法。如果我们在这个阶段编译我们的 TypeScript 文件,我们将收到以下错误:

error TS2095: Build: Could not find symbol 'ErrorHelper'.

这个错误表明,虽然我们在 JavaScript 文件中有ErrorHelper的完整源代码,但没有有效的 TypeScript 类型名为ErrorHelper。默认情况下,TypeScript 会查找项目中所有的 TypeScript 文件来找到类定义,但不会解析 JavaScript 文件。为了正确编译这段代码,我们需要一个新的 TypeScript 定义文件。

注意

这个定义文件根本没有包含在 HTML 文件中;它只被 TypeScript 编译器使用,不会生成任何 JavaScript。

在我们的ErrorHelper类上没有一套有用的文档,我们需要通过阅读源代码来纯粹地逆向工程一个 TypeScript 定义。这显然不是一个理想的情况,也不推荐,但在这个阶段,这是我们能做的一切。在这些情况下,最好的起点就是简单地查看用法示例,然后从那里开始。

通过查看 JavaScript 中ErrorHelper闭包的用法,我们应该在我们的声明文件中包含两个关键部分。第一个是containsErrorstrace函数的一组函数定义。第二个是一组接口,用于描述ErrorHelper闭包依赖的response对象的结构。让我们从函数定义开始,创建一个名为ErrorHelper.d.ts的新的 TypeScript 文件,其中包含以下代码:

declare module ErrorHelper {
    function containsErrors(response);
    function trace(message);
}

这个声明文件以我们之前见过的declare关键字开头,然后使用了一个新的 TypeScript 关键字:modulemodule关键字后面必须跟着一个模块名,这里是ErrorHelper。这个模块名必须与我们描述的原始 JavaScript 中的闭包名匹配。在我们所有对ErrorHelper的使用中,我们总是用闭包名ErrorHelper本身作为containsErrorstrace函数的前缀。这个模块名也被称为命名空间。如果我们有另一个名为AjaxHelper的类,它也包括一个containsErrors函数,我们可以通过使用这些命名空间或模块名来区分AjaxHelper.containsErrorsErrorHelper.containsErrors函数。

前面代码片段的第二行指示我们正在定义一个名为containsErrors的函数,它接受一个参数。模块声明的第三行指示我们正在定义另一个名为trace的函数,它接受一个参数。有了这个定义,我们的 TypeScript 代码样本将能够正确编译。

接口

虽然我们已经正确定义了ErrorHelper闭包可用的两个函数,但我们缺少关于ErrorHelper闭包可用的函数的第二部分信息——response参数的结构。我们没有为containsErrorstrace函数中的任何一个强类型参数。在这个阶段,我们的 TypeScript 代码可以将任何东西传递给这两个函数,因为它没有responsemessage参数的定义。然而,我们知道这两个函数都查询这些参数的特定结构。如果我们传入一个不符合这个结构的对象,那么我们的 JavaScript 代码将会引起运行时错误。

为了解决这个问题并使我们的代码更稳定,让我们为这些参数定义一个接口:

interface IResponse {
    responseText: IFailureMessage;
}

interface IFailureMessage {
    failure: boolean;
    errorMessage: string;
}

我们从一个名为IResponse的接口开始,它具有一个名为responseText的属性,与原始的 JSON 对象相同。这个responseText属性被强类型为IFailureMessage类型。IFailureMessage接口被强类型为具有两个属性:failureboolean类型,errorMessagestring类型。这些接口正确描述了containsErrors函数的response参数的正确结构。现在我们可以修改containsErrors函数的原始声明,以在response参数上使用这个接口。

declare module ErrorHelper {
    function containsErrors(response: IResponse);
    function trace(message);
}

containsErrors的函数定义现在将响应参数强类型为我们之前定义的IResponse类型。对声明文件的这种修改现在将强制containsErrors函数的任何进一步使用发送一个符合IResponse结构的有效参数。让我们写一些故意不正确的 TypeScript 代码,看看会发生什么:

var anotherFailure : IResponse = { responseText: { success: true } };

if (ErrorHelper.containsErrors(anotherFailure))
    ErrorHelper.trace(anotherFailure);

我们首先创建一个名为anotherFailure的变量,并将其类型指定为IResponse类型。即使我们使用定义文件来定义这个接口,TypeScript 编译器应用的规则与我们以前看到的没有什么不同。这段代码中的第一行将生成以下错误:

接口

编译错误的响应文本对象

从这个相当冗长但信息丰富的错误消息中可以看出,anotherFailure变量的结构导致了所有的错误。即使我们正确引用了IResponseresponseText属性,responseText属性也被强类型为IFailureMessage类型,它要求failure属性和errorMessage属性都存在,因此会出现错误。

我们可以通过在变量anotherFailure中包含failureerrorMessage的必需属性来修复这些错误:

var anotherFailure: IResponse = {
    responseText: {
        failure: false, errorMessage: "", success: true
        }
    };

我们的 TypeScript 现在可以正确编译。变量anotherFailure现在具有所有必需的属性,以便正确使用ErrorHelper函数。通过为现有的ErrorHelper类创建一个强类型声明文件,我们可以确保对现有的ErrorHelper JavaScript 闭包的任何进一步的 TypeScript 使用都不会生成运行时错误。

函数重载

我们对ErrorHelper的声明文件还没有完全完成。如果我们看一下ErrorHelper的原始 JavaScript 用法,我们会注意到containsErrors函数还允许responseTextfailure属性是一个字符串:

var failureMessageString = {
    responseText: { "failure": "true",
        "errorMessage": "Error Message" }
};

if (ErrorHelper.containsErrors(failureMessageString))
    ErrorHelper.trace(failureMessage);

如果我们现在编译这段代码,将会得到以下编译错误:

函数重载

响应文本的多个定义的编译错误

在变量failureMessageString的先前定义中,failure属性的类型为true,这是一个string类型,而不是boolean类型的true。为了允许在原始IFailureMessage接口上进行这种变体,我们需要修改我们的声明文件。首先,我们需要两个新接口,指定failure属性的类型为string

interface IResponseString {
    responseText: IFailureMessageString;
}

interface IFailureMessageString {
    failure: string;
    errorMessage: string;
}

IResponseString接口与IResponse接口几乎相同,只是它使用IFailureMessageString类型作为responseText属性的类型。IFailureMessageString接口与原始的IFailureMessage接口几乎相同,只是failure属性的类型为string。现在我们需要修改我们的声明文件,以允许containsErrors函数上的两个调用签名:

declare module ErrorHelper {
    function containsErrors(response: IResponse);
    function containsErrors(response: IResponseString);
    function trace(message);
}

与接口和类定义一样,模块也允许函数覆盖。模块ErrorHelper现在有一个containsErrors函数定义,使用原始的IResponse接口,以及一个使用新的IReponseString接口的第二个函数定义。这个模块定义的新版本将允许failure消息结构的两种变体都正确编译。

在这个例子中,我们还可以利用联合类型,并简化我们先前对containsErrors函数的声明为单个定义:

declare module ErrorHelper {
    function containsErrors(response: IResponse | IResponseString);
    function trace(message: string);
}

完善我们的定义文件

现在我们可以将注意力集中在trace函数上。trace函数可以接受IResponse接口的两个版本,或者它可以简单地接受一个字符串。让我们更新trace函数签名的定义文件:

declare module ErrorHelper {
    function containsErrors(response: IResponse | IResponseString);
    function trace(message: string | IResponse | IResponseString);
}

在这里,我们已经更新了trace函数,以允许三种不同类型的消息类型——普通的string,一个IResponse类型,或一个IResponseString类型。

这完成了我们对ErrorHelperJavaScript 类的定义文件。

模块合并

正如我们现在所知,TypeScript 编译器将自动搜索项目中所有.d.ts文件,以获取声明文件。如果这些声明文件包含相同的模块名称,TypeScript 编译器将合并这两个声明文件,并使用模块声明的组合版本。

如果我们有一个名为MergedModule1.d.ts的文件,其中包含以下定义:

declare module MergedModule {
    function functionA();
}

和一个名为MergedModule2.d.ts的第二个文件,其中包含以下定义:

declare module MergedModule {
    function functionB();
}

TypeScript 编译器将合并这两个模块,就好像它们是单个定义一样:

declare module MergedModule {
    function functionA();
    function functionB();
}

这将允许functionAfunctionB都是相同MergedModule命名空间的有效函数,并允许以下用法:

MergedModule.functionA();
MergedModule.functionB();

注意

模块还可以与接口、类和枚举合并。但是,类不能与其他类、变量或接口合并。

声明语法参考

在创建声明文件并使用module关键字时,可以使用一些规则来混合和匹配定义。我们已经涵盖了其中之一——函数覆盖。作为 TypeScript 程序员,你通常只会偶尔编写模块定义,并且偶尔需要向现有声明文件添加新的定义。

因此,本节旨在成为此声明文件语法的快速参考指南,或者一张备忘单。每个部分包含模块定义规则的描述,JavaScript 语法片段,然后是等效的 TypeScript 声明文件语法。

要使用此参考部分,只需匹配 JavaScript 语法部分中要模拟的 JavaScript,然后使用等效的定义语法编写您的声明文件。我们将以函数覆盖语法作为示例开始:

函数覆盖

声明文件可以包含同一函数的多个定义。如果相同的 JavaScript 函数可以使用不同类型进行调用,则需要为函数的每个变体声明一个函数覆盖。

JavaScript 语法

trace("trace a string");
trace(true);
trace(1);
trace({ id: 1, name: "test" });

声明文件语法

declare function trace(arg: string | number | boolean );
declare function trace(arg: { id: number; name: string });

注意

每个函数定义必须具有唯一的函数签名。

嵌套命名空间

模块定义可以包含嵌套的模块定义,然后转换为嵌套的命名空间。如果您的 JavaScript 使用命名空间,则需要定义嵌套模块声明以匹配 JavaScript 命名空间。

JavaScript 语法

FirstNamespace.SecondNamespace.ThirdNamespace.log("test");

声明文件语法

declare module FirstNamespace {
    module SecondNamespace {
        module ThirdNamespace {
            function log(msg: string);
        }
    }
}

类定义允许在模块定义内。如果您的 JavaScript 使用类或 new 操作符,则可实例化的类将需要在声明文件中定义。

JavaScript 语法

var myClass = new MyClass();

声明文件语法

declare class MyClass {
}

类命名空间

类定义允许在嵌套模块定义中。如果您的 JavaScript 类具有前置命名空间,则需要先声明匹配命名空间的嵌套模块,然后可以在正确的命名空间内声明类。

JavaScript 语法

var myNestedClass = new OuterName.InnerName.NestedClass();

声明文件语法

declare module OuterName {
    module InnerName {
        class NestedClass {}
    }
}

类构造函数重载

类定义可以包含构造函数重载。如果您的 JavaScript 类可以使用不同类型或多个参数进行构造,则需要在声明文件中列出每个变体作为构造函数重载。

JavaScript 语法

var myClass = new MyClass();
var myClass2 = new MyClass(1, "test");

声明文件语法

declare class MyClass {
    constructor(id: number, name: string);
    constructor();
}

类属性

类可以包含属性。您需要在类声明中列出类的每个属性。

JavaScript 语法

var classWithProperty = new ClassWithProperty();
classWithProperty.id = 1;

声明文件语法

declare class ClassWithProperty {
    id: number;
}

类函数

类可以包含函数。您需要在类声明中列出 JavaScript 类的每个函数,以便 TypeScript 编译器接受对这些函数的调用。

JavaScript 语法

var classWithFunction = new ClassWithFunction();
classWithFunction.functionToRun();

声明文件语法

declare class ClassWithFunction {
    functionToRun(): void;
}

注意

被视为私有的函数或属性不需要通过声明文件公开,可以简单地省略。

静态属性和函数

类方法和属性可以是静态的。如果您的 JavaScript 函数或属性可以在不需要对象实例的情况下调用,则这些属性或函数需要标记为静态。

JavaScript 语法

StaticClass.staticId = 1;
StaticClass.staticFunction();

声明文件语法

declare class StaticClass {
    static staticId: number;
    static staticFunction();
}

全局函数

不带命名空间前缀的函数可以在全局命名空间中声明。如果您的 JavaScript 定义了全局函数,则需要在没有命名空间的情况下声明这些函数。

JavaScript 语法

globalLogError("test");

声明文件语法

declare function globalLogError(msg: string);

函数签名

函数可以使用函数签名作为参数。使用回调函数或匿名函数的 JavaScript 函数,需要用正确的函数签名声明。

JavaScript 语法

describe("test", function () {
    console.log("inside the test function");
});

声明文件语法

declare function describe(name: string, functionDef: () => void);

可选属性

类或函数可以包含可选属性。在 JavaScript 对象参数不是必需时,这些参数需要在声明中标记为可选属性。

JavaScript 语法

var classWithOpt  = new ClassWithOptionals();
var classWithOpt1 = new ClassWithOptionals({ id: 1 });
var classWithOpt2 = new ClassWithOptionals({ name: "first" });
var classWithOpt3 = new ClassWithOptionals({ id: 2, name: "second" });

声明文件语法

interface IOptionalProperties {
    id?: number;
    name?: string;
}

declare class ClassWithOptionals {
    constructor(options?: IOptionalProperties);
}

合并函数和模块

具有特定名称的函数定义可以与相同名称的模块定义合并。这意味着如果您的 JavaScript 函数可以使用参数调用并且还具有属性,则需要将函数与模块合并。

JavaScript 语法

fnWithProperty(1);
fnWithProperty.name = "name";

声明文件语法

declare function fnWithProperty(id: number);
declare module fnWithProperty {
    var name: string;
}

总结

在本章中,我们概述了您需要了解的内容,以便编写和使用自己的声明文件。我们讨论了在呈现的 HTML 中的 JavaScript 全局变量以及如何在 TypeScript 中访问它们。然后,我们转向了一个小的 JavaScript 辅助函数,并为这个 JavaScript 编写了我们自己的声明文件。我们通过列出一些模块定义规则来结束本章,强调了所需的 JavaScript 语法,并展示了等效的 TypeScript 声明语法。在下一章中,我们将讨论如何使用现有的第三方 JavaScript 库,以及如何将这些库的现有声明文件导入到您的 TypeScript 项目中。

为 Bentham Chang 准备,Safari ID bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第五章:第三方库

如果我们无法重用现有的 JavaScript 库、框架和其他好东西,那么我们的 TypeScript 开发环境就不会有多大作用。然而,正如我们所看到的,为了在 TypeScript 中使用特定的第三方库,我们首先需要一个匹配的定义文件。

TypeScript 发布后不久,Boris Yankov 建立了一个 github 存储库,用于存放第三方 JavaScript 库的 TypeScript 定义文件。这个名为 DefinitelyTyped 的存储库(github.com/borisyankov/DefinitelyTyped)迅速变得非常受欢迎,目前是获取高质量定义文件的地方。DefinitelyTyped 目前拥有超过 700 个定义文件,这些文件是来自世界各地数百名贡献者多年来建立起来的。如果我们要衡量 TypeScript 在 JavaScript 社区中的成功,那么 DefinitelyTyped 存储库将是 TypeScript 被采用程度的一个很好指标。在尝试编写自己的定义文件之前,先检查 DefinitelyTyped 存储库,看看是否已经有可用的文件。

在这一章中,我们将更仔细地研究如何使用这些定义文件,并涵盖以下主题:

  • 下载定义文件

  • 在 Visual Studio 中使用 NuGet

  • 使用 TypeScript Definition manager (TSD)

  • 选择一个 JavaScript 框架

  • 使用 Backbone 的 TypeScript

  • 使用 Angular 的 TypeScript

  • 使用 ExtJs 的 TypeScript

下载定义文件

在 TypeScript 项目中包含定义文件的最简单方法是从 DefinitelyTyped 下载匹配的.d.ts文件。这只是简单地找到相关文件,并下载原始内容。假设我们想要在项目中开始使用 jQuery。我们已经找到并下载了 jQuery JavaScript 库(v2.1.1),并在项目中的一个名为lib的目录下包含了相关文件。要下载声明文件,只需浏览到 DefinitelyTyped 上的jquery目录(github.com/borisyankov/DefinitelyTyped/tree/master/jquery),然后点击jquery.d.ts文件。这将打开一个 GitHub 页面,显示文件的编辑器视图。在这个编辑器视图的菜单栏上,点击Raw按钮。这将下载jquery.d.ts文件,并允许您将其保存在项目目录结构中。在lib文件夹下创建一个名为typings的新目录,并将jquery.d.ts文件保存在其中。

您的项目文件应该看起来像这样:

下载定义文件

带有下载的 jquery.d.ts 文件的 Visual Studio 项目结构

现在我们可以修改我们的index.html文件,包含jquery JavaScript 文件,并开始编写针对 jQuery 库的 TypeScript 代码。我们的index.html文件需要修改如下:

<!DOCTYPE html>

<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>TypeScript HTML App</title>
    <link rel="stylesheet" href="app.css" type="text/css" />
    <script src="img/jquery-2.1.1.min.js"></script>
    <script src="img/app.js"></script>
</head>
<body>
    <h1>TypeScript HTML App</h1>

    <div id="content"></div>
</body>
</html>

这个index.html文件的第一个<script>标签现在包含了一个指向jquery-2.1.1.min.js的链接,第二个<script>标签包含了一个指向 TypeScript 生成的app.js的链接。打开app.ts TypeScript 文件,删除现有的源代码,并用以下 jQuery 代码替换它:

$(document).ready(() => {
    $("#content").html("<h1>Hello World !</h1>");
});

这段代码首先定义了一个匿名函数,在 jQuery 的document.ready事件上执行。document.ready函数类似于我们之前使用的window.onload函数,它会在 jQuery 初始化后执行。这段代码的第二行简单地使用 jQuery 选择器语法获取名为content的 DOM 元素的句柄,然后调用html函数设置其 HTML 值。

我们下载的jquery.d.ts文件为我们提供了在 TypeScript 中编译 jQuery 所需的相关模块声明。

使用 NuGet

NuGet 是一个流行的包管理平台,可以下载所需的外部库,并自动包含在您的 Visual Studio 或 WebMatrix 项目中。它可用于打包为 DLL 的外部库,例如 StructureMap,也可用于 JavaScript 库和声明文件。NuGet 也可用作命令行实用程序。

使用扩展管理器

要在 Visual Studio 中使用 NuGet 包管理器对话框,请在主工具栏上选择工具选项,然后选择NuGet 包管理器,最后选择管理解决方案的 NuGet 包。这将打开 NuGet 包管理器对话框。在对话框的左侧,单击在线。NuGet 对话框将查询 NuGet 网站并显示可用包的列表。屏幕右上方有一个搜索框。单击搜索框,并输入jquery,以显示 NuGet 中为 jQuery 提供的所有包,如下图所示:

使用扩展管理器

NuGet 包管理器对 jQuery 查询的对话框

搜索结果面板中选择包时,每个包都将有一个突出显示的安装按钮。选择包后,右侧窗格将显示有关所讨论的 NuGet 包的更多详细信息。请注意,项目详细信息面板还显示了您即将安装的包的版本。单击安装按钮将自动下载相关文件以及任何依赖项,并将它们自动包含在您的项目中。

注意

NuGet 用于 JavaScript 文件的安装目录实际上称为Scripts,而不是我们之前创建的lib目录。NuGet 使用Scripts目录作为标准,因此任何包含 JavaScript 的包都将安装相关的 JavaScript 文件到Scripts目录中。

安装声明文件

您会发现在 DefinitelyTyped GitHub 存储库上找到的大多数声明文件都有相应的 NuGet 包。这些包的命名约定是<library>.TypeScript.DefinitelyTyped。如果我们在搜索框中输入jquery typescript,我们将看到返回的这些 DefinitelyTyped 包的列表。我们要找的 NuGet 包的名称是jquery.TypeScript.DefinitelyTyped,由Jason Jarret创建,在撰写本文时,版本为 1.4.0。

注意

DefinitelyTyped 包有它们自己的内部版本号,这些版本号不一定与您使用的 JavaScript 库的版本匹配。例如,jQuery 包的版本为 2.1.1,但相应的 TypeScript 定义包显示的版本号为 1.4.0。

安装jQuery.TypeScript.DefinitelyTyped包将在Scripts目录下创建一个typings目录,然后包含jquery.d.ts定义文件。这种目录命名标准已被各种 NuGet 包作者采用。

使用包管理器控制台

Visual Studio 还有一个命令行版本的 NuGet 包管理器,可以作为控制台应用程序使用,也集成到了 Visual Studio 中。单击工具,然后NuGet 包管理器,最后包管理器控制台,将打开一个新的 Visual Studio 窗口,并初始化 NuGet 命令行界面。NuGet 的命令行版本具有一些在 GUI 版本中不包括的功能。输入get-help NuGet以查看可用的顶级命令行参数列表。

安装包

要从控制台命令行安装 NuGet 包,只需输入install-package <packageName>。例如,要安装jquery.TypeScript.DefinitelyTyped包,只需输入:

Install-Package jquery.TypeScript.DefinitelyTyped

此命令将连接到 NuGet 服务器,并下载并安装包到您的项目中。

注意

包管理器控制台窗口的工具栏上有两个下拉列表,包源默认项目。如果您的 Visual Studio 解决方案有多个项目,您需要从默认项目下拉列表中选择正确的项目,以便 NuGet 将包安装到其中。

搜索包名称

从命令行搜索包名称是通过Get-Package –ListAvailable命令完成的。此命令使用–Filter参数作为搜索条件。例如,要查找包含definitelytyped搜索字符串的可用包,请运行以下命令:

Get-Package –ListAvailable –Filter definitelytyped

安装特定版本

有一些 JavaScript 库与 jQuery 2.x 版本不兼容,需要使用 1.x 范围内的 jQuery 版本。要安装特定版本的 NuGet 包,我们需要从命令行指定-Version参数。例如,要安装jquery v1.11.1包,请从命令行运行以下命令:

Install-Package jQuery –Version 1.11.1

注意

如果 NuGet 发现您的项目中已经安装了另一个版本的包,它将升级或降级要安装的包的版本。在上面的示例中,我们已经在项目中安装了最新版本的 jQuery(2.1.1),因此 NuGet 将首先删除jQuery 2.1.1,然后安装jQuery 1.11.1

使用 TypeScript Definition Manager

如果您正在使用 Node 作为 TypeScript 开发环境,那么您可能考虑使用TypeScript Definition Manager来获取 DefinitelyTyped 的 TypeScript 定义(TSD位于definitelytyped.org/tsd/)。TSD 提供类似于 NuGet 包管理器的功能,但专门针对 DefinitelyTyped GitHub 存储库中的 TypeScript 定义。

要安装 TSD,请使用以下npm命令:

npm install tsd@next –g

这将安装tsd prerelease v0.6.x

注意

在撰写本文时,您需要 v0.6.x 及更高版本才能从命令行使用install关键字。如果您只是输入npm install tsd –g,那么 npm 将安装 v0.5.x,其中不包括install关键字。

查询包

TSD 允许使用query关键字查询包存储库。要搜索jquery定义文件,输入以下内容:

tsd query jquery

上述命令将在DefinitelyTyped存储库中搜索任何名为jquery.d.ts的定义文件。由于只有一个,搜索返回的结果将是:

Jquery / jquery

使用通配符

TSD 还允许使用星号*作为通配符。要搜索以jquery开头的DefinitelyTyped声明文件,输入以下内容:

tsd query jquery.*

这个tsd命令将搜索存储库,并返回以 jQuery 开头的声明文件的结果。

安装定义文件

要安装定义文件,请使用以下install关键字:

tsd install jquery

此命令将下载jquery.d.ts文件到以下目录:

\typings\jquery\jquery.d.ts

注意

TSD 将基于运行 tsd 的当前目录创建\typings目录,因此请确保每当您从命令行使用 TSD 时,都要导航到项目中的相同基本目录。

使用第三方库

在本章的这一部分,我们将开始探索一些更受欢迎的第三方 JavaScript 库,它们的声明文件以及如何为每个框架编写兼容的 TypeScript。我们将比较 Backbone、Angular 和 ExtJs,它们都是用于构建丰富的客户端 JavaScript 应用程序的框架。在我们的讨论中,我们将看到一些框架与 TypeScript 语言及其特性高度兼容,一些部分兼容,一些则兼容性很低。

选择 JavaScript 框架

选择一个 JavaScript 框架或库来开发单页应用程序是一个困难且有时令人望而生畏的任务。似乎每个月都会出现一个新的框架,承诺用更少的代码提供更多的功能。

为了帮助开发人员比较这些框架,并做出明智的选择,Addy Osmani 写了一篇名为Journey Through the JavaScript MVC Jungle的优秀文章。(www.smashingmagazine.com/2012/07/27/journey-through-the-javascript-mvc-jungle/)。

实质上,他的建议很简单 - 这是一个个人选择 - 所以尝试一些框架,看看哪个最适合你的需求、编程思维方式和现有技能。Addy 开始的TodoMVC项目(todomvc.com),在几种 MV* JavaScript 框架中实现了相同的应用程序,做得非常出色。这真的是一个参考站点,可以深入了解一个完全工作的应用程序,并比较不同框架的编码技术和风格。

同样,取决于你在 TypeScript 中使用的 JavaScript 库,你可能需要以特定的方式编写你的 TypeScript 代码。在选择框架时要记住这一点 - 如果在 TypeScript 中使用起来很困难,那么你可能最好看看另一个集成更好的框架。如果在 TypeScript 中使用这个框架很容易和自然,那么你的生产力和整体开发体验将会更好。

在本节中,我们将看一些流行的 JavaScript 库,以及它们的声明文件,并了解如何编写兼容的 TypeScript。要记住的关键是 TypeScript 生成 JavaScript - 所以如果你在使用第三方库时遇到困难,那么打开生成的 JavaScript,看看 TypeScript 生成的 JavaScript 代码是什么样子的。如果生成的 JavaScript 与库的文档中的 JavaScript 代码示例匹配,那么你就在正确的轨道上。如果不匹配,那么你可能需要修改你的 TypeScript,直到编译后的 JavaScript 与示例相匹配。

当尝试为第三方 JavaScript 框架编写 TypeScript 代码时 - 特别是如果你是根据 JavaScript 文档进行工作 - 你的初始尝试可能只是试错。在这个过程中,你可能会发现你需要以特定的方式编写你的 TypeScript,以匹配特定的第三方库。本章的其余部分展示了三种不同的库需要不同的 TypeScript 编写方式。

Backbone

Backbone 是一个流行的 JavaScript 库,通过提供模型、集合和视图等内容,为 Web 应用程序提供结构。Backbone 自 2010 年以来一直存在,并且拥有大量的追随者,许多商业网站都在使用这个框架。根据Infoworld.com的报道,Backbone 在 GitHub 上有超过 1600 个与 Backbone 相关的项目,评分超过 3 星,这意味着它拥有庞大的扩展生态系统和相关库。

让我们快速看一下用 TypeScript 编写的 Backbone。

注意

要在自己的项目中跟着代码进行,你需要安装以下 NuGet 包:backbone.js(当前版本为 v1.1.2),和backbone.TypeScript.DefinitelyTyped(当前版本为 1.2.3)。

在 Backbone 中使用继承

从 Backbone 的文档中,我们找到了在 JavaScript 中创建Backbone.Model的示例如下:

var Note = Backbone.Model.extend(
    {
        initialize: function() {
            alert("Note Model JavaScript initialize");
        },
        author: function () { },
        coordinates: function () { },
        allowedToEdit: function(account) {
            return true;
        }
    }
);

这段代码展示了 JavaScript 中 Backbone 的典型用法。我们首先创建一个名为Note的变量,它扩展(或派生自)Backbone.Model。这可以通过Backbone.Model.extend语法看出。Backbone 的extend函数使用 JavaScript 对象表示法在外部花括号{ ... }中定义一个对象。在前面的代码中,这个对象有四个函数:initializeauthorcoordinatesallowedToEdit

根据 Backbone 文档,initialize函数将在创建此类的新实例时被调用一次。在我们之前的示例中,initialize函数只是创建一个警报来指示该函数被调用。authorcoordinates函数在这个阶段是空的,只有allowedToEdit函数实际上做了一些事情:return true

如果我们只是简单地将上面的 JavaScript 复制粘贴到一个 TypeScript 文件中,我们将生成以下编译错误:

Build: 'Backbone.Model.extend' is inaccessible.

在使用第三方库和来自 DefinitelyTyped 的定义文件时,我们首先应该看看定义文件是否有错误。毕竟,JavaScript 文档说我们应该能够像示例中那样使用extend方法,那么为什么这个定义文件会导致错误呢?如果我们打开backbone.d.ts文件,然后搜索找到Model类的定义,我们会找到编译错误的原因:

class Model extends ModelBase {

    /**
    * Do not use, prefer TypeScript's extend functionality.
    **/
    private static extend(
        properties: any, classProperties?: any): any;

这个声明文件片段显示了 Backbone Model类的一些定义。在这里,我们可以看到extend函数被定义为private static,因此它在 Model 类本身之外不可用。然而,这似乎与我们在文档中看到的 JavaScript 示例相矛盾。在extend函数定义的前面评论中,我们找到了在 TypeScript 中使用 Backbone 的关键:更喜欢 TypeScript 的 extend 功能。

这个评论表明 Backbone 的声明文件是围绕 TypeScript 的extends关键字构建的,因此我们可以使用自然的 TypeScript 继承语法来创建 Backbone 对象。因此,这段代码的 TypeScript 等价物必须使用extends TypeScript 关键字从基类Backbone.Model派生一个类,如下所示:

class Note extends Backbone.Model {
    initialize() {
        alert("Note model Typescript initialize");
    }
    author() { }
    coordinates() { }
    allowedToEdit(account) {
        return true;
    }
}

现在我们正在创建一个名为Note的类定义,它extendsBackbone.Model基类。这个类然后有initializeauthorcoordinatesallowedToEdit函数,与之前的 JavaScript 版本类似。我们的 Backbone 示例现在将正确编译和运行。

使用这两个版本中的任何一个,我们都可以通过在 HTML 页面中包含以下脚本来创建Note对象的实例:

<script type="text/javascript">
    $(document).ready( function () {
        var note = new Note();
    });
</script>

这个 JavaScript 示例只是等待 jQuery 的document.ready事件被触发,然后创建一个Note类的实例。如前所述,当类的实例被构造时,initialize函数将被调用,因此当我们在浏览器中运行时,我们会看到一个警报框出现。

Backbone 的所有核心对象都是以继承为设计基础的。这意味着创建新的 Backbone 集合、视图和路由器将在 TypeScript 中使用相同的extends语法。因此,Backbone 非常适合 TypeScript,因为我们可以使用自然的 TypeScript 语法来继承创建新的 Backbone 对象。

使用接口

由于 Backbone 允许我们使用 TypeScript 继承来创建对象,因此我们也可以轻松地在任何 Backbone 对象中使用 TypeScript 接口。提取上面Note类的接口将如下所示:

interface INoteInterface {
    initialize();
    author();
    coordinates();
    allowedToEdit(account: string);
}

现在我们可以更新我们的Note类定义来实现这个接口,如下所示:

class Note extends Backbone.Model implements INoteInterface {
    // existing code
}

我们的类定义现在实现了INoteInterface TypeScript 接口。这个简单的改变保护了我们的代码不会被无意中修改,并且还打开了使用标准面向对象设计模式与核心 Backbone 对象一起工作的能力。如果需要的话,我们可以应用第三章中描述的工厂模式,接口、类和泛型,来返回特定类型的 Backbone 模型 - 或者其他任何 Backbone 对象。

使用泛型语法

Backbone 的声明文件还为一些类定义添加了泛型语法。这在为 Backbone 编写 TypeScript 代码时带来了更强的类型化好处。Backbone 集合(惊喜,惊喜)包含一组 Backbone 模型,允许我们在 TypeScript 中定义集合如下:

class NoteCollection extends Backbone.Collection<Note> {
    model = Note;
    //model: Note; // generates compile error
    //model: { new (): Note }; // ok
}

在这里,我们有一个NoteCollection,它派生自Backbone.Collection,但也使用泛型语法来限制集合只处理Note类型的对象。这意味着任何标准的集合函数,比如at()pluck(),都将被强类型化为返回Note模型,进一步增强了我们的类型安全和智能感知。

请注意第二行用于将类型分配给集合类的内部model属性的语法。我们不能使用标准的 TypeScript 语法model: Note,因为这会导致编译时错误。我们需要将model属性分配给类定义,就像model=Note语法所示,或者我们可以使用{ new(): Note }语法,就像最后一行所示。

使用 ECMAScript 5

Backbone 还允许我们使用 ECMAScript 5 的能力来为Backbone.Model类定义 getter 和 setter,如下所示:

interface ISimpleModel {
    Name: string;
    Id: number;
}
class SimpleModel extends Backbone.Model implements ISimpleModel {
    get Name() {
        return this.get('Name');
    }
    set Name(value: string) {
        this.set('Name', value);
    }
    get Id() {
        return this.get('Id');
    }
    set Id(value: number) {
        this.set('Id', value);
    }
}

在这个片段中,我们定义了一个具有两个属性的接口,名为ISimpleModel。然后我们定义了一个SimpleModel类,它派生自Backbone.Model,并且还实现了ISimpleModel接口。然后我们为我们的NameId属性定义了 ES 5 的 getter 和 setter。Backbone 使用类属性来存储模型值,所以我们的 getter 和 setter 只是调用了Backbone.Model的底层getset方法。

Backbone TypeScript 兼容性

正如我们所看到的,Backbone 允许我们在我们的代码中使用 TypeScript 的所有语言特性。我们可以使用类、接口、继承、泛型,甚至是 ECMAScript 5 属性。我们所有的类也都派生自基本的 Backbone 对象。这使得 Backbone 成为了一个非常兼容 TypeScript 的构建 Web 应用程序的库。我们将在后面的章节中更多地探索 Backbone 框架。

Angular

AngularJs(或者只是 Angular)也是一个非常流行的 JavaScript 框架,由 Google 维护。Angular 采用了完全不同的方法来构建 JavaScript SPA,引入了一个 HTML 语法,运行中的 Angular 应用程序可以理解。这为应用程序提供了双向数据绑定的能力,自动同步模型、视图和 HTML 页面。Angular 还提供了依赖注入DI)的机制,并使用服务来为视图和模型提供数据。

让我们来看一下 Angular 教程中的一个示例,该示例位于第 2 步,我们开始构建一个名为PhoneListCtrl的控制器。教程中提供的示例显示了以下 JavaScript:

var phonecatApp = angular.module('phonecatApp', []);
phonecatApp.controller('PhoneListCtrl', function ($scope) 
{
  $scope.phones = [
    {'name': 'Nexus S',
     'snippet': 'Fast just got faster with Nexus S.'},
    {'name': 'Motorola XOOM™ with Wi-Fi',
     'snippet': 'The Next, Next Generation tablet.'},
    {'name': 'MOTOROLA XOOM™',
     'snippet': 'The Next, Next Generation tablet.'}
  ];
});

这段代码片段是典型的 Angular JavaScript 语法。我们首先创建一个名为phonecatApp的变量,并通过在angular全局实例上调用module函数将其注册为一个 Angular 模块。module函数的第一个参数是 Angular 模块的全局名称,空数组是其他模块的占位符,这些模块将通过 Angular 的依赖注入机制注入。

然后我们调用新创建的phonecatApp变量上的controller函数,带有两个参数。第一个参数是控制器的全局名称,第二个参数是一个接受名为$scope的特殊命名的 Angular 变量的函数。在这个函数中,代码将$scope变量的phones对象设置为一个 JSON 对象数组,每个对象都有namesnippet属性。

如果我们继续阅读教程,我们会发现一个单元测试,展示了PhoneListCtrl控制器的使用方式:

describe('PhoneListCtrl', function(){
    it('should create "phones" model with 3 phones', function() {
      var scope = {},
          ctrl = new PhoneListCtrl(scope);

      expect(scope.phones.length).toBe(3);
  });

});

这段代码片段的前两行使用了一个名为describe的全局函数,以及在这个函数内部另一个名为it的函数。这两个函数是单元测试框架 Jasmine 的一部分。我们将在下一章讨论单元测试,但目前让我们专注于代码的其余部分。

我们声明了一个名为scope的变量,它是一个空的 JavaScript 对象,然后声明了一个名为ctrl的变量,它使用new关键字来创建我们PhoneListCtrl类的一个实例。new PhoneListCtrl(scope)语法表明 Angular 正在使用控制器的定义,就像我们在 TypeScript 中使用普通类一样。

在 TypeScript 中构建相同的对象将允许我们使用 TypeScript 类,如下所示:

var phonecatApp = angular.module('phonecatApp', []);

class PhoneListCtrl  {
    constructor($scope) {
        $scope.phones = [
            { 'name': 'Nexus S',
              'snippet': 'Fast just got faster' },
            { 'name': 'Motorola',
              'snippet': 'Next generation tablet' },
            { 'name': 'Motorola Xoom',
              'snippet': 'Next, next generation tablet' }
        ];
    }
};

我们的第一行与之前的 JavaScript 示例相同。然而,我们使用了 TypeScript 类语法来创建一个名为PhoneListCtrl的类。通过创建一个 TypeScript 类,我们现在可以像在 Jasmine 测试代码中所示的那样使用这个类:ctrl = new PhoneListCtrl(scope)。我们PhoneListCtrl类的constructor函数现在充当了原始 JavaScript 示例中看到的匿名函数:

phonecatApp.controller('PhoneListCtrl', function ($scope) {
    // this function is replaced by the constructor
}

Angular 类和$scope

让我们进一步扩展我们的PhoneListCtrl类,并看看完成后会是什么样子:

class PhoneListCtrl  {
    myScope: IScope;
    constructor($scope, $http: ng.IHttpService, Phone) {
        this.myScope = $scope;
        this.myScope.phones = Phone.query();
        $scope.orderProp = 'age';
         _.bindAll(this, 'GetPhonesSuccess');
    }
    GetPhonesSuccess(data: any) {
        this.myScope.phones = data;
    }
};

这个类中需要注意的第一件事是,我们正在定义一个名为myScope的变量,并将通过构造函数传入的$scope参数存储在这个内部变量中。这是因为 JavaScript 的词法作用域规则。请注意构造函数末尾的_.bindAll调用。这个 Underscore 实用函数将确保每当调用GetPhonesSuccess函数时,它将在类实例的上下文中使用变量this,而不是在调用代码的上下文中。我们将在后面的章节中详细讨论_.bindAll的用法。

GetPhonesSuccess函数在其实现中使用了this.myScope变量。这就是为什么我们需要将初始的$scope参数存储在内部变量中的原因。

从这段代码中我们注意到的另一件事是,myScope变量被类型化为一个名为IScope的接口,需要定义如下:

interface IScope {
    phones: IPhone[];
}
interface IPhone {
    age: number;
    id: string;
    imageUrl: string;
    name: string;
    snippet: string;
};

这个IScope接口只包含了一个IPhone类型的对象数组(请原谅这个接口的不幸命名 - 它也可以包含安卓手机)。

这意味着我们在处理$scope对象时没有标准的接口或 TypeScript 类型可用。由于其性质,$scope参数的类型会根据 Angular 运行时调用它的时间和位置而改变,因此我们需要定义一个IScope接口,并将myScope变量强类型化为这个接口。

PhoneListCtrl类的构造函数中另一个有趣的事情是$http参数的类型。它被设置为ng.IHttpService类型。这个IHttpService接口在 Angular 的声明文件中找到。为了在 TypeScript 中使用 Angular 变量(如$scope$http),我们需要在声明文件中找到匹配的接口,然后才能使用这些变量上可用的任何 Angular 函数。

在这个构造函数代码中要注意的最后一个参数是名为Phone的参数。它没有分配给它的 TypeScript 类型,因此自动变成了any类型。让我们快速看一下这个Phone服务的实现,如下所示:

var phonecatServices = angular.module('phonecatServices', ['ngResource']);

phonecatServices.factory('Phone',
    [
        '$resource', ($resource) => {
            return $resource('phones/:phoneId.json', {}, {
                query: {
                    method: 'GET',
                    params: {
                        phoneId: 'phones'
                    },
                    isArray: true
                }
            });
        }
    ]
);

这段代码片段的第一行再次使用angular.module全局函数创建了一个名为phonecatServices的全局变量。然后我们调用phonecatServices变量上可用的factory函数,以定义我们的Phone资源。这个factory函数使用一个名为'Phone'的字符串来定义Phone资源,然后使用 Angular 的依赖注入语法来注入一个$resource对象。通过查看这段代码,我们可以看到我们不能轻松地为 Angular 在这里使用标准的 TypeScript 类。也不能在这个 Angular 服务上使用标准的 TypeScript 接口或继承。

Angular TypeScript 兼容性

在使用 TypeScript 编写 Angular 代码时,我们可以在某些情况下使用类,但在其他情况下必须依赖于底层的 Angular 函数(如modulefactory)来定义我们的对象。此外,当使用标准的 Angular 服务(如$http$resource)时,我们需要指定匹配的声明文件接口才能使用这些服务。因此,我们可以描述 Angular 库与 TypeScript 的兼容性为中等。

继承 - Angular 与 Backbone

继承是面向对象编程的一个非常强大的特性,也是在使用 JavaScript 框架时的一个基本概念。在每个框架中使用 Backbone 控制器或 Angular 控制器都依赖于某些特性或可用的功能。然而,我们已经看到,每个框架以不同的方式实现继承。

由于 JavaScript 没有继承的概念,每个框架都需要找到一种实现方式,以便框架可以允许我们扩展基类及其功能。在 Backbone 中,这种继承实现是通过每个 Backbone 对象的extend函数来实现的。正如我们所见,TypeScript 的extends关键字与 Backbone 的实现方式类似,允许框架和语言相互配合。

另一方面,Angular 使用自己的继承实现,并在 angular 全局命名空间上定义函数来创建类(即angular.module)。我们有时也可以使用应用程序的实例(即<appName>.controller)来创建模块或控制器。不过,我们发现 Angular 与 TypeScript 类似地使用控制器,因此我们可以简单地创建标准的 TypeScript 类,这些类将在 Angular 应用程序中起作用。

到目前为止,我们只是浅尝辄止了 Angular TypeScript 语法和 Backbone TypeScript 语法。这个练习的目的是尝试理解如何在这两个第三方框架中使用 TypeScript。

一定要访问todomvc.com,并查看用 TypeScript 编写的 Angular 和 Backbone 的 Todo 应用程序的完整源代码。它们可以在示例部分的Compile-to-JS选项卡中找到。这些运行的代码示例,结合这些网站上的文档,将在尝试在外部第三方库(如 Angular 或 Backbone)中编写 TypeScript 语法时,证明是一个宝贵的资源。

Angular 2.0

微软 TypeScript 团队和谷歌 Angular 团队刚刚完成了数月的合作,并宣布即将发布的名为 Angular 2.0 的 Angular 版本将使用 TypeScript 构建。最初,Angular 2.0 将使用一种名为 AtScript 的新语言进行 Angular 开发。然而,在微软和谷歌团队的合作工作期间,AtScript 的功能已经在 TypeScript 中实现,这是 Angular 2.0 开发所需的。这意味着一旦 Angular 2.0 库和 TypeScript 编译器的 1.5 版可用,Angular 2.0 库将被归类为与 TypeScript 高度兼容。

ExtJs

ExtJs 是一个流行的 JavaScript 库,拥有各种各样的小部件、网格、图形组件、布局组件等。在 4.0 版中,ExtJs 将模型、视图、控制器式的应用程序架构整合到他们的库中。虽然它对于开源开发是免费的,但对于商业用途需要许可证。它受到开发团队的欢迎,这些团队正在构建基于 Web 的桌面替代品,因为它的外观和感觉与普通的桌面应用程序相当。ExtJs 默认确保每个应用程序或组件在任何浏览器中运行时看起来和感觉都完全相同,并且几乎不需要 CSS 或 HTML。

然而,尽管社区施加了很大压力,ExtJs 团队尚未发布官方的 TypeScript 声明文件。幸运的是,更广泛的 JavaScript 社区已经出手相助,首先是 Mike Aubury。他编写了一个小型实用程序,从 ExtJs 文档中生成声明文件(github.com/zz9pa/extjsTypescript)。

这项工作是否影响了 DefinitelyTyped 上当前版本的 ExtJs 定义,还有待观察,但 Mike Aubury 的原始定义和 DefinitelyTyped 上 brian428 的当前版本非常相似。

在 ExtJs 中创建类

ExtJs 是一个以自己的方式做事的 JavaScript 库。如果我们要对 Backbone、Angular 和 ExtJs 进行分类,我们可能会说 Backbone 是一个高度兼容的 TypeScript 库。换句话说,TypeScript 中的类和继承语言特性与 Backbone 高度兼容。

在这种情况下,Angular 将是一个部分兼容的库,其中一些 Angular 对象的元素符合 TypeScript 语言特性。另一方面,ExtJs 将是一个最低限度兼容的库,几乎没有适用于该库的 TypeScript 语言特性。

让我们来看一个用 TypeScript 编写的示例 ExtJs 4.0 应用程序。考虑以下代码:

Ext.application(
    {
        name: 'SampleApp',
        appFolder: '/code/sample',
        controllers: ['SampleController'],
        launch: () => {

            Ext.create('Ext.container.Viewport', {
                layout: 'fit',
                items: [{
                    xtype: 'panel',
                    title: 'Sample App',
                    html: 'This is a Sample Viewport'
                }]
            });

        }

    }
);

我们首先通过在Ext全局实例上调用application函数来创建一个 ExtJs 应用程序。然后,application函数使用一个 JavaScript 对象,在第一个和最后一个大括号{ }中定义属性和函数。这个 ExtJs 应用程序将name属性设置为SampleAppappFolder属性设置为/code/samplecontrollers属性设置为一个包含一个条目的数组:'SampleController'

然后我们定义了一个launch属性,这是一个匿名函数。这个launch函数然后使用全局Ext实例上的create函数来创建一个类。create函数使用"Ext.container.Viewport"名称来创建Ext.container.Viewport类的一个实例,该类具有layoutitems属性。layout属性只能包含特定一组值之一,例如'fit''auto''table'items数组包含进一步的 ExtJs 特定对象,这些对象根据它们的xtype属性创建。

ExtJs 是那种不直观的库之一。作为程序员,你需要随时打开一个浏览器窗口,查看库文档,并用它来弄清楚每个属性对于每种可用类的含义。它还有很多魔术字符串 - 在前面的示例中,如果我们错写了'Ext.container.Viewport'字符串,或者在正确的位置忘记了大写,Ext.create函数将会失败。对于 ExtJs 来说,'viewport''ViewPort'是不同的。记住,我们在 TypeScript 中解决魔术字符串的一个方法是使用枚举。不幸的是,当前版本的 ExtJs 声明文件没有一组枚举来表示这些类类型。

使用类型转换

然而,我们可以使用 TypeScript 的类型转换语言特性来帮助编写 ExtJs 代码。如果我们知道我们要创建的 ExtJs 对象的类型,我们可以将 JavaScript 对象转换为这种类型,然后使用 TypeScript 来检查我们使用的属性是否适用于该类型的 ExtJs 对象。为了帮助理解这个概念,让我们只考虑Ext.application的外部定义。去掉内部代码后,对Ext全局对象上的application函数的调用将被简化为这样:

Ext.application(
    {
        // properties of an Ext.application
        // are set within this JavaScript
        // object block
    }
);

使用 TypeScript 声明文件、类型转换和大量的 ExtJs 文档,我们知道内部 JavaScript 对象应该是Ext.app.IApplication类型,因此我们可以将这个对象转换为如下形式:

Ext.application(
   <Ext.app.IApplication> {
       // this JavaScript block is strongly
       // type to be of Ext.app.IApplication
    }
);

这段代码片段的第二行现在使用了 TypeScript 类型转换语法,将大括号{ }之间的 JavaScript 对象转换为Ext.app.IApplication类型。这给我们提供了强类型检查和智能感知,如下图所示:

使用类型转换

Visual Studio 对 ExtJs 配置块的智能感知

类似地,这些显式类型转换也可以用于创建 ExtJs 类的任何 JavaScript 对象。目前在 DefinitelyTyped 上的 ExtJs 声明文件使用与 ExtJs 文档相同的对象定义名称,因此找到正确的类型应该相当简单。

上述显式类型转换的技术几乎是我们可以在 ExtJs 库中使用的唯一的 TypeScript 语言特性 - 但这仍然突显了对象的强类型化如何在开发过程中帮助我们,使我们的代码更加健壮,更加抗错误。

ExtJs 特定的 TypeScript 编译器

如果你经常使用 ExtJs,那么你可能会想看看 Gareth Smith、Fabio Parra dos Santos 及其团队在github.com/fabioparra/TypeScript上的工作。这个项目是 TypeScript 编译器的一个分支,它将从标准的 TypeScript 类中生成 ExtJs 类。使用这个版本的编译器可以改变正常的 ExtJs 开发方式,允许使用自然的 TypeScript 类语法,通过extends关键字使用继承,以及自然的模块命名,而不需要魔术字符串。这个团队的工作表明,由于 TypeScript 编译器是开源的,它可以被扩展和修改以特定的方式生成 JavaScript,或者针对特定的库。向 Gareth、Fabio 和他们的团队致敬,因为他们在这个领域做出了开创性的工作。

总结

在本章中,我们已经看过第三方 JavaScript 库以及它们如何在 TypeScript 应用程序中使用。我们首先看了包括社区发布的 TypeScript 声明文件在内的各种包含方式,从下载原始文件到使用 NuGet 和 TSD 等包管理器。然后,我们看了三种类型的第三方库,并讨论了如何将这些库与 TypeScript 集成。我们探讨了 Backbone,它可以被归类为高度兼容的第三方库,Angular 是一个部分兼容的库,而 ExtJs 是一个最低限度兼容的库。我们看到了 TypeScript 语言的各种特性如何与这些库共存,并展示了在这些情况下 TypeScript 等效代码会是什么样子。在下一章中,我们将看看测试驱动开发,并探讨一些可用于单元测试、集成测试和自动验收测试的库。

为 Bentham Chang 准备,Safari ID bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需著作权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第六章:测试驱动开发

在过去的几年中,模型视图控制器MVC)、模型视图呈现器MVP)和模型视图视图模型MVVM)模式的流行使得出现了一系列第三方 JavaScript 库,每个库都实现了自己的这些模式的版本。例如,Backbone 可以被描述为 MVP 实现,其中视图充当呈现器。ExtJS 4 引入了 MVC 模式到他们的框架中,而 Angular 可以被描述为更多的 MVVM 框架。当一起讨论这组模式时,有些人将它们描述为模型视图任何MVW)或模型视图某物MV*)。

编写应用程序的 MV风格的一些好处包括模块化和关注点分离。构建应用程序的 MV风格还带来了一个巨大的优势——能够编写可测试的 JavaScript。使用 MV*允许我们对我们精心编写的 JavaScript 进行单元测试、集成测试和功能测试。这意味着我们可以测试我们的渲染函数,以确保 DOM 元素在页面上正确显示。我们还可以模拟按钮点击、下拉选择和动画。我们还可以将这些测试扩展到页面转换,包括登录页面和主页。通过为我们的应用程序构建大量的测试,我们将获得对我们的代码按预期工作的信心,并且它将允许我们随时重构我们的代码。

在本章中,我们将讨论与 TypeScript 相关的测试驱动开发。我们将讨论一些更受欢迎的测试框架,编写一些单元测试,然后讨论测试运行器和持续集成技术。

测试驱动开发

测试驱动开发TDD)是一个开发过程,或者说是一个开发范式,它从测试开始,并通过这些测试推动生产代码的动力。测试驱动开发意味着提出问题“我如何知道我已经解决了问题?”而不仅仅是“我如何解决这个问题?”

测试驱动方法的基本步骤如下:

  • 编写一个失败的测试

  • 运行测试以确保它失败

  • 编写代码使测试通过

  • 运行测试以查看它是否通过

  • 运行所有测试以确保新代码不会破坏其他任何测试

  • 重复这个过程

使用测试驱动开发实践实际上是一种心态。一些开发人员遵循这种方法,首先编写测试,而其他人先编写他们的代码,然后再编写测试。然后还有一些人根本不写测试。如果你属于最后一类人,那么希望你在本章学到的技术将帮助你朝正确的方向迈出第一步。

有很多借口可以用来不写单元测试。一些典型的借口包括诸如“测试框架不在我们最初的报价中”,或者“它将增加 20%的开发时间”,或者“测试已经过时,所以我们不再运行它们”。然而,事实是,在当今这个时代,我们不能不写测试。应用程序的规模和复杂性不断增长,需求随时间变化。一个拥有良好测试套件的应用程序可以比没有测试的应用程序更快地进行修改,并且对未来的需求变化更具有弹性。这时,单元测试的真正成本节约才显现出来。通过为应用程序编写单元测试,您正在未来保护它,并确保对代码库的任何更改不会破坏现有功能。

在 JavaScript 领域的 TDD 为我们的代码覆盖率增加了另一层。开发团队经常只编写针对应用程序的服务器端逻辑的测试。例如,在 Visual Studio 空间中,这些测试通常只针对控制器、视图和基础业务逻辑的 MVC 框架。测试应用程序的客户端逻辑一直是相当困难的——换句话说,就是实际呈现的 HTML 和基于用户的交互。

JavaScript 测试框架为我们提供了填补这一空白的工具。现在我们可以开始对呈现的 HTML 进行单元测试,以及模拟用户交互,比如填写表单和点击按钮。这种额外的测试层,结合服务器端测试,意味着我们有一种方法来对应用程序的每一层进行单元测试——从服务器端业务逻辑,通过服务器端页面呈现,直到呈现和用户交互。对前端用户交互进行单元测试是任何 JavaScript MV*框架的最大优势之一。事实上,它甚至可能影响您在选择技术栈时所做的架构决策。

单元测试、集成测试和验收测试

自动化测试可以分为三个一般领域,或测试类型——单元测试、集成测试和验收测试。我们也可以将这些测试描述为黑盒测试或白盒测试。白盒测试是测试者知道被测试代码的内部逻辑或结构的测试。另一方面,黑盒测试是测试者不知道被测试代码的内部设计或逻辑的测试。

单元测试

单元测试通常是一种白盒测试,其中代码块的所有外部接口都被模拟或存根化。例如,如果我们正在测试一些进行异步调用以加载一块 JSON 的代码,单元测试这段代码将需要模拟返回的 JSON。这种技术确保被测试对象始终获得已知的数据集。当出现新的需求时,这个已知的数据集当然可以增长和扩展。被测试对象应该被设计为与接口交互,以便这些接口可以在单元测试场景中轻松地被模拟或存根化。

集成测试

集成测试是另一种白盒测试的形式,允许被测试的对象在接近真实代码的环境中运行。在我们之前的例子中,一些代码进行异步调用以加载一块 JSON,集成测试需要实际调用生成 JSON 的表述性状态转移REST)服务。如果这个 REST 服务依赖于来自数据库的数据,那么集成测试就需要数据库中与集成测试场景匹配的数据。如果我们将单元测试描述为在被测试对象周围有一个边界,那么集成测试就是简单地扩展这个边界,以包括依赖对象或服务。

为应用程序构建自动化集成测试将极大地提高应用程序的质量。考虑我们一直在使用的场景——一段代码调用 REST 服务获取一些 JSON 数据。有人很容易改变 REST 服务返回的 JSON 数据的结构。我们的单元测试仍然会通过,因为它们实际上并没有调用 REST 服务器端代码,但我们的应用程序会出现问题,因为返回的 JSON 不是我们期望的。

没有集成测试,这些类型的错误只能在手动测试的后期阶段被发现。考虑集成测试,实现特定的数据集用于集成测试,并将其构建到测试套件中,将能够及早消除这些类型的错误。

验收测试

验收测试是黑盒测试,通常基于场景。它们可能包含多个用户屏幕或用户交互以通过。这些测试通常也由测试团队执行,因为可能需要登录到应用程序,搜索特定的数据,更新数据等。通过一些规划,我们还可以将这些验收测试的部分自动化为集成套件,因为我们在 JavaScript 中有能力查找并单击按钮,将数据插入所需字段,或选择下拉项。项目拥有的验收测试越多,它就会越健壮。

注意

在测试驱动开发方法论中,手动测试团队发现的每个错误都必须导致新的单元测试、集成测试或验收测试的创建。这种方法将有助于确保一旦发现并修复错误,它就不会再次出现。

使用持续集成

当为任何应用程序编写单元测试时,很快就会变得重要,设置一个构建服务器,并将您的测试作为每个源代码控制检入的一部分运行。当您的开发团队超出单个开发人员时,使用持续集成CI)构建服务器变得至关重要。这个构建服务器将确保提交到源代码控制服务器的任何代码都通过所有已知的单元测试、集成测试和自动验收测试。构建服务器还负责标记构建并生成在部署过程中需要使用的任何部署工件。

构建服务器的基本步骤如下:

  • 检出最新版本的源代码,并增加构建编号

  • 在构建服务器上编译应用程序

  • 运行任何服务器端单元测试

  • 为部署打包应用程序

  • 将软件包部署到构建环境

  • 运行任何服务器端集成测试

  • 运行任何 JavaScript 单元测试、集成测试和验收测试

  • 标记更改集和构建编号为通过或失败

  • 如果构建失败,请通知责任人

注意

如果前面的任何步骤失败,构建服务器应该失败。

持续集成的好处

使用构建服务器运行前面的步骤对任何开发团队都带来巨大的好处。首先,应用程序在构建服务器上编译,这意味着任何使用的工具或外部库都需要安装在构建服务器上。这为您的开发团队提供了在新机器上安装软件的机会,以便编译或运行应用程序。

其次,在尝试打包之前,可以运行一组标准的服务器端单元测试。在 Visual Studio 项目中,这些测试将是使用任何流行的.NET 测试框架构建的 C#单元测试,例如 MSTest、NUnit 或 xUnit。

接下来,运行整个应用程序的打包步骤。假设一名开发人员在项目中包含了一个新的 JavaScript 库,但忘记将其添加到 Visual Studio 解决方案中。在这种情况下,所有测试将在他们的本地计算机上运行,但由于缺少库文件,构建将失败。如果我们在这个阶段部署站点,运行应用程序将导致 404 错误-文件未找到。通过运行打包步骤,这类错误可以很快被发现。

一旦成功完成了打包步骤,构建服务器应该将站点部署到一个特别标记的构建环境中。这个构建环境仅用于 CI 构建,因此必须具有自己的数据库实例、Web 服务引用等,专门为 CI 构建设置。再次,实际上部署到目标环境测试了部署工件以及部署过程。通过为自动打包部署设置构建环境,您的团队再次能够记录部署的要求和过程。

在这个阶段,我们在一个独立的构建环境上完整地运行了我们的网站实例。然后,我们可以轻松地针对特定的网页运行我们的 JavaScript 测试,并直接在完整版本的网站上运行集成或自动接受测试。这样,我们可以编写针对真实网站 REST 服务的测试,而无需模拟这些集成点。因此,实际上,我们是从头开始测试应用程序。显然,我们可能需要确保我们的构建环境具有一组特定的数据,可以用于集成测试,或者一种生成所需数据集的方法,我们的集成测试将需要。

选择构建服务器

有许多持续集成构建服务器,包括 TeamCity、Jenkins 和 Team Foundation Server(TFS)。

Team Foundation Server

TFS 需要在其构建代理上进行特定配置,以便能够运行 Web 浏览器的实例。对于较大的项目,实际在特定浏览器中运行 JavaScript 测试是有意义的,并很快就成为必需的步骤。您可能需要支持多个浏览器,并希望在 Firefox、Chrome、IE、Safari 或其他浏览器中运行您的测试。TFS 还使用 Windows Workflow Foundation(WF)来配置构建步骤,这需要相当多的经验和知识来修改。

Jenkins

Jenkins 是一个开源的免费使用的 CI 构建服务器。它有广泛的社区使用和许多插件。Jenkins 的安装和配置相当简单,Jenkins 将允许进程运行浏览器实例,使其与基于浏览器的 JavaScript 单元测试兼容。Jenkins 的构建步骤是基于命令行的,有时需要一些技巧来正确配置构建步骤。

TeamCity

一个非常受欢迎且功能强大的免费设置的构建服务器是 TeamCity。如果您有少量开发人员(<20)和少量项目(<20),TeamCity 允许免费安装。完整的商业许可证只需约 1500 美元,这使得大多数组织都能负担得起。在 TeamCity 中配置构建步骤比在 Jenkins 或 TFS 中要容易得多,因为它使用向导样式的配置,具体取决于您正在创建的构建步骤的类型。TeamCity 还具有丰富的围绕单元测试的功能,能够显示每个单元测试的图表,因此被认为是构建服务器的最佳选择。

单元测试框架

有许多可用的 JavaScript 单元测试框架,也有一些用 TypeScript 编写的框架。最受欢迎的两个 JavaScript 框架是 Jasmine(jasmine.github.io/)和 QUnit(qunitjs.com/)。如果您正在编写 Node TypeScript 代码,那么您可能想看看 mocha(github.com/mochajs/mocha/wiki)。

两个基于 TypeScript 的测试框架是 MaxUnit(github.com/KnowledgeLakegithub/MaxUnit)和 tsUnit(github.com/Steve-Fenton/tsUnit)。不幸的是,MaxUnit 和 tsUnit 都是这个领域的新手,因此可能没有老一辈更流行的框架所固有的功能。例如,MaxUnit 在撰写时没有任何文档,而 tsUnit 没有与 CI 构建服务器兼容的测试报告框架。随着时间的推移,这些 TypeScript 框架可能会成长,但是看到使用第三方库和使用 DefinitelyTyped 声明文件编写 QUnit 或 Jasmine 的单元测试是非常简单的。

在本章的其余部分,我们将使用 Jasmine 2.0 作为我们的测试框架。

Jasmine

在本章的这一部分,我们将创建一个基于 MVC 框架项目类型的新的 Visual Studio 项目。现在,我们可以使用空的 MVC 模板。

Jasmine 可以通过以下两个 NuGet 包安装到我们的新 TypeScript 项目中:

Install-Package JasmineTest
Install-Package jasmine.TypeScript.DefinitelyTyped

有了这两个包,我们就有了所需的 JavaScript 库和 TypeScript 定义文件,可以开始编写 Jasmine 测试。

注意

通过 NuGet 默认安装JasmineTest使用了 ASP.NET MVC 框架,并在Controllers目录中创建了一个JasmineController。如果您没有使用 MVC 框架,或者在 Node 环境中安装了这个包,那么这个JasmineController应该被删除,因为它会导致编译错误。在本章的后面,我们将展示如何对这个JasmineController运行集成测试,所以最好暂时保留它。

一个简单的 Jasmine 测试

Jasmine 使用一种简单的格式来编写测试。考虑以下 TypeScript 代码:

describe("tests/01_SimpleJasmineTests.ts ", () => {
    it("should fail", () => {
        var undefinedValue;
        expect(undefinedValue).toBeDefined();
    });
});

这个片段以一个名为describe的 Jasmine 函数开始,它接受两个参数。第一个参数是测试套件的名称,第二个是包含我们的测试套件的匿名函数。接下来的一行使用了名为it的 Jasmine 函数,它也接受两个参数。第一个参数是测试名称,第二个参数是包含我们的测试的匿名函数;换句话说,it匿名函数中的内容就是我们的实际测试。这个测试首先定义了一个名为undefinedValue的变量,但实际上并没有设置它的值。接下来,我们使用了 Jasmine 函数expect。仅仅通过阅读这个expect语句的代码,我们就可以快速理解这个单元测试在做什么。它期望undefinedValue变量的值应该被定义,也就是不是undefined

expect函数接受一个参数,并返回一个 Jasmine 匹配器。然后我们可以调用任何 Jasmine 匹配器函数来评估传入expect的值与匹配器函数的关系。expect关键字类似于其他测试库中的Assert关键字。expect语句的格式是人类可读的,使得 Jasmine 的期望相对简单易懂。

Jasmine SpecRunner.html 文件

为了运行这个测试,我们需要一个包含所有相关 Jasmine 第三方库以及我们的测试 JavaScript 文件的 HTML 页面。我们可以创建一个SpecRunner.html文件,其中包含以下 HTML:

<!DOCTYPE html>
<html >
    <head>
        <title>Jasmine Spec Runner</title>
        <link rel="shortcut icon" type="image/png" href="/Content/jasmine/jasmine_favicon.png">
        <link rel="stylesheet" type="text/css" href="/Content/jasmine/jasmine.css">
        <script type="text/javascript" src="img/jasmine.js"></script>
        <script type="text/javascript" src="img/jasmine-html.js"></script>
        <script type="text/javascript" src="img/boot.js"></script>
        <script type="text/javascript" src="img/01_SimpleJasmineTests.js"></script>

    </head>
<body>

</body>
</html>

这个 HTML 页面只是包含了所需的 Jasmine 文件,jasmine.cssjasmine.jsjasmine-html.jsboot.js。最后一行包含了从我们的 TypeScript 测试文件编译出的 JavaScript 文件。

如果我们将这个页面设置为在 Visual Studio 中的启动页面并运行它,我们应该会看到一个失败的单元测试:

Jasmine SpecRunner.html 文件

显示 Jasmine 输出的 SpecRunner.html 页面

太棒了!我们正在遵循测试驱动开发的过程,首先创建一个失败的单元测试。结果正是我们所期望的。我们的名为undefinedVariable的变量还没有被赋值,因此将是undefined。如果我们遵循 TDD 过程的下一步,我们应该编写使测试通过的代码。更新我们的测试如下将确保测试通过:

describe("tests/01_SimpleJasmineTests.ts ", () => {
    it("value that has been assigned should be defined", () => {
        var undefinedValue = "test";
        expect(undefinedValue).toBeDefined();
    });
});

请注意,我们已经更新了我们的测试名称以描述测试的目标。为了使测试通过,我们只需将值"test"赋给我们的undefinedValue变量。现在运行SpecRunner.html页面将显示一个通过的测试。

匹配器

Jasmine 有各种各样的匹配器可以在测试中使用,并且还允许我们编写和包含自定义匹配器。从以下 TypeScript 代码中可以看出,Jasmine 匹配器的语法非常直观:

    var undefValue;
    expect(undefValue).not.toBeDefined();

在这里,我们使用.not.匹配器语法来检查变量undefValue是否确实是undefined

    var definedValue = 2;
    expect(definedValue).not.toBe(null);

这个expect语句使用not.toBe匹配器来确保definedValue变量不是null

    expect(definedValue).toBe(2);

在这里,我们使用.toBe匹配器来检查definedValue实际上是一个值为 2 的数字。

    expect(definedValue.toString()).toEqual("2");

这个expect语句使用toEqual匹配器来确保toString函数将返回字符串值"2"

    var trueValue = true;
    expect(trueValue).toBeTruthy();
    expect(trueValue).not.toBeFalsy();

在这里,我们使用toBeTruthytoBeFalsy匹配器来测试boolean值。

    var stringValue = "this is a string";
    expect(stringValue).toContain("is");
    expect(stringValue).not.toContain("test");

最后,我们还可以使用toContain匹配器来解析一个字符串,并测试它是否包含另一个字符串,或者使用.not.匹配器与toContain进行相反的测试。

一定要前往 Jasmine 网站查看匹配器的完整列表,以及编写自定义匹配器的详细信息。

测试启动和拆卸

与其他测试框架一样,Jasmine 提供了一种定义函数的机制,这些函数将在每个测试之前和之后运行,或作为测试启动和拆卸机制。在 Jasmine 中,beforeEachafterEach函数充当测试启动和拆卸函数,如下面的 TypeScript 代码所示:

describe("beforeEach and afterEach tests", () => {
    var myString;

    beforeEach(() => {
        myString = "this is a test string";
    });
    afterEach(() => {
        expect(myString).toBeUndefined();
    });

    it("should find then clear the myString variable", () => {
        expect(myString).toEqual("this is a test string");
        myString = undefined;
    });

});

在这个测试中,我们在匿名函数的开头定义了一个名为myString的变量。根据 JavaScript 的词法作用域规则,这个myString变量将在接下来的beforeEachafterEachit函数中可用。在beforeEach函数中,这个变量被设置为一个字符串值。在afterEach函数中,测试这个变量是否已被重置为undefined。我们在测试中的期望是,这个变量已经通过beforeEach函数设置。在测试结束时,我们将变量重置为undefined。请注意,afterEach函数也调用了一个expect,在这种情况下是为了确保测试已将变量重置为undefined

注意

Jasmine 2.1 版本引入了第二个版本的设置和拆卸,称为beforeAllafterAll。在撰写本书时,jasmine.jsjasmine.d.ts文件的版本都还没有更新到 v2.1。

数据驱动测试

为了展示 Jasmine 测试库的可扩展性,JP Castro 编写了一个非常简短但功能强大的实用程序,以在 Jasmine 中提供数据驱动测试。他关于这个主题的博客可以在这里找到(blog.jphpsf.com/2012/08/30/drying-up-your-javascript-jasmine-tests/),GitHub 存储库可以在这里找到(github.com/jphpsf/jasmine-data-provider)。这个简单的扩展允许我们编写直观的 Jasmine 测试,每个测试都带有一个参数,如下所示:

describe("data driven tests", () => {
    using<string>("valid values", [
        "first string",
        "second string",
        "third string"
    ], (value) => {
        it("should contain string (" + value + ")", () => {
            expect(value).toContain("string");
        });
    });
});

在这里,我们将我们的it测试函数包裹在另一个名为using的函数中。这个using函数接受三个参数:值集的字符串描述,值的数组,以及一个函数定义。这个最后的函数定义使用变量value,并将使用这个值来调用我们的测试。还要注意,在调用我们的测试时,我们正在动态更改测试名称,以包含传入的value参数。这是为了确保每个测试都有一个唯一的测试名称。

前面的解决方案只需要 JP Castro 的 Jasmine 扩展,如下面的 JavaScript 代码所示:

function using(name, values, func) {
    for (var i = 0, count = values.length; i < count; i++) {
        if (Object.prototype.toString.call(values[i]) !== '[object Array]') 
        {
            values[i] = [values[i]];
        }
        func.apply(this, values[i]);
    }
}

这是一个非常简单的名为using的函数,它接受我们之前提到的三个参数。该函数通过数组值进行简单的循环,并将每个数组值传递给我们的测试。

我们需要的最后一样东西是一个用于前面using函数的 TypeScript 定义文件。这是一个非常简单的函数声明,如下所示:

declare function using<T>(
    name: string,
    values : T [],
    func : (T) => void
);

这个 TypeScript 声明使用了泛型语法<T>,以确保第二个和第三个参数使用相同的类型。有了这个声明,以及 JavaScript 的using函数,我们的代码将正确编译,并且测试将针对数据数组中的每个值运行一次:

data driven tests
should contain string (first string)
should contain string (second string)
should contain string (third string)

使用间谍

Jasmine 还有一个非常强大的功能,可以让你的测试看到特定的函数是否被调用,以及它被调用时使用的参数。它还可以用来创建模拟和存根。所有这些功能都包含在 Jasmine 所称的间谍中。

考虑以下测试:

class MySpiedClass {
    testFunction(arg1: string) {
        console.log(arg1);
    }
}
describe("simple spy", () => {
    it("should register a function call", () => {
        var classInstance = new MySpiedClass();
        spyOn(classInstance, 'testFunction');

        classInstance.testFunction("test");

        expect(classInstance.testFunction).toHaveBeenCalled();
    });
});

我们从一个名为MySpiedClass的简单类开始,它有一个名为testFunction的函数。这个函数接受一个参数,并将参数记录到控制台上。

我们的测试从创建一个MySpiedClass的新实例开始,并将其赋值给一个名为classInstance的变量。然后我们在classInstance变量的testFunction函数上创建了一个 Jasmine 间谍。一旦我们创建了一个间谍,就可以调用这个函数。我们的期望是检查这个函数是否被调用。这就是间谍的本质。Jasmine 将“监视”MySpiedClass实例的testFunction函数,以查看它是否被调用。

注意

默认情况下,Jasmine 间谍会阻止对底层函数的调用。换句话说,它们会用 Jasmine 代理替换你试图调用的函数。如果你需要对一个函数进行间谍,但仍然需要执行函数体,你必须使用.and.callThrough()流畅语法来指定这种行为。

虽然这只是一个非常简单的例子,但在许多不同的测试场景中,间谍变得非常强大。例如,需要回调参数的类或函数需要一个间谍来确保回调函数实际上被调用。

让我们看看如何测试回调函数是否被正确调用。考虑以下 TypeScript 代码:

class CallbackClass {
    doCallBack(id: number, callback: (result: string) => void ) {
        var callbackValue = "id:" + id.toString();
        callback(callbackValue);
    }
}

class DoCallBack {
    logValue(value: string) {
        console.log(value);
    }
}

在这段代码片段中,我们定义了一个名为CallbackClass的类,它有一个名为doCallback的函数。这个doCallback函数接受一个number类型的id参数,还有一个callback函数。callback函数接受一个string作为参数,并返回void

我们定义的第二个类有一个名为logValue的函数。这个函数的签名与doCallback函数上所需的回调函数签名相匹配。使用 Jasmine 间谍,我们可以测试doCallBack函数的逻辑。这个逻辑根据传入的id参数创建一个字符串,然后用这个字符串调用callback函数。我们的测试需要确保这个字符串格式正确。因此,我们的 Jasmine 测试可以写成如下形式:

describe("using callback spies", () => {
    it("should execute callback with the correct string value", () => {
        var doCallback = new DoCallBack();
        var classUnderTest = new CallbackClass();

        spyOn(doCallback, 'logValue');
        classUnderTest.doCallBack(1, doCallback.logValue);

        expect(callbackSpy.logValue).toHaveBeenCalled();
        expect(callbackSpy.logValue).toHaveBeenCalledWith("id:1");

    });
});

这个测试代码首先创建了一个CallbackClass类的实例,也创建了一个DoCallBack类的实例。然后我们在DoCallBack类的logValue函数上创建了一个间谍。接着我们调用doCallback函数,将1作为第一个参数传入,并将logValue函数作为第二个参数传入。我们在最后两行的expect语句中检查回调函数logValue是否被实际调用,以及它被调用时使用的参数。

使用间谍作为伪装

Jasmine 间谍的另一个好处是它们可以充当伪装。换句话说,它们代替了对真实函数的调用,而是委托给了 Jasmine 间谍。Jasmine 还允许间谍返回值——这在生成小型模拟框架时非常有用。考虑以下测试:

Class ClassToFake {
    getValue(): number {
        return 2;
    }
}
describe("using fakes", () => {
    it("calls fake instead of real function", () => {
        var classToFake = new ClassToFake();
        spyOn(classToFake, 'getValue')
            .and.callFake( () => { return 5; }
            );
        expect(classToFake.getValue()).toBe(5);
    });
});

我们从一个名为ClassToFake的类开始,它有一个名为getValue的单一函数,返回2。我们的测试然后创建了这个类的一个实例。然后我们调用 Jasmine 的spyOn函数来创建一个对getValue函数的间谍,然后使用.and.callFake语法将一个匿名函数附加为一个伪造函数。这个伪造函数将返回5而不是原来会返回2getValue函数。测试然后检查当我们在ClassToFake实例上调用getValue函数时,Jasmine 会用我们的新伪造函数替换原来的getValue函数,并返回5而不是2

Jasmine 的伪造语法有许多变体,包括抛出错误或返回值的方法,请参考 Jasmine 文档以获取其伪造能力的完整列表。

异步测试

JavaScript 的异步特性——由 AJAX 和 jQuery 广泛使用,一直是这门语言的吸引点之一,也是 Node.js 应用程序的主要架构原理。让我们快速看一下一个异步类,然后描述我们应该如何测试它。考虑以下 TypeScript 代码:

class MockAsyncClass {
    executeSlowFunction(success: (value: string) => void) {
        setTimeout(() => {
            success("success");
        }, 1000);
    }
}

MockAsyncClass有一个名为executeSlowFunction的单一函数,它接受一个名为success的函数回调。在executeSlowFunction的代码中,我们通过使用setTimeout函数模拟了一个异步调用,并且只在1000毫秒(1 秒)后调用成功回调。这种行为模拟了标准的 AJAX 调用(它会使用successerror回调),这可能需要几秒钟才能返回,取决于后端服务器的速度或数据包的大小。

我们对executeSlowFunction的测试可能如下所示:

describe("asynchronous tests", () => {
    it("failing test", () => {

        var mockAsync = new MockAsyncClass();
        var returnedValue;
        mockAsync.executeSlowFunction((value: string) => {
            returnedValue = value;
        });
        expect(returnedValue).toEqual("success");
    });

});

首先,我们实例化了MockAsyncClass的一个实例,并定义了一个名为returnedValue的变量。然后我们用一个匿名函数调用executeSlowFunction作为success回调函数。这个匿名函数将returnedValue的值设置为从MockAsyncClass传入的任何值。我们的期望是returnedValue应该等于"success"。然而,如果我们现在运行这个测试,我们的测试将失败,并显示以下错误消息:

Expected undefined to equal 'success'.

这里发生的情况是,因为executeSlowFunction是异步的,JavaScript 不会等到回调函数被调用之后再执行下一行代码。这意味着期望被调用之前executeSlowFunction还没有机会调用我们的匿名回调函数(设置returnedValue的值)。如果你在expect(returnValue).toEqual("success")行上设置一个断点,并在returnedValue = value行上设置另一个断点,你会看到期望行先被调用,而returnedValue行只在一秒后才被调用。这个时间问题导致了这个测试的失败。我们需要以某种方式让我们的测试等到executeSlowFunction调用回调之后再执行我们的期望。

使用done()函数

Jasmine 2.0 版本引入了一种新的语法来帮助我们处理这种异步测试。在任何beforeEachafterEachit函数中,我们传递一个名为done的参数,它是一个函数,然后在我们的异步代码的末尾调用它。考虑以下测试:

describe("asynch tests with done", () => {
    var returnedValue;

    beforeEach((done) => {
        returnedValue = "no_return_value";
        var mockAsync = new MockAsyncClass();
        mockAsync.executeSlowFunction((value: string) => {
            returnedValue = value;
            done();
        });
    });

    it("should return success after 1 second", (done) => {
        expect(returnedValue).toEqual("success");
        done();
    });
});

首先,我们已经将returnedValue变量移出了我们的测试,并包含了一个beforeEach函数,在我们实际的测试之前运行。这个beforeEach函数首先重置了returnValue的值,然后设置了MockAsyncClass的实例。最后调用了这个实例上的executeSlowFunction

请注意beforeEach函数接受一个名为done的参数,然后在调用returnedValue = value行之后调用此done函数。还要注意,it函数的第二个参数现在也接受一个done参数,并在测试完成时调用此done函数。

注意

来自 Jasmine 文档:在调用beforeEach时,done函数被调用之前,规范不会开始,并且在调用done函数之前,规范不会完成。默认情况下,Jasmine 将等待 5 秒钟,然后导致超时失败。可以使用jasmine.DEFAULT_TIMEOUT_INTERVAL变量进行覆盖。

Jasmine fixtures

很多时候,我们的代码要么负责从 JavaScript 中读取 DOM 元素,要么在大多数情况下操纵 DOM 元素。这意味着任何依赖于 DOM 元素的运行代码,如果底层 HTML 不包含正确的元素或一组元素,可能会失败。另一个名为jasmine-jquery的 Jasmine 扩展库允许我们在测试执行之前将 HTML 元素注入到 DOM 中,并在测试运行后从 DOM 中删除它们。

在撰写本书时,此库尚未在 NuGet 上可用,因此我们需要以传统方式下载jasmine-jquery.js文件,并将其包含在我们的项目中。但是,TypeScript 定义文件在 NuGet 上是可用的:

Install-package Jasmine-jquery.TypeScript.DefinitelyTyped

注意

我们还需要更新.html文件,在头部脚本部分包含jquery.jsjasmine-jquery.js文件。

让我们看一个使用jasmine-jquery库注入 DOM 元素的测试。首先,一个操纵特定 DOM 元素的类:

Class ModifyDomElement {
    setHtml() {
        var elem = $("#my_div");
        elem.html("<p>Hello world</p>");
    }
}

这个ModifyDomElement类有一个名为setHtml的单个函数,它使用 jQuery 查找 id 为my_div的 DOM 元素。然后,这个 div 的 HTML 被设置为一个简单的"Hello world"段落。现在是我们的 Jasmine 测试:

describe("fixture tests", () => {
    it("modifies dom element", () => {
        setFixtures("<div id='my_div'></div>");
        var modifyDom = new ModifyDomElement();
        modifyDom.setHtml();
        var modifiedElement = $("#my_div");
        expect(modifiedElement.length).toBeGreaterThan(0);
        expect(modifiedElement.html()).toContain("Hello");
    });
});

测试从调用jasmine-jquery函数setFixtures开始。此函数将提供的 HTML 作为第一个字符串参数直接注入到 DOM 中。然后,我们创建ModifyDomElement类的一个实例,并调用setHtml函数来修改my_div元素。然后,我们将变量modifiedElement设置为 DOM 中 jQuery 搜索的结果。如果 jQuery 找到了元素,则其length属性将为> 0,然后我们可以检查 HTML 是否确实被修改。

注意

jasmine-jquery提供的 fixture 方法还允许从磁盘加载原始 HTML 文件,而不必编写 HTML 的冗长字符串表示。如果您的 MV*框架使用 HTML 文件片段,这也特别有用。jasmine-jquery库还具有从磁盘加载 JSON 的实用程序,并且可以与 jQuery 一起使用的特定构建匹配器。请务必查看文档(github.com/velesin/jasmine-jquery)。

DOM 事件

jasmine-jquery库还添加了一些 Jasmine 间谍,以帮助处理 DOM 事件。如果我们正在创建一个按钮,无论是在 TypeScript 代码中还是在 HTML 中,我们都可以确保我们的代码正确响应 DOM 事件,比如click。考虑以下代码和测试:

Function handle_my_click_div_clicked() {
    // do nothing at this time
}
describe("click event tests", () => {
    it("spies on click event element", () => {
        setFixtures("<div id='my_click_div' "+"onclick='handle_my_click_div_clicked'>Click Here</div>");

        var clickEventSpy = spyOnEvent("#my_click_div", "click");

        $('#my_click_div').click();
        expect(clickEventSpy).toHaveBeenTriggered();
    });
});

首先,我们定义了一个名为handle_my_click_div_clicked的虚拟函数,该函数在 fixture HTML 中使用。仔细查看setFixtures函数调用中使用的 HTML,我们创建了一个带有 id 为my_click_div的按钮,并且具有一个onclick DOM 事件,将调用我们的虚拟函数。然后,我们在my_click_div div 上创建一个点击事件的间谍,然后在下一行实际调用点击事件。我们的期望是使用jasmine-jquery匹配器toHaveBeenTriggered来测试onclick处理程序是否被调用。

注意

jQuery 和 DOM 操作为我们提供了一种填写表单、单击提交取消确定按钮,并一般模拟用户与我们的应用程序的交互的方法。我们可以使用这些技术在 Jasmine 中轻松编写完整的验收或用户验收测试,进一步巩固我们的应用程序,防止错误和变更。

茉莉花运行器

有许多方法可以在实际网页之外运行 Jasmine 测试,就像我们一直在做的那样。但请记住,Visual Studio 不支持在直接运行 Internet Explorer 的网页之外调试 TypeScript。在这些情况下,您需要回到目标浏览器中现有的开发人员工具。

大多数测试运行器依赖于一个简单的静态 HTML 页面来包含所有测试,并将启动一个小型的 Web 服务器实例,以便将此 HTML 页面提供给测试运行器。一些测试运行器使用配置文件来实现这一目的,并构建一个无需 HTML 的测试环境。这对于单元测试可能很好,其中代码的集成点被模拟或存根,但这种方法对于集成或验收测试效果不佳。

例如,许多现实世界的 Web 应用程序通过一些服务器端业务逻辑来生成每个 Web 请求的 HTML。例如,身份验证逻辑可能会将用户重定向到登录页面,然后在后续页面请求或 RESTful 数据请求中使用基于表单的身份验证 cookie。在这些情况下,在实际 Web 应用程序之外运行简单的 HTML 页面将不起作用。您需要在实际与 Web 应用程序的其余部分一起托管的页面中运行您的测试。此外,如果您尝试将 JavaScript 测试套件添加到现有的 Web 项目中,这种逻辑可能不容易放在一边。

出于这些原因,我们专注于在我们的 Web 应用程序中使用标准 HTML 页面来运行我们的测试。例如,在 MVC 应用程序中,我们将设置一个 Jasmine 控制器,其中包含一个返回SpecRunner.cshtml视图页面的Run函数。实际上,NuGet 包JasmineTest的默认安装将在安装时为我们设置这些控制器和视图作为标准模板。

Testem

Testem 是一个基于 Node 的命令行实用程序,当它检测到 JavaScript 文件已被修改时,将连续运行测试套件以针对连接的浏览器。Testem 非常适用于在多个浏览器上快速获得反馈,还具有可以在构建服务器上使用的持续集成标志。Testem 适用于单元测试。更多信息可以在 GitHub 存储库中找到(github.com/airportyh/testem)。

可以通过以下命令在 Node 上安装 Testem:

Npm install –g testem

要运行testem,只需在命令行窗口中导航到测试套件的根文件夹,并输入testem。Testem 将启动,启动一个 Web 服务器,并邀请您通过浏览器连接到它。按照屏幕截图,Testem 在http://localhost:7357上运行。您可以将多个不同的浏览器连接到此 URL,并且 Testem 将针对每个浏览器运行它找到的规范。默认情况下,Testem 将在当前目录中搜索包含测试的 JavaScript 文件,构建包含这些测试的 HTML 页面并执行它们。如果您已经有一个包含您的测试的 HTML 页面,那么可以通过testem.yml配置文件将此页面指定给 Testem,如下所示:

{
    "test_page":"tests/01_SpecRunner.html"
}

此 HTML 页面还需要包含 testem.js 文件,以便与 Testem 服务器进行通信。

Testem

Testem 输出显示三个连接的浏览器

Testem 有许多强大的配置选项,可以在配置文件中指定。请务必前往 GitHub 存储库获取更多信息。

请注意,Testem 将无法与 ASP.NET MVC 控制器路由一起工作,因此不适用于 ASP.NET MVC 站点的集成测试。如果您正在使用 MVC 控制器和视图来生成您的测试套件,例如,您正在运行测试页面的 URL 是/Jasmine/Run,Testem 将无法工作。

Karma

Karma 是由 Angular 团队构建的测试运行器,并在 Angular 教程中大量使用。它只是一个单元测试框架,Angular 团队建议使用 Protractor 构建和运行端到端或集成测试。Karma,像 Testem 一样,运行自己的 Web 服务器实例,以便为测试套件提供所需的页面和工件,并具有大量的配置选项。它也可以用于不针对 Angular 的单元测试。要安装 Karma 以与 Jasmine 2.0 一起使用,我们需要使用npm安装一些软件包:

Npm install karma-jasmine@2_0 –save-dev
Npm install jasmine-core –save-dev
Npm install karma-chrome-launcher
Npm install karma-jasmine-jquery

要运行 Karma,我们首先需要一个配置文件。按照惯例,这通常称为karma.conf.js。示例karma配置文件如下:

module.exports = function (config) {
    config.set({
        basePath: '../../',
        files: [
          'Scripts/underscore.js',
          'Scripts/jquery-1.8.0.js',
          'Scripts/jasmine-jquery/jasmine-jquery.js',
          'Scripts/jasmine-data-provider/SpecHelper.js',
          'tests/*.js'
        ],
        autoWatch: true,
        frameworks: ['jasmine'],
        browsers: ['Chrome'],
        plugins: [
                'karma-chrome-launcher',
                'karma-jasmine'
        ],

        junitReporter: {
            outputFile: 'test_out/unit.xml',
            suite: 'unit'
        }
    });
};

所有 Karma 的配置都必须通过module.exportsconfig.set约定传递,如前两行所示。basePath参数指定 Web 项目的根路径,并与karma.config.js文件所在的目录相关。files数组包含要包含在生成的 HTML 文件中的文件列表,并且可以使用\**\*.js匹配算法来加载整个目录和子目录的 JavaScript 文件。autoWatch参数使 Karma 在后台运行,监视文件的更改,类似于 Testem。Karma 还允许指定各种浏览器,每个浏览器都有自己的启动器插件。最后,本示例中使用junitReporter将测试报告回报给 Jenkins CI 服务器。一旦配置文件就位,只需运行以下命令启动 karma:

karma start <path to karma.config.js>.

Karma

Karma 从一个简单的测试中输出

Protractor

Protractor 是一个基于 Node 的测试运行器,用于端到端测试。它最初是为 Angular 应用程序设计的,但可以与任何网站一起使用。与 Testem 和 Karma 不同,Protractor 能够浏览到特定页面,然后从 JavaScript 与页面交互,适用于集成测试。它可以检查页面标题等元数据属性,或填写表单和点击按钮,并允许后端服务器重定向到不同的页面。Protractor 文档可以在这里找到(github.com/angular/protractor),并可以使用npm安装:

Npm install –g protractor

稍后我们将运行 Protractor,但首先让我们讨论 Protractor 用于自动化网页的引擎。

使用 Selenium

Selenium 是一个用于 Web 浏览器的驱动程序。它允许对 Web 浏览器进行编程远程控制,并可用于在 Java、C#、Python、Ruby、PHP、Perl 甚至 JavaScript 中创建自动化测试。Protractor 在底层使用 Selenium 来控制 Web 浏览器实例。要安装用于 Protractor 的 Selenium 服务器,请运行以下命令:

Webdriver-manager update

要启动 Selenium 服务器,请运行以下命令:

Webdriver-manager start

如果一切顺利,Selenium 将报告服务器已启动,并详细说明 Selenium 服务器的地址。检查您的输出是否有类似以下行:

RemoteWebDriver instances should connect to: http://127.0.0.1:4444/wd/hub

注意

您需要在您的计算机上安装 Java 才能运行 Selenium 服务器,因为 webdriver-manager 脚本使用 Java 启动 Selenium 服务器。

一旦服务器运行,我们将需要一个 Protractor 的配置文件(名为protractor.conf.js),其中包含一些设置。在这个阶段,我们只需要以下内容:

exports.config = {
    seleniumAddress: 'http://localhost:4444/wd/hub',
    specs: ['*.js']
}

这些 protractor 设置只是将seleniumAddress设置为之前报告的 Selenium 服务器的地址。我们还有一个specs属性,它被设置为在与protractor.conf.js相同目录中查找任何.js文件,并将它们视为测试规范。

现在是最简单的测试:

describe("simple protractor test", () => {
    it("should navigate to a page and find a title", () => {
        browser.driver.get('http://localhost:64227/Jasmine/Run');
        expect(browser.driver.getTitle()).toContain("Jasmine");
    });
});

我们的测试从在/Jasmine/Run打开页面开始。请注意,这是一个使用默认 Jasmine 控制器的 ASP.NET MVC 路径,并返回Views/Jasmine/SpecRunner.cshtml。这个控制器和视图是之前安装的 Jasmine NuGet 包中包含的。在尝试执行 Protractor 测试之前,请确保您可以在浏览器中导航到此页面。

使用配置文件运行 Protractor 现在将执行我们之前的测试:

protractor .\tests\protractor\protractor.conf.js

并且将产生期望的结果:

Using the selenium server at http://localhost:4444/wd/hub.
Finished in 1.606 seconds
1 test, 1 assertion, 0 failures

注意

这里必须有两件事情在运行,以便这个测试能够工作:

Selenium 服务器必须在命令提示符中运行,以便localhost:4444/wd/hub是有效地址,并且不返回 404 错误

开发人员 ASP.NET 网站必须正常运行,以便localhost:64277/Jasmine/Run访问我们的 Visual Studio Jasmine 控制器,并呈现 HTML 页面

集成测试

假设我们正在进行集成测试,测试页面是使用 ASP.NET MVC 路由渲染的。我们希望使用标准的 MVC 控制器、操作、视图方法来生成 HTML 页面,因为我们可能需要执行一些服务器端逻辑来设置集成测试开始之前的前提条件。

请注意,在现实世界的应用程序中,通常需要运行服务器端逻辑或使用服务器端 HTML 渲染进行集成测试。例如,大多数应用程序在允许通过 JavaScript 调用 REST 服务之前,都需要某种形式的身份验证。向 RESTful API 控制器实现[Authorize]属性是合乎逻辑的解决方案。不幸的是,从普通 HTML 页面调用这些 REST 控制器将返回 401(未经授权)错误。解决这个问题的一种方法是使用 MVC 控制器来提供测试 HTML 页面,然后在服务器端代码中设置虚拟表单身份验证票证。一旦这个设置完成,从此页面对 RESTful 服务的任何调用都将使用虚拟用户配置文件进行身份验证。这种技术也可以用于运行具有不同角色和不同权限的用户的集成测试,这些角色和权限基于他们的身份验证凭据。

模拟集成测试

为了模拟这种集成测试页面,让我们重用之前安装的 Jasmine NuGet 包中的JasmineController。如前所述,集成测试将需要访问后端服务器端逻辑(在这种情况下是 Jasmine MVC 控制器),然后将服务器端生成的 HTML 页面呈现到浏览器(在这种情况下是SpecRunner.cshtml视图)。这种模拟意味着我们依赖服务器端 MVC 框架来解析/Jasmine/Run URL,动态生成 HTML 页面,并将生成的 HTML 页面返回给浏览器。

这个SpecRunner.cshtml文件(用于生成 HTML 的 MVC 模板)非常简单:

{
  Layout = null;
}
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
  <title>Jasmine Spec Runner</title>

  <link rel="shortcut icon" type="image/png" href="/Content/jasmine/jasmine_favicon.png">
  <link rel="stylesheet" type="text/css" href="/Content/jasmine/jasmine.css">
  <script type="text/javascript" src="img/jasmine.js"></script>
  <script type="text/javascript" src="img/jasmine-html.js"></script>
  <script type="text/javascript" src="img/boot.js"></script>

  <!—include source files here... -->
  <script type="text/javascript" src="img/SpecHelper.js"></script>
  <script type="text/javascript"
          src="img/PlayerSpec.js"></script>

  <!—include spec files here... -->
  <script type="text/javascript" src="img/Player.js"></script>
  <script type="text/javascript" src="img/Song.js"></script>
</head>

<body>
</body>
</html>

这个 ASP.NET MVC 视图页面使用 Razor 语法,不是基于主页面,因为文件顶部的Layout参数设置为null。页面在head元素中包含了一些链接,包括jasmine.cssjasmine.jsjasmine-html.jsboot.js。这些是我们之前看到的必需的 Jasmine 文件。之后,我们只包括了jasmine-samples目录中的SpecHelper.jsPlayerSpec.jsPlayer.jsSong.js文件。通过导航到/Jasmine/Run URL 运行此页面将运行 Jasmine 附带的示例测试。

模拟集成测试

默认/Jasmine/Run 网页的输出

在这个示例中,我们模拟的集成测试页面只运行了一些标准的 Jasmine 测试。现在使用服务器端生成的 HTML 页面可以允许我们使用虚拟身份验证,如果需要的话。有了虚拟身份验证,我们可以开始编写 Jasmine 测试来针对安全的 RESTful 数据服务。

在下一章中,我们将看一下构建和测试一些 Backbone 模型和集合,并将通过更多的集成测试示例来实际请求服务器上的数据。不过,目前我们有一个由服务器端生成的示例页面,可以作为进一步集成测试的基础。

注意

这样的测试页面不应该被打包在用户验收测试(UAT)或发布配置中。在 ASP.NET 中,我们可以简单地在我们的控制器类周围使用编译指令,比如#if DEBUG … #endif,来排除它们从任何其他构建配置中。

详细的测试结果

所以现在我们有了一个集成测试页面的开端,它显示了我们的 Jasmine 测试运行的结果。这个 HTML 页面对于快速概览很好,但我们现在希望一些更详细的关于每个测试的信息,以便我们可以报告给我们的构建服务器;每个测试花费的时间,以及它的success / fail状态。

为了报告这些目的,Jasmine 包括使用自定义测试报告者的能力,超出了 Jasmine 默认的HtmlReporter。GitHub 项目 jasmine-reporters(github.com/larrymyers/jasmine-reporters)有许多预构建的测试报告者,适用于最流行的构建服务器。不幸的是,这个项目没有相应的 NuGet 包,所以我们需要手动在我们的项目中安装.js文件。

注意

管理 JavaScript 库的另一种方法是使用Bower包管理器。Bower 是一个基于 Node 的命令行实用程序,类似于 NuGet,但只处理 JavaScript 库和框架。

现在让我们修改我们的 HTML 页面来包含 TeamCity 报告者。首先,修改SpecRunner.cshtml文件,包含teamcity_reporter.js文件的script标签如下:

<script type="text/javascript" src="img/teamcity_reporter.js">
</script>

接下来,我们需要在body标签内创建一个简单的脚本来注册这个报告者到 Jasmine:

<script type="application/javascript">
    window.tcapi = new jasmineReporters.TeamCityReporter({});
    jasmine.getEnv().addReporter(window.tcapi);
</script>

这个脚本只是创建了一个TeamCityReporter类的实例,并将其分配给window对象上的一个名为tcapi的变量。这个脚本的第二行将这个报告者添加到 Jasmine 环境中。现在运行我们的页面将会产生记录在控制台的 TeamCity 结果:

详细的测试结果

Jasmine 输出与记录在控制台的 TeamCity 消息

记录测试结果

现在我们需要访问这个输出,并找到一种方法将其报告给 Protractor 实例。不幸的是,通过 Selenium 访问控制台的日志只会报告关键错误,因此前面的 TeamCity 报告输出将不可用。快速查看teamcity_reporter.js代码,发现所有的console.log输出消息都使用tclog函数来构建一个字符串,然后调用console.log输出这个字符串。由于我们有一个可用的TeamCityReporter实例,我们可以很容易地将这些记录的消息存储到一个数组中,然后在测试套件运行结束后读取它们。对 JavaScript 文件teamcity_reporter.js进行一些快速修改如下。

TeamCityReporter类的构造函数下方,创建一个数组:

exportObject.TeamCityReporter = function (args) {

    self.logItems = new Array();
}

现在我们可以修改tclog函数来返回它构建的字符串:

Function tclog(message, attrs) {

    log(str); // call to console.log
    return str; // return the string to the calling function
}

然后,每次调用tclog都可以将返回的字符串推送到这个数组中:

self.jasmineStarted = function (summary) {

    self.logItems.push(
       tclog("progressStart 'Running Jasmine Tests'"));
};

现在TeamCityReporter有一个logItems数组,我们需要一些方法来找出测试套件何时完成,然后我们可以循环遍历logItems数组,并将它们附加到 DOM 上。一旦它在 DOM 中,我们的 Protractor 实例就可以使用 Selenium 来读取这些值并报告给命令行。

让我们构建一个名为JasmineApiListener的小类,它接受TeamCityReporter类的一个实例来为我们做所有这些工作:

class JasmineApiListener {
    private _outputComplete: boolean;
    private _tcReporter: jasmine.ITeamCityReporter;

    constructor(tcreporter: jasmine.ITeamCityReporter) {
        this._outputComplete = false;

        this._tcReporter = tcreporter;
        var self = this;

        window.setInterval(() => {

            if (self._tcReporter.finished && !self._outputComplete) {
                var logItems = self._tcReporter.logItems;
                var resultNode = document.getElementById( 'teamCityReporterLog');
                resultNode.setAttribute('class', 'teamCityReporterLog');
                for (var I = 0; I < logItems.length; i++) {
                    var resultItemNode = document.createElement('div');
                    resultItemNode.setAttribute('class', 'logentry');
                    var textNode = document.createTextNode(logItems[i]);
                    resultItemNode.appendChild(textNode);
                    resultNode.appendChild(resultItemNode);

                }
                self._outputComplete = true;

                var doneFlag = document.getElementById( 'teamCityResultsDone');
                var doneText = document.createTextNode("done");
                doneFlag.appendChild(doneText);
            }

        }, 3000);
    }

}

我们的JasmineApiListener类有两个私有变量。_outputComplete变量是一个布尔标志,指示测试套件已完成,并且结果已经写入 DOM。_tcReporter变量保存了TeamCityReporter类的一个实例,它通过constructor传递。constructor简单地将标志_outputComplete设置为false,创建一个名为self的变量,并在三秒间隔上设置一个简单的定时器。

注意

self变量是必要的作用域步骤,以便在传递给setInterval的匿名函数内访问this的正确实例。

我们匿名函数的主体是所有好东西发生的地方。首先,我们检查TeamCityReporter实例上的_tcReporter.finished属性,以判断套件是否已完成。如果是,并且我们还没有将结果附加到 DOM (!self._outputComplete),那么我们可以访问logItems数组,并为每个条目创建 DOM 元素。这些元素作为<div class="logentry">…</div>元素附加到父级<div id="teamCityReporterLog">元素。

请注意,前面的代码使用了原生的document.getElementByIdappendChild语法进行 DOM 操作,而不是 jQuery 风格的语法,以避免对 jQuery 的依赖。

现在我们可以在SpecRunner.cshtml视图中修改脚本如下:

<script type="application/javascript">
    window.tcapi = new jasmineReporters.TeamCityReporter({});
    jasmine.getEnv().addReporter(window.tcapi);
    var jasmineApiListener = new JasmineApiListener(window.tcapi);
</script>

<div id="teamCityResultsDone"></div>
<div id="teamCityReporterLog"></div>

第一个脚本是我们之前使用的更新版本,现在它创建了我们的JasmineApiListener类的一个实例,并在构造函数中传递了TeamCityReporter类的实例。我们还添加了两个<div>标签。第一个teamCityResultsDone是一个标志,表示我们已经完成了将 TeamCity 结果写入 DOM,第二个teamCityReporterLog是父div,用于容纳所有子logentry元素。

如果我们现在打开这个页面,我们应该能看到我们的测试运行,然后三秒后,DOM 将被更新,显示我们从TeamCityReporter数组中读取的结果,如下面的截图所示:

记录测试结果

Jasmine 输出被记录到 DOM

现在我们有了一种将测试结果记录到 DOM 的方法,我们可以更新基于 Protractor 的 Selenium 测试,将这些结果与构建服务器相关联。

查找页面元素

如前所述,Protractor 可以用于运行集成测试,以及自动接受测试。Protractor 测试可以浏览到登录页面,找到登录用户名文本框,向该文本框发送值,例如"testuser1",然后重复该过程以输入密码。然后可以使用相同的测试代码单击登录按钮,这将提交表单到我们的服务器登录控制器。然后我们的测试可以确保服务器以正确的重定向响应到我们的主页。这个主页可能包含多个按钮、网格、图片、侧边栏和导航元素。理想情况下,我们希望为每个这些页面元素编写接受测试。

Protractor 使用定位器在 DOM 中查找这些元素。这些元素可以通过它们的 CSS 选择器、id来找到,或者如果使用 Angular,则可以通过模型或绑定来找到。构建这些选择器的正确字符串有时可能很困难。

Selenium 为我们提供了一个有用的 Firefox 扩展,用于编写基于 Selenium 的测试 - Selenium IDE (docs.seleniumhq.org/projects/ide/)。安装了这个扩展后,我们可以使用 IDE 来帮助找到页面上的元素。

作为如何使用这个扩展的示例,让我们继续我们正在编写的 Jasmine 报告器的工作,并找到我们一直在使用来标记完成测试套件的teamCityResultsDoneDOM 元素。我们用来找到这个 DOM 元素的代码和过程与我们在登录页面上找到其他页面元素的代码和过程相同,例如,或者我们通过 Selenium 驱动的任何其他页面。

如果我们在 Firefox 中启动我们的/Jasmine/Run页面,现在我们可以点击浏览器右上角的 Selenium IDE 按钮来启动 Selenium IDE。这个 IDE 使用命令来记录对网页的交互,并在主窗口中显示这些命令列表。右键单击命令窗口,然后选择插入新命令。在命令名称文本框中给新命令一个名称,比如find done element。一旦命令有了名称,目标输入框旁边的两个按钮就变成了启用状态,我们可以点击选择。然后我们可以在网页上拖动鼠标,并点击页面顶部的done文本。注意命令已经自动填写了 Selenium IDE 中的目标元素。目标输入框现在变成了一个下拉列表,我们可以使用这个列表来显示我们teamCityResultsDonediv的 Selenium 选择器语法,如下面的截图所示:

查找页面元素

FireFox Selenium IDE

在 Jasmine 中使用页面元素

现在我们知道如何使用 Selenium IDE 来找到 HTML 页面元素,我们可以开始编写 Selenium 命令来查询我们 Jasmine 测试的页面元素。记住我们需要找到两个元素。

首先,我们需要找到teamCityResultsDonediv,并等待该元素的文本被更新。这个div只有在我们的 Jasmine 测试套件完成时才会被更新,并且我们的测试结果已经包含在 DOM 中。一旦我们的测试套件被标记为完成,我们就需要循环遍历teamCityReporterLog的子元素logentry的每一个div。这些logentrydiv将包含我们每个测试的详细结果。

我们在 protractor 测试中需要的更改如下:

describe("team city reporter suite", () => {
    it("should find test results", () => {
        browser.driver.get('http://localhost:64227/Jasmine/Run');

        expect(browser.driver.getTitle()).toContain("Jasmine");

        var element = browser.driver.findElement(
            { id: "teamCityResultsDone" });

        browser.driver.wait(() => {
            return element.getText().then((value) => {
                return value.length > 0;
            });
        }, 60000, "failed to complete in 60 s");
    });

    afterEach(() => {
        browser.driver.findElements(
                by.css("#teamCityReporterLog > div.logentry")
            ).then((elements) => {
            for (var i = 0; i < elements.length; i++) {
                elements[i].getText().then((textValue) => {
                    console.log(textValue);
                });
            }
        });
    });
});

我们的测试从浏览到/Jasmine/Run页面开始,并期望该页面的标题包含"Jasmine",就像我们之前看到的那样。然后,我们使用来自 Selenium 的findElement函数在页面上找到一个元素。这个函数传递了一个 JavaScript 对象,其中id设置为teamCityResultsDone,并且使用了我们之前在 Selenium IDE 中看到的选择语法。

然后,我们调用wait函数等待teamCityResultsDone元素的文本被更新(即其length> 0),并为这个wait函数设置了 60 秒的超时。记住我们的JasmineApiListener代码将在我们完成更新 DOM 时将这个div的文本值设置为"done",这将有效地触发wait函数。

然后,我们使用afterEach函数循环遍历logentrydivs。我们现在不是找到父元素,而是使用findElements Selenium 函数在页面上找到多个元素。

注意我们用于这些div的 Selenium 选择器语法:by.css("#teamCityReporterLog > div.logentry")。这个by.css函数使用 CSS 选择器语法来找到我们的元素,输入字符串对应于 Selenium IDE 显示的 CSS 选择器。因此,我们可以使用 Selenium IDE 来帮助我们找到正确的 CSS 选择器语法。

Selenium 对其大多数 API 函数使用流畅的语法。因此,对 findElements 的调用后面跟着一个 .then 函数,它将在数组中找到的元素传递给匿名函数。我们使用这个匿名函数与 .then( (elements) => { .. }) 语法。在这个函数中,我们循环遍历元素数组的每个元素,并调用 .getText Selenium 函数。同样,这个 getText 函数提供了流畅的语法,允许我们编写另一个匿名函数来使用返回的文本值,就像在 elements[i].getText().then( (textValue ) => { … }); 中看到的那样。这个函数只是将 textValue 记录到 protractor 控制台中。

现在运行我们的 Protractor 测试将会将测试结果报告到命令行,如下所示:

在 Jasmine 中使用页面元素

Protractor 将测试结果记录到控制台

任务完成。我们现在正在使用 Protractor 浏览到一个由服务器生成的 HTML 页面,运行一组 Jasmine 测试。然后我们使用 Selenium 在页面上查找元素,等待 DOM 更新,然后循环遍历元素数组,以便将我们的 Jasmine 测试结果记录到 protractor 控制台中。

这些 Selenium 函数,如 browser.driver.getfindElementswait,都是 Selenium 提供的丰富功能集的一部分,用于处理 DOM 元素。请务必查阅 Selenium 文档以获取更多信息。

我们现在有了一种机制,可以启动集成测试页面,运行 Jasmine 测试套件,将这些测试结果报告给 DOM,然后读取这些结果并将其记录到 Protractor 控制台中。然后在 TeamCity 构建服务器中设置一个构建步骤来执行 protractor,并在构建过程中记录这些测试结果。

总结

在本章中,我们从头开始探讨了测试驱动开发。我们讨论了 TDD 的理论,探讨了单元测试、集成测试和验收测试之间的区别,并看了一下 CI 构建服务器流程会是什么样子。然后我们探讨了 Jasmine 作为一个测试框架,学习了如何编写测试,使用期望和匹配器,还探讨了 Jasmine 扩展,以帮助进行数据驱动测试和通过固定装置进行 DOM 操作。最后,我们看了测试运行器,并构建了一个基于 Protractor 的测试框架,通过 Selenium 驱动网页,并将结果报告给构建服务器。在下一章中,我们将探讨 TypeScript 模块语法,以便同时使用 CommonJS 和 AMD JavaScript 模块。

为 Bentham Chang 准备,Safari ID bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online, LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他使用都需要版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第七章:模块化

模块化是现代编程语言中常用的一种技术,它允许程序由一系列较小的程序或模块构建而成。编写使用模块的程序鼓励程序员编写符合称为“关注点分离”的设计原则的代码。换句话说,每个模块专注于做一件事,并且有一个明确定义的接口。如果我们通过关注接口来使用这个模块,我们可以很容易地用其他东西替换这个接口,而不会破坏我们的代码。我们将在下一章更多地关注“关注点分离”和其他面向对象的设计模式。

JavaScript 本身并没有模块的概念,但它被提议用于即将到来的 ECMAScript 6 标准。流行的框架和库,如 Node 和 Require,已经在它们的框架中构建了模块加载功能。然而,这些框架使用略有不同的语法。Node 使用 CommonJS 语法进行模块加载,而 Require 使用异步模块加载AMD)语法。TypeScript 编译器有一个选项可以打开模块编译,然后在这两种语法风格之间切换。

在本章中,我们将看一下两种模块风格的语法,以及 TypeScript 编译器如何实现它们。我们将看一下在编写 Node 和 Require 的代码时如何使用模块。我们还将简要介绍 Backbone,以及如何使用 Model、View 和 Controller 编写应用程序。这些 Backbone 组件将被构建为可加载的模块。

CommonJs

使用 CommonJs 语法编写模块的最普遍用法是编写服务器端代码。有人认为基于浏览器的 CommonJs 语法简直无法做到,但也有一些库,比如 Curl(github.com/cujojs/curl)可以实现这种语法。然而,在本节中,我们将专注于 Node 应用程序开发。

在 Visual Studio 中设置 Node

在 Visual Studio 中使用 Node 已经变得非常简单,这得益于 Node 工具的 Visual Studio 插件(nodejstools.codeplex.com)。这个工具集也已经更新,使用 TypeScript 作为默认编辑器,为 Node 带来了完整的 TypeScript 开发体验。安装了扩展后,我们可以创建一个新的空白 Node 应用程序,如下面的截图所示:

在 Visual Studio 中设置 Node

使用 Node 工具集创建空白 Node 应用程序

这个项目模板将自动为我们创建一个server.ts TypeScript 文件,并自动包含node.d.ts声明文件。如果我们编译并运行这个默认实现,只需按下F5,项目模板将自动启动一个新的控制台来运行我们的 Node 服务器,启动服务器实例,并打开一个浏览器连接到这个实例。如果一切顺利,你的浏览器将简单地显示Hello World

让我们来看看创建我们的 Node 服务器实例的server.ts TypeScript 文件:

import _http = require('http');
var port = process.env.port || 1337
http.createServer(function (req, res) {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World\n');
}).listen(port);

这段代码片段的第一行使用 CommonJs 模块语法告诉我们的 Node 服务器必须import名为'http'的库。

这一行有两个关键部分。为了解释这些关键部分,让我们从=号的右侧开始,然后向左工作。require函数接受一个参数,并用于告诉应用程序有一个名为'http'的库。require函数还告诉应用程序需要这个库才能继续正常运行。由于require是 TypeScript 模块语法的关键部分,它被赋予了关键字状态,并且将会像varstringfunction等其他关键字一样以蓝色高亮显示。如果应用程序找不到'http'库,那么 Node 将立即抛出异常。

=号的左侧使用了import关键字,这也是模块语法中的一个基本概念。import语句告诉应用程序将通过require函数加载的库require('http')附加到名为_http的命名空间中。'http'库公开的任何函数或对象都将通过_http命名空间对程序可用。

如果我们快速跳到第三行,我们会看到我们调用了'http'模块中定义的createServer函数,并通过_http命名空间调用它,因此是_http.createServer()

注意

由空白 Node 项目模板生成的默认server.ts文件与我们前面的代码示例略有不同。它将导入命名为http,与库名'http'匹配,如下所示:

import http = require('http');

这是 Node 的一个常见命名标准。当然,您可以将导入的命名空间命名为任何您喜欢的名称,但是将命名空间与导入的库的名称匹配会有助于提高代码的可读性。

我们的代码片段的第二行只是将名为port的变量设置为全局变量process.env.port的值,或者默认值1337。这个端口号在最后一行使用,使用流畅的语法在http.createServer函数的返回值上调用listen函数。

我们的createServer函数有两个名为reqres的变量。如果我们将鼠标悬停在req变量上,我们会看到它的类型是_http.ServerRequest。同样,res变量的类型是_http.ServerResponse。这两个变量是我们的 HTTP 请求和响应流。在代码体中,我们在 HTTP 响应上调用writeHead函数来设置内容类型,然后在 HTTP 响应上调用end函数来向浏览器写入文本'Hello World\n'

通过这几行代码,我们创建了一个运行中的 Node HTTP 服务器,提供一个简单的网页,其中包含文本"Hello World"

请注意,如果您对 TypeScript 语法有敏锐的眼光,您会注意到这个文件使用 JavaScript 语法而不是 TypeScript 语法来调用我们的createServer函数。这很可能是由于最近将 Node 工具集从 JavaScript 升级到 TypeScript。调用createServer也可以使用 TypeScript 的箭头函数语法来编写,如下所示:

_http.createServer((req, res) => { .. }

创建一个 Node 模块

要创建一个 Node 模块,我们只需要创建另一个 TypeScript 文件来存放我们的模块代码。让我们创建一个名为ServerMain.ts的文件,并将写入 HTTP 响应的代码移入此模块,如下所示:

import http = require('http');
export function processRequest(
    req: http.ServerRequest,
    res: http.ServerResponse): void
{
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World\n');
}

我们的ServerMain模块以将'http'模块导入到http命名空间开始。这是必要的,以便我们可以使用此库的ServerRequestServerResponse类型。

现在使用关键字export来指示哪些函数将对该模块的用户可用。正如我们所看到的,我们导出了一个名为processRequest的函数,它接受两个参数,reqres。这个函数将用作替代我们之前在server.ts文件中使用的匿名函数(req, res) => { ... }

请注意,作为优秀的 TypeScript 编码者,我们还强类型化了reqres变量,分别为http.ServerRequest类型和http.ServerResponse类型。这将使我们的 IDE 内置智能提示,并且也符合强类型的两个原则(S.F.I.A.T 和自描述函数)。

在修改server.ts文件以使用我们的新模块之前,让我们打开生成的 JavaScript 文件,更仔细地查看一下 CommonJs 语法:

function processRequest(req, res) {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World\n');
}
exports.processRequest = processRequest;

这个 JavaScript 的前半部分足够简单——我们有一个名为processRequest的函数。然而,最后一行将这个函数附加到exports全局变量的一个属性上。这个exports全局变量是 CommonJs 将模块发布到外部世界的方式。任何需要暴露给外部世界的函数、类或属性都必须附加到exports全局变量上。每当我们在 TypeScript 文件中使用exports关键字时,TypeScript 编译器将为我们生成这行代码。

使用 Node 模块

现在我们已经有了我们的模块,我们可以修改我们的server.ts文件来使用这个模块,如下所示:

import http = require('http');
import ServerMain = require('./ServerMain');
var port = process.env.port || 1337;
http.createServer(ServerMain.processRequest).listen(port);

第一行保持不变,但第二行使用相同的importrequire语法来将我们的'./ServerMain'模块导入到ServerMain命名空间中。

注意

我们用来命名这个模块的语法指向一个本地文件模块,因此使用相对文件路径到模块文件。这个相对路径将解析为 TypeScript 生成的ServerMain.js文件。创建一个名为'ServerMain'的全局 Node 模块,它将全局可用——类似于'http'模块——超出了本讨论的范围。

我们对http.createServer函数的调用现在将我们的processRequest函数作为参数传入。我们已经从使用箭头函数的匿名函数改为了来自ServerMain模块的命名函数。我们还开始遵循我们的“关注点分离”设计模式。server.ts文件在特定端口上启动服务器,而ServerMain.ts文件现在包含用于处理单个请求的代码。

链接异步函数

在编写 Node 代码时,有必要仔细注意所有 Node 编程的异步性质,以及 JavaScript 的词法作用域规则。幸运的是,TypeScript 编译器会在我们违反这些规则时生成错误。举个例子,让我们更新我们的ServerMain模块,从磁盘中读取文件,并提供该文件的内容,而不是我们的Hello world文本,如下所示:

import fs = require("fs");
export function processRequestReadFromFileAnonymous(
      req: http.ServerRequest, res: http.ServerResponse) 
{
    fs.readFile('server.js', 'utf8', (err, data) => {
        res.writeHead(200, { 'Content-Type': 'text/plain' });
        if (err)
            res.write("could not open file for reading");
        else {
            res.write(data);
            res.end();
        }
    });
}

要从磁盘中读取文件,我们需要使用名为"fs"的 Node 全局模块,或者文件系统,它在代码的第一行被导入。然后我们暴露一个名为processRequestReadFromFileAnonymous的新函数,再次使用reqres参数。在这个函数内部,我们使用fs.readFile函数来使用三个参数从磁盘中读取文件。第一个参数是要读取的文件名,第二个参数是文件类型,第三个参数是一个回调函数,Node 在从磁盘中读取文件后将调用它。

这个匿名函数的主体与我们之前看到的类似,但它还检查err参数,以查看在加载文件时是否出现错误。如果没有错误,函数就简单地将文件写入响应流中。

在现实世界的应用程序中,主processRequestReadFromFileAnonymous函数内部的逻辑可能会变得非常复杂(除了名称之外),并且可能涉及从磁盘读取硬编码文件名的多个步骤。让我们将这个匿名函数移到一个私有函数中,看看会发生什么。我们对重构这段代码的第一次尝试可能类似于以下内容:

export function processRequestReadFromFileError(
    req: http.ServerRequest, res: http.ServerResponse) 
{
    fs.readFile('server.js', 'utf8', writeFileToStreamError);
}
function writeFileToStreamError(err, data) {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    if (err)
        res.write("could not open file for reading");
    else {
        res.write(data);
        res.end();
    }
}

在这里,我们修改了fs.readFile函数调用,并用命名函数writeFileToStreamError替换了匿名回调函数。然而,这个改变会立即生成一个编译错误:

Cannot find name 'res'.

这个编译错误是由 JavaScript 的词法作用域规则引起的。函数writeFileToStreamError试图使用父函数的res参数。然而,一旦我们将这个函数移出父函数的词法作用域,变量res就不再在作用域内 - 因此将是undefined。为了解决这个错误,我们需要确保res参数的词法作用域在我们的代码结构中得到维持,并且我们需要将res参数的值传递给我们的writeFileToStream函数,如下所示:

export function processRequestReadFromFileChained(
    req: http.ServerRequest, res: http.ServerResponse) 
{
    fs.readFile('server.js', 'utf8', (err, data) => {
        writeFileToStream(err, data, res);
    });
}
function writeFileToStream(
    err: ErrnoException, data: any, 
    res: http.ServerResponse): void 
{
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    if (err)
        res.write("could not open file for reading");
    else {
        res.write(data);
        res.end();
    }
}

请注意,在前面代码的第三行调用fs.readFile时,我们已经恢复到了匿名语法,并将父级res参数的值传递给我们的新函数writeFileToStream。我们对代码的这种修改现在正确地遵守了 JavaScript 的词法作用域规则。另一个副作用是,我们已经清楚地定义了writeFileToStream函数需要哪些变量才能工作。它需要fs.readFile回调中的errdata变量,但它还需要原始 HTTP 请求中的res变量。

注意

我们没有导出writeFileToStream函数;它纯粹是我们模块内部使用的函数。

现在我们可以修改我们的server.ts文件来使用我们的新的链式函数:

http.createServer(ServerMain.processRequestReadFromFileChained) .listen(port);

现在运行应用程序将展示server.js文件的内容:

链接异步函数

Node 应用程序提供磁盘上文件的内容

请注意,由于我们使用了模块,我们已经能够编写processRequest函数的三个不同版本,每个版本都有一点不同。然而,我们对启动服务器的server.ts文件的修改非常简单。我们只是替换了服务器调用的函数,以有效地运行我们应用程序的三个不同版本。再次,这符合“关注点分离”设计原则。server.ts代码只是用于在特定端口上启动 Node 服务器,并不应该关心每个请求是如何处理的。我们ServerMain.ts中的代码只负责处理请求。

这结束了我们在 TypeScript 中编写 Node 应用程序的部分。正如我们所见,TypeScript 开发者体验带来了一个编译步骤,它将快速捕捉到我们代码中的词法作用域规则和许多其他问题。最终得分,TypeScript:1,有错误的代码:0!

使用 AMD

AMD 代表异步模块定义,正如其名称所示,它异步加载模块。这意味着当加载 HTML 页面时,获取 JavaScript 模块文件的请求同时发生。这使得我们的页面加载更快,因为我们同时请求了更小量的 JavaScript。

AMD 模块加载通常用于浏览器应用程序,并与提供脚本加载功能的第三方库一起工作。目前最流行的脚本和模块加载器之一是 Require。在本节中,我们将看看如何使用 AMD 模块加载语法,以及如何在基于浏览器的应用程序中实现 Require。

首先,让我们使用“带有 TypeScript 的 HTML 应用程序”Visual Studio 模板创建一个简单的基于 TypeScript 的解决方案。如果您不使用 Visual Studio,那么只需创建一个新项目或基本源目录,并设置 TypeScript 编译环境。为了使用 AMD 编译,我们需要设置 TypeScript 项目属性,以便编译为 AMD 模块语法。

使用 NuGet,我们将安装以下包:

  • RequireJS

  • Requirejs.TypeScript.DefinitelyTyped

  • jQuery

  • jquery.TypeScript.DefinitelyTyped

  • JasmineTest

  • Jasmine.TypeScript.DefinitelyTyped

因此,我们还将基于 Backbone 构建我们的应用程序,因此我们需要以下 NuGet 包:

  • Backbone.js

  • Backbone.TypeScript.DefinitelyTyped

注意

Backbone 安装还将安装 Underscore,而Backbone.TypeScript.DefinitelyTyped包还将安装underscore.TypeScript.DefinitelyTyped

Backbone

Backbone 提供了一个非常简约的框架,用于编写丰富的客户端 JavaScript 应用程序。它使用 MVC 模式将我们的逻辑抽象出来,远离直接的 DOM 操作。Backbone 提供了一组核心功能,分为模型、集合和视图,以及一些辅助类来帮助处理事件和路由。库本身非常小,最小化的.js文件大小不到 20 KB。它的唯一依赖是 Underscore,这是一个实用库,大小不到 16 KB。Backbone 是一个非常受欢迎的库,有大量的扩展,并且相对容易学习和实现。

模型、集合和视图

在 Backbone 的核心是模型。模型是一个具有一组属性的类,代表将被视为一个单元的信息项。您可以将模型视为数据库表中的单行数据,或者作为保存特定类型信息的对象。模型对象通常非常简单,每个属性都有一些 getter 和 setter,可能还有一个用于 RESTful 服务的url:属性。模型的数组存储在集合中。集合可以被视为数据库表中的所有数据行,或者是相同类型的逻辑模型组。模型可以包含其他模型,也可以包含集合,因此我们可以自由地混合和匹配和组合集合和模型。

因此,模型用于定义我们的应用程序使用的数据结构。Backbone 为模型和集合都提供了一个简单的url:属性,用于将 Backbone 模型与 RESTful 服务同步。Backbone 将通过这个url:属性来生成对我们服务的创建、读取、更新和删除的 AJAX 调用。

一旦模型或集合被创建,它就会被传递给视图。Backbone 视图负责将模型的属性与 HTML 模板结合在一起。模板由普通 HTML 组成,但具有特殊的语法,允许将模型的属性注入到此 HTML 中。一旦将此 HTML 模板与模型结合,视图就可以将生成的 HTML 呈现到页面上。

Backbone 实际上并没有控制器的概念,就像经典的 MVC 定义中那样,但我们可以使用普通的 TypeScript 类来实现相同的功能。

创建模型

让我们立即深入 Backbone,并从定义模型开始。在此示例中,我们将使用联系人的概念——只有NameEmailAddress属性——如下所示。

请注意,ContactModel.ts文件位于/tscode/app/models目录下:

interface IContactModel {
    Name: string;
    EmailAddress: string;
}
export class ContactModel extends Backbone.Model
    implements IContactModel 
{
    get Name() {
        return this.get('Name');
    }
    set Name(val: string) {
        this.set('Name', val);
    }
    get EmailAddress() {
        return this.get('EmailAddress');
    }
    set EmailAddress(val: string) {
        this.set('EmailAddress', val);
    }
}

我们从定义一个名为IContactModel的接口开始,其中包含我们的NameEmailAddress属性,都是字符串。

接下来,我们创建了一个名为ContactModel的类,它派生自基类Backbone.Model。请注意,我们在类定义之前使用了export关键字,以指示给 TypeScript 编译器我们正在创建一个可以在其他地方导入的模块。export关键字和用法与我们之前使用 CommonJS 语法时完全相同。我们的ContactModel类实现了IContactModel接口,并且还使用了 ES5 的getset语法来定义NameEmailAddress属性。

注意

每个属性的实现都调用了 Backbone 的this.get('<propertyname>')this.set('<propertyname>', value)函数。Backbone 将模型属性存储为对象属性,并在内部使用这些getset函数与模型属性交互,因此之前使用的语法。

让我们遵循 TDD 实践,并编写一组单元测试,以确保我们可以正确地创建ContactModel的实例。对于这个测试,我们将在/tscode/tests/models目录下创建一个ContactModelTests.ts文件,如下所示:

import cm = require("../../app/models/ContactModel");
describe('/tests/models/ContactModelTests', () => {
    var contactModel: cm.ContactModel;
    beforeEach(() => {
        contactModel = new cm.ContactModel(	
            { Name: 'testName', EmailAddress: 'testEmailAddress'
            });
    });
    it('should set the Name property', () => {
        expect(contactModel.Name).toBe('testName');
    });
    it('should set the Name attribute', () => {
        expect(contactModel.get('Name')).toBe('testName');
    });
});

这个测试的第一行使用了我们之前见过的import <namespace> = require('<filename>')语法,导入了我们之前导出的ContactModel模块。您会注意到文件名使用了相对路径,它在指定"app/models/ContactModel"路径之前向下跨越了两个目录("../../")。这是因为 AMD 模块编译使用相对于当前文件的路径。由于我们的测试代码在/tscode/tests/models目录中,这个相对路径必须指向包含ContactModel.ts TypeScript 文件的正确目录。

我们的测试定义了一个名为contactModel的变量,它被强类型为cm.ContactModel类型。同样,我们使用了import语句中的前缀作为命名空间,以便引用导出的ContactModel类。我们的beforeEach函数然后创建了ContactModel类的一个实例,将一个具有NameEmailAddress属性的 JavaScript 对象传递给构造函数。

注意

我们在ContactModel类的构造函数中使用了 JSON 语法。这个语法与 RESTful 服务返回的数据非常接近,因此是一种方便的方式来构造类并在单个构造函数调用中分配属性。

我们的第一个测试检查contactModel.Name ES5 语法是否正确工作,并且将返回文本'testName'。第二个测试几乎相同,但是使用了.get('Name')内部 Backbone 属性语法,以确保我们的 TypeScript 类和 Backbone 类按预期工作。

require.config 文件

现在我们已经定义了一个Backbone.Model,并且为它编写了一个 Jasmine 测试,我们需要在浏览器中运行这个测试来验证我们的结果。通常,我们会创建一个 HTML 页面,然后在头部部分包含每个 JavaScript 文件的<script>标签。这就是 AMD 发挥作用的地方。我们不再需要在 HTML 中指定每个 JavaScript 文件。我们只需要包含一个 Require 的<script>标签(这是我们的模块加载器),它将自动协调加载我们需要的所有文件。

为此,让我们在/tests目录中创建一个SpecRunner.html文件,如下所示:

<!DOCTYPE html>
<html >
<head>
    <title>AMD SpecRunner</title>
    <link rel="stylesheet" 
          type="text/css" 
          href="/Scripts/jasmine/jasmine.css">
    <script
        data-main="/tscode/tests/TestConfig"
        type="text/javascript"
        src="img/require.js">
    </script>
</head>
<body>
</body>
</html>

这是一个非常简单的 HTML 文件。这里需要注意的是<script>标签加载了/Scripts/require.js。这个脚本标签有一个data-main属性,它设置为"/tscode/tests/TestConfig"data-main属性被传递给 Require,它告诉 Require 从哪里开始寻找我们的 JavaScript 文件。在前面的代码中,Require 将寻找一个名为/tscode/tests/TestConfig.js的文件。

我们将按照以下方式构建/tscode/tests/TestConfig.ts文件:

require.config(
    {
        baseUrl: "../../",
        paths: {
            'jasmine': '/Scripts/jasmine/jasmine',
            'jasmine-html': '/Scripts/jasmine/jasmine-html',
            'jasmine-boot': '/Scripts/jasmine/boot',
            'underscore' : '/Scripts/underscore',
            'backbone': '/Scripts/backbone',
            'jquery': '/Scripts/jquery-2.1.1',
        },
        shim: {
            underscore: {
                exports: '_'
            },
            backbone : {
                deps: ['underscore'],
                exports: 'Backbone'
            },
            'jasmine' : {
                exports: 'window.jasmineRequire'
            },
            'jasmine-html': {
                deps : ['jasmine'],
                exports: 'window.jasmineRequire'
            },
            'jasmine-boot': {
                deps : ['jasmine-html', 'backbone'],
                exports: 'window.jasmineRequire'
            }
        }
    }
);

var specs = [
    'tscode/tests/models/ContactModelTests'
];

require(['jasmine-boot'], (jb) => {
    require(specs, () => {
        (<any>window).onload();
    });
});

我们从调用require.config函数开始,并传递一个具有三个属性的 JavaScript 对象:baseUrlpathsshimbaseUrl属性告诉 Require 在查找 JavaScript 文件时要使用的基本目录。在示例应用程序中,我们的TestConfig.ts文件位于/tscode/tests目录中,因此我们的基本目录将是/

paths属性指定了我们 JavaScript 文件的完整路径,每个条目都有一个名称。在前面的示例中,脚本/Scripts/jasmine/jasmine.js被命名为'jasmine',并且可以在脚本的其余部分中被称为'jasmine'

注意

Require 会自动将.js附加到这些条目中,因此paths属性中的任何条目都不应包含文件条目中的.js

shim属性告诉 Require 关于paths属性中每个条目的更多细节。看一下backboneshim条目。它有一个deps属性,指定了 Backbone 的依赖关系。Backbone 依赖于 Underscore,因此必须在 Backbone 之前加载 Underscore。

exports属性告诉 Require 将库附加到指定为 exports 值的命名空间。因此,在我们之前的示例中,对 Underscore 的任何调用都必须在 Underscore 库中的任何函数调用之前加上_。例如,_.bindAll调用 Underscore 的bindAll函数。

require.configshim部分指定的依赖关系是递归的。如果我们看一下jasmine-boot的 shim,我们可以看到它依赖于jasmine-html,而jasmine-html又依赖于jasmine。Require 将确保在运行需要jasmine-boot的代码之前,按正确的顺序加载所有这些脚本。

接下来让我们看一下文件底部的require函数调用。这个调用有两个参数:需要加载的文件数组和一旦加载步骤完成后要调用的回调函数。这个回调函数对应于数组中每个文件条目的参数。因此,在前面的示例中,'jasmine-boot'将通过相应的参数jb提供给我们的函数。稍后我们将看到更多这方面的例子。

require函数的调用,每个调用都有其需要加载的文件数组和相应的回调参数,可以嵌套。在我们的示例中,我们在初始调用内嵌套了对 require 函数的第二次调用,但这次我们传入了specs数组并省略了回调参数。这个specs数组目前只包含我们的ContactModelTests文件。我们嵌套的匿名函数只是调用window.onload函数,这将触发 Jasmine 运行我们所有的测试。

注意

window.onload()的调用具有稍微奇怪的语法。在调用onload()函数之前,我们使用显式转换将window变量转换为<any>类型。这是因为 TypeScript 编译器期望将Event参数传递给onload()函数。我们没有事件参数,需要确保生成的 JavaScript 语法正确 - 因此转换为<any>

如果一切顺利,我们现在可以启动浏览器,并在/tscode/tests/SpecRunner.html页面上调用SpecRunner.html

修复 Require 配置错误

在使用 Require 开发 AMD 应用程序时,经常会出现意外行为、奇怪的错误消息或者空白页面。这些奇怪的结果通常是由 Require 的配置引起的,要么是在pathsshimdeps属性中。修复这些 AMD 错误一开始可能会令人沮丧,但通常是由两种情况引起的 - 不正确的依赖关系或file-not-found错误。

要修复这些错误,我们需要打开浏览器中的调试工具,大多数浏览器可以通过简单地按下F12来实现。

不正确的依赖关系

一些 AMD 错误是由require.config中的不正确依赖关系引起的。可以通过检查浏览器中的控制台输出来找到这些错误。依赖错误会生成类似以下的浏览器错误:

ReferenceError: jasmineRequire is not defined
ReferenceError: Backbone is not defined

这种类型的错误可能意味着 AMD 加载器在加载 Underscore 之前加载了 Backbone,例如。因此,每当 Backbone 尝试使用下划线函数时,我们会得到一个未定义错误,如前面的输出所示。修复这种类型的错误的方法是更新导致错误的库的deps属性。确保所有先决条件库都已在deps属性中命名,错误应该会消失。如果没有,那么错误可能是由下一种类型的 AMD 错误引起的,即文件未找到错误。

404 错误

文件未找到,或 404 错误通常由类似以下的控制台输出指示:

Error: Script error for: jquery
http://requirejs.org/docs/errors.html#scripterror
Error: Load timeout for modules: jasmine-boot
http://requires.org/docs/errors.html#timeout

要找出哪个文件导致了前面的错误,请切换到调试工具中的网络选项卡并刷新页面。查找 404(文件未找到)错误,如下面的截图所示:

404 错误

Firefox 网络选项卡显示 404 错误

在这个截图中,我们可以看到对jquery.js的调用生成了一个 404 错误,因为我们的文件实际上被命名为/Scripts/jquery-2.1.1.js。这种错误可以通过在require.configpaths参数中添加一个条目来修复,这样任何对jquery.js的调用都会被替换为对jquery-2.1.1.js的调用。

注意

Require 有一套很好的常见 AMD 错误文档(requirejs.org/docs/errors.html),以及包括循环引用在内的高级 API 用法,因此请务必查看该网站,了解可能的 AMD 错误的更多信息。

使用 Backbone.Collections

现在我们已经有了一个工作并经过测试的ContactModel,我们可以构建一个Backbone.Collection来容纳一组ContactModel实例。由于我们使用了 AMD,我们可以创建一个新的ContactCollection.ts文件,并添加以下代码:

import cm = require("./ContactModel")
export class ContactCollection
    extends Backbone.Collection<cm.ContactModel> {
    model = cm.ContactModel;
    url = "/tscode/tests/contacts.json";
}

创建一个Backbone.Collection相对简单。首先,我们像之前看到的那样importContactModel,并将其赋值给cm命名空间。然后我们创建了一个名为ContactCollection的类,它extendsBackbone.Collection,并使用了泛型类型cm.ContactModel。这个ContactCollection有两个属性:modelurlmodel属性告诉 Backbone 内部使用哪个模型类,url属性指向服务器端的 RESTful URL。当我们将数据与服务器同步时,Backbone 将为服务器端的 RESTful 调用生成正确的 POST、GET、DELETE 和 UPDATE HTTP 协议。在前面的示例中,我们只是返回一个硬编码的 JSON 文件,因为我们只会使用 HTTP GET。

如果我们打开 TypeScript 生成的结果 JavaScript 文件,我们会看到编译器已经对我们的文件进行了相当多的修改:

var __extends = this.__extends || function (d, b) {
    for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
    function __() { this.constructor = d; }
    __.prototype = b.prototype;
    d.prototype = new __();
};
define(["require", "exports", "./ContactModel"], function (require, exports, cm) {
    var ContactCollection = (function (_super) {
        __extends(ContactCollection, _super);
        function ContactCollection() {
            _super.apply(this, arguments);
            this.model = cm.ContactModel;
            this.url = "/tscode/tests/contacts.json";
        }
        return ContactCollection;
    })(Backbone.Collection);
    exports.ContactCollection = ContactCollection;
});
//# sourceMappingURL=ContactCollection.js.map

文件的前六行以var __extends开头,只是 TypeScript 在 JavaScript 中实现继承的方式,我们不需要过多关注它。

需要注意的行以define函数开头。TypeScript 已经将我们的类定义包裹在一个外部的define调用中。这个define函数现在有三个参数:requireexports./ContactModel。这个函数的语法和用法与我们在TestConfig.ts文件中自己编写的require函数调用完全相同。

第一个参数是要导入的文件数组,第二个参数是在这些文件加载完成后要调用的回调函数。同样,我们第一个数组中的每个元素在回调参数中都有对应的参数。TypeScript 会自动为我们添加"require""exports"参数,然后包含我们使用import关键字指定的任何文件。当 TypeScript 使用 AMD 语法编译我们的文件时,它会自动生成与 AMD 加载器(如 Require)兼容的 JavaScript 样式。

现在让我们为我们的ContactCollection编写一些单元测试:

import cc = require("../../app/models/ContactCollection");
import cm = require("../../app/models/ContactModel");
describe("/tests/models/ContactCollectionTests", () => {
    it("should create a collection", () => {
        var contactCollection = new cc.ContactCollection(
        [
            new cm.ContactModel(
              { Name: 'testName1', EmailAddress: 'testEmail1' }),
            new cm.ContactModel(
              { Name: 'testName2', EmailAddress: 'testEmail2' })
        ]);
        expect(contactCollection.length).toBe(2);
    });
});

这个测试以import语句开始,导入了ContactCollectionContactModel,因为我们将在这个测试中使用这两者。然后简单地创建一个新的ContactCollection,并传入一个包含两个新的ContactModels的数组。这个测试突出了如何通过编程方式创建一个新的ContactCollection并填充它。

现在让我们编写一个测试,通过url属性加载集合:

describe("contact json tests", () => {
    var collection: cc.ContactCollection;
    it("should load collection from url", () => {
        collection = new cc.ContactCollection();
        collection.fetch({ async: false });
        expect(collection.length).toBe(4);
    });
});

这个测试创建了一个新的ContactCollection,然后调用了fetch函数。

注意

我们传递了一个设置为falseasync标志,以强制 Backbone 使用同步调用服务器。换句话说,JavaScript 将在获取完成之前暂停,然后再继续执行下一行。我们本可以使用 Jasmine 的异步done语法来编写这个测试,但对于较小的测试,传递这个async标志使代码更容易阅读。

如前所述,fetch函数将使用url参数向提供的 URL 发出 GET HTTP 请求,在这种情况下,它只是加载contacts.json文件。该文件的内容如下:

[
    { "Name": "Mr Test Contact", 
       "EmailAddress": "mr_test_contact@test.com" },
    { "Name": "Mrs Test Contact", 
       "EmailAddress": "mrs_test_contact@test.com" },
    { "Name": "Ms Test Contact",
       "EmailAddress": "ms_test_contact@test.com" },
    { "Name": "Dr Test Contact", 
       "EmailAddress": "dr_test_contact@test.com" }
]

这个文件使用简单的 JSON 语法定义了四个联系人,每个联系人都有一个NameEmailAddress属性。让我们编写一些集成测试,以确保使用这个 JSON 的fetch函数实际上正确地创建了一个ContactCollection

describe("contact json model tests", () => {
    var collection: cc.ContactCollection;
    beforeEach(() => {
        collection = new cc.ContactCollection();
        collection.fetch({ async: false });
    });
    it("ContactModel at 0 should have attribute called Name", () => {
        var contactModel = collection.at(0);
        expect(contactModel.get('Name')).toBe('Mr Test Contact');
    });
    it("ContactModel at 0 should have property called Name", () => {
        var contactModel : cm.ContactModel = collection.at(0);
        expect(contactModel.Name).toBe('Mr Test Contact');
    });
});

在这个测试代码中,我们使用beforeEach函数用ContactCollection类的一个实例填充我们的集合变量,然后再次调用fetch函数,使用{async: false}标志。我们的第一个测试然后使用 Backbone 的at函数从索引0处的集合中检索第一个模型。然后我们使用 Backbone 的内部get函数检查返回的模型的'Name'属性。第二个测试使用我们ContactModel类的 ES5 语法,只是为了测试 Backbone 是否确实在其集合中存储了我们的ContactModel类的实例。

要将这些测试包含在我们的测试套件中,现在我们只需要修改TestConfig.ts文件,并在我们的 specs 数组中添加一个条目,如下所示:

var specs = [
    'tscode/tests/models/ContactModelTests',
    'tscode/tests/models/ContactCollectionTests'
];

Backbone 视图

现在我们有了一个用于存放我们的ContactModelsContactCollection,让我们创建一个Backbone.View,将这个集合渲染到 DOM 中。为了做到这一点,我们实际上会创建两个视图:一个视图用于集合中的每个项目,另一个视图用于集合本身。请记住,Backbone 视图将Backbone.Model与模板结合起来,以便将模型的属性呈现到 DOM 中。

我们将从视图开始,以渲染单个集合项(在本例中是单个ContactModel),称为ContactItemView

import cm = require("../models/ContactModel");
export class ContactItemView extends Backbone.View<cm.ContactModel> {
    template: (properties?: any) => string;
    constructor(options?: any) {
        this.className = "contact-item-view";
        this.template = _.template(
            '<p><%= Name %> (<%= EmailAddress %>)</p>');
        super(options);
    }
    render(): ContactItemView {
        this.$el.html(this.template(this.model.attributes));
        return this;
    }
}

这段代码片段以我们附加到cm命名空间的ContactModel类的import开始。然后我们创建了一个名为ContactItemView的类,它extendsBackbone.View。与我们用于集合的通用语法类似,这个视图类也使用ContactModel作为其通用实例的类型。最后,我们导出这个类,使其作为 AMD 模块对我们的代码可用。

ContactItemView类有一个名为template的公共属性,它是一个返回字符串的函数。这个函数将模型的属性作为输入参数。template函数在构造函数的第二行被赋值为调用 Underscore 的_.template( … )函数的结果。如果我们仔细看一下这个模板函数中使用的字符串,我们会发现它是一个 HTML 字符串,它使用<%= propertyName %>语法将 Backbone 模型的属性注入到 HTML 中。我们还指定了 DOM 的className应该设置为"contact-item-view"。最后,我们使用传递给构造函数的options参数调用基类构造函数。

那么,我们在这里做了什么?我们创建了一个Backbone.View类,指定了它的className,并设置了视图应该用来将其模型呈现到 DOM 的template。我们需要的最后一段代码是render函数本身。这个render函数在一行中做了几件事情。首先,每个 Backbone 视图都有一个$el属性,它保存着 DOM 元素。然后我们在这个元素上调用html函数来设置它的 HTML,并传入template函数的调用结果。按照惯例,render函数总是返回this,以便调用类在调用render函数后使用流畅的语法。

注意

Backbone 可以与许多模板引擎一起使用,例如 Handlebars(handlebarsjs.com/)和 Moustache(github.com/janl/mustache.js/)。在这个示例中,我们将坚持使用 Underscore 模板引擎。

现在我们已经定义了Backbone.View,我们可以为其编写一个简单的测试:

import cm = require("../../app/models/ContactModel");
import ccv = require("../../app/views/ContactItemView");
describe("/tscode/tests/views/ContactItemViewTests", () => {
    it("should generate html from template and model", () => {
        var contactModel = new cm.ContactModel(
            { Name: 'testName', EmailAddress: 'testEmailAddress' });

        var contactItemView = new ccv.ContactItemView(
            { model: contactModel });
        var html = contactItemView.render().$el.html();

        expect(html).toBe('<p>testName (testEmailAddress)</p>');
    });
});

这段代码片段以ContactModelContactItemView的导入开始。这个套件中只有一个测试,而且非常简单。首先,我们创建一个ContactModel的实例,在构造函数中设置NameEmailAddress属性。然后我们创建ContactItemView类的一个实例,并将我们刚刚创建的模型作为构造函数的参数传递。请注意我们在构造函数中使用的语法:{ model: contactModel }。Backbone 视图可以以几种不同的方式构造,我们在构造时设置的属性-在这种情况下是model属性-通过我们的构造函数中的super()函数调用传递给基本的 Backbone 类。

我们的测试然后在contactItemView实例上调用render函数。请注意,我们直接引用了视图的$el属性,并调用了html函数-就好像它是一个 jQuery DOM 元素一样。这就是为什么所有render函数都应该返回this的原因。

我们的测试然后检查render函数的结果是否生成了我们根据模板和我们的模型属性所期望的 HTML。

使用 Text 插件

然而,在我们的视图中硬编码 HTML 将使我们的代码难以维护。为了解决这个难题,我们将使用一个名为 Text 的 Require 插件。Text 使用正常的 require 语法,只是使用'text!"前缀从站点加载文件以在我们的代码中使用。要通过 NuGet 安装此插件,只需键入:

Install-package RequireJS.Text

要使用 Text,我们首先需要在require.config paths属性中列出text,如下所示:

paths: {
    // existing code
    'text': '/Scripts/text'
},

然后我们可以修改我们在TestConfig.ts中对require的调用如下:

var CONTACT_ITEM_SNIPPET = "";
require(
    ['jasmine-boot',
     'text!/tscode/app/views/ContactItemView.html'],
    (jb, contactItemSnippet) => {
        CONTACT_ITEM_SNIPPET = contactItemSnippet;
        require(specs, () => {
            (<any>window).onload();
        });
    });

在这段代码片段中,我们创建了一个名为CONTACT_ITEM_SNIPPET的全局变量来保存我们的片段,然后我们在调用require时使用'text!<path to html>'语法来包含我们需要加载的 HTML 文件。同样,我们在require函数调用的数组中的每个项目都在我们的匿名函数中有一个对应的变量。

这样,Require 将加载在/tscode/app/views/ContactItemView.html找到的文本,并通过字符串作为contactItemSnippet参数传递给我们的函数。然后我们可以将全局变量CONTACT_ITEM_SNIPPET设置为这个值。然而,在运行这段代码之前,我们需要修改我们的ContactItemView来使用这个变量。

constructor(options?: any) {
    this.className = "contact-item-view";
    this.events = <any>{ 'click': this.onClicked };
    this.template = _.template(CONTACT_ITEM_SNIPPET);

    super(options);
}

在前面的代码中改变的行是使用全局变量CONTACT_ITEM_SNIPPET的值调用_.template函数,而不是使用硬编码的 HTML 字符串。

我们需要做的最后一件事是创建ContactItemView.html文件本身,如下所示:

<div class="contact-outer-div">
    <div class="contact-name-div">
        <%= Name %>
    </div>
    <div class="email-address-div">
        (<%= EmailAddress %>)
    </div>
</div>

这个 HTML 文件使用了与之前相同的<%= propertyName %>语法,但是现在我们可以很容易地扩展我们的 HTML,包括外部的divs,并为每个属性分配自己的 CSS 类,以便稍后进行一些样式设置。

然而,现在运行我们的测试将会破坏我们的ContactItemViewTests,因为我们使用的 HTML 已经被更改了。让我们现在修复这个破损的测试:

//expect(html).toBe('<p>testName (testEmailAddress)</p>');
expect(html).toContain('testName');
expect(html).toContain('testEmailAddress');

我们已经注释了有问题的行,并使用.toContain匹配器来确保我们的 HTML 已经正确地注入了模型属性,而不是寻找html字符串值的精确匹配。

渲染一个集合

现在我们有了一个用于渲染单个联系人项目的视图,我们需要另一个视图来渲染整个ContactCollection。为此,我们简单地为我们的集合创建一个新的Backbone.View,然后为集合中的每个项目创建一个新的ContactItemView实例,如下所示:

import cm = require("../models/ContactModel");
import civ = require("./ContactItemView");
export class ContactCollectionView extends Backbone.View<Backbone.Model> {
    constructor(options?: any) {
        super(options);
        _.bindAll(this, 'renderChildItem');
    }

    render(): ContactCollectionView {
        this.collection.each(this.renderChildItem);
        return this;
    }
    renderChildItem(element: Backbone.Model, index: number) {
        var itemView = new civ.ContactItemView( { model: element });
        this.$el.append(itemView.render().$el);
    }
}

我们从ContactModelContactItemView模块导入开始这个代码片段。然后我们创建了一个扩展了Backbone.ViewContactCollectionView,这次使用了一个基本的Backbone.Model来进行通用的语法。我们的constructor简单地通过super函数调用将它接收到的任何options传递给基本视图类。然后我们调用了一个 Underscore 函数命名为bindAll。Underscore 的bindAll函数是一个实用函数,用于在类函数中绑定this的作用域到正确的上下文。让我们稍微探索一下代码,以使这一点更清楚。

render函数将被ContactCollectionView的用户调用,并简单地为它的集合中的每个模型调用renderChildItem函数。this.collection.each接受一个参数,这个参数是一个回调函数,用于对集合中的每个模型进行调用。我们可以将这段代码写成如下形式:

render(): ContactCollectionView {
    this.collection.each(
        (element: Backbone.Model, index: number) => {
// include rendering code within this anonymous function
        }
    );
    return this;
}

这个版本的相同代码在each函数内部使用了一个匿名函数。然而,在我们之前的代码片段中,我们已经将renderChildItem写成了一个类函数,而不是使用匿名函数。由于 JavaScript 的词法作用域规则,这种细微的变化意味着this属性现在将指向函数本身,而不是类实例。通过使用_.bindAll(this,'renderChildItem'),我们已经将变量this绑定为所有对renderChildItem的调用的类实例。然后我们可以在renderChildItem函数内部使用this变量,this.$el将正确地作用于ContactCollectionView类的实例。

现在对这个ContactCollectionView类进行一些测试:

import cc = require("../../app/models/ContactCollection");
import cm = require("../../app/models/ContactModel");
import ccv = require("../../app/views/ContactCollectionView");
describe("/ts/views/ContactCollectionViewTests", () => {
    var contactCollection: cc.ContactCollection;
    beforeAll(() => {
        contactCollection = new cc.ContactCollection([
            new cm.ContactModel(
                { Name: 'testName1', EmailAddress: 'testEmail1' }),
            new cm.ContactModel(
                { Name: 'testName2', EmailAddress: 'testEmail2' })
        ]);
    });

    it("should create a collection property on the view", () => {
        var contactCollectionView = new ccv.ContactCollectionView({
            collection: contactCollection
        });
        expect(contactCollectionView.collection.length).toBe(2);
    });
});

在这个代码片段中,importbeforeAll函数应该很容易理解,所以让我们专注于实际测试的主体。首先,我们创建了一个ContactCollectionView实例,并通过构造函数中的{ collection: contactCollection}属性将这个contactCollection实例传递给它。使用单个项目的 Backbone 视图使用{ model: <modelName> }属性,而使用集合的视图使用{ collection: <collectionInstance> }属性。我们的第一个测试简单地检查内部的collection属性是否确实包含一个length2的集合。

现在我们可以写一个测试,检查当我们在ContactCollectionView上调用render函数时,renderChildItem函数是否被调用:

it("should call render on child items", () => {
    var contactCollectionView = new ccv.ContactCollectionView({
        collection: contactCollection
    });
    spyOn(contactCollectionView, 'renderChildItem');
    contactCollectionView.render();

 expect(contactCollectionView.renderChildItem).toHaveBeenCalled();
});

这个测试创建了一个视图,就像我们之前看到的那样,然后在renderChildItem函数上创建了一个间谍。为了触发调用这个函数,我们在视图实例上调用render函数。最后,我们只是检查我们的间谍是否被调用了。

接下来,我们可以写一个快速测试,看看render函数生成的 HTML 是否包含我们集合模型的属性:

it("should generate html from child items", () => {
    var contactCollectionView = new ccv.ContactCollectionView({
        collection: contactCollection
    });
    var renderedHtml = contactCollectionView.render().$el.html();
    expect(renderedHtml).toContain("testName1");
    expect(renderedHtml).toContain("testName2");

});

这个测试与我们的ContactItemView渲染测试非常相似,但是使用了ContactCollectionViewrender函数。

创建一个应用程序

有了这两个 Backbone 视图,我们现在可以构建一个简单的类来协调我们集合的加载和完整集合的渲染到 DOM 中:

import cc = require("tscode/app/models/ContactCollection");
import cm = require("tscode/app/models/ContactModel");
import civ = require("tscode/app/views/ContactItemView");
import ccv = require("tscode/app/views/ContactCollectionView");
export class ContactViewApp {
    run() {
        var contactCollection = new cc.ContactCollection();
        contactCollection.fetch(
            {
                success: this.contactCollectionLoaded,
                error: this.contactCollectionError
            });
    }

    contactCollectionLoaded(model, response, options) {
        var contactCollectionView = new ccv.ContactCollectionView(
            {
                collection: model
            });
        $("#mainContent").append(
            contactCollectionView.render().$el);
    }
    contactCollectionError(model, response, options) {
        alert(model);
    }
}

我们的代码从各种模块的导入开始。然后我们创建了一个名为ContactViewApp的类定义,在这个类中,有一个名为run的方法。这个run方法简单地创建了一个新的ContactCollection,并调用fetch来触发 Backbone 加载集合。这次调用fetch然后定义了一个success和一个error回调,每个都设置为类内部的相关函数。

ContactCollection成功返回时,Backbone 将调用contactCollectionLoaded函数。在这个函数中,我们简单地创建一个ContactCollectionView,然后使用 jQuery 将通过render函数返回的 HTML 附加到 DOM 元素"#mainContent"上。

现在我们可以创建一个网页来把所有东西放在一起。我们的 HTML 页面的内容现在应该如下所示:

<!DOCTYPE html>
<html >
<head>
    <title>Contacts View</title>
    <link rel="stylesheet" type="text/css"
          href="/css/app.css">
    <script data-main="/tscode/app/AppConfig"
            type="text/javascript"
            src="img/require.js"></script>

</head>
<body>
    <div id="mainContent"></div>
</body>
</html>

这个页面与我们之前用于运行测试的页面非常相似。我们包含了一个app.css链接以允许一些样式,然后调用 Require 并使用一个新的配置文件,名为/tscode/app/AppConfig。我们还在 body 标签内有一个 id 为mainContentdiv,用来容纳我们的ContactViewApp返回的渲染 HTML。现在我们需要创建我们的AppConfig.ts文件供 Require 使用,如下所示:

require.config(
    {
        baseUrl: "../../",
        paths: {
            'underscore': '/Scripts/underscore',
            'backbone': '/Scripts/backbone',
            'jquery': '/Scripts/jquery-2.1.1',
            'ContactViewApp': '/tscode/app/ContactViewApp',
            'text': '/Scripts/text'
        },
        shim: {
            underscore: {
                exports: '_'
            },
            backbone: {
                deps: ['underscore'],
                exports: 'Backbone'
            }
            ,ContactViewApp: {
                deps: ['backbone']
            }
        }
    }
);

var CONTACT_ITEM_SNIPPET = "";

require([
    'ContactViewApp',
    'text!/tscode/app/views/ContactItemView.html'
    ], (app, contactItemSnippet) => {

    CONTACT_ITEM_SNIPPET = contactItemSnippet;
    var appInstance = new app.ContactViewApp();
    appInstance.run();
});

在这段代码片段中要注意的第一件事是,我们现在已经在我们的ContactViewApp中包含了一个paths引用。ContactViewApp的相应shim条目指定它依赖于backbone。同样,我们有一个名为CONTACT_ITEM_SNIPPET的全局变量,然后我们调用require函数来加载我们的ContactViewApp类,以及 HTML 片段。还要注意,我们能够通过匿名函数中的app参数引用我们的ContactViewApp,并且通过contactItemSnippet参数引用 HTML。要运行应用程序,我们只需创建ContactViewApp类的一个实例,并调用run方法。

现在我们应该能够看到我们所有辛苦工作的结果了:

创建一个应用程序

使用 Require.js 运行的 Backbone 应用程序

使用 jQuery 插件

完成我们的应用程序,让我们使用一个名为flip的 jQuery 插件(lab.smashup.it/flip/),触发一个动画来旋转或翻转项目的外部div。Flip 是一系列可以应用于我们应用程序元素的 jQuery 插件的典型代表。然而,在触发 Flip 动画之前,我们需要在ContactItemView中响应用户的点击事件,如下所示:

import cm = require("../models/ContactModel");

export class ContactItemView extends Backbone.View<cm.ContactModel> {
    template: (properties?: any) => string;
    constructor(options?: any) {
        this.className = "contact-item-view";
        this.events = <any>{ 'click': this.onClicked };
        this.template = _.template(CONTACT_ITEM_SNIPPET);
        super(options);
    }

    render(): ContactItemView {
        this.$el.html(this.template(this.model.attributes));
        return this;
    }

    onClicked() {
        alert('clicked : ' + this.model.Name);
    }
}

在这段代码片段中,我们现在在我们的ContactItemView类中添加了一个onClicked函数,简单地弹出一个alert。请注意,我们能够引用视图类的model属性,以便从该类实例创建时使用的底层Backbone.Model中读取属性。在constructor中,我们还将this.events设置为一个具有一个属性'click'的 JavaScript 对象。

'click'属性设置为我们的onClicked函数,并在ContactItemView DOM 元素接收到用户点击事件时调用。有了这个设置,每当我们在页面上点击渲染的元素时,我们将收到一个警报弹窗:

使用 jQuery 插件

点击事件显示模型属性的警报弹窗

现在我们可以转向使用 Flip jQuery 插件。Flip 依赖于 jQuery 和 jQueryUI,因此我们需要从 NuGet 安装 jQueryUI,如下所示:

Install-package jQuery.UI.Combined

Flip 本身没有 NuGet 包,因此需要下载并以传统的方式将其包含在我们的项目中。Flip 也没有 DefinitelyTyped 定义,因此我们需要在项目中包含一个如下所示的定义:

interface IFlipOptions {
    direction: string;
    onBefore?: () => void;
    onAnimation?: () => void;
    onEnd?: () => void;
    speed?: number;
    color?: string;
    content?: string;
}
interface JQuery {
    flip(input: IFlipOptions): JQuery;
    revertFlip();
}

Flip 插件的声明文件非常简单,是从网站上的文档生成的。由于 Flip 是一个 jQuery 插件,它可以在通过$( )符号引用的任何 jQuery 对象上使用。因此,我们必须使用我们自己的extend JQuery 类型定义 - 因此我们创建了带有我们两个新函数fliprevertFlip的 jQuery 接口。Flip 的输入已被定义为IFlipOptions接口,根据网站文档构建。

要在 Require 中加载此库,我们修改对require.config的调用如下:

require.config(
    {
        baseUrl: "../../",
        paths: {
            'underscore': '/Scripts/underscore',
            'backbone': '/Scripts/backbone',
            'jquery': '/Scripts/jquery-2.1.1',
            'ContactViewApp': '/tscode/app/ContactViewApp',
            'text': '/Scripts/text',
            'jqueryui': '/Scripts/jquery-ui-1.11.2',
            'jqueryflip' : '/Scripts/jquery.flip'
        },
        shim: {
            underscore: {
                exports: '_'
            },
            backbone: {
                deps: ['underscore'],
                exports: 'Backbone'
            }
            ,jqueryui: {
                deps: ['jquery']
            }
            ,jqueryflip: {
                deps: ['jqueryui'],
                exports: '$'
            }
            ,ContactViewApp: {
                deps: ['backbone'
                    , 'jqueryflip'
                ]
            }
        }
    }
);

在这里,我们已经向我们的路径对象添加了两个条目:jqueryuijqueryflip。然后,我们添加了相应的shim条目并指定了相关的依赖关系。这里需要注意的一行是jqueryflip上的exports属性。我们指定它必须导出到$符号。这是默认的 jQuery 选择器符号,所有 jQuery 插件必须导出到$符号,以便在使用 Require 时正确定义。我们对代码的最终更改是在ContactItemView的点击事件上使用flip函数,如下所示:

onClicked() {
    this.$el.flip({
        direction: 'tb',
        speed : 200
    });
}

在这里,我们引用了Backbone.View中的$el元素,这是 jQuery 选择器的简写语法。然后我们调用flip函数,并指定从上到下翻转,持续 200 毫秒。现在运行我们的页面,点击联系人元素将触发翻转动画:

使用 jQuery 插件

Flip.js 在操作中翻转 div 元素

为 Bentham Chang 准备,Safari ID bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

摘要

在本章中,我们已经研究了使用模块 - 包括 CommonJs 和 AMD。我们探讨了在 Node 应用程序中使用的 CommonJS 模块,并讨论了在 TypeScript 中创建和使用这些模块。然后,我们转向基于浏览器的模块,并探讨了与 Require 相关的 AMD 编译的使用。我们构建了一个非常简单的基于 Backbone 的应用程序,包括 Jasmine 单元测试,然后研究了在 Require 中使用 Text 插件。我们还整合了一个名为 Flip 的第三方 jQuery 插件,以在用户界面上提供一些动画。在下一章中,我们将探讨一些面向对象的编程原则,并研究依赖注入和领域事件。

为 Bentham Chang 准备,Safari ID bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第八章:使用 TypeScript 进行面向对象编程

1995 年,四人帮GoF)出版了一本名为设计模式:可复用面向对象软件的元素的书。在这本书中,作者 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 描述了许多经典的软件设计模式。这些模式提供了常见软件问题的简单而优雅的解决方案。如果你从未听说过工厂模式、组合模式、观察者模式或单例模式等设计模式,那么强烈建议阅读这本 GoF 书籍。

GoF 提出的设计模式已经在许多不同的编程语言中复制,包括 Java 和 C#。Mark Torok 甚至将这些模式移植到了 TypeScript 中,他的 GitHub 存储库可以在github.com/torokmark/design_patterns_in_typescript找到。我们已经在第三章接口、类和泛型中探讨了其中的一个模式,即工厂设计模式,Mark 的工作为 TypeScript 中的所有 GoF 模式提供了快速简单的参考实现。

Simon Timms 还出版了一本名为精通 JavaScript 设计模式的书,Packt Publishingwww.packtpub.com/application-development/mastering-javascript-design-patterns),该书为读者逐一介绍了这些模式,何时使用它们以及如何使用它们。

在本章中,我们不会涵盖标准的 GoF 设计模式,而是看一看另外两种流行的设计模式以及它们如何在 TypeScript 中实现。我们将讨论使用服务定位器模式进行依赖注入,然后看看这些技术如何用于构建领域事件模式的实现。

按接口编程

四人帮坚持的主要观念之一是,程序员应该“按接口编程,而不是按实现编程”。这意味着程序是使用接口作为对象之间定义的交互来构建的。通过按接口编程,客户对象不知道其依赖对象的内部逻辑,并且更具有抵抗变化的能力。

TypeScript 语言带来了interface关键字,使我们能够以比标准 JavaScript 更简单的方式针对接口编写面向对象的代码。不过,请记住,接口只是 TypeScript 的概念,会在生成的 JavaScript 中被编译掉。

请注意,许多其他语言都有能够询问对象以查看它们实现了哪些接口的概念,这个过程称为反射

SOLID 原则

“按接口编程”原则的延伸是所谓的 SOLID 设计原则,基于 Robert Martin 的思想。这是五个不同编程原则的首字母缩写,无论何时讨论面向对象编程,都值得一提。单词 SOLID 中的每个字母都与一个面向对象原则相关,如下所示:

  • S:单一职责

  • O:开闭原则

  • L:里氏替换

  • I:接口隔离

  • D:依赖反转

单一职责

单一职责原则的理念是,一个对象应该只有一个职责,或者说只有一个存在的理由。换句话说,做一件事,并且做好。我们在上一章中已经看到了这个原则的例子,在我们使用 Backbone 时。Backbone 模型类用于表示单个模型。Backbone 集合类用于表示这些模型的集合,Backbone 视图类用于渲染模型或集合。

开闭原则

开闭原则的理念是,一个对象应该对扩展开放,但对修改关闭。换句话说,一旦为一个类设计了接口,随着时间的推移对这个接口的更改应该通过继承来实现,而不是直接修改接口。

请注意,如果您正在编写通过 API 由第三方使用的库,则此原则至关重要。对 API 的更改应仅通过新的、有版本的发布进行,并且不应破坏现有的 API 或接口。

里斯科夫替换

里斯科夫替换原则规定,如果一个对象是从另一个对象派生的,那么这些对象可以相互替换而不会破坏功能。虽然这个原则似乎很容易实现,但在处理与更复杂类型相关的子类型规则时,比如对象列表或对象上的操作时,情况可能会变得非常复杂——这些通常出现在使用泛型的代码中。在这些情况下,引入了变异的概念,对象可以是协变的、逆变的或不变的。我们不会在这里讨论变异的细节,但在编写库或使用泛型的代码时,请记住这个原则。

接口分离

接口分离原则的理念是,许多接口比一个通用接口更好。如果我们将这个原则与单一责任原则联系起来,我们将开始将我们的接口视为谜题的小部分,这些小部分将被组合在一起,以创建更广泛的应用程序功能。

依赖反转

依赖反转原则规定,我们应该依赖于抽象(或接口),而不是具体对象的实例。同样,这与“根据接口而不是实现编程”的原则相同。

构建服务定位器

服务定位器模式的理念是,应用程序的某些区域可以被分解为服务。每个服务都应遵循我们的 SOLID 设计原则,并提供一个作为服务 API 的小外部接口。应用程序使用的每个服务都会在服务定位器中注册。当应用程序需要特定的信息或功能时,它可以查询这个服务定位器,以找到基于服务接口的正确服务。

问题空间

在上一章中,我们探讨了 Backbone,我们的应用程序被分解为模型、集合和视图。除了这些元素之外,我们还有一个应用程序类来协调通过集合加载数据,并使用视图呈现此集合。一旦我们构建了应用程序类,谜题的最后一块就是组合require.config对象,以协调加载我们的 AMD 模块、应用程序中需要的任何 HTML 和我们的 jQuery 插件。

如果我们看一下应用程序加载哪些文件的视觉表示,我们会得到以下内容:

问题空间

应用对象依赖树

我们从一个名为ContactViewApp.html的 HTML 页面开始,这是我们应用程序的主入口页面,将提供给 Web 浏览器。然后这个 HTML 页面加载 Require 库,Require 库又加载包含require.config部分的AppConfig.ts文件。然后require.config部分指示 Require 从/Scripts/目录加载各种脚本,以及通过 Text 插件加载一小段 HTML。一旦 Require 加载了所有文件,AppConfig.ts文件的最后一部分加载ContactViewApp.ts,然后加载我们的ContactCollection.tsContactCollectionView.ts文件。然后这两个文件指示 Require 分别加载名为ContactModel.tsContactItemView.ts的模块文件。

如果我们更仔细地看一下这个层次结构,很容易想象在一个大型应用程序中,我们会有大量的集合、模型、视图和项目视图。可能我们正在加载集合的集合,以及包含子视图的视图,其中包含进一步的子视图。每个这些视图都需要通过文本插件加载一些 HTML,以使用我们的模板机制。

让我们更仔细地看一下在我们之前的例子中如何加载和使用 HTML 片段:

问题空间

使用全局变量的依赖树

在这个图中,我们可以看到我们通过文本插件在AppConfig.ts文件中加载了一个 HTML 片段,然后将其存储到名为CONTACT_ITEM_SNIPPET的全局变量中。唯一使用这个全局变量的代码是ContactItemView类本身。

使用全局变量违反了我们的依赖反转原则,因为我们在编程时针对一个全局变量的具体实例,而不是一个接口。这个全局变量也可能被任何正在运行的代码无意中改变,这可能导致我们的视图停止工作。当运行我们的测试套件时,我们遇到的另一个问题是,更改原始的 HTML 模板会破坏一些单元测试。虽然我们能够稍微修改测试以通过,但这个破损的测试突显出我们在某个地方违反了开闭原则。

创建一个服务

我们将分两部分解决使用全局变量存储 HTML 片段的问题。

首先,让我们定义一个服务来替换我们的全局变量 - SnippetService。这个服务将有一个非常简单的接口,只负责两件事:存储 HTML 片段和检索 HTML 片段。

其次,我们需要一种机制来获取这个SnippetService,在我们存储片段的代码点(在AppConfig.ts中)和使用片段的代码点(在ContactItemView.ts中)。我们稍后将在这两个接触点使用服务定位器,但现在,让我们为我们的片段服务设计一个设计。

引入SnippetService会改变我们的依赖图如下:

创建一个服务

使用服务存储 HTML 片段的依赖树

我们可以看到,我们现在已经抽象出了对全局变量的使用。我们仍然有一个全局区域来存储这些 HTML 片段,即片段存储区,但我们现在是针对一个接口编程 - SnippetService提供的接口,而不是针对一个具体的实现。我们的应用程序现在受到了对这些 HTML 片段的内部存储的任何更改的保护。例如,我们可能决定从使用 HTML 文件改为在数据库中存储 HTML 片段。在这种情况下,只需要修改SnippetService的内部,我们的代码就可以继续运行而无需更改。

显然,我们需要一种键来允许我们存储多个片段,但SnippetService是否应该负责定义这个键呢?考虑单一职责原则。SnippetService是否真的负责管理与片段相关的键?换句话说,它需要添加或删除这些键吗?并不是真的。一个更小的枚举类在这里会非常有用,并且更倾向于许多较小的接口而不是一个通用接口 - 考虑接口隔离。

考虑到这些事情,我们可以定义SnippetService的接口如下:

enum SnippetKey {
    CONTACT_ITEM_SNIPPET,
    OTHER_SNIPPET,
}

interface ISnippetService {
    storeSnippet(key: SnippetKey, value: string): void;
    retrieveSnippet(key: SnippetKey): string;
}

首先,我们定义了一个名为SnippetKeyenum,用于存储SnippetService要使用的所有键。其次,我们定义了实际SnippetService的接口,名为ISnippetService,它有两个函数。第一个函数将是一个存储片段的方法,名为storeSnippet。这个函数有两个参数,第一个是SnippetKey枚举值,第二个参数当然是 HTML 片段本身。类似地,第二个函数,名为retrieveSnippet,使用一个SnippetKey参数来检索 HTML 片段。

现在我们已经定义了一个接口,我们可以创建SnippetService类的结构:

class SnippetService implements ISnippetService {
    public storeSnippet(key: SnippetKey, value: string) {
    }
    public retrieveSnippet(key: SnippetKey) {
        return "";
    }
}

在这里,我们有一个名为SnippetService的类,它实现了我们的ISnippetService接口。我们已经创建了接口中定义的两个方法,但尚未提供实现。我们将利用这个机会遵循 TDD 原则,在编写使测试通过的代码之前编写一个失败的单元测试。我们的单元测试如下:

describe("/tscode/tests/services/SnippetServiceTests.ts", () => {
    it("should store a snippet", () => {
        var snippetService = new SnippetService();
        snippetService.storeSnippet(
            SnippetKey.CONTACT_ITEM_SNIPPET, "contact_snippet");
        expect(
            snippetService.retrieveSnippet(
                SnippetKey.CONTACT_ITEM_SNIPPET)
        ).toBe("contact_snippet");
    });
});

在这个测试中,我们只是创建了一个SnippetService的实例,使用SnippetKey.CONTACT_ITEM_SNIPPET作为键存储了一个片段,然后使用相同的键调用retrieveSnippet,验证返回的字符串值。请记住,这是一个模拟测试,在真实应用中,storeSnippet调用将在应用初始化期间发生,而retrieveSnippet调用将在稍后的阶段发生。

现在让我们完善SnippetService,使测试通过:

class SnippetService implements ISnippetService {
    private snippetArray: string[] = new Array();
    public storeSnippet(key: SnippetKey, value: string) {
        this.snippetArray[key] = value;
    }
    public retrieveSnippet(key: SnippetKey) {
        if (!this.snippetArray[key]) {
            throw new Error(
                "SnippetService no snippet with key :" + key);
        }
        return this.snippetArray[key];
    }
}

我们的SnippetService类现在有一个名为snippetArray的内部字符串数组,标记为private,它将保存我们的 HTML 片段值。我们的storeSnippetretrieveSnippet函数现在只是简单地从这个数组中存储或检索值。有了这段代码,我们的测试现在将通过,我们简单的SnippetService完成了。

依赖解析

到目前为止,我们已经重构了我们的代码,使其依赖于接口而不是具体对象。这一切都很好,但引出了一个问题:“我们如何获得一个接口?”- 或者更正确地说 - “我们如何获得当前实现这个接口的具体类?”这是依赖注入器试图回答的基本问题。

类可以获得实现接口的另一个类的方式有很多种。

服务定位

如果类本身根据接口请求一个具体对象,那么这个过程称为“服务定位”。换句话说,类使用注册表或助手来定位它需要的服务。您还可以将这种技术描述为“依赖请求”。一个中央注册表保存了所有已注册类与它们各自接口的查找表。当接口被请求时,服务定位器简单地查找其表中存储的接口对应的类实例,并从其注册表返回对象。

依赖注入

如果创建类的实例的行为可以交给某种框架处理,那么这个框架可以找出类需要什么接口,并在类实例化期间“注入”这些依赖关系。这种依赖注入也称为装配。在这种情况下,装配器类或框架需要能够查询对象以找出它依赖的接口。不幸的是,在 JavaScript 或 TypeScript 中我们没有这种能力,因为所有接口都被编译掉了。因此,我们不能单独使用 TypeScript 接口来实现依赖注入。如果我们要在 TypeScript 或 JavaScript 中实现依赖注入,我们需要一种命名约定来告诉装配器框架我们需要一个具体对象来替换接口。

依赖注入也被称为控制反转,因为我们把类的创建和依赖项的解析控制权交给了第三方。当我们收到类的实例时,所有的服务或依赖项都已经被“神奇”地填充进去了。

服务定位与依赖注入

服务定位模式的想法最早是由马丁·福勒在 2004 年左右提出的,在一篇名为《控制反转容器和依赖注入模式》的博客中(martinfowler.com/articles/injection.html)。然而,在他的书《.NET 中的依赖注入》中,马克·西曼认为服务定位模式实际上是一种反模式。

马克对马丁最初的想法是,使用服务定位很容易引入运行时错误,或者误解特定类的使用。这是因为找出一个类使用了哪些服务意味着要阅读整个类。他认为更好的使用依赖注入的方法是,在类的构造函数中列出所有的依赖项,并让服务定位器在类构造过程中解析每个依赖项。马克的大部分例子似乎都围绕着构建和使用 API,其中特定类的内部不能简单地从代码中读取,并且在不知道一个类依赖于哪些服务的情况下使用一个类很容易引起运行时错误。

尽管他的想法确实是正确的,但是解决这个问题的方法都与.NET 语言相关,而这在 JavaScript 中是不可用的,这就是反射。反射是程序在运行时询问对象自身信息的能力,比如它有哪些属性,它实现或期望实现哪些接口。尽管 TypeScript 提供了接口关键字,并对这些接口进行了编译时检查,但所有接口都在生成的 JavaScript 中被编译掉了。

这给我们带来了一个严重的问题。如果一个类依赖于一个接口,我们不能在运行时使用这个接口来查找接口的具体实现,因为在运行时,这个接口根本不存在。

Angular 使用命名约定(以$前缀)来提供依赖注入功能。这已经相当成功,尽管在使用缩小程序时会有一些注意事项和一些解决方法。Angular 2.0 也通过提供自定义语法来解决这个问题,以表示需要注入依赖项的位置。其他 JavaScript 框架,如 ExtJs,提供了使用全局创建例程来创建对象的机制,然后允许框架注入依赖项。不幸的是,这种 ExtJs 技术与 TypeScript 语法不太兼容(参见第五章,“第三方库”中我们讨论了 ExtJs)。

此外,如果我们不使用 Angular、Angular 2.0、ExtJs 或任何其他框架,那么在标准 JavaScript 中依赖注入就略微超出了我们的能力。另一方面,服务定位是可以实现的,并且结合 TypeScript 接口,可以为我们带来依赖项解析的所有好处,因此也可以实现模块化编程。

我们也可以做出妥协,以纳入马克建议的想法,并将我们的服务定位限制在对象构造函数中。在编写使用服务定位的库时,我们需要清楚地记录特定类有哪些依赖项,以及它们需要如何注册。即使像 StructureMap 这样的流行.NET 依赖注入框架仍然允许使用服务定位技术,尽管它们正在被弃用。

因此,为了本书的目的,让我们探讨如何编写一个简单的服务定位器,并在我们的代码中使用它来构建一个更模块化的应用程序,并将模式与反模式的论点留给那些具有自然实现依赖注入功能的语言。

一个服务定位器

让我们回到我们问题的核心:给定一个接口,我们如何获得当前实现它的类的具体实现?

在第三章, 接口,类和泛型,我们编写了一个名为InterfaceChecker的通用类,它对类进行了运行时评估,以检查它是否实现了一组特定的方法和属性。这个InterfaceChecker背后的基本思想是,如果我们提供了一个列出了接口的预期属性和方法的元数据类,我们就可以在运行时根据这些元数据来查询一个类。如果类具有所有必需的属性和方法,那么就说它实现了这个接口。

因此,我们现在有了一个机制——在运行时——来确保一个类实现了一个接口:注意,不是 TypeScript 接口,而是元数据定义的接口。如果我们扩展这个想法,并为我们的每个元数据接口提供一个唯一的名称,我们就有了“命名接口”的概念。只要这些接口名称在我们的应用程序中是唯一的,我们现在就有了一个在运行时查询一个类是否实现了命名接口的机制。

如果一个类实现了一个命名接口,我们可以使用注册表来存储该类的实例与其命名接口。任何需要实现这个命名接口的类实例的其他代码,只需查询注册表,提供接口名称,注册表就能返回类实例。

只要我们确保我们的 TypeScript 接口与命名接口定义匹配,我们就可以开始了。

命名接口

回到第三章, 接口,类和泛型,我们编写了一个名为IInterfaceChecker的接口,我们可以将其用作元数据的标准模板。让我们更新这个接口,并给它一个必需的className属性,这样我们就可以将其用作命名接口:

interface IInterfaceChecker {
    methodNames?: string[];
    propertyNames?: string[];
    className: string;
}

我们仍然有可选的methodNamespropertyNames数组,但现在每个实现这个接口的类也将需要一个className属性。

因此,考虑到以下 TypeScript 接口:

interface IHasIdProperty {
    id: number;
}

我们的命名接口元数据类匹配这个 TypeScript 接口将如下所示:

class IIHasIdProperty implements IInterfaceChecker {
    propertyNames: string[] = ["id"];
    className: string = "IIHasIdProperty";
}

这个IHasIdProperty接口有一个名为id的属性,类型为number。然后我们创建一个名为IIHasIdProperty的类,作为一个命名接口定义。这个类实现了我们更新的IInterfaceChecker接口,因此必须提供一个className属性。propertyNames属性有一个名为id的单个数组条目,并将被我们的InterfaceChecker类用来与我们的 TypeScript 接口的id属性进行匹配。

注意这个类的命名约定——它与接口的名称相同,但添加了额外的I。这个双I约定将帮助我们将 TypeScript 接口命名为IHasIdProperty与其IIHasIdProperty元数据命名接口类联系起来。

现在,我们可以创建一个正常的 TypeScript 类,实现IHasIdPropertyTypeScript 接口,如下所示:

class PropertyOne implements IHasIdProperty  {
    id = 1;
}

我们现在已经有了所有的要素来开始构建一个服务定位器:

  • 一个名为IHasIdProperty的 TypeScript 接口。这将提供对实现这个接口的类的编译时类型检查。

  • 一个名为IIHasIdProperty的命名接口或元数据类。这将提供对类的运行时类型检查,并且还有一个唯一的名称。

  • 一个实现了 TypeScript 接口IHasIdProperty的类。这个类将通过运行时类型检查,并且这个类的实例可以被注册到我们的服务定位器中。

注册类与命名接口对应

有了这些元数据类,我们现在可以创建一个中央存储库,作为服务定位器。这个类有用于注册类以及解析接口的静态函数:

class TypeScriptTinyIoC {
    static registeredClasses: any[] = new Array();
    public static register(
        targetObject: any,
        targetInterface: { new (): IInterfaceChecker; }): void {
    }

    public static resolve(
        targetInterface: { new (): IInterfaceChecker; }): any {
    }
    public static clearAll() {}
}

这个名为TypeScriptTinyIoC的类有一个名为registeredClasses的静态属性,它是一个any类型的数组。这个数组本质上是我们的注册表。由于我们不知道要在这个数组中存储什么类型的类,所以在这种情况下使用any类型是正确的。

这个类提供了两个主要的静态函数,名为registerresolveregister函数以targetObject作为第一个参数,然后是一个命名接口的类定义,即从IInterfaceChecker派生的类。注意targetInterface参数的语法,它与我们在第三章中使用的泛型语法相同,用于表示类定义。

如果我们看一下它们的使用示例,就更容易理解这些函数签名,所以让我们写一个快速测试:

it("should resolve instance of IIProperty to PropertyOne", () => {
    var propertyInstance = new PropertyOne();
    TypeScriptTinyIoC.register(propertyInstance, IIHasIdProperty);

    var iProperty: IHasIdProperty = 
        TypeScriptTinyIoC.resolve(IIHasIdProperty);
    expect(iProperty.id).toBe(1);
});

这个测试首先创建了一个PropertyOne类的实例,该类实现了IHasIdProperty接口。这个类是我们想要注册的类。然后测试调用TypeScriptTinyIoCregister函数,有两个参数。第一个参数是类实例本身,第二个参数是与命名接口IIHasIdProperty相关的类定义。我们之前已经见过这种语法,当我们讨论使用泛型创建类的实例时,但它的签名也适用于非泛型函数。

如果不使用targetInterface: { new (): IInterfaceChecker; }的签名,我们将不得不如下调用这个函数:

TypeScriptTinyIoC.register(propertyOneInstance,
    new IIHasIdProperty());

但是有了这个签名,我们可以将IIHasIdProperty命名接口类的创建推迟到register函数中,并且可以删除如下的新语法:

TypeScriptTinyIoC.register(propertyOneInstance, IIHasIdProperty);

然后我们的测试调用TypeScriptTinyIoCresolve函数,并再次传入我们命名接口的类定义作为查找键。最后,我们检查返回的类是否实际上是我们最初注册的PropertyOne类的实例。

在这个阶段,我们的测试将会失败,所以让我们完善TypeScriptTinyIoC类,从register函数开始:

public static register(
    targetObject: any,
    targetInterface: { new (): IInterfaceChecker; })
{
    var interfaceChecker = new InterfaceChecker();
    var targetClassName = new targetInterface();
    if (interfaceChecker.implementsInterface(
        targetObject, targetInterface)) {
        this.registeredClasses[targetObject.className]
            = targetObject;
    } else {
        throw new Error(
            "TypeScriptTinyIoC cannot register instance of "
            + targetClassName.className);
    }
}

这个register函数首先创建了一个InterfaceChecker类的实例,然后通过targetInterface参数创建了传入的类定义的实例。这个targetInterface是命名接口或元数据类。然后我们调用interfaceCheckerimplementsInterface函数来确保targetObject实现了targetInterface描述的接口。如果通过了这个检查,我们就使用className属性作为键将其添加到我们的内部数组registeredClasses中。

再次使用我们的InterfaceChecker给我们提供了运行时类型检查,这样我们就可以确保我们注册的任何类实际上都实现了正确的命名接口。

现在我们可以如下完善resolve函数:

public static resolve(
    targetInterface: { new (): IInterfaceChecker; })
{
    var targetClassName = new targetInterface();
    if (this.registeredClasses[targetClassName.className]) {
        return this.registeredClasses[targetClassName.className];
    } else {
        throw new Error(
            "TypeScriptTinyIoC cannot find instance of "
            + targetClassName.className);
    }
}

这个resolve函数只有一个参数,即我们命名接口的定义。同样,我们使用了之前见过的可实例化的语法。这个函数简单地创建了targetInterface类的一个实例,然后使用className属性作为registeredClasses数组的键。如果找到了条目,我们就简单地返回它;否则,我们抛出一个错误。

我们TypeScriptTinyIoC类上的最后一个函数是clearAll函数,它主要用于测试,用于清除我们的注册类数组:

public static clearAll() {
    this.registeredClasses = new Array();
}

我们的服务定位器现在已经完成。

使用服务定位器

现在让我们更新我们的依赖树,看看TypeScriptTinyIoC服务定位器将如何被使用:

使用服务定位器

带有服务定位器模式的依赖图

我们的AppConfig.ts代码现在将创建一个SnippetService的实例,并使用命名接口IISnippetService将其注册到TypeScriptTinyIoC中。然后我们将更新ContactItemView的构造函数,以从注册表中解析IISnippetService的实例。这样,我们现在是编程到一个接口——IISnippetService接口。我们在注册服务到服务定位器时使用这个命名接口,以及在以后解析服务时再次使用。然后,我们的ContactItemView要求服务定位器给我们实现IISnippetService接口的当前对象。

为了实现这个改变,我们首先需要一个命名接口来匹配ISnippetService TypeScript 接口。作为一个复习,我们的ISnippetService定义如下:

interface ISnippetService {
    storeSnippet(key: SnippetKey, value: string): void;
    retrieveSnippet(key: SnippetKey): string;
}

根据我们的命名规则,我们的命名接口定义将被称为IISnippetService,如下所示:

class IISnippetService implements IInterfaceChecker {
    methodNames: string[] = ["storeSnippet", "retrieveSnippet"];
    className: string = "IISnippetService";
}

请注意,methodNames数组包含两个与我们的 TypeScript 接口匹配的条目。按照惯例,我们还指定了一个className属性,这样我们就可以将这个类用作命名接口。使用类的名称(IISnippetService)作为className属性也将确保一个唯一的名称,因为 TypeScript 不允许使用相同名称定义多个类。

现在让我们专注于我们的测试套件。记住我们的TestConfig.ts文件几乎与我们的AppConfig.ts文件相同,但是它启动了 Jasmine 测试套件而不是运行我们的应用程序。我们将修改这个TestConfig.ts文件,包括我们的SnippetServiceTypeScriptTinyIoC,如下所示。

require.config(
    {
        // existing code 
        paths: {
            // existing code
            'tinyioc': '/tscode/app/TypeScriptTinyIoC',
            'snippetservice': '/tscode/app/services/SnippetService'
        },
        shim: {
          // existing code
        }
    }
);

require(
    ['jasmine-boot', 'tinyioc', 'snippetservice',
    'text!/tscode/app/views/ContactItemView.html'],
     (jb, tinyioc, snippetservice, contactItemSnippet) => {
        var snippetService = new SnippetService();
        snippetService.storeSnippet( SnippetKey.CONTACT_ITEM_SNIPPET, contactItemSnippet);
        TypeScriptTinyIoC.register(snippetService, IISnippetService);
        require(specs, () => {
             (<any>window).onload();
        });
    }
);

首先,我们在路径属性中包含了对tinyiocsnippetservice的条目,以确保 Require 会从指定目录加载我们的文件。然后我们更新对 require 函数的调用,将tinyiocsnippetservice都包含在两个参数中。我们的匿名函数然后创建了SnippetService的一个新实例,并使用CONTACT_ITEM_SNIPPET键存储由 Text 加载的片段。然后我们使用命名接口IISnippetService将这个SnippetService的实例注册到TypeScriptTinyIoC中。如果我们现在运行测试套件,应该会有一些失败的测试:

使用服务定位器

单元测试失败

这个失败是因为ContactItemView仍然引用CONTACT_ITEM_SNIPPET全局变量。现在让我们修改这个视图的构造函数如下:

constructor(options?: any) {
    var snippetService: ISnippetService =
        TypeScriptTinyIoC.resolve(IISnippetService);
    var contactItemSnippet = snippetService.retrieveSnippet(
        SnippetKey.CONTACT_ITEM_SNIPPET);

    this.className = "contact-item-view";
    this.events = <any>{ 'click': this.onClicked };
    this.template = _.template(contactItemSnippet);

    super(options);
}

构造函数的第一行调用TypeScriptTinyIoC.resolve函数,使用命名接口IISnippetService的定义。这个调用的结果存储在snippetService变量中,它的类型与ISnippetService接口强类型绑定。这就是服务定位器模式的本质:我们编程到一个接口(ISnippetService),并且通过我们的服务定位器定位这个接口。一旦我们有了提供接口的类的实例,我们就可以简单地使用所需的键调用retrieveSnippet来加载我们的模板。

现在我们已经更新并修复了我们的测试,我们只需要以与我们修改TestConfig.ts文件相同的方式修改我们的AppConfig.ts文件。

可测试性

现在我们正在根据一个定义好的接口进行编程,我们可以开始以不同的方式测试我们的代码。在一个测试中,我们现在可以用另一个在调用retrieveSnippet时抛出错误的服务替换实际的SnippetService。对于这个测试,让我们创建一个名为SnippetServiceRetrieveThrows的类,如下所示:

class SnippetServiceRetrieveThrows implements ISnippetService {
    storeSnippet(key: SnippetKey, value: string) {}

    retrieveSnippet(key: SnippetKey) {
        throw new Error("Error in retrieveSnippet");
    }
}

这个类可以注册到IISnippetService命名接口,因为它正确实现了 TypeScript 接口ISnippetService。然而,retrieveSnippet函数只是抛出一个错误。

然后,我们的测试可以轻松注册此服务的版本,然后创建一个ContactItemView类的实例,以查看如果调用retrieveSnippet函数失败会发生什么。请注意,我们并没有以任何方式修改我们的ContactItemView类 - 我们只是针对IISnippetService命名接口注册了一个不同的类。在这种情况下,我们的测试将如下:

beforeAll(() => {
    var errorService = new SnippetServiceRetrieveThrows();
    TypeScriptTinyIoC.register(errorService, IISnippetService);
});

it("should handle an error on constructor", () => {
    var contactModel = new cm.ContactModel(
      { Name: 'testName', EmailAddress: 'testEmailAddress' });

    var contactItemView = new ccv.ContactItemView(
      { model: contactModel });
    var html = contactItemView.render().$el.html();
    expect(html).toContain('error');

});

在这个测试中,我们在beforeAll函数中注册了我们抛出版本的SnippetService,然后测试了ContactItemView的渲染能力。运行此测试将在ContactItemView调用retrieveSnippet时引发错误。为了使此测试通过,我们需要更新ContactItemView以优雅地处理错误:

var contactItemSnippet = "";
var snippetService: ISnippetService =
    TypeScriptTinyIoC.resolve(IISnippetService);
try {
    contactItemSnippet = snippetService.retrieveSnippet(
        SnippetKey.CONTACT_ITEM_SNIPPET);
} catch (err) {
    contactItemSnippet = 
     "There was an error loading CONTACT_ITEM_SNIPPET";
}

在这里,我们只是用try catch块包围了对retrieveSnippet的调用。如果发生错误,我们将修改片段为标准错误消息。通过放置这样的测试,我们进一步巩固了我们的代码,以便处理各种错误。

到目前为止,我们取得了什么成就呢?我们已经建立了一个服务来提供 HTML 片段,并且我们已经建立了一个服务定位器,可以注册此服务的实例,以便在整个代码中使用。通过在测试期间注册不同版本的此服务,我们还可以通过模拟常见错误来进一步防止错误,并在这些情况下测试我们的组件。

域事件模式

大多数 JavaScript 框架都有事件总线的概念。事件总线只是一种将事件发布到全局总线的方法,以便订阅这些事件的应用程序的其他部分将接收到消息,并能够对其做出反应。使用基于事件的架构有助于解耦我们的应用程序,使其更具有适应变化的能力,并更易于测试。

域事件是特定于我们应用程序域的事件。例如“当发生错误时,将其记录到控制台”,或者“当单击菜单按钮时,更改子菜单面板以反映此选项”。域事件可以在代码的任何位置引发。任何类都可以针对此事件注册事件处理程序,然后在引发此事件时将收到通知。对于单个域事件可以有多个事件处理程序。

Martin Fowler 在 2005 年的一篇博客中首次提出了域事件的概念,该博客位于martinfowler.com/eaaDev/DomainEvent.html。然后,Udi Dahan 在另一篇博客中展示了如何在 C#中实现简单的域事件模式,该博客位于www.udidahan.com/2009/06/14/domain-events-salvation/。Mike Hadlow 还在博客中讨论了域事件的关注点分离,该博客位于mikehadlow.blogspot.com.au/2010/09/separation-of-concerns-with-domain.html

Mike 认为,引发事件的代码片段不应该关心之后会发生什么 - 我们应该有单独的处理程序来处理这些事件 - 这些处理程序与实际引发事件的任何内容都没有耦合。

虽然有许多处理事件的 JavaScript 库 - 例如 Postal - 但这些库中的大多数都将字符串或简单的 JavaScript 对象作为消息包发送。无法确保发送消息的对象填写了消息处理程序所期望的所有属性。换句话说,这些消息不是强类型的 - 可能会很容易地导致运行时错误 - 试图将“方形销子”消息适配到“圆形孔”事件处理程序中。

在本节中,我们将构建一个强类型的领域事件消息总线,并展示事件引发方和事件处理方如何确保引发的事件具有事件处理方期望的所有属性。我们还将展示如何确保事件处理程序被正确编写和正确注册,以便以强类型的方式传递事件。

问题空间

假设我们有以下业务需求:“如果发生错误,请向用户显示一个通知弹出窗口中的错误消息。这个弹出窗口应该显示两秒钟,然后消失,让用户继续工作。”

在我们当前的应用程序中,有许多可能发生错误的地方——例如通过ContactCollection加载 JSON 时,或者渲染ContactItemView时。这些错误可能会发生在我们的类层次结构中的深层。为了实现我们的需求,我们需要在ContactViewApp级别处理这些错误。请考虑以下图表:

问题空间

带有领域事件处理程序和事件引发方的依赖树。

我们的ContactViewApp将使用TypeScriptTinyIoC注册一个事件处理程序,指定它感兴趣的事件类型。当我们的模块中的任何一个引发了这种类型的事件时,我们的消息总线将把消息传递给正确的处理程序或一组处理程序。在前面的图表中,ContactCollectionContactItemView类被显示为通过TypeScriptTinyIoC引发ErrorEvent

消息和处理程序接口

我们需要两组关键信息来注册和引发强类型消息。第一组是描述消息本身的接口,与其命名接口配对。第二组是描述消息处理程序函数的接口,同样与其命名接口配对。我们的 TypeScript 接口为我们提供了消息和处理程序的编译时检查,而我们的命名接口(实现IInterfaceChecker)为我们提供了消息和处理程序的运行时类型检查。

首先,我们的消息接口如下:

interface IErrorEvent {
    Message: string;
    Description: string;
}

export class IIErrorEvent implements IInterfaceChecker {
    propertyNames: string [] = ["Message", "Description"];
    className: string = "IIErrorEvent";
}

我们从 TypeScript 接口IErrorEvent开始。这个接口有两个属性,MessageDescription,都是字符串。然后我们创建我们的IIErrorEvent类,它是我们命名接口的一个实例——再次使用propertyNames数组匹配我们的 TypeScript 接口属性名。className属性也设置为类的名称IIErrorEvent,以确保唯一性。

然后我们的事件处理程序接口如下:

interface IErrorEvent_Handler {
    handle_ErrorEvent(event: IErrorEvent);
}

export class IIErrorEvent_Handler implements IInterfaceChecker {
    methodNames: string[] = ["handle_ErrorEvent"];
    className: string = "IIErrorEvent_Handler";
}

TypeScript 接口IErrorEvent_Handler包含一个名为handle_ErrorEvent的方法。这个处理程序方法有一个名为event的参数,再次强类型化为我们的事件接口IErrorEvent。然后我们构建一个名为IIErrorEvent_Handler的命名接口,并通过methodNames数组匹配 TypeScript 接口。同样,我们为这个命名接口提供一个独特的className属性。

有了这两个接口和命名接口,我们现在可以创建实际的ErrorEvent类如下:

export class ErrorEvent implements IErrorEvent {
    Message: string;
    Description: string;
    constructor(message: string, description: string) {
        this.Message = message;
        this.Description = description;
    }
}

ErrorEvent的类定义实现了IErrorEvent接口,从而使其与我们的事件处理程序兼容。请注意这个类的constructor。我们强制这个类的用户在构造函数中提供messagedescription参数——从而使用 TypeScript 编译时检查来确保我们无论在何处都正确构造这个类。

然后我们可以创建一个实现IErrorEvent_Handler接口的类,该类将接收事件本身。举个快速的例子,考虑以下类:

class EventHandlerTests_ErrorHandler
    implements IErrorEvent_Handler {
    handle_ErrorEvent(event: IErrorEvent) {
    }
}

这个类实现了IErrorEvent_Handler TypeScript 接口,因此编译器将强制这个类定义一个具有正确签名的handle_ErrorEvent函数,以接收消息。

多事件处理程序

为了能够注册多个事件,并且每个事件可以有多个事件处理程序,我们将需要一个事件数组,每个事件将依次保存一个处理程序数组,如下所示:

多事件处理程序

用于注册每个事件的多个事件处理程序的类结构。

我们的TypeScriptTinyIoC类将有一个名为events的数组,它使用事件的名称作为键。这个名称将来自我们的事件的命名接口 - 再次因为 TypeScript 接口被编译掉了。为了帮助管理每个事件的多个事件处理程序,我们将创建一个名为EventHandlerList的新类,它将便于注册多个事件处理程序。这个EventHandlerList类的实例将被存储在我们已注册的每个命名事件的events数组中。

让我们从事件处理程序列表开始,并实现我们的EventHandlerList类。在这个阶段,我们只需要一个内部数组来存储处理程序,名为eventHandlers,以及一个registerHandler函数,如下所示:

class EventHandlerList {
    eventHandlers: any[] = new Array();
    registerHandler(handler: any,
        interfaceType: { new (): IInterfaceChecker }) {
    }
}

registerHandler函数再次使用{ new(): IInterfaceChecker }语法来为interfaceType参数,从而允许我们为这个函数调用使用类型名称。一个快速的单元测试如下:

import iee = require("../app/events/ErrorEvent");

class EventHandlerTests_ErrorHandler
    implements iee.IErrorEvent_Handler {
    handle_ErrorEvent(event: iee.IErrorEvent) {
    }
}

describe("/tests//EventHandlerTests.ts", () => {

    var testHandler: EventHandlerTests_ErrorHandler;
    beforeEach(() => {
        testHandler = new EventHandlerTests_ErrorHandler();
    });

    it("should register an event Handler", () => {
        var eventHandlerList = new EventHandlerList();
        eventHandlerList.registerHandler(testHandler,
            iee.IIErrorEvent_Handler);

        expect(eventHandlerList.eventHandlers.length).toBe(1);
    });
});

我们从导入我们的事件类的import语句开始,然后是一个名为EventHandlerTests_ErrorHandler的类。这个类将被用作一个仅用于这个测试套件的注册事件处理程序。该类实现了iee.IErrorEvent_Handler,因此,如果我们没有一个接受IErrorEvent作为唯一参数的handle_ErrorEvent函数,它将生成一个编译错误。仅仅通过使用 TypeScript 接口,我们已经确保这个类具有正确的函数名称和函数签名来接受ErrorEvent消息。

我们的测试首先声明一个名为testHandler的变量来存储我们的EventHandlerTests_ErrorHandler类的一个实例。beforeEach函数将创建这个实例,并将其赋给我们的testHandler变量。测试本身然后创建一个EventHandlerList类的实例,调用registerHandler,然后期望内部eventHandlers属性的length值为 1。

再次注意registerHandler的调用语法。我们将我们的testHandler实例作为第一个参数传入,然后指定命名接口IIErrorEvent_Handler类类型。正如我们在服务定位器模式中看到的,我们再次使用相同的类名语法来表示我们的命名接口,而不是调用new()

现在让我们填写代码使测试通过:

class EventHandlerList {
    eventHandlers: any[] = new Array();
    registerHandler(handler: any,
        interfaceType: { new (): IInterfaceChecker }) {

        var interfaceChecker = new InterfaceChecker();
        if (interfaceChecker.implementsInterface(
            handler, interfaceType)) {
            this.eventHandlers.push(handler);
        } else {
            var interfaceExpected = new interfaceType();
            throw new Error(
                "EventHandlerList cannot register handler of "
                + interfaceExpected.className);
        }
    }
}

我们的registerHandler函数首先创建一个InterfaceChecker类的实例,然后调用implementsInterface来确保在运行时,传入的处理程序对象确实具有我们命名接口定义的所有方法名称。如果implementsInterface函数返回true,我们可以简单地将这个处理程序推入我们的内部数组。

如果处理程序没有实现命名接口,我们会抛出一个错误。为了完整起见,这个错误包含了命名接口的className属性,因此我们首先要实例化这个命名接口类的一个实例,然后才能提取className属性。

现在让我们创建一个测试,故意使我们的implementsInterface检查失败,并确保实际上抛出了一个错误:

class No_ErrorHandler {
}

it("should throw an error with the correct className", () => {
    var eventHandlerList = new EventHandlerList();
    expect(() => {
        eventHandlerList.registerHandler(new No_ErrorHandler(),
            iee.IIErrorEvent_Handler);
    }).toThrow(new Error(
        "EventHandlerList cannot register handler of IIErrorEvent_Handler"
        ));
});

我们从No_ErrorHandler类的类定义开始,显然它没有实现我们的命名接口。然后我们设置EventHandlerList类,并调用registerHandler函数,使用No_ErrorHandler类的新实例和我们的IIErrorEvent_Handler命名接口。然后我们期望一个特定的错误消息 - 这个消息应该包括我们命名接口IIErrorEvent_Handler的名称。

触发事件

现在我们可以把注意力转向触发事件。为了做到这一点,我们需要知道事件处理程序的实际函数名称。我们将对EventHandlerList进行轻微更改,并将事件名称传递给构造函数,如下所示:

class EventHandlerList {
    handleEventMethod: string;
    constructor(handleEventMethodName: string) {
        this.handleEventMethod = handleEventMethodName;
    }

    raiseEvent(event: any) {
    }
}

我们的构造函数现在期望一个handleEventMethodName作为必需的参数,并且我们将其存储在名为handleEventMethod的属性中。请记住,注册到此类实例的所有处理程序都在响应相同的事件 - 因此都将具有相同的方法名称 - 这是由 TypeScript 编译器强制执行的。我们还定义了一个raiseEvent函数,由于我们不知道这个类将处理什么事件,所以事件的类型是any

现在,我们可以创建一个单元测试,该测试将失败,因为raiseEvent函数实际上还没有做任何事情。在这之前,让我们更新我们的测试处理程序类EventHandlerTests_ErrorHandler,以便将最后触发的事件存储在一个我们以后可以访问的属性中:

class EventHandlerTests_ErrorHandler
    implements iee.IErrorEvent_Handler {
    LastEventFired: iee.IErrorEvent;
    handle_ErrorEvent(event: iee.IErrorEvent) {
        this.LastEventFired = event;
    }
}

我们已经更新了这个类定义,增加了一个名为LastEventFired的属性,并在handle_ErrorEvent函数中设置了这个属性。有了这个改变,当一个事件被触发时,我们可以询问LastEventFired属性来查看最后触发的事件是什么。现在让我们编写一个调用raiseEvent方法的测试。

it("should fire an event", () => {
    var eventHandlerList = new
        EventHandlerList('handle_ErrorEvent');
    eventHandlerList.registerHandler(testHandler,
        iee.IIErrorEvent_Handler);
    eventHandlerList.raiseEvent(
        new iee.ErrorEvent("test", "test"));
    expect(testHandler.LastEventFired.Message).toBe("test");
});

我们从一个名为eventHandlerList的变量开始,它保存了我们EventHandlerList类的一个实例,并通过构造函数传递了要调用的函数的名称。然后我们使用这个testHandler实例调用registerHandler。现在,我们可以调用raiseEvent函数,传入一个new ErrorEvent。由于我们ErrorEvent类的构造函数需要两个参数,我们刚刚为这些参数传入了"test"。最后,我们期望我们的事件处理程序的LastEventFired属性被正确设置。在这个阶段运行我们的测试将失败,所以让我们实现EventHandlerList类上的raiseEvent方法如下:

raiseEvent(event: any) {
    var i, len = 0;
    for (i = 0, len = this.eventHandlers.length; i < len; i++) {
        var handler = this.eventHandlers[i];
        handlerthis.handleEventMethod;
    }
}

这个raiseEvent函数的实现相对简单。我们只需遍历我们的eventHandlers数组,然后使用索引引用每个事件处理程序。这里需要注意的一行是我们如何执行处理程序函数:handlerthis.handleEventMethod。这利用了 JavaScript 能够使用与函数名称匹配的字符串值来调用函数的能力。在我们的测试中,这相当于handler'handle_ErrorEvent',在 JavaScript 中相当于handler.handle_ErrorEvent(event) - 对处理程序函数的实际调用。有了这个 JavaScript 魔法,我们的事件被触发,我们的单元测试正确运行。

为事件注册事件处理程序

现在我们有一个可工作、经过测试的类来管理多个事件处理程序响应特定事件,我们可以把注意力转回到TypeScriptTinyIoC类上。

就像我们为服务定位器模式所做的那样,我们需要注册一个对象的实例来处理特定的事件。我们的事件处理程序注册的方法签名将如下所示:

public static registerHandler(
    handler: any,
    handlerInterface: { new (): IInterfaceChecker },
    eventInterface: { new (): IInterfaceChecker }) {
}

这个registerHandler函数有三个参数。第一个是实现处理程序的对象的实例。第二个参数是处理程序的命名接口类,这样我们可以在运行时检查这个类,以确保它实现了处理程序接口。第三个参数是事件本身的命名接口。这个register函数也是将事件绑定到处理程序的方法。

在我们组合单元测试之前,我们需要另一个静态函数来触发事件:

static raiseEvent(event: any,
    eventInterface: { new (): IInterfaceChecker }) {
}

这个TypeScriptTinyIoC类上的raiseEvent函数将调用这个事件的EventHandlerList类实例上的raiseEvent函数。我们还将在这里进行一个interfaceChecker测试,以确保正在触发的事件与我们为事件提供的命名接口类匹配——在我们实际触发事件之前。

现在到我们的单元测试:

it("should register an event handler with
TypeScriptTinyIoC and fire an event", () => {
    TypeScriptTinyIoC.registerHandler(testHandler,
        iee.IIErrorEvent_Handler, iee.IIErrorEvent);
    TypeScriptTinyIoC.raiseEvent(
        new iee.ErrorEvent("test", "test"),
        iee.IIErrorEvent);
    expect(testHandler.LastEventFired.Message).toBe("test");
});

这个测试与我们为EventHandlerList类编写的测试非常相似,只是我们在TypeScriptTinyIoC类上调用registerHandlerraiseEvent方法,而不是特定的EventHandlerList。有了这个失败的测试,我们现在可以填写registerHandlerraiseEvent函数如下:

static events: EventHandlerList[] = new Array<EventHandlerList>();
public static registerHandler(
    handler: any,
    handlerInterface: { new (): IInterfaceChecker },
    eventInterface: { new (): IInterfaceChecker }) {

    var eventInterfaceInstance = new eventInterface();
    var handlerInterfaceInstance = new handlerInterface();

    var handlerList = 
        this.events[eventInterfaceInstance.className];
    if (handlerList) {
        handlerList.registerHandler(handler, handlerInterface);
    } else {
        handlerList = new EventHandlerList(
            handlerInterfaceInstance.methodNames[0]);
        handlerList.registerHandler(handler, handlerInterface);
        this.events[eventInterfaceInstance.className] =
            handlerList;
    }
}

首先,我们添加了一个名为events的静态属性,它是EventHandlerList实例的数组。我们将使用命名事件接口的className作为键来添加到这个数组中。我们的registerHandler函数首先创建通过handlerInterfaceeventInterface参数传入的命名接口类的实例。然后我们检查我们的内部数组是否已经有了一个针对这个事件的EventHandlerList实例,通过命名事件接口的className属性作为键。如果已经有了条目,我们可以简单地在现有的EventHandlerList实例上调用registerHandler函数。如果这个事件尚未注册,我们只需创建一个EventHandlerList类的新实例,调用registerHandler,然后将这个条目添加到我们的内部数组中。

注意我们是如何找出事件处理程序函数调用的实际名称的。我们只是使用在我们的方法名称数组中找到的第一个方法名称:handlerInterfaceInstance.methodNames[0],这将返回一个字符串。在我们的示例中,这将返回'handle_ErrorEvent'字符串,这是我们在调用事件的处理程序函数时需要调用的方法名称。

接下来,我们可以专注于raiseEvent函数:

static raiseEvent(event: any,
    eventInterface: { new (): IInterfaceChecker }) {

    var eventChecker = new InterfaceChecker();
    if (eventChecker.implementsInterface(event, eventInterface)) {
        var eventInterfaceInstance = new eventInterface();
        var handlerList = 
            this.events[eventInterfaceInstance.className];
        if (handlerList) {
            handlerList.raiseEvent(event);
        }
    }

}

这个函数首先创建一个InterfaceChecker类的实例,然后确保正在触发的事件符合我们作为第二个参数提供的命名接口。同样,这是一个运行时类型检查,以确保我们试图触发的事件实际上是正确类型的。如果事件是有效的,我们获取为这个事件注册的EventHandlerList类的实例,然后调用它的raiseEvent函数。

我们的强类型域事件机制现在已经完成。我们在两个方面使用了编译时 TypeScript 接口检查和运行时类型检查。首先,在注册处理程序时,我们进行了接口检查,然后在触发事件时,我们进行了另一个接口检查。这意味着事件的两个方面——注册和触发——在编译时和运行时都是强类型的。

显示错误通知

现在我们已经在TypeScriptTinyIoC中有了事件机制,我们可以专注于解决当错误发生时显示错误通知的业务问题。Notify 是一个完全符合我们需求的 jQuery 插件(notifyjs.com/)。我们可以从 NuGet 安装 JavaScript 库(安装jQuery.notify包),但是这个包的默认版本依赖于另一个名为 Bootstrap 的包来进行样式设置。然而,Notify 还在他们的网站上提供了一个选项,可以下载一个包含所有这些样式的自定义 notify.js 脚本。我们将使用这个自定义版本,因为我们的项目没有使用 Bootstrap 包。

Notify 的定义文件可以从 DefinitelyTyped(github.com/borisyankov/DefinitelyTyped/tree/master/notify)下载。然而,在撰写本文时,似乎有两个版本的 Notify 库,一个名为 Notify,另一个名为 Notify.js。使用 Notify 版本,因为它似乎更加更新。

为了模拟一个错误,让我们附加到ContactItemView onClicked函数,我们当前正在执行 flip,并在某人点击我们的联系链接时引发一个虚拟错误:

onClicked() {
    this.$el.flip({
        direction: 'tb',
        speed : 200
    });
    var errorEvent = new iee.ErrorEvent(
        "Dummy error message", this.model.Name);
    TypeScriptTinyIoC.raiseEvent(errorEvent, iee.IIErrorEvent);
}

在我们调用 flip 之后,我们只是创建了一个ErrorEvent类的实例,带有它的两个必需参数。然后我们调用TypeScriptTinyIoC上的raiseEvent函数,使用这个errorEvent实例和我们正在引发的事件类型的命名接口。就是这么简单。

现在,我们可以修改我们的ContactViewApp来注册此事件的处理程序如下:

import iee = require("tscode/app/events/ErrorEvent");

export class ContactViewApp implements iee.IErrorEvent_Handler {
    constructor() {
        TypeScriptTinyIoC.registerHandler(this,
            iee.IIErrorEvent_Handler, iee.IIErrorEvent);
    }
    run() {

    }

    contactCollectionLoaded(model, response, options) {

    }
    contactCollectionError(model, response, options) {

    }
    handle_ErrorEvent(event: iee.IErrorEvent) {
        $.notify("Error : " + event.Message
            + "\n" + event.Description);
    }
}

在这里,我们对ContactViewApp类进行了一些更改。首先,我们实现了IErrorEvent_Handler TypeScript 接口,这将强制我们在类中包含handle_ErrorEvent函数。我们还定义了一个constructor,在其中,我们使用我们的两个命名接口IIErrorEvent_HandlerIIErrorEvent注册了类实例作为处理程序。

handle_ErrorEvent函数中,我们调用$.notify——Notify jQuery 插件。请注意,传递给handle_ErrorEvent函数的event参数的类型是IErrorEvent。这意味着我们可以在事件处理程序函数中安全地使用IErrorEvent接口的任何属性或方法,因为在事件引发期间,我们已经确保此事件正确实现了接口。

我们调用 Notify 只是使用了从我们的ErrorEvent构建的消息。以下屏幕截图显示了此 Notify 调用的结果:

显示错误通知

应用程序显示错误通知的屏幕截图

注意

在本章中,我们已经通过 GitHub 项目typescript-tiny-iocgithub.com/blorkfish/typescript-tiny-ioc)实现了此服务定位器模式和强类型域事件模式。该项目还有更多的代码示例以及用于 AMD 和普通 JavaScript 使用的完整单元测试套件。

总结

在本章中,我们研究了面向对象编程,从 SOLID 设计原则开始。然后,我们针对这些原则回顾了我们在第七章 模块化 中构建的应用程序。我们讨论了各种依赖注入的方法,然后构建了一个基于我们在第三章 接口、类和泛型 中的InterfaceChecker的机制,以获得命名接口的实例。我们使用了这个原则来构建一个服务定位器,然后将这个原则扩展到为域事件模式构建一个强类型的事件总线。最后,我们将 Notify 整合到我们的应用程序中,用于对这些错误事件进行简单通知。在我们接下来的最后一章中,我们将把我们迄今学到的所有原则付诸实践,并从头开始构建一个应用程序。

为 Bentham Chang 准备,Safari ID 为 bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需获得版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第九章:让我们动手吧

在本章中,我们将从头开始构建一个 TypeScript 单页 Web 应用程序。我们将从讨论网站应该是什么样子开始,我们希望我们的页面转换如何流动,然后转向探索 Bootstrap 框架的功能,并讨论我们网站的纯 HTML 版本。我们的重点将转向我们应用程序所需的数据结构,以及我们需要用来表示这些数据的 Backbone 模型和集合。在此过程中,我们将为这些模型和集合编写一组单元和集成测试。

一旦我们有了要处理的数据,我们将使用Marionette框架来构建视图,以将我们的应用程序呈现到 DOM 中。然后,我们将展示如何将我们网站的纯 HTML 版本分解为 HTML 片段的较小部分,然后将这些片段与我们的 Marionette 视图集成。最后,我们将使用事件将应用程序联系在一起,并探讨StateMediator设计模式,以帮助我们管理复杂的页面转换和 DOM 元素。

Marionette

Marionette 是 Backbone 库的扩展,引入了一些增强功能,以减少样板 Backbone 代码,并使处理 DOM 元素和 HTML 片段更容易。Marionette 还引入了布局和区域的概念,以帮助管理大型网页中的逻辑部分。Marionette 布局是一种管理多个区域的控制器,而 Marionette 区域是管理我们页面上特定 HTML 部分的对象。例如,我们可以为标题面板设置一个区域,为侧边栏面板设置一个区域,为页脚区域设置另一个区域。这使我们能够将应用程序分解为逻辑区域,然后通过消息将它们联系在一起。

Bootstrap

我们还将使用 Bootstrap 来帮助我们进行页面布局。Bootstrap 是一个流行的移动优先框架,用于在许多不同平台上呈现 HTML 元素。Bootstrap 的样式和定制是一个足够大的主题,需要一本专门的书来探讨,所以我们不会探讨各种 Bootstrap 选项的细节。如果你有兴趣了解更多,请务必阅读 David Cochran 和 Ian Whitley 的优秀著作Boostrap Site BlueprintsPackt Publishing (www.packtpub.com/web-development/bootstrap-site-blueprints)。

Board Sales

我们的应用将是一个相当简单的应用,名为 Board Sales,将在主页上列出一系列风浪板,使用摘要视图或板列表视图。单击其中任何一个板将使页面转换为显示所选板的详细信息。在屏幕的左侧,将有一个简单的面板,允许用户通过制造商或板类型来过滤主板列表。

现代的风浪板有各种尺寸,并且是按体积来衡量的。较小体积的板通常用于波浪帆船,而较大体积的板用于比赛或障碍赛。介于两者之间的板可以归类为自由式板,用于在平静水域上进行杂技表演。任何板的另一个重要元素是板设计的帆范围。在非常强风下,使用较小的帆来允许风帆手控制风力产生的动力,在较轻的风中,使用较大的帆来产生更多的动力。我们的摘要视图将包括对每个板的体积测量的快速参考,我们的详细视图将显示所有各种板的测量和兼容的帆范围列表。

页面布局

通过这个应用程序,我们将利用 JavaScript 的强大功能来提供从左到右的面板式页面布局。我们将使用一些 Bootstrap 过渡效果,从左侧或右侧滑入面板,以提供用户稍微不同的浏览体验。让我们来看看这在概念上是什么样子:

页面布局

Board Sales 的页面转换的概念视图

查看面板将是我们的主页面,有一个头部面板,一个板块列表面板和一个页脚面板。左侧隐藏的是过滤面板,主面板的左上方有一个按钮,用于显示或隐藏此过滤面板。需要时,过滤面板将从左侧滑入,隐藏时将滑回左侧。同样,板块详细 面板将在点击板块时从右侧滑入,点击返回按钮时将滑回右侧,显示板块列表面板。

当在桌面设备上查看网站时,左侧的过滤面板将默认显示,但当在平板设备上查看网站时,由于屏幕较小,过滤面板将默认隐藏,以节省屏幕空间。

安装 Bootstrap

Bootstrap 是一组 CSS 样式和 JavaScript 函数,可帮助简单轻松地构建响应式网站。Bootstrap 的响应性意味着页面将自动调整元素大小,以便在手机的较小屏幕尺寸上呈现,以及在平板电脑和台式机上使用的较大屏幕上呈现。通过使用 Bootstrap,我们获得了额外的好处,可以以非常少的改动来针对移动用户和桌面用户。

Bootstrap 可以通过 NuGet 包安装,以及相应的 TypeScript 定义如下:

Install-package bootstrap
Install-package bootstrap.TypeScript.DefinitelyTyped

安装了 Bootstrap 后,我们可以开始构建一个纯粹使用 Bootstrap 编写的示例网页。以这种方式构建演示页面有助于我们确定要使用的 Bootstrap 元素,并允许我们在开始构建应用程序之前修改我们的 CSS 样式和正确构造我们的 HTML。这就是 Brackets 编辑器真正发挥作用的地方。通过使用编辑器的实时预览功能,我们可以在一个 IDE 中编辑我们的 HTML 和 CSS,并在预览窗格中获得即时的视觉反馈。以这种方式在示例 HTML 上工作既有益又有趣,更不用说节省了大量时间。

使用 Bootstrap

我们的页面将使用一些 Bootstrap 元素来定义主页面区域,如下:

  1. 一个导航栏组件来渲染头部面板。

  2. 一个页脚组件来渲染页脚面板。

  3. 一个轮播组件,用于从板块列表视图滑动到板块详细视图。

  4. 一个手风琴组件来渲染左侧面板中的过滤选项。

  5. 组件来控制我们板块列表视图中的板块的 HTML 布局,以及板块详细视图中的布局。

  6. 表格 CSS 元素来渲染表格。

在本章中,我们不会详细介绍如何使用 Bootstrap 构建 HTML 页面。相反,我们将从一个可在目录/tscode/tests/brackets/TestBootstrap.html下的示例代码中找到的工作版本开始。

我们的 Bootstrap 元素如下:

使用 Bootstrap

在我们页面的顶部是导航栏元素,它被赋予了navbar-inverse样式,以黑色背景呈现。轮播面板 1元素是第一个轮播面板,包含左侧的过滤面板,以及板块列表和显示/隐藏面板按钮。左侧面板上的过滤选项使用了 Bootstrap 手风琴组件。最后,我们的页脚被设计成“粘性页脚”,意味着它将始终显示在页面上。

当我们点击板列表中的任何一个板时,我们的轮播组件将把轮播面板向左滑动,并从右侧滑入板详细视图。

我们的面板详细信息如下:

使用 Bootstrap

再次,我们有标准的页眉和页脚区域,但这次我们正在查看轮播面板 2。该面板在左上角有一个返回按钮,并显示所选板的详细信息。

当您运行此测试页面时,您会注意到页脚区域有四个链接,分别命名为nextprevshowhide。这些按钮用于测试轮播面板的循环和左侧面板的显示/隐藏功能。

Bootstrap 非常适合快速构建站点的工作版本的模拟。这个版本可以轻松地展示给客户,或者用于项目会议的演示目的。向客户展示站点的演示模型将为您提供有关整个站点流程和设计的宝贵反馈。理想情况下,这样的工作应该由一位资深的网页设计师或者具有相同技能的人来完成,他们专门负责 CSS 样式。

当我们开始构建 Marionette 视图时,我们将稍后重用和重新设计这个 HTML。然而,将这些演示 HTML 页面保留在项目中是一个好主意,这样您就可以在不同的浏览器和设备上测试它们的外观和感觉,同时调整您的 HTML 布局和 CSS 样式。

数据结构

在现实世界的应用程序中,网站的数据将存储在某种数据库中,并从中检索。为了在 JavaScript 网页中使用数据,这些数据结构将被序列化为 JSON 格式。Marionette 使用标准的 Backbone 模型和集合来加载和序列化数据结构。对于这个示例应用程序,我们的数据结构将如下所示:

数据结构

ManufacturerCollection和相关的 Backbone 模型的类图

我们的数据源是ManufacturerCollection,它将有一个url属性来从我们的网站加载数据。这个ManufacturerCollection持有一个ManufacturerModels集合,可以通过models属性获得。ManufacturerCollection还实现了两个接口:IManufacturerCollectionIFilterProvider。我们稍后会讨论这两个接口。

ManufacturerModel的属性将用于将单个制造商的名称和徽标呈现到 DOM 中。每个ManufacturerModel还有一个名为boards的数组,其中包含一个BoardModels数组。

每个BoardModel都有必要用于呈现的属性,以及一个名为board_types的数组,其中包含一组BoardType类。BoardType是一个简单的字符串,将包含"Wave"、"Freestyle"或"Slalom"中的一个值。

每个BoardModel还将有一个sizes数组,其中包含一个BoardSize类,其中包含有关可用尺寸的详细信息。

例如,用于序列化前述对象结构的 JSON 数据结构将如下所示:

{
"manufacturer": "JP Australia",
"manufacturer_logo": "jp_australia_logo.png",
"logo_class" : "",
"boards": [
    {
        "name": "Radical Quad",
        "board_types": [ { "board_type": "Wave" } ],

        "description": "Radical Wave Board",
        "image": "jp_windsurf_radicalquad_ov.png",
        "long_description": "long desc goes here",
        "sizes": [
            { "volume": 68, "length": 227, 
              "width": 53, "sail_min": "< 5.0", "sail_max": "< 5.2" }
        ]
    }]
}

在我们的示例应用程序中,完整的 JSON 数据集可以在/tscode/tests/boards.json找到。

数据接口

为了在 TypeScript 中使用这个 JSON 数据结构,我们需要定义一组接口来描述上述数据结构,如下所示:

export interface IBoardType {
    board_type: string;
}
export interface IBoardSize {
    volume: number;
    length: number;
    width: number;
    sail_min: string;
    sail_max: string;
}
export interface IBoardModel {
    name: string;
    board_types: IBoardType[];
    description: string;
    image: string;
    long_description: string;
    sizes: IBoardSize[];
}
export interface IManufacturerModel {
    manufacturer: string;
    manufacturer_logo: string;
    logo_class: string;
    boards: IBoardModel[];
}

这些接口简单地匹配了前面图表中的模型属性,然后我们可以构建相应的实现这些接口的Backbone.Model类。请注意,为了简洁起见,我们没有在这里列出每个模型的每个属性,因此请务必参考附带的源代码以获取完整列表。我们的 Backbone 模型如下:

export class BoardType extends Backbone.Model
    implements IBoardType {
    get board_type() { return this.get('board_type'); }
    set board_type(val: string) { this.set('board_type', val); }
}
export class BoardSize extends Backbone.Model 
    implements IBoardSize {
    get volume() { return this.get('volume');}
    set volume(val: number) { this.set('volume', val); }
    // more properties
}
export class BoardModel extends Backbone.Model implements IBoardModel {
    get name() { return this.get('name'); }
    set name(val: string) { this.set('name', val); }
    // more properties
    get sizes() { return this.get('sizes'); }
    set sizes(val: IBoardSize[]) { this.set('sizes', val); }
}
export class ManufacturerModel extends Backbone.Model implements IManufacturerModel {
    get manufacturer() { return this.get('manufacturer'); }
    set manufacturer(val: string) { this.set('manufacturer', val); }
    // more properties
    get boards() { return this.get('boards'); }
    set boards(val: IBoardModel[]) { this.set('boards', val); }
}

每个类都扩展了Backbone.Model,并实现了我们之前定义的接口之一。这些类没有太多内容,除了为每个属性定义getset方法,并使用正确的属性类型。

此时,我们的模型已经就位,我们可以编写一些单元测试,以确保我们可以正确地创建我们的模型:

it("should build a BoardType", () => {
    var boardType = new bm.BoardType(
        { board_type: "testBoardType" });
    expect(boardType.board_type).toBe("testBoardType");
});

我们从一个简单的测试开始,创建一个BoardType模型,然后测试board_type属性是否已正确设置。同样,我们可以为BoardSize模型创建一个测试:

describe("BoardSize tests", () => {
    var boardSize: bm.IBoardSize;
    beforeAll(() => {
        boardSize = new bm.BoardSize(
          { "volume": 74, "length": 227,
            "width": 55, "sail_min": "4.0", "sail_max": "5.2" });
    });
    it("should build a board size object",() => {
        expect(boardSize.volume).toBe(74);
    });
});

这个测试也只是创建了一个BoardSize模型的实例,但它使用了beforeAll Jasmine 方法。为简洁起见,我们只展示了一个测试,检查volume属性,但在实际应用中,我们会测试每个BoardSize属性。最后,我们可以编写一个BoardModel的测试如下:

describe("BoardModel tests",() => {
    var board: bm.IBoardModel;
    beforeAll(() => {
        board = new bm.BoardModel({
            "name": "Thruster Quad",
            "board_types": [{ "board_type": "Wave" }],
            "description": "Allround Wave Board",
            "image": "windsurf_thrusterquad_ov.png",
            "long_description": 
                "Shaper Werner Gnigler and pro riders Robby Swift",
            "sizes": [
                { "volume": 73, "length": 228, "width": 55.5,
                     "sail_min": "4.0", "sail_max": "5.2" }
            ]
        });
    });

    it("should find name property",() => {
        expect(board.name).toBe("Thruster Quad");
    });
    it("should find sizes[0].volume property",() => {
        expect(board.sizes[0].volume).toBe(73);
    });
    it("should find sizes[0].sail_max property",() => {
        expect(board.sizes[0].sail_max).toBe("5.2");
    });
    it("should find board_types[0].sail_max property",() => {
        expect(board.board_types[0].board_type).toBe("Wave");
    });
});

再次强调,在我们的beforeAll函数中创建了一个BoardModel实例,然后测试属性是否设置正确。注意代码片段底部附近的测试:我们正在检查sizes属性和board_types属性是否已正确构建,并且它们实际上是可以用[]数组表示法引用的数组。

在附带的源代码中,您将找到这些模型的进一步测试,以及对ManufacturerModel的测试。

注意

注意每个模型是如何通过简单地剪切和粘贴原始 JSON 样本的部分来构建的。当 Backbone 模型通过 RESTful 服务进行填充时,这些服务只是简单地返回 JSON,因此我们的测试与 Backbone 本身的操作是匹配的。

集成测试

此时,您可能会想为什么我们要编写这些测试,因为它们可能看起来微不足道,只是检查某些属性是否已正确构建。在实际应用中,模型经常会发生变化,特别是在项目的初期阶段。通常会有一个开发人员或团队的一部分负责后端数据库和向前端提供 JSON 的服务器端代码。另一个团队可能负责前端 JavaScript 代码的开发。通过编写这样的测试,您清楚地定义了数据结构应该是什么样子,以及您的模型中期望的属性是什么。如果服务器端进行了修改数据结构的更改,您的团队将能够快速确定问题的原因所在。

编写基于属性的测试的另一个原因是,Backbone、Marionette 和几乎任何其他 JavaScript 库都将使用这些属性名称来将 HTML 呈现到前端。如果您的模板期望一个名为manufacturer_logo的属性,而您将此属性名称更改为logo_image,那么您的渲染代码将会出错。这些错误通常很难在运行时跟踪。遵循“尽早失败,失败得响亮”的测试驱动开发原则,我们的模型属性测试将快速突出显示这些潜在错误,如果发生的话。

一旦一系列基于属性的测试就位,我们现在可以专注于一个集成测试,实际上会调用服务器端代码。这将确保我们的 RESTful 服务正常工作,并且我们网站生成的 JSON 数据结构与我们的 Backbone 模型期望的 JSON 数据结构匹配。同样,如果两个独立的团队负责客户端和服务器端代码,这种集成测试将确保数据交换是一致的。

我们将通过Backbone.Collection类加载此应用程序的数据,并且此集合将需要加载多个制造商。为此,我们现在可以构建一个ManufacturerCollection类,如下所示:

export class ManufacturerCollection 
    extends Backbone.Collection<ManufacturerModel>
{
    model = ManufacturerModel;
    url = "/tscode/boards.json";
}

这是一个非常简单的Backbone.Collection类,它只是将model属性设置为我们的ManufacturerModel,将url属性设置为/tscode/boards.json。由于我们的示例应用程序没有后端数据库或 REST 服务,因此我们将在此阶段仅从磁盘加载我们的 JSON。请注意,即使在此测试中我们使用静态 JSON 文件,Backbone 仍将向服务器发出 HTTP 请求以加载此文件,这意味着对ManufacturerCollection的任何测试实际上都是集成测试。现在我们可以编写一些集成测试,以确保该模型可以从url属性正确加载,如下所示:

describe("ManufacturerCollection tests", () => {
    var manufacturers: bm.ManufacturerCollection;

    beforeAll(() => {
        manufacturers = new bm.ManufacturerCollection();
        manufacturers.fetch({ async: false });
    });

    it("should load 3 manufacturers", () => {
        expect(manufacturers.length).toBe(3);
    });

    it("should find manufacturers.at(2)",() => {
        expect(manufacturers.at(2).manufacturer)
           .toBe("Starboard");
    });
}

我们再次使用 Jasmine 的beforeAll语法来设置我们的ManufacturerCollection实例,然后调用fetch({ async: false })来等待集合加载。然后我们有两个测试,一个是检查我们是否将三个制造商加载到我们的集合中,另一个是检查索引为2Manufacturer模型。

遍历集合

现在我们已经加载了完整的ManufacturerCollection,我们可以将注意力转向处理它包含的数据。我们需要搜索此集合以找到两件事:制造商列表和板类型列表。这两个列表将被用于左侧面板上的过滤面板。在现实世界的应用程序中,这两个列表可能由服务器端代码提供,返回简单的 JSON 数据结构来表示这两个列表。然而,在我们的示例应用程序中,我们将展示如何遍历我们已经加载的主制造商 Backbone 集合。过滤数据结构如下:

遍历集合

具有相关 Backbone 模型的 FilterCollection 类图

与前面图表中显示的 Backbone 模型的完整实现不同,我们将查看 TypeScript 接口。我们的这些过滤模型的接口如下:

export enum FilterType {
    Manufacturer,
    BoardType,
    None
}
export interface IFilterValue {
    filterValue: string;
}
export interface IFilterModel {
    filterType: FilterType;
    filterName: string;
    filterValues: IFilterValue[];
}

我们从FilterType枚举开始,我们将使用它来定义我们可用的每种类型的过滤器。我们可以通过制造商名称、板类型或使用None过滤器类型清除所有过滤器来过滤我们的板列表。

IFilterValue接口简单地保存一个用于过滤的字符串值。当我们按板类型进行过滤时,此字符串值将是“Wave”、“Freestyle”或“Slalom”之一,当我们按制造商进行过滤时,此字符串值将是制造商的名称。

IFilterModel接口将保存FilterType,过滤器的名称和filterValues数组。

我们将为这些接口创建一个 Backbone 模型,这意味着我们最终将拥有两个 Backbone 模型,名为FilterValue(实现IFilterValue接口)和FilterModel(实现IFilterModel接口)。为了容纳FilterModel实例的集合,我们还将创建一个名为FilterCollection的 Backbone 集合。此集合有一个名为buildFilterCollection的方法,它将使用IFilterProvider接口来构建其内部的FilterModels数组。此IFilterProvider接口如下:

export interface IFilterProvider {
    findManufacturerNames(): bm.IManufacturerName[];
    findBoardTypes(): string[]
}

我们的IFilterProvider接口有两个函数。findManufacturerNames函数将返回制造商名称列表(及其关联的标志),findBoardTypes函数将返回所有板类型的字符串列表。这些信息是构建我们的FilterCollection内部数据结构所需的全部信息。

用于填充此FilterCollection所需的所有值将来自已包含在我们的ManufacturerCollection中的数据。因此,ManufacturerCollection将需要实现此IFilterProvider接口。

查找制造商名称

让我们继续在我们的测试套件中工作,以充实ManufacturerCollection需要实现的IFilterProvider接口的findManufacturerNames函数的功能。这个函数返回一个IManufacturerName类型的数组,定义如下:

export interface IManufacturerName {
    manufacturer: string;
    manufacturer_logo: string;
}

现在我们可以使用这个接口构建一个测试:

it("should return manufacturer names ",() => {
    var results: bm.IManufacturerName[] = 
        manufacturers.findManufacturerNames();
    expect(results.length).toBe(3);
    expect(results[0].manufacturer).toBe("JP Australia");
});

这个测试重用了我们在之前的测试套件中设置的manufacturers变量。然后调用findManufacturerNames函数,并期望结果是一个包含三个制造商名称的数组,即"JP Australia","RRD"和"Starboard"。

现在,我们可以更新实际的ManufacturerCollection类,以提供findManufacturerNames函数的实现:

public findManufacturerNames(): IManufacturerName[] {
    var items = _(this.models).map((iterator) => {
        return {
            'manufacturer': iterator.manufacturer,
            'manufacturer_logo': iterator.manufacturer_logo
        };
    });
    return items;
}

在这个函数中,我们使用 Underscore 实用函数map来循环遍历我们的集合。每个 Backbone 集合类都有一个名为models的内部数组。map函数将循环遍历这个models属性,并为集合中的每个项目调用匿名函数,通过iterator参数将当前模型传递给我们的匿名函数。然后我们的代码构建了一个具有IManufacturer接口所需属性的 JSON 对象。

注意

如果返回的对象不符合IManufacturer名称接口,TypeScript 编译器将生成错误。

查找板类型

现在我们可以专注于IFilterProvider接口的第二个函数,名为findBoardTypesManufacturerCollection需要实现。这是一个单元测试:

it("should find board types ",() => {
    var results: string[] = manufacturers.findBoardTypes();
    expect(results.length).toBe(3);
    expect(results).toContain("Wave");
    expect(results).toContain("Freestyle");
    expect(results).toContain("Slalom");
});

这个测试调用findBoardTypes函数,它将返回一个字符串数组。我们期望返回的数组包含三个字符串:"Wave","Freestyle"和"Slalom"。

我们ManufacturerCollection类中对应的函数实现如下:

public findBoardTypes(): string[] {
    var boardTypes = new Array<string>();
    _(this.models).each((manufacturer) => {
        _(manufacturer.boards).each((board) => {
            _(board.board_types).each((boardType) => {
                if (! _.contains(
                    boardTypes, boardType.board_type)) {
                        boardTypes.push(boardType.board_type);
                }
            });
        });
    });
    return boardTypes;
}

findBoardTypes函数的实现从创建一个名为boardTypes的新字符串数组开始,它将保存我们的结果。然后我们使用 Underscore 的each函数来循环遍历每个制造商。Underscore 的each函数类似于map函数,将迭代我们集合中的每个项目。然后我们循环遍历制造商的所有板,以及每个板上列出的每种板类型。最后,我们测试看看板类型集合是否已经包含一个项目,使用 underscore 的_.contains函数。如果数组中还没有板类型,我们将board_type字符串推入我们的boardTypes数组中。

注意

Underscore 库有许多实用函数可用于搜索、操作和修改数组和集合,因此请务必查阅文档,找到适合在您的代码中使用的合适函数。这些函数不仅限于 Backbone 集合,可以用于任何类型的数组。

这完成了我们对IFilterProvider接口的工作,以及它在ManufacturerCollection类中的实现。

集合过滤

当用户在左侧面板上点击过滤选项时,我们需要将所选的过滤器应用到制造商集合中包含的数据。为了做到这一点,我们需要在ManufacturerCollection类中实现两个函数,名为filterByManufacturerfilterByBoardType。让我们从一个测试开始,通过制造商名称来过滤我们的集合:

it("should filter by manufacturer name ",() => {
    var results = manufacturers.filterByManufacturer("RRD");
    expect(results.length).toBe(1);
});

这个测试调用filterByManufacturer函数,期望只返回一个制造商。有了这个测试,我们可以在ManufacturerCollection上创建真正的filterByManufacturer函数,如下所示:

public filterByManufacturer(manufacturer_name: string) {
    return _(this.models).filter((item) => {
        return item.manufacturer === manufacturer_name;
    });
}

在这里,我们使用 Underscore 函数filter来对我们的集合应用过滤器。

第二个筛选函数是按板子类型筛选,稍微复杂一些。我们需要循环遍历我们的集合中的每个制造商,然后循环遍历每个板子,然后循环遍历每个板子类型。如果我们找到了板子类型的匹配,我们将标记这个板子包含在结果集中。在我们着手编写filterByBoardType函数之前,让我们写一个测试:

it("should only return Slalom boards ",() => {
    var results = manufacturers.filterByBoardType("Slalom");
    expect(results.length).toBe(2);
    _(results).each((manufacturer) => {
        _(manufacturer.boards).each((board) => {
            expect(_(board.board_types).some((boardType) => {
                return boardType.board_type == 'Slalom';
            })).toBeTruthy(); 

        });
    });
});

我们的测试调用filterByBoardType函数,使用字符串"Slalom"作为筛选条件。请记住,这个函数将返回一个ManufacturerModel对象的集合,顶层的每个对象中的boards数组都经过板子类型的筛选。我们的测试然后循环遍历每个制造商,以及结果集中的每个板子,然后使用 Underscore 函数some来测试board_types数组是否有正确的板子类型。

我们在ManufacturerCollection上实现这个函数的代码也有点棘手,如下所示:

public filterByBoardType(board_type: string) {
    var manufWithBoard = new Array();
    _(this.models).each((manuf) => { 
        var hasBoardtype = false;
        var boardMatches = new Array();
        _(manuf.boards).each((board) => {
            var match = _(board.board_types).some((item) => {
                return item.board_type == board_type;
            });
            if (match) {
                boardMatches.push(new BoardModel(board));
                hasBoardtype = true;
            }
        });

        if (hasBoardtype) {
            var manufFiltered = new ManufacturerModel(manuf);
            manufFiltered.set('boards', boardMatches);
            manufWithBoard.push(manufFiltered);
        }
    });
    return manufWithBoard;
}

我们的ManufacturerCollection类实例保存了通过网站上的 JSON 文件加载的整个集合。为了保留这些数据以进行重复筛选,我们需要构造一个新的ManufacturerModel数组来从这个函数中返回——这样我们就不需要修改基础的“全局”数据。一旦我们构造了这个新数组,我们就可以循环遍历每个制造商。如果我们找到与所需筛选匹配的板子,我们将设置一个名为hasBoardType的标志为 true,以指示这个制造商必须添加到我们的筛选数组中。

在这个经过筛选的数组中,每个制造商还需要列出与我们的筛选条件匹配的板子类型,因此我们需要另一个数组——称为boardMatches——来保存这些匹配的板子。然后我们的代码将循环遍历每个板子,并检查它是否具有所需的board_type。如果是,我们将把它添加到boardMatches数组中,并将hasBoardType标志设置为true

一旦我们循环遍历了每个制造商的板子,我们就可以检查hasBoardType标志。如果我们的制造商有这种板子类型,我们将构造一个新的ManufacturerModel,然后将这个模型的boards属性设置为我们内存中匹配的板子的数组。

我们对底层的 Backbone 集合和模型的工作现在已经完成。我们还编写了一组单元测试和集成测试,以确保我们可以从网站加载我们的集合,从这个集合构建我们的筛选列表,然后对这些数据应用特定的筛选。

Marionette 应用程序、区域和布局

现在我们可以把注意力集中在构建应用程序本身上。在 Marionette 中,这是通过创建一个从Marionette.Application派生的类来实现的,如下所示:

export class BoardSalesApp extends Marionette.Application {
    viewLayout: pvl.PageViewLayout;
    constructor(options?: any) {
        if (!options)
            options = {};
        super();
        this.viewLayout = new pvl.PageViewLayout();
    }
    onStart() {
        this.viewLayout.render();
    }
}

在这里,我们定义了一个名为BoardSalesApp的类,它派生自Marionette.Application类,并将作为我们应用程序的起点。我们的构造函数非常简单,它创建了PageViewLayout类的一个新实例,我们将很快讨论。我们应用程序中的唯一其他函数是onStart函数,它将我们的PageViewLayout呈现到屏幕上。当应用程序启动时,Marionette 将触发这个onStart函数。

我们的PageLayoutView类如下:

export class PageViewLayout extends Marionette.LayoutView<Backbone.Model> {
    constructor(options?: any) {
        if (!options)
            options = {};
        options.el = '#page_wrapper';
        var snippetService: ISnippetService = 
            TypeScriptTinyIoC.resolve(IISnippetService);
        options.template = snippetService.retrieveSnippet(
            SnippetKey.PAGE_VIEW_LAYOUT_SNIPPET);
        super(options);
    }
}

这个类扩展自Marionette.LayoutView,并做了两件重要的事情。首先,在options对象上设置了一些属性,然后通过super函数调用了基类的构造函数,传入了这个options对象。这个options对象的一个属性名为el,包含了这个视图将呈现到的 DOM 元素的名称。在这段代码中,这个el属性被设置为 DOM 元素'#page_wrapper'。如果不设置这个el属性,当我们尝试将视图呈现到 DOM 时,我们将得到一个空白屏幕。

我们构造函数中的第二个重要步骤是从SnippetService加载一个片段。然后使用这个片段来设置options对象上的template属性。与 Backbone 类似,Marionette 加载模板,然后将底层模型属性与视图模板结合起来,以生成将呈现到 DOM 中的 HTML。

在这个阶段,为了运行我们的BoardSalesApp,并让它将PageViewLayout呈现到 DOM 中,我们需要两样东西。第一是在我们的index.html页面中有一个id="page_wrapper"的 DOM 元素,以匹配我们的options.el属性,第二是我们的PAGE_VIEW_LAYOUT_SNIPPET

我们的index.html页面如下:

<!DOCTYPE html>
<html >
<head>
    <title>BoardSales</title>
    <link rel="stylesheet" href="/Content/bootstrap.css" />
    <link rel="stylesheet" type="text/css"
          href="/Content/app.css">
    <script type="text/javascript"
            src="img/head-1.0.3.js"></script>
    <script data-main="/tscode/app/AppConfig"
            type="text/javascript"
            src="img/require.js"></script>
</head>
<body>
    <div id="page_wrapper">

    </div>
    <footer class="footer footer_style">
        <div class="container">
            <p class="text-muted"><small>Footer</small></p>
        </div>

    </footer>
</body>
</html>

这个页面包括bootstrap.cssapp.css样式表,以及一个带有data-main属性设置为名为/tscode/app/AppConfig的 Require 配置文件的 Require 调用。index.html页面的主体只包括带有id="page_wrapper"的 DOM 元素和页脚。这是我们之前构建的演示 HTML 页面的一个非常简化的版本。

注意

我们还包括了一个名为head-1.0.3.js的脚本,可以通过 NuGet 包HeadJS安装。这个脚本会查询我们的浏览器,以找出它是在移动设备还是桌面设备上运行,我们正在使用什么浏览器,甚至当前屏幕尺寸是多少。我们将在应用程序中稍后使用head.js的输出。

我们现在需要为PageViewLayout创建一个 HTML 片段。这个文件叫做PageViewLayout.html,位于/tscode/app/views目录中,因此在处理PageViewLayout.ts文件时可以很容易找到。查看完整的 HTML 文件清单的示例代码,其中包括以下相关部分:

<div id="page_wrapper">
    <div id="main_panel_div">
            <div class="carousel-inner" >
                <div id="carousel_panel_1" >
                    <div id="content_panel_left" >
                            <!--filter panel goes here-->
                    </div>
                    <div id="content_panel_main">
                      <div id="manufacturer_collection">
                            <!--board list goes here-->
                        </div>
                    </div>
                </div>
                <div id="carousel_panel_2">
                        <!--board detail panel goes here-->
                </div>
            </div>
    </div>
</div>

我们的PageViewSnippet.html文件包含了我们页面的主要元素。我们有一个main_panel_div作为应用程序的中间面板,其中包含了我们的两个轮播面板 div,名为carousel_panel_1carousel_panel_2。在这些轮播面板中,我们将呈现过滤面板、板块列表面板和板块详细信息面板。

现在我们需要组合我们的AppConfig.ts文件,Require 将加载,并设置SnippetService来加载PageViewLayout.html片段。为了简洁起见,我们没有在这里列出完整的require.config,并且已经排除了pathsshims部分。我们将专注于对 Require 的调用如下:

require([
    'BoardSalesApp', 'tinyioc', 'snippetservice'
    ,'text!/tscode/app/views/PageViewLayout.html' ],
    (app, tinyioc, snippetservice, pageViewLayoutSnippet) => {

     var snippetService = new SnippetService();
     snippetService.storeSnippet(
          SnippetKey.PAGE_VIEW_LAYOUT_SNIPPET,
          pageViewLayoutSnippet);
     TypeScriptTinyIoC.register(snippetService, IISnippetService);

     var boardSalesApp = new app.BoardSalesApp();
     boardSalesApp.start();

    });

在这里,我们包括了BoardSalesApptinyiocsnippetservice,以及我们的PageViewLayout.html片段在 require 的调用中。然后我们设置了SnippetService,将pageViewLayoutSnippet存储在正确的键下,并将SnippetService注册到我们的服务定位器中。为了启动我们的 Marionette 应用程序,我们创建了BoardSalesApp的一个新实例,并调用start。一旦调用了start方法,Marionette 将触发我们的BoardSalesApp.onStart方法,然后渲染PageViewLayout类。

加载主要集合

在这个应用程序中,我们将只加载我们的ManufacturerCollection一次,然后重复使用这个“全局”集合进行过滤。现在让我们更新我们的BoardSalesApp,以包括这个“全局”集合,并在应用程序启动时加载它。再次参考完整清单的示例代码:

export class BoardSalesApp extends Marionette.Application {
    viewLayout: pvl.PageViewLayout;
    _manufCollection: bm.ManufacturerCollection;

    constructor(options?: any) {
        if (!options)
            options = {};
        super();
        _.bindAll(this, 'CollectionLoaded');
        _.bindAll(this, 'CollectionLoadError');
        this.viewLayout = new pvl.PageViewLayout();
    }

    onStart() {
        this.viewLayout.render();
        this._manufCollection = new bm.ManufacturerCollection();
        TypeScriptTinyIoC.register(this._manufCollection, 
            bm.IIManufacturerCollection);
        this._manufCollection.fetch({ 
            success: this.CollectionLoaded, 
            error: this.CollectionLoadError });
    }

    CollectionLoaded() {
        TypeScriptTinyIoC.raiseEvent(
            new ev.NotifyEvent(
                ev.EventType.ManufacturerDataLoaded), ev.IINotifyEvent);
    }

    CollectionLoadError(err) {
        TypeScriptTinyIoC.raiseEvent(
           new ev.ErrorEvent(err), ev.IIErrorEvent);
    }
}

我们已经更新了我们的BoardSalesApp,在私有变量_manufCollection中存储了ManufacturerCollection类的一个实例。我们的onStart函数已经更新,以在调用viewLayout.render之后实例化这个集合。注意下一个对TypeScriptTinyIoC的调用。我们正在注册this._manufCollection作为一个将实现IIManufacturerCollection命名接口的服务。然后我们在集合上调用 Backbone 的fetch函数,带有successerror回调。success回调和error回调都只是触发一个事件。

通过将我们的ManufacturerCollection类的实例注册到命名接口IIManufacturerCollection,我们的任何需要访问主要集合的类都可以简单地从我们的服务定位器中请求此类的实例。这些命名接口如下:

export interface IManufacturerCollection {
    models: ManufacturerModel[];
}
export class IIManufacturerCollection implements IInterfaceChecker {
    propertyNames = ['models'];
    className = 'IIManufacturerCollection';
}

我们还需要修改我们的ManufacturerCollection类以实现IManufacturerCollection接口,如下所示:

export class ManufacturerCollection extends Backbone.Collection<ManufacturerModel>
    implements IManufacturerCollection
{
    // existing code
}

现在让我们来看一下将从我们的successerror回调中触发的事件。在success函数回调中,我们正在引发INotifyEvent类型的事件。请注意,我们在这里只列出接口定义—有关相应的IInterfaceChecker类和事件类,请参考附带的源代码:

export enum EventType {
    ManufacturerDataLoaded,
    ErrorEvent
}
export interface INotifyEvent {
    eventType: EventType;
}
export interface INotifyEvent_Handler {
    handle_NotifyEvent(event: INotifyEvent): void;
}

在这里,我们定义了一个EventType枚举来保存事件类型,然后定义了一个INotifyEvent接口,它只包含一个名为eventType的属性。我们还定义了相应的INotifyEvent_Handler接口,任何处理程序都需要实现。

我们的错误事件将使用继承从这些接口派生如下:

export interface IErrorEvent extends INotifyEvent {
    errorMessage: string;
}
export interface IErrorEvent_Handler {
    handle_ErrorEvent(event: IErrorEvent);
}

在这里,我们从INotifyEvent派生IErrorEvent接口,从而重用基接口的EventType枚举和属性。

现在我们可以在我们的PageViewLayout类中响应这些事件:

export class PageViewLayout extends Marionette.LayoutView<Backbone.Model>
    implements ev.INotifyEvent_Handler
{

    private _manufacturerView: mv.ManufacturerCollectionView;

    constructor(options?: any) {
        // exising code
        _.bindAll(this, 'handle_NotifyEvent');
        TypeScriptTinyIoC.registerHandler(
            this, ev.IINotifyEvent_Handler, ev.IINotifyEvent);
    }
    handle_NotifyEvent(event: ev.INotifyEvent) {
        if (event.eventType == ev.EventType.ManufacturerDataLoaded) 
        {
            this._manufacturerView =
                new mv.ManufacturerCollectionView();
            this._manufacturerView.render();
        }
    }
}

我们已经实现了INotifyEvent_Handler接口,并在TypeScriptTinyIoC中为IINotifyEvent注册了。我们的handle_NotifyEvent类将检查事件类型是否为ManufacturerDataLoaded事件,然后创建ManufacturerCollectionView类的实例并将其渲染到 DOM 中。

Marionette 视图

Marionette 提供了许多不同的视图类供我们使用,根据我们需要渲染到 DOM 的对象类型。任何需要渲染Backbone.Collection的类都可以使用CollectionView,任何需要渲染此集合中的单个项目的类都可以使用ItemView。Marionette 还提供了这两种视图的混合称为CompositeView。如果我们看一下我们的演示应用程序,我们将能够将我们的屏幕分解为许多逻辑视图,如下所示:

Marionette views

带有 Marionette 视图覆盖的板列表视图

我们需要构建的视图的确定与我们为 Backbone 集合和模型设置的数据结构密切相关。当我们将前面的视图叠加在我们的ManufacturerCollection类的类图上时,这种关系显而易见:

Marionette views

具有相应 Marionette 视图的模型类图

ManufacturerCollectionView 类

我们从ManufacturerCollectionView开始,这是一个渲染整个ManufacturerCollection的视图。我们还需要一个ManufacturerView来渲染特定的ManufacturerModel,然后是一个BoardView来渲染制造商武器库中的每个板。每个板都有一个内部的BoardSize对象数组,因此我们将创建一个BoardSizeView来渲染这些项目。

让我们开始构建这些视图,从ManufacturerCollectionView开始:

export class ManufacturerCollectionView
    extends Marionette.CollectionView<bm.ManufacturerModel> {
    constructor(options?: any) {
        if (!options)
            options = {};
        options.el = '#manufacturer_collection';
        options.className = "row board_row";

        super(options);
        this.childView = ManufacturerView;

        var manufColl: bm.IManufacturerCollection = 
           TypeScriptTinyIoC.resolve(bm.IIManufacturerCollection);
        if (!options.collection) {
            this.collection = <Backbone.Collection<bm.ManufacturerModel>> manufColl;
        } else {
            this.collection = options.collection;
        }
    }
}

这个类扩展自Marionette.CollectionView,并将我们的ManufacturerModel指定为类的泛型类型。我们的constructorel属性设置为"#manufacturer_collection"options对象。正如我们在PageLayoutView中看到的,Marionette 将使用此属性将整个集合渲染到 DOM 中。我们还在我们的options中设置了一个className属性。Marionette 将使用className属性将class="…"属性附加到外部 DOM 元素。这将在渲染的 HTML 中将CSS样式应用于manufacturer_collection元素的rowboard_row。一旦我们正确构造了我们的options,我们调用super(options)将这些选项传递给基类构造函数。

CollectionViewchildView属性指示 Marionette 为集合中找到的每个元素创建我们指定的类的实例。我们将这个childView属性设置为ManfuacturerView,因此 Marionette 将为集合中的每个元素构造一个新的ManufacturerView

最后,在我们的构造函数中,我们使用我们的服务定位器模式查找ManufacturerCollection服务的一个实例,然后将内部的this.collection属性设置为返回的对象。一旦我们定义了childView类名,并设置了this.collection属性,Marionette 将自动创建我们的子视图的实例,并将它们呈现到 DOM 中。

请注意,对于CollectionView,我们不需要 HTML 模板或片段。这是因为我们将单个项目的渲染推迟到childView类。

ManufacturerView 类

我们的childViewManufacturerView如下:

export class ManufacturerView
    extends Marionette.CompositeView<Backbone.Model> {
    constructor(options?: any) {
        if (!options)
            options = {};
        options.template = _.template('<div></div>');
        super(options);
        this.collection = new Backbone.Collection(
            this.model.get('boards')
        );
        this.childView = BoardView;
        this.childViewOptions = { 
            parentIcon: this.model.get('manufacturer_logo')
        };
    }
}

在这种情况下,我们从Marionette.CompositeView派生我们的视图,并使用标准的Backbone.Model作为通用类型。因为我们的板列表视图中有多个制造商,我们实际上不需要为每个制造商渲染任何特定的内容。因此,我们的模板是一个简单的<div></div>

这个视图的重要部分是为我们的boards数组设置一个新的Backbone.Collection,然后设置一个childView类来渲染集合中的每个board。我们的childView属性设置为BoardView,我们还设置了一个childViewOptions属性,将通过它传递给每个BoardView实例。请记住,每个BoardView显示制造商的标志,但这个标志图像是在制造商级别保存的。因此,我们需要将这些信息传递给每个创建的BoardView。Marionette 允许我们使用childViewOptions属性将任何额外的属性传递给子视图。在这里,我们在childViewOptions对象中定义了一个parentIcon属性,以便将制造商的标志传递给每个子BoardView类的实例。然后,这个parentIcon属性将通过options参数对子视图可用。

BoardView 类

我们的BoardView类也是一个CompositeView,如下所示:

export class BoardView
    extends Marionette.CompositeView<bm.BoardModel> {
    constructor(options?: any) {
        if (!options)
            options = {};
            var snippetService: ISnippetService =
               TypeScriptTinyIoC.resolve(IISnippetService);
            options.template = _.template(
               snippetService.retrieveSnippet(
                  SnippetKey.BOARD_VIEW_SNIPPET)
            );
        super(options);

        this.model.set('parentIcon', options.parentIcon);

         this.collection =
            <any>(new Backbone.Collection(
                this.model.get('sizes')));
        this.childView = BoardSizeView;
        this.childViewContainer = 'tbody';

        var snippetService: ISnippetService = 
             TypeScriptTinyIoC.resolve(IISnippetService);
        this.childViewOptions = { 
             template: _.template(
                  snippetService.retrieveSnippet(
                      SnippetKey.BOARD_SIZE_MINI_VIEW_SNIPPET)
                )
        };

    }

}

这个BoardView构造函数做了几件事。首先,它检索名为BOARD_VIEW_SNIPPET的片段,用作自己的template。然后,它设置一个内部模型属性parentIcon,用于存储通过父视图的options参数传递的parentIcon属性。然后,我们为sizes数组创建一个新的Backbone.Collection,并将childView属性设置为BoardSizeViewchildViewContainer属性告诉 Marionette 在我们的片段中有一个<tbody></tbody>的 HTML div,它应该用来渲染任何childView。最后,我们检索另一个名为BOARD_SIZE_MINI_VIEW_SNIPPET的片段,并将这个片段作为template属性传递给childView

BoardSizeView类不是解析自己的 HTML 片段,而是将控制权移动到类层次结构的父类BoardSizeView的父类。这使我们能够在摘要视图中重用BoardSizeView类,以及在稍后将讨论的BoardDetailView中重用。由于摘要大小视图和详细大小视图的内部数据模型是相同的,唯一需要改变的是我们的 HTML 模板。因此,我们使用childViewOption属性将此模板传递到BoardSizeView中,就像我们之前看到的那样。

BoardSizeView 类

我们的BoardSizeView类非常简单,如下所示:

export class BoardSizeView
    extends Marionette.ItemView<bm.BoardSize> {
    constructor(options?: any) {
        if (!options)
            options = {};
        super(options);
    }
}

这个类只是一个ItemView,它使用BoardSize模型作为通用类型。在这个类中我们没有任何自定义代码,而是简单地将它作为前面的BoardView类中的一个命名的childView

现在让我们来看看我们将需要为每个视图准备的 HTML 片段。首先是我们的BoardViewSnippet.html。同样,您可以在附带的源代码中找到完整的片段。BoardViewSnippet.html的一般结构如下:

<div class="col-sm-4 board_panel">
    <div class="board_inner_panel">
         <div class="row board_title_row">
         <!- -some divs just for styling here -->
            <%= name %>
         <!- -some divs just for styling here -->
            <%= description %>
            <img src="img/<%= parentIcon %>" />
         </div>
         <div class="row board_details_row">
            <a >
                <img src="img/<%= image %>" />
            </a>
         <!- -some divs just for styling here -->
             Sizes:
             <table>
                <tbody></tbody>
             </table>
         </div>
    </div>
</div>

在这个片段中,我们包含了<%= name %><%= description %><%= parentIcon %><%= image %>语法作为我们模型属性的占位符。在片段的底部附近,我们创建了一个带有空的<tbody></tbody>标记的表。这个标记对应于我们在BoardView类中使用的childViewContainer属性,Marionette 将每个BoardSizeView项目呈现到这个<tbody>标记中。

我们的BoardSizeMiniViewSnippet.html如下:

<tr>
    <td>&nbsp;</td>
    <td><%= volume %> L</td>
</tr>

在这里,我们只对BoardSize模型的<%= volume %>属性感兴趣。有了这些视图类和两个片段,我们的板列表视图就完成了。我们需要做的就是在我们的require.config块中加载这些片段,并将适当的片段存储在我们的SnippetService实例上:

require([
    'BoardSalesApp', 'tinyioc', 'snippetservice'
    , 'text!/tscode/app/views/PageViewLayout.html'
    , 'text!/tscode/app/views/BoardViewSnippet.html'
    , 'text!/tscode/app/views/BoardSizeMiniViewSnippet.html'
    ],(app, tinyioc, snippetservice, pageViewLayoutSnippet
      , boardViewSnippet, bsMiniViewSnippet) => {

        var snippetService = new SnippetService();
        snippetService.storeSnippet(
            SnippetKey.PAGE_VIEW_LAYOUT_SNIPPET,
                pageViewLayoutSnippet);
        snippetService.storeSnippet(
            SnippetKey.BOARD_VIEW_SNIPPET, boardViewSnippet);
        snippetService.storeSnippet(
            SnippetKey.BOARD_SIZE_MINI_VIEW_SNIPPET,
                bsMiniViewSnippet);

        var boardSalesApp = new app.BoardSalesApp();
        boardSalesApp.start();

    });

使用 IFilterProvider 接口进行过滤

当我们组合ManufacturerCollection类时,我们编写了两个函数来查询数据结构,并返回制造商和板类型的列表。这两个函数分别称为findManufacturerNamesfindBoardTypes。我们的新FilterCollection类将需要调用这些方法来从我们的“全局”数据集中检索过滤器值。

我们可以以两种方式实现这个功能。一种方式是通过IIManufacturerCollection命名接口获取对全局ManufacturerCollection实例的引用。然而,这个选项意味着FilterCollection的代码需要理解ManufacturerCollection的代码。实现这个功能的更好方式是获取对IFilterProvider接口的引用。然后,这个接口将只公开我们构建过滤器列表所需的两个方法。让我们采用这种第二种方法,并定义一个命名接口,如下所示:

export interface IFilterProvider {
    findManufacturerNames(): bm.IManufacturerName[];
    findBoardTypes(): string[]
}
export class IIFilterProvider implements IInterfaceChecker {
    methodNames = ['findManufacturerNames', 'findBoardTypes'];
    className = 'IIFilterProvider';
}

然后我们可以简单地修改现有的ManufacturerCollection以实现这个接口(它已经这样做了):

export class ManufacturerCollection extends Backbone.Collection<ManufacturerModel>
    implements IManufacturerCollection, fm.IFilterProvider
{
    // existing code
}

我们现在可以在我们的BoardSalesApp.onStart方法中使用TypeScriptTinyIoC注册ManufacturerCollectionIIFilterProvider命名接口,如下所示:

onStart() {
        this.viewLayout.render();
        this._manufCollection = new bm.ManufacturerCollection();
        TypeScriptTinyIoC.register(this._manufCollection, bm.IIManufacturerCollection);
        TypeScriptTinyIoC.register(this._manufCollection,
            fm.IIFilterProvider);
        this._manufCollection.fetch({ 
            success: this.CollectionLoaded, error: this.CollectionLoadError });
}

我们现在已经注册了我们的ManufacturerCollection来提供名为IIManfacturerCollection的接口,以及名为IIFilterProvider的接口。

FilterCollection 类

然后,我们的FilterCollection可以在其构造函数中解析IIFilterProvider接口,如下所示:

export class FilterCollection extends Backbone.Collection<FilterModel> {
    model = FilterModel;

    private _filterProvider: IFilterProvider;
    constructor(options?: any) {
        super(options);
        try {
            this._filterProvider = 
            TypeScriptTinyIoC.resolve(IIFilterProvider);
        } catch (err) {
            console.log(err);
        }
    }
}

在这里,我们将调用TypeScriptTinyIoC返回的类存储在名为_filterProvider的私有变量中。通过为FilterProvider定义这些接口,我们现在可以使用模拟FilterProvider对我们的FilterCollection进行单元测试,如下所示:

class MockFilterProvider implements fm.IFilterProvider {
    findManufacturerNames(): bm.IManufacturerName[] {
        return [ 
        { manufacturer: 'testManuf1',
          manufacturer_logo: 'testLogo1'}, { manufacturer: 'testManuf2',
          manufacturer_logo: 'testLogo2' }
        ];
    }
    findBoardTypes(): string[] {
        return ['boardType1', 'boardType2', 'boardType3'];
    }
}
describe('/tscode/tests/models/FilterModelTests',() => {
    beforeAll(() => {
        var mockFilterProvider = new MockFilterProvider();
        TypeScriptTinyIoC.register(
            mockFilterProvider, fm.IIFilterProvider);
    });
});

在我们的测试设置中,我们创建了一个实现我们的IFilterProvider接口的MockFilterProvider,并为我们的测试目的注册了它。通过使用模拟提供程序,我们还知道在我们的测试中可以期望什么数据。我们的实际测试将如下所示:

describe("FilterCollection tests",() => {
    var filterCollection: fm.FilterCollection;
    beforeAll(() => {
        filterCollection = new fm.FilterCollection();
        filterCollection.buildFilterCollection();
    });

    it("should have two manufacturers", () => {
        var manufFilter = filterCollection.at(0);
        expect(manufFilter.filterType)
           .toBe(fm.FilterType.Manufacturer);
        expect(manufFilter.filterValues[0].filterValue)
           .toContain('testManuf1');
    });

    it("should have two board types",() => {
        var manufFilter = filterCollection.at(1);
        expect(manufFilter.filterType)
           .toBe(fm.FilterType.BoardType);
        expect(manufFilter.filterValues[0].filterValue)
           .toContain('boardType1');
    });
});

这些测试从创建FilterCollectionClass的实例开始,然后调用buildFilterCollection函数。然后我们测试集合在索引0处是否有FilterType.Manufacturer,以及预期值。有了这些失败的测试,我们可以完善buildFilterCollection函数:

buildFilterCollection() {
    // build Manufacturer filter.
    var manufFilter = new FilterModel({
        filterType: FilterType.Manufacturer,
        filterName: "Manufacturer"
    });
    var manufArray = new Array<FilterValue>();
    if (this._filterProvider) {
        _(this._filterProvider.findManufacturerNames())
            .each((manuf) => {
                manufArray.push(new FilterValue(
                    { filterValue: manuf.manufacturer }));
        });
        manufFilter.filterValues = manufArray;
    }
    this.push(manufFilter);
    // build Board filter.
    var boardFilter = new FilterModel({
        filterType: FilterType.BoardType,
        filterName: "Board Type"
    });
	var boardTypeArray = new Array<FilterValue>();
    if (this._filterProvider) {
        _(this._filterProvider.findBoardTypes()).each((boardType) =>
        {
            boardTypeArray.push(new FilterValue(
                { filterValue: boardType }));
        });
        boardFilter.filterValues = boardTypeArray;
    }
    this.push(boardFilter);
    // build All filter to clear filters.
    var noFilter = new FilterModel({
        filterType: FilterType.None,
        filterName: "All"
    });
    var noTypeArray = new Array<FilterValue>();
    noTypeArray.push(new FilterValue({ filterValue: "Show All" }));
    noFilter.filterValues = noTypeArray;
    this.push(noFilter);
}

我们的buildFilterCollection函数正在创建三个FilterModel的实例。第一个实例名为manufFilter,其filterType设置为FilterType.Manufacturer,并使用_filterProvider.findManufacterNames函数来构建此FilterModel的值。然后通过调用this.push(manufFilter)manufFilter实例添加到内部collection中。第二个和第三个FilterModel实例的filterType分别设置为FilterType.BoardTypeFilterType.None

过滤视图

当我们将视图叠加在我们的 Backbone 模型上时,我们需要实现的 Marionette 视图之间的关系很容易可视化如下:

过滤视图

显示相关 Marionette 视图的过滤类图

第一个视图名为FilterCollectionView,将从CollectionView派生,并将与我们的顶级FilterCollection绑定。第二个视图名为FilterModelView,将是一个CompositeView,并将每个FilterType呈现到其自己的手风琴标题中。第三个和最后一个视图将是每个过滤选项的ItemView,名为 FilterItemView。

构建这些 Marionette 视图的过程与我们之前对制造商和板视图所做的工作非常相似。因此,我们不会在这里详细介绍每个视图的实现。请务必参考本章附带的示例代码,以获取这些视图及其相关 HTML 片段的完整列表。

现在我们在左侧面板上呈现了我们的过滤器,我们需要能够响应FilterItemView上的点击事件,并触发实际的过滤代码。

Marionette 中的 DOM 事件

Marionette 提供了一个简单的语法来捕获 DOM 事件。任何视图都有一个名为events的内部属性,它将把 DOM 事件绑定到我们的 Marionette 视图上。然后,我们的FilterItemView可以更新以响应 DOM 事件,如下所示:

export class FilterItemView
    extends Marionette.ItemView<fm.FilterValue> {
    private _filterType: number;
    constructor(options?: any) {
        if (!options)
            options = {};
        options.tagName = "li";
        options.template = 
            _.template('<a><%= filterValue %></a>');

        options.events = { click: 'filterClicked' };
        this._filterType = options.filterType;
        super(options);
        _.bindAll(this, 'filterClicked');

    }
    filterClicked() {
        TypeScriptTinyIoC.raiseEvent(
            new bae.FilterEvent(
                this.model.get('filterValue'),
                    this._filterType),
            bae.IIFilterEvent);
    }
}

我们已经向我们的options对象添加了一个events属性,并为click DOM 事件注册了一个处理程序函数。每当有人点击FilterItemView时,Marionette 将调用filterClicked函数。我们还为此事件添加了一个_.bindAll调用,以确保在调用filterClicked函数时,this变量被限定为类实例。

请记住,每个FilterItemView的实例都可以通过内部的model属性获得相应的FilterValue模型。因此,在我们的filterClicked函数中,我们只是使用内部model变量的属性来引发一个新的FilterEvent

我们的事件定义接口如下 - 再次,请参考匹配的IInterfaceChecker定义的示例代码:

export interface IFilterEvent {
    filterType: fm.FilterType;
    filterName: string;
}
export interface IFilterEvent_Handler {
    handle_FilterEvent(event: IFilterEvent);
}

现在我们可以在代码的其他地方注册这些过滤器事件的处理程序。将此事件处理程序放在PageViewLayout本身上是一个合乎逻辑的地方,因为这个类负责呈现板列表。我们将在PageViewLayout上定义我们的handle_FilterEvent函数如下:

handle_FilterEvent(event: ev.IFilterEvent) {

    var mainCollection: bm.ManufacturerCollection =
        TypeScriptTinyIoC.resolve(bm.IIManufacturerCollection);
    var filteredCollection;
    if (event.filterType == fm.FilterType.BoardType)
        filteredCollection = new bm.ManufacturerCollection(
            mainCollection.filterByBoardType(event.filterName));
    else if (event.filterType == fm.FilterType.Manufacturer)
        filteredCollection = new bm.ManufacturerCollection(
            mainCollection.filterByManufacturer(event.filterName));
    else if (event.filterType == fm.FilterType.None)
        filteredCollection = mainCollection;

    this._manufacturerView.collection = filteredCollection;
    this._manufacturerView.render();
}

该功能首先通过获取对我们“全局”注册的ManufacturerCollection的引用来开始。然后,我们定义一个名为filteredCollection的变量来保存我们对主ManufacturerCollection进行过滤的版本。根据事件本身的FilterType,我们调用filterByBoardTypefilterByManufacturer。如果事件类型是FilterType.None,我们只需将filteredCollection设置为mainCollection,有效地清除所有过滤器。

该函数的最后部分将我们主视图(this._manufacturerView)的内部collection属性设置为结果filteredCollection,然后调用render

我们的应用程序现在正在响应FilterItemView上的点击事件,触发一个事件,并重新渲染ManufacturerView,以便将所选的过滤器应用于我们的数据进行渲染。

触发详细视图事件

然而,我们还需要响应另一个点击事件。当用户点击特定的面板时,我们需要触发一个事件,将面板滑动过去,并显示详细的面板视图。

在我们继续讨论详细视图以及如何渲染它之前,让我们首先在BoardView类上挂接一个点击事件。为此,我们只需要在BoardView类的options.events参数上指定一个点击事件处理程序,类似于我们之前的点击事件处理程序。我们还需要创建一个onClicked函数,如下所示:

export class BoardView
    extends Marionette.CompositeView<bm.BoardModel> {
    constructor(options?: any) {
        // existing code
        options.events = {
            "click": this.onClicked,
        };

        super(options);

        // existing code
        _.bindAll(this, 'onClicked');
    }

    onClicked() {
        this.$el.find('.board_inner_panel').flip({
            direction: 'lr',
            speed: 100,
            onEnd: () => {
            TypeScriptTinyIoC.raiseEvent(
                new bae.BoardSelectedEvent(this.model),
                    bae.IIBoardSelectedEvent);
            }
        });
    }
}

对这个类的更改非常小,我们只需正确设置options上的events属性,发出对_.bindAll的调用,就像我们在FilterItem代码中所做的那样,然后编写一个onClicked函数。这个onClicked函数发出一个调用flip,就像我们在第七章中看到的那样,模块化,然后触发一个新的BoardSelectedEvent。我们的BoardSelectedEvent接口和处理程序接口如下-再次,请参考示例代码以获取匹配的IInterfaceChecker定义:

export interface IBoardSelectEvent {
    selectedBoard: bm.BoardModel;
}
export interface IBoardSelectedEvent_Handler {
    handle_BoardSelectedEvent(event: IBoardSelectEvent);
}

BoardSelectedEvent只是包含整个BoardModel本身,在selectedBoard属性中。有了这些事件接口和类,我们现在可以在代码的任何地方注册BoardSelectedEvent

渲染 BoardDetailView

在这个应用程序中,处理BoardSelectedEvent的逻辑位置应该是在PageViewLayout中,因为它负责循环轮播面板,并渲染BoardDetailView。让我们按照以下方式更新这个类:

export class PageViewLayout extends Marionette.LayoutView<Backbone.Model>
    implements ev.INotifyEvent_Handler,
    ev.IBoardSelectedEvent_Handler,
    ev.IFilterEvent_Handler
{
    // existing code
    constructor(options?: any) {
        // existing code
        _.bindAll(this, 'handle_NotifyEvent');
        _.bindAll(this, 'handle_BoardSelectedEvent');
        TypeScriptTinyIoC.registerHandler(this, ev.IINotifyEvent_Handler, ev.IINotifyEvent);
        TypeScriptTinyIoC.registerHandler(this,
            ev.IIBoardSelectedEvent_Handler,
            ev.IIBoardSelectedEvent);
    }
    handle_BoardSelectedEvent(event: ev.IBoardSelectEvent) {
        var boardDetailView = new bdv.BoardDetailView(
            { model: event.selectedBoard });
        boardDetailView.render();
    }
}

在这里,我们已经更新了我们的PageViewLayout类以实现IBoardSelectedEvent_Hander接口,并将其注册到TypeScriptTinyIoC。我们通过创建一个新的BoardDetailView类来响应BoardSelectedEvent,使用事件中包含的完整BoardModel,然后调用render。我们的BoardDetailView类如下:

export class BoardDetailView
    extends Marionette.CompositeView<bm.BoardSize> {
    constructor(options?: any) {
        if (!options)
            options = {};

        options.el = "#board_detail_view";
        var snippetService: ISnippetService = 
            TypeScriptTinyIoC.resolve(IISnippetService);
        options.template = _.template(
            snippetService.retrieveSnippet(
                SnippetKey.BOARD_DETAIL_VIEW_SNIPPET));

        super(options);

        this.collection = <any>(
            new Backbone.Collection(this.model.get('sizes')));
        this.childView = mv.BoardSizeView;
        this.childViewContainer = 'tbody';

        var snippetService: ISnippetService = 
            TypeScriptTinyIoC.resolve(IISnippetService);
        this.childViewOptions = { 
               template: _.template(
                  snippetService.retrieveSnippet(
                    SnippetKey.BOARD_SIZE_VIEW_SNIPPET)), tagName: 'tr'
        };
    }

}

BoardDetailView类与我们的BoardView非常相似,但它使用"#board_detail_view"元素作为options.el属性,这是我们对应的 DOM 元素。我们的片段具有BOARD_DETAIL_VIEW_SNIPPET键。然后我们从sizes属性创建一个Backbone.Collection,并将childView设置为BoardSize视图类模板,就像我们之前为BoardView所做的那样。

然而,我们的childViewContainer现在将目标定位到<tbody></tbody>标签以渲染子元素。我们还将模板从BOARD_SIZE_VIEW_SNIPPET传递给子BoardSize视图,并将tagName设置为'tr'。还记得我们如何将子BoardSize视图的配置移到BoardView中吗?嗯,我们在这里做同样的事情。

有关BoardDetailViewSnippet.htmlBoardSizeViewSnippet.html的完整清单,请参考示例代码。

状态设计模式

我们这个应用程序的最后一个任务是在用户与我们的应用程序交互时控制各种屏幕元素。当用户导航应用程序时,我们需要从轮播面板 1 移动到轮播面板 2,并更新屏幕元素,例如显示和隐藏左侧的过滤面板。在大型 Web 应用程序中,可能会有许多屏幕元素,许多不同的过渡效果,以及诸如弹出窗口或遮罩等内容,显示“加载中…”,而我们的应用程序从后端服务获取数据。跟踪所有这些元素变得困难且耗时,通常会在代码的许多不同区域留下大量的 if-else 或 switch 语句,导致大量直接的 DOM 操作混乱。

状态设计模式是一种可以简化我们应用程序代码的设计模式,这样可以将操作这些不同 DOM 元素的代码放在一个地方。状态设计模式定义了应用程序可能处于的一组状态,并提供了一种简单的机制来在这些状态之间进行转换,控制视觉屏幕元素,并处理动画。

问题空间

作为我们试图实现的一个例子,考虑以下业务规则:

  • 当用户首次登录到桌面上的 BoardSales 应用程序时,左侧的筛选面板应该可见。

  • 如果用户使用移动设备,当用户首次登录时,左侧的筛选面板不应该可见。这样做是为了节省屏幕空间。

  • 如果筛选面板可见,则展开图标应该切换为左箭头(<),以允许用户隐藏它。

  • 如果筛选面板不可见,则展开图标应该是右箭头(>),以允许用户显示它。

  • 如果用户展开了筛选面板,然后切换到看板详细视图,然后再切回来,那么筛选面板应该保持展开状态。

  • 如果用户隐藏了筛选面板,然后切换到看板详细视图,然后再切回来,那么筛选面板应该保持隐藏状态。

除了这些业务规则之外,我们还有一个已经报告给使用 Firefox 浏览器的用户的未解决 bug(您可以使用演示 HTML 页面测试此行为):

在看板列表视图中点击一个看板时,如果筛选面板是打开的,轮播面板就不会正确地行为。轮播首先跨越到看板详细视图,然后关闭筛选面板。这种转换与其他浏览器不一致,在其他浏览器中,筛选面板与看板列表同时循环。

因此,这个 bug 给我们的清单增加了另一个业务需求:

  • 对于使用 Firefox 浏览器的用户,请在循环轮播到看板详细视图之前先隐藏筛选面板。

状态设计模式使用一组非常相似的类,每个类代表特定的应用程序状态。这些状态类都是从同一个基类派生的。当我们希望应用程序切换到不同的状态时,我们只需切换到表示我们感兴趣的状态的对象。

例如,我们的应用实际上只有三种状态。我们有一个状态,其中看板列表和筛选面板都是可见的。我们有另一个状态,只有看板列表是可见的,我们的第三个状态是看板详细面板可见。根据我们所处的状态,我们应该在carousel_panel_1上,或者在carousel_panel_2上。此外,与筛选面板一起使用的图标需要根据应用程序状态从左手的尖角<切换到右手的尖角>

状态设计模式还有一个中介者类的概念,它将跟踪当前状态,并包含如何在这些状态之间切换的逻辑。

状态类图

考虑以下状态和中介者设计模式的类图:

状态类图

状态和中介者模式类图

我们从一个名为StateType的枚举开始,列出了我们的三种应用程序状态,第二个名为PanelType的枚举用于指示每个状态所在的轮播面板。然后,我们定义了一个名为IState的接口,每个状态都必须实现该接口。为了保存每个状态的公共属性,我们还定义了一个名为State的基类,所有状态都将从中派生。我们的实现如下所示:这些枚举,IState接口和基类State

export enum StateType {
    BoardListOnly,
    BoardListWithFilter,
    BoardDetail,
}
export enum PanelType { Initial, Secondary }
export interface IState {
    getPanelType(): PanelType;
    getStateType(): StateType;
    getShowFilterClass(): string;
    isFilterPanelVisible(): boolean;
}
export class State {
    private _mediator: sm.Mediator;
    constructor(mediator: sm.Mediator) {
        this._mediator = mediator;
    }
}

我们的StateType枚举已经定义了我们将使用的每个状态。因此,我们的应用程序可能处于BoardListOnly状态、BoardListWithFilter状态或BoardDetail状态。我们的第二个枚举,名为PanelType,用于指示我们当前位于哪个旋转木马面板,即Initial面板(carousel_panel_1)或Secondary面板(carousel_panel_2)。

然后我们定义了一个IState接口,所有状态对象都必须实现。此接口允许我们查询每个状态,并确定四个重要信息。 getPanelType函数告诉我们我们当前应该查看哪个面板,getStateType函数返回StateType枚举值。 getShowFilterClass函数将返回一个字符串,用于将 CSS 类应用于显示/隐藏过滤按钮,isFilterPanelVisible函数返回一个布尔值,指示过滤面板是否可见。

每个状态都需要引用“中介者”类,因此我们创建了一个带有constructor函数的基本State类,从中可以派生出我们的每个 State 对象。

具体状态类

现在让我们为每个状态创建具体类。我们的应用程序可能处于的第一个状态是,当我们查看看板列表时,过滤面板是隐藏的:

export class BoardListOnlyState
    extends ss.State
    implements ss.IState {
    constructor(mediator: sm.Mediator) {
        super(mediator);
    }
    getPanelType(): ss.PanelType {
        return ss.PanelType.Initial;
    }
    getShowFilterClass() {
        return "glyphicon-chevron-right";
    }
    isFilterPanelVisible(): boolean {
        return false;
    }
    getStateType(): ss.StateType {
        return ss.StateType.BoardListOnly;
    }
}

我们的BoardListOnlyState类扩展了我们之前定义的State类,并实现了IState接口。在这种BoardListOnly状态下,我们应该在Initial旋转木马面板上,用于显示/隐藏过滤面板按钮的类应该是glyphicon-chevron-right [ > ],左侧的过滤面板不应该可见。

我们的应用程序可能处于的下一个状态是,当看板列表显示时,我们还可以看到过滤面板:

export class BoardListWithFilterPanelState
    extends ss.State 
    implements ss.IState {
    constructor(mediator: sm.Mediator) {
        super(mediator);
    }
    getPanelType(): ss.PanelType {
        return ss.PanelType.Initial;
    }
    getShowFilterClass() {
        return "glyphicon-chevron-left";
    }
    isFilterPanelVisible(): boolean {
        return true;
    }
    getStateType(): ss.StateType {
        return ss.StateType.BoardListWithFilter;
    }
}

BoardListWithFilterPanel状态下,我们的旋转木马面板再次是Initial面板,但我们用于显示/隐藏过滤面板按钮的类现在是glyphicon-chevron-left(<)。我们的过滤面板也是可见的。

我们需要为我们的应用程序定义的最后一个状态是,当我们循环到carousel_panel_2并查看看板详细信息屏幕时:

export class DetailPanelState
    extends ss.State
    implements ss.IState {
    constructor(mediator: sm.Mediator) {
        super(mediator);
    }
    getPanelType(): ss.PanelType {
        return ss.PanelType.Secondary;
    }
    getShowFilterClass() {
        return "";
    }
    isFilterPanelVisible(): boolean {
        return false;
    }
    getStateType(): ss.StateType {
        return ss.StateType.BoardDetail;
    }
}

DetailPanel状态下,我们位于Secondary旋转木马面板上,我们不需要一个用于显示/隐藏过滤面板按钮的类(因为面板已经移出屏幕),过滤面板本身也不可见。

请注意,在示例应用程序源代码中,您将找到一系列单元测试,测试每个属性。出于简洁起见,我们在这里不列出它们。

中介者类

在面向对象的模式中,中介者用于封装一组对象交互的逻辑。在我们的情况下,我们有一组状态,定义了应该显示哪些视觉元素。还需要定义这些不同元素如何根据这些状态之间的移动进行过渡。

因此,我们将定义一个“中介者”类来封装所有这些过渡逻辑,并根据状态之间的移动协调对我们的视觉元素的更改。为了使我们的“中介者”类与 UI 交互,我们将定义一组四个函数,任何使用此“中介者”的类都必须实现:

export interface IMediatorFunctions {
    showLeftPanel();
    hideLeftPanel();
    cyclePanels(forwardOrNext: string);
    showFilterButtonChangeClass(
        fromClass: string, toClass: string
    );
}

我们的IMediatorFunctions接口有四个函数。showLeftPanel函数将显示我们的过滤面板。hideLeftPanel函数将隐藏过滤面板。cyclePanels函数将以'prev'字符串或'next'字符串调用,以将轮播面板从carousel_panel_1循环到carousel_panel_2showFilterButtonChangeClass将以两个参数调用——一个是 CSS 类的fromClass字符串,另一个是另一个 CSS 类的toClass字符串。这个函数将从 DOM 元素中删除fromClass CSS 类,然后将toClass CSS 类添加到 DOM 元素中。通过这种方式,我们可以将用于显示/隐藏过滤按钮的图标从 chevron-right(>)更改为 chevron-left(<)。

现在我们可以看一下Mediator类本身的内部逻辑,从一组私有变量和构造函数开始:

export class Mediator {
    private _currentState: ss.IState;
    private _currentMainPanelState: ss.IState;
    private _pageViewLayout: IMediatorFunctions;
    private _isMobile: boolean;

    private _mainPanelState: as.BoardListOnlyState;
    private _detailPanelState: as.DetailPanelState;
    private _filterPanelState: as.BoardListWithFilterPanelState;

    constructor(pageViewLayout: IMediatorFunctions,
        isMobile: boolean) {
        this._pageViewLayout = pageViewLayout;
        this._isMobile = isMobile;

        this._mainPanelState = new as.BoardListOnlyState(this);
        this._detailPanelState = new as.DetailPanelState(this);
        this._filterPanelState = new as.BoardListWithFilterPanelState(this);

        if (this._isMobile)
            this._currentState = this._mainPanelState;
        else
            this._currentState = this._filterPanelState;
        this._currentMainPanelState = this._currentState;
    }
}

我们的Mediator类有许多私有变量。_currentState变量用于保存我们State类之一的实例,并表示 UI 的当前状态。这个_currentState变量可以保存我们三个状态中的任何一个。_currentMainPanelState变量再次保存我们的State类之一,但表示主面板的当前状态。这个_currentMainPanelState只会保存BoardListOnlyStateBoardListWithFilterPanelState中的一个。

_pageViewLayout变量将保存实现我们的IMediatorFunctions接口的类的实例,我们将通过这个变量对 UI 应用状态变化。对于熟悉 MVP 模式的人来说,Mediator类充当 Presenter,_pageViewLayout变量充当 View。

_isMobile变量只是保存一个布尔值,指示我们是否在移动设备上。我们稍后会设置这个变量。

然后我们有三个私有变量,它们将保存我们三个状态的实例——BoardListOnlyStateDetailPanelStateBoardListWithFilterPanelState

我们的构造函数简单地设置了这些私有变量,然后实例化了我们每个状态类的一个实例,并将它们分配给正确的内部变量。

请注意构造函数底部附近的代码。这是我们一个业务规则的实现。如果应用程序在移动设备上查看,则过滤面板默认情况下不应可见。因此,我们将_currentState变量的值设置为初始状态之一,基于我们的isMobile标志。为了完成构造函数功能,我们还将_currentMainPanelState变量的初始值设置为_currentState

我们的下一个Mediator函数getNextState只是使用StateType枚举作为输入返回我们的私有State变量之一:

private getNextState(stateType: ss.StateType): ss.IState {
    var nextState: ss.IState;
    switch (stateType) {
       case ss.StateType.BoardDetail:
            nextState = this._detailPanelState;
            break;
        case ss.StateType.BoardListOnly:
            nextState = this._mainPanelState;
            break;
        case ss.StateType.BoardListWithFilter:
            nextState = this._filterPanelState;
    }
    return nextState;
}

这本质上是一个迷你工厂方法,将根据StateType参数的值返回正确的内部State对象。

转移到新状态

控制 UI 如何根据状态之间的移动更新的主要逻辑体现在moveToState函数中,如下所示:

public moveToState(stateType: ss.StateType) {
    var previousState = this._currentState;
    var nextState = this.getNextState(stateType);

    if (previousState.getPanelType() == ss.PanelType.Initial &&
        nextState.getPanelType() == ss.PanelType.Secondary) {
        this._pageViewLayout.hideLeftPanel();
        this._pageViewLayout.cyclePanels('next');
    }

    if (previousState.getPanelType() == ss.PanelType.Secondary &&
        nextState.getPanelType() == ss.PanelType.Initial) {
        this._pageViewLayout.cyclePanels('prev');
    }

    this._pageViewLayout.showFilterButtonChangeClass(
        previousState.getShowFilterClass(),
        nextState.getShowFilterClass()
    );

    if (nextState.isFilterPanelVisible())
        this._pageViewLayout.showLeftPanel();
    else
        this._pageViewLayout.hideLeftPanel();

    this._currentState = nextState;
    if (this._currentState.getStateType() == ss.StateType.BoardListOnly 
       || this._currentState.getStateType() == ss.StateType.BoardListWithFilter)
        this._currentMainPanelState = this._currentState;
}

这个函数将在我们想要从一个状态转换到另一个状态时调用。这个函数做的第一件事是设置两个变量:previousStatenextStatepreviousState变量实际上是我们当前的状态对象,而nextState变量是我们要转移到的状态的State对象。

现在我们可以比较previousState变量和nextState变量并做出一些决定。

我们第一个 if 语句的逻辑大致如下:如果我们从Initial面板类型移动到Secondary面板,则调用 UI 上的相关函数隐藏左侧面板,并启动轮播循环到'next'。这个逻辑将修复我们之前收到的 Firefox 错误。

我们第二个 if 语句的逻辑与第一个相反:如果我们从Secondary面板移动到Initial面板,那么就用'prev'来启动轮播循环。

我们逻辑的下一步是通过在 UI 上调用showFilterButtonChangeClass函数,将显示/隐藏过滤按钮的类应用到 UI 上,传入来自previousState的 CSS 类名和来自nextState的 CSS 类名作为参数。请记住,这将从previousState中移除 CSS 类,然后将nextState中的 CSS 类添加到显示/隐藏过滤按钮的 CSS 中。

我们的下一个逻辑步骤是检查过滤面板是否应该显示或隐藏,并在我们的_pageViewLayout上调用相应的函数。

由于我们现在已经完成了状态更改逻辑,并且可以将_currentState变量的值设置为持有我们的nextState

最后一部分逻辑只是检查我们当前是否处于BoardListOnlyBoardListWithFilter状态,如果是的话,将当前状态存储在_currentMainPanelState变量中。这个逻辑将成为我们已经给出的业务规则的一部分,以确保当我们从主面板切换到详细面板,然后再切换回来时,过滤面板的状态被正确地维护。

我们的Mediator类中还有两个要讨论的函数,如下所示:

public showHideFilterButtonClicked() {
    switch (this._currentState.getStateType()) {
        case ss.StateType.BoardListWithFilter:
            this.moveToState(ss.StateType.BoardListOnly);
            break;
        case ss.StateType.BoardListOnly:
            this.moveToState(ss.StateType.BoardListWithFilter);
            break;
    }
}

public getCurrentMainPanelState(): ss.IState {
    return this._currentMainPanelState;
}

第一个函数叫做showHideFilterButtonClicked,实际上是当我们在应用程序中点击显示/隐藏过滤按钮时需要调用的函数。根据过滤面板是打开还是关闭,此按钮的行为会略有不同。唯一知道根据应用程序的状态该做什么的对象是Mediator类本身。因此,我们将决定当按钮被点击时该做什么的决策推迟到Mediator类。

showHideFilterButtonClicked函数的实现只是检查我们当前的状态是什么,然后调用一个带有正确nextState作为参数的moveToState

注意

当构建大型应用程序时,可能会有许多不同的按钮或屏幕元素,这些元素会根据应用程序的状态稍有不同。将决策逻辑推迟到中介者类提供了一种简单而优雅的方式来管理所有屏幕元素。这个业务逻辑被捕获在一个地方,并且也可以得到充分的测试。一定要检查中介者类周围的完整测试套件的示例代码。

我们的最后一个函数getCurrentMainPanelState只是返回我们主面板的最后已知状态,并将用于实现业务逻辑,以记住过滤面板是打开还是关闭。

实现 IMediatorFunctions 接口

Mediator类需要触发对 UI 的更改时,它会调用IMediatorFunctions接口上的函数,就像我们之前看到的那样。因此,我们的应用程序必须在某个地方实现这个IMediatorFunctions接口。由于PageViewLayout类持有我们需要更改的每个 UI 元素的引用,因此实现这个接口的逻辑地方是在PageViewLayout类本身,如下所示:

export class PageViewLayout extends
    Marionette.LayoutView<Backbone.Model>
    implements ev.INotifyEvent_Handler,
    ev.IBoardSelectedEvent_Handler,
    ev.IFilterEvent_Handler,
    sm.IMediatorFunctions
{
    private _mediator: sm.Mediator;
    constructor(options?: any) {
        // existing code
        options.events = {
             "click #show_filter_button": 
             this.showHideFilterButtonClicked
           };
        // existing code
        var isMobile = $('html').hasClass('mobile');
        this._mediator = new sm.Mediator(this, isMobile);
        // existing code
    }
    // existing functions
    showLeftPanel() {
        $('#content_panel_left')
            .removeClass('sidebar_panel_push_to_left');
        $('#content_panel_main')
            .removeClass('main_panel_push_to_left');
    }
    hideLeftPanel() {
        $('#content_panel_left')
            .addClass('sidebar_panel_push_to_left');
        $('#content_panel_main')
            .addClass('main_panel_push_to_left');
    }
    cyclePanels(forwardOrNext: string) {
      $('#carousel-main-container').carousel(forwardOrNext);
    }
    showFilterButtonChangeClass(
       fromClass: string, toClass: string) {
           $('#show_filter_button')
            .removeClass(fromClass).addClass(toClass);
    }
    showHideFilterButtonClicked() {
      this._mediator.showHideFilterButtonClicked();
    }
    // existing functions
}

我们已经更新了我们的PageViewLayout类,以实现IMediatorFunctions接口中的所有函数。我们还包括了一个名为_mediator的私有变量,用于保存Mediator类的一个实例,并在我们的构造函数中设置这个实例。

与我们的其他需要响应点击事件的视图一样,我们设置了一个options.events对象,将 DOM 上的click事件与#show_filter_button DOM 元素(我们的显示/隐藏按钮)绑定到showHideFilterButtonClicked函数上。

注意

我们正在使用 jQuery 来检查我们页面中的主 HTML 元素是否有一个名为mobile的类。这个类将由我们在本章开头包含在index.html页面中的head.js实用程序脚本设置。通过这种方式,我们能够确定我们的应用程序是在移动设备上还是在桌面设备上使用。

showLeftPanelhideLeftPanel函数只是包含了 jQuery 片段,以应用或移除相关的类,以便滑动筛选面板进入或退出。

cyclePanels函数调用我们的 Bootstrap 轮播函数,带有'next''prev'参数,就像我们在演示 HTML 页面中所做的那样。

showFilterButtonChangeClass只是从我们的show_filter_button DOM 元素中移除fromClass CSS 样式,然后添加新的toClass CSS 样式。移除和添加这些 CSS 类将切换按钮的显示,从左切换到右(<>),或者反之。

当用户点击#show_filter_button DOM 元素时,我们的showHideFilterButtonClicked方法将被调用。正如之前讨论的,我们正在将这个调用转发到Mediator实例,以便Mediator逻辑可以决定当按钮被点击时该做什么。

触发状态变化

为了完成我们的状态和中介者设计模式,我们现在只需要在正确的位置调用Mediator函数,以触发逻辑移动到不同的状态。

我们第一次调用moveToState函数的地方是在我们的handle_NotifyEvent中,当我们的ManufacturerDataLoaded事件被触发时。这个事件在我们的应用程序中只会发生一次,那就是在ManufacturerCollection成功加载之后。我们已经在我们的PageViewLayout类中有一个事件处理程序,所以让我们更新这个函数如下:

handle_NotifyEvent(event: ev.INotifyEvent) {
    if (event.eventType == ev.EventType.ManufacturerDataLoaded) {
        // existing code
        this._manufacturerView =
            new mv.ManufacturerCollectionView();
        this._manufacturerView.render();

        this._mediator.moveToState(
            this._mediator
                .getCurrentMainPanelState().getStateType()
              );
    }
    if (event.eventType == ev.EventType.BoardDetailBackClicked) {
        this._mediator.moveToState(
            this._mediator.getCurrentMainPanelState()
               .getStateType()
            );
    }
}

我们的第一个if语句检查ManufacturerDataLoaded事件类型,然后创建一个新的ManufacturerCollectionView并调用它的render函数,就像我们之前看到的那样。然后我们调用moveToState函数,传入中介者的currentMainPanelState作为参数。还记得我们如何在中介者的构造函数中根据浏览器是否在移动设备上设置了初始主面板状态吗?这次对moveToState的调用将使用该初始状态作为参数,从而在正确的状态下启动应用程序。

我们的第二个if语句将在用户在BoardDetail屏幕上,并在标题面板上点击返回按钮时触发moveToState。这个逻辑再次使用currentMainPanelState根据我们的业务规则将我们的板块列表恢复到正确的状态。

PageLayoutView中的另一个函数将触发对moveToState的调用,是我们对BoardSelectedEvent的处理程序:

handle_BoardSelectedEvent(event: ev.IBoardSelectEvent) {
    var boardDetailView = new bdv.BoardDetailView(
       { model: event.selectedBoard });
    boardDetailView.render();

    this._mediator.moveToState(ss.StateType.BoardDetail);
}

每当用户在板块列表中点击一个板块时,都会触发一个BoardSelectedEvent,然后我们渲染BoardDetailView。然而,这个BoardDetailView位于第二个轮播面板上,所以我们需要在这个事件处理程序中移动到BoardDetail状态。

最后,当用户在BoardDetailView中,并点击返回按钮时,我们需要触发moveToState函数。为了实现这一点,我们需要从我们的BoardDetailView中触发一个NotifyEvent,并将eventType设置为BoardDetailBackClicked,如下所示:

export class BoardDetailView
    extends Marionette.CompositeView<bm.BoardSize> {
    constructor(options?: any) {
        // existing code
        options.events = {
            "click #prev_button": this.onPrev
           };
        super(options);
        // existing code
    }

    onPrev() {
        TypeScriptTinyIoC.raiseEvent(
            new bae.NotifyEvent(bae.EventType.BoardDetailBackClicked),
            bae.IINotifyEvent);
    }
}

在这里,我们将onPrev函数绑定到#prev_button元素上的 DOMclick事件。一旦触发了点击,我们只需要触发一个新的NotifyEvent,并将eventType设置为BoardDetailBackClicked,以触发moveToState函数调用。

有了我们的状态和中介者设计模式类,我们的示例应用现在已经完成。

总结

在本章中,我们从头开始构建了一个完整的 TypeScript 单页应用程序。我们从应用程序设计的初始想法开始,以及我们希望页面如何过渡。然后,我们使用现成的 Bootstrap 元素构建了一个纯 HTML 演示页面,并添加了一些 JavaScript 魔法来创建一个完整的演示页面。我们对 HTML 应用了各种样式,在 Brackets 中预览,并调整外观,直到满意为止。

我们接下来的主要步骤是理解并处理我们应用程序中需要的数据结构。我们编写了 Jasmine 单元测试和集成测试来巩固我们的 Backbone 模型和集合,并编写了我们需要的过滤函数。

然后,我们建立了一组 Marionette 视图,并将我们的演示 HTML 拆分成每个视图使用的片段。我们将视图与我们的集合和模型联系起来,并使用接口与数据提供程序一起工作。我们的应用程序随后开始通过使用真实的服务器端数据来完善。

最后,我们讨论了页面过渡策略,并实现了状态和中介者设计模式来实现我们所需的业务逻辑。

希望您喜欢从头开始构建应用程序的旅程——从概念到可视化,然后通过实施和测试。我们最终到达了一个工业强度、企业就绪的 TypeScript 单页 Marionette 应用程序。

为 Bentham Chang 准备,Safari ID bentham@gmail.com 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他使用均需版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

posted @ 2024-05-22 12:08  绝不原创的飞龙  阅读(12)  评论(0编辑  收藏  举报