JavaScript面向对象—深入ES6的class

197 阅读11分钟

前言

在前面一篇中主要介绍了JavaScript中使用构造函数+原型链实现继承,从实现的步骤来说还是比较繁琐的。在ES6中推出的class的关键字可以直接用来定义类,写法类似与其它的面向对象语言,但是使用class来定义的类其本质上依然是构造函数+原型链的语法糖而已,下面就一起来全面的了解一下class吧。

前言2

在正式进入ES6的class学习,先来简单补充下什么是实例方法,类方法,这个部分用先ES5的内容讲解。

实例方法

定义在类(或构造函数)的原型对象上的函数,通过类创建出来的各个实例对象都可以调用这个方法。(类就是构造函数,我们常见的class只不过是它的语法糖而已,后面讲解)

什么意思呢?🤔我们来看下面这段代码

function Person(name, age) {
      this.name = name;
      this.age = age;
    }

    Person.prototype.running = function() {
      console.log(this.name + " running~");
    };

这里的running()是定义在Person这个构造函数的原型上的,所以running就是一个实例方法。

注意,没有实例对象的时候,是不能调用函数里面的实例方法的。(已经知道的可以跳过证明过程)

 Person.running() // Person.running is not a function
 var p = new Person("why", 18);
 p.running(); // why running~
  • 对于函数原型链查找机制,首先会在自身上面去查找有没有running这个属性,注意原型不算属性哦😄
  • 找不到就回去在他的原型上找,通过隐式原型(__proto__)找到对应的构造函数的显式原型(prototype),如果在这个显式原型上还没找到,就继续通过这个显式原型的隐式原型(其本身作为对象也有 __proto__ 属性)再找到更上层的显式原型。
  • console.log(Person.__proto__) // function,而Person这时候没有实例化,他的隐式原型是Function.protype显示原型,而我们并没有在Function的原型上添加running属性,所以会报错。
  • 而实例化后p对象的隐式原型就是Person的显示原型,所以可以打印出来

类方法

类方法(也常被称为静态方法)是定义在类上,直接与类本身相关联,而不需要通过类的实例对象去调用的方法。函数本身也是类(这个后面会介绍)

Person.studying = function() {
      console.log("studying~")
    }

Person.studying() //studying

这里的studying就是一个类方法,可以通过类名直接调用。

1.类的定义

按照前面的构造函数形式创建类,不仅仅和编写普通的函数过于相似,而且代码并不容易理解。

声明式定义

ES5中定义类

function Person() {}

var p1 = new Person()
console.log(p1)

ES6定义类

注意:class只是语法糖而已(但是使用它方便了很多,后面会讲解)

    class Person {

    }

    // 创建实例对象
    var p2 = new Person()
    console.log( p2)

可以发现他们打印出来的是一样的 image.png

表达式写法

还有一种定义方法(了解即可,用的很少)

 // 另外一种定义方法: 表达式写法(了解, 少用)
 var Student = class {

 }
 var foo = function() {

 }

2.类的构造函数constructor

从上面class定义类可以发现是没有()让我们来传递参数的,当希望在实例化对象的给类传递一些参数,这个时候就可以使用到类的构造函数constructor了。

  • 每个类都可以有一个自己的constructor方法,注意只能有一个,如果有多个会抛出异常;

    **

    class Person {
      constructor(name, age) {
        this.name = name
        this.age = age
      }
    
      constructor() {}
    }
    

  • 当通过new操作符来给类实例化对象的时候,默认调用class中的constructor方法(具体new操作符调用函数时的默认操作步骤在我的博客里中有讲解。这里不多说明) class Person { constructor(name, age) { this.name = name this.age = age } }

    const p = new Person('curry', 30)
    console.log(p) // Person { name: 'curry', age: 30 }
    

3.类的实例方法

在构造函数中实例方法是将其放到构造函数的原型prototype上。

而在class定义的类中,可直接在类中定义方法。但是本质上是放在Person.prototype

class Person {
  constructor(name, age) {
    this.name = name
    this.age = age
  }

  eating() {
    console.log(this.name + 'is eating.')
  }

  running() {
    console.log(this.name + 'is running.')
  }
}

注意:class只是个语法糖,我们依旧不能通过Perosn.running直接调用实例方法!!

console.log(Person.running()) //Person.running is not a function

关于实例方法本质是放在原型上,这里也证明一下

console.log(Person.prototype === p1.__proto__)//true

4.类和构造函数的异同

你会发现它和我们的构造函数的特性其实是一致的

// function定义类
    function Person1(name, age) {
      this.name = name
      this.age = age
    }

    Person1.prototype.running = function() {}
    Person1.prototype.eating = function() {}

    var p1 = new Person1("why", 18)
    console.log(p1.__proto__ === Person1.prototype) //true
    console.log(Person1.prototype.constructor) //指向自己Person1
    console.log(typeof Person1) // function

    // 不同点: 作为普通函数去调用
    Person1("abc", 100)

    // class定义类
    class Person2 {
      constructor(name, age) {
        this.name = name
        this.age = age
      }

      running() {}
      eating() {}
    }

    var p2 = new Person2("kobe", 30)
    console.log(p2.__proto__ === Person2.prototype) //true
    console.log(Person2.prototype.constructor) //指向自己,Person2
    console.log(typeof Person2) //function

    // 不同点: class定义的类, 不能作为一个普通的函数进行调用
    Person2("cba", 0) //Uncaught TypeError: Class constructor Person2 cannot be invoked without 'new'

5.类的访问器方法

对象访问器

控制对象的属性时,可以通过使用

  • Object.defineProperty()方法来在其数据属性描述符中可以使用set和get函数。
  • 也可以通过访问器方法(不推荐)
    // 针对对象
    // 方式一: 描述符
    // var obj = {
      // _name: "why"
    // }
    // Object.defineProperty(obj, "name", {
    //   configurable: true,
    //   enumerable: true,
    //   set: function() {
    //   },
    //   get: function() {
    //   }
    // })

    // 方式二: 直接在对象定义访问器
    // 监听_name什么时候被访问, 什么设置新的值
    var obj = {
      _name: "why",
      // setter方法
      set name(value) {
        this._name = value
      },
      // getter方法
      get name() {
        return this._name
      }
    }

    obj.name = "kobe"
    console.log(obj.name)

类访问器

在class定义的类中,也是可以使用这两个访问器方法的。

可能在别人代码看到这样的_name的变量命名方式,这里提醒一,下程序员之间的约定,使用_定义的属性表示为私有属性,不可直接访问(但也只是约定,你想访问也是可以的)

class Person {
  constructor(name, age) {
    this.name = name
    this._age = 30 
  }

  get age() {
    console.log('age被访问')
    return this._age
  }

  set age(newValue) {
    console.log('age被设置')
    this._age = newValue
  }
}

const p = new Person('curry', 30)
console.log(p) // Person { name: 'curry', _age: 30 }
p.age // age被访问
p.age = 24 // age被设置
console.log(p) // Person { name: 'curry', _age: 24 }

6.类的静态方法

我们前面提到了类方法,并用构造函数讲解了什么是类方法

什么叫类的静态方法呢?

  • 类的静态方法”和“类方法”通常指的是同一个概念
  • 该方法不是供实例对象来使用的,而是直接加在类本身的方法,可以使用类名点出来的方法,可以使用static关键字来定义静态方法。

**

var names = ["abc", "cba", "nba", "mba"]
class Person {
      constructor(name, age) {
        this.name = name
        this.age = age
      }

      // 实例方法
      running() {
        console.log(this.name + " running~")
      }

      // 类方法(静态方法)
      static randomPerson() {
        console.log(this) // Person
        //产生随机名字
        var randomName = names[Math.floor(Math.random() * names.length)]
        return new this(randomName, Math.floor(Math.random() * 100))
      }
    }
 var p1 = new Person("lilei",12)
    p1.running() //lilei running
    //Person.randomPerson()调用静态方法
    var randomPerson = Person.randomPerson()
    console.log(randomPerson) // {Person:{age:18,name:cba}}

注意,这个例子中我们最后返回了一个 new this,这是为什么?

在静态方法中,这个this指向的就是Person(隐式绑定),所以最后可以返回一个new this,相当于返回了一个new Person

7.类的继承

7.1.extends关键字

在ES6之前实现继承是不方便的,ES6中增加了extends关键字,可以方便的帮助我们实现类的继承。

实现Student子类继承自Person父类:

    class Person {
      constructor(name, age) {
        this.name = name
        this.age = age
      }

      eating() {
        console.log(this.name + ' is eating.')
      }
    }

    class Student extends Person {
      constructor(sno) {
        this.sno = sno
      }

      studying() {
        console.log(this.name + ' is studying.')
      }
    }

那么子类如何使用父类的属性和方法呢?🤔

7.2.super关键字

使用super关键字可以在子类构造函数中调用父类的构造函数,但是在子类(派生)类的构造函数中使用this或者返回默认对象之前,必须先通过super调用父类的构造函数! ⚠️

    class Person {
      constructor(name, age) {
        this.name = name
        this.age = age
      }

      eating() {
        console.log(this.name + ' is eating.')
      }
    }

    class Student extends Person {

      constructor(name,age,sno){
          super(name,age)
          this.sno = sno
      }

      studying() {
        console.log(this.name + ' is studying.')
      }
    }

    const stu = new Student('curry', 30, 101111)
    console.log(stu) // Student { name: 'curry', age: 30, sno: 101111 }
    // 父类的方法可直接调用
    stu.eating() // curry is eating.
    stu.studying() // curry is studying.

7.3.为什么要先写super

  • this 的初始化依赖 super 的调用:

    • 在 ES6 中,当你创建一个子类时,this 是由父类的构造函数初始化的。
    • 在调用 super() 之前,this 尚未被定义。如果你尝试在调用 super 之前访问或操作 this,JavaScript 会抛出一个错误(ReferenceError: Must call super constructor in derived class before accessing 'this')。
    • 这是因为 super() 是一个特殊的语句,它的作用是调用父类的构造函数,并完成对 this 的初始化。如果不调用它,this 就无法正常使用。
  • 建立正确的原型关联
    在基于类的继承机制中,子类的原型对象(prototype)会通过原型链链接到父类的原型对象上,这一关联的建立与构造函数的调用顺序息息相关。调用 super 其实是在构建原型链的过程中确保了从子类到父类的正确链接顺序。 如果不先调用 super,子类的原型对象与父类原型对象之间的正确继承关联就无法建立,可能导致在子类实例调用从父类继承来的方法时出现找不到对应方法等错误,因为原型链的继承链路没有正确搭建起来。

  • JavaScript 的规范要求:

    • 在 ES6 的类规范中,子类构造函数在执行前必须先调用 super,否则无法正确完成对象的实例化。这是为了保证父类的逻辑先被执行,并为子类的初始化提供必要的上下文。 。

7.5.super的其他用法

但是super关键字的用途并不仅仅只有这个,super关键字一般可以在三个地方使用:

  • 子类的构造函数中(上面的用法);

  • 实例方法中

    • 1.重写父类方法
    • 2.复用父类方法
    • 3.重写静态方法(类方法)
    class Animal {
      running() {
        console.log("running")
      }
      eating() {
        console.log("eating")
      }
    
      static sleep() {
        console.log("static animal sleep")
      }
    }
    
    class Dog extends Animal {
      // 重写
      running() {
        console.log("dog四条腿running~")
        // 复用
        super.running()
      }
    
      // 重写父类的静态方法
      static sleep() {
        console.log("趴着")
        super.sleep()
      }
    }
    
    var dog = new Dog()
    dog.running()
    dog.eating()
    
    Dog.sleep()
    

image.png

8.继承和扩展内置类

extends关键字不仅可以实现继承我们自定义的父类,还可以继承JavaScript提供的内置类,可对内置类的功能进行扩展。

比如,在Array类上扩展两个方法,一个方法获取指定数组的第一个元素,一个方法数组的最后一个元素:

class myArray extends Array {
  firstItem() {
    return this[0]
  }

  lastItem() {
    return this[this.length - 1]
  }
}

const arr = new myArray(1, 2, 3)
console.log(arr) // myArray(3) [ 1, 2, 3 ]
console.log(arr.firstItem()) // 1
console.log(arr.lastItem()) // 3

额外补充: 我们也可以直接对内置类进行拓展

 // 2.直接对Array进行扩展
    Array.prototype.lastItem = function() {
      return this[this.length - 1]
    }

    var arr = new Array(10, 20, 30)
    console.log(arr.__proto__ === Array.prototype)
    console.log(arr.lastItem())

本质就是在内置类的原型上做修改

console.log(arr.__proto__ === Array.prototype) //true

9.类的混入

何为类的混入?在上面的演示代码中,都只实现了子类继承自一个父类,JavaScript的类只支持单继承,不支持多继承。如果非要实现继承自多个类呢?那么就可以引入混入(Mixin)的概念了。

看看JavaScript中通过代码如何实现混入效果:

// 封装混入Animal类的函数
    function mixinAnimal(BaseClass) {
      return class extends BaseClass {
        running() {
          console.log("running~")
        }
      }
    }

    function mixinRunner(BaseClass) {
      return class extends BaseClass {
        flying() {
          console.log("flying~")
        }
      }
    }

    class Bird {
      eating() {
        console.log("eating~")
      }
    }

    // var NewBird = mixinRunner(mixinAnimal(Bird))
    class NewBird extends mixinRunner(mixinAnimal(Bird)) {
    }
    var bird = new NewBird()
    bird.flying() //flying~
    bird.running() // running~
    bird.eating() // eating~

混入的实现一般不常用,因为参数不太好传递,过于局限,在JavaScript中单继承已经足够用了。

10.class定义类转ES5

上面介绍ES6中类的各种使用方法,极大的方便了我们对类的使用。我们在日常开发中编写的ES6代码都是会被babel解析成ES5代码,为了对低版本浏览器做适配。那么使用ES6编写的类被编译成ES5语法会是什么样呢?通过babel官网的试一试可以清楚的看到ES6语法转成ES5后的样子。

  • 刚开始通过执行自调用函数得到一个Person构造函数;
  • 定义的实例方法和类方法会分别收集到一个数组中,便于后面直接调用函数进行遍历添加;
  • 判断方法类型:如果是实例方法就添加到Person原型上,是类方法直接添加到Person上;
  • 所以class定义类的本质还是通过构造函数+原型链,class就是一种语法糖;

这里可以提出一个小问题:定义在constructor外的属性最终会被添加到哪里呢?🤔其实还是会被添加到类的实例化对象上,因为ES6对这样定义的属性进行了单独的处理。

class Person {
  message = 'hello world'

  constructor(name, age) {
    this.name = name
    this.age = age
  }

  eating() {
    console.log(this.name + ' is eating.')
  }

  static personMethod() {
    console.log('personMethod')
  }
}

const p = new Person('curry', 30)
console.log(p) // Person { message: 'hello world', name: 'curry', age: 30 }

扩展:在上图中通过通过babel转换后的代码中,定义的Person函数前有一个/*#__PURE__*/,那么这个有什么作用呢?

  • 实际上这个符号将函数标记为了纯函数,在JavaScript中纯函数的特点就是没有副作用,不依赖于其它东西,独立性很强;
  • 在使用webpack构建的项目中,通过babel转换后的语法更有利于webpack进行tree-shaking,没有使用到的纯函数会直接在打包的时候被压缩掉,达到减小包体积效果;

babel这个东西很好玩,这里就不展开讲解了,有时间的同学可以自己去研究一下🚀🚀