TypeScript 类型系统详解(三)

211 阅读5分钟

在 TypeScript 中,类型系统是确保代码质量的关键特性之一。通过类型注解和类型推断,开发者可以构建出既灵活又安全的应用。本文对 TypeScript 类型系统的深入探讨,是系列文章的第三篇。

使用 ts 之前对编辑器的设置

  1. 在 path 中添加 code 命令的路径
  2. vscode 安装 Prettier 扩展
  3. 设置保存时运行 Prettier
  4. 使用 Prettier 时采用单引号
  5. 使用两个空格进行缩进
  6. 将主题设置为 'Solarized Light'

使用 TS 创建可复用的代码

在 Ts 中创建可重用的代码的一般原理:

  1. 创建函数的时候,需要使用类型约束入参,并且通常是通过 interface 定义的接口来约束的。
  2. 对象 / 类 可以决定实现一个给定的接口来与函数协同工作。

class 在 TS 中的意义

class 是用来创建一个对象的蓝图,创建的这个对象包含一些属性和方法,对象本身表示一个事务。

TS 中的 modifier

  1. public: 受此约束的属性和方法可以再任何时间、任何地方使用.
  2. private: 受此约束的属性和方法仅能在本类的作用域中被使用.
  3. protected: 受此约束的属性和方法仅能在本类及其子类的作用域中被使用.

使用示例展示父类的 protected 可以在子类中被使用,但是不能像 public 一样被外部的实例对象调用:

class Vehicle {
  protected honk(): void {
    console.log("beep");
  }
}

const vehicle = new Vehicle();
vehicle.honk(); // 这里会报错

class Car extends Vehicle {
  private drive(): void {
    console.log("vroom");
  }

  startDrivingProcess(): void {
    this.drive();
    this.honk(); // 这里不会报错
  }
}

再举一例:

class P {
  protected people = '汉';
}

class S extends P {
  print() {
    console.log(this.people); // 汉
  }
}

const s = new S();
console.log('outer print:', s.people); // 报错

TS 中提供的简写方法

TS 中提供了一种构造参数的简写方式,这种简写方式必须使用某个 modifier 作为标识,如下所示的两段代码是等价的:

class Vehicle {
  constructor(public color: string) {}
  protected honk(): void {
    console.log("beep");
  }
}

const vehicle = new Vehicle("orange");
console.log(vehicle.color);

等价于:

class Vehicle {
  public color: string;
  constructor(color: string) {
    this.color = color;
  }
  protected honk(): void {
    console.log("beep");
  }
}

const vehicle = new Vehicle("orange");
console.log(vehicle.color);

使用简写方式之后如何处理继承过程中与父类构造函数的关系

如果一开始不是很习惯这种写法的话,可以先按照之前非简写的方式码出来,然后再简化。这个过程涉及到了 super 所以看起来会复杂一些:

非简写的代码:

class Vehicle {
  constructor(public color: string) {}
  protected honk(): void {
    console.log("beep");
  }
}

class Car extends Vehicle {
  public wheels: number;
  constructor(wheels: number, color: string) {
    super(color); // 调用父类构造函数
    this.wheels = wheels;
  }

  private drive(): void {
    console.log("vroom");
  }

  startDrivingProcess(): void {
    this.drive();
    this.honk();
  }
}

简写的代码:

class Vehicle {
  constructor(public color: string) {}
  protected honk(): void {
    console.log("beep");
  }
}

class Car extends Vehicle {
  constructor(public wheels: number, color: string) {
    super(color); // 调用父类构造函数
  }

  private drive(): void {
    console.log("vroom");
  }

  startDrivingProcess(): void {
    this.drive();
    this.honk();
  }
}

总结一下,就是从 constructor 的形参列表中各取所需

在 index.html 中使用 ts 文件

为了能够在 html 文件中直接使用 ts 文件,我们需要使用 parcel-bundler 这个库,这个库提供了 html 文件的编译环境,在此环境中,html 文件中的 ts 文件是可以被正常解析的。

  1. 搭建环境
mkdir myTS myTs/src
cd myTs
npm init -y
npm install -g parcel-bundler
touch src/index.ts
touch src/index.html
  1. 在 index.html 中引入 index.ts
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="./index.ts"></script>
  </head>
  <body></body>
</html>
  1. parcel-bundler 环境下执行 index.html: parcel src/index.html
$ parcel src/index.html
Server running at http://localhost:1234
✨  Built in 6.77s.

并在项目的根目录下生成 dist .cache 两个目录。

使用 Google Map 服务的步骤

要生成一个 Google Developer 项目并启用 Google Maps 支持,以及生成 API 密钥,并在 HTML 文件中添加 Google Maps 脚本标签,你可以按照以下步骤操作:

  1. 访问 Google Developers Console:

  2. 创建新项目:

    • 在控制台中,点击顶部栏的“选择项目”下拉菜单。
    • 点击“新建项目”,输入项目名称,然后点击“创建”。
  3. 启用 Google Maps API:

    • 在项目仪表板中,点击左侧菜单中的“API 和服务” > “库”。
    • 搜索“Google Maps”,然后选择你需要的 Google Maps 服务(例如,“Maps JavaScript API”)。
    • 点击“启用”按钮来启用 API。
  4. 创建 API 密钥:

    • 在“API 和服务”仪表板中,点击“凭据”。
    • 点击“创建凭据”按钮,然后选择“API 密钥”。
    • 一个 API 密钥将被生成。你可以设置应用的 HTTP 引用程序,以限制 API 的使用范围。
  5. 在 HTML 文件中添加 Google Maps 脚本标签:

    • 在你的 HTML 文件中,通常在<head>部分或<body>标签的最后,添加以下脚本标签:
      <script
        async
        defer
        src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&callback=initMap"
      ></script>
      
    • YOUR_API_KEY替换为你从 Google Developers Console 中获取的 API 密钥。
  6. 初始化地图:

    • 在你的 JavaScript 代码中,添加一个initMap函数来初始化地图。这个函数将在加载 Google Maps 脚本后被调用。

请注意,上述步骤可能随着 Google Developers Console 的更新而有所变化。如果你需要更详细的指导或遇到问题,可以查看Google Maps Platform documentation

安装库的类型声明

注意,在上小节中,我们实际上是通过 cdn 的方式引入谷歌地图的相关服务的,index.html 引入相关脚本之后,会在全局对象上挂载一个名为 google 的对象,但是我们本地的 TS 不认识,或者说并不认为全局对象上应该有此属性,所以会报错,为了消除错误,我们应该引入这个库对应的类型说明:

npm install @types/google.maps --save-dev
console.log("google:", google);

上述代码,在安装 @types/google.maps 之前会报错,在安装之后不再报错。

也就是说,就算是通过 cdn 而不是 node_modules 本地提供的第三方库,我们仍然可以通过安装 @types/ 的方式对其进行声明。

Google Map 使用示例

export class CustomMap {
  private googleMap: google.maps.Map;

  constructor() {
    // 假设有一个id为"map"的HTML元素用于显示地图
    const mapElement = document.getElementById("map"); // 获取地图容器元素
    this.googleMap = new google.maps.Map(mapElement, {
      zoom: 1, // 设置地图的缩放级别
      center: {
        // 设置地图的中心点
        lat: 0, // 纬度
        lng: 0, // 经度
      },
    });
  }
}

折叠代码的小技巧

在 vscode 中,使用 ctrl + shift + p 打开命令窗口之后,在其中输入 fold level 2 来对代码进行精确折叠。

然后使用 unfold all 可以把它们全部拆开。

TS 中的联合类型(Union Types)

这里我们不是要完整的介绍联合类型(Union Types)所有的内容,而是对其一点进行重点说明。见下面代码及注释中的结论:

class User {
  name: string;
  age: number;
  mark() {
    console.log(`User can be marked on the map`);
  }
}

class Company {
  factoryName: string;
  mark() {
    console.log(`Company can be marked on the map`);
  }
}

type U = User | Company;

function test(item: U): void {
  item.mark();
}

const reportor = {
  mark() {
    console.log(`reportor can be marked on the map`);
  },
};

test(reportor); // 会报错,证明类型 U 不等同于 { mark(): void; }

上述代码的 refactor

通过上面的代码,我们知道了联合类型并不等同于取交集,你可以这样记,万一 User 和 Company 没有交集呢?那还能等同于 never 不是?

但是上面的代码使用了联合类型 User | Company 作为入参的约束,这不是好的实践,因为下次再增加一个 Reportor 类型的,岂不是要将代码改成 User | Company | Reportor ? 这显然是不正确的!

正确的做法是使用最小原则,因为测试函数 test 中只是用了入参上面的 mark 方法,那么在约束入参的时候,就应该只针对这一点进行约束:

type U = { mark(): void };

这样就不会有问题了,test(reportor); 也能顺利通过检测。

上述代码的 refactor2

既然 User Company 的实例都需要,或者都有可能会传入 test 函数,那么它们在创建的时候就应该明确的被告知应该实现 mark 方法。否则,就只能等到使用的时候才发现错误。这当然是不好的实践,那么我们如何在创建的时候就明确的告诉 User 和 Company 需要实现 mark 方法呢?

implement 关键字

我们使用 implements 关键字来告诉某个 class 必须包含什么样的属性,或者实现什么样的方法。如下所示:

type U = {
  mark(): void;
};

class User implements U {
  name: string;
  age: number;
  mark() {
    console.log(`User can be marked on the map`);
  }
}

class Company implements U {
  factoryName: string;
  mark() {
    console.log(`Company can be marked on the map`);
  }
}

function test(item: U): void {
  item.mark();
}

const reportor = {
  mark() {
    console.log(`reportor can be marked on the map`);
  },
};

test(reportor);

这样一来,如果 User 或者 Company 中没有包含这个 mark 方法,在构建它们的时候就已经开始报错了。

TS 配置文件

我们使用 TS 的配置文件更好的约束我们在 application 中使用 typescript. 这个配置文件的名字为 tsconfig.json 一般会放在项目的根目录下面。

那么如何生成这个配置文件呢?

  1. 在项目的根目录下运行 npm install typescript --save-dev && tsc --init
  2. 在生成的 tsconfig.json 中,我们修改 outDirrootDir 字段的值。这里我们改成:
"outDir": "./build",
"rootDir": "./src",
  1. 然后使用 tsc 命令对 index.ts 进行编译:tsc, 会在根目录下生成名为 build 的目录,此目录中有编译之后的 index.js 文件。
  2. 更进一步,我们想要修改是实时生效的,这个时候运行 tsc -w 即可!

将 tsc 和 parcel 结合起来使用

我们要完成的任务,不只是实时编译我们的 ts 文件,还要运行修改之后的文件。为了实现这个目标,我们应该首先安装如下的依赖:

npm install nodemon concurrently

然后在 package.json 中写如下的脚本命令:

  "scripts": {
    "start:build": "tsc -w",
    "start:run": "nodemon build/index.js",
    "start": "concurrently npm:start:*"
  },

注意这里的 "concurrently npm:start:*".

最后,我们只需执行 npm start 就可以在 node 环境的控制台中看到实时的执行结果了!非常好用!

字符是如何排序的

当我们需要对字符进行排序的时候,我们通常依据其 charCode 的值的大小来排,例如:

"X" > "a"; // false
"X".charCodeAt(0); // 88
"a".charCodeAt(0); // 97

联合类型的类型收缩 -- type union & type guard

对于联合类型,我们通常使用类型收缩的技术精确化类型,通常用到的两个操作符为:instanceof typeof

  1. Narrow type of a value to a primitive type. 这包括:number string boolean symbol
  2. instanceof: Narrow down every other type of value. 这包括:Every other value that is created with a constructor function.

interface 对于 getter 的约束

对于 getter 而言,其本质上还是属性,因此在 interface 中像一般约束属性那样约束 getter 即可,如下所示:

type U = {
  mark(): void;
  name: string;
};

class User implements U {
  get name(): string {
    return "123";
  }
  age: number;
  mark() {
    console.log(`User can be marked on the map`);
  }
}

对于 3.1 版本之后的 TS, 可以写的更精准:

type U = {
  mark(): void;
  get name(): string;
};

class User implements U {
  get name(): string {
    return "123";
  }
  age: number;
  mark() {
    console.log(`User can be marked on the map`);
  }
}

TS 对于编码的加强示例

Ts 的威力在于将复杂功能做了清晰的分离,将不变的部分和变化的部分分开,这有助于难度的降低;从整个功能的实现变成了先实现算法,后利用既定接口实现数据结构,更加重要的是,结合编辑器的提示,在实现算法之后,实现数据结构的难度也下降了。

将上面的话展开来说就是:

TypeScript 作为一种静态类型语言,其核心优势在于提供了一种结构化的方法来处理软件开发中的复杂性。以下是对原句的扩展和深入解释:

  1. 清晰的功能分离:TypeScript 通过其接口(Interfaces)、类(Classes)、枚举(Enums)等特性,允许开发者将大型系统的复杂功能分解为更小、更易于管理的部分。这种分离使得每个组件都可以独立开发和测试,从而简化了开发过程。

  2. 不变与变化的分离:在软件开发中,有些部分是相对稳定的,而有些部分则可能经常变化。TypeScript 的类型系统可以帮助开发者明确区分这两部分。例如,使用接口定义的 API 契约是不变的,而实现这些接口的具体类可以根据需要进行更改。

  3. 算法实现优先:TypeScript 鼓励开发者首先关注算法和业务逻辑的实现。这意味着开发者可以先编写核心功能的代码,而不必担心具体的数据结构或接口细节。

  4. 接口的后续实现:一旦算法和逻辑被确定下来,开发者可以利用 TypeScript 的接口来定义数据结构和类的契约。这样做的好处是,接口为类的实现提供了清晰的规范,确保了实现的一致性和正确性。

  5. 编辑器智能提示:TypeScript 与现代 IDE(集成开发环境)和编辑器紧密集成,提供了强大的代码自动完成和类型检查功能。在实现了算法之后,编辑器的提示可以帮助开发者快速地实现接口,减少了编码时的错误和提高了开发效率。

  6. 降低学习曲线:通过将复杂问题分解并利用 TypeScript 的类型系统,新加入项目的开发者可以更容易地理解项目结构和代码逻辑,因为接口和类型提供了清晰的指导。

  7. 提高代码质量和可维护性:清晰的接口定义和类型检查有助于在编译时捕捉错误,减少了运行时错误的可能性,从而提高了代码的质量和可维护性。

下面通过实现一个冒泡排序对上面所说的原理进一步阐述

如下所示的代码,其功能是对 number[] 类型的数据进行从小到大的排序。

class Sorter {
  constructor(public collection: number[]) {}

  sort(): void {
    // 外层循环控制遍历次数
    for (let i = 0; i < this.collection.length - 1; i++) {
      // 内层循环进行相邻元素的比较和交换
      for (let j = 0; j < this.collection.length - 1 - i; j++) {
        if (this.collection[j] > this.collection[j + 1]) {
          // 交换元素
          let temp = this.collection[j];
          this.collection[j] = this.collection[j + 1];
          this.collection[j + 1] = temp;
        }
      }
    }
  }
}

// 创建Sorter类的实例并使用冒泡排序方法
const sorter = new Sorter([10, 3, -5, 0]);
sorter.sort(); // 执行排序
console.log(sorter.collection); // 输出排序后的数组

上述代码有一个致命缺点,就是只能对 number[] 类型的数据进行排序。也就是完全是命令式的编程,现在我们需要将其改造成声明式的。

class NumbersCollection {
  data: number[];

  constructor(data: number[]) {
    this.data = data;
  }

  swap(i: number, j: number): void {
    let temp = this.data[i];
    this.data[i] = this.data[j];
    this.data[j] = temp;
  }

  compare(i: number, j: number): boolean {
    return this.data[i] > this.data[j];
  }

  get length(): number {
    return this.data.length;
  }
}

class Sorter {
  private collection: NumbersCollection;

  constructor(collection: NumbersCollection) {
    this.collection = collection;
  }

  sort(): void {
    for (let i = 0; i < this.collection.length - 1; i++) {
      for (let j = 0; j < this.collection.length - 1 - i; j++) {
        if (this.collection.compare(j, j + 1)) {
          this.collection.swap(j, j + 1);
        }
      }
    }
  }
}

// 使用示例
const data = [10, 3, -5, 0];
const numbersCollection = new NumbersCollection(data);
const sorter = new Sorter(numbersCollection);

sorter.sort(); // 执行排序
console.log(numbersCollection.data); // 输出排序后的数组

为什么说上面的代码是声明式的呢?因为 Sort 类的 sort 方法只负责实现基本的冒泡算法,至于算法中要求的交换或者比较则是需要被排序者自行提供。这里引出声明式编程的第一个特点:职责分离,作为排序类 Sort 没有义务或者不可能实现每一个不同的被排的数据结构的交换和比较逻辑,所以这些功能由它们自身提供。

这样一来,站在 Sort 的角度,当需要比较的时候,我们直接调用现成的 compare 方法就可以了,并不关心这个方法的实现逻辑,这就完全符合声明式编程的思想了。

另外一个方面,Sorter 的构造参数从 number[] 变成了 NumbersCollection, 这也可以理解成一种 包装,和 string 的包装类是一个道理。

当我们写成声明式代码之后,排序功能的算法和数据结构完成了分离,这个时候,我们的适用入参也不再局限于 NumbersCollection 这个类中,观察 Sort 类,我们发现,只要实现了如下的 Sortable 接口都可以用来排序:

interface Sortable {
  length: number;
  compare(leftIndex: number, rightIndex: number): boolean;
  swap(leftIndex: number, rightIndex: number): void;
}

class Sorter {
  constructor(public collection: Sortable) {
    this.collection = collection;
  }

  sort(): void {
    for (let i = 0; i < this.collection.length - 1; i++) {
      for (let j = 0; j < this.collection.length - 1 - i; j++) {
        if (this.collection.compare(j, j + 1)) {
          this.collection.swap(j, j + 1);
        }
      }
    }
  }
}

进一步优化

上面的代码中,如果我们要完成排序就必须使用如下的顺序:

  1. 进行包装:
const data = [10, 3, -5, 0];
const numbersCollection = new NumbersCollection(data);
  1. 使用包装之后的对象得到排序对象:
const sorter = new Sorter(numbersCollection);
  1. 排序对象执行排序方法:
sorter.sort(); // 执行排序

虽然完成了任务,并且是声明式的,但是总觉得有些别扭,好的方式是这样的:在第一步做完之后,我们直接运行 numbersCollection.sort() 就完成了排序。这样看来,sort 方法就在 numbersCollection 对象之上,这才是符合线性逻辑的代码,那么我们应该如何针对其修改呢?

修改思路:既然 NumbersCollection.sort 需要执行,那就必须先保证 NumbersCollection 类上实现了 sort 方法。同时我们不能直接在 NumbersCollection 类上实现 sort 方法,因为之后还有 StringCollection 等。那么我们的策略为:让 NumbersCollection 类继承 Sort 类,但是这里又出现了一个问题,那就是 Sort 类中的 sort 方法用到了 this.collection.comparethis.collection.swap 现在将 this.collection 移除之后,这两个方法找不到了,怎么办?

  1. 首先,this.collection.comparethis.collection.swap 这个时候应该修改成 this.comparethis.swap.
  2. 其次,即使是这样,我们的基类 Sort 上面也是没有 compareswap 这两个方法的。
  3. 现在的情况就是,对于 Sort 我们需要 compareswap 这两个方法,单我们不直接使用 Sort, 只是使用其子类 NumbersCollection 等,在这种情况下,既然 Sort 不需要实例化,那么我们将其做成抽象类即可!

修改之后的代码如下所示:

abstract class Sorter {
  abstract compare(i: number, j: number): boolean;
  abstract swap(i: number, j: number): void;
  abstract get length(): number;

  sort(): void {
    for (let i = 0; i < this.length - 1; i++) {
      for (let j = 0; j < this.length - 1 - i; j++) {
        if (this.compare(j, j + 1)) {
          this.swap(j, j + 1);
        }
      }
    }
  }
}

class NumbersCollection extends Sorter {
  data: number[];

  constructor(data: number[]) {
    super();
    this.data = data;
  }

  compare(i: number, j: number): boolean {
    return this.data[i] > this.data[j];
  }

  swap(i: number, j: number): void {
    let temp = this.data[i];
    this.data[i] = this.data[j];
    this.data[j] = temp;
  }

  get length(): number {
    return this.data.length;
  }
}

// 使用示例
const data = [10, 3, -5, 0];
const numbersCollection = new NumbersCollection(data);

numbersCollection.sort(); // 执行排序
console.log(numbersCollection.data); // 输出排序后的数组

至此,我们就将一个复杂的,耦合度高的,单一功能、命令式的代码,使用 interface abstract class 等技术改成了可扩展的、适用于多种数据结构、算法分离的、声明式代码。

抽象类

抽象类的特点在于:

  1. 不能直接用于创建对象。
  2. 仅用作父类。
  3. 可以为某些方法提供实际实现。
  4. 已实现的方法可以引用其他尚未实际存在的方法(但我们仍需为未实现的方法提供名称和类型)。
  5. 可以要求子类承诺实现某些其他方法。

抽象类和接口的对比

接口(Interfaces)

  • 用于定义不同对象之间的契约,使得这些对象能够协同工作。
  • 适用于我们想要让非常不同的对象一起工作的情况。
  • 促进松耦合,即实现接口的类不需要知道接口的其他实现细节。
  • 主要目的是设置不同类之间的契约。

抽象类(Abstract Classes)

  • 用于构建对象的定义,为子类提供一个共同的基类。
  • 适用于我们试图构建一个对象的定义,并且希望子类能够继承这个定义的情况。
  • 强烈耦合类在一起,因为子类必须继承抽象类并实现其抽象方法。
  • 不仅仅是为了设置契约,更是为了提供一个可以被多个子类共享的代码基础。

从硬编码说起

如下所示的代码:

match[5] === "H";

这里的 'H' 就是硬编码在代码中了,这对于可维护性和可读性都是不利的,试想一下,几个月之后,你还知道 'H' 是什么意思吗?为此,我们对其进行改进:

const homeWin = 'H';
...
match[5]=== homeWin

但是这样仍然是有问题的,如下所示:

const homeWin = 'H';
const draw = 'D';
...
match[5] === homeWin

上面的代码中,draw 表示平局,但是目前还没有用上,所以在代码中会显示成浅灰色,那么其它开发者见到之后很有可能就直接将其删除了,这样一来就造成了信息的不完整。那么我们写成下面这样可以吗?

const MatchResult = {
  HomeWin:'H',
  AwayWin:'A',
  Draw:'D'
}
...
match[5] === MatchResult.homeWin

那么我们为什么不这样写呢?原因如下:

一般我们使用 object 来存储数据而不是用来枚举,所以使用 object 尽管没了上面的问题,但是缺乏语义。在 Ts 中,我们有专门用来枚举的数据类型 enum:

enum MatchResult {
  HomeWin = "H",
  AwayWin = "A",
  Praw = "D",
}

你看,这很像 object 只是少了一个等号而已, 但是这并不是 js 的标准语法,在 ts 中写这样的代码需要编译之后才能成为有效的 js 代码。上面的 enum 编译之后会变成:

var MatchResult;
(function (MatchResult) {
  MatchResult["HomeWin"] = "H";
  MatchResult["AwayWin"] = "A";
  MatchResult["Praw"] = "D";
})(MatchResult || (MatchResult = {}));

在 Ts 中,我们可以使用 enum 中的数据或者将其作为 type 对数据进行约束

enum MatchResult {
  HomeWin = "H",
  AwayWin = "A",
  Praw = "D",
}

const printMatchResult = (): MatchResult => {
  if (Math.random() > 0.5) {
    return MatchResult.HomeWin;
  } else if (Math.random() < 0.5) {
    return MatchResult.AwayWin;
  }
  return MatchResult.Praw;
};

我们可以使用断言将符合枚举值的字符串直接断言成枚举类型从而缩小类型范围,这也是一种 Type Guard.

enum MatchResult {
  HomeWin = "H",
  AwayWin = "A",
  Praw = "D",
}

const H = "H" as MatchResult;

解析字符串类型的日期

在这一小节中,我们将 10/08/2018 这样的日期解析为 Date 类型,如下代码所示:

export const dateStringToDate = (dateString: string): Date => {
  const dateParts = dateString.split("/").map((value: string): number => {
    return parseInt(value, 10); // 明确指定基数为10
  });
  // 假设日期格式为 dd/mm/yyyy,并且月份需要减1(因为JavaScript的Date月份是从0开始的)
  return new Date(dateParts[2], dateParts[1] - 1, dateParts[0]);
};

小插曲 -- 关于 tuple 类型

看起来 tuple 用的并不多,感觉就像是专门为复杂的数组量身定做的一样。它可以用在对从 xls 文件中读取的数据的约束上。

关于泛型

  • 术语: Generics(泛型)
  • 定义: 允许在类或函数定义中为类型参数指定类型,类似于函数参数。
  • 目的: 定义属性、参数或返回值的类型,以便在未来的某个时刻指定。
  • 应用场景: 广泛用于编写可重用代码。

泛型,它是一种编程语言特性,用于提高代码的灵活性和重用性。通过泛型,开发者可以在编写类或函数时不指定具体的数据类型,而是使用类型参数,这样同一个类或函数就可以用于不同的数据类型,从而减少代码重复并提高效率。

泛型可以看成是面向对象--多态概念在 typescript 中的体现。

声明式读取文件代码

在这小节中,我们首先定义抽象类 CsvFileReader 在其中实现 read 方法用来读取文件内容,然后声明抽象方法 mapRow 用于对读取到的内容进行格式化。这段代码遵循了声明式编程的原则,具有可扩展性和较强的可读性。

enum MatchResult {
  HomeWin = "H",
  AwayWin = "A",
  Draw = "D",
}

type MatchData = [Date, string, string, number, number, MatchResult];

class DateUtils {
  static dateStringToDate = (dateString: string): Date => {
    const dateParts = dateString.split("/").map((value: string): number => {
      return parseInt(value, 10); // 明确指定基数为10
    });
    // 假设日期格式为 dd/mm/yyyy,并且月份需要减1(因为JavaScript的Date月份是从0开始的)
    return new Date(dateParts[2], dateParts[1] - 1, dateParts[0]);
  };
}

abstract class CsvFileReader<T> {
  data: T[] = [];
  constructor(public filename: string) {}
  abstract mapRow(row: string[]): T;
  read(): void {
    const fs = require("fs");
    const fileContent = fs.readFileSync(this.filename, { encoding: "utf-8" });
    const rows = fileContent.split("\n");
    for (const row of rows) {
      if (row.trim() !== "") {
        const mappedRow = this.mapRow(row.split(","));
        this.data.push(mappedRow);
      }
    }
  }
}

class MatchDataReader extends CsvFileReader<MatchData> {
  mapRow(row: string[]): MatchData {
    return [
      DateUtils.dateStringToDate(row[0]),
      row[1],
      row[2],
      parseInt(row[3], 10),
      parseInt(row[4], 10),
      MatchResult[row[5] as keyof typeof MatchResult] as MatchResult,
    ];
  }
}

// 使用示例
const reader = new MatchDataReader("matches.csv");
reader.read();
console.log(reader.data);

上面的代码架构使用的仍然是**抽象类提供基础公共方法,并通过抽象方法约束不同数据结构具体的实现,再由其子类根据自身情况实现(抽象类设定了操作框架,要求子类根据具体需求实现细节)**这一套,下面我们整点不一样的。如果将上面的方法称为:继承法,那么下面的就是:接口法。用标准名称就是 Inheritance vs Composition, composition 方法见下:

// 假设 MatchResult 枚举和 dateStringToDate 函数已定义在其他地方
enum MatchResult {
  HomeWin = "H",
  AwayWin = "A",
  Draw = "D",
}

// 假设 dateStringToDate 是一个函数,将字符串转换为 Date 对象
function dateStringToDate(dateString: string): Date {
  return new Date(dateString);
}

interface DataReader {
  read(): void;
  data: string[][];
}

type MatchData = [Date, string, string, number, number, MatchResult, string];

export class MatchReader {
  matches: MatchData[] = [];

  constructor(public reader: DataReader) {}

  load(): void {
    this.reader.read();
    this.matches = this.reader.data.map((row: string[]): MatchData => {
      return [
        dateStringToDate(row[0]),
        row[1],
        row[2],
        parseInt(row[3], 10),
        parseInt(row[4], 10),
        row[5] as MatchResult,
        row[6],
      ];
    });
  }
}

// 假设 CsvFileReader 是一个满足 DataReader 接口的类,也定义在其他地方
import { MatchReader } from "./MatchReader";
import { CsvFileReader } from "./CsvFileReader";
import { MatchResult } from "./MatchResult";

// 创建一个满足 DataReader 接口的对象
const csvFileReader = new CsvFileReader("football.csv");
// 创建 MatchReader 实例并传入满足 DataReader 接口的对象
const matchReader = new MatchReader(csvFileReader);
matchReader.load();

let manUnitedWins = 0;
for (let match of matchReader.matches) {
  if (match[1] === "Man United" && match[5] === MatchResult.HomeWin) {
    manUnitedWins++;
  } else if (match[2] === "Man United" && match[5] === MatchResult.AwayWin) {
    manUnitedWins++;
  }
}

// 注意:代码中的逻辑可能需要根据实际的业务需求进行调整

这个方法的核心就在于 MatchReader 类中的 constructor(public reader: DataReader) {} 这一句,简单来说,对于 MatchReader, 它借助了外部力量(指 DataReader 的实例)来完成功能。

对比不同:

  1. Inheritance 方法:Characterized by an 'is a' relationship between two classes
  2. Composition 方法:Characterized by a 'has a' relationship between two classes

Inheritance 方法对比 Composition 方法

对于上面的两个例子,我们可能看不出来这两个方法有什么巨大的差异;事实上,使用 Inheritance 方法不够灵活,而相对的,使用 Composition 方法能够方便的创建出相当复杂的对象。因为 Composition 方法的核心就是:让一个对象获得另外一个对象的引用,而相对于 Composition 方法的引用,Inheritance 方法采用的是复制的思路。

在实践中,我们总是选择后者,有一个著名的准则:Favor object composition over class inheritance - Design Patterns. page 20.

并且实际中,在 Angular 中就深度使用了 Composition 方法。

不要将【多继承】和 Composition 搞混

本节示例如何使用 Js 实现多继承,以及为什么多继承是不好的实践方式。

多继承的基本形式:

// 定义一个高阶函数,它接收一个状态对象并返回一个函数
const canFight = (state) => {
  return () => {
    console.log(`${state.name} slashes at the foe!`);
    state.stamina--;
  };
};

// 定义一个构造函数,用于创建战斗者对象
const fighter = (name) => {
  let state = {
    name,
    health: 100,
    stamina: 100,
  };
  // 使用 Object.assign 将 canFight 函数绑定到 state 对象上
  return Object.assign(state, { canFight: canFight(state) });
};

多继承示例:

// 定义一个函数,计算矩形的面积
const rectangular = (state) => {
  return {
    area: state.height * state.width,
  };
};

// 定义一个高阶函数,用于切换状态的 open 属性
const openable = (state) => {
  return {
    toggleOpen: () => {
      state.open = !state.open;
    },
  };
};

// 定义一个函数,创建一个具有面积计算和开关功能的矩形窗口对象
const buildRectangleWindow = (state) => {
  // 将 rectangular 和 openable 函数中定义的功能合并到 state 对象上
  return Object.assign(state, rectangular(state), openable(state));
};

// 使用 buildRectangleWindow 函数创建一个矩形窗口实例
const rectangleWindow = buildRectangleWindow({
  height: 20,
  width: 20,
  open: false,
});

// 测试 rectangleWindow 对象的 toggleOpen 方法
console.log(`Initial open state: ${rectangleWindow.open}`); // 应该输出 false
rectangleWindow.toggleOpen(); // 切换 open 状态
console.log(`Open state after toggle: ${rectangleWindow.open}`); // 应该输出 true

现在将上面的函数简单改成如下所示:

const openable = (state) => {
  return {
    toggleOpen: () => {
      state.open = !state.open;
    },
    area: () => {},
  };
};

这就很容易看出来,多继承的缺点在于:可能会造成混乱! 所以,我们需要的是 Composition 而不是 Multiple Inheritance!

Composition 方法构造复杂对象

如下代码所示:

// 假设 MatchResult 枚举已在其他文件中定义
export enum MatchResult {
  HomeWin = "H",
  AwayWin = "A",
  Draw = "D",
}

// 定义 MatchData 类型
export type MatchData = [
  Date,
  string,
  string,
  number,
  number,
  MatchResult,
  string
];

// 定义 Analyzer 接口
export interface Analyzer {
  run(matches: MatchData[]): string;
}

// 定义 OutputTarget 接口
export interface OutputTarget {
  print(report: string): void;
}

// 定义 Summary 类
export class Summary {
  constructor(public analyzer: Analyzer, public outputTarget: OutputTarget) {}

  buildAndPrintReport(matches: MatchData[]): void {
    const report = this.analyzer.run(matches);
    this.outputTarget.print(report);
  }
}

// 定义 WinsAnalysis 类,实现 Analyzer 接口
export class WinsAnalysis implements Analyzer {
  constructor(public team: string) {}

  run(matches: MatchData[]): string {
    let wins = 0;
    for (let match of matches) {
      if (
        (match[1] === "Man United" && match[5] === MatchResult.HomeWin) ||
        (match[2] === "Man United" && match[5] === MatchResult.AwayWin)
      ) {
        wins++;
      }
    }
    return `${this.team} won ${wins} games`;
  }
}

// 定义 ConsoleReport 类,实现 OutputTarget 接口
export class ConsoleReport implements OutputTarget {
  print(report: string): void {
    console.log(report);
  }
}

// 定义 HtmlReport 类,实现 OutputTarget 接口
export class HtmlReport implements OutputTarget {
  print(report: string): void {
    const html = `<div><h1>Analysis Output</h1><div>${report}</div></div>`;
    // 假设 fs 是 Node.js 的文件系统模块
    // @ts-ignore
    fs.writeFileSync("report.html", html);
  }
}

// 假设 CsvFileReader 和 MatchReader 类已在其他文件中定义,并且满足 DataReader 接口
// 假设 DataReader 接口定义如下:
interface DataReader {
  read(): void;
  data: MatchData[];
}

// 使用示例
import { CsvFileReader } from "./CsvFileReader";
import { MatchReader } from "./MatchReader";

// 创建 CsvFileReader 和 MatchReader 实例
const csvFileReader = new CsvFileReader("football.csv");
const matchReader = new MatchReader(csvFileReader);
matchReader.load();

// 创建 Summary 实例,传入 WinsAnalysis 和 ConsoleReport
const summary = new Summary(
  new WinsAnalysis("Man United"),
  new ConsoleReport()
);
summary.buildAndPrintReport(matchReader.matches);

// 如果需要生成 HTML 报告,可以这样做:
const htmlSummary = new Summary(
  new WinsAnalysis("Man United"),
  new HtmlReport()
);
htmlSummary.buildAndPrintReport(matchReader.matches);

分析:

上述代码通过组合(Composition)实现了代码的模块化和功能的复用。Summary 类接受任何实现了 Analyzer 接口的对象和实现了 OutputTarget 接口的对象。这种设计允许不同的分析逻辑和输出方式灵活组合,而不是通过继承固定行为。例如,WinsAnalysis 类实现了 Analyzer 接口,提供特定球队胜利次数的分析逻辑;而 ConsoleReportHtmlReport 类实现了 OutputTarget 接口,分别提供控制台和 HTML 格式的输出功能。通过这种方式,Summary 类可以在不修改现有代码的情况下,通过传入不同的分析器和输出目标来扩展新功能,体现了组合优于继承的设计原则。