本文整理来自深入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擦除操作的意义。