Typescript中的面向对象

interface

interface 是专门用于定义特定对象类型的模板,它跟type对象定义有大部分功能重叠,且在继承(extend)和联合交叉时可以混用。不同的是,接口允许重名合并和this关键字,而type可以属性映射(mapping),这是它们各自的独有功能。

(1)表示对象

interface Point extends Item {
  x?: number;
  y: number;
  readonly a: string;
  add(num:number): this;
}

type Point<T> = {
  x?: number;
  y: number;
  readonly a: string;
  add(num:number): string;
  [Key in keyof T]: Point[Key];
}

(2)表示记录

// 实际表示记录类型
interface A {
  [prop: string]: number;
}

(3)对象方法的重载

类型方法可以重载。使用方法重载可以严格约束参数类型和返回值类型的对应关系。如下代码所示:

  • 当使用 GET 方法时,TypeScript 知道返回类型一定是 Promise<string>
  • 当使用 POST 方法时,TypeScript 知道 body 是必需的,并且返回类型是 Promise<void>

不使用方法重载,当使用 POST 方法时,返回类型是 Promise<string> 也是被允许的。

// 使用 interface
interface FetchAPI {
  fetch(url: string): Promise<string>;
  fetch(url: string, options: { method: 'GET' }): Promise<string>;
  fetch(url: string, options: { method: 'POST', body: string }): Promise<void>;
}

// 使用 type 定义的对象类型也可以重载
type FetchAPI = {
  fetch(url: string): Promise<string>;
  fetch(url: string, options: { method: 'GET' }): Promise<string>;
  fetch(url: string, options: { method: 'POST', body: string }): Promise<void>;
};

// 实现
const api: FetchAPI = {
  fetch(url: string, options?: { method: 'GET' | 'POST', body?: string }): Promise<string | void> {
    if (!options) {
      return Promise.resolve('Default GET request');
    }
    if (options.method === 'GET') {
      return Promise.resolve('GET request with options');
    }
    if (options.method === 'POST' && options.body) {
      console.log('POST request with body');
      return Promise.resolve();
    }
    throw new Error('Invalid options');
  }
};

// 使用
api.fetch('https://example.com')
  .then(response => console.log(response));

api.fetch('https://example.com', { method: 'GET' })
  .then(response => console.log(response));

api.fetch('https://example.com', { method: 'POST', body: 'Hello' })
  .then(() => console.log('POST completed'));

常规函数重载是通过枚举的方式列出所有参数与返回值类型的可能组合,除了枚举法,这种组合关系还可以使用条件法描述:

function example<T extends string | number>(arg: T): T extends string ? number : string {
  if (typeof arg === "string") {
    return arg.length as any; // 返回数字
  } else {
    return arg.toString() as any; // 返回字符串
  }
}

// 调用时自动推断
const result1 = example("hello"); // result1 的类型是 number
const result2 = example(42);      // result2 的类型是 string

属性定义的三个层次:访问修饰符(默认public,有public、private、protected三种) -> 实例/静态属性(是否有static 修饰符)-> 是否只读(是否有readonly修饰符)。这三重身份是叠加的。游离于这个体系之外的是JavaScript的原生私有属性(可用来代替private)。

  1. 访问修饰符(控制外部可访问性):
    • public(默认):可以在任何地方访问
    • private:只能在声明的类内部访问
    • protected:只能在声明的类及其子类中访问
  2. 静态/实例(强调数据来源,实际相当于Python的实例方法和类方法,但是Python的实例名也是可以访问类方法的):
    • 实例属性:每个实例都有自己的副本
    • 静态属性(static):属于类本身
  3. 只读:
    • readonly:只能在声明时或构造函数中初始化,之后不能修改
  4. JavaScript 原生私有属性:
    • 使用 # 前缀声明,真正的私有属性,甚至不能通过类型转换访问

实例属性和静态属性在写法和使用上的区别:

class Example {
  #privateInstance = 'I am a private instance property';
  static #privateStatic = 'Static property'

  constructor() {
    console.log(this.#privateInstance, Example.#privateStatic);
    // 区别在于使用时调用方式
  }

  method() {
    console.log(this.#privateInstance, Example.#privateStatic);
  }
}

const instance = new Example();
instance.method(); 
console.log(instance.#privateInstance);

类除了是Javascript中的功能语法外,类名在TypeScript中还能直接当作类型名使用,发挥出跟 interface 相似的功能。类与接口还存在联动关系,不允许两个同名的类,但是如果一个类和一个接口同名,那么接口会被合并进类。因此,TypeScript 有三种方法可以为对象类型起名:type、interface 和 class。

在写法上,类就是增加了实现语句的接口,其修饰符、方法的类型标注等写法都是一模一样的。类特有的修饰符有get、set等。

class Point {
  readonly id = 'foo';
  x:number;
  y:number;
   _name = '';

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

  add(point:Point):Point {
    return new Point(
      this.x + point.x,
      this.y + point.y
    );
  }

  get name() {
    return this._name;
  }
  
  set name(value) {
    this._name = value;
  }
}

const a: Point = new Point(12, 13); // 类名直接作为类型进行标注
a.id = 'bar'; // 报错

类型契约与实务中间层

类可以指明部署了哪些接口,部署某个接口就要把该接口中的属性、方法都实现,这造成了以下后果:

(一)类型契约。最明显的效果,就是当类遭到修改时会发出错误警告。接口标注是具有强约束力的并不是不作为的,这样接口标注就可以当作显式文档来使用,实现了多个接口的类,实际上依靠接口名对类的内部属性和方法进行了功能分组和贴标签,这非常有利于对复杂类的理解和分析。

(二)实务中间层。从类的角度出发,部署了接口的类使得同一个类实例拥有更宽泛的多重身份(而不是仅仅局限于该类本身),从而增加在使用时的灵活性。这是“中间层思想”的体现。从实务的角度出发,完成系统实务的功能函数都需要明确其能处理的数据类型,这样的数据类型有时体现为某种接口。使用这些接口来定义类,体现了用实务定义主体的思想方法,能够形成严整的代码结构。

有趣的是,即使不进行明确的类型降维,在使用时(赋值、传参等)只要该类的实例符合相应的鸭子类型,就不会报错。但根据上述理由,这不妨碍我们继续坚持部署接口。接口的意义体现在最终的代码组织上。

interface Drivable {
  drive(): void;
}

class Car implements Drivable {
// 去掉 implements Drivable 下述代码仍不会出错
  drive() {
    console.log("Driving a car");
  }
}

class Truck implements Drivable {
// 去掉 implements Drivable 下述代码仍不会出错
  drive() {
    console.log("Driving a truck");
  }
}

function startDriving(vehicle: Drivable) {
  vehicle.drive();
}

let car = new Car();
let truck = new Truck();

startDriving(car);  // Driving a car
startDriving(truck); // Driving a truck

(1)直接实现多个接口

类可以实现多个接口(其实是接受多重限制),每个接口之间使用逗号分隔。但是,同时实现多个接口并不是一个好的写法,容易使得代码难以管理,可以使用两种方法替代。

class Car implements MotorVehicle, Flyable, Swimmable {
  // ...
}

(2)类的继承

class Car implements MotorVehicle {
}

class SecretCar extends Car implements Flyable, Swimmable {
}

(3)接口的拓展

interface MotorVehicle {
  // ...
}
interface Flyable {
  // ...
}
interface Swimmable {
  // ...
}

interface SuperCar extends MotorVehicle,Flyable, Swimmable {
  // ...
}

class SecretCar implements SuperCar {
  // ...
}

类自身的类型(构造函数)

要想标注类本身,而不是实例的类型,需要用到 typeof 操作符。又由于类实际是构造函数的语法糖,所以类型也可写为构造函数的形式。

function createPoint(
  PointClass: new (x:number, y:number) => Point,
  x: number,
  y: number
):Point {
  return new PointClass(x, y);
}

实例属性初始化的简写形式

实际开发中,很多实例属性的值,是通过构造方法传入的。

class Point {
  x:number;
  y:number;

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

这样的写法等于对同一个属性要声明两次类型,一次在类的头部,另一次在构造方法的参数里面。这有些累赘,TypeScript 就提供了一种简写形式。

class A {
  constructor(
    public a: number,
    protected b: number,
    private c: number, 
    readonly d: number 
  ) {}
}

下面示例中,构造方法的参数x前面有public修饰符,这时 TypeScript 就会自动声明一个公开属性x,不必在构造方法里面写任何代码,同时还会设置x的值为构造方法的参数值。

class Point {
  constructor(
    public x:number,
    public y:number
  ) {}
}

const p = new Point(10, 10);
p.x // 10
p.y // 10

应尽量使用简写形式,除非属性初始化时涉及到运算。

class User  {
  age: number;

  constructor(private currentYear: number) {
    this.age = this.currentYear - 1998;
    console.log('Current age:', this.age);
  }
}

const user = new User(2023);

泛型类

类也可以写成泛型,使用类型参数。

class Box<Type> {
  contents: Type;

  constructor(value:Type) {
    this.contents = value;
  }
}

const b:Box<string> = new Box('hello!');

抽象类,抽象成员

TypeScript 允许在类的定义前面,加上关键字abstract,表示该类不能被实例化,只能当作其他类的模板。这种类就叫做“抽象类”(abstract class)。抽象类只能当作基类使用,用来在它的基础上定义子类。直接新建抽象类的实例,会报错。

abstract class A {
  id = 1;
}

const a = new A(); // 报错
abstract class A {
  id = 1;
}

class B extends A {
  amount = 100;
}

const b = new B();

b.id // 1
b.amount // 100

抽象类的内部可以有已经实现好的属性和方法,也可以有还未实现的属性和方法。后者就叫做“抽象成员”(abstract member),即属性名和方法名有abstract关键字,表示该方法需要子类实现。如果子类没有实现抽象成员,就会报错。

abstract class A {
  abstract foo:string;
  bar:string = '';
}

class B extends A {
  foo = 'b';
}
abstract class A {
  abstract execute():string;
}

class B extends A {
  execute() {
    return `B executed`;
  }
}

this

类的方法经常用到this关键字,它表示该方法当前所在的对象。

class A {
  name = 'A';

  getName() {
    return this.name;
  }
}

const a = new A();
a.getName() // 'A'

const b = {
  name: 'b',
  getName: a.getName
};
b.getName() // 'b'

上面示例中,变量abgetName()是同一个方法,但是执行结果不一样,原因就是它们内部的this指向不一样的对象。如果getName()在变量a上运行,this指向a;如果在b上运行,this指向b

有些场合需要给出this类型,但是 JavaScript 函数通常不带有this参数,这时 TypeScript 允许函数增加一个名为this的参数,放在参数列表的第一位,用来描述函数内部的this关键字的类型。

// 编译前
function fn(
  this: SomeType,
  x: number
) {
  /* ... */
}

// 编译后
function fn(x) {
  /* ... */
}

上面示例中,函数fn()的第一个参数是this,用来声明函数内部的this的类型。编译时,TypeScript 一旦发现函数的第一个参数名为this,则会去除这个参数,即编译结果不会带有该参数。

class A {
  name = 'A';

  getName(this: A) {
    return this.name;
  }
}

const a = new A();
const b = a.getName;

b() // 报错

上面示例中,类AgetName()添加了this参数,如果直接调用这个方法,this的类型就会跟声明的类型不一致,从而报错。

this参数的类型可以声明为各种对象。

function foo(
  this: { name: string }
) {
  this.name = 'Jack';
  this.name = 0; // 报错
}

foo.call({ name: 123 }); // 报错

上面示例中,参数this的类型是一个带有name属性的对象,不符合这个条件的this都会报错。

TypeScript 提供了一个noImplicitThis编译选项。如果打开了这个设置项,如果this的值推断为any类型,就会报错。

// noImplicitThis 打开

class Rectangle {
  constructor(
    public width:number,
    public height:number
  ) {}

  getAreaFunction() {
    return function () {
      return this.width * this.height; // 报错
    };
  }
}

上面示例中,getAreaFunction()方法返回一个函数,这个函数里面用到了this,但是这个thisRectangle这个类没关系,它的类型推断为any,所以就报错了。

在类的内部,this本身也可以当作类型使用,表示当前类的实例对象。

class Box {
  contents:string = '';

  set(value:string):this {
    this.contents = value;
    return this;
  }
}

上面示例中,set()方法的返回值类型就是this,表示当前的实例对象。

有些方法返回一个布尔值,表示当前的this是否属于某种类型。这时,这些方法的返回值类型可以写成this is Type的形式,其中用到了is运算符。

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }

  isDirectory(): this is Directory {
    return this instanceof Directory;
  }

  // ...
}