🔥TS进阶之「深入理解类和接口」

202 阅读11分钟

面向对象概述

我们为什么要去学习面向对象

笔者作为耕耘前端领域,现在技术迭代更新不穷,如果还停留在以前的学习模式和应用的知识框架下,对于自身的发展会很有限。那就来说说TS给我们到底能带来什么吧。

  1. TS为前端面向对象开发带来了契机

    JS语言是一门弱类型语言,没有类型检查,如果使用面向对象的方式开发,会产生大量的接口,而大量的接口会导致调用复杂度剧增,这种复杂度必须通过严格的类型检查来避免错误,尽管我们可以使用注释、文档或记忆力,但是它们没有强的一个约束力。

    而TS带来了完整的类型系统,因此在我们开发复杂程序时,无论接口数量有多少,都可以获得完整的类型检查,并且这种检查是具有强约束力的。

  2. 面向对象中有许多非常成熟的模式,能处理复杂问题

    在过去的很多年中,在大型应用或复杂领域,面向对象已经积累了非常多的经验。(后续也会在相应的部分介绍相对成熟的设计模式)

什么是面向对象

面向对象:Oriented(基于) Object(事物),简称OO。

是一种编程思想,它提出一切以类对切入点思考问题。

当然还有其他编程思想:比如面向过程、函数式编程等等

开发中最重要最难的是什么?

比起书写代码,最难的是开发思维和设计模式

面向过程:以功能流程为思考切入点,不太适合大型应用

函数式编程:以数学运算为思考切入点

面向对象:以划分类为思考切入点。类是最小的功能单元

类的继承

继承的作用

继承可以描述类与类之间的关系

如果A和B都是类,并且可以描述为A是B,则A和B形成继承关系:

  • B是父类,A是子类
  • B派生A,A继承自B
  • B是A的基类,A是B的派生类

如果A继承自B,则A中自动拥有B中的所有成员

export class Fruits{
  color: string = '水果的颜色';
  name: string = '水果'

  showInfo() { 
    console.log(`这是一个${this.name},他是${this.color}`)
  }
}

export class Apple extends Fruits { 
  color: string = '红色';
  name: string = '苹果';
  life: string = '3个月'
  
  showInfo() {
    console.log('我是苹果');
  }
}

export class Banana  extends Fruits { 
  color: string = '黄色';
  name: string = '香蕉'
}

const a = new Apple()
const b = new Banana()
a.showInfo();
b.showInfo();

打印结果:

image.png

成员的重写

重写(override):子类中覆盖父类的成员

子类成员不能改变父类成员的类型

在上面的例子,创建一个苹果实例对象,对实例属性name进行重新赋值number类型, 这时候TS提示:不能将类型“number”分配给类型“string”。

const a = new Apple()
a.name = 123  // 不能将类型“number”分配给类型“string”。

无论是属性还是方法,子类都可以对父类的相应成员进行重写,但是重写时,需要保证类型的匹配。

🔥注意:

this关键字:在继承关系中,this的指向是动态——调用方法时,根据具体的调用者确定this指向

super关键字:在子类的方法中,可以使用super关键字读取父类成员

class Apple extends Fruits { 
  color: string = '红色';
  life: string = '3个月'

  test() { 
    super.showInfo()
    // 或者
    this.showInfo()
  }

  showInfo() {
    console.log('我是苹果');
  }
}

类型匹配(继承关系中怎么类型匹配)

鸭子辨型法

子类的对象,始终可以赋值给父类

eg: 给常量a限制Fruits类型

const a: Fruits = new Apple()`

a.showInfo() // 我是苹果
a.life // ??? 能使用吗

给常量a限制约束Fruits后,a.life ts推断:类型“Fruits”上不存在属性“life”。因为约束了Fruits,a只能使用Fruits中共有的部分,如果确实要使用这个属性,我们可以使用类型保护,如下:

// 判断原型链是否具有   
if (a instanceof Apple) { a.life }

面向对象中,这种现象,叫做 里氏替换原则

如果需要判断一个数据的具体子类类型,可以使用 instanceof

protected修饰符

readonly:只读修饰符

访问权限修饰符:

private:私有的

public:共有的

protected: 受保护的成员,只能在自身和子类中访问

继承的单根性和传递性

单根性:每个类最多只能拥有一个父类,

传递性:如果A是B的父类,并且B是C的父类,则,可以认为A也是C的父类

抽象类

为什么需要抽象类

image.png

比如中国象棋里面的旗子,马,兵,炮...都是旗子的子类,但是我们又不能用旗子这个概念去描述他,我们可以使用类具体去new对应的实例,但是我们不用去具体的创建一个旗子的类。比如:

class Chess {}

class Horse extends Chess {}

class Pao extends Chess {}

class Soldier extends Chess {}

class King extends Chess {}

const h = new Horse()
const p = new Pao()
const s = new Soldier()
const k = new King()

有时,某个类只表示一个抽象概念,主要用于提取子类共有的成员,而不能直接创建它的对象。该类可以作为抽象类。

给类前面加上abstract,表示该类是一个抽象类,不可以创建一个抽象类的对象。

abstract class Chess {}

在我们创建一个抽象类的实例时,TS会提示:

image.png

抽象成员

abstract class Chess {
  x: number = 0
  y: number = 0

  // 在父类中不知道具体的旗子名字,也可定义为抽象
  abstract readonly name: string;
}

// 让子类中必须实现name属性
class Horse extends Chess {
  readonly name: string = '马';
}

父类中,可能知道有些成员是必须存在的,但是不知道该成员的值或实现是什么,因此,需要有一种强约束,让继承该类的子类,必须要实现该成员。

抽象类中,可以有抽象成员,这些抽象成员必须在子类中实现

扩展设计模式之 「模板模式」

设计模式: 面对一些常见的功能场景,有一些固定的、经过多年实践的成熟方法,这些方法称之为设计模式。

比如在中国象棋里面,定义了棋子的抽象父类,每个棋子都有共有的移动规则,比如每个棋子都要进行边界判断,目标位置是否有己方的棋子,但是每个棋子的移动规则又不一样,马踏斜日、象飞田等等,当是在父类中我们想不到怎么去处理这么复杂的类型,我们可以在父类中做成抽象方法:

abstract class Chess {
  x: number = 0
  y: number = 0

  abstract readonly name: string;

  move(targetX: number, targetY: number): boolean {
    // 1. 边界判断
    console.log('1. 边界判断')

    // 2. 目标位置是否有己方旗子
    console.log('2. 目标位置是否有己方旗子')

    if (this.rule(targetX, targetY)) {
      console.log(`${this.name}移动成功`)
    }
    return false

  }
  protected abstract rule(targetX: number, targetY: number): boolean
}

class Horse extends Chess {
  readonly name: string = '马';
  
  protected rule(targetX: number, targetY: number): boolean {
    return true
  }
}

class Pao extends Chess {
  name: string
  constructor() {
    super()
    this.name = '炮'
  }
  
  protected rule(targetX: number, targetY: number): boolean {
    return false
  }
}

class Soldier extends Chess {
  // 访问器
  get name() {
    return '兵'
  }
  
  protected rule(targetX: number, targetY: number): boolean {
    return true
  } 
}

class King extends Chess {
  name: string = '将';
  protected rule(targetX: number, targetY: number): boolean {
    return true
  }
}

const h = new Horse()
const p = new Pao()
const s = new Soldier()
const k = new King()

console.log(h.name)
console.log(p.name)
console.log(s.name)
h.move(1, 2)
p.move(1, 2)
s.move(1, 2)
k.move(2, 3)

模板模式:有些方法,所有的子类实现的流程完全一致,只是流程中的某个步骤的具体实现不一致,可以将该方法提取到父类,在父类中完成整个流程的实现,遇到实现不一致的方法时,将该方法做成抽象方法。

静态成员

什么是静态成员

如果有这么一个登录注册的场景,创建实例的时候相当于注册,登录的时候去判断是否存在,登录的时候我们我们不能创建用户对象,可以使用静态成员来实现:

class User {
    static users: User[] = [];

    constructor(
        public loginId: string,
        public loginPwd: string,
        public name: string,
    ) {
        //需要将新建的用户加入到数组中
        User.users.push(this);
    }

    sayHello() {
        console.log(`大家好,我叫${this.name},我的账号是${this.loginId}`)
    }

    static login(loginId: string, loginPwd: string): User | undefined {
        return this.users.find(u => u.loginId === loginId && u.loginPwd === loginPwd)
    }
}

new User("s1", "123", "小花");
new User("s2", "123", "小明");
new User("s3", "123", "程程");

const result = User.login("s3", "123");
if(result){
    result.sayHello();
}
else{
    console.log("登录失败,账号或密码不正确")
}

静态成员是指,附着在类上的成员(属于某个构造函数的成员)

使用static修饰的成员,是静态成员

实例成员:对象成员,属于某个类的对象

静态成员:非实例成员,属于某个类

静态方法中的this

实例方法中的this指向的是当前对象

而静态方法中的this指向的是当前类

扩展设计模式之「单例模式」

中国象棋,棋盘可以是个类,它有宽度高度,初始化等属性和方法,创建棋盘的时候new Board(),初始化的时候有需要创建一个new Board(),如何避免去创建一些没有意义的对象,可以使用单例模式,把构造函数私有化,只能在类的内部创建对象。

定义私有的静态的棋·_board,也是唯一的棋盘, 在内部定义一个创建棋盘的方法createBoard(),内部判断是否有值,有值的话直接返回,没有值的话新建一个对象并返回。

class Board {
    width: number = 500;
    height: number = 700;

    init() {
        console.log("初始化棋盘");
    }

    private constructor() { }
    
    // 定义私有的静态的棋盘,也是唯一的棋盘
    private static _board;

    static createBoard(): Board {
        // 静态方法中的this指向的是Board类
        if (this._board) {
            return this._board;
        }
        this._board = new Board();
        return this._board;
    }
}
const b1 = Board.createBoard();
const b2 = Board.createBoard();
console.log(b1 === b2);  // true

单例模式:某些类的对象,在系统中最多只能有一个,为了避免开发者造成随意创建多个类对象的错误,可以使用单例模式进行强约束。

接口

接口用于约束类、对象、函数,是一个类型契约。

有一个马戏团,马戏团中有很多动物,包括:狮子、老虎、猴子、狗,这些动物都具有共同的特征:名字、年龄、种类名称,还包含一个共同的方法:打招呼,它们各自有各自的技能,技能是可以通过训练改变的。狮子和老虎能进行火圈表演,猴子能进行平衡表演,狗能进行智慧表演

马戏团中有以下常见的技能:

  • 火圈表演:单火圈、双火圈
  • 平衡表演:独木桥、走钢丝
  • 智慧表演:算术题、跳舞

首先创建动物类:

export abstract class Animal {
  abstract type: string;

  constructor(
    public name: string,
    public age: number
  ) {}
 
  sayHello() { 
    console.log(`各位观众,大家好,我是${this.type},今年${this.age}岁了!`)
  }
}

export class Lion extends Animal{
  type: string = '狮子'

  singeFire() { 
    console.log(`${this.name}表演了单火圈!`)
  }
  doubleFire() { 
    console.log(`${this.name}表演了双火圈!`)
  }
}

export class Tiger extends Animal{
  type: string = '老虎'
  singeFire() { 
    console.log(`${this.name}表演了单火圈!`)
  }
  doubleFire() { 
    console.log(`${this.name}表演了双火圈!`)
  }
}

export class Monkey extends Animal{
  type: string = '猴子'

  dumuqiao() { 
    console.log(`${this.name}表演走独木桥!`)
  }
}

export class Dog extends Animal{
  type: string = '狗'
}

引用:

import { Animal, Lion, Tiger, Monkey, Dog } from "./animals";

const animals: Animal[] = [
  new Lion('小狮子', 2),
  new Tiger('小老虎', 3),
  new Monkey('小猴子', 4),
  new Dog('小狗', 2),
]
// 1. 所以的动物打招呼
console.log('====================================');
console.log('1. 所以的动物打招呼');
animals.forEach(element => { 
  element.sayHello()
})
console.log('====================================');


// 2. 所有会进行火圈表演的动物完成火圈表演
console.log('====================================');
console.log('2. 所有会进行火圈表演的动物完成火圈表演');
/**
 * 存在隐患,变相变成判断狮子老虎了
 * 后续如果狮子老虎如果不具有火圈表演的能力,而猴子具有火圈表演,狮子老虎类中没有对应的函数支持,导致问题
 * 判断能力变成了判断类型,容易将类型和能力耦合在一起 
 */
animals.forEach(element => { 
  if (element instanceof Lion || element instanceof Tiger) { 
    element.singeFire()
    element.doubleFire()
  }
})
console.log('====================================');

在上面的第二点中,存在隐患,变相变成判断狮子老虎了,后续如果狮子老虎如果不具有火圈表演的能力,而猴子具有火圈表演,狮子老虎类中没有对应的函数支持,导致问题,判断能力变成了判断类型,容易将类型和能力耦合在一起,不适用接口实现时:

  • 对能力(成员函数)没有强约束力
  • 容易将类型和能力耦合在一起

系统中缺少对能力的定义 —— 接口,新增interface.ts对动物的能力做定义

// 火圈能力
export interface IFireshow { 
  singleFire(): void;
  doubleFire(): void;
}
// 智慧能力
export interface IWisdomShow{
  suanshuti(): void;
  dance(): void;
}
// 表演能力
export interface IBalanceShow{
  dumuqiao(): void;
  zougangsi(): void;
}

在继承类后使用implements:实现 了某某具体的能力,实现了接口意味着类方法必须有对应函数,不然会有报错,实现了成员的强约束力

import { IBalanceShow, IFireshow, IWisdomShow } from './interface';

export abstract class Animal {
 ... ...
}

export class Lion extends Animal implements IFireshow{
  type: string = '狮子'

  singleFire() { 
    console.log(`${this.name}表演了单火圈!`)
  }
  doubleFire() { 
    console.log(`${this.name}表演了双火圈!`)
  }
}

export class Tiger extends Animal implements IFireshow{
  type: string = '老虎'
  singleFire() { 
    console.log(`${this.name}表演了单火圈!`)
  }
  doubleFire() { 
    console.log(`${this.name}表演了双火圈!`)
  }
}

export class Monkey extends Animal implements IBalanceShow{
  type: string = '猴子'
  dumuqiao() { 
    console.log(`${this.name}表演走独木桥!`)
  }
  zougangsi(): void {
    console.log(`${this.name}表演走钢丝!`)
  }
}

export class Dog extends Animal implements IWisdomShow{
  suanshuti(): void {
    console.log(`${this.name}表演算术题!`)
  }
  dance(): void {
    console.log(`${this.name}表演跳舞!`)
  }
  type: string = '狗'
}

面向对象领域中的接口的语义:表达了某个类是否拥有某种能力

某个类具有某种能力,其实,就是实现了某种接口

如何具体的去判断某个类是否具有接口的能力,比如刚刚的第二点,在Java中可用 element instanceof IFireShow来判断, 但是是在运行过程中,TS中不可以,只能使用类型保护函数来实现

// 类型保护函数
function hasFireShow(ani: object): ani is IFireshow { 
  if ((ani as unknown as IFireshow).singleFire && (ani as unknown as IFireshow).doubleFire) {
    return true
  }
  return false
}

animals.forEach(element => { 
  if (hasFireShow(element)) { 
    element.singleFire()
    element.doubleFire()
  }
})

运行结果:

image.png

类型保护函数:通过调用该函数,会触发TS的类型保护,该函数必须返回boolean

给老虎移除火圈表演的能力,猴子培养火圈表演的能力, implements 可以实现多个接口的能力:

export class Monkey extends Animal implements IBalanceShow,IFireshow{
  type: string = '猴子'

  dumuqiao() { 
    console.log(`${this.name}表演走独木桥!`)
  }
  zougangsi(): void {
    console.log(`${this.name}表演走钢丝!`)
  }
  singleFire() { 
    console.log(`${this.name}表演了单火圈!`)
  }
  doubleFire() { 
    console.log(`${this.name}表演了双火圈!`)
  }
}

image.png

接口和类型别名的区别

接口和类型别名的最大区别:接口可以被类实现,而类型别名不可以

扩展: 接口可以继承类,表示该类的所有成员都在接口中。

class A {
    a1: string = ""
    a2: string = ""
    a3: string = ""
}
class B {
    b1: number = 0;
    b2: number = 0;
    b3: number = 0;
}
// 接口可以继承类
interface C extends A, B { }
const c: C = {
    a1: "",
    a2: "",
    a3: "",
    b1: 0,
    b2: 3,
    b3: 4
}

索引器

索引器: 对象[值],使用成员表达式

export class User { 
  public name: string = 'xiaohua'
  public age: number = 18
}

const u = new User()
console.log(u['pid']) // undefined

在TS中,默认情况下,不对索引器(成员表达式)做严格的类型检查

tsconfig.json使用配置noImplicitAny开启对隐式any的检查。

image.png

隐式any:TS根据实际情况推导出的any类型

在索引器中,键的类型可以是字符串,也可以是数字

在类中,索引器书写的位置应该是所有成员之前

export class User { 
  [prop: string]: string | number | {(): void} // 对类中所有成员也进行了限制
  public name: string = 'xiaohua'
  public age: number = 18

  sayHello() { }
}

const u = new User()
u["sayHello"]
console.log(u['pid']) 

TS中索引器的作用

  • 在严格的检查下,可以实现为类动态增加成员
  • 可以实现动态的操作类成员

在JS中,所有的成员名本质上,都是字符串,如果使用数字作为成员名,会自动转换为字符串。

在TS中,如果某个类中使用了两种类型的索引器,要求两种索引器的值类型必须匹配

this指向约束

参考this指向

在JS中this指向的几种情况

在JS大部分时候,this的指向取决于函数的调用方式

  • 如果直接调用函数(全局调用),this指向全局对象或undefined (启用严格模式)
  • 如果使用对象.方法调用,this指向对象本身
  • 如果是dom事件的处理函数,this指向事件处理对象

特殊情况:

  • 箭头函数,this在函数声明时确定指向,指向函数位置的this

  • 使用bind、apply、call手动绑定this对象

TS中的this

tsconfig.json配置noImplicitThis:true,表示不允许this隐式的指向any

class User { 
  constructor(
    public name: string,
    public age: number
  ) { }

  sayHello(this: User) { 
    console.log(this, this.name, this.age)
  }
}

const u = new User('huahua', 18);
const say = u.sayHello

在TS中,允许在书写函数时,手动声明该函数中this的指向,将this作为函数的第一个参数,该参数只用于约束this,并不是真正的参数,也不会出现在编译结果中。