TypeScript类(class)总结

980 阅读9分钟

我在上一篇文章中介绍了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()

从上面的例子,我们可以看出一个类有以下几个特点:

  1. 通过关键词class定义,语法为:class className {} ;
  2. 一个class一般具有三种不同类型的元素:属性、构造函数、方法,其中构造函数只有一个 ;
  3. 属性必须被初始化,要么在定义的时候初始化,要么在构造函数中初始化;
  4. 类需要通过关键词new来实例化,实例化后得到一个类的具体实现——类的对象,类的对象可以通过.语法访问类的属性和方法(.又被称为成员访问符);
  5. 构造函数函数会在类被实例化的时候被调用一次;

类的继承

继承是类的一大特性,子类可以继承父类的属性和方法。被继承的类被称为基类,也被称为超类父类,实现继承的类被称为派生类,也被称为子类。下面是一个类的继承的例子:

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())。

除此之外,深入到代码层次,我们来探究一下有哪些需要注意点的点:

  1. 派生类(子类)的构造函数中第一行调用了一个super方法并传入了参数name,这里的super(name)实际含义是调用了父类的构造函数,我们知道父类的构造函数需要传入一个参数用来为基类的属性name初始化,所以这里要传入这个参数;
  2. 派生类(子类)构造函数里传入了2个参数,所以这里要注意传入参数的顺序要与构造函数函数的顺序保持一致,TypeScript会检查传入参数的类型与构造函数是否一致,但是并不能判断我们传入的值具体是什么含义,所以要注意实例化类的时候要保证传入参数的顺序与构造函数一致;
  3. TypeScript中派生类(子类)约定一定要在构造函数里执行super()
  4. 派生类(子类)虽然继承了基类(父类)的属性和方法,但是同时也定义了一个同名的方法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实际上是类的成员属性,也需要在构造函数中初始化;

仔细阅读上方的例子后,我们可以看到:

  1. 抽象类定义的时候需要使用关键词abstract,它需要定义在关键词class的前面声明当前的类是抽象类;
  2. 同时,上面的抽象类还定义了一个方法printMeeting,它的最前面也有关键词abstractprintMeeting只包含方法的签名,不包含具体实现,这个方法被称为抽象方法;
  3. 抽象方法在派生类(子类)中必须实现,而定义一个方法为抽象方法必须使用关键词abstract
  4. 和继承普通类一样,在构造函数中一样需要调用super()方法来调用基类(父类)的构造函数;

类的修饰符(public、protected、private)

类的成员包括2种:属性和方法,也可以成为成员属性。成员方法。它们都可以用public、protected、private来修饰,这3个修饰符有各自的特点。TypeScript的类中所有成员在不指定修饰符的情况下都是默认被关键词public修饰的,所以在TypeScript中不需要显式地用public来修饰类的成员。 而使用protected修饰的成员则受到了一定的限制,这些成员只能在当前类或者它的派生类中被使用,不能被实例化后使用; 而使用private修饰的成员则受到了更多的限制,这些成员只能在当前类中被使用,不能被派生类和实例化后的对象中被使用;

public protected private
默认,不需要显示声明 需要显示声明 需要显示声明
可用范围:当前类、派生类、当前类对象、派生类对象 可用范围:当前类、派生类 可用范围:当前类

根据我上面总结的几个修饰符的特性,开发者可以根据自己的需要选择性地使用。

  1. 抽象类定义的时候需要使用关键词abstract,它需要定义在关键词class的前面声明当前的类是抽象类;
  2. 同时,上面的抽象类还定义了一个方法printMeeting,它的最前面也有关键词abstractprintMeeting只包含方法的签名,不包含具体实现,这个方法被称为抽象方法;
  3. 抽象方法在派生类(子类)中必须实现,而定义一个方法为抽象方法必须使用关键词abstract
  4. 和继承普通类一样,在构造函数中一样需要调用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 存取器可以让类的实例很方便的访问类的成员属性。