【黑马】2
05 讲我们学习了 TypeScript 中抽象、封装的利器——函数类型,接下来我们将学习另一个集面向对象继承、封装、多态三要素为一体的编程利器,类类型。
学习建议:请使用 VS Code,新建一个 06.ts 文件尝试以下所有示例,以便帮助你更好地理解、吸收知识。
在 JavaScript(ES5)中仅支持通过函数和原型链继承模拟类的实现(用于抽象业务模型、组织数据结构并创建可重用组件),自 ES6 引入 class 关键字后,它才开始支持使用与Java
类似的语法定义声明类。
TypeScript 作为 JavaScript 的超集,自然也支持 class 的全部特性,并且还可以对类的属性、方法等进行静态类型检测。
类
在实际业务中,任何实体都可以被抽象为一个使用类表达的类似对象的数据结构,且这个数据结构既包含属性,又包含方法,比如我们在下方抽象了一个狗的类。
class Dog {
name: string;
constructor(name: string) {
this.name = name;
}
bark() {
console.log('Woof! Woof!');
}
}
const dog = new Dog('Q');
dog.bark();
首先,我们定义了一个 class Dog ,它拥有 string 类型的 name 属性(见第 2 行)、bark 方法(见第 7 行)和一个构造器函数(见第 3 行)。然后,我们通过 new 关键字创建了一个 Dog 的实例,并把实例赋值给变量 dog(见 12 行)。最后,我们通过实例调用了类中定义的 bark 方法(见 13 行)。
如果使用传统的 JavaScript 代码定义类,我们需要使用函数 + 原型链的形式进行模拟,如下代码所示:
function Dog(name: string) {
this.name = name;
}
Dog.prototype.bark = function () {
console.log('Woof! Woof!');
};
const dog = new Dog('Q');
dog.bark();
在第 1~ 3 行,我们定义了 Dog 类的构造函数,并在构造函数内部定义了 name 属性,再在第 4 行通过 Dog 的原型链添加 bark 方法。
和通过 class 方式定义类相比,这种方式明显麻烦不少,而且还缺少静态类型检测。因此,类是 TypeScript 编程中十分有用且不得不掌握的工具。
下面我们看一下关于类最主要的特性——继承,也是面向对象编程三大要素之一。
继承
在 TypeScript 中,使用 extends 关键字就能很方便地定义类继承的抽象模式,如下代码所示:
class Animal {
type = 'Animal';
say(name: string) {
console.log(`I'm ${name}!`);
}
}
class Dog extends Animal {
bark() {
console.log('Woof! Woof!');
}
}
const dog = new Dog();
dog.bark();
dog.say('Q');
dog.type;
上面的例子展示了类最基本的继承用法。比如第 8 ~12 行定义的Dog
是派生类,它派生自第 1~6 行定义的Animal
基类,此时Dog
实例继承了基类Animal
的属性和方法。因此,在第 15~17 行我们可以看到,实例 dog 支持 bark、say、type 等属性和方法。
说明:派生类通常被称作子类,基类也被称作超类(或者父类)。
细心的你可能发现了,这里的 Dog 基类与第一个例子中的类相比,少了一个构造函数。这是因为派生类如果包含一个构造函数,则必须在构造函数中调用 super() 方法,这是 TypeScript 强制执行的一条重要规则。
如下示例,因为第 1~10 行定义的 Dog 类构造函数中没有调用 super 方法,所以提示了一个 ts(2377) 的错误;而第 12~22 行定义的 Dog 类构造函数中添加了 super 方法调用,所以可以通过类型检测。
class Dog extends Animal {
name: string;
constructor(name: string) {
this.name = name;
}
bark() {
console.log('Woof! Woof!');
}
}
class Dog extends Animal {
name: string;
constructor(name: string) {
super();
this.name = name;
}
bark() {
console.log('Woof! Woof!');
}
}
有些同学可能会好奇,这里的 super() 是什么作用?其实这里的 super 函数会调用基类的构造函数,如下代码所示:
class Animal {
weight: number;
type = 'Animal';
constructor(weight: number) {
this.weight = weight;
}
say(name: string) {
console.log(`I'm ${name}!`);
}
}
class Dog extends Animal {
name: string;
constructor(name: string) {
super();
this.name = name;
}
bark() {
console.log('Woof! Woof!');
}
}
将鼠标放到第 15 行 Dog 类构造函数调用的 super 函数上,我们可以看到一个提示,它的类型是基类 Animal 的构造函数:constructor Animal(weight: number): Animal 。并且因为 Animal 类的构造函数要求必须传入一个数字类型的 weight 参数,而第 15 行实际入参为空,所以提示了一个 ts(2554) 的错误;如果我们显式地给 super 函数传入一个 number 类型的值,比如说 super(20),则不会再提示错误了。
公共、私有与受保护的修饰符
类属性和方法除了可以通过 extends 被继承之外,还可以通过修饰符控制可访问性。
在 TypeScript 中就支持 3 种访问修饰符,分别是 public、private、protected。
- public 修饰的是在任何地方可见、公有的属性或方法;
- private 修饰的是仅在同一类中可见、私有的属性或方法;
- protected 修饰的是仅在类自身及子类中可见、受保护的属性或方法。
在之前的代码中,示例类并没有用到可见性修饰符,在缺省情况下,类的属性或方法默认都是 public。如果想让有些属性对外不可见,那么我们可以使用private
进行设置,如下所示:
class Son {
public firstName: string;
private lastName: string = 'Stark';
constructor(firstName: string) {
this.firstName = firstName;
this.lastName;
}
}
const son = new Son('Tony');
console.log(son.firstName);
son.firstName = 'Jack';
console.log(son.firstName);
console.log(son.lastName);
在上面的例子中我们可以看到,第 3 行 Son 类的 lastName 属性是私有的,只在 Son 类中可见;第 2 行定义的 firstName 属性是公有的,在任何地方都可见。因此,我们既可以通过第 10 行创建的 Son 类的实例 son 获取或设置公共的 firstName 的属性(如第 11 行所示),还可以操作更改 firstName 的值(如第 12 行所示)。
不过,对于 private 修饰的私有属性,只可以在类的内部可见。比如第 6 行,私有属性 lastName 仅在 Son 类中可见,如果其他地方获取了 lastName ,TypeScript 就会提示一个 ts(2341) 的错误(如第 14 行)。
注意:TypeScript 中定义类的私有属性仅仅代表静态类型检测层面的私有。如果我们强制忽略 TypeScript 类型的检查错误,转译且运行 JavaScript 时依旧可以获取到 lastName 属性,这是因为 JavaScript 并不支持真正意义上的私有属性。
目前,JavaScript 类支持 private 修饰符的提案已经到 stage 3 了。相信在不久的将来,私有属性在类型检测和运行阶段都可以被限制为仅在类的内部可见。如果你感兴趣的话,可以在proposal-private-methods中进行查看。
接下来我们再看一下受保护的属性和方法,如下代码所示:
class Son {
public firstName: string;
protected lastName: string = 'Stark';
constructor(firstName: string) {
this.firstName = firstName;
this.lastName;
}
}
class GrandSon extends Son {
constructor(firstName: string) {
super(firstName);
}
public getMyLastName() {
return this.lastName;
}
}
const grandSon = new GrandSon('Tony');
console.log(grandSon.getMyLastName());
grandSon.lastName;
在第 3 行,修改 Son 类的 lastName 属性可见修饰符为 protected,表明此属性在 Son 类及其子类中可见。如示例第 6 行和第 16 行所示,我们既可以在父类 Son 的构造器中获取 lastName 属性值,又可以在继承自 Son 的子类 GrandSon 的 getMyLastName 方法获取 lastName 属性的值。
需要注意:虽然我们不能通过派生类的实例访问
protected
修饰的属性和方法,但是可以通过派生类的实例方法进行访问。比如示例中的第 21 行,通过实例的 getMyLastName 方法获取受保护的属性 lastName 是 ok 的,而第 22 行通过实例直接获取受保护的属性 lastName 则提示了一个 ts(2445) 的错误。
只读修饰符
在前面的例子中,Son 类 public 修饰的属性既公开可见,又可以更改值,如果我们不希望类的属性被更改,则可以使用 readonly 只读修饰符声明类的属性,如下代码所示:
class Son {
public readonly firstName: string;
constructor(firstName: string) {
this.firstName = firstName;
}
}
const son = new Son('Tony');
son.firstName = 'Jack';
在第 2 行,我们给公开可见属性 firstName 指定了只读修饰符,这个时候如果再更改 firstName 属性的值,TypeScript 就会提示一个 ts(2540) 的错误(参见第 9 行)。这是因为只读属性修饰符保证了该属性只能被读取,而不能被修改。
注意:如果只读修饰符和可见性修饰符同时出现,我们需要将只读修饰符写在可见修饰符后面。
存取器
除了上边提到的修饰符之外,在 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);
grandSon.myLastName = 'Rogers';
console.log(grandSon.myLastName);
const grandSon1 = new GrandSon('Tony1');
grandSon1.myLastName = 'Rogers';
在第 14~24 行,我们使用 myLastName 的getter
、setter
重写了之前的 GrandSon 类的方法,在 getter 中实际返回的是 lastName 属性。然后,在 setter 中,我们限定仅当 lastName 属性值为’Tony’ ,才把入参 name 赋值给它,否则打印错误。
在第 28 行中,我们可以像访问类属性一样访问getter
,同时也可以像更改属性值一样给setter
赋值,并执行一些自定义逻辑。
在第 27 行,因为 grandSon 实例的 lastName 属性被初始化成了’Tony’,所以在第 29 行我们可以把’Rogers’ 赋值给 setter 。而 grandSon1 实例的 lastName 属性在第 32 行被初始化为’Tony1’,所以在第 33 行把’Rogers’ 赋值给 setter 时,打印了我们自定义的错误信息。
静态属性
以上介绍的关于类的所有属性和方法,只有类在实例化时才会被初始化。实际上,我们也可以给类定义静态属性和方法。
因为这些属性存在于类这个特殊的对象上,而不是类的实例上,所以我们可以直接通过类访问静态属性,如下代码所示:
class MyArray {
static displayName = 'MyArray';
static isArray(obj: unknown) {
return Object.prototype.toString.call(obj).slice(8, -1) === 'Array';
}
}
console.log(MyArray.displayName);
console.log(MyArray.isArray([]));
console.log(MyArray.isArray({}));
在第 2~3 行,通过 static 修饰符,我们给 MyArray 类分别定义了一个静态属性 displayName 和静态方法 isArray。之后,我们无须实例化 MyArray 就可以直接访问类上的静态属性和方法了,比如第 8 行访问的是静态属性 displayName,第 9~10 行访问的是静态方法 isArray。
基于静态属性的特性,我们往往会把与类相关的常量、不依赖实例 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);
console.log(numAdder.add());
console.log(numAdder.addTwice());
在第 1~10 行,通过 abstract 关键字,我们定义了一个抽象类 Adder,并通过abstract
关键字定义了抽象属性x
、y
及方法add
,而且任何继承 Adder 的派生类都需要实现这些抽象属性和方法。
同时,我们还在抽象类 Adder 中定义了可以被派生类继承的非抽象属性displayName
和方法addTwice
。
然后,我们在第 12~23 行定义了继承抽象类的派生类 NumAdder, 并实现了抽象类里定义的 x、y 抽象属性和 add 抽象方法。如果派生类中缺少对 x、y、add 这三者中任意一个抽象成员的实现,那么第 12 行就会提示一个 ts(2515) 错误,关于这点你可以亲自验证一下。
抽象类中的其他非抽象成员则可以直接通过实例获取,比如第 26~28 行中,通过实例 numAdder,我们获取了 displayName 属性和 addTwice 方法。
因为抽象类不能被实例化,并且派生类必须实现继承自抽象类上的抽象属性和方法定义,所以抽象类的作用其实就是对基础逻辑的封装和抽象。
实际上,我们也可以定义一个描述对象结构的接口类型(详见 07 讲)抽象类的结构,并通过 implements 关键字约束类的实现。
使用接口与使用抽象类相比,区别在于接口只能定义类成员的类型,如下代码所示:
interface IAdder {
x: number;
y: number;
add: () => number;
}
class NumAdder implements IAdder {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
add() {
return this.x + this.y;
}
addTwice() {
return (this.x + this.y) * 2;
}
}
在第 1~5 行,我们定义了一个包含 x、y、add 属性和方法的接口类型(详见 07 讲),然后在第 6~12 行实现了拥有接口约定的 x、y 属性和 add 方法,以及接口未约定的 addTwice 方法的 NumAdder 类 。
类的类型
类的最后一个特性——类的类型和函数类似,即在声明类的时候,其实也同时声明了一个特殊的类型(确切地讲是一个接口类型),这个类型的名字就是类名,表示类实例的类型;在定义类的时候,我们声明的除构造函数外所有属性、方法的类型就是这个特殊类型的成员。如下代码所示:
class A {
name: string;
constructor(name: string) {
this.name = name;
}
}
const a1: A = {};
const a2: A = { name: 'a2' };
在第 1~6 行,我们在定义类 A ,也说明我们同时定义了一个包含字符串属性 name 的同名接口类型 A。因此,在第 7 行把一个空对象赋值给类型是 A 的变量 a1 时,TypeScript 会提示一个 ts(2741) 错误,因为缺少 name 属性。在第 8 行把对象 { name: ‘a2’ } 赋值给类型同样是 A 的变量 a2 时,TypeScript 就直接通过了类型检查,因为有 name 属性。
小结与预告
在 TypeScript 中,因为我们需要实践 OOP 编程思想,所以离不开类的支撑。在实际工作中,类与函数一样,都是极其有用的抽象、封装利器。
这里插播一道思考题:public、private、protected 属性的区别是什么?欢迎你在留言区互动、交流。
07 讲我们将详细介绍这一讲中重度涉及和依赖的接口类型,敬请期待。
另外,如果你觉得本专栏有价值,欢迎分享给更多好友哦~