我在上一篇文章中介绍了TypeScript的接口,这一篇我们介绍TypeScript的类(Class)。
在es6之前,在原生的js语法中并没有class这个语法,但是在原生js里早就有了对象这个概念。最简单的实现一个对象的方法是通过对象字面量表示法:
let obj = {}
一个对象可能包含任意多的属性和方法,而类是对指定对象的抽象,对象是类的实例化。举个简单的例子,如果家猫是一个类,那么汤姆就是一个具体的对象;汤姆是家猫,但是家猫可不只有汤姆。 我想对于许多接触过面向对象编程思想的读者而言,类的概念已经深入人心,尤其是对于有过java编程经验的读者而言。所以在这里我就不再赘述,以免贻笑大方。
基本介绍
TypeScript中的类的概念与其他编程语言中类的概念不尽相同,但又有自己的特点,所以在此处还是容我一一介绍在TypeScript中的类。
首先,我来定义一个最简单的类:
class Animal {
name: string
constructor(name: string) {
console.log(name)
this.name = name
}
run() {
console.log('running')
}
}
let aAnimal = new Animal('一种动物') // 打印: '一种动物'
aAnimal.run()
从上面的例子,我们可以看出一个类有以下几个特点:
- 通过关键词
class定义,语法为:class className {} ; - 一个class一般具有三种不同类型的元素:属性、构造函数、方法,其中构造函数只有一个 ;
- 属性必须被初始化,要么在定义的时候初始化,要么在构造函数中初始化;
- 类需要通过关键词
new来实例化,实例化后得到一个类的具体实现——类的对象,类的对象可以通过.语法访问类的属性和方法(.又被称为成员访问符); - 构造函数函数会在类被实例化的时候被调用一次;
类的继承
继承是类的一大特性,子类可以继承父类的属性和方法。被继承的类被称为基类,也被称为超类、父类,实现继承的类被称为派生类,也被称为子类。下面是一个类的继承的例子:
class Animal {
name: string
constructor(name: string) {
console.log(name)
this.name = name
}
run() {
console.log('running')
}
}
class Dog extends Animal {
owner: string
constructor(name: string, owner: string) {
super(name)
this.owner = owner
}
run() {
console.log(`${this.name} is running`)
}
bark() {
console.log('汪汪汪……')
}
}
let myDog= new Dog('斯派克', 'a woman') // 输出 '斯派克'
console.log(`dog's name is ${myDog.name}`) // 输出 'dog's name is 斯派克'
myDog.run() // 输出 '斯派克 is running'
myDog.bark() // 输出 '汪汪汪……'
通过上面的例子,我们可以看出类的继承需要通过关键词extends来实现,extends后面是被继承的类的名称 。同时上面的例子中,在派生类(子类)中调用了不属于它本身而是从父类继承的的属性(name)和方法(run())。
除此之外,深入到代码层次,我们来探究一下有哪些需要注意点的点:
- 派生类(子类)的构造函数中第一行调用了一个
super方法并传入了参数name,这里的super(name)实际含义是调用了父类的构造函数,我们知道父类的构造函数需要传入一个参数用来为基类的属性name初始化,所以这里要传入这个参数; - 派生类(子类)构造函数里传入了2个参数,所以这里要注意传入参数的顺序要与构造函数函数的顺序保持一致,
TypeScript会检查传入参数的类型与构造函数是否一致,但是并不能判断我们传入的值具体是什么含义,所以要注意实例化类的时候要保证传入参数的顺序与构造函数一致; - 在
TypeScript中派生类(子类)约定一定要在构造函数里执行super(); - 派生类(子类)虽然继承了基类(父类)的属性和方法,但是同时也定义了一个同名的方法
run(),那么派生类(子类)定义的这个方法会覆盖它从基类(父类)继承的方法,这个叫做重写,这意味着派生类(子类)也可以覆盖从父类继承的属性和方法;派生类(子类)覆盖基类(父类)的属性和方法也表明,不同的派生类可以拥有独特的属性和方法,即便是同样继承自基类(父类)的属性和方法也可以有不同的实现,这意味着基类(父类)应当进一步抽象出公有的特性;
我们知道类也可以实现接口,接口中只定义接口成员不包含具体实现;而基类(父类)中包含了成员的具体实现,还有一种类,它的成员既可以具体实现也可以只包含成员定义,这种类就是抽象类。
| 类(class) | 接口(interface) | 抽象类(abstract class) |
|---|---|---|
| 成员都必须实现 | 成员都只定义,不能实现 | 成员可以实现也可以只定义不实现 |
| 关键词:extends | 关键词:implements | 关键词:extends |
可以看到这3种方式有各自的特点,需要开发者根据具体场景去选择使用哪种方式。接口我在上一篇文章中已经讲过就不在赘述,这里我来讲一下抽象类(abstract class)的语法。我举例说明:
abstract class Department {
constructor(public name: string) {
}
printName(): void {
console.log('Department name: ' + this.name);
}
abstract printMeeting(): void; // 必须在派生类中实现
}
class AccountingDepartment extends Department {
constructor() {
super('Accounting and Auditing'); // 在派生类的构造函数中必须调用 super()
}
printMeeting(): void {
console.log('The Accounting Department meets each Monday at 10am.');
}
generateReports(): void {
console.log('Generating accounting reports...');
}
}
let department: Department; // 允许创建一个对抽象类型的引用
department = new Department(); // 错误: 不能创建一个抽象类的实例
department = new AccountingDepartment(); // 允许对一个抽象子类进行实例化和赋值
department.printName();
department.printMeeting();
department.generateReports(); // 错误: 方法在声明的抽象类中不存在
注意:抽象类Department的构造函数的参数列表中的public name: string,实际上是一种简写形式,name实际上是类的成员属性,也需要在构造函数中初始化;
仔细阅读上方的例子后,我们可以看到:
- 抽象类定义的时候需要使用关键词
abstract,它需要定义在关键词class的前面声明当前的类是抽象类; - 同时,上面的抽象类还定义了一个方法
printMeeting,它的最前面也有关键词abstract,printMeeting只包含方法的签名,不包含具体实现,这个方法被称为抽象方法; - 抽象方法在派生类(子类)中必须实现,而定义一个方法为抽象方法必须使用关键词
abstract; - 和继承普通类一样,在构造函数中一样需要调用
super()方法来调用基类(父类)的构造函数;
类的修饰符(public、protected、private)
类的成员包括2种:属性和方法,也可以成为成员属性。成员方法。它们都可以用public、protected、private来修饰,这3个修饰符有各自的特点。TypeScript的类中所有成员在不指定修饰符的情况下都是默认被关键词public修饰的,所以在TypeScript中不需要显式地用public来修饰类的成员。
而使用protected修饰的成员则受到了一定的限制,这些成员只能在当前类或者它的派生类中被使用,不能被实例化后使用;
而使用private修饰的成员则受到了更多的限制,这些成员只能在当前类中被使用,不能被派生类和实例化后的对象中被使用;
| public | protected | private |
|---|---|---|
| 默认,不需要显示声明 | 需要显示声明 | 需要显示声明 |
| 可用范围:当前类、派生类、当前类对象、派生类对象 | 可用范围:当前类、派生类 | 可用范围:当前类 |
根据我上面总结的几个修饰符的特性,开发者可以根据自己的需要选择性地使用。
- 抽象类定义的时候需要使用关键词
abstract,它需要定义在关键词class的前面声明当前的类是抽象类; - 同时,上面的抽象类还定义了一个方法
printMeeting,它的最前面也有关键词abstract,printMeeting只包含方法的签名,不包含具体实现,这个方法被称为抽象方法; - 抽象方法在派生类(子类)中必须实现,而定义一个方法为抽象方法必须使用关键词
abstract; - 和继承普通类一样,在构造函数中一样需要调用
super()方法来调用基类(父类)的构造函数;
readonly修饰符
readonly修饰符只能用于成员属性,用它修饰的成员属性在被赋予初始值后就不能再被赋予新的值,不再允许被写入。
static修饰符
使用static修饰符修饰的成员(可以修饰成员属性,也可以用来修饰成员方法),只能通过类名来访问,不能通过this来访问。请看下方的例子:
class Snack {
static scientificName = '蛇'
constructor () {
// console.log(this.scientificName) // 报错:scientificName只能通过类名访问
// this.run() // 报错:run() 只能通过类名访问
}
static run () {
console.log('snack is running')
}
}
let snack = new Snack()
// snack.scientificName // 报错:只能通过类名访问
// snack.run() // 报错:只能通过类名访问
类的存取器(get / set)
TypeScript中类也支持通过getters/setters来截取对对象成员的访问。这里我引用TypeScipt官方网站的例子来说明:
let passcode = "secret passcode";
class Employee {
private _fullName: string;
get fullName(): string {
return this._fullName;
}
set fullName(newName: string) {
if (passcode && passcode == "secret passcode") {
this._fullName = newName;
}
else {
console.log("Error: Unauthorized update of employee!");
}
}
}
let employee = new Employee();
employee.fullName = "Bob Smith";
if (employee.fullName) {
alert(employee.fullName);
}
上面的例子中,_fullName是私有成员属性,只能通过类名直接访问;而通过访问器属性提供了方法去访问和修改这里的私有属性,这里避免了直接访问类的成员属性,同时可以在方法体内部实现一定的属性访问控制逻辑,保证属性的安全性。 上面的说法结合实际的业务逻辑才更具有说服力,但仅仅就编写代码的层次来说,通过getter / setter 存取器可以让类的实例很方便的访问类的成员属性。