在JavaScript(ES5)中仅支持通过函数和原型链继承模拟类的实现(用于抽象业务模型、组织数据结构并创建可重用组件),自 ES6 引入 class 关键字后,它才开始支持使用与 Java 类似的语法定义声明类。
TypeScript 作为 JavaScript 的超集,自然也支持 class 的全部特性,并且还可以对类的属性、方法等进行静态类型检测。
类
在实际业务中,任何实体都可以被抽象为一个使用类表达的类似对象的数据结构,且这个数据结构既包含属性,又包含方法,比如我们在下方抽象了一个狗的类。
class Dog {
name: string;
constructor(name: string) {
this.name = name;
}
bark() {
console.log('汪!汪!汪!');
}
}
const dog = new Dog('QQ');
dog.bark(); // 汪!汪!汪!
首先,我们定义了一个 class Dog ,它拥有 string 类型的 name 属性、bark 方法和一个构造器函数。然后,我们通过 new 关键字创建了一个 Dog 的实例,并把实例赋值给变量 dog。最后,我们通过实例调用了类中定义的 bark 方法。
继承
在 TypeScript 中,使用 extends 关键字就能很方便地定义类继承的抽象模式,如下代码所示:
class Animal {
type = 'Animal';
say(name: string) {
console.log(`我是 ${name}`);
}
}
class Dog extends Animal {
bark() {
console.log('汪!汪!汪!');
}
}
const dog = new Dog();
dog.bark(); // 汪!汪!汪!
dog.say('QQ'); // 我是QQ
console.log(dog.type) // Animal
上面的例子展示了类最基本的继承用法。比如代码中定义的 Dog
是派生类,它派生自第 之前定义的 Animal
基类,此时 Dog
实例继承了基类 Anima
l的属性和方法。因此,我们可以看到实例 dog 支持 bark、say、type 等属性和方法。
说明:派生类通常被称作子类,基类也被称作超类(或者父类)。
细心的你可能发现了,这里的 Dog 基类与第一个例子中的类相比,少了一个构造函数。这是因为派生类如果包含一个构造函数,则必须在构造函数中调用 super() 方法,这是 TypeScript 强制执行的一条重要规则。
如下示例,因为先定义的 Dog 类构造函数中没有调用 super 方法,所以提示了一个 ts(2377) 的错误;而后定义的 Dog 类构造函数中添加了 super 方法调用,所以可以通过类型检测。
class Animal {
type = 'Animal';
say(name: string) {
console.log(`我是 ${name}`);
}
}
class Dog extends Animal {
name: string;
constructor(name: string) { // error TS2377: Constructors for derived classes must contain a 'super' call.
this.name = name;
}
bark() {
console.log('Woof! Woof!');
}
}
// class Dog extends Animal {
// name: string;
// constructor(name: string) {
// super()
// this.name = name
// }
// bark() {
// console.log('汪!汪!汪!');
// }
// }
const dog = new Dog('QQ');
dog.bark();
dog.say('QQ');
console.log(dog.type)
这里的 super() 是什么作用?其实这里的 super 函数会调用基类的构造函数,如下代码所示:
class Animal {
weight: number;
type = 'Animal';
constructor(weight: number) {
this.weight = weight;
}
say(name: string) {
console.log(`我是${name}`);
}
}
class Dog extends Animal {
name: string;
constructor(name: string) {
super(); // error TS2554: Expected 1 arguments, but got 0.
this.name = name
}
bark() {
console.log('汪!汪!汪!');
}
}
将鼠标放到Dog 类构造函数调用的 super 函数上,我们可以看到一个提示,它的类型是基类 Animal 的构造函数:constructor Animal(weight: number): Animal 。并且因为 Animal 类的构造函数要求必须传入一个数字类型的 weight 参数,而实际入参为空,所以提示了一个 ts(2554) 的错误;如果我们显式地给 super 函数传入一个 number 类型的值,比如说 super(20),则不会再提示错误了。
公共、私有与受保护的修饰符
类属性和方法除了可以通过 extends 被继承之外,还可以通过修饰符控制可访问性。
在 TypeScript 中就支持 3 种访问修饰符,分别是 public、private、protected。
public
修饰的是在任何地方可见、公有的属性或方法;private
修饰的是仅在同一类中可见、私有的属性或方法;protected
修饰的是仅在类自身及子类中可见、受保护的属性或方法。
在之前的代码中,示例类并没有用到可见性修饰符,在缺省情况下,类的属性或方法默认都是 public。如果想让有些属性对外不可见,那么我们可以使用 private
进行设置,如下所示:
class Someone {
public firstName: string;
private lastName: string = 'Jae';
constructor(firstName: string) {
this.firstName = firstName;
this.lastName
}
}
const person = new Someone('Wong');
console.log(person.firstName); // Wong
person.firstName = 'Jack';
console.log(person.firstName); // Jack
console.log(person.lastName); // error TS2341: Property 'lastName' is private and only accessible within class 'Someone'.
在上面的例子中我们可以看到,Somone 类的 lastName 属性是私有的,只在 Somone 类中可见;而代码中定义的 firstName 属性是公有的,在任何地方都可见。因此,我们既可以通过创建的 Somone 类的实例 person 获取或设置公共的 firstName 的属性,还可以操作更改 firstName 的值。
不过,对于 private 修饰的私有属性,只可以在类的内部可见。比如私有属性 lastName 仅在 Somone 类中可见,如果其他地方获取了 lastName ,TypeScript 就会提示一个 ts(2341) 的错误。
注意: TypeScript 中定义类的私有属性仅仅代表静态类型检测层面的私有。如果我们强制忽略 TypeScript 类型的检查错误,转译且运行 JavaScript 时依旧可以获取到 lastName 属性,这是因为 JavaScript 并不支持真正意义上的私有属性。
接下来我们再看一下受保护的属性和方法,如下代码所示:
class Someone {
public firstName: string;
protected lastName: string = 'Jae';
constructor(firstName: string) {
this.firstName = firstName;
this.lastName
}
}
class Otherone extends Someone {
constructor(firstName: string) {
super(firstName);
}
public getMyLastName() {
return this.lastName;
}
}
const oth = new Otherone('Tony');
console.log(oth.getMyLastName()); // Jae
oth.lastName; // error TS2445: Property 'lastName' is protected and only accessible within class 'Someone' and its subclasses.
代码中,修改 Someone 类的 lastName 属性可见修饰符为 protected,表明此属性在 Someone 类及其子类中可见。我们既可以在父类 Someone 的构造器中获取 lastName 属性值,又可以在继承自 Someone 的子类 Otherone 的 getMyLastName 方法获取 lastName 属性的值。
需要注意: 虽然我们不能通过派生类的实例访问
protected
修饰的属性和方法,但是可以通过派生类的实例方法进行访问。比如示例中通过实例的 getMyLastName 方法获取受保护的属性 lastName 是 ok 的,而第 22 行通过实例直接获取受保护的属性 lastName 则提示了一个 ts(2445) 的错误。
只读修饰符
在前面的例子中,Someone 类 public 修饰的属性既公开可见,又可以更改值,如果我们不希望类的属性被更改,则可以使用 readonly 只读修饰符声明类的属性,如下代码所示:
class Someone {
public readonly firstName: string;
protected lastName: string = 'Jae';
constructor(firstName: string) {
this.firstName = firstName;
}
}
const person = new Someone('Tony');
person.firstName = 'Jack'; // error TS2540: Cannot assign to 'firstName' because it is a read-only property.
在示例中我们给公开可见属性 firstName 指定了只读修饰符,这个时候如果再更改 firstName 属性的值,TypeScript 就会提示一个 ts(2540) 的错误。这是因为只读属性修饰符保证了该属性只能被读取,而不能被修改。
注意:如果只读修饰符和可见性修饰符同时出现,我们需要将只读修饰符写在可见修饰符后面。
存取器
除了上边提到的修饰符之外,在 TypeScript 中还可以通过getter
、setter
截取对类成员的读写访问。
通过对类属性访问的截取,我们可以实现一些特定的访问控制逻辑。下面我们把之前的示例改造一下,如下代码所示:
class Son {
public firstName: string;
protected lastName: string = 'Stark';
constructor(firstName: string) {
this.firstName = firstName;
}
}
class GrandSon extends Son {
constructor(firstName: string) {
super(firstName);
}
get myLastName() {
return this.lastName;
}
set myLastName(name: string) {
if (this.firstName === 'Tony') {
this.lastName = name;
} else {
console.error('Unable to change myLastName');
}
}
}
const grandSon = new GrandSon('Tony');
console.log(grandSon.myLastName); // Stark
grandSon.myLastName = 'Rogers';
console.log(grandSon.myLastName); // Rogers
const grandSon2 = new GrandSon('Tony1');
grandSon2.myLastName = 'Rogers'; // Unable to change myLastName
在上述代码中,使用 myLastName 的getter
、setter
重写了之前的 GrandSon 类的方法,在 getter 中实际返回的是 lastName 属性。然后,在 setter 中,限定仅当 lastName 属性值为 ‘Tony’ ,才把入参 name 赋值给它,否则打印错误。
静态属性
以上介绍的关于类的所有属性和方法,只有类在实例化时才会被初始化。实际上,我们也可以给类定义静态属性和方法。
因为这些属性存在于类这个特殊的对象上,而不是类的实例上,所以我们可以直接通过类访问静态属性,如下代码所示:
class MyArray {
static displayName = 'MyArray';
static isArray(obj: unknown) {
return Object.prototype.toString.call(obj).slice(8, -1)
}
}
console.log(MyArray.displayName);
console.log(MyArray.isArray([]));
console.log(MyArray.isArray({}));
通过 static 修饰符,我们给 MyArray 类分别定义了一个静态属性 displayName 和静态方法 isArray。之后,我们无须实例化 MyArray 就可以直接访问类上的静态属性和方法了。
基于静态属性的特性,我们往往会把与类相关的常量、不依赖实例 this 上下文的属性和方法定义为静态属性,从而避免数据冗余,进而提升运行性能。
注意: 上边我们提到了不依赖实例 this 上下文的方法就可以定义成静态方法,这就意味着需要显式注解 this 类型才可以在静态方法中使用 this;非静态方法则不需要显式注解 this 类型,因为 this 的指向默认是类的实例。
抽象类
接下来我们看看关于类的另外一个特性——抽象类,它是一种不能被实例化仅能被子类继承的特殊类。
我们可以使用抽象类定义派生类需要实现的属性和方法,同时也可以定义其他被继承的默认属性和方法,如下代码所示:
abstract class Adder {
abstract x: number;
abstract y: number;
abstract add(): number;
displayName = 'Adder';
addTwice(): number {
return (this.x + this.y) * 2;
}
}
class NumAdder extends Adder {
x: number;
y: number;
constructor(x: number, y: number) {
super();
this.x = x;
this.y = y;
}
add(): number {
return this.x + this.y;
}
}
const numAdder = new NumAdder(1, 2);
console.log(numAdder.displayName); // Adder
console.log(numAdder.add()); // 3
console.log(numAdder.addTwice()); // 6
通过 abstract 关键字,我们定义了一个抽象类 Adder,并通过 abstract
关键字定义了抽象属性 x
、y
及方法 add
,而且任何继承 Adder 的派生类都需要实现这些抽象属性和方法。
同时,还在抽象类 Adder 中定义了可以被派生类继承的非抽象属性 displayName
和方法 addTwice
。
然后,在代码中又定义了继承抽象类的派生类 NumAdder, 并实现了抽象类里定义的 x、y 抽象属性和 add 抽象方法。如果派生类中缺少对 x、y、add 这三者中任意一个抽象成员的实现,那么代码中就会提示一个 ts(2515) 错误,有兴趣的可以亲自验证一下。
抽象类中的其他非抽象成员则可以直接通过实例获取,比如通过实例 numAdder,我们获取了 displayName 属性和 addTwice 方法。
因为抽象类不能被实例化,并且派生类必须实现继承自抽象类上的抽象属性和方法定义,所以抽象类的作用其实就是对基础逻辑的封装和抽象。
类的类型
类的最后一个特性——类的类型和函数类似,即在声明类的时候,其实也同时声明了一个特殊的类型(确切地讲是一个接口类型),这个类型的名字就是类名,表示类实例的类型;在定义类的时候,我们声明的除构造函数外所有属性、方法的类型就是这个特殊类型的成员。如下代码所示:
class A {
name: string;
constructor(name: string) {
this.name = name;
}
}
const a1: A = {}; // error TS2741: Property 'name' is missing in type '{}' but required in type 'A'.
const a2: A = { name: 'a2' };