一、高级 TypeScript 特性

在本章中,我们将了解 TypeScript 的一些基本方面。 如果使用得当,这些特性将为 TypeScript 提供一种干净、直观的工作方式,并帮助你编写专业级别的代码。 我们在这里讨论的一些内容可能对您来说并不新鲜,但我将它们包括在内,以便在后面的章节中有一个共同的知识基线,以及对我们为什么要使用这些特性的理解。 我们还将讨论为什么需要这些技术; 仅仅知道如何应用是不够的,我们还需要知道在什么情况下我们应该使用它们,以及当我们这样做时我们需要考虑什么。 这一章的重点不是创建一个干巴巴的、详尽的每个特性列表——相反,我们将介绍我们在本书的其余部分所需要的信息。 这些都是我们将在日常开发中反复使用的实用技术。

由于这是一本关于 web 开发的书,我们也将创建大量的 ui,所以我们将看看如何使用流行的 Bootstrap 框架创建有吸引力的界面。

本章将涵盖以下主题:

  • 使用不同类型的联合类型
  • 组合类型和交集类型
  • 使用类型别名简化类型声明
  • 使用 REST 属性解构对象
  • 使用 REST 处理可变数量的参数
  • 面向方面编程(AOP
  • 使用 mixin 组合类型
  • 使用不同类型和泛型的相同代码
  • 使用映射映射值
  • 创建带有 promise 和 async/await 的异步代码
  • 使用 Bootstrap 创建 ui

技术要求

为了完成本章,你需要安装 Node.js。 Node.js 可从https://nodejs.org/en/下载安装。

你还需要安装 TypeScript 编译器。 有两种方法可以通过 Node.js 使用Node Package Manager(NPM)。 如果你想在你所有的应用中使用相同的 TypeScript 版本,并且在每次更新时都能在相同的版本上运行,请使用以下命令:

npm install -g typescript

如果你想让 TypeScript 的版本在特定项目的本地,请在项目文件夹中输入以下命令:

npm install typescript --save-dev

对于代码编辑器,您可以使用任何合适的编辑器,甚至是基本的文本编辑器。 在本书中,我将使用 Visual Studio Code,一个免费的跨平台集成开发环境(IDE),可在https://code.visualstudio.com/上获得。

所有代码可在 GitHub 上的https://github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter01

使用 tsconfig 构建防将来的 TypeScript

随着 TypeScript 的流行,它得益于快速发展的开源架构。 原始实现背后的设计目标意味着它已经被证明是开发人员的流行选择,从新手到经验丰富的专业人员。 这种流行意味着该语言迅速获得了新的特性,有些是直接的,有些是针对致力于 JavaScript 生态系统前沿的开发人员的。 本章旨在介绍 TypeScript 引入的一些特性,以匹配你以前可能没有遇到过的当前或即将到来的 ECMAScript 实现。

随着本章的深入,我偶尔会提到一些需要更新 ECMAScript 标准的特性。 在某些情况下,TypeScript 已经提供了一个功能的多填充实现,该功能可以与早期版本的 ECMAScript 一起工作。 在其他情况下,我们编译的版本将有一个功能,不能回填超过某一点,所以它将是值得使用一个更最新的设置。

虽然完全从命令行只使用参数就可以编译 TypeScript,但我更喜欢使用tsconfig.json。 你可以手动创建这个文件,也可以在命令行中用以下命令让 TypeScript 为你创建:

tsc --init

如果你想复制我的设置,这些是我默认设置的。 当我们需要更新引用时,我会指出需要添加的条目:

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "commonjs",
    "lib": [ "ES2015", "dom" ],
    "sourceMap": true,
    "outDir": "./script", 
    "strict": true, 
    "strictNullChecks": true, 
    "strictFunctionTypes": true, 
    "noImplicitThis": true, 
    "alwaysStrict": true, 
    "noImplicitReturns": true, 
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true,
    "experimentalDecorators": true, 
  }
}

介绍 TypeScript 的高级特性

随着每一次发布,TypeScript 都取得了长足的进步,在第一版引入的基础语言基础上添加了新的特性和功能。 从那以后,JavaScript 继续前进,TypeScript 也为新出现的标准添加了特性,为旧的 JavaScript 实现提供实现,或者在针对更新的 ECMA 标准时调用本地实现。 在第一章中,我们将看到其中的一些特性,我们将在本书中使用这些特性。

使用不同类型的联合类型

我们将要介绍的第一个特性是我最喜欢的特性之一,即使用联合类型的能力。 当函数希望单个参数是一种或另一种类型时,使用这些类型。 例如,假设我们有一个验证例程,需要检查一个值是否在特定范围内,这个验证可以从文本框中接收到string值,或者从计算中接收到number值。 由于解决这个问题的每一种技术都有很多共同点,我们将从一个简单的类开始,它允许我们指定形成我们的范围的最小值和最大值,以及一个实际执行验证的函数,如下所示:

class RangeValidationBase {
     constructor(private start : number, private end : number) { }
     protected RangeCheck(value : number) : boolean {
         return value >= this.start && value <= this.end;
     }
     protected GetNumber(value : string) : number {
        return new Number(value).valueOf();
     }
 }

如果你以前没见过这样的constructor,那就相当于下面这样写:

 private start : number = 0;
 private end : number = 0;
 constructor(start : number, end : number) {
     this.start = start;
     this.end = end;
 }

如果您需要检查参数或以某种方式操作它们,您应该使用这种扩展格式的参数。 如果只是简单地将值赋给私有字段,那么第一种格式是一种非常优雅的方法,可以避免代码混乱。

有几种方法可以解决只使用stringnumber执行验证的问题。 解决这个问题的第一种方法是提供两个接受相关类型的独立方法,如下所示:

class SeparateTypeRangeValidation extends RangeValidationBase {
     IsInRangeString(value : string) : boolean {
         return this.RangeCheck(this.GetNumber(value));
     }
     IsInRangeNumber(value : number) : boolean {
         return this.RangeCheck(value);
     }
 }

虽然这种技术可以工作,但它不是很优雅,而且肯定没有利用 TypeScript 的强大功能。 我们可以使用的第二种技术是允许我们在没有约束的情况下传入值,如下所示:

class AnyRangeValidation extends RangeValidationBase {
     IsInRange(value : any) : boolean {
         if (typeof value === "number") {
             return this.RangeCheck(value);
         } else if (typeof value === "string") {
             return this.RangeCheck(this.GetNumber(value));
         }
         return false;
     }
 }

这无疑是对原始实现的改进,因为我们为函数确定了一个签名,这意味着调用代码更加一致。 不幸的是,我们仍然可以向方法传递无效类型,因此,如果我们传递boolean,例如,这段代码将成功编译,但在运行时将失败。

如果我们想约束我们的验证,使它只接受字符串或数字,那么我们可以使用联合类型。 它与上一个实现没有太大区别,但它确实给了我们所追求的编译时类型安全,如下所示:

class UnionRangeValidation extends RangeValidationBase {
     IsInRange(value : string | number) : boolean {
         if (typeof value === "number") {
             return this.RangeCheck(value);
         }
         return this.RangeCheck(this.GetNumber(value));
     }
 }

将类型约束标识为联合的签名是函数名中的type | type。 这告诉编译器(和我们)这个方法的有效类型是什么。 正如我们所约束的输入是numberstring,一旦我们已经排除,类型不是number,typeof我们不需要检查是否这是一个string我们有进一步简化代码。

We can chain as many types together as we need in a union statement. There's no practical limit but we have to make sure that each type in the union list needs a corresponding typeof check if we are going to handle it properly. The order of the types does not matter either, so number | string is treated the same as string | number. Something to remember though is if the function has lots of types combined together, then it is probably doing too much and the code should be looked at to see whether it can be broken up into smaller pieces.

对于工会类型,我们可以更进一步。 在 TypeScript 中,我们有两种特殊类型:nullundefined。 这些类型可以被分配给任何东西,除非我们使用–strictNullChecks选项编译代码,或者如果我们在tsconfig.json文件中将其设置为一个标志,则使用strictNullChecks = true。 我喜欢设置这个值,以便我的代码只在应该处理空值的地方处理空值,这是防止仅仅因为函数接收到空值而产生副作用的一个很好的方法。 如果我们想要允许null(或undefined),我们只需要添加这些作为联合类型。

组合类型和交集类型

有时,对于我们来说,有能力处理这样的情况是很重要的,我们可以将多种类型放在一起,并将它们视为一种类型。 交集类型是具有被组合的每个类型的所有属性的类型。 我们可以通过下面的简单示例看到交集是什么样子的。 首先,我们将为GridMargin创建类,以应用于Grid,如下所示:

class Grid {
     Width : number = 0;
     Height : number = 0;
 }
 class Margin {
     Left : number = 0;
     Top : number = 0;
 }

我们要创建的是一个交集,它最终将与Grid属性中的WidthHeight,以及Margin中的LeftTop结合。 为此,我们将创建一个函数,该函数接受GridMargin,并返回一个包含所有这些属性的类型,如下所示:

function ConsolidatedGrid(grid : Grid, margin : Margin) : Grid & Margin {
     let consolidatedGrid = <Grid & Margin>{};
     consolidatedGrid.Width = grid.Width;
     consolidatedGrid.Height = grid.Height;
     consolidatedGrid.Left = margin.Left;
     consolidatedGrid.Top = margin.Top;
     return consolidatedGrid;
 }

请注意,我们将在本章稍后讨论对象扩散时回到这个函数,看看如何删除大量的样板文件属性复制。

魔力使这工作是我们定义consolidatedGrid的方式。 我们使用&将我们想要创建交集的类型连接在一起。 当我们想把GridMargin放在一起时,我们使用<Grid & Margin>来告诉编译器我们的类型是什么样子的。 可以看到,我们不需要显式地命名这个类型; 编译器足够聪明,可以为我们解决这个问题。

如果两种类型都有相同的属性会发生什么? TypeScript 会阻止我们将这些类型混合在一起吗? 只要属性是相同类型的,那么 TypeScript 就会很乐意我们使用相同的属性名。 为了看到这一点,我们将扩展我们的Margin类,也包括WidthHeight属性,如下所示:

class Margin {
     Left : number = 0;
     Top : number = 0;
     Width : number = 10;
     Height : number = 20;
 }

我们如何处理这些额外的属性取决于我们想对它们做什么。 例中,将MarginWidthHeight加到GridWidthHeight上。 这使得我们的函数看起来像这样:

function ConsolidatedGrid(grid : Grid, margin : Margin) : Grid & Margin {
     let consolidatedGrid = <Grid & Margin>{};
     consolidatedGrid.Width = grid.Width + margin.Width;
     consolidatedGrid.Height = grid.Height + margin.Height;
     consolidatedGrid.Left = margin.Left;
     consolidatedGrid.Top = margin.Top;
     return consolidatedGrid;
 }

但是,如果我们想尝试重用相同的属性名,但这些属性的类型不同,那么如果这些类型对它们有限制,就会出现问题。 为了看到这个效果,我们将扩展我们的GridMargin类,包括WeightGrid班的Weight是数字,Margin班的Weight是字符串,如下:

class Grid {
     Width : number = 0;
     Height : number = 0;
     Weight : number = 0;
 }
 class Margin {
     Left : number = 0;
     Top : number = 0;
     Width : number = 10;
     Height : number = 20;
     Weight : string = "1";
 }

我们将尝试在我们的ConsolidatedGrid函数中添加Weight类型:

consolidatedGrid.Weight = grid.Weight + new          
    Number(margin.Weight).valueOf();

在这一点上,TypeScript 会提示以下错误:

error TS2322: Type 'number' is not assignable to type 'number & string'.
   Type 'number' is not assignable to type 'string'.

虽然有解决这个问题的方法,例如在Grid中为Weight使用联合类型并解析输入,但通常不值得这么麻烦。 如果类型不同,这通常是一个很好的迹象,表明属性的行为不同,所以我们确实应该寻找不同的名称。

虽然我们在这里的示例中使用类,但值得指出的是,交集不仅仅局限于类。 交叉也适用于接口、泛型和基本类型。

在处理交叉路口时,我们还需要考虑其他一些规则。 如果我们有相同的属性名,但该属性只有一面是可选的,那么最终确定的属性将是强制性的。 我们将引入一个填充属性到我们的GridMargin类,并使PaddingMargin可选,如下所示:

class Grid {
     Width : number = 0;
     Height : number = 0;
     Padding : number;
 }
 class Margin {
     Left : number = 0;
     Top : number = 0;
     Width : number = 10;
     Height : number = 20;
     Padding?: number;
 }

因为我们提供了一个强制性的Padding变量,所以我们不能改变交集,如下所示:

consolidatedGrid.Padding = margin.Padding;

因为不能保证边界填充将被分配,编译器将尽其所能阻止我们。 为了解决这个问题,我们将修改代码,如果设置了margin填充,则应用grid填充,如果没有设置,则返回grid填充。 为了做到这一点,我们将做一个简单的修正:

consolidatedGrid.Padding = margin.Padding ? margin.Padding : grid.Padding;

这种看起来奇怪的语法称为三元运算符。 如果margin.Padding有一个值,让consolidatedGrid.Padding等于这个值; 否则,让它等于grid.Padding。 这可以写成 if/else 语句,但由于这是 TypeScript 和 JavaScript 等语言中的常见范例,所以值得我们熟悉一下。

使用类型别名简化类型声明

与交集类型和联合类型相关的是类型别名。 TypeScript 让我们可以创建一个方便的别名,编译器会把它扩展到相关的代码中,而不是用string | number | null来混淆我们的代码。

假设我们想要创建一个表示联合类型string | number的类型别名,那么我们可以创建一个别名,如下所示:

type StringOrNumber = string | number;

如果我们再次访问范围验证示例,我们可以更改函数的签名来使用这个别名,如下所示:

class UnionRangeValidationWithTypeAlias extends RangeValidationBase {
     IsInRange(value : StringOrNumber) : boolean {
         if (typeof value === "number") {
             return this.RangeCheck(value);
         }
         return this.RangeCheck(this.GetNumber(value));
     }
 }

在这段代码中需要注意的重要一点是,我们实际上并没有在这里创建任何新类型。 类型别名只是一个语法技巧,我们可以使用它使代码更具可读性,更重要的是,当我们在更大的团队中工作时,它可以帮助我们创建更加一致的代码。

我们还可以将类型别名与类型组合起来创建更复杂的类型别名。 如果我们想要在之前的类型别名中添加null支持,我们可以添加以下类型:

type NullableStringOrNumber = StringOrNumber | null;

因为编译器仍然可以看到底层的类型并使用它,所以我们可以使用下面的语法来调用IsInRange方法:

let total : string | number = 10;
if (new UnionRangeValidationWithTypeAlias(0,100).IsInRange(total)) {
    console.log(`This value is in range`);
}

显然,这并没有给我们提供非常一致的代码,所以我们可以将string | number更改为StringOrNumber

使用对象分布分配属性

Intersection 类型部分的ConsolidatedGrid示例中,我们将每个属性分别赋给交集。 根据我们试图实现的效果,还有另一种方法可以用更少的代码创建<Grid & Margin>交集类型。 使用扩展运算符,可以自动地对一个或多个输入类型的属性进行浅拷贝。

首先,让我们看看如何重写前面的示例,使其自动填充空白信息:

function ConsolidatedGrid(grid : Grid, margin : Margin) : Grid  & Margin {
    let consolidatedGrid = <Grid & Margin>{...margin};
    consolidatedGrid.Width += grid.Width;
    consolidatedGrid.Height += grid.Height;
    consolidatedGrid.Padding = margin.Padding ? margin.Padding : 
    grid.Padding;
    return consolidatedGrid;
}

当我们实例化我们的consolidatedGrid函数时,这段代码从margin中复制属性并填充它们。 三个点(...)告诉编译器将其视为一个扩展操作。 因为我们已经填充了WidthHeight,所以我们使用+=简单地从网格中添加元素。

如果我们想同时应用gridmargin的值,会发生什么? 要做到这一点,我们可以将实例化改为如下所示:

let consolidatedGrid = <Grid & Margin>{grid, ...margin};

grid中的值填充Grid值,然后用margin中的值填充Margin值。 这告诉我们两件事。 第一个是扩展操作将适当的属性映射到适当的属性。 它告诉我们的第二件事是它的顺序很重要。 由于margingrid属性相同,故grid设置的值会被margin设置的值覆盖。 为了设置属性,以便我们看到WidthHeight中的grid值,我们必须颠倒这一行的顺序。 当然,在现实中我们可以看到这样的效果:

let consolidatedGrid = <Grid & Margin>{...margin, grid };

在这个阶段,我们应该看看 TypeScript 从中生成的 JavaScript。 这是我们使用 ES5 编译代码时的样子:

var __assign = (this && this.__assign) || function () {
    __assign = Object.assign || function(t) {
        for (var s, i = 1, n = arguments.length; i < n; i++) {
            s = arguments[i];
            for (var p in s) if (Object.prototype.hasOwnProperty.call(s,
            p))
                t[p] = s[p];
        }
        return t;
    };
    return __assign.apply(this, arguments);
};
function ConsolidatedGrid(grid, margin) {
    var consolidatedGrid = __assign({}, margin, grid);
    consolidatedGrid.Width += grid.Width;
    consolidatedGrid.Height += grid.Height;
    consolidatedGrid.Padding = margin.Padding ? margin.Padding : 
    grid.Padding;
    return consolidatedGrid;
}

然而,如果我们使用 ES2015 或更高版本编译代码,__assign函数将被删除,我们的ConsolidatedGridJavaScript 如下所示:

function ConsolidatedGrid(grid, margin) {
    let consolidatedGrid = Object.assign({}, margin, grid);
    consolidatedGrid.Width += grid.Width;
    consolidatedGrid.Height += grid.Height;
    consolidatedGrid.Padding = margin.Padding ? margin.Padding : 
    grid.Padding;
    return consolidatedGrid;
}

我们在这里看到的是,TypeScript 努力工作,以确保无论我们的目标是哪个版本的 ECMAScript,它都能生成工作正常的代码。 我们不必担心这个功能是否可用; 我们把它留给 TypeScript 来填补我们的空白。

使用 REST 属性解构对象

在使用扩展运算符构建对象的地方,我们还可以使用 REST 属性来解构对象。 解构简单的意思是我们将把一个复杂的事物分解成更简单的东西。 换句话说,当我们将数组中的元素或对象的属性赋值给单个变量时,就会发生解构。 虽然我们总是能够将复杂的对象和数组分解成更简单的类型,但 TypeScript 提供了一种干净优雅的方式来使用 REST 参数分解这些类型,它可以分解对象和数组。

为了理解什么是 REST 属性,我们首先需要理解如何解构对象或数组。 我们将从解构下面的对象字面量开始,如下所示:

let guitar = { manufacturer: 'Ibanez', type : 'Jem 777', strings : 6 };

我们可以用下面的方法来解构它:

const manufacturer = guitar.manufacturer;
const type = guitar.type;
const strings = guitar.strings;

虽然这是可行的,但不是很优雅,而且有很多重复。 幸运的是,TypeScript 采用了这样的 JavaScript 语法来进行简单的解析,它提供了更简洁的语法:

let {manufacturer, type, strings} = guitar;

在功能上,这将导致与原始实现相同的单个项。 各个属性的名称必须与我们正在解构的对象中的属性名称相匹配——这就是语言如何知道哪个变量与对象上的哪个属性相匹配的。 如果出于某种原因需要更改属性的名称,可以使用以下语法:

let {manufacturer : maker, type, strings} = guitar;

对象上的 REST 操作符背后的想法是,当你取一个数量可变的项目时,它会应用,所以我们将把这个对象分解成制造商,而其他字段将打包成一个 REST 变量,如下所示:

let { manufacturer, ...details } = guitar;

The REST operator must appear at the end of the assignment list; the TypeScript compiler complains if we add any properties after it.

在这条语句之后,details现在包含了类型值和字符串值。 当我们查看生成的 JavaScript 时,事情就变得有趣了。 上一个示例中的解构形式在 JavaScript 中是相同的。 在 JavaScript 中没有与 REST 属性等效的属性(在 ES2018 之前的版本中肯定没有),所以 TypeScript 会为我们生成代码,为我们提供一种一致的方式来解构更复杂的类型:

// Compiled as ES5
var manufacturer = guitar.manufacturer, details = __rest(guitar, ["manufacturer"]);
var __rest = (this && this.__rest) || function (s, e) {
    var t = {};
    for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && 
    e.indexOf(p) < 0)
        t[p] = s[p];
    if (s != null && typeof Object.getOwnPropertySymbols === "function")
        for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length;
        i++) if (e.indexOf(p[i]) < 0)
            t[p[i]] = s[p[i]];
    return t;
};

数组解构的工作方式与对象解构类似。 其语法实际上与对象版本完全相同; 不同之处在于它使用[ ]来代替对象版本使用的{ },并且变量的顺序是基于数组中项目的位置。

初始的数组解构方法依赖于与数组中某一下标项相关联的变量:

const instruments = [ 'Guitar', 'Violin', 'Oboe', 'Drums' ];
const gtr = instruments[0];
const violin = instruments[1];
const oboe = instruments[2];
const drums = instruments[3];

使用数组解构,我们可以改变这个语法,使其更简洁,如下所示:

let [ gtr, violin, oboe, drums ] = instruments;

既然 TypeScript 团队擅长为我们提供一致的逻辑体验,那么我们也可以使用类似的语法将 REST 属性应用到数组中,这也就不足为奇了:

let [gtr, ...instrumentslice] = instruments;

同样的,没有直接的 JavaScript 等效物,但是编译过的 TypeScript 表明,JavaScript 确实提供了底层的基础,TypeScript 设计者已经能够使用array.slice:优雅地将其引入。

// Compiled as ES5
var gtr = instruments[0], instrumentslice = instruments.slice(1);

使用 REST 处理可变数量的参数

关于 REST,我们需要了解的最后一件事是函数具有 REST 参数。 这些属性与 REST 属性不同,但语法非常相似,因此我们应该很容易掌握它们。 REST 参数解决的问题是如何处理传入函数的参数数量的变化。 在函数中标识 REST 参数的方法是在其前面加上省略号,并将其类型化为数组。

在这个例子中,我们将注销一个头文件,后面跟着一个变量instruments:

function PrintInstruments(log : string, ...instruments : string[]) : void {
    console.log(log);
    instruments.forEach(instrument => {
        console.log(instrument);
    });
}
PrintInstruments('Music Shop Inventory', 'Guitar', 'Drums', 'Clarinet', 'Clavinova');

由于 REST 参数是一个数组,因此我们可以访问数组函数,这意味着我们可以直接从它执行forEach之类的操作。 重要的是,REST 参数不同于 JavaScript 函数中的 arguments 对象,因为它们从参数列表中未命名的值开始,而 arguments 对象包含所有参数的列表。

由于 REST 参数在 ES5 中不可用,TypeScript 做了必要的工作来提供模拟 REST 参数的 JavaScript。 首先,我们将看到它编译为 ES5 时的样子,如下所示:

function PrintInstruments(log) {
    var instruments = [];
    // As our rest parameter starts at the 1st position in the list of 
    // arguments,
    // our index starts at 1.
    for (var _i = 1; _i < arguments.length; _i++) {
        instruments[_i - 1] = arguments[_i];
    }
    console.log(log);
    instruments.forEach(function (instrument) {
        console.log(instrument);
    });
}

当我们查看从 ES2015 编译生成的 JavaScript(你需要在tsconfig.json文件中将 target 的条目更改为 ES2015),我们看到它看起来和我们的 TypeScript 代码完全一样:

function PrintInstruments(log, ...instruments) {
    console.log(log);
    instruments.forEach(instrument => {
        console.log(instrument);
    });
}

在这一点上,我再怎么强调查看正在生成的 JavaScript 是多么重要。 TypeScript 非常擅长向我们隐藏复杂性,但我们确实应该熟悉将要生成的内容。 我发现这是一个了解背后发生的事情的好方法,如果可能的话,可以使用不同版本的 ECMAScript 标准进行编译,看看生成了什么代码。

AOP 使用修饰符

TypeScript 中我最喜欢的特性之一就是使用装饰器的能力。 decorator 是作为实验特性引入的,它是我们可以用来修改单个类的行为,而无需更改类的内部实现的代码片段。 有了这个概念,我们就可以适应现有类的行为,而不必子类化它。

如果你是从 Java 或 c#等语言学习 TypeScript 的,你可能会注意到装饰器看起来很像一种称为 AOP 的技术。 AOP 技术为我们提供的是通过切割一段代码并将其分离到不同位置来提取重复代码的能力。 这意味着我们不必在实现中添加大量的样板代码,但这些代码绝对必须出现在运行的应用中。

要解释什么是 decorator,最简单的方法是从一个示例开始。 假设我们有一个类,其中只有特定角色的用户才能访问特定的方法,如下所示:

interface IDecoratorExample {
    AnyoneCanRun(args:string) : void;
    AdminOnly(args:string) : void;
}
class NoRoleCheck implements IDecoratorExample {
    AnyoneCanRun(args: string): void {
        console.log(args);
    }   
    AdminOnly(args: string): void {
        console.log(args);
    }
}

现在,我们将创建一个具有adminuser角色的用户,这意味着在这个类中调用这两个方法没有问题:

let currentUser = {user: "peter", roles : [{role:"user"}, {role:"admin"}] };
function TestDecoratorExample(decoratorMethod : IDecoratorExample) {
    console.log(`Current user ${currentUser.user}`);
    decoratorMethod.AnyoneCanRun(`Running as user`);
    decoratorMethod.AdminOnly(`Running as admin`);       
}
TestDecoratorExample(new NoRoleCheck());

这就给出了我们的预期输出,如下所示:

Current user Peter
Running as user
Running as admin

如果我们要创建一个只具有user角色的用户,我们希望他们不能运行仅管理的代码。 由于我们的代码没有角色检查,不管用户分配了什么角色,AdminOnly方法都将运行。 修复此代码的一种方法是添加检查授权的代码,然后将其添加到每个方法中。

首先,我们将创建一个简单的函数来检查当前用户是否属于某个特定的角色:

function IsInRole(role : string) : boolean {
    return currentUser.roles.some(r => r.role === role);
}

重新访问我们现有的实现,我们将改变我们的函数来调用这个检查,并确定是否允许user运行该方法:

AnyoneCanRun(args: string): void {
    if (!IsInRole("user")) {
        console.log(`${currentUser.user} is not in the user role`);
        return;
    };
    console.log(args);
}   
AdminOnly(args: string): void {
    if (!IsInRole("admin")) {
        console.log(`${currentUser.user} is not in the admin role`);
    };
    console.log(args);
}

当我们看这段代码时,我们可以看到这里有很多重复的代码。 更糟糕的是,虽然我们有重复的代码,但在这个实现中有一个 bug。 在AdminOnly代码,没有返回语句在IsInRole所以代码块仍运行AdminOnly代码,但它会告诉我们,用户没有在admin的角色,将输出消息。 这突出了重复代码的一个问题:很容易在没有意识到的情况下引入微妙(或不那么微妙)的错误。 最后,我们违背了良好的面向对象(OO)开发实践的基本原则之一。 我们的类和方法正在做它们不应该做的事情; 代码应该只做一件事,所以检查角色不属于这里。 在第二章用 TypeScript创建 Markdown 编辑器中,当我们深入探讨 OO 开发思维时,我们会更深入地讨论这个问题。

让我们看看如何使用方法装饰器来删除样板代码并解决单一责任问题。

在编写代码之前,我们需要确保 TypeScript 知道我们将使用装饰器,这是一个实验性的 ES5 特性。 我们可以通过从命令行运行以下命令来做到这一点:

tsc --target ES5 --experimentalDecorators

或者,我们可以在我们的tsconfig文件中设置:

"compilerOptions": {
        "target": "ES5",
// other parameters….
        "experimentalDecorators": true
    }

启用了修饰器构建特性后,我们现在可以编写第一个修饰器,以确保用户属于admin角色:

function Admin(target: any, propertyKey : string | symbol, descriptor : PropertyDescriptor) {
        let originalMethod = descriptor.value;
        descriptor.value = function() {
            if (IsInRole(`admin`)) {
                originalMethod.apply(this, arguments);
                return;
            }
            console.log(`${currentUser.user} is not in the admin role`);
        }
        return descriptor;
    }

每当我们看到与此类似的函数定义时,我们就知道我们正在查看一个方法装饰器。 TypeScript 期望的参数顺序如下:

function (target: any, propertyKey : string | symbol, descriptor : PropertyDescriptor)

第一个参数用于引用我们要应用它的元素。 第二个参数是元素的名称,最后一个参数是我们要应用装饰器的方法的描述符; 这允许我们改变方法的行为。 我们必须有一个带有这个签名的函数来用作我们的装饰器:

let originalMethod = descriptor.value;
descriptor.value = function() {
    ...
}
return descriptor;

decorator 方法的内部并不像它们看起来那么可怕。 我们所做的是从描述符复制原始方法,然后用我们自己的自定义实现替换该方法。 这个包装的实现将被返回,并将是我们遇到它时执行的代码:

if (IsInRole(`admin`)) {
    originalMethod.apply(this, arguments);
    return;
}
console.log(`${currentUser.user} is not in the admin role`);

在包装的实现中,我们正在执行相同的角色检查。 如果检查通过,我们就采用原来的方法。 通过使用这样的技术,我们添加了一些可以避免以一致的方式调用我们的方法的东西。

为了应用它,我们在修饰器工厂函数名前面使用@,就在类中的方法前面。 当我们添加 decorator 时,我们必须避免在它和方法之间放置分号,如下所示:

class DecoratedExampleMethodDecoration implements IDecoratorExample {
    AnyoneCanRun(args:string) : void {
        console.log(args);
    }
    @Admin
    AdminOnly(args:string) : void {
        console.log(args);
    }
}

虽然这段代码适用于AdminOnly代码,但它不是特别灵活。 当我们添加更多的角色时,我们最终将不得不添加越来越多几乎相同的功能。 如果我们有一种方法来创建一个通用函数,我们可以用它来返回一个 decorator,该 decorator 将接受一个参数来设置我们想要允许的角色。 幸运的是,我们有一种方法可以做到这一点,那就是使用一种叫做装饰工厂的东西。

简单地说,TypeScript 装饰器工厂是一个函数,它可以接收参数,并使用参数返回实际的装饰器。 它只需要对我们的代码进行一些小的调整,我们有一个工作工厂,在那里我们可以指定我们想要保护的角色:

function Role(role : string) {
    return function(target: any, propertyKey : string | symbol, descriptor 
    : PropertyDescriptor) {
        let originalMethod = descriptor.value;
        descriptor.value = function() {
            if (IsInRole(role)) {
                originalMethod.apply(this, arguments);
                return;
            }
            console.log(`${currentUser.user} is not in the ${role} role`);
        }
        return descriptor;
    }
}

这里唯一真正的区别是,我们有一个返回装饰器的函数,它不再有名称,并且工厂函数参数正在我们的装饰器中使用。 现在我们可以将类改为使用这个工厂:

class DecoratedExampleMethodDecoration implements IDecoratorExample {
    @Role("user") // Note, no semi-colon
    AnyoneCanRun(args:string) : void {
        console.log(args);
    }
    @Role("admin")
    AdminOnly(args:string) : void {
        console.log(args);
    }
}

有了这个变化,当我们调用我们的方法时,只有管理员将能够访问AdminOnly方法,而任何用户将能够调用AnyoneCanRun。 一个重要的边注是,我们的装饰器只应用于类内部。 我们不能在一个独立的函数上使用它。

我们称这种技术为 decorator 的原因是它遵循了所谓的decorator 模式。 该模式识别了一种用于向单个对象添加行为的技术,而不影响来自同一类的其他对象,也不需要创建子类。 模式仅仅是对软件工程中常见问题的形式化解决方案,因此名称作为描述功能上发生的事情的有用速记。 如果知道这里也有工厂模式,可能就不会那么令人惊讶了。 在阅读这本书的过程中,我们还会遇到其他模式的例子,所以当我们读到最后的时候,我们会很舒服地使用它们。

我们也可以将 decorator 应用于类中的其他项。 例如,如果我们想阻止未经授权的用户实例化我们的类,我们可以定义一个类装饰器。 类装饰器被添加到类定义中,并期望将构造函数作为函数接收。 这是我们的构造器装饰器从工厂创建时的样子:

function Role(role : string) {
    return function(constructor : Function) {
        if (!IsInRole (role)) {
            throw new Error(`The user is not authorized to access this class`);
        }
    }
}

当我们应用这个时,我们遵循使用@前缀的相同格式,因此,当代码试图为非 admin 用户创建这个类的新实例时,应用将抛出一个错误,阻止创建这个类:

@Role ("admin")
class RestrictedClass {
    constructor() {
        console.log(`Inside the constructor`);
    }
    Validate() {
        console.log(`Validating`);
    }
}

可以看到,我们没有在类中声明任何装饰器。 我们应该始终将它们创建为顶级函数,因为它们的用法不适合装饰类,所以我们不会看到像@MyClass.Role("admin");这样的语法。

除了构造函数和方法修饰之外,我们还可以修饰属性、访问器等。 我们不打算在这里讲这些,但它们会在后面的书中出现。 我们还将研究如何将装饰器链在一起,这样我们就有了如下的语法:

@Role ("admin")
@Log(Creating RestrictedClass)
class RestrictedClass {
    constructor() {
        console.log(`Inside the constructor`);
    }
    Validate() {
        console.log(`Validating`);
    }
}

使用 mixin 组合类型

当我们第一次遇到经典的 OO 理论时,我们遇到了类可以继承的想法。 这里的想法是,我们可以从通用类创建更专门化的类。 一个比较流行的例子是,我们有一个 vehicle 类,它包含关于车辆的基本细节。 我们从vehicle类继承一个car类。 然后我们从car类继承一个sports car类。 这里的每一层继承都添加了我们所继承的类中没有的特性。

一般来说,这对我们来说是一个简单的概念,但当我们想要将两个或更多看起来不相关的东西放在一起来编写代码时,会发生什么呢? 让我们来看一个简单的例子。

数据库应用通常会存储一条记录是否被删除,而不是实际删除该记录,以及该记录上一次更新发生的时间。 乍一看,我们似乎想要在一个人的数据实体中跟踪这些信息。 与其将这些信息添加到每个数据实体中,我们可能会创建一个包含这些信息的基类,然后从它继承:

class ActiveRecord {
    Deleted = false;
}
class Person extends ActiveRecord {
    constructor(firstName : string, lastName : string) {
        this.FirstName = firstName;
        this.LastName = lastName;
    }

    FirstName : string;
    LastName : string;
}

这种方法的第一个问题是,它将记录状态的细节与实际记录本身混合在一起。 在接下来的几章中,随着我们进一步深入 OO 设计,我们将继续强调这样混合项不是一个好主意,因为我们创建的类必须做不止一件事,这会使它们不那么健壮。 这种方法的另一个问题是,如果我们想要添加的日期记录更新,我们要么是要添加ActiveRecord的更新日期,这意味着每一个类,它扩展了ActiveRecord也会得到更新的日期, 或者我们将不得不创建一个新类来添加更新日期并将其添加到层次结构链中,这意味着没有删除字段就不能有更新字段。

虽然继承确实有它的位置,但最近几年已经看到了组合对象来制作新对象的想法。 这种方法背后的思想是构建不依赖继承链的离散元素。 如果我们重新访问 person 实现,我们将使用一个称为 mixin 的特性来构建相同的特性。

我们需要做的第一件事是定义一个类型,它将作为 mixin 的合适构造函数。 我们可以将这个类型命名为任何类型,但 TypeScript 中关于 mixin 的约定是使用以下类型:

type Constructor<T ={}> = new(...args: any[]) => T;

这个类型定义为我们提供了一些可以扩展的东西来创建我们的专用 mixin。 这个看起来奇怪的语法实际上是说,对于任何特定的类型,都会使用任何合适的参数创建一个新实例。

以下是我们的记录状态执行:

function RecordStatus<T extends Constructor>(base : T) {
    return class extends base {
        Deleted : boolean = false;
    }
}

RecordStatus函数通过返回一个扩展了构造函数实现的新类来扩展Constructor类型。 在这里,我们添加了我们的Deleted标志。

为了合并或混合这两种类型,我们只需做以下事情:

const ActivePerson = RecordStatus(Person);

这创建了一些东西,我们可以用来创建一个具有RecordStatus属性的Person对象。 它还没有实际实例化任何对象。 为此,我们用与其他类型相同的方式实例化信息:

let activePerson = new ActivePerson("Peter", "O'Hanlon");
activePerson.Deleted = true;

现在,我们还想添加有关记录最后一次更新时间的细节。 我们创建另一个 mixin,如下所示:

function Timestamp<T extends Constructor>(base : T) {
 return class extends base {
   Updated : Date = new Date();
 }
}

为了将其添加到ActivePerson中,我们将定义包括Timestamp。 不管我们先放哪个 mixin,是Timestamp还是RecordStatus:

const ActivePerson = RecordStatus(Timestamp(Person));

除了属性,我们还可以向 mixins 中添加构造函数和方法。 我们将改变我们的RecordStatus功能,当记录被删除时注销。 为了做到这一点,我们要将我们的Deleted属性转换为一个 getter 方法,并添加一个新方法来实际执行删除:

function RecordStatus<T extends Constructor>(base : T) {
    return class extends base {
        private deleted : boolean = false;
        get Deleted() : boolean {
            return this.deleted;
        }
        Delete() : void {
            this.deleted = true;
            console.log(`The record has been marked as deleted.`);
        }
    }
}

关于像这样使用 mixin 的一个警告。 它们是一种很棒的技术,并且能够很好地完成一些真正有用的事情,但我们不能将它们作为参数传递,除非我们放宽参数限制。 这意味着我们不能这样使用代码:

function DeletePerson(person : ActivePerson) {
     person.Delete();
}

If we look at mixins in the TypeScript documentation at https://www.typescriptlang.org/docs/handbook/mixins.html, we see that the syntax looks very different. Rather than dealing with that approach, with all of the inherent limitations it has, we will stick with the method here, which I was first introduced to at https://basarat.gitbooks.io/typescript/docs/types/mixins.html.

使用不同类型和泛型的相同代码

当我们第一次开始在 TypeScript 中开发类时,我们经常会一次又一次地重复相同的代码,只改变我们所依赖的类型。 例如,如果我们想要存储一个整数队列,我们可能会编写以下类:

class QueueOfInt {
    private queue : number[]= [];

    public Push(value : number) : void {
        this.queue.push(value);
    }

    public Pop() : number | undefined {
        return this.queue.shift();
    }
}

调用这段代码就像这样简单:

const intQueue : QueueOfInt = new QueueOfInt();
intQueue.Push(10);
intQueue.Push(35);
console.log(intQueue.Pop()); // Prints 10
console.log(intQueue.Pop()); // Prints 35

稍后,我们决定我们也需要创建一个字符串队列,所以我们也添加了代码来做这件事:

class QueueOfString {
    private queue : string[]= [];

    public Push(value : string) : void {
        this.queue.push(value);
    }

    public Pop() : string | undefined {
        return this.queue.shift();
    }
}

很容易看到,像这样添加的代码越多,我们的工作就会变得越乏味,也越容易出错。 假设我们忘了在这些实现中放入移位操作。 移位操作允许我们从数组中删除第一个元素并返回它,这给了我们一个队列的核心行为(队列的操作方式为first In first Out(或FIFO))。 如果我们忘记了移位操作,我们将实现一个堆栈操作(后进先出(或后进先出))。 这可能会导致代码中出现一些微妙而危险的错误。

通过泛型,TypeScript 为我们提供了创建泛型的能力,这是一种使用占位符来表示正在使用的类型的类型。 调用泛型的代码负责确定它们接受的类型。 我们之所以能识别泛型,是因为它们出现在<>内部的类名之后,或者出现在方法名之后。 如果我们重写队列以使用泛型,我们将看到这意味着什么:

class Queue<T> {
    private queue : T[]= [];

    public Push(value : T) : void {
        this.queue.push(value);
    }

    public Pop() : T | undefined {
        return this.queue.shift();
    }
}

让我们来分析一下:

class Queue<T> {
}

在这里,我们创建了一个名为Queue的类,它接受任何类型。 <T>语法告诉 TypeScript,当它在这个类中看到T时,它指向传入的类型:

private queue : T[]= [];

下面是泛型类型的第一个实例。 编译器将使用泛型类型来创建数组,而不是将数组固定为特定类型:

public Push(value : T) : void {
    this.queue.push(value);
}

public Pop() : T | undefined {
    return this.queue.shift();
}

同样,我们用泛型替换了代码中的特定类型。 注意,TypeScript 很乐意在Pop方法中使用undefined关键字。

改变我们使用代码的方式,我们现在可以告诉我们的Queue对象我们想要应用的类型:

const queue : Queue<number> = new Queue<number>();
const stringQueue : Queue<string> = new Queue<string>();
queue.Push(10);
queue.Push(35);
console.log(queue.Pop());
console.log(queue.Pop());
stringQueue.Push(`Hello`);
stringQueue.Push(`Generics`);
console.log(stringQueue.Pop());
console.log(stringQueue.Pop());

特别有用的是,TypeScript 强制我们在引用它时赋值的类型,所以如果我们试图在queue变量中添加一个字符串,TypeScript 会编译失败。

While TypeScript does its best to protect us, we have to remember that it converts into JavaScript. This means that it cannot protect our code from being abused, so, while TypeScript enforces the type we assign, if we were to write external JavaScript that also called our generic types, there is nothing there to prevent adding an unsupported value. The generic is enforced at compile time only so, if we have code that is going to be called from outside our control, we should take steps to guard against incompatible types in our code.

我们并不局限于在泛型列表中只有一种类型。 泛型允许我们在定义中指定任意数量的类型,只要它们有唯一的名称,如下所示:

function KeyValuePair<TKey, TValue>(key : TKey, value : TValue)

Keen-eyed readers will note that we have already encountered generics. When we created a mixin, we were using generics in our Constructor type.

如果我们想从泛型调用一个特定的方法会发生什么? 因为 TypeScript 希望知道该类型的底层实现是什么,所以它对我们能做什么是严格的。 这意味着以下代码是不可接受的:

interface IStream {
    ReadStream() : Int8Array; // Array of bytes
}
class Data<T> {
    ReadStream(stream : T) {
        let output = stream.ReadStream();
        console.log(output.byteLength);
    }
}

因为 TypeScript 猜不出我们想在这里使用IStream接口,所以如果我们试图编译它,它就会报错。 幸运的是,我们可以使用泛型约束来告诉 TypeScript 我们有一个想要在这里使用的特定类型:

class Data<T extends IStream> {
    ReadStream(stream : T) {
        let output = stream.ReadStream();
        console.log(output.byteLength);
    }
}

<T extends IStream>部分告诉 TypeScript,我们将使用任何基于IStream接口的类。

While we can constrain generics to types, we are generally going to want to constrain our generics to interfaces. This gives us a lot of flexibility in the classes that we use in the constraint and does not impose limitations that we can only use classes that inherit from a particular base class. 

为了看到它的实际效果,我们将创建两个实现IStream的类:

class WebStream implements IStream {
    ReadStream(): Int8Array {
        let array : Int8Array = new Int8Array(8);
        for (let index : number = 0; index < array.length; index++){
            array[index] = index + 3; 
        }
        return array;
    }
}
class DiskStream implements IStream {
    ReadStream(): Int8Array {
        let array : Int8Array = new Int8Array(20); 
        for (let index : number = 0; index < array.length; index++){
            array[index] = index + 3;
        }
        return array;
    }
}

这些现在可以在泛型Data实现中用作类型约束:

const webStream = new Data<WebStream>();
const diskStream = new Data<DiskStream>();

我们刚刚告诉webStreamdiskStream他们将可以参加我们的课程。 要使用它们,我们仍然需要传递一个实例,如下所示:

webStream.ReadStream(new WebStream());
diskStream.ReadStream(new DiskStream());

虽然我们在类级别声明了泛型及其约束,但我们不必这样做。 如果需要,我们可以在方法级声明更细粒度的泛型。 在这种情况下,如果我们想在代码的多个地方引用泛型类型,那么将它设置为类级泛型是有意义的。 如果我们只希望在一个或两个方法上应用特定泛型,则可以将类签名更改为:

class Data {
    ReadStream<T extends IStream>(stream : T) {
        let output = stream.ReadStream();
        console.log(output.byteLength);
    }
}

使用映射映射值

经常出现的一种情况是,需要存储一些具有易于查找的键的项目。 举个例子来说吧,假设我们有一个音乐集合,它被分解成许多类型:

enum Genre {
    Rock,
    CountryAndWestern,
    Classical,
    Pop,
    HeavyMetal
}

针对每一种流派,我们将存储一些艺术家或作曲家的细节。 我们可以采取的一种方法是创建一个代表每种类型的类。 虽然我们可以这样做,但这将浪费我们的编码时间。 我们要解决这个问题的方法是使用一种叫做map的东西。 映射是一个泛型类,它接受两种类型:用于映射的键的类型和存储在其中的对象的类型。

键是一个唯一的值,它允许我们存储值或快速查找——这使 map 成为快速查找值的一个很好的选择。 键可以是任意类型,值可以是任意值。 对于我们的音乐集合,我们将创建一个类,它使用一个类型作为键的映射和一个字符串数组来表示作曲家或艺术家:

class MusicCollection {
    private readonly collection : Map<Genre, string[]>;
    constructor() {
        this.collection = new Map<Genre, string[]>();
    }
}

为了填充一个 map,我们调用set方法,如下所示:

public Add(genre : Genre, artist : string[]) : void {
    this.collection.set(genre, artist);
}

从映射中检索值就像使用相关键调用Get一样简单:

public Get(genre : Genre) : string[] | undefined {
    return this.collection.get(genre);
}

We have to add the undefined keyword to the return value here because there is a possibility that the map entry does not exist. If we forgot to take the possibility of undefined into account, TypeScript helpfully warns us of this. Yet again, TypeScript works hard to provide that robust safety net for our code.

我们现在可以填充我们的集合,如下所示:

let collection = new MusicCollection();
collection.Add(Genre.Classical, [`Debussy`, `Bach`, `Elgar`, `Beethoven`]);
collection.Add(Genre.CountryAndWestern, [`Dolly Parton`, `Toby Keith`, `Willie Nelson`]);
collection.Add(Genre.HeavyMetal, [`Tygers of Pan Tang`, `Saxon`, `Doro`]);
collection.Add(Genre.Pop, [`Michael Jackson`, `Abba`, `The Spice Girls`]);
collection.Add(Genre.Rock, [`Deep Purple`, `Led Zeppelin`, `The Dixie Dregs`]);

如果我们想要添加一个美工,我们的代码就会稍微复杂一些。 使用 set,我们可以向 map 中添加一个新条目,或者用新条目替换之前的条目。 在这种情况下,我们确实需要检查是否已经添加了那个特定的键。 为此,我们调用has方法。 如果我们没有添加类型,我们将使用空数组调用 set。 最后,我们将使用 get 从 map 中获取数组,这样我们就可以将值压入:

public AddArtist(genre: Genre, artist : string) : void {
    if (!this.collection.has(genre)) {
        this.collection.set(genre, []);
    }
    let artists = this.collection.get(genre);
    if (artists) {
        artists.push(artist);
    }
}

我们要对代码做的另一件事是更改Add方法。 现在,该执行覆盖了之前针对特定类型的Add调用,这意味着调用AddArtistAdd将覆盖我们分别添加的Add调用的美工:

collection.AddArtist(Genre.HeavyMetal, `Iron Maiden`);
// At this point, HeavyMetal just contains Iron Maiden
collection.Add(Genre.HeavyMetal, [`Tygers of Pan Tang`, `Saxon`, `Doro`]);
// Now HeavyMetal just contains Tygers of Pan Tang, Saxon and Doro

为了修复Add方法,我们可以做一个简单的改变,迭代我们的美工并调用AddArtist方法,如下所示:

public Add(genre : Genre, artist : string[]) : void {
    for (let individual of artist) {
        this.AddArtist(genre, individual);
    }
}

现在,当我们完成了HeavyMetal类型,我们的美工包括Iron MaidenTygers of Pan TangSaxonDoro

创建带有 promise 和 async/await 的异步代码

我们经常需要编写以异步方式运行的代码。 我们的意思是,我们需要启动一个任务,并让它在后台运行,而我们在做其他事情。 例如,当我们向 web 服务发出调用时,可能需要一段时间才能返回。 很长一段时间以来,JavaScript 的标准方法是使用回调。 这种方法的一个大问题是,我们需要的回调越多,我们的代码就会变得越复杂,而且容易出错。 这就是承诺发挥作用的地方。

承诺告诉我们某事将异步发生; 在异步操作完成后,我们可以选择继续处理 promise 的结果,或者捕获由异常抛出的任何异常。

下面的示例演示了这一点:

function ExpensiveWebCall(time : number) : Promise<void> {
    return new Promise((resolve, reject) => setTimeout(resolve, time));
}
class MyWebService {
    CallExpensiveWebOperation() : void {
        ExpensiveWebCall(4000).then(()=> console.log(`Finished web 
        service`))
            .catch(()=> console.log(`Expensive web call failure`));
    }
}

当我们编写一个承诺时,我们有选择地接受两个参数——一个resolve函数和一个reject函数,可以调用它们来触发错误处理。 promise 为我们提供了两个函数来处理这些值,因此,成功完成操作后,将触发then()函数,并触发一个单独的catch函数来处理reject函数。

现在,我们要运行这段代码来看看它的效果:

console.log(`calling service`);
new MyWebService().CallExpensiveWebOperation();
console.log(`Processing continues until the web service returns`);

当我们运行这段代码时,我们得到如下输出:

calling service
Processing continues until the web service returns
Finished web service

Processing continues until the web service returnsFinished web service行之间,我们预计有 4 秒的延迟,因为应用正在等待 promise 返回,然后才写出then()函数中的文本。 这向我们展示的是,代码在这里的行为是异步的,因为它在执行处理控制台日志时没有等待 web 服务调用返回。

我们可能会认为这段代码太冗长了,而且分散Promise<void>并不是让其他人理解我们的代码是异步的最直观的方式。 TypeScript 提供了一个等价的语法,使得我们的代码在哪些地方是异步的更加明显。 通过使用asyncawait关键字,我们可以轻松地将之前的示例变得更加优雅:

function ExpensiveWebCall(time : number) {
    return new Promise((resolve, reject) => setTimeout(resolve, time));
}
class MyWebService {
    async CallExpensiveWebOperation() {
        await ExpensiveWebCall(4000);
        console.log(`Finished web service`);
    }
}

关键字async告诉我们函数返回Promise。 它还告诉编译器,我们希望以不同的方式处理函数。 当我们在async函数中发现await时,应用将在该点暂停该函数,直到正在等待的操作返回。 此时,处理继续进行,模仿我们在Promise中看到的then()函数内部的行为。

为了捕获async/await中的错误,我们真的应该在 try… catch 块。 当错误被catch()函数显式捕获时,async/await没有相同的处理错误的方法,所以由我们来处理问题:

class MyWebService {
    async CallExpensiveWebOperation() {
        try {
            await ExpensiveWebCall(4000);
            console.log(`Finished web service`); 
        } catch (error) {
            console.log(`Caught ${error}`);
        }
    }
}

Whichever approach you choose to take is going to be a personal choice. The use of async/await just means it wraps the Promise approach so the runtime behavior of the different techniques is exactly the same. What I do recommend though is, once you decide on an approach in an application, be consistent. Don't mix styles as that will make it much harder for anyone reviewing your application.

使用 Bootstrap 创建 ui

在接下来的章节中,我们将在浏览器中做大量的工作。 创造一个有吸引力的 UI 可能是一件困难的事情,特别是在我们可能瞄准不同布局模式的移动设备的时代。 为了使我们自己更容易,我们将非常依赖 Bootstrap。 Bootstrap 被设计成一个移动设备第一 UI 框架,平滑地扩展到 PC 浏览器。 在本节中,我们将布局包含标准 Bootstrap 元素的基本模板,然后看看如何使用 Bootstrap 网格系统等特性布局简单的页面。

我们将从 Bootstrap(https://getbootstrap.com/docs/4.1/getting-started/introduction/#starter-template)的启动器模板开始。 有了这个特定的模板,我们就不必下载和安装各种 CSS 样式表和 JavaScript 文件; 相反,我们依靠知名的Content Delivery Networks(cdn)来为我们获取这些文件。

Where possible, I would recommend using CDNs to source external JavaScript and CSS files. This provides many benefits including not needing to maintain these files ourselves and getting the benefit of browser caching when the browser has encountered this CDN file elsewhere.

启动器模板如下所示:

<!doctype html>
<html lang="en">
   <head>
      <!-- Required meta tags -->
      <meta name="viewport" content="width=device-width, initial-scale=1, 
      shrink-to-fit=no">
      <link rel="stylesheet"href="https://stackpath.bootstrapcdn.com/bootstrap
      /4.1.3/css/bootstrap.min.css" integrity="sha384-
      MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
      crossorigin="anonymous">
      <title>
         <
         <Template Bootstrap>
         >
      </title>
   </head>
   <body>
      <!-- 
         Content goes here...
         Start with the container.
         -->
      <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" 
         integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" 
         crossorigin="anonymous"></script>
      <script 
         src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" 
         integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" 
         crossorigin="anonymous"></script>
      <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" 
         integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" 
         crossorigin="anonymous"></script>
   </body>
</html>

布局内容的起点是容器。 这在前面的内容部分。 以下代码显示了div部分:

<div class="container">

</div>

The container class gives us that familiar Twitter look where it has a fixed size for each screen size. If we need to fill the full window, we can change this to container-fluid.

在容器内部,Bootstrap 尝试以网格模式布局项目。 Bootstrap 操作一个系统,其中屏幕的每一行可以表示为多达 12 个离散列。 默认情况下,这些列均匀地分布在页面上,所以我们可以通过为 UI 的每个部分选择适当的列数来实现复杂的布局。 幸运的是,Bootstrap 提供了一套广泛的预定义样式,帮助我们为不同类型的设备(无论是 pc、移动电话还是平板电脑)创建布局。 这些样式都遵循相同的.col-<<size-identifier>>-<<number-of-columns>>命名约定:

| 类型 | 超小器件 | 小型设备 | 介质器件 | 大型设备 | | 尺寸 | 手机< 768 px | 平板电脑> = 768 px | 桌面> = 992 px | 桌面> = 1200 px | | 前缀 | .col-xs - | .col-sm- | .col-md- | .col-lg - |

列数的工作方式是每行加起来应该是 12 列。 因此,如果我们想要一个包含三列、六列和另外三列的内容的行,我们将在容器中定义行如下所示:

<div class="row">
  <div class="col-sm-3">Hello</div>
  <div class="col-sm-6">Hello</div>
  <div class="col-sm-3">Hello</div>
</div>

这种样式定义了它在小型设备上的显示方式。 可以覆盖较大设备的样式。 例如,如果我们希望大型设备使用 5、2 和 5 的列,我们可以应用以下样式:

<div class="row">
  <div class="col-sm-3 col-lg-5">Hello</div>
  <div class="col-sm-6 col-lg-2">Hello</div>
  <div class="col-sm-3 col-lg-5">Hello</div>
</div>

这就是响应式布局系统的魅力所在。 它允许我们生成适合我们设备的内容。

让我们看看如何向页面添加一些内容。 我们将在第一列添加jumbotron,在第二列添加一些文本,在第三列添加一个按钮:

<div class="row">
  <div class="col-md-3">
    <div class="jumbotron">
      <h2>
        Hello, world!
      </h2>
      <p>
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus 
        eget mi odio. Praesent a neque sed purus sodales interdum. In augue sapien, 
        molestie id lacus eleifend...
      </p>
      <p>
        <a class="btn btn-primary btn-large" href="#">Learn more</a>
      </p>
    </div>
  </div>
  <div class="col-md-6">
    <h2>
      Heading
    </h2>
    <p>
      Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus 
      eget mi odio. Praesent a neque sed purus sodales interdum. In augue sapien, 
      molestie id lacus eleifend...
    </p>
    <p>
      <a class="btn" href="#">View details</a>
    </p>
  </div>
  <div class="col-md-3">
    <button type="button" class="btn btn-primary btn-lg btn-block active">
      Button
    </button>
  </div>
</div>

同样,我们使用 CSS 样式来控制显示的外观。 通过给div节一个jumbotron的样式,Bootstrap 立即为我们应用那个样式。 我们通过选择将按钮设置为主按钮(btn-primary)来控制按钮的外观。

jumbotron通常横跨所有列的宽度。 我们把它放在三列div中,这样我们就可以看到,宽度和样式是由网格布局系统控制的,而jumbotron没有一些特殊的属性,迫使它在整个页面布局。

When I want to rapidly prototype a layout, I always follow a two-stage process. The first step is to draw on a piece of paper what I want my UI to look like. I could do this using a wireframe tool but I like the ability to quickly draw things out. Once I have got a general idea of what I want my layout to look like, I use a tool such as Layoutit! (https://www.layoutit.com/) to put the ideas on to the screen; this also gives me the option to export the layout so that I can further refine it by hand.

总结

在本章中,我们了解了 TypeScript 的一些特性,这些特性可以帮助我们构建面向未来的 TypeScript 代码。 我们了解了如何设置适当的 ES 级别来模拟或使用现代的 ECMAScript 特性。 我们了解了如何使用联合和交集类型以及如何创建类型别名。 然后,在介绍带装饰器的 AOP 之前,我们研究了对象扩展和 REST 属性。 我们还介绍了如何创建和使用映射类型,以及如何使用泛型和承诺。

作为我们将在本书其余部分中生成的 ui 的准备,我们简要地介绍了使用 Bootstrap 来布局 ui,并介绍了 Bootstrap 网格布局系统的基础知识。

在下一章中,我们将使用一个连接到 TypeScript 的 Bootstrap 网页来构建一个简单的 markdown 编辑器。 我们将看到设计模式和单一责任类等技术如何帮助我们创建健壮的专业代码。

问题

  1. 我们已经编写了一个应用,允许用户将华氏温度转换为摄氏温度,并将摄氏温度转换为华氏温度。 计算在下列类中执行:
class FahrenheitToCelsius {
    Convert(temperature : number) : number {
        return (temperature - 32) * 5 / 9;
    }
}

class CelsiusToFahrenheit {
    Convert(temperature : number) : number {
        return (temperature * 9/5) + 32;
    }
}

我们希望编写一个接受温度和这两种类型的实例的方法,然后它将执行相关的计算。 我们用什么技术来写这个方法?

  1. 我们写了以下类:
class Command {
    public constructor(public Name : string = "", public Action : Function = new Function()){}
}

我们想在另一个类中使用它,在这个类中我们将添加一些命令。 命令的Name将是我们以后在代码中查找Command的键。 我们将使用什么来提供这个键值功能,以及如何向它添加记录?

  1. 如果我们在问题 2中添加了命令,而没有在Add方法中添加任何代码,我们如何自动记录我们正在添加的条目?
  2. 我们已经创建了一个 Bootstrap 网页,我们想在其中显示一行,其中有 6 个中等大小的列。 我们该怎么做呢?