typescript - 面向对象

101 阅读10分钟

在早期的JavaScript开发中(ES5)我们需要通过函数和原型链来实现类和继承,从ES6开始,引入了class关键字,可以更加方 便的定义和使用类

TypeScript作为JavaScript的超集,也是支持使用class关键字的,并且还可以对类的属性和方法等进行静态类型检测

class Person {
  // 在TS中,所有的类的实例属性(成员属性)都必须使用类的属性表达式进行类型注解
  name: string
  age: number

  // 构造器 - 参数是形参需要类型注解
  constructor(name: string, age: number) {
    this.name = name
    this.age = age
  }
}

const per = new Person('Klaus', 23)
console.log(per.name, per.age)
class Person {
  // 类的属性表达式赋予初始值的时候,ts可以自动进行类型推导
  name = 'Alex'
  age = 12

  constructor(name: string, age: number) {
    this.name = name
    this.age = age
  }
}

const per = new Person('Klaus', 23)
console.log(per.name, per.age)
class Person {
  name = 'Alex'
  age = 12

  constructor(name: string, age: number) {
    this.name = name
    this.age = age
  }

  // 在类中,类的方法中的this是可以正确被推导出来的
  eatting() {
    console.log(this.name + ' eatting')
  }
}

const per = new Person('Klaus', 23)

console.log(per.name, per.age)
per.eatting()

strictPropertyInitialization

在默认的strictPropertyInitialization模式下面我们的属性是必须 初始化的,如果没有初始化,那么编译时就会报错

给属性进行初始化的方式有以下两种

  1. 通过类的成员属性表达式进行初始化
  2. 通过类的构造函数的方式进行初始化

如果我们在strictPropertyInitialization模式下确实不希望给属性初始化, 可以使用非空断言

class Person {
  // 使用非空断言 告诉tsc 该属性后期会被初始化
  // 并需要在这里进行强制初始化
  name!: string
  age!: number

  eatting() {
    console.log(this.name + ' eatting')
  }
}

const per = new Person()

console.log(per.name, per.age)
per.eatting()

成员修饰符

修饰符说明
public在任何地方可见、公有的属性或方法 默认值
private在同一类中可见、私有的属性或方法
protected在类自身及子类中可见、受保护的属性或方法

readonly

如果有一个属性我们不希望外界可以任意的修改,只希望确定值后直接使用,那么可以使用readonly

class Person {
  // 对于只读属性,除了初次赋值之外的其它时候都不能再被二次赋值
  // 初次赋值的情况为 在类的成员属性中赋值 或者 通过类的构造函数进行赋值
  readonly name: string
  age: number

  constructor(name: string, age: number) {
    this.name = name
    this.age = age
  }
}

const per = new Person('Klaus', 23)
// per.name = 'Alex' // error

getter/setter

class Product {
  name: string
  // 私有属性 一般使用下划线 进行标识
  // ps: 类中可以使用#开头属性 定义标识符
  // 但是private 标识符 不可以和 #开头的私有属性一起连用
  // 因为#开头属性 已经在语言层面阻止其在类外被访问
  // 所以对于这类属性 并不需要使用private标识符进行标识
  private _price: number | string = 0

  constructor(name: string, price: number | string) {
    this.name = name
    this.price = price
  }

  // getter/setter 的根本作用是拦截属性的设置和访问
  // 从而在设置属性或获取属性前,进行通用的自定义操作
  set price(newV: number | string) {
    if (newV >= 0) {
      this._price = newV
    }
  }

  // 如果一个属性 设置了其getter和setter方法
  // 如果没有setter 那么这个属性就是一个只读属性
  // 如果没有getter 那么获取这个属性值的时候 获取到的结果就是undefined
  get price() {
    return "¥" + this._price
  }
}

const product = new Product('apple', 8230)
console.log(product.price) // => ¥8230
product.price = -100 // => ¥8230
console.log(product.price)

参数属性

TypeScript 提供了特殊的语法,可以把一个构造函数参数转成一个同名同值的类属性,这些就被称为参数属性(parameter properties)

你可以通过在构造函数参数前添加一个可见性修饰符 public private protected 或者 readonly 来创建参数属性,最后这些类 属性字段也会得到这些修饰符

class Person {
  // 在构造函数的参数上设置修饰符
  // 会在类中声明一个同名同类型的类型注解 和 同名属性
  // 并将传入的实参 传入到对应的同名属性中
  // tips:
  //      1. 如果需要使用参数属性 类型修饰符是不可以省略的,即使是public
  //      2. 如果private修饰符 和 readonly修饰符 同时存在 private修饰符必须在readonly修饰符前面
  constructor(public name: string, public age: number, private readonly title: string) {

  }

  printTitle() {
    console.log(this.title)
  }
}

const per = new Person('Klaus', 23, 'developer')
console.log(per.name) // => Klaus
console.log(per.age) // => 23
per.printTitle() // => developer

抽象类

继承是多态使用的前提,所以在定义很多通用的调用接口时, 我们通常会让调用者传入父类,通过多态来实现更加灵活的调用方式

但是,父类本身可能并不需要对某些方法进行具体的实现,所以父类中定义的方法, 我们可以将其定义为抽象方法

// 抽象方法 必须存在于 抽象类中
// 抽象类中的抽象方法 必须在 子类中被定义
// 如果子类依旧是一个抽象类 那么子类可以不实现对应父类中的抽象方法

// 抽象类是不可以被实例化的
// 所以无论祖先类中有几个抽象类 实例化的那个类 不可以是抽象类 且必须实现对应的抽象方法
abstract class Shape {
  // 抽象方法 必须使用abstract进行定义
  // 抽象方法 不需要存在对应的实现体
  abstract getArea(): number
}

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

  getArea() {
    return this.width * this.height
  }
}

class Circle extends Shape {
  constructor(public radius: number) {
    super()
  }

  getArea() {
    return this.radius ** 2 * Math.PI
  }
}

// 子类实例 赋值给 父类类型变量 --- 也就是所谓的父类引用指向子类实例
// 从而实现对于同一个方法 传入不同类型的参数 可以 得到不同的行为 --- 也就是多态
function getArea(shap: Shape) {
  console.log(shap.getArea())
}

const rect = new Rectangle(100, 200)
const circle = new Circle(10)

getArea(new Rectangle(100, 200))
getArea(new Circle(10))

// 在本例中 Shape类型本质上等于 { getArea(): number }
// 对于JS而言,对应的类型是鸭子类型 只要类型结构一致即可 并不需要一定是Shape的实例
getArea({ getArea() { return 0 } })

鸭子类型

对于TS和JS而言, 其类型检测 基于的是鸭子类型, 也就是说只关心对应所需的属性和行为是否匹配,并不进行严格的类型校验

// 当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子
class Person {
  constructor(public name: string) {}
  running() {}
}

class Dog {
  constructor(public name: string) {}
  running() {}
}

// 虽然狗和人是两种不同的类型
// 但是在本例中 狗和人 都有name属性 也都有running方法
// 也就是说这两个类型的属性和行为是一致的
// 所以在ts中,这两种类型是可以相互赋值的
const per: Person = new Dog('大黄')

抽象类和接口的区别

抽象类在很大程度上和接口会有点类似:都可以在其中定义一个方法,让子类或实现类来实现对应的方法

但是他们之间依旧存在如下区别:

  1. 抽象类是事物的抽象,抽象类用来捕捉子类的通用特性,接口通常是一些行为的描述

    • 抽象类是对一系列具体类的共性的抽离, 如 动物 是对猫,狗等一系列动物的抽象,所有动物可以定义为抽象类

    • 接口是对行为的抽离,并不关心实现这个接口的实例之间是否存在共性,如对于一个实现了run功能的接口

      狗可以实现run接口,火车也可以实现run接口,但狗和火车之间没有必然联系

  2. 抽象类通常用于一系列关系紧密的类之间,接口只是用来描述一个类应该具有什么行为

  3. 接口可以被多层实现,而抽象类只能单一继承

    • 一个类可以实现多个接口
    • 一个类只能继承自一个父类
  4. 抽象类中可以有实现体,接口中只能有函数的声明

    • 抽象类中既可以只有抽象方法,也可以有具体有实现体的方法
    • 接口中只能有函数的声明,不能有函数的实现

总结:

  1. 抽象类是对事物的抽象,表达的是 is a 的关系。如猫是一种动物
  2. 接口是对行为的抽象,表达的是 has a 的关系。如猫可以跑,火车也可以跑

类类型

当我们在ts中定义了一个类的时候,该类具有以下三个作用:

  1. 可以用于创建类对应的实例对象
  2. 可以作为该类对应的实例对象的数据类型
  3. 可以看成是一个拥有对应构造签名的函数
class Person {}

// foo的参数需要一个无参的构造函数
function foo(foo: new () => void) {}

foo(Person)

对象属性描述符

描述符示例功能
可选类型name?: string我们可以在属性名后面加一个 ? 标记表示这个属性是可选的
只读类型readonly name: string在 TypeScript 中,属性可以被标记为 readonly,这不会改变任何运行时的行为 但在类型检查的时候,一个标记为 readonly的属性是不能被写入的

严格的字面量赋值检测

  1. 每个对象字面量最初都被认为是“新鲜的(fresh)”
  2. 当一个新的对象字面量分配给一个变量或传递给一个非空目标类型的参数时,对象字面量指定目标类型中不存在的属性是错误的
  3. 当类型断言或对象字面量的类型扩大时,新鲜度会消失
interface IFoo {
  name: string
  age: number
}

// error - 对于变量的首次赋值会开启严格类型检测
// 此时不可以存在接口中不存在的对应属性
const foo: IFoo = {
  name: 'klaus',
  age: 23,
  height: 1.88
}
interface IFoo {
  name: string
  age: number
}

const baz = {
  name: 'klaus',
  age: 23,
  height: 1.88
}

// 当foo的值是来自于变量,而不是字面量
// 也就是不是首次赋值的时候,对应的对象会失去新鲜感,所以可以存在接口中不存在的额外属性
// 此时对应的foo类型为 { name: string, age: number } 并不会存在height属性,所以这种行为也可以被称之为类型擦除
const foo: IFoo = baz
// 对于空对象的首次赋值 是不会进行严格的字面量类型检测的
const foo: {} = {
  name: 'klaus',
  age: 23
}

索引签名

有的时候,你不能提前知道一个类型里的所有属性的名字,但是你知道这些值的某些公共特征,(如都可以通过索引来进行获取,且获取到的值都是字符串类型)

在这种情况下,你就可以用一个索引签名 (index signature) 来描述可能的值的类型

interface IFoo {
  // 索引签名 - 可以通过number类型的索引去获取类型为string类型的值
  [index: number]: string
  length: number
}

function foo(arg: IFoo) {
  console.log(arg.length)

  console.log(arg[0])
  console.log(arg[1])
}

foo(['Klaus', 'Alex', 'Steven'])
interface ICollection {
  // 索引的类型只能是number或string中的一种
  // 在JS中 通过数值索引去获取对应元素的时候,其本质上会先转换为字符串索引后,再去获取对应的元素
  // 所以在TS中 对应的机制也是一样的
  [index: number]: string
  length: number
}

function printCollection(collection: ICollection) {
  // collection的类型是 ICollection
  // 所以collection一定存在length属性
  // 且可以通过number类型的索引 去获取 string类型的值
  for(let i = 0; i < collection.length; i++) {
    console.log(collection[i])
  }
}

printCollection(['Klaus', 'Alex', 'Steven'])

// 元素也是可以满足ICollection类型的
const tuple: [string, string] = ['aaa', 'bbb']
printCollection(tuple)
interface IFoo {
  [key: string]: string | number
}

const foo: IFoo = {
  name: 'Klaus',
  age: 23
}

// 对于索引签名 对应的属性 以下两种获取和赋值方式都是相同的
console.log(foo.name)
console.log(foo['name'])

索引签名常见问题

类型问题

interface ICollection {
  [index: number]: string
}

// 如果只是通过number类型索引去获取对应的值 可以确保获取到的值一定是字符串
let collection: ICollection = ['Klaus', 'Alex', 'Steven']

export {}
interface ICollection {
  [index: string]: string
}

// error
// string类型的索引值可以获取的值即可能是string类型,也可能是function类型(如map,filter或forEach等)
// 所以此时类型检测会报错
let collection: ICollection = ['Klaus', 'Alex', 'Steven']

export {}
interface ICollection {
  [index: string]: any
}

let collection: ICollection = ['Klaus', 'Alex', 'Steven']

export {}

两个索引签名

interface IFoo {
  // 对于number类型索引最终都会被转换为通过字符串索引来取值
  // 所以number类型索引对于的值类型必须是string类型索引对于值类型的子类型
  [index: number]: string
  [key: string]: string | number
}

interface IFoo {
  [index: number]: string
  [key: string]: string | number
  // 对于自定义的额外类型,也需要满足字符串索引类型对应的值类型
  // 所以自定义属性的类型也必须是字符串索引类型对应的值类型的子类型
  customProp: number
}

接口补充

继承

接口和类一样是可以进行继承的,也是使用extends关键字,并且我们会发现,接口是支持多继承的 (类不支持多继承)

当一个接口继承自另一个接口的时候

  1. 可以减少相同代码的重复编写
  2. 如果我们所继承的那个接口是第三方库的接口,那么我们就可以在此基础上对第三方库的接口进行自定义扩展
interface IFoo {
  name: string
  age: number
}

interface IBaz {
  running: () => void
}

// 接口可以多继承
// 当一个接口继承自其它接口,那么实现这个接口的数据必须同时满足所有其所继承过来的接口和方法
interface IBar extends IFoo, IBaz {
  eatting: () => void
}

const bar: IBar = {
  name: 'Klaus',
  age: 23,
  running() {},
  eatting() {}
}

接口的实现

interface IProp {
  name: string,
  age: number
}

interface IFunc {
  running: () => void
}

// 当一个类实现了某个接口的时候,对应接口中的属性和方法都必须要实现
// 类也是可以实现多个接口的
class Person implements IProp, IFunc {
  constructor(public name: string, public age: number) {}
  running() {}
}