ES6中的class对象和它的家人们

时间:2023-02-26 17:07:00

在ES6中新增了一个很重要的特性: class(类)。作为一个在2015年就出了的特性, 相信很多小伙伴对class并不陌生。但是在日常开发中使用class的频率感觉并不高(可能仅限于作者),感觉对class总有种一知半解的感觉。今天就带着小伙伴们一起,好好剖析剖析这个特性。

1.什么是class

一个特性的诞生,总是为了解决某些问题的。而class的诞生还要从ES5中的构造函数说起。

在ES5中为了能更加高效的创建对象,使用了一种名为构造函数模式 的方式创建对象。创建方式如下

function Animal() {}  // 构造函数
const animal = new Animal()
// 通过new的方式创建一个新的对象, 该对象称为构造函数的实例对象

我们发现上述的构造函数在定义的时候和普通的函数定义是一模一样的。而事实上,上述所谓的构造函数本质就是一个函数, 只是这个函数的作用是用于创建对象。这导致构造函数和普通的函数难以区分, 这是ES5构造函数的一个弊端。另一个问题是, ES5中的构造函在实现继承的时候,代码冗长且混乱(下文中有举例说明)。在这样的背景下class诞生了。

什么是class:ES5中的构造函数的语法糖,本质还是一个函数对象。用于高效的创建对象或实现继承。

2.创建一个class

和ES5中的构造函数一样,class也有两种创建方式:类声明和类表达式。我们分别的列举下

ES6中的class对象和它的家人们

上图中左右加起来共四种创建类的方式,其产生的结果基本是一致的。其中需要注意的是,左侧的函数式声明会存在函数提升的的过程,而类声明的方式不会进行提升。举例来说

const animal = new Animal()  // 可以成功创建实例
function Animal() {}

const animal = new Animal()  //抛错:Cannot access 'Animal' before initialization(不允许初始化前创建实例)
class Animal {}

而至于 左侧的变量式声明 和 右侧的类表达式声明,由于都是使用一个变量进行接收,所以都受变量提升影响。

3.class对象和他们的家人们

和class直接相关的有三个对象,分别是:实例对象(以下统称为实例), 类对象(定义一个类,类本身就是一个对象),原型对象。这三个对象是怎么创建和使用的呢?三个对象之间有什么关系呢?接下来将分别阐述他们。

3.1 实例对象

根据类,使用new关键字创建的对象,称之为这个类的实例。与之对应有两个成员:实例属性和实例方法(定义在实例自身的属性和方法,以下统称为实例成员)。该怎么定义这两种成员呢,有如下两种方式:

方式一: 实例创建之后,手动添加属性和方法

class Animal {}

const animal = new Animal()
animal.name = 'lsm'    // 添加属性
animal.move = () => {  // 添加方法
  console.log('moving ...')
}

但是这种方式,有个最大的弊端:当有些属性和方法需要每个实例都要有的时候,需要每次创建完实例之后都添加一遍。代码冗余度非常高, 并且要是都这样写class就失去了它的意义。

如果我们想在创建实例的时候就创建这些成员该怎么做呢?要实现这一点,需要提前在类中定义好这些成员。来看方式二。

方式二:在类中定义实例成员

想要在类中定义实例成员我们就要用到一个函数:constructor。那首先来了解下constructor吧。

  • constructor是什么:一个方法。定义在每个类对象的原型对象上(这两个对象将在3.2、3.3中进行讲解)。可以在类的代码块中进行重写。

  • constructor作用:初始化实例对象。

  • constructor参数:接收创建实例时传递进来的实参,用于初始化实例成员。

  • constructor特性:constructor函数体中的this指向实例,所以我们给this添加的成员,实际上就是添加在实例上。换而言之,给该this添加的成员就是实例成员。最终返回this。

  • constructor调用时机:在我们通过new关键字创建实例的时候,默认的会调用定义在类中constructor函数。如果在类中没有显示的定义constructor函数,则会调用类的原型对象上的constructor函数。

了解了constructor函数,接下用一组代码对上述的总结进行阐述

class Animal {
  // 重写了Animal原型对象上的constructor方法
  constructor(name) {   // 接收的name参数,用于初始化实例的name属性
    console.log("new 关键字调用")
    this.name = name    // 给实例添加name属性并赋值
    this.move = function(speed) {   // 给实例添加move方法
      console.log('moving speed ' + speed + ' m/s')
    }
  }
}

const animal = new Animal('lsm')  //new 关键字调用
console.log(animal.name)  // lsm
animal.move(10) // moving spead 10 m/s

在上述的案例中,我们了解了如何通过class定义一个实例成员。针对于方式一, 实际上就是在给一个普通的对象添加属性和方法,如果我们想在某个实例上加上独属于自身的成员,就可以使用方式一。

3.2 类对象

类是什么?在上述对类的定义中是这样定义的:ES5中的构造函数的语法糖,本质还是一个函数对象。 总结来说类是一个函数,验证的方式很简单: typeof关键字

class Animal {}
console.log(typeof Animal) // funtion
const animal = new Animal()
Animal()  // Uncaught TypeError: Class constructor Animal cannot be invoked without 'new'

虽然类本质的是个函数,但是我们并不能像调用函数那样调用它,像Animal()就会报错, 需要通过new关键字进行调用, 。

明白了类其实是一个函数对象,那么怎么给这个对象添加成员呢?其实我们可以像给一个普通的对象添加成员一样给类对象添加成员, 就像下面这样:

class Animal {}
Animal.age = 25;
Animal.move = () => {
  console.log("moving ...");
};

console.log(Animal.age)  // 25
Animal.move()  // moving ...

但class作为ES5中构造函数的语法糖,ES6中对这种给类对象添加成员的需求提供了一种新的方式:将需要添加的类成员直接定义在类代码块中,并在定义的成员前面添加static修饰符。我们将这种通过static修饰的成员称之为静态成员。 具体的实现如下:

class Animal {
  static age = 25
  static move() {
    console.log('moving ...')
  }
}

console.log(Animal.age)  // 25
Animal.move()   // moving ...

通过上述两个案例可以看出,虽然定义类成员的方式不同,但使用类成员的方式并没有区别。从结果而言,上述的两种定义类成员的方式是完全等价的。

针对于上述的案例我们不妨总结一下什么是静态成员:给类对象自身添加的成员称之为静态成员。在ES6中提供了使用static修饰符创建静态成员的方式。

知道了什么是静态成员,那静态成员有什么用呢?其实静态成员最主要的作用就是:脱离实例。创建与类本身强绑定的成员。 总的来说就是我想创建一些属性和方法, 但是这些属性和方法并不需要创建实例就能调用或者和实例本身就没啥关系。这句话可能不好理解,我用两个例子来说明下。

  1. Math.PI、Math.random(): Math中的这些成员都是静态成员。通过创建实例的方式去调用这些成员是毫无意义的(实际上也不能),因为这些属性的值或方法的结构全都是固定的。
  2. Array.isArray(): 这个方法的作用是判断所有类型的对象是不是数组,这和数组的实例没有一毛钱关系。
  3. ......

看到这,如果是细心的小伙伴,可能就会产生一些疑问:

  1. 为什么ES6中添加静态成员的时候需要添加static修饰符?
  2. 如果不加static修饰符,这个成员就不是静态成员了吗?
  3. 如果问题2成立,在3.1讲述constructor的时候,constructor这个函数是直接定义在class代码块中的,没有添加static,那我们创建实例的时候调用的constructor函数又是属于哪个对象的?

在回答这三个问题之前,我想重新带大家复习一遍,和class直接相关的三个对象:实例对象, 类对象,原型对象。这很重要!!!

我带大家首先验证一下问题2,下述代码会用到一个新的API:hasOwnProperty

hasOwnProperty方法的作用:可以检测一个成员是否存在于对象自身中(不包括原型),返回布尔值。只有当成员存在于对象自身时才会返回true,否则返回false

class Animal {
  static age = 25  // 静态属性
  static move() {  // 静态方法
    console.log('moving ...')
  }
  speed = 10  // 普通的属性
  constructor() {}  // 构造方法
}

console.log(Animal.hasOwnProperty('age'))  // true
console.log(Animal.hasOwnProperty('move'))  // true
console.log(Animal.hasOwnProperty('speed'))  // false
console.log(Animal.hasOwnProperty('constructor')) // false

通过上述的测试,可以发现通过static修饰的成员确实属于类对象本身。而没有static修饰的成员则不属于类对象本身。这就是问题2的答案。而至于这些没有satic修饰的成员到底属于哪个对象,将在3.4中进行总结归纳。

至于问题一的答案其实很简单:为了区分类对象自身的成员和其他成员。 可能有一些小伙伴对作者提出的问题一,觉得莫名其妙。其实这里作者是想加固小伙伴的认知:所谓static静态成员,就是在类对象本身的一个成员而已, static只是一个语法糖。

回到问题3,我们现在可以确认,constructor这个函数并不属于类对象, 那具体属于哪个对象?问题的答案就是原型对象

3.3原型对象

了解一个新的东西大概总是从这几个方面入手的:是什么?怎么用?存在意义?

3.3.1 是什么:一个对象。会伴随类的声明而创建的一个对象。类中通过prototype属性指向的一个对象。举例如下:
class Animal {}
console.log(Animal.prototype)  // 打印结果如下图

ES6中的class对象和它的家人们

默认情况下,该对象中只有一个constructor属性。上述案例的结果也证实了3.2中问题3的答案。也就是说通过new关键字创建一个对象的时候, 无论有没有在类中显示的声明constructor,调用的始终都是原型对象中的constructor方法。 并且针对上图中的打印结果我们发现一个有意思的点,原型对象上的constructor是一个属性,该属性指向的是类对象本身。 我们不妨打印看看

console.log(Animal.prototype.constructor === Animal)  // true

结果为true。看到这,有些小伙伴可能就迷惑了,constructor不是一个用于初始化实例的函数吗?现在怎么又变成了一个属性? 并且还指向类本身? 这都是些什么乱七八糟的。

首先, constructor这个属性指向的是类本身,而类本身就是一个函数,所以说constructor是一个函数并没有问题。其次,我们已经知道通过new关键字创建对象,最终调用的就是constructor, 而constructor指向的又是类本身,所以真正去创建对象的还是类本身。那为什么要绕这么一圈, 而不直接使用类本身创建对象。原因有如下两点:

  • constructor的作用是什么:初始化实例对象。我们要明白,在我们调用new关键字的那一刻就已经创建了一个对象,而constructor仅仅是初始化了这个对象,初始化完成再返回这个对象。我们可以简单的将constructor当成一个入口, 供开发者初始化实例的入口。所以我们需要调用constructor而不是直接调用类
  • 我们知道所有的数组都是Array类的实例, 那是怎么确定的呢?就是通过constructor。正是通过constructor的指向,我们才能确定实例对象属于哪个类(实现原理会在3.5中详讲)。这也是为什么需要让constructor指向类本身。

言归正传,了解了原型对象是什么,接下来说说,具体怎么用。

3.3.2 怎么用

我们知道直接定义类代码块中的 constructor,其实最终是定义在原型对象上的。我们可以进行一波猜测:直接定义在类代码块中的成员就是原型对象上的成员,用代码验证一波。

class Animal {
  move() {
    console.log('moving ...')
  }
  name = 'cat'
}

console.log(Animal.prototype.hasOwnProperty("move")) // true
console.log(Animal.prototype.hasOwnProperty("name")) // false

有意思的是,我们发现定义在类代码块中的方法确实是原型方法,但是定义在类代码块中的属性却不是。不是话又属于谁,接着验证。

const animal = new Animal()
console.log(Animal.hasOwnProperty("myName"))  // false
console.log(animal.hasOwnProperty("myName"))  // true

经过验证我们发现,直接定义在类代码块中的属性是实例属性。其实这个实例属性并没有多大意义,因为我们已经知道了可以在constructor初始化实例成员。所以在开发中这种定义方式相当少。

那为什么在设计的时候,将直接定义在类代码块中的属性当成是实例属性而不是原型属性呢?这就要牵扯到原型对象存在的意义了

3.3.3 存在的意义

我们先看一段简单代码

class Animal {
  constructor(name) {
    this.name = name
    this.move = () => {
      console.log(this.name + ' moving ...')
    }
  }
}

const animal = new Animal(cat)
animal.move() // cat moving ...
const animal1 = new Animal(cat2)
animal.move() // cat2 moving ...

这段代码很简单,就是创建了一个类和两个类的实例。这段代码有问题吗,逻辑上来说没有问题,但是有一个弊端,就是在对方法的处理上冗余度过高。上述代码中, 我们每创建一个实例,就会给这个实例添加一个move方法。但是move方法里面的处理逻辑是完全相同的, 如果大量的创建对象,将会占用大量的内存空间,浪费资源。

而解决这个问题就是原型对象最重要的责任之一。我们可以将一些实例公用的方法抽取到原型对象上。而原型对象只会随着类的创建而创建, 只会加载一次。 之后我们创建的实例可以直接调用这个原型对象上的方法。从而避免重复创建冗余的方法。至于实例为什么可以直接使用原型对象的上的方法将在3.5中介绍。改造一下上面的代码。

class Animal {
  move() {
    console.log(this.name + " moving ...");  // this指向方法的调动者
  }
  constructor(name) {
    this.name = name;
  }
}

const animal = new Animal(cat)
animal.move() // cat moving ...
const animal1 = new Animal(cat2)
animal.move() // cat2 moving ...

上述代码值得注意的一点是:move方法中的this和constructor中的this没有任何关系。constructor中的this指向的是实例。move方法中的this指向的是方法的调用者。

我们可以将一些公共的方法抽取到原型对象上。自然也可以将一些属性抽取到原型对象上。但大部分情况下我们不会这么做,因为将一个属性放到原型对象中之后,所有的实例将共享这个属性,这会导致数据的变更变得不可控。

大部分情况下我们可能希望每个对象都拥有自身的属性。这也回答了3.3.2中遗留的问题:为什么直接定义在类代码块中的属性当成是实例属性而不是原型属性呢? 因为在设计之初就并不希望开发者去定义原型属性。如果我们真的想定义原型属性, 可以采用ES5的方式:

Animal.prototype.myName = 'lsm'
const animal = new Animal(cat)
console.log(animal.name)  // cat
console.log(animal.myName) // lsm

看到这的小伙伴估计就会有一种感觉:属性和方法的定义好乱!!没事接下来我给大家总结一下。

3.4 三个对象中的成员归纳

  1. 想定义实例成员, 可以在constructor方法中进行初始化。对于实例属性也可以直接定义在类的代码块中。
  2. 想定义静态成员, 可以在类的代码块中的使用static 修饰符修饰属性和方法。也可以直接使用对象的形式添加(Obj.key=val)。
  3. 想定义原型成员, 可以通过对象的形式在类的原型上添加成员。对于原型方法, 可以直接定义在类的代码块中。

示例代码如下。

class Animal {
  name1 = 'lsm'  // 实例属性
  move1() {  // 原型方法
    console.log("moving1 ...")
  }

  constructor(name) { // 原型方法
    this.name = name  // 实例属性
    this.move = () => {  // 实例方法
      console.log("moving ...");
    }
  }

  static name2 = 'cat'  // 静态属性
  static move2() {  // 静态方法
    console.log("moving2 ...")
  }
}

Animal.name3 = "lion"  // 静态属性, 推荐使用static的方式
Animal.move3 = () => {  // 静态方法, 同上
  console.log("moving3 ...")
}

Animal.prototype.name4 = "cattle"  // 原型属性, 不推荐
Animal.prototype.move4 = () => {  // 原型方法, 推荐直接在类中定义
  console.log("moving4 ...")
}

下来我们来对比下ES5和ES6的类中定义不同对象成员的方式

ES6中的class对象和它的家人们

上图可以让我们可以很清晰的感知到, ES6中的class就是一个语法糖。

讲解上述的三种对象时, 我基本都是在说如何定义却没说使用。因为确实也没啥好说的。三种对象都可以使用自身的属性和方法,除此之外唯一需要注意的就是实例对象可以使用原型对象上的成员。但是为什么实例对象可以使用原型对象上的成员呢?接下来,让我们好好剖析下这三个对象之间的关系

3.5 实例对象, 类对象,原型对象之间的关系。

上文遗留了两个问题:

  1. 为什么通过constructor的指向,我们能确定实例对象属于哪个类。
  2. 为什么实例对象可以使用原型对象上的属性。

其实上述两个问题的答案是一致的。因为在实例对象中有一个默认的指针[[Prototype]]指向原型对象。不同浏览器对该指针有不同的实现方式。在chrome、Firefox等浏览器中的,对该指针的实现为__proto__属性。换而言之,我们可以通过__proto__属性访问到原型对象。 正是因为实例和原型对象之间存在这样的引用关系,我们才可以实现上述的两种操作。我们可以验证一波:

class Animal {
  move() {  // 定义了原型方法
    console.log('moving ...')
  }
}
Animal.prototype.myName = "cat"  // 定义了原型属性

const animal = new Animal()
console.log("animal = ", animal)  // 打印结果见下图
console.log("animal.__proto__ = ", animal.__proto__)

ES6中的class对象和它的家人们

通过上图我们可知, 实例中确实有一个[[Prototype]]指针(这个指针仅代表一种引用关系,无法被访问)指向一个对象,并且这个对象可以通过__proto__属性获取到, 但是这个指向的对象是不是原型对象呢。我们可以换一种能思路验证。

通过3.3.1可知:类对象通过prototype属性指向其原型对象。如果实例的__proto__属性和类对象的prototype属性相等, 是不是就可以证明实例的__proto__属性指向的是原型对象。

console.log(animal.__proto__ === Animal.prototype)  // true

验证的结果是肯定的。而在ES5中的instanceof方法正是通过这种方式来判断某个实例是否属于某构造函数。

而这种引用关系同样也是原型链查找的基础。所谓原型链查找就是:在调用一个对象属性的时候,会从对象自身开始查找,查找不到会去对象的原型上查找,并依次向上进行查找, 直到找到或查找到原型链的顶端null为止。

上面阐述了两种引用关系:

  1. 实例对象的[[Prototype]]指针指向原型对象。
  2. 类对象的prototype属性指向其原型对象。

需要注意的是,虽然实例对象和类对象都有属性指向原型对象,但是这两个对象之间没有任何直接引用关系。

在3.3.1中还阐述了另一种关系:原型对象中的constructor指向类对象。

我用图例来展示这三个对象之间的引用关系

ES6中的class对象和它的家人们

了解了这三个对象和他们之间的关系, 整个class基本上只剩一个东西:继承,一起看看吧

4.继承

开篇在,什么是class中我们提到:ES5中的构造函在实现继承的时候,代码冗长且混乱。那我们不妨先来看看ES5中的继承方式。ES5中的继承方式有很多,最常用的就是寄生式组合继承,我们就以寄生式组合继承为例:

function Animal(myName) {  // 父类
  this.myName = myName
}
Animal.prototype.move = () => {
  console.log("moving ...")
}

function Cat (myName, age) {  // 子类
  // 1.继承父类实例成员。这里就是将Animal当成一个普通的函数,通过call调用,返回的结果就是父类中的实例成员
  Animal.call(this, myName) 
  this.age = age
}

 // 2.继承父类原型对象成员。
 //  Object.create创建一个新对象,对象的原型是 Animal.prototype, 结果返回给子类的原型
Cat.prototype = Object.create(Animal.prototype) 
 // 3.此时子类的原型是空对象,下面的操作是给子类的原型添加constructor属性并指向子类自身
Cat.prototype.constructor = Cat

const cat = new Cat("lsm", 25)
cat.move() // moving ...

根据上述的代码可知,ES5中的寄生式组合继承大致分为三步:

  1. 继承父类实例成员
  2. 继承父类原型对象成员(执行完这一步,其实子类的原型是一个空对象)
  3. 添加子类的constructor指向自己(用于确定实例属于哪个类)

上述的代码不难看出,实现的过程还是比较复杂的,并且实现继承的一些步骤是写在构造函数的外部的,代码比较混乱。接下来我们来看看ES6中的继承吧。

class Animal {
  move () {
    console.log("moving ...")
  }

  constructor (myName) {
    this.myName = myName
  }
}

class Cat extends Animal {
  constructor(myName, age) {
    super(myName)
    this.age = age
  }
}

const cat = new Cat("lgt", 75)
cat.move()  // moving ...

以上两种继承方式的结果几乎是相同的。不难看出, class的继承方式简洁很多, 并且继承的步骤都是在类上执行的,比起ES5的继承方式更加内聚。

接下我来说明下ES6的继承步骤,主要依靠两个关键字extends和super。

  • extends 用于继承父类的原型对象成员。相当于ES5继承中的步骤2。除此之外,extends甚至还可以继承父类的静态成员当做子类的静态成员。这是ES5中的继承所不具备的。 示例代码如下

ES6中的class对象和它的家人们

从上述代码中我们还可以知道,父类中没有实例成员时,子类可以不用显式的声明constructor,但是在创建实例的过程中还是会隐式的调用constructor。

  • super 用于继承父类的实例成员。相当于ES5继承中的步骤1。 super的使用有一些注意点,但在此之前我想先和大家讨论下super是什么。

已知的,我们在子类的constructor中调用super时候,父类的constructor被调用了。我们又知道constructor指向的其实就是类本身。所以其实super最终指向的就是父类本身。在了解这一点之后我们再来看看super使用的注意事项。

  1. super调用位置可以是cosntructor或静态方法中。
  2. 子类的cosntructor被显式定义时,也必须显式的调用super方法。super接收的参数用于传递给父类的cosntructor
  3. super方法调用之前不能使用this。 这一点很好理解。super调用的是父类的cosntructor,cosntructor的作用是初始化并返回的this。所以在super调用之前,压根就拿不到this。
  4. 在静态方法中super可以调用父类的静态成员。 这点也很好理解,因为super指向的就是父类,调用父类自身的属性是合理的。这一点带大家实践一波
class Animal {
  static myName = "lsm"
  static move() {
    console.log('moving ...')
  }
}

class Cat extends Animal {
  static useAnimal() {
    console.log(super.myName)  // 调用父类的静态属性
    console.log(Cat.myName)  // 调用继承来的静态属性
    super.move()  // 调用父类的静态方法
    Cat.move()  // 调用继承来的静态方法
  }
}

Cat.useAnimal() // lsm
                // lsm
                // moving ...
                // moving ...

最后,站在三个对象的角度怎么理解继承呢,来看张图吧

ES6中的class对象和它的家人们

唯一需要注意的就是标红的那根线了。子类的原型对象其实也是一个普通对象, 是对象就有[[Prototype]]指针。该指针指向父类的原型对象。正是因为这种引用关系的存在, 我们才可以实现原型链查找。

以上就是今天的全部内容啦,谢谢各位看官老爷的观看。不好的地方,还请包涵。不对的地方,还请指正。

参考文献:JavaScript高级程序设计(第四版)