前端面试之JavaScript基础(六)—— 继承

963 阅读6分钟

继承是面向对象编程当中一个非常重要的概念,在 JavaScript 中我们可以通过原型链来模拟这种特性,今天我们就来认识一下 ES6 之前是如何实现继承这一功能的。

原型链继承

原型链继承:将子类的原型对象改写为父类的实例对象,再通过父类实例对象上的 [[Prototype]] 属性与父类的原型对象产生关联,从而达到让子类的实例对象能够使用父类原型上的属性和方法的目的。

function Parent() {
  this.type = 'Parent'
}

Parent.prototype.getType = function() {
  return this.type
}

function Child() {
  this.subType = 'Child'
}

Child.prototype = new Parent()

var instance = new Child()

console.log(instance.getType())  // Parent

上面的例子就是原型链继承的实现方法,子类实例可以访问父类的属性和方法。但是这种继承方法有一个非常大的问题,子类的所有实例的 [[Prototype]] 都会与这个父类的实例对象产生关联。当这个父类的实例对象上存在引用类型的属性时,又刚好某个子类实例通过方法修改了这个引用属性,那这种修改会影响到所有的实例。请看示例:

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

function Child() { }

Child.prototype = new Parent()

var instance1 = new Child()

instance1.colors.push('black')

var instance2 = new Child()

console.log(instance2.colors) // ["red", "blue", "green", "black"]

instance1instance2 共享原型对象的 colors 属性,这种互相影响的情况是程序设计当中应该尽量避免的。原型链继承的另一个问题是,子类在实例化时无法给父类的构造函数传递参数。

盗用构造函数(经典继承)

为了解决原型链继承中引用类型相互影响和无法向父类构造函数传递参数的问题,衍生出了盗用构造函数技巧,许多时候这种技巧也被称为经典继承。它会在子类当中调用父类的构造函数并使用 call 或者 apply 方法指定 this ,让子类实例化时先执行父类构造函数中初始化的逻辑。

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

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

var instance1 = new Child('O_c', 24)

instance1.colors.push('black')

var instance2 = new Child('馒头君', 24)

console.log(instance1.name, instance2.colors) // O_c ["red", "blue", "green"]

上面的例子为我们展现了如何盗用构造函数,这种技术其实相当于借用了父类构造函数的初始化逻辑。当我们在执行子类实例化时,用 call 方法改变 this 的指向,当父类构造函数执行时相当于是在子类生成的实例对象上面添加属性。这样操作后引用类型的属性是独立存在于各个实例对象当中的,与原型对象无关自然没有互相影响的问题了。

盗用构造函数这种继承方式也有缺点,当我们使用这种方式实现继承时,子类就无法访问父类原型上定义的方法。

组合继承

组合继承其实就是将原型链继承和经典继承相结合,通过互补规避各自的缺点:

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

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

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

Child.prototype = new Parent()

var instance1 = new Child('O_c', 24)

instance1.colors.push('black')

var instance2 = new Child('馒头君', 24)

console.log(instance1.getName()) // O_c

console.log(instance2.colors) // ["red", "blue", "green"]

组合继承通过原型链继承,实现了访问原型对象方法;又通过经典继承在实例对象上生成了 colors 属性,从而遮蔽了 Child 原型对象上的 colors 属性,这样实例的修改就不会互相影响。

原型式继承

如果你希望直接通过 [[Prototype]] 实现对象之间的信息共享,那么你就可以了解下原型式继承。实现方法如下:

function object(obj) {
  function Fn() {}
  Fn.prototype = obj
  return new Fn()
}

var originObject = {
  name: 'originObject',
  colors: ['red', 'blue', 'green']
}

var anotherObject = object(originObject)

console.log(anotherObject.name) // originObject

anotherObject.name = 'anotherObject'
anotherObject.colors.push('black')
console.log(originObject.name, originObject.colors) // originObject ["red", "blue", "green", "black"]

原型式继承需要借助一个工具函数,这个函数会将传入的对象作为临时构造函数 Fn 的原型对象,然后返回临时构造函数的实例,此时这个实例对象就和一开始传入的对象产生了关联关系。原型式继承当中同样存在原型对象上引用类型属性的问题。

上述例子中的工具函数是我们自己创建的,在 ES5 当中可以通过 Object.create(...) 实现原型继承,并且可以通过该函数的第二个参数显示指定实例对象的属性。

寄生式继承

寄生式继承可以看作是原型式继承的变式,它通过包覆函数来增强实例对象,为实例对象添加更多的功能。

var originObject = {
  name: 'originObject'
}

function createObject(originObj) {
  var anotherObj = Object.create(originObj)
  anotherObj.sayName = function() {
    console.log(this.name)
  }
  return anotherObj
}

var obj = createObject(originObject)

obj.sayName() // originObject

寄生式组合继承

寄生式组合继承是我们今天讲的最后一种继承方式,它主要是为了解决组合继承的效率问题。组合继承当中父类的构造函数始终会被调用两次,请看示例:

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

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

function Child(name, age) {
  Parent.call(this, name) // 第二次调用
  this.age = age
}

Child.prototype = new Parent() // 第一次调用

var instance = new Child('O_c', 24)

为了解决这个问题,我们可以使用寄生式继承的思想来替代掉父类构造函数第一次调用生成实例对象的操作。我们先来创建一个包装函数,它会使用父类的原型对象来创建一个新对象,然后将这个新对象关联到子类的原型对象上,这样父类构造函数就不用调用两次。

function inheritPrototype(parent, child) {
  var prototype = Object.create(parent.prototype)
  prototype.constructor = child
  child.prototype = prototype
}

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

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

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

inheritPrototype(Parent, Child)

var instance = new Child('O_c', 24)

console.log(instance.getName()) // O_c

寄生式组合继承解决了父类构造函数多次调用的问题,可以算是现阶段继承的最佳模式。

小结

今天我们学习了 ES6 之前如何实现继承,继承的主要类型有:

  • 原型链继承:缺点主要有无法向父类构造函数传递参数和原型对象上引用类型篡改导致实例对象互相影响。
  • 盗用构造函数(经典继承):解决了原型链继承的弊端,缺点在于无法共享父类原型对象的方法。
  • 组合继承:将原型链继承和盗用构造函数相结合解决了前两者的问题,缺点在于父类构造函数会执行两次影响效率
  • 原型式继承:可以直接通过包装函数将实例和源对象关联起来,但是存在源对象上引用类型篡改导致实例对象互相影响的问题。
  • 寄生式继承:原型式继承的变式,通过再包装来添加增强实例的功能。
  • 寄生式组合继承:是寄生式继承和组合继承的结合体,目前最佳的继承实践模式。