一、前言
在JavaScript(ES5)中仅支持通过函数和原型链继承模拟类的实现(用于抽象业务模型、组织数据结构并创建可重用组件),自ES6引入class关键字后,它才开始支持使用与`Java`类似的语法定义声明类。TypeScript作为JavaScript的超集,自然也支持class的全部特性,并且还可以对类的属性、方法等进行静态类型检测。
二、类
在实际业务中,任何实体都可以被抽象为一个使用类表达的类似对象的数据结构,且这个数据结构既包含属性,又包含方法,比如我们在下方抽象了一个狗的类。
classDog{
name:string;
constructor(name:string){
this.name=name;
}
bark(){
console.log(Woof!Woof!);
}
}
constdog=newDog(Q);
dog.bark();//=Woof!Woof!
首先,我们定义了一个classDog,它拥有string类型的name属性(见第2行)、bark方法(见第7行)和一个构造器函数(见第3行)。然后,我们通过new关键字创建了一个Dog的实例,并把实例赋值给变量dog(见12行)。最后,我们通过实例调用了类中定义的bark方法(见13行)。如果使用传统的JavaScript代码定义类,我们需要使用函数+原型链的形式进行模拟,如下代码所示:
functionDog(name:string){
this.name=name;//ts()thisimplicitlyhastypeanybecauseitdoesnothaveatypeannotation.
}
Dog.prototype.bark=function(){
console.log(Woof!Woof!);
};
constdog=newDog(Q);//ts()newexpression,whosetargetlacksaconstructsignature,implicitlyhasananytype.
dog.bark();//=Woof!Woof!
在第1~3行,我们定义了Dog类的构造函数,并在构造函数内部定义了name属性,再在第4行通过Dog的原型链添加bark方法。和通过class方式定义类相比,这种方式明显麻烦不少,而且还缺少静态类型检测。因此,类是TypeScript编程中十分有用且不得不掌握的工具。
三、继承
在TypeScript中,使用extends关键字就能很方便地定义类继承的抽象模式,如下代码所示:
classAnimal{
type=Animal;
say(name:string){
console.log(`Im${name}!`);
}
}
classDogextendsAnimal{
bark(){
console.log(Woof!Woof!);
}
}
constdog=newDog();
dog.bark();//=Woof!Woof!
dog.say(Q);//=ImQ!
dog.type;//=Animal
上面的例子展示了类最基本的继承用法。比如第8~12行定义的`Dog`是派生类,它派生自第1~6行定义的`Animal`基类,此时`Dog`实例继承了基类`Animal`的属性和方法。因此,在第15~17行我们可以看到,实例dog支持bark、say、type等属性和方法。
说明:派生类通常被称作子类,基类也被称作超类(或者父类)。
细心的你可能发现了,这里的Dog基类与第一个例子中的类相比,少了一个构造函数。**这是因为派生类如果包含一个构造函数,则必须在构造函数中调用super()方法,这是TypeScript强制执行的一条重要规则。**
如下示例,因为第1~10行定义的Dog类构造函数中没有调用super方法,所以提示了一个ts()的错误;而第12~22行定义的Dog类构造函数中添加了super方法调用,所以可以通过类型检测。
classDogextendsAnimal{
name:string;
constructor(name:string){//ts()Constructorsforderivedclassesmustcontainasupercall.
this.name=name;
}
bark(){
console.log(Woof!Woof!);
}
}
classDogextendsAnimal{
name:string;
constructor(name:string){
super();//添加super方法
this.name=name;
}
bark(){
console.log(Woof!Woof!);
}
}
这里的super()是什么作用?其实这里的super函数会调用基类的构造函数,如下代码所示:
classAnimal{
weight:number;
type=Animal;
constructor(weight:number){
this.weight=weight;
}
say(name:string){
console.log(`Im${name}!`);
}
}
classDogextendsAnimal{
name:string;
constructor(name:string){
super();//ts()Expected1arguments,butgot0.
this.name=name;
}
bark(){
console.log(Woof!Woof!);
}
}
将鼠标放到第15行Dog类构造函数调用的super函数上,我们可以看到一个提示,它的类型是基类Animal的构造函数:constructorAnimal(weight:number):Animal。并且因为Animal类的构造函数要求必须传入一个数字类型的weight参数,而第15行实际入参为空,所以提示了一个ts()的错误;如果我们显式地给super函数传入一个number类型的值,比如说super(20),则不会再提示错误了。
四、公共、私有与受保护的修饰符
类属性和方法除了可以通过extends被继承之外,还可以通过修饰符控制可访问性。
在TypeScript中就支持3种访问修饰符,分别是public、private、protected。
-public修饰的是在任何地方可见、公有的属性或方法;
-private修饰的是仅在同一类中可见、私有的属性或方法;
-protected修饰的是仅在类自身及子类中可见、受保护的属性或方法。
在之前的代码中,示例类并没有用到可见性修饰符,在缺省情况下,类的属性或方法默认都是public。如果想让有些属性对外不可见,那么我们可以使用`private`进行设置,如下所示:
classSon{
publicfirstName:string;
privatelastName:string=Stark;
constructor(firstName:string){
this.firstName=firstName;
this.lastName;//ok
}
}
constson=newSon(Tony);
console.log(son.firstName);//="Tony"
son.firstName=Jack;
console.log(son.firstName);//="Jack"
console.log(son.lastName);//ts()PropertylastNameisprivateandonlyaccessiblewithinclassSon.
在上面的例子中我们可以看到,第3行Son类的lastName属性是私有的,只在Son类中可见;第2行定义的firstName属性是公有的,在任何地方都可见。因此,我们既可以通过第10行创建的Son类的实例son获取或设置公共的firstName的属性(如第11行所示),还可以操作更改firstName的值(如第12行所示)。不过,对于private修饰的私有属性,只可以在类的内部可见。比如第6行,私有属性lastName仅在Son类中可见,如果其他地方获取了lastName,TypeScript就会提示一个ts()的错误(如第14行)。
**注意**:TypeScript中定义类的私有属性仅仅代表静态类型检测层面的私有。如果我们强制忽略TypeScript类型的检查错误,转译且运行JavaScript时依旧可以获取到lastName属性,这是因为JavaScript并不支持真正意义上的私有属性。
目前,JavaScript类支持private修饰符的提案已经到stage3了。相信在不久的将来,私有属性在类型检测和运行阶段都可以被限制为仅在类的内部可见。
classSon{
publicfirstName:string;
protectedlastName:string=Stark;
constructor(firstName:string){
this.firstName=firstName;
this.lastName;//ok
}
}
classGrandSonextendsSon{
constructor(firstName:string){
super(firstName);
}
publicgetMyLastName(){
returnthis.lastName;
}
}
constgrandSon=newGrandSon(Tony);
console.log(grandSon.getMyLastName());//="Stark"
grandSon.lastName;//ts()PropertylastNameisprotectedandonlyaccessiblewithinclassSonanditssubclasses.
在第3行,修改Son类的lastName属性可见修饰符为protected,表明此属性在Son类及其子类中可见。如示例第6行和第16行所示,我们既可以在父类Son的构造器中获取lastName属性值,又可以在继承自Son的子类GrandSon的getMyLastName方法获取lastName属性的值。
**需要注意**:虽然我们不能通过派生类的实例访问`protected`修饰的属性和方法,但是可以通过派生类的实例方法进行访问。比如示例中的第21行,通过实例的getMyLastName方法获取受保护的属性lastName是ok的,而第22行通过实例直接获取受保护的属性lastName则提示了一个ts()的错误。
五、只读修饰符
在前面的例子中,Son类public修饰的属性既公开可见,又可以更改值,如果我们不希望类的属性被更改,则可以使用readonly只读修饰符声明类的属性,如下代码所示:
classSon{
publicreadonlyfirstName:string;
constructor(firstName:string){
this.firstName=firstName;
}
}
constson=newSon(Tony);
son.firstName=Jack;//ts()CannotassigntofirstNamebecauseitisaread-onlyproperty.
在第2行,我们给公开可见属性firstName指定了只读修饰符,这个时候如果再更改firstName属性的值,TypeScript就会提示一个ts()的错误(参见第9行)。这是因为只读属性修饰符保证了该属性只能被读取,而不能被修改。
注意:如果只读修饰符和可见性修饰符同时出现,我们需要将只读修饰符写在可见修饰符后面。
六、存取器
除了上边提到的修饰符之外,在TypeScript中还可以通过`getter`、`setter`截取对类成员的读写访问。通过对类属性访问的截取,我们可以实现一些特定的访问控制逻辑。下面我们把之前的示例改造一下,如下代码所示:
classSon{
publicfirstName:string;
protectedlastName:string=Stark;
constructor(firstName:string){
this.firstName=firstName;
}
}
classGrandSonextendsSon{
constructor(firstName:string){
super(firstName);
}
getmyLastName(){
returnthis.lastName;
}
setmyLastName(name:string){
if(this.firstName===Tony){
this.lastName=name;
}else{
console.error(UnabletochangemyLastName);
}
}
}
constgrandSon=newGrandSon(Tony);
console.log(grandSon.myLastName);//="Stark"
grandSon.myLastName=Rogers;
console.log(grandSon.myLastName);//="Rogers"
constgrandSon1=newGrandSon(Tony1);
grandSon1.myLastName=Rogers;//="UnabletochangemyLastName"
在第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时,打印了我们自定义的错误信息。
七、静态属性
以上介绍的关于类的所有属性和方法,只有类在实例化时才会被初始化。实际上,我们也可以给类定义静态属性和方法。因为这些属性存在于类这个特殊的对象上,而不是类的实例上,所以我们可以直接通过类访问静态属性,如下代码所示:
classMyArray{
staticdisplayName=MyArray;
staticisArray(obj:unknown){
returnObject.prototype.toString.call(obj).slice(8,-1)===Array;
}
}
console.log(MyArray.displayName);//="MyArray"
console.log(MyArray.isArray([]));//=true
console.log(MyArray.isArray({}));//=false
在第2~3行,通过static修饰符,我们给MyArray类分别定义了一个静态属性displayName和静态方法isArray。之后,我们无须实例化MyArray就可以直接访问类上的静态属性和方法了,比如第8行访问的是静态属性displayName,第9~10行访问的是静态方法isArray。基于静态属性的特性,我们往往会把与类相关的常量、不依赖实例this上下文的属性和方法定义为静态属性,从而避免数据冗余,进而提升运行性能。
**注意:上边我们提到了不依赖实例this上下文的方法就可以定义成静态方法,这就意味着需要显式注解this类型才可以在静态方法中使用this;非静态方法则不需要显式注解this类型,因为this的指向默认是类的实例。**
八、抽象类
接下来我们看看关于类的另外一个特性——抽象类,它是一种不能被实例化仅能被子类继承的特殊类。我们可以使用抽象类定义派生类需要实现的属性和方法,同时也可以定义其他被继承的默认属性和方法,如下代码所示:
abstractclassAdder{
abstractx:number;
abstracty:number;
abstractadd():number;
displayName=Adder;
addTwice():number{
return(this.x+this.y)*2;
}
}
classNumAdderextendsAdder{
x:number;
y:number;
constructor(x:number,y:number){
super();
this.x=x;
this.y=y;
}
add():number{
returnthis.x+this.y;
}
}
constnumAdder=newNumAdder(1,2);
console.log(numAdder.displayName);//="Adder"
console.log(numAdder.add());//=3
console.log(numAdder.addTwice());//=6
在第1~10行,通过abstract关键字,我们定义了一个抽象类Adder,并通过`abstract`关键字定义了抽象属性`x`、`y`及方法`add`,而且任何继承Adder的派生类都需要实现这些抽象属性和方法。同时,我们还在抽象类Adder中定义了可以被派生类继承的非抽象属性`displayName`和方法`addTwice`。然后,我们在第12~23行定义了继承抽象类的派生类NumAdder,并实现了抽象类里定义的x、y抽象属性和add抽象方法。如果派生类中缺少对x、y、add这三者中任意一个抽象成员的实现,那么第12行就会提示一个ts()错误,关于这点你可以亲自验证一下。
抽象类中的其他非抽象成员则可以直接通过实例获取,比如第26~28行中,通过实例numAdder,我们获取了displayName属性和addTwice方法。因为抽象类不能被实例化,并且派生类必须实现继承自抽象类上的抽象属性和方法定义,所以抽象类的作用其实就是对基础逻辑的封装和抽象。实际上,我们也可以定义一个描述对象结构的接口类型(详见07讲)抽象类的结构,并通过implements关键字约束类的实现。
使用接口与使用抽象类相比,区别在于接口只能定义类成员的类型,如下代码所示:
interfaceIAdder{
x:number;
y:number;
add
)=number;
}
classNumAdderimplementsIAdder{
x:number;
y:number;
constructor(x:number,y:number){
this.x=x;
this.y=y;
}
add(){
returnthis.x+this.y;
}
addTwice(){
return(this.x+this.y)*2;
}
}
在第1~5行,我们定义了一个包含x、y、add属性和方法的接口类型(详见07讲),然后在第6~12行实现了拥有接口约定的x、y属性和add方法,以及接口未约定的addTwice方法的NumAdder类。
九、类的类型
类的最后一个特性——类的类型和函数类似,即在声明类的时候,其实也同时声明了一个特殊的类型(确切地讲是一个接口类型),这个类型的名字就是类名,表示类实例的类型;在定义类的时候,我们声明的除构造函数外所有属性、方法的类型就是这个特殊类型的成员。如下代码所示:
classA{
name:string;
constructor(name:string){
this.name=name;
}
}
consta1:A={};//ts()Propertynameismissingintype{}butrequiredintypeA.
consta2:A={name:a2};//ok
在第1~6行,我们在定义类A,也说明我们同时定义了一个包含字符串属性name的同名接口类型A。因此,在第7行把一个空对象赋值给类型是A的变量a1时,TypeScript会提示一个ts()错误,因为缺少name属性。在第8行把对象{name:a2}赋值给类型同样是A的变量a2时,TypeScript就直接通过了类型检查,因为有name属性。
十、总结
在TypeScript中,因为我们需要实践OOP编程思想,所以离不开类的支撑。在实际工作中,类与函数一样,都是极其有用的抽象、封装利器。