在以前,JS 也可以实现面向对象的开发,我们直接写构造函数即可,但是如果读者学习过其他的编程语言,就会知道这个东西和其他的语言不太一样,也确实,JS 的对象并不是基于类构建的,而是基于原型构建的,这和目前的主流编程语言都不太一样,目前其他主流的面向对象编程语言实际上都是基于类来构造,为了适应其他编程语言吧,或者说顺着大潮流,JS 在 ES 6 的时候也引入了类这个概念。
首先,我们先来看一下 JS 以前基于构造函数的面向对象有什么缺陷,首先,对于属性,我们一般是在构造函数中写上 this.属性名 = 参数名,以这样的方式添加属性,但是对于方法就不太一样了,由于方法这个东西是没法修改的,顶多重写覆盖,所以为了避免对象太多占用过多开销,我们其实是写到原型上的,即 构造函数名.prototype.方法名 = 函数,以这样的方式来添加方法,我们会发现,属性写在构造函数内部,但是方法则是在构造函数外部手动添加到原型上的,这是很不合理的。
在面向对象中,把一个对象所有成员的定义称为一个类,也就是说不管是对属性进行定义也好,对方法进行定义也好,这都是一个类里面的东西,但是从写法上,我却给他分开书写了,这是不合理的。至于为什么,这是由于 JS 的对象是基于原型的,和其他语言本身就不一样,在设计这个语言之初就没怎么考虑到面向对象,但是现在 JS 大火,也就从其他语言中引入了面向对象的这种思想。
除此之外,我们使用 for...in 循环一个对象的时候,实际上是可以枚举到原型的,也就是继承链上的所有成员都会被枚举到,这其实是我们不期望的,虽然我们有一些方法可以实现这样的效果,但是稍显麻烦。除此之外,构造函数他居然能够被当做普通函数使用,这本身就很扯淡,像 JAVA,C++,这些语言的构造函数都是写在类里面的,都是不可能被调用的,所以在 JS 中构造函数能被直接调用就是一个非常神奇的现象,也是我们不希望的。
基于以上种种原因,ES 6 对面向对象提供了很多支持,引入了类这个概念,也提供了 class 关键字,接下来用 ES 6 的新语法书写一个类。
js
class Animal{
constructor(type,name,age,sex){//属性直接写在构造器里
this.type = type;
this.name = name;
this.age = age;
this.sex = sex;
}
print(){
console.log(`种类:${type},名字:${name},年龄:${age},性别:${sex}`);
}
}以上就实现了一个类,以上的写法和下面的写法几乎一模一样
js
function Animal(type,name,age,sex){
this.type = type;
this.name = name;
this.age = age;
this.sex = sex;
}
Animal.prototype.print = function(){
console.log(`种类:${type},名字:${name},年龄:${age},性别:${sex}`);
}这里额外注意,在类里面给方法赋值是可以使用箭头函数的,并且箭头函数中的 this 指向当前对象。
从这里我们也应该意识到,ES 6 给我们提供的面向对象支持,实际上就是让我们使用起来更方便一些,它只是一个语法糖,并没有提供实际的功能。
那为什么要说几乎一模一样呢?因为它在幕后帮我们干了很多事,比如原型成员会被自动设置为不可枚举,这其实也是通过属性描述符实现的。同样这个 construcor 就是构造函数,但是这个构造函数被写到的类里面,是无法在外面被调用的。
如果读者学过其他编程语言,这里就提一嘴,在 JS 中,同一个作用域下,函数之以函数名作为区分,所以 JS 不存在重载这一概念,同样,构造函数也不能重载,但是我们可以使用剩余参数来完成类似重载的效果。
类这里还有一些细节需要注意
- 类的声明会存在暂时性死区,也就是说不能再类声明前使用类
- 类中所有的代码均运行在严格模式下
- 类中所有的方法不可枚举
- 类中定义的所有方法均不可当做构造函数使用
- 类的构造器必须使用 new 来调用
除了以上的基础用法,ES 6,甚至包括后面的 ES 7 还给我们提供一些写法来帮助我们更方便地书写类。
首先就是可计算的属性名,在前面我们其实介绍过,如果我一个属性名来自于一个变量,如果是字面量那种对象,我们可以使用 [表达式]:属性值 在字面量中实现这样的效果,那在类里面,其实也可以:
js
const printName = "print";
class Animal{
[printName](){//把属性表达式的值作为变量名
console.log("I am a Animal");
}
}在 ES 5 中,我们要使用属性描述符,是不是要使用 Object.defineProperty() 方法,这是不是很麻烦,但是属性描述符实际上是一个很好用很牛逼的东西,现在其实有更方便的解决方案
js
class Animal{
constructor(age){
this.setAge(age);
}
setAge(age){
age < 0 && age = 0;
age > 1000 && age = 1000;
this._age = age;
}
getAge(){
return this._age + "岁";
}
}也就是说,我们现在其实可以在构造函数内部,调用自身的一个方法,来完成年龄的设置,同样,在获取年龄的时候,我也不让你直接获取,而是通过一个方法来获取。
这种方法是一种很常见的方式,让使用者通过方法来获取和修改我的属性,而不让读者直接设置或修改,一方面可以实现类似属性表达式的效果,另一方面也更加安全,在其他面向对象语言中使用 getter 和 setter 来获取私有属性也是非常常见的做法。但是 JS 并没有权限控制这个东西,对象里面的所有属性其实外面都可以拿到,所以只能在前面加一个下划线作为区分,让外面的人不要直接用。
在这种模式下,对一个对象的赋值和操作都变成了方法的调用,看起来不像属性了,如果我们更近一步,可以像下面这样写
js
class Animal{
constructor(age){
this.age = age;
}
set age(age){//会在对象上新增一个age属性,改属性为存取器属性,并将该函数作为其setter
age < 0 && age = 0;
age > 1000 && age = 1000;
this._age = age;
}
get age(){//会在对象上新增一个age属性,改属性为存取器属性,并将该函数作为其getter
return this._age + "岁";
}
}使用这种方法就可以快速实现属性描述符,当给 age 属性赋值的时候,会运行 set age() 函数,并且传入赋值的参数,当读取 age 属性的时候,会自动运行 get age() 方法。
接下来是一些静态成员,很多时候我们希望给类本身添加一些方法或属性,这种属性或者方法,就叫做静态属性或者静态方法,比如前面刚刚提到的 Object.defineProperty(),这个方法就是直接添加到 Object 类上的,也只能通过 Object 类本身来调用,这就是一个静态方法。我们以前要实现这样的是在构造函数外部一个一个添加,ES 6 提供了一个新的方式快速添加。
一个成员,不管是属性还是方法,如果是通过对象来访问,那就是实例成员,如果是通过类本身来访问,那就是类成员,或者也叫静态成员。
那什么时候需要使用静态属性和静态方法呢?这其实是一个开发经验上的问题,举个例子,比如一个象棋游戏,每一个棋子都有宽高,并且固定不变,如果我把宽高这个属性放在每一个棋子里面,那我如果要获取到这个信息,根据棋子的宽高去创建一个棋盘,我就必须去创建一个棋子来获取这个棋子的宽高,这是很奇怪的,这个时候我们就可以将其作为静态属性,不管有没有棋子,宽高都是可以获取到的。
在 ES 6 中,使用 static 关键字定义的成员,就是静态成员,属性和方法都可以使用这个关键字定义。
实际上,在 ES 7 还给我们提供了字段初始化器,使得我们可以使用以下写法
js
class Animal{
type = "牛";
name = "默认名";
age = "0";
sex = "公";
print(){
console.log(`种类:${type},名字:${name},年龄:${age},性别:${sex}`);
}
}也就是说,如果我们可以直接在里面给其赋默认值,这些属性会自动添加到对象中去。当然,如果有的属性的在创建对象的时候需要修改的,我们再在构造函数里面进行修改即可。如果一些成员本身就有一个默认值,并且这个默认值不依赖于对象的创建,也就是说所有的对象默认值都是这个,那我们就可以直接这样写即可。
实际上,上面的代码等价于:
js
class Animal{
constructor(){
this.type = "牛";
this.name = "默认名";
this.age = "0";
this.sex = "公";
}
print(){
console.log(`种类:${type},名字:${name},年龄:${age},性别:${sex}`);
}
}实际上他也是这么实现的,这里读者应该有点明白为什么箭头函数会指向当前对象了,这里简要分析
js
class Test{
print = () =>{
console.log(this);
}
}
//上面的代码相当于
class Test{
constructor(){
this.print = () =>{
console.log(this);//很显然,这里构造函数中的this就是当前对象。
}
}
}也请注意,使用箭头函数之后,和原来的方式就不太一样的,如果使用箭头函数,那么这个函数会放到每一个对象的实例中,而原来那种方式,函数只会放到原型上,所有的对象共享同一个方法。
综上,我们可以认为,如果使用箭头函数定义方法,可以强制绑定 this 指向,但是函数放到了每一个对象中,会存在资源浪费,这也是看具体需求来进行取舍。
实际上,JS 还可以写匿名类
js
class{
}以上就是一个匿名类,这个匿名类我们可以使用一个变量来接受
js
const Test = class{
}这个相当于就创建了一个名为 Test 的类,这个例子希望读者知道,类其实也是可以被当做表达式的,就和匿名函数一样,我们也可以用函数去返回一个类,或者把类作为参数进行传递。
所以类其实和函数一样,实际上在 JS 底层,类就是构造函数,是一模一样的,只不过提供了很多语法糖,帮助我们快速书写代码,没有什么神奇的地方。
在前面,我们学习了继承,我们要实现继承,需要手动去用原型实现,也就是圣杯模式,这是非常麻烦的。ES 6 为我们提供了新的写法。
首先,先来详细介绍什么是继承,A 继承 B,实际上就是 A 不仅包含了 B 的所有属性和方法,还在此基础上添加了很多方法,我们可以认为,A 是 B,但是 A 是很特别的 B。比如,老虎和动物,我们可以认为老虎类继承了动物类,老虎具备了动物的所有属性和方法,但是老虎在其基础上加了一些独特的属性和方法,这就是继承。
也就是说,如果我们可以说 A 是 B,但 B 不是 A,我们就可以认为 A 和 B 形成继承关系,A 就是 B 的子类,B 就是 A 的父类。比如我们认为老虎是动物,但是动物肯定不止老虎,所以我们可以认为老虎类就是动物类的子类,动物类就是老虎类的父类,这就是一个类之间的继承关系。
如果 A 是 B 的子类,我们可以说 A 继承自 B,也可以说 B 派生了 A
前面提到了,子类继承自父类,那子类就能拥有父类的所有实例成员,什么意思呢,比如老虎类是动物类的子类,那么老虎是不是就应该拥有动物的所有属性和方法,这是很自然的事情。
如果我们类有很多,我们就可以抽象出来一个类的继承关系,比如我们要做飞机大战,有各种各样的飞机,我们就可以把飞机作为一个类拥有其共同的特征,不同的飞机就可以看做不同的子类。
在以前,我要让子类拥有父类里面的成员,我们需要在子类的构造函数中直接调用父类的构造函数,并使用 call 关键字修改 this 指向,相当于把父类的构造函数拿来给自己用一下,同时需要使用圣杯模式继承父类原型上的方法,这是非常麻烦的,我们先用下面的代码回顾一下在 ES 6 之前如何继承。
js
/**
* 构造函数Animal,用于创建一个动物对象。
* @param {string} type 动物的类型。
* @param {string} name 动物的名称。
* @param {number} age 动物的年龄。
* @param {string} sex 动物的性别。
*/
function Animal(type, name, age, sex) {
this.type = type; // 设置动物类型
this.name = name; // 设置动物名称
this.age = age; // 设置动物年龄
this.sex = sex; // 设置动物性别
}
/**
* Animal原型上的print方法,用于打印基本信息。
*/
Animal.prototype.print = function () {
console.log("I am a animal");
}
/**
* 构造函数Dog,用于创建一个犬类对象,继承自Animal。
* @param {string} name 犬类的名称。
* @param {number} age 犬类的年龄。
* @param {string} sex 犬类的性别。
*/
function Dog(name, age, sex) {
// 通过调用Animal的构造函数来初始化Dog对象
Animal.call(this, "犬类", name, age, sex);
}
// 以下代码用于确保Dog对象能够正确继承Animal的原型链
(function () {
function Temp() { }
Temp.prototype = Animal.prototype; // Temp的原型指向Animal的原型
Dog.prototype = new Temp(); // Dog的原型指向一个全新Temp实例,从而继承Animal的原型属性和方法
Dog.prototype.constructor = Dog; // 修复Dog.prototype.constructor指向
})();
// 创建一个Dog实例
const dog = new Dog("大黄", 5, "公");
// 打印Dog实例的属性
console.log(dog.type, dog.name, dog.age, dog.sex);
// 调用Dog实例的print方法
dog.print();可以看到,我们要让 Dog 类继承 Animal 类,实际上是很麻烦的,要在 Dog 类里面使用 call 直接调用 Animal 构造函数初始化 Animal 类的属性,同时要使用圣杯模式来让 Dog 去继承 Animal 的原型链。
在 ES 6 中,实现继承是非常简单的,如下所示:
js
class Animal{
constructor(type, name, age, sex) {
this.type = type; // 设置动物类型
this.name = name; // 设置动物名称
this.age = age; // 设置动物年龄
this.sex = sex; // 设置动物性别
}
print() {
console.log("I am a animal");
}
}
class Dog extends Animal{//直接使用extends来表达继承
constructor(name,age,sex){
//在构造函数内,需要调用父类的构造函数
super("犬类",name,age,sex);//在子类构造函数内有一个super函数,这个函数就直接指向super的构造函数,并且不需要绑定this,直接调用
}
}
// 创建一个Dog实例
const dog = new Dog("大黄", 5, "公");
// 打印Dog实例的属性
console.log(dog.type, dog.name, dog.age, dog.sex);
// 调用Dog实例的print方法
dog.print();很显然,在 ES 6 中,要实现继承其实就两个步骤,第一步是在类名后面加上 extends 父类,表明要继承的父类。第二步是在构造函数内手动调用 super 方法,也就是调用父类的构造方法。并且 JS 中还有强制规定,如果一个类是继承自另一个类的子类,或者说使用了 extends 关键字实现了继承,并且手动写了构造函数,那么在构造函数内必须调用 super 函数,并且只能在第一行进行调用,如果不这么干,在创建对象的时候会直接报错。
如果不写构造函数,JS 会自动加上构造函数,这个构造函数的参数列表父类一模一样,但是在其内部只是单纯调用 super 函数,并将参数列表原封不动进行传递。
在 JS 中并不支持多继承,也就是说子类只能继承一个父类,不允许同时继承多个父类
super 关键字如果当做函数直接调用,可以直接调用父类的构造函数,但是如果当做对象去访问它的属性,那么此时 super 相当于父类的原型。我们可以认为,super 是一个新的函数,该函数的函数体就是父类的构造函数,但是该函数的属性上添加了父类的所有原型上的属性。但需要注意的是,我们无法在非构造函数中使用 super 关键字调用父类的构造函数。
抽象类
抽象类是一个专门用来继承的类,我们无法通过该类创建对象,比如说 Animal 类就是一个典型的抽象类,这是一个很抽象的概念,我们无法创建一个名为动物的对象对吧,我们要创建的其实是猫呀,狗呀,这些对象,这个类是专门用于继承的,这就是抽象类。在其他很多语言中其实是直接支持抽象类的,比如说 JAVA 中使用 abstract 关键字就可以创建一个抽象类,C++中只要包含了纯虚函数就是抽象类。但是 JS 原生并不支持抽象类这个东西,接下来的问题就是如何在 JS 中让一个类只能被继承不能被调用。我们只需要在父类的构造函数加上这么一句话:
js
if(new.target === Animal){
throw new Error("这是一个抽象类,不能用来创建函数");
}有了这行代码之后,如果直接 new Animal,就会报错,但是如果是从子类中调用的这个构造函数,就不会出现问题,这就实现了抽象类。
不用担心 this 的问题,在正常情况下,this 始终指向具体类的对象,哪怕调用的是父类的方法,this 也是指向对象自己的。