JS基础: 继承

153 阅读8分钟

继承

原型链继承

function Parent () {
  this.name = 'kevin'
}

Parent.prototype.getName = function () {
  console.log(this.name)
}

function Child () {

}

Child.prototype = new Parent()

const child1 = new Child()

console.log(child1.getName()) // kevin

存在两个问题:

  1. 引用类型的属性会被多个实例共享,举个例子:

    function Parent () {
      this.names = ['kevin', 'daisy']
    }
    
    function Child () {
    
    }
    
    Parent.prototype.getName = function () {
      console.log(this.name)
    }
    
    Child.prototype = new Parent()
    
    const child1 = new Child()
    const child2 = new Child()
    
    child1.names.push('yayu')
    
    console.log(child1.names) // ["kevin", "daisy", "yayu"]
    console.log(child2.names) // ["kevin", "daisy", "yayu"]
    

    为什么会存在这种问题呢?

    其实,Child.prototype = new Parent() 相当于 const p1 = new Parent(); Child.prototype = p1,可以改写为下面代码:

    function Parent () {
      this.names = ['kevin', 'daisy']
    }
    
    function Child () {
    
    }
    
    Parent.prototype.getName = function () {
      console.log(this.name)
    }
    
    const p1 = new Parent()
    
    Child.prototype = p1
    
    const child1 = new Child()
    
    console.log(child1)
    

    我们在控制台打印实例 child1:

extends1.png

我们可以看到,child1.__proto__ === p1p1.__proto__ === Parent.prototype

JS基础: 原型与原型链一节中有讲到,函数的 prototype 属性指向了一个对象,这个对象正是调用该构造函数而创建的实例的原型。所以这里 Parent.prototype 指向的也是一个对象,这个对象就是 p1 的原型。

再展开来看:

extends2.png

Parent.prototype.__proto__ === Object.prototype,p1 的原型的原型就是 Object.prototype 了。

回到刚才的问题,即为什么会存在引用类型的属性被多个实例共享的呢?

因为这里实际上是 child1.__proto__ === p1,child1 继承的是 p1 这个对象,child1 继承的 names 属性也始终是 p1 对象中的那个 names(也就是继承的引用地址始终是同一个),所以其他实例 child2, child3 等等都会继承同一个 names。

换种方式理解,使用 child1.names 时,在自身里找不到 names 这个属性,于是顺着原型链往上找,在 p1 对象中找到了 names 属性。使用 child2.names 时,在自身里找不到 names 这个属性,顺着原型链往上找也是在 p1 对象中找到了 names 属性,于是他们使用的是同一个 names 属性。

而如果是使用构造函数 new 出来的两个对象,它们里面的 names 属性的引用地址才会不同,如下:

function Parent () {
  this.names = ['kevin', 'daisy']
}

const p1 = new Parent()
const p2 = new Parent()

p1.names.push('Coran')
console.log(p1.names) // ['kevin', 'daisy', 'Coran']
console.log(p2.names) // ['kevin', 'daisy']
  1. 在创建 Child 的实例时,不能向 Parent 传参。

    因为 Child 构造函数里面未使用 Parent.call(this, 参数)

借助构造函数实现继承

function Parent () {
  this.names = ['kevin', 'daisy']
}

function Child () {
  Parent.call(this)
}

const child1 = new Child()
const child2 = new Child()

child1.names.push('yayu')

console.log(child1.names) // ["kevin", "daisy", "yayu"]
console.log(child2.names) // ["kevin", "daisy"]

优点:

  1. 避免了引用类型的属性被所有实例共享。

  2. 在创建 Child 的实例时,可以向 Parent 传参。

    举个例子:

    function Parent (name) {
      this.name = name
    }
    
    function Child (name) {
      Parent.call(this, name)
    }
    
    const child1 = new Child('kevin')
    const child2 = new Child('daisy')
    
    console.log(child1.name) // kevin
    console.log(child2.name) // daisy
    

缺点:

方法都需要在构造函数中定义,每次创建实例都会创建一遍方法。

因为,这种方式不能继承父类原型链上的属性,只能继承父类显式声明的属性。比如,通过 Parent.prototype.say 给 Parent 新增一个 say 方法,那么 child1 不能继承。

组合继承

function Parent (name) {
  this.name = name
  this.colors = ['red', 'blue', 'green']
}

Parent.prototype.getName = function () {
  console.log(this.name)
}

function Child (name, age) {
  Parent.call(this, name)
  this.age = age
}

Child.prototype = new Parent()
Child.prototype.constructor = Child

const child1 = new Child('kevin', '18')
const child2 = new Child('daisy', '20')

child1.colors.push('black')

console.log(child1.name) // kevin
console.log(child1.age) // 18
console.log(child1.colors) // ["red", "blue", "green", "black"]

console.log(child2.name) // daisy
console.log(child2.age) // 20
console.log(child2.colors) // ["red", "blue", "green"]

优点:融合了原型链继承和构造函数继承的优点,是 JavaScript 中最常用的继承模式。

使用 Parent.call(this) 解决了引用类型的属性被所有实例共享的问题

不过,为什么需要这一句呢?

Child.prototype.constructor = Child

我们可以看上面原型链继承方式中的一个图片:

extends2.png

可以看到这里 child1 继承了对象 p1,但是 p1 中只有 __proto__,没有 constructor 属性来指向 Child 这个构造函数,那么这时如果打印 child1.constructor,那么在 child1 和 p1 中都找不到这个属性,就会到 Parent.prototype 中找,结果是找到了 Parent.prototype.constructor,也就是 Parent 构造函数,这显然是不对的。

child1 的构造函数应该是 Child 才对,所以我们需要在 p1 上增加一个 constructor 属性指向 Child,所以才有这一句 Child.prototype.constructor = Child

我们再回过头来看JS基础: 原型与原型链一节中的原型链图解,上面的那句代码相当于添加了 Person.prototype.constructor === Person 这条链路,这时整个原型链才完整了:

prototype1.png

寄生组合式继承

上面组合继承最大的缺点是会调用两次父构造函数。

一次是设置子类型实例的原型的时候:

Child.prototype = new Parent()

一次在创建子类型实例的时候:

const child1 = new Child('kevin', '18')
// 这个时候执行了 Child 构造函数中的 Parent.call(this, name),所以在这里我们又调用了一次父构造函数

所以,在这个例子中,如果我们打印 child1 对象,我们会发现 Child.prototype 和 child1 都有一个属性为colors,属性值为['red', 'blue', 'green']。且 Child.prototype 还有个 name 属性,为 undefined

extends3.png

这些就是因为 Child.prototype = new Parent() 这句执行 new Parent() 时没传参数,所以会产生一个 colors 数组和一个未定义的 name 属性。

理论上 Child.prototype 上应该是没有这两个属性的,所以我们需要某种方法来避免 Child.prototype = new Parent() 这一句重复执行构造函数。

我们可以想想,Child.prototype = new Parent() 这一句的作用是什么呢?

换个角度来想,Parent.call(this)这句是能使我们继承显示声明的属性,但不能继承原型上的属性。所以 Child.prototype = new Parent() 这句的目的是能让我们继承原型上的属性,既然如此,我们可以换种方式实现这个目的,也就是间接的让 Child.prototype 访问到 Parent.prototype:

const F = function () {}
F.prototype = Parent.prototype

Child.prototype = new F()

像上面这样写就可以间接的让 Child.prototype 访问到 Parent.prototype 了,由此我们还可以联想到 Object.create() 方法的模拟实现:

function createObj(o) {
  function F () {}
  F.prototype = o
  return new F()
}

将 Parent.prototype 传进去的话,就是:

function createObj(Parent.prorotype) {
  function F () {}
  F.prototype = Parent.prototype
  return new F()
}

所以我们可以借用 Object.create() 方法来简化一下,以下就是简化后的寄生组合式继承代码:

function Parent (name) {
  this.name = name
  this.colors = ['red', 'blue', 'green']
}

Parent.prototype.getName = function () {
  console.log(this.name)
}

function Child (name, age) {
  Parent.call(this, name)
  this.age = age
}

// 关键的三步
// var F = function () {}
// F.prototype = Parent.prototype
// Child.prototype = new F()
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child

const child1 = new Child('kevin', '18')

console.log(child1)

最后打印出来的 child1 如图所示:

extends4.png

可见,Child.prorotype 中,已经没有了 namecolors 这两个属性,成功避免了重复执行两次父构造函数的问题。

引用《JavaScript高级程序设计》中对寄生组合式继承的夸赞就是:

这种方式的高效率体现它只调用了一次 Parent 构造函数,并且因此避免了在 Parent.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

ES6 继承

class Parent {
  constructor() {
    this.name = 'Kevin'
  }
}

class Child extends Parent {
  constructor() {
    super()
    this.age = 13
  }
}

const child1 = new Child()

代码中:

  1. 类 Child 通过 extends 关键字继承类 Parent 的原型对象上的属性
  2. 通过在类 Child 的 constructor 函数中执行 super() 函数,让类 Child 的实例继承类 Parent 的构造函数中定义的属性

super() 的作用类似构造函数中的 Parent.call(this),但它们是有区别的,下面会详述。

ES5继承与ES6继承的区别

我们以SubClassSuperClassinstance为例

ES5中继承的实质是:(经典寄生组合式继承法)

  • 先由子类(SubClass)构造出实例对象this
  • 然后在子类的构造函数中,将父类(SuperClass)的属性添加到this上,SuperClass.apply(this, arguments)
  • 子类原型(SubClass.prototype)指向父类原型(SuperClass.prototype
  • 所以instance是子类(SubClass)构造出的(所以没有父类的[[Class]]关键标志)
  • 所以,instanceSubClassSuperClass的所有实例属性,以及可以通过原型链回溯,获取SubClassSuperClass原型上的方法

ES6中继承的实质是:

  • 先由父类(SuperClass)构造出实例对象this,这也是为什么必须先调用父类的super()方法(子类没有自己的this对象,需先由父类构造)
  • 然后在子类的构造函数中,修改this(进行加工),譬如让它指向子类原型(SubClass.prototype),这一步很关键,否则无法找到子类原型(注,子类构造中加工这一步的实际做法是推测出的,从最终效果来推测
  • 然后同样,子类原型(SubClass.prototype)指向父类原型(SuperClass.prototype
  • 所以instance是父类(SuperClass)构造出的(所以有着父类的[[Class]]关键标志)
  • 所以,instanceSubClassSuperClass的所有实例属性,以及可以通过原型链回溯,获取SubClassSuperClass原型上的方法

所以,总的来说,ES5 与 ES6 继承的区别:

  1. 在 ES6 中类 Child 继承了类 Parent 的属性;在 ES5 中,构造函数 Child 没有继承构造函数 Parent 的属性。
  2. ES5 是先创建子类的实例对象 this,ES6 是先创建父类的实例对象 this。

ES6中在super中构建this的好处

因为 ES6 中允许我们继承内置的类,如 Date,Array,Error 等。

但如果 this 先被创建出来,再传给 Array 等系统内置类的构造函数,这些内置类的构造函数是不认这个 this 的。 所以需要先在 super 中构建出来,这样才能有着 super 中关键的[[Class]]标志,才能被允许调用。(否则就算继承了,也无法调用这些内置类的方法)

举个例子:

// ES5 继承
function MyArray() {
  Array.call(this)
}

MyArray.prototype = Object.create(Array.prototype, {
  constructor: { value: MyArray, writable: true, configurable: true }
})
// 相当于
// MyArray.prototype = Object.create(Array.prototype)
// MyArray.prototype.constructor = MyArray

const colors = new MyArray()
colors[0] = "red"
colors.length // 0
// 因为其无法获取 length 方法
// ES6 继承
class MyArray extends Array {
  constructor() {
    super()
  }
}

const arr = new MyArray()
arr[0] = 12
arr.length // 1
// 可以获取 length 方法