65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 TypeScript 的 Mario5

starIconstarIconstarIconstarIconstarIcon

5.00/5 (28投票s)

2014年11月18日

CPOL

27分钟阅读

viewsIcon

82492

downloadIcon

2153

通过扩展/重写原始 Mario5 源代码,探索 TypeScript 的能力、优势和特性。

Mario5 TypeScript

目录

  1. 引言
  2. 背景
  3. 转换现有项目
    1. 参考文献
    2. 注解
    3. 枚举
    4. 接口
    5. 胖箭头函数
  4. 扩展项目
    1. 默认参数
    2. 重载
    3. Generics
    4. 模块
  5. 使用代码
  6. 关注点
  7. 历史

引言

我在 CodeProject 上最史诗般的时刻之一是发布了关于 Mario5 的文章。在文章中,我描述了如何基于 HTML5、CSS3 和 JavaScript 等 Web 技术制作游戏。这篇文章获得了大量的关注,可能是我引以为豪的文章之一。

原始文章使用了我称之为“OOP JavaScript”的东西。我编写了一个名为 oop.js 的小型辅助脚本,它允许我使用简单的继承/类模式。当然,JavaScript 从一开始就是非常面向对象的。类不是 OOP 的直接标准。尽管如此,这种模式极大地帮助使代码易于阅读和维护。这是通过不必直接处理 prototype 方案来实现的。

通过 TypeScript,我们可以在 JavaScript 中获得统一的类结构。语法基于 ES6 版本,这使得 TypeScript 即使在 ES6 实现中也能保持 JavaScript 的完整超集。当然,TypeScript 会编译成 ES3 或 ES5,这意味着这个类结构将被分解成现在可用的东西:再次是 prototype 机制。然而,留下的是可读的、跨实现(ES3/ES5)安全的,并遵循共同基础的代码。使用我自己的方法(oop.js),除了我之外,没有人会在不阅读辅助代码的情况下知道发生了什么。使用 TypeScript,广泛的开发人员使用相同的模式,因为它嵌入在语言中。

因此,将 Mario5 项目转换为 TypeScript 是很自然的。为什么这值得在 CodeProject 上发表文章?我认为这是一个很好的研究,说明如何转换项目。它还说明了 TypeScript 的主要观点。最后,它对语法和行为进行了很好的介绍。毕竟,TypeScript 对于已经了解 JavaScript 的人来说很简单,并且对于还没有任何经验的人来说,它更容易上手 JavaScript。

背景

一年多前,Anders Heijlsberg 宣布了微软的新语言 TypeScript。这让大多数人感到惊讶,因为微软(尤其是 Anders)似乎反对动态语言,尤其是 JavaScript。然而,事实证明,微软意识到了通用编程到 Web 编程的集中化是一个巨大的机会。随着 Windows Store 应用程序的 JavaScript、node.js 的持续热潮以及使用 JavaScript 运行查询的 NoSQL 运动,JavaScript 显然非常重要。

备注:Anders 在开发过程中的某个时刻(v0.8)加入了 TypeScript 团队。目前尚不清楚是他发明了这门语言,还是其他人提出了这个想法。尽管如此,该团队目前由 Anders 领导,鉴于他的经验和专业知识,由他负责这个项目无疑是件好事。

这一认识影响了新语言的设计决策。Anders 没有从头开始创建一门新语言(像 Google 对 Dart 所做的那样),而是决定任何可能仍将建立的语言都必须扩展 JavaScript。任何解决方案都不应该是正交的。CoffeeScript 的问题在于它隐藏了 JavaScript。这可能对一些开发人员有吸引力,但对大多数开发人员来说,这是一个绝对的排斥标准。Anders 决定这门语言必须是强类型的,尽管只有智能编译器(或者更确切地说,转译器)才能看到这些注解。

那么发生了什么?一个真正的 ECMAScript 5 超集被创建了。这个超集被称为 TypeScript,以表示与 JavaScript(或一般的 ECMAScript)的密切关系,并带有额外的类型注解。所有其他功能,如接口、枚举、泛型、类型转换等,都源于这些类型注解。未来 TypeScript 将会发展。有两个方向

  1. 拥抱 ES6 以保持 JavaScript 的真正超集
  2. 引入更多功能以简化 JS 开发

使用 TypeScript 的主要好处是双重的。一方面,我们可以在编译时被告知潜在的错误和问题。如果一个参数不满足给定的签名,编译器就会抛出错误。这在与大型团队或大型项目合作时特别有用。另一方面也很有趣。微软以其卓越的 Visual Studio 工具而闻名。由于 JavaScript 代码的动态特性,为 JavaScript 代码提供良好的工具支持是繁琐的。因此,即使是简单的重构任务,例如重命名变量,也无法以所需的稳定性进行。

最终,TypeScript 为我们提供了强大的工具支持,并让我们更好地了解代码将如何工作。生产力与健壮性的结合是使用 TypeScript 最吸引人的论点。在本文中,我们将探讨如何转换现有项目。我们将看到,将代码转换为 TypeScript 可以逐步完成。

转换现有项目

TypeScript 不会隐藏 JavaScript。它从纯 JavaScript 开始。

JavaScript logo

使用 TypeScript 的第一步当然是拥有 TypeScript 源文件。由于我们想在一个现有项目中使用 TypeScript,因此我们必须转换这些文件。这里没有作为要求的任务,但是,我们只是将文件从 *.js 重命名为 *.ts。这只是一种约定,并不是实际要求。尽管如此,由于 TypeScript 编译器 tsc 通常将 *.ts 文件视为输入,将 *.js 文件作为输出,因此重命名扩展名可以确保不会发生任何错误。

接下来的小节将介绍转换过程中的渐进式改进。我们现在假设每个文件都有通常的 TypeScript 扩展名 *.ts,尽管没有使用任何额外的 TypeScript 功能。

参考文献

第一步是提供从单个 JavaScript 文件到所有其他(必需的)JavaScript 文件的引用。通常我们只编写单个文件,但是,(通常)必须以特定顺序插入到我们的 HTML 代码中。JavaScript 文件不知道 HTML 文件,也不知道这些文件的顺序(更不用说哪些文件了)。

现在我们想给智能编译器 (TypeScript) 一些提示,我们需要指定可能有哪些其他对象可用。因此,我们需要在代码文件开头放置一个引用提示。引用提示将声明当前文件将使用的所有其他文件。

例如,我们可以通过定义包含 jQuery(例如,由 main.ts 文件使用),如下所示:

/// <reference path="def/jquery.d.ts"/>

我们也可以包含库的 TypeScript 版本或 JavaScript 版本,但是,只包含定义文件是有原因的。定义文件不包含任何逻辑。这将使文件大幅缩小,解析速度更快。此外,此类文件通常会包含更多/更好的文档注释。最后,虽然我们更喜欢自己的 *.ts 文件而不是 *.d.ts 文件,但在 jQuery 和其他库的情况下,原始文件是用 JavaScript 编写的。目前尚不清楚 TypeScript 编译器是否对源代码满意。通过使用定义文件,我们可以确保一切正常。

我们自己编写纯定义文件也有原因。最基本的一个原因在 def/interfaces.d.ts 文件中有所体现。我们没有任何代码,这使得编译变得无关紧要。另一方面,引用此文件是有意义的,因为该文件提供的附加类型信息有助于注释我们的代码。

注解

最重要的 TypeScript 特性是类型注解。实际上,语言的名称就表明了这个特性极其重要。

大多数类型注解实际上不是必需的。如果一个变量被立即赋值(即我们定义一个变量,而不是仅仅声明它),那么编译器可以推断出该变量的类型。

var basepath = 'Content/';

显然,这个变量的类型是 string。这也是 TypeScript 推断的。尽管如此,我们也可以显式地命名类型。

var basepath: string = 'Content/';

通常我们不希望对这些注解过于明确。它会引入更多的杂乱和更少的灵活性,这与我们的目标背道而驰。然而,有时这些注解是必需的。当然,最明显的情况出现在我们只声明一个变量时

var frameCount: number;

也有其他情况。考虑创建一个单个对象,该对象可能会扩展更多属性。编写普通的 JavaScript 代码对于编译器来说信息量肯定不够

var settings = { };

有哪些属性可用?属性的类型是什么?也许我们不知道,我们想把它用作字典。在这种情况下,我们应该指定对象的任意用法

var settings: any = { };

但还有另一种情况。我们已经知道可能有哪些属性可用,并且我们只需要设置或获取其中一些可选属性。在这种情况下,我们也可以指定确切的类型

 var settings: Settings = { };

到目前为止,最重要的案例一直被忽略。虽然变量(局部或全局)在大多数情况下可以被推断,但函数参数却永远不能被推断。事实上,函数参数可能在单个使用中被推断(例如泛型参数的类型),但不能在函数本身内部被推断。因此,我们需要告诉编译器我们有什么类型的参数。

setPosition(x: number, y: number) {
	this.x = x;
	this.y = y;
}

因此,使用类型注解逐步转换 JavaScript 是一个从更改函数签名开始的过程。那么,这些注解的基本知识是什么?我们已经了解到 numberstringany 是内置类型,代表基本类型。此外,我们还有 booleanvoid。后者仅对函数的返回类型有用。它表示没有返回任何有用的东西(因为 JS 函数总是会返回一些东西,至少是 undefined)。

那数组呢?标准数组的类型是 any[]。如果我们要表明该数组只能用于数字,我们可以将其注解为 number[]。多维数组也是可能的。一个矩阵可以注解为 number[][]。由于 JavaScript 的特性,多维数组我们只有锯齿状数组。

枚举

现在我们已经开始注释我们的函数和变量,我们最终将需要自定义类型。当然,我们已经到处都有一些类型,但是,这些类型可能注释得不如我们想要的,或者定义的方式过于特殊。

有时 TypeScript 会提供更好的替代方案。例如,数字常量集合可以定义为枚举。在旧代码中,我们有这样的对象

var directions = {
    none: 0,
    left: 1,
    up: 2,
    right: 3,
    down: 4
};

包含的元素是常量这一点并不明显。它们很容易被更改。那么,如果真的想对这样的对象做一些恶劣的事情,编译器是否会给我们一个错误呢?这就是 enum 类型派上用场的地方。目前它们仅限于数字,但是对于大多数常量集合来说,这已经足够了。最重要的是,它们作为类型传输,这意味着我们可以在类型注解中使用它们。

名称已更改为大写,表示 Direction 确实是一种类型。由于我们不想像枚举标志一样使用它,因此我们使用单数版本(遵循 .NET 约定,这在此场景中是有意义的)。

enum Direction {
	none  = 0,
	left  = 1,
	up    = 2,
	right = 3,
	down  = 4,
};

现在我们可以在代码中这样使用它

setDirection(dir: Direction) {
	this.direction = dir;
}

请注意,dir 参数被注解为仅限于 Direction 类型的参数。这排除了任意数字,并且必须使用 Direction 枚举的值。如果用户输入恰好是一个数字怎么办?在这种情况下,我们也可以大胆使用 TypeScript 类型转换

var userInput: number;
// ...
setDirection(<Direction>userInput);

TypeScript 中的类型转换只有在可行的情况下才有效。由于每个 Direction 都是一个数字,所以一个 number 可以是一个有效的 Direction。有时,类型转换已知会先验失败。如果 userInput 是一个纯 string,TypeScript 会抱怨并返回类型转换错误。

接口

接口定义类型而不指定实现。它们将完全消失在生成的 JavaScript 中,就像我们所有的类型注解一样。基本上,它们与 C# 中的接口非常相似,但是有一些显著的区别。

让我们看一个示例接口

interface LevelFormat {
    width: number;
    height: number;
    id: number;
    background: number;
    data: string[][];
}

这定义了关卡定义的格式。我们看到这样的定义必须包含 widthheightbackgroundid 等数字,以及一个二维字符串数组来定义关卡中应使用的各种图块。

我们已经提到 TypeScript 接口与 C# 接口不同。其中一个原因是 TypeScript 接口允许合并。如果已经存在同名接口,它不会被覆盖。也没有编译器警告或错误。相反,现有接口将用新接口中定义的属性进行扩展。

以下接口将现有的 Math 接口(来自 TypeScript 基本定义)与提供的接口合并。我们获得了一个额外的方法

interface Math {
	sign(x: number): number;
}

方法通过在圆括号中指定参数来指定。通常的类型注解是方法的返回类型。通过提供的接口(扩展),TypeScript 编译器允许我们编写以下方法

Math.sign = function(x: number) {
	if (x > 0)
		return 1;
	else if (x < 0)
		return -1;
		
	return 0;
};

TypeScript 接口中另一个有趣的选项是混合声明。在 JavaScript 中,对象不限于纯粹的键值载体。对象也可以作为函数调用。jQuery 是这种行为的一个很好的例子。有许多可能的方法来调用 jQuery 对象,每种方法都会返回一个新的 jQuery 选择。另外,jQuery 对象还带有一些属性,这些属性代表了漂亮的小助手和更多有用的东西。

在 jQuery 的例子中,其中一个接口看起来像

interface JQueryStatic {
    (): JQuery;
    (html: string, ownerDocument?: Document): JQuery;
    ajax(settings: JQueryAjaxSettings): JQueryXHR;
    /* ... */
}

这里我们有两个可能的调用(其中有很多)和一个可以直接使用的属性。因此,混合接口要求实现对象实际上是一个函数,该函数会通过更多属性进行扩展。

我们还可以基于其他接口(或类,在此上下文中将用作接口)创建接口。

让我们考虑以下情况。为了区分点,我们使用 Point 接口。这里我们只声明了两个坐标,xy。如果要在代码中定义图片,我们需要两个值。一个位置(偏移量),它应该放在哪里,以及表示图像源的字符串。

因此,我们定义接口以表示此功能,使其派生/特化自 Point 接口。我们使用 extends 关键字在 TypeScript 中触发此行为。

interface Point {
	x: number;
	y: number;
}

interface Picture extends Point {
	path: string;
}

我们可以使用任意数量的接口,但需要用逗号分隔。

在这个阶段,我们已经对大部分代码进行了类型化,但一个重要的概念还没有翻译成 TypeScript。原始代码库使用了一种特殊的概念,它将类似类的对象(包括继承)引入 JavaScript。最初它看起来像下面的示例

var Gauge = Base.extend({
    init: function(id, startImgX, startImgY, fps, frames, rewind) {
        this._super(0, 0);
        this.view = $('#' + id);
        this.setSize(this.view.width(), this.view.height());
        this.setImage(this.view.css('background-image'), startImgX, startImgY);
        this.setupFrames(fps, frames, rewind);
    },
});

不幸的是,所示方法存在很多问题。最大的问题是它不是规范的,即它不是标准方法。因此,不熟悉这种实现类对象风格的开发人员无法像往常一样阅读或编写代码。此外,确切的实现方式也不清楚。总而言之,任何开发人员都必须查看 Class 对象的原始定义及其用法。

通过 TypeScript,存在一种统一的创建类对象的方式。此外,它以与 ECMAScript 6 相同的方式实现。因此,我们获得了易于使用和标准化的可移植性、可读性和可扩展性。回到我们最初的示例,我们可以将其转换为

class Gauge extends Base {
    constructor(id: string, startImgX: number, startImgY: number, fps: number, frames: number, rewind: boolean) {
        super(0, 0);
        this.view = $('#' + id);
        this.setSize(this.view.width(), this.view.height());
        this.setImage(this.view.css('background-image'), startImgX, startImgY);
        this.setupFrames(fps, frames, rewind);
    }
};

这看起来非常相似,行为也几乎相同。然而,用 TypeScript 变体替换旧的定义需要在一次迭代中完成。为什么?如果我们更改基类(仅称为 Base),我们需要更改所有派生类(TypeScript 要求类继承自其他 TypeScript 类)。

另一方面,如果我们更改其中一个派生类,我们就不能再使用基类了。也就是说,只有与类层次结构完全解耦的类才能在一次迭代中转换。否则,我们需要转换整个类层次结构。

extends 关键字与接口的含义不同。接口通过指定的一组定义来扩展其他定义(接口或类的接口部分)。类通过将其原型设置为给定原型来扩展另一个类。此外,还在此基础上放置了一些其他巧妙的功能,例如通过 super 访问父级功能的能力。

最重要的类是类层次结构的根,称为 Base。它包含相当多的功能,最值得注意的是

class Base implements Point, Size {
	frameCount: number;
	x: number;
	y: number;
	image: Picture;
	width: number;
	height: number;
	currentFrame: number;
	frameID: string;
	rewindFrames: boolean;
	frameTick: number;
	frames: number;
	view: JQuery;

	constructor(x: number, y: number) {
		this.setPosition(x || 0, y || 0);
		this.clearFrames();
		this.frameCount = 0;
	}
	setPosition(x: number, y: number) {
		this.x = x;
		this.y = y;
	}
	getPosition(): Point {
		return { x : this.x, y : this.y };
	}
	setImage(img: string, x: number, y: number) {
		this.image = {
			path : img,
			x : x,
			y : y
		};
	}
	setSize(width, height) {
		this.width = width;
		this.height = height;
	}
	getSize(): Size {
		return { width: this.width, height: this.height };
	}
	setupFrames(fps: number, frames: number, rewind: boolean, id?: string) {
		if (id) {
			if (this.frameID === id)
				return true;
			
			this.frameID = id;
		}
		
		this.currentFrame = 0;
		this.frameTick = frames ? (1000 / fps / setup.interval) : 0;
		this.frames = frames;
		this.rewindFrames = rewind;
		return false;
	}
	clearFrames() {
		this.frameID = undefined;
		this.frames = 0;
		this.currentFrame = 0;
		this.frameTick = 0;
	}
	playFrame() {
		if (this.frameTick && this.view) {
			this.frameCount++;
			
			if (this.frameCount >= this.frameTick) {			
				this.frameCount = 0;
				
				if (this.currentFrame === this.frames)
					this.currentFrame = 0;
					
				var $el = this.view;
				$el.css('background-position', '-' + (this.image.x + this.width * ((this.rewindFrames ? this.frames - 1 : 0) - this.currentFrame)) + 'px -' + this.image.y + 'px');
				this.currentFrame++;
			}
		}
	}
};

implements 关键字类似于 C# 中(显式)实现接口。我们基本上启用了一个约定,即我们在类中提供给定接口中定义的功能。虽然我们只能从单个类继承,但我们可以实现任意数量的接口。在前面的示例中,我们选择不从任何类继承,而是实现两个接口。

然后,我们定义给定类型对象上可用的字段类型。顺序无关紧要,但最初定义它们(最重要的是:在单个位置)是有意义的。constructor 函数是一个特殊函数,其含义与之前的自定义 init 方法相同。我们将其用作类的构造函数。基类的构造函数可以通过 super() 随时调用。

TypeScript 也提供修饰符。它们不包含在 ECMAScript 6 标准中。因此,我也不喜欢使用它们。尽管如此,我们可以将字段设为私有(但请记住:仅从编译器的角度来看,而不是在 JavaScript 代码本身中),从而限制对这些变量的访问。

这些修饰符与构造函数本身结合使用时,可以发挥出色的作用

class Base implements Point, Size {
	frameCount: number;
	// no x and y
	image: Picture;
	width: number;
	height: number;
	currentFrame: number;
	frameID: string;
	rewindFrames: boolean;
	frameTick: number;
	frames: number;
	view: JQuery;

	constructor(public x: number, public y: number) {
		this.clearFrames();
		this.frameCount = 0;
	}
	/* ... */
}

通过指定参数为 public,我们可以省略类中 xy 的定义(和初始化)。TypeScript 会自动处理这个问题。

胖箭头函数

有人还记得在 C# 中,在 Lambda 表达式出现之前,如何创建匿名函数吗?大多数(C#)开发人员都不能。原因很简单:Lambda 表达式带来了表达性和可读性。在 JavaScript 中,一切都围绕着匿名函数的概念发展。就我个人而言,我只使用函数表达式(匿名函数)而不是函数声明(命名函数)。它更清楚地说明了正在发生的事情,更灵活,并为代码带来了一致的外观和感觉。我会说它是一致的。

尽管如此,还是有一些小片段,写起来很糟糕,比如

var me = this;
me.loop = setInterval(function() {
	me.tick();
}, setup.interval);

为什么要这样浪费?四行代码毫无意义。第一行是必需的,因为间隔回调是在 window 的名义下调用的。因此,我们需要缓存原始的 this,以便访问/查找对象。这个闭包是有效的。现在我们将 this 存储在 me 中,我们已经可以从更短的类型(至少有些)中受益。最后,我们需要将那个单一函数传递给另一个函数。疯了吗?让我们使用胖箭头函数!

this.loop = setInterval(() => this.tick(), setup.interval);

啊,现在它只是一个简洁的单行代码。我们“失去”了一行,因为我们在胖箭头函数中保留了 this(让我们称它们为 lambda 表达式)。另外两行用于保留函数的样式,现在由于我们使用了 lambda 表达式而变得多余。在我看来,这不仅可读,而且易于理解。

当然,在底层,TypeScript 使用的是我们之前使用的相同的东西。但我们不在乎。我们也不在乎 C# 编译器生成的 MSIL,或者任何 C 编译器生成的汇编代码。我们只关心(原始)源代码的可读性和灵活性大大提高。如果我们不确定 this 的含义,我们应该使用胖箭头运算符。

扩展项目

TypeScript 编译成(人类可读的)JavaScript。根据目标,它以 ECMAScript 3 或 5 结束。

TypeScript logo

现在我们基本上已经为整个解决方案编写了类型,我们甚至可以更进一步,使用一些 TypeScript 特性来使代码更美观、更易于扩展和使用。我们将看到 TypeScript 提供了一些有趣的概念,使我们能够完全解耦应用程序,并使其不仅在浏览器中,而且在 node.js(以及因此在终端中)等其他平台上都可访问。

默认值和可选参数

在这个阶段我们已经做得相当不错了,但为什么要止步于此呢?让我们为一些参数设置默认值,使它们成为可选的。

例如,以下 TypeScript 片段将被转换...

var f = function(a: number = 0) {
}
f();

...变成这样

var f = function (a) {
    if (a === void 0) { 
    	a = 0; 
    }
};
f();

void 0 基本是 undefined 的安全变体。这样,这些默认值总是动态绑定的,而不是 C# 中的静态绑定默认值。这大大减少了代码,因为我们现在可以省略所有默认值检查,让 TypeScript 完成工作。

举个例子,考虑以下代码片段

constructor(x: number, y: number) {
	this.setPosition(x || 0, y || 0);
	// ...
}

我们为什么要确保 xy 的值已设置?我们可以直接将此约束放置在构造函数上。让我们看看更新后的代码是怎样的

constructor(x: number = 0, y: number = 0) {
	this.setPosition(x, y);
	// ...
}

也有其他例子。以下已经展示了修改后的函数

setImage(img: string, x: number = 0, y: number = 0) {
	this.view.css({
		backgroundImage : img ? c2u(img) : 'none',
		backgroundPosition : '-' + x + 'px -' + y + 'px',
	});
	super.setImage(img, x, y);
}

同样,这使得代码更易于阅读。否则,backgroundPosition 属性将与默认值考虑一起赋值,这看起来相当丑陋。

拥有默认值当然很好,但我们也可能遇到这样的情况:我们可以安全地省略参数,而无需指定默认值。在这种情况下,我们仍然需要检查是否提供了参数,但调用者可以省略参数而不会遇到麻烦。

关键是在参数后面加上问号。让我们看一个例子

setupFrames(fps: number, frames: number, rewind: boolean, id?: string) {
	if (id) {
		if (this.frameID === id)
			return true;
		
		this.frameID = id;
	}
	
	// ...
	return false;
}

显然,我们允许在不指定 id 参数的情况下调用该方法。因此,我们需要检查它是否存在。这在方法主体的第一行完成。这个保护措施保护了可选参数的使用,即使 TypeScript 允许我们随意使用它。然而,我们应该小心。TypeScript 不会检测所有错误——确保在所有可能路径中代码正常工作仍然是我们的责任。

重载

JavaScript 天生不支持函数重载。原因很简单:命名函数只会产生一个局部变量。将函数添加到对象会在其字典中放置一个键。这两种方式都只允许唯一的标识符。否则,我们将被允许拥有两个同名变量或属性。当然,有一个简单的解决方法。我们创建一个超级函数,根据参数的数量和类型调用子函数。

然而,检查参数数量很容易,获取类型却很难。至少对于 TypeScript 来说是这样。TypeScript 只在编译时知道/保留类型,然后将整个创建的类型系统抛弃。这意味着在运行时无法进行类型检查——至少不能超出非常基本的 JavaScript 类型检查。

好的,那么既然 TypeScript 在这里帮不上忙,为什么还要专门有一个小节讨论这个话题呢?嗯,显然编译时重载仍然是可能的和必需的。许多 JavaScript 库都提供根据参数提供一种或另一种功能的函数。例如,jQuery 通常提供两种或更多种变体。一种是读取某个属性,另一种是写入某个属性。当我们在 TypeScript 中重载方法时,我们只有一个实现,但有多个签名。

通常人们会尽量避免这种模糊的定义,这就是为什么原始代码中没有这样的方法。我们现在不想引入它们,但让我们看看如何编写它们

interface MathX {
    abs: {
        (v: number[]): number;
        (n: number): number;
    }
}

实现可能如下所示

var obj: MathX = {
	abs: function(a) {
		var sum = 0;

		if (typeof(a) === 'number')
			sum = a * a;
		else if (Array.isArray(a))
			a.forEach(v => sum += v * v);

		return Math.sqrt(sum);
	}
};

告知 TypeScript 多个调用版本的优点在于增强的用户界面功能。像 Visual Studio 这样的 IDE 或像 Bracket 这样的文本编辑器可能会显示所有重载,包括描述。像往常一样,调用仅限于提供的重载,这将确保一定的安全性。

泛型

泛型对于驯服多种(类型)用法也很有用。它们的工作方式与 C# 有些不同,因为它们只在编译时进行评估。此外,它们在运行时表示方面没有任何特殊之处。这里没有模板元编程或任何东西。泛型只是处理类型安全而不变得过于冗长的另一种方式。

让我们考虑以下函数

function identity(x) {
	return x;
}

这里参数 x 的类型是 any。因此,函数将返回 any 类型的值。这听起来可能不是问题,但让我们假设以下函数调用。

var num = identity(5);
var str = identity('Hello');
var obj = identity({ a : 3, b : 9 });

numstrobj 的类型是什么?它们可能有明显的名称,但从 TypeScript 编译器的角度来看,它们都是 any 类型。

这就是泛型发挥作用的地方。我们可以告诉编译器,函数的返回类型就是调用类型,它应该与已使用的精确类型相同。

function identity(x: T): T {
	return x;
}

在上面的代码片段中,我们只是返回了与进入函数时相同的类型。有多种可能性(包括返回由上下文确定的类型),但返回其中一个参数类型可能是最常见的。

当前代码不包含任何泛型。原因很简单:代码主要侧重于改变状态,而不是评估输入。因此,我们主要处理过程而不是函数。如果我们将函数与多种参数类型、具有参数类型依赖关系的类或类似结构一起使用,那么泛型肯定会有帮助。目前,所有事情都可以在没有它们的情况下完成。

模块

最后一步是解耦我们的应用程序。我们将不再引用所有文件,而是使用模块加载器(例如,用于浏览器的 AMD,或用于 Node 的 CommonJS)并按需加载各种脚本。这种模式有很多优点。代码更易于测试、调试,并且通常不会因错误的顺序而受损,因为模块总是在指定的依赖项可用后才加载。

TypeScript 在整个模块系统之上提供了一个简洁的抽象,因为它提供了两个关键字(importexport),这些关键字被转换为与所需模块系统相关的代码。这意味着单个代码库可以编译为符合 AMD 规范的代码,也可以编译为符合 CommonJS 规范的代码。无需任何魔法。

例如,文件 constants.ts 将不再被引用。相反,该文件将以模块的形式导出其内容。这是通过以下方式完成的

export var audiopath = 'Content/audio/';
export var basepath  = 'Content/';

export enum Direction {
	none  = 0,
	left  = 1,
	up    = 2,
	right = 3,
	down  = 4,
};

/* ... */

这要怎么用呢?我们不再使用引用注释,而是使用 require() 方法。为了表示我们希望直接使用模块,我们不写 var,而是写 import。请注意,我们可以跳过 *.ts 扩展名。这很有意义,因为文件稍后将具有相同的名称,但扩展名不同。

import constants = require('./constants');

varimport 之间的区别非常重要。考虑以下几行

import Direction = constants.Direction;
import MarioState = constants.MarioState;
import SizeState = constants.SizeState;
import GroundBlocking = constants.GroundBlocking;
import CollisionType = constants.CollisionType;
import DeathMode = constants.DeathMode;
import MushroomMode = constants.MushroomMode;

如果我们写 var,那么我们实际上使用的是属性的 JavaScript 表示。但是,我们想使用 TypeScript 抽象。Direction 的 JavaScript 实现只是一个对象。TypeScript 抽象是一种类型,它将以对象的形式实现。有时这并没有什么区别,但是对于接口、类或枚举等类型,我们应该优先使用 import 而不是 var。否则,我们只是使用 var 进行重命名

var setup = constants.setup;
var images = constants.images;

这就是全部了吗?嗯,关于模块还有很多要说的,但我在这里尽量简要。首先,我们可以使用这些模块来创建文件接口。例如,main.ts 的公共接口由以下代码片段给出

export function run(levelData: LevelFormat, controls: Keys, sounds?: SoundManager) {
	var level = new Level('world', controls);
	level.load(levelData);

	if (sounds)
		level.setSounds(sounds);

	level.start();
};

然后,所有模块都被整合到一个文件(例如 game.ts)中。我们加载所有依赖项,然后运行游戏。虽然大多数模块只是单个部分捆绑在一起的对象,但模块也可以只是这些部分中的一个。

import constants = require('./constants');
import game = require('./main');
import levels = require('./testlevels');
import controls = require('./keys');
import HtmlAudioManager = require('./HtmlAudioManager');

$(document).ready(function() {
	var sounds = new HtmlAudioManager(constants.audiopath);
	game.run(levels[0], controls, sounds);
});

controls 模块是单个模块的示例。我们通过一个简单的语句实现这一点,例如

export = keys;

这将 export 对象赋值给 keys 对象。

让我们看看目前为止我们得到了什么。由于我们代码的模块化特性,我们包含了一些新文件。

TypeScript restructure

我们对 RequireJS 有了另一个依赖,但实际上我们的代码比以前更健壮,更易于扩展。此外,所有依赖项总是暴露的,这极大地消除了未知依赖项的可能性。模块加载器系统与智能感知、改进的重构能力和强类型相结合,为整个项目增加了许多安全性。

当然,并非每个项目都能如此轻松地重构。这个项目很小,并且基于一个坚实的、没有太多腐蚀的代码库。

在最后一步,我们将拆分庞大的 main.ts 文件,创建小型、解耦的文件,这些文件可能只依赖于某些设置。这些设置将在开始时注入。然而,这种转换并非适用于所有人。对于某些项目,它可能会增加过多的干扰,而不是提高清晰度。

无论哪种方式,对于 Matter 类,我们都会有以下代码

/// <reference path="def/jquery.d.ts"/>
import Base = require('./Base');
import Level = require('./Level');
import constants = require('./constants');

class Matter extends Base {
	blocking: constants.GroundBlocking;
	level: Level;

	constructor(x: number, y: number, blocking: constants.GroundBlocking, level: Level) {
		this.blocking = blocking;
		this.view = $('<div />').addClass('matter').appendTo(level.world);
		this.level = level;
		super(x, y);
		this.setSize(32, 32);
		this.addToGrid(level);
	}
	addToGrid(level) {
		level.obstacles[this.x / 32][this.level.getGridHeight() - 1 - this.y / 32] = this;
	}
	setImage(img: string, x: number = 0, y: number = 0) {
		this.view.css({
			backgroundImage : img ? img.toUrl() : 'none',
			backgroundPosition : '-' + x + 'px -' + y + 'px',
		});
		super.setImage(img, x, y);
	}
	setPosition(x: number, y: number) {
		this.view.css({
			left: x,
			bottom: y
		});
		super.setPosition(x, y);
	}
};

export = Matter;

这项技术将细化依赖关系。此外,代码库将获得可访问性。然而,是否进一步细化实际上是期望的还是不必要的装饰,取决于项目和代码的状态。

使用代码

Mario5 TypeScript

代码已上线,可在 GitHub 上获取。仓库可通过 github.com/FlorianRappl/Mario5TS 访问。仓库本身包含一些关于 TypeScript 的信息。此外,还使用了构建系统 Gulp。我将在另一篇文章中介绍这个构建系统。尽管如此,仓库还包含一份简短的安装/使用指南,应该能让所有对 gulp 或 TypeScript 不了解的人快速上手。

由于代码的起源在于 Mario5 文章,我也建议所有尚未阅读过它的人都去看看。该文章可在 CodeProject 上获取,地址是 codeproject.com/Articles/396959/Mario。CodeProject 上还有一篇后续文章,内容是扩展原始源代码。这个扩展是一个关卡编辑器,它展示了 Mario5 游戏的设计确实非常出色,因为 UI 的大部分都可以轻松重复使用来创建编辑器。您可以通过 codeproject.com/Articles/432832/Editor-for-Mario 访问该文章。需要注意的是,该文章还涉及一个社交游戏平台,该平台将游戏和编辑器结合在一个网页中,可用于保存和分享自定义关卡。

兴趣点

原文章中最常被问到的问题之一是声音的获取/声音系统的设置。事实证明,声音可能是最有趣的部分之一,但我决定将其从文章中删除。为什么?

  • 声音文件可能引起法律问题(尽管图形也可能如此)
  • 音效文件实际上相当大(效果文件很小,但背景音乐是 O(MB))
  • 每个声音文件都必须复制以避免兼容性问题(分发 OGG 和 MP3 文件)
  • 游戏已独立于特定的声音实现

最后一个论点是我的关键点。我想说明游戏实际上可以在不强烈耦合到特定实现的情况下工作。音频一直是网络应用程序广泛讨论的话题。首先,我们需要考虑一系列格式,因为不同的格式和编码只在部分浏览器上有效。为了覆盖所有主流浏览器,通常至少需要两种不同的格式(通常由一种开放格式和一种专有格式组成)。此外,HTMLAudioElement 的当前实现对于游戏来说效率不高且用处不大。这正是促使 Google 研发另一项标准的原因,该标准更适合游戏。

尽管如此,您想要一个标准实现吗?GitHub 仓库实际上包含一个标准实现。原始 JavaScript 版本和类型化版本都可用。两者都叫做 SoundManager。一个在 Original 文件夹中,另一个在 Scripts 文件夹中(两者都是 src 的子文件夹)。

历史

  • v1.0.0 | 首次发布 | 2014年11月18日
  • v1.1.0 | 关于 TypeScript 历史的备注 | 2014年12月18日
© . All rights reserved.