TypeScript(四)类、接口

本文整理来自深入Vue3+TypeScript技术栈-coderwhy大神新课,只作为个人笔记记录使用,请大家多支持王红元老师。

认识类的使用

在早期的JavaScript开发中(ES5)我们需要通过函数和原型链来实现类和继承,从ES6开始,引入了class关键字,可以更加方便的定义和使用类。TypeScript作为JavaScript的超集,也是支持使用class关键字的,并且还可以对类的属性和方法等进行静态类型检测。

实际上在JavaScript的开发过程中,我们更加习惯于函数式编程,比如React开发中,目前更多使用的函数组件以及结合Hook的开发模式,比如在Vue3开发中,目前也更加推崇使用 Composition API,它们都是偏向于函数式编程。但是在封装某些业务的时候,类具有更强大的封装性,所以我们也需要掌握它们。

类的定义我们通常会使用class关键字,在面向对象的世界里,任何事物都可以使用类的结构来描述,类中包含特有的属性和方法。

类的定义

我们使用class关键字来定义一个Person类,在类的内部声明类的属性以及对应的类型,如果类型没有声明,那么它们默认是any的,我们也可以给属性设置初始化值。类中可以有自己的函数,定义的函数称之为方法。

class Person {
  name: string
  age: number

  eating() {
    console.log(this.name + " eating")
  }
}

//会报错,因为类的属性没初始化
const p = new Person()

上面代码会报错,因为在默认的strictPropertyInitialization模式下面我们的属性是必须初始化的,如果没有初始化,那么编译时就会报错。我们可以给属性设置默认值,如下:

class Person {
  name: string = ""
  age: number = 0

  eating() {
    console.log(this.name + " eating")
  }
}

//不会报错
const p = new Person()

除了设置默认值,我们还可以使用构造函数constructor。当我们通过new关键字创建一个实例时,构造函数会被调用,构造函数不需要返回任何值,默认返回当前创建出来的实例。下面的构造函数要求new实例的时候传入两个参数,这样name和age都会被赋值,就不会报错了。

class Person {
  name: string
  age: number

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

  eating() {
    console.log(this.name + " eating")
  }
}

const p = new Person("why", 18)
console.log(p.name)
console.log(p.age)
p.eating()

如果我们在strictPropertyInitialization模式下确实不希望给属性初始化,可以使用 name!: string 语法。

类的继承

面向对象的三大特性:封装,继承,多态。

面向对象的其中一大特性就是继承,继承不仅仅可以减少我们的代码量,也是多态的使用前提。我们使用extends关键字来实现继承,子类中使用super来访问父类。

我们来看一下Student类继承自Person,Student类可以有自己的属性和方法,并且会继承Person的属性和方法。

class Person {
  // 设置默认值
  name: string = ""
  age: number = 0

  eating() {
    console.log("eating")
  }
}

// 继承
class Student extends Person {
  sno: number = 0

  studying() {
    console.log("studying")
  }
}

// 继承
class Teacher extends Person {
  title: string = ""

  teaching() {
    console.log("teaching")
  }
}

const stu = new Student()
stu.name = "coderwhy"
stu.age = 10
console.log(stu.name)
console.log(stu.age)
stu.eating()

上面代码,我们是给属性设置默认值了,如果不设置默认值,我们可以在构造函数中,通过super来调用父类的构造方法,对父类中的属性进行初始化,然后再初始化自己的属性。

class Person {
  name: string
  age: number

  // 父类的构造器
  constructor(name: string, age: number) {
    this.name = name
    this.age = age
  }

  eating() {
    console.log("eating 100行")
  }
}

class Student extends Person {
  sno: number

  // 子类的构造器
  constructor(name: string, age: number, sno: number) {
    // super调用父类的构造器
    super(name, age)
    this.sno = sno
  }

  // 子类重写父类的方法,和以前说的重载不是一个概念
  eating() {
    console.log("student eating")
    // 子类调用父类的方法,当然也可以不调用
    super.eating()
  }

  studying() {
    console.log("studying")
  }
}

const stu = new Student("why", 18, 111)
console.log(stu.name)
console.log(stu.age)
console.log(stu.sno)

// 优先调用子类的eating方法,子类没有的话再去父类找
stu.eating()

多态

面向对象的三大特性:封装,继承,多态。

多态的前提是继承,多态的代码体现就是父类指针指向子类对象,多态的目的是为了写出更加具备通用性的代码。

class Animal {
  action() {
    console.log("animal action")
  }
}

class Dog extends Animal {
  // 重写父类方法
  action() {
    console.log("dog running")
  }
}

class Fish extends Animal {
  // 重写父类方法
  action() {
    console.log("fish swimming")
  }
}

class Person extends Animal {

}

// animal: dog/fish
// 多态的目的是为了写出更加具备通用性的代码
function makeActions(animals: Animal[]) {
  animals.forEach(animal => {
    animal.action()
  })
}

// 传入三个对象数组
// 父类指针指向子类对象
makeActions([new Dog(), new Fish(), new Person()])
//最后打印: "dog running"  "fish swimming"  "animal action"

类的成员修饰符

在TypeScript中,类的属性和方法支持三种修饰符:public、protected、private。

  • public 修饰的是在任何地方可见,公有的属性或方法,默认编写的属性就是public的;
  • protected 修饰的是仅在类自身及子类中可见,是受保护的属性或方法;
  • private 修饰的是仅在同一类中可见,是私有的属性或方法;

private演示:

class Person {
  // 私有的, 只有在当前类内部进行访问
  private name: string = ""

  // 封装了两个方法, 通过方法来访问name
  getName() {
    return this.name
  }
  setName(newName) {
    this.name = newName
  }
}

const p = new Person()

// 报错
// const name = p.name

// 如果我们实在想访问name, 可以在类里面封装两个方法, 通过方法来访问name
p.setName("why")
console.log(p.getName()) // why

protected演示:

class Person {
  // 受保护的, 在类内部和子类中可以访问
  protected name: string = "123"
}

class Student extends Person {
  getName() {
    // 子类可以访问
    return this.name
  }
}

const stu = new Student()
// 封装一个方法, 用于在外面访问
console.log(stu.getName()) // 123

只读属性readonly

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

class Person {
  readonly name: string
  age?: number
  readonly friend?: Person
  constructor(name: string, friend?: Person) {
    // 1.只读属性是可以在构造器中赋值, 赋值之后就不可以修改
    this.name = name
    this.friend = friend
  }
}

const p = new Person("why", new Person("kobe"))
console.log(p.name)
console.log(p.friend)

// 2.只读属性本身不能进行修改, 但是如果它是对象类型, 对象中的属性是可以修改
// p.friend = new Person("james")
if (p.friend) {
  p.friend.age = 30
}

// 只读属性不能修改
// p.name = "123"

getters/setters

前面我们是添加了getName和setName方法用来访问私有属性,这些添加方法的操作其实是java遗传过来的。在TypeScript中,类似私有属性的访问,或者某些属性我们想要监听它的获取(getter)和设置(setter)的过程,这个时候我们可以使用访问器。

class Person {
  private _name: string
  constructor(name: string) {
    // 规范:一般私有属性,我们都以_开头
    this._name = name
  }

  // setter访问器
  set name(newName) {
    this._name = newName
  }
  // getter访问器
  get name() {
    return this._name
  }
}

const p = new Person("why")
p.name = "coderwhy"
console.log(p.name)

类的静态成员/方法

前面我们在类中定义的成员和方法都属于对象级别的,在开发中,我们有时候也需要定义类级别的成员和方法,在TypeScript中通过关键字static来定义。

抽象类abstract

我们知道,继承是多态使用的前提。所以在定义很多通用的调用接口时,我们通常会让调用者传入父类,通过多态来实现更加灵活的调用方式。但是,父类本身可能并不需要对某些方法进行具体的实现,这时候父类中定义的方法,我们可以定义为抽象方法。

在TypeScript中没有具体实现的方法(没有方法体),就是抽象方法。抽象方法,必须存在于抽象类中,抽象类是使用abstract声明的类。

抽象类有如下的特点:

  • 抽象类是不能被实例化(也就是不能通过new创建)
  • 抽象方法必须被子类实现,否则该类必须是一个抽象类;
// 传入一个继承Shape的类
function makeArea(shape: Shape) {
  // 继承Shape的类必有getArea方法,调用getArea方法,获取面积
  return shape.getArea()
}

// 定义抽象类
abstract class Shape {
  // 定义抽象方法
  abstract getArea(): number
}

// 继承抽象类
class Rectangle extends Shape {
  private width: number
  private height: number

  constructor(width: number, height: number) {
    super()
    this.width = width
    this.height = height
  }

  // 实现抽象类的方法
  getArea() {
    return this.width * this.height
  }
}

// 继承抽象类
class Circle extends Shape {
  private r: number

  constructor(r: number) {
    super()
    this.r = r
  }

  // 实现抽象类的方法
  getArea() {
    return this.r * this.r * 3.14
  }
}

const rectangle = new Rectangle(20, 30)
const circle = new Circle(10)

console.log(makeArea(rectangle))
console.log(makeArea(circle))
// makeArea(new Shape())
// makeArea(123)
// makeArea("123")

如果抽象类中的某个方法没有实现体,那么这个方法必须加abstract,如果这个方法有实现体,那么不用加abstract。

类的类型

类本身也是可以作为一种数据类型的:

接口

在前面我们通过type可以用来声明一个对象类型:

对象的另外一种声明方式就是通过接口来声明:

他们在使用上的区别,我们后续再来说明,接下来我们继续学习一下接口的其他特性。

接口中我们可以定义可选类型也可以定义只读属性。还有一个小规范,定义接口的时候一般我们会在接口名前面加上一个I,比如IInfoType。

interface IInfoType {
  // 定义只读属性
  readonly name: string
  age: number
  // 定义可选类型
  friend?: {
    name: string
  }
}

const info: IInfoType = {
  name: "why",
  age: 18,
  friend: {
    name: "kobe"
  }
}

console.log(info.friend?.name)
console.log(info.name)
// info.name = "123"
info.age = 20

接口的索引类型

如果我们有一个对象,要求key是数字,value是字符串,那么我们就可以定义一个索引类型,这时候如果对象不是这个格式的就会报错,如下:

interface IndexLanguage {
  [index: number]: string
}

const frontLanguage: IndexLanguage = {
  0: "HTML",
  1: "CSS",
  2: "JavaScript",
  3: "Vue"
}

如果有一个属性是必须有的,那么我们可以直接写出来,如下,必须有个Java,并且值是number。

interface ILanguageYear {
  [name: string]: number
  Java: number
}

const languageYear: ILanguageYear = {
  "Java": 1995,
  "C": 1972,
  "JavaScript": 1996,
  "TypeScript": 2014
}

函数类型

前面我们都是通过interface来定义对象中普通的属性和方法的,实际上它也可以用来定义函数类型。

当然,除非特别的情况,还是推荐大家使用类型别名来定义函数:

接口继承

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

交叉类型

前面我们学习了联合类型,联合类型表示多个类型中一个即可。

还有另外一种类型合并,就是交叉类型(Intersection Types),交叉类似表示需要满足多个类型的条件,交叉类型使用 & 符号。

我们来看下面的交叉类型:

上面表达的含义是number和string要同时满足,但是有同时满足是一个number又是一个string的值吗?其实是没有的,所以MyType其实是一个never类型。

所以,在开发中,我们进行交叉时,通常是对对象类型进行交叉的:

interface ISwim {
  swimming: () => void
}

interface IFly {
  flying: () => void
}

type MyType1 = ISwim | IFly
type MyType2 = ISwim & IFly

const obj1: MyType1 = {
  flying() {

  }
}

const obj2: MyType2 = {
  swimming() {

  },
  flying() {
    
  }
}

接口的实现

接口定义后,也是可以被类实现的,如果被一个类实现,那么在之后需要传入接口的地方,都可以将这个类传入,这就是面向接口开发,有点像swift的面向协议开发。

interface ISwim {
  swimming: () => void
}

interface IEat {
  eating: () => void
}

// 父类
class Animal {
  
}

// 继承: 只能实现单继承
// 实现: 实现接口, 类可以实现多个接口
class Fish extends Animal implements ISwim, IEat {
  swimming() {
    console.log("Fish Swmming")
  }

  eating() {
    console.log("Fish Eating")
  }
}

class Person implements ISwim {
  swimming() {
    console.log("Person Swimming")
  }
}

// 编写一些公共的API: 面向接口编程
function swimAction(swimable: ISwim) {
  swimable.swimming()
}

// 所有实现了接口的类对应的对象, 都是可以传入
swimAction(new Fish())
swimAction(new Person())
// 也可以
swimAction({swimming: function() {}})

interface和type区别

上面我们说了,interface和type都可以用来定义对象类型,那么在开发中定义对象类型时,到底选择哪一个呢?

  • 如果是定义非对象类型,通常推荐使用type,比如Direction、Alignment、一些Function。
  • 如果是定义对象类型,那么他们是有区别的:interface 可以重复的对某个接口来定义属性和方法,而且最后的结果是合并到一起。而type定义的是别名,别名是不能重复的。

如果我们想给Window添加一个age属性,可以通过interface实现,但是通过type实现就会报错。

interface Window {
  age: number
}
window.age = 19
console.log(window.age)

字面量赋值和freshness擦除操作

我们来看下面的代码,会报错。

这是因为TypeScript在字面量直接赋值的过程中,为了进行类型推导会进行严格的类型限制。比如根据右边的字面量推导出p是有name、age和eating的对象,但是我们写的类型注解只有name和eating,显然会报错。

但是下面的代码就不会报错。

这是因为,如果我们是将一个变量标识符赋值给其他的变量时,会进行freshness擦除操作。啥意思呢,就是把obj赋值给p的时候会先看看有没有name和eating,其他的直接忽视,显然满足,所以赋值成功。当然因为age被擦除了,所以我们访问p.age肯定会报错。

那么开发中有什么用呢?

比如我们想打印一个人的信息,这个人需要有name和age,当我们直接传入一个字面量,类型检测肯定不通过,代码报错:

interface IPerson {
  name: string
  age: number
}

function printInfo(person: IPerson) {
  console.log(person)
}

// 代码会报错
printInfo({
  name: "why",
  age: 18,
  address: "广州市"  // 报错
})

如果我们传入一个标识符,不需要的信息会被擦除,所以下面代码不会报错,当然因为被擦除了,我们也是无法访问info.address的。

const info = {
  name: "why",
  age: 18,
  address: "广州市"
}

printInfo(info)

实际开发中我们人的信息可能不止name和age,正因为有类型擦除,我们把具有其他属性的人传进去也是可以的,这就让我们的编码更灵活,这就是freshness擦除操作的意义。

你可能感兴趣的:(TypeScript(四)类、接口)