2017 年 1 月
第 32 卷,第 1 期
孜孜不倦的程序员 - 如何成为 MEAN: 使用 TypeScript 键入脚本
作者 Ted Neward | 2017 年 1 月
欢迎回来,MEAN。
在过去一年半 MEAN 系列的发展进程中,主题列表的一个有趣变化是此系列名称的“A”部分: 正式发布其行的版本 2 是 AngularJS 的重大转折,一些深入(颠覆性)的框架变化也随之而来。其中最显著的变化之一是选择采用 TypeScript 而不是适用于 1.x 行的普通传统 ECMAScript 作为 AngularJS 的“选择语言”,以用于构建 AngularJS 2 应用程序。
当然,许多读者都很熟悉 TypeScript,因为它是源自 Redmond 中代码信息库的最新语言。对于那些不熟悉 TypeScript 的人来说,也不必紧张,TypeScript 的语法实际上与你早期读到的关于此系列的 ECMAScript 2015 语法类似,只是具有更多的信息类型方法。我们鼓励想要全面深入了解该语言的读者阅读 Peter Vogel 于 2015 年 1 月在 MSDN 杂志上发表的文章“了解 TypeScript”(msdn.com/magazine/dn890374)。
然而,AngularJS 2 使用的是 TypeScript 的特定功能子集,此外,并非所有读者全都喜欢使用 TypeScript 语法。还有,自 Vogel 的文章发表以来,TypeScript 已进行过一系列更改(版本 2 于 2015 年 9 月下旬发表)。因此,在介绍 AngularJS 2 之前,我想要快速过一遍语言以确保我们处在同一页面。
所以,让我们讨论一下 TypeScript 吧。
将“Type”添加到“Script”
从概念上讲,TypeScript 的含义非常直观: 与 F# 和其他功能性语言提供类型声明的方法类似,TypeScript 使用传统的 ECMAScript 语法并以类型批注的形式添加一些(可选)类型信息。TypeScript 编译器(通常称为“转译器”,因为它从源到源,在进程之外生成 ECMAScript 代码)验证所有类型信息都被尊重和遵守;但结果仍然是如过去一般良好、动态类型化且浏览器友好的 JavaScript。换句话说,其目标是在不必更改基础 JavaScript 浏览器平台(不过这需要有好运气)或构建另一个昂贵的平台上平台的情况下,获得类型安全语言(比如 C#)的所有益处(通过静态验证减少明显的代码错误)。因为 TypeScript 的核心原则之一是“任何合法的 ECMAScript 程序也是一个合法的 TypeScript 程序”,采用 TypeScript 会是渐进式的过程。采取婴儿式步骤,仅以让团队尽量感觉舒适的方法使用新功能,而不是突然跳转到完全使用新语法(比如某个语法可能需要处理全新的转译语言,如 CoffeeScript、Fantom 或 ClojureScript)。
秉承着后者的精神,我开始探索 TypeScript。我将专注于 AngularJS 2 使用最多或最明显的功能,而其他功能会在探索过程中进行深入探索。
安装 TypeScript
首先要注意的问题是,和大多数基于 Node.js 的程序包一样,TypeScript 也是 npm 程序包。所以,可通过常用的“npm install”命令来安装 TypeScript:
npm install –g typescript
因为 TypeScript 将安装一个全局命令(“tsc”),所以安装 TypeScript 时使用“全局”标志“-g”非常重要。花一点时间,确保你已经通过运行“tsc”完全安装好了命令行工具:
$ tsc --version
Version 2.0.3
因此,在安装了 TypeScript 的情况下,下一步要编写一些 TypeScript 代码。
模块
此讨论的起点是 TypeScript 模块。
假设我创建了一个文件 person.ts,并且它包含一个组件。(“组件”不是 TypeScript 强调的术语,但是 AngularJS 2 强调。) 第一步,创建可从另一个文件调用的简单函数,让我们首先创建该函数:
function sayHello(message: string) {
console.log("Person component says", message);
}
请注意参数的类型批注,确保单个参数必须为一个字符串;这是 TypeScript 的基本原则,要保证仅有字符串可作为参数传递。其自行授予本函数以生成简单组件,但是不论复杂还是简单,它都需要有效使用。
因此让我们这样:TypeScript 应用程序可通过导入语句来使用组件,如下所述:
import { sayHello } from './person';
sayHello("Fred");
实质上,“导入”语句声明你正在使用“个人”模块中名为“sayHello”的元素。(稍后你将会看到导入语法的其他形式。) 因此,通过 tsc 编译器运行两个文件,即前述 person.ts 和本代码 app.ts:
tsc person.ts app.ts
遗憾的是,TypeScript 将会控诉,指明 person.ts 并不是一个模块。
在 TypeScript 术语中,模块是指围绕紧密组合的一组代码提供“方框”的语言构造。按照设计,person.ts 可轻松用作 JavaScript 较早方案下的一个模块;只需定义文件中的函数并引用该文件以将函数放入全局范围中。然而,TypeScript 要求更加明显的语法 — 必须使用导出关键字来声明组件的外部外围应用部件,如下所述:
export function sayHello(message: string) {
console.log("Person component says", message);
}
在 TypeScript 中,任何具有顶级导入或导出语句的文件都要考虑模块,所以只需声明导出的函数将所有 person.ts 隐式定义为模块。
一旦修改完成 TypeScript 就会愉快,两个新文件(即文件系统上的 person.js 和 app.js)现处于备用状态。
添加类
TypeScript 和其基于的 ECMAScript 2015 语言一样了解类的核心概念,这对于将 Person 定义为要使用的类非常有意义,正如 图 1 所示。
图 1 一个简单的 Person 类
export class Person {
firstName: string;
lastName: string;
constructor(fn: string, ln: string) {
this.firstName = fn;
this.lastName = ln;
}
greet() : string {
return this.fullName + " says hello!";
}
get fullName() : string {
return this.firstName + " " + this.lastName;
}
}
即使是之前从没有看过 TypeScript 的人也会比较容易明白图中的所有类。导出关键字再次表明此类用于本模块的外部。字段 firstName 与 lastName 使用 TypeScript 批注让编译器强制执行“string-ness”,此问候方法向调用者返回一个字符串,且声明 fullName 方法为 firstName 与 lastName 字段构成的综合只读属性访问器。在 app.ts 文件中使用 Person 类型也非常简单 — 只需从 person.ts 文件导入 Person 类型并使用新关键字构造一个即可:
import { Person } from './Person';
let ted = new Person("Ted", "Neward");
console.log(ted.greet());
细心的读者会注意到导入行已更改 — 它导入 Person 类型中而不是 sayHello 中。虽然肯定会列出 Person 中导入语句括号之间导出的所有符号,但此过程很快就变得非常乏味。因此 TypeScript 提供了一个通配符导入设备,但由于你不希望所有模块的导出名称仅影响全局命名空间,你需要在所有将会可见的名称下面提供一个名称。使用此设备将略微更改应用程序的代码:
import * as PerMod from './Person';
let ted = new PerMod.Person("Ted", "Neward");
console.log(ted.greet());
显然,这不是具有量产品质的代码,因为 PerMod 是一个不尽人意的名称。
在 TypeScript 中进行交互
当然,一个基于组件开发(请记住,此为 AngularJS 2 所强调)的普遍目标是强力将组件用户如何利用组件与组件如何提供该利用分离开来 — 换言之,就是“接口与实现”的区别。此处,TypeScript 从其概念同级 C# 获取一个页面,以便能够声明接口 — 和 C# 中一样,即一个实现提供的行为允诺。
因此,如果 Person 组件想在无需请求任何实现限制的情况下区分不同类型的 Person,可以将 Person 定义为一个接口,提供多个不同的实现,可能构造函数会使构造 Person 变得更为轻松,不必担心它们之间的细节,如 图 2 所示。
图 2 创建 Person
export function createPerson(
firstName: string, lastName: string, occupation: string) : Person {
if (occupation == "Programmer")
return new Programmer(firstName, lastName);
else if (occupation == "Manager")
return new Manager(firstName, lastName);
else
return new NormalPerson(firstName, lastName);
}
export interface Person {
firstName: string;
lastName: string;
greet() : string;
fullName: string;
}
创建实现 Person 的类非常简单,只需使用 implements 关键字即可,如 图 3 所示。
图 3 Person 实现
class NormalPerson implements Person {
firstName: string;
lastName: string;
constructor(fn: string, ln: string) {
this.firstName = fn;
this.lastName = ln;
}
greet() : string {
return this.fullName + " says hello!";
}
get fullName() : string {
return this.firstName + " " + this.lastName;
}
}
此外,如 图 4 所示,创建 NormalPerson 子类型(适用于管理者和程序员)也同样简单,创建将会推迟到父类,然后重写 greet 方法的构造函数以返回适合每个职业的信息。
图 4 程序员实现
class Programmer extends NormalPerson {
constructor(fn: string, ln: string) {
super(fn, ln);
}
greet() : string {
return this.fullName + " says Hello, World!";
}
}
class Manager extends NormalPerson {
constructor(fn: string, ln: string) {
super(fn, ln);
}
greet() : string {
return this.fullName + " says let's dialogue about common synergies!";
}
}
同样,与 ECMAScript 2015 的直接语法类似,除了类型描述符之外(包括接口声明本身),任何使用非字符串作为构造函数参数的尝试都将被坚决拒绝。但请注意,无法导出类的事实意味着客户端代码不知道实际实现是什么;所有客户端均了解的是 Person 接口定义了三个属性 — firstName、lastName 和 fullName — 以及一种客户端可使用的方法 — greet。
装饰器
TypeScript 需要解释的最后一个明显的功能是装饰器,它是 ECMAScript(TypeScript 亦如此)的一个实验性功能,看起来有点像自定义属性,但行为却完全不同。实质上,通过使用 @ 前缀表示法,你可以定义一个可在随时调用不同的代码构造时被调用的函数 — 当构造类、调用方法、访问(或修改)属性、甚至参数作为方法或函数调用的一部分传递时调用此函数。这是一个明显的尝试,即给 TypeScript 和 AngularJS 2 提供一些可充分利用面向方面的编程方法。
AOP 库的典型示例是日志记录函数调用;你可能喜欢重复使用每次调用特殊函数或方法时记录到控制台的代码,不论该调用来自何处。根据定义,这是一个横切关注点,一个无视面向传统对象的复用构造(如继承)的代码块。使用 TypeScript,你可以编写一个日志装饰器,并将该装饰器应用到想要使用日志记录行为装饰的方法中。调用已装饰的方法时就会同时调用该行为。
在实际中,这意味着如果你已编写了一个日志装饰器,则返回的 Person 实现可在 greet 方法中使用 @log,并且将会把调用记录到控制台,如此处所示:
import log from './log';
// ... Code as before
class Manager extends NormalPerson {
constructor(fn: string, ln: string) {
super(fn, ln);
}
@log()
greet() : string {
return this.fullName + " says let's dialogue about common synergies!";
}
}
运行时会生成一些很好的方法级别日志记录:
$ node app.js
Call: greet() => "Ted Neward says Hello, World!"
Ted Neward says Hello, World!
Call: greet() => "Andy Lientz says let's dialogue about common synergies!"
Andy Lientz says let's dialogue about common synergies!
Call: greet() => "Charlotte Neward says hello!"
Charlotte Neward says hello!
日志组件本身是相当不错的运行时类型器件,但它稍微超出了本栏的范围。它在 图 5 中呈现以供细阅,但我不会在此处描述该组件如何运作,而是描述 TypeScript 将有效地把一些代码插入到合适位置,以调用日志装饰器返回的函数。
图 5 定义日志记录批注
export default function log() {
return function(target: any,
propertyKey: string,
descriptor: PropertyDescriptor)
{
// Save a reference to the original method
var originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
var argsLog = args.map(a => JSON.stringify(a)).join();
var result = originalMethod.apply(this, args);
var resultLog = JSON.stringify(result);
console.log(`Call: ${propertyKey}(${argsLog}) => ${resultLog}`);
return result;
}
// Return edited descriptor instead of overwriting
// the descriptor by returning a new descriptor
return descriptor;
}
}
TypeScript 网站 bit.ly/2fh1lzC 为想了解如何创建装饰器的读者进行了介绍。幸运的是,高效使用 AngularJS 2 无需了解如何创建你自己的装饰器;然而,了解如何使用已存在的装饰器却是绝对要求。对于初学者而言,AngularJS 2 将其用于依赖项注入,即“角方法”确立以来的核心工具。
总结
我已快速浏览过 TypeScript,它的确不是一个详细全面的解决方式语言,但是一旦你开始学习 AngularJS 2,它将带你起飞,开启黑客之旅。请注意,此处介绍的一些功能需要特定编译器开关;尤其是装饰器需要该开关 — experimentalDecorators(或 tscconfig.json 中的等效开关)。然而大多时候,Yeoman 生成的基架已经具有合适的开关,AngularJS 2 开发人员无需担心这一点。
说到这里,是时候开始探索 AngularJS 了 — 组件、模型和视图,哦,那是下一期了。在那以前,祝你编码愉快!
Ted Neward 是本部位于西雅图的 Polytechnology 公司的顾问、讲师和导师。他是一位 F #MVP,写过 100 多篇文章,独自撰写并与人合著过十几本书。如果您有兴趣请他参与您的团队工作,请通过 ted@tedneward.com 与他联系,或通过 blogs.tedneward.com 访问其博客。
衷心感谢以下技术专家对本文的审阅: Shawn Wildermuth