JS实现最佳继承

  继承是OO语言一个最为津津乐道的概念,JS继承主要依靠的是原型链的原理来实现的。关于原型链的内容,有不明白的可以阅读 《构造函数与原型链》一文。

# 纯原型链继承

  简单的说,继承的基本思想就是利用原型让一个引用类型继承另一个引用类型的属性和方法。为了方便理解,举例如下:

function Super() {
  this.name = 'zfs'
}
Super.prototype.sayName = function() {
  return this.name
}
function Sub() {
  this.sex = 'male'
}
// 继承了 Super,解释见下方
Sub.prototype = new Super()

var person = new Sub()

console.log(person.sayName())

  以上代码定义了两个类型 SuperSub,后者重写了原型对象为Super的实例,这意味着,Super的所有实例属性和原型属性都将成为Sub的原型属性,那么任意一个由Sub实例化出来的实例,自然就能访问到Super的所有属性,从而实现继承。其关系图如下:


  在上面的代码中,我们没有使用Sub默认的原型对象,而是将其更换成Super的实例。那么此时person.constructor指向的是Super而不是Sub,原因是因为在原型对象中的constructor也被重写了。

所有的函数的默认原型都是Object的实例,因此,默认原型内部都会包含一个指针指向Object.prototype,这就是为什么自定义类型会有toString()valueOf()这些方法的原因。

【缺陷】
(1)因原型属性共享于所有实例之中,当在实例中需要对超类中某个属性进行更新时,必然会影响到所有父类的其他实例,造成数据污染
(2)在创建子类的实例时,不能向超类的构造函数传递参数。

# 构造函数式继承

  这种方式是鉴于对纯原型链式继承缺陷的改进,来进一步提升继承的实用性。思路即在子类构造函数的内部调用超类构造函数。什么意思?
  函数只不过是在特定的环境中执行代码的对象,且它具有私有作用域。当我们将超类在子类内部调用时,就将超类的作用域限定在了子类内部,是一种“借调”的思想,超类属性就变成了子类的实例属性,从而实现子类不同实例对象所继承来的属性互补干扰的效果。
  涉及到函数执行环境改变,很容易想到使用apply()call()来实现。赘述一下applycall第一个参数都是thisapply其他参数封装在一个数组里,call则直接列出即可。

function Super() {
  this.colors = ['red', 'blue']
}
function Sub() {
  Super.call(this)
}

var instance1 = new Sub()
instance1.colors.push('green')
console.log(instance1.colors) // "red, blue, green"

var instance2 = new Sub()
instance2.colors.push('yellow')
console.log(instance2.colors) // "red, blue, yellow"

  从两个打印结果也可以看出,子类实例已经实现了数据隔离,另外,因为超类会在子类内部调用,因此,但实例化的时候给子类传递参数,也能够被超类所接手,如下

function Sub(c) {
  Super.call(this, c)
}
var instance3 = new Sub('pink')

【缺陷】
(1)由于超类中的所有属性都会转成子类的实例属性,包括方法,导致了方法无法复用
(2)在超类的原型中定义的方法对子类型不可见,结果导致只能使用构造函数定义方法,还是不能复用方法

# 组合继承(最常用)

  结合以上两种继承的优点,其思路是 使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对属性的继承。以达到方法能够复用,属性能够隔离的目的。

function Super(name) {
  this.name = name
  this.colors = ['red', 'blue']
}
Super.prototype.sayName = function() {
  console.log(this.name)
}
function Sub(name) {
  Super.call(this, name) // 第二次调用超类型
  this.sex = 'male'
}

Sub.prototype = new Super()  // 第一次调用超类型
Sub.prototype.constructor = Sub // 修正构造器丢失问题

var instance1 = new Sub('zfs')
instance1.colors.push('green')
console.log(instance1.colors) // "red, blue, green"
instance1.sayName() // 'zfs'

var instance2 = new Sub()
instance2.colors.push('wmm')
console.log(instance2.colors) // "red, blue, yellow"
instance2.sayName() // 'wmm'

  细心的你可能会疑惑,这里使用了原型链继承,是否依旧会有超类实例属性共享的问题。答案是有的,它们依旧正常存在于子类的原型属性中,当时因为子类内部实现了对超类属性的“借调”,子类实例属性中也有一份超类实例属性的属性备份,因此覆盖了原型属性中的那部分,从而子类实例对象中实例属性并不会互相影响。
【缺陷】
(1)正如以上所说,在子类的实例中其实是有两份来自超类的实例属性的,一份来自于原型对象中的原型属性,一份来自于“借调”超类而来的实例属性,也就是说,他调用了两次超类型构造函数。

# 寄生组合式继承

  说这个继承方式之前,必须先理解Object.create()的用法

* Object.create()

Object.create(proto[, propertiesObject])方法用于在现有提供的原型对象下创建一个新的对象,接受两个参数:
(1)proto: 必须。表示新建对象的原型对象,该参数会复制到目标对象的原型上。可以是null普通对象,函数的prototype属性
(2)propertiesObject: 可选。添加到新创建对象的可枚举属性,这些属性会被添加为实例属性

对比 new Object()Object.create()的区别

var proto = { like: 'apple' }
var inst1 = new Object(proto)
console.log(inst1) // { like: 'apple' }
console.log(inst1.__proto__) // {}

var inst2 = Object.create(proto)
console.log(inst2) // {}
console.log(inst2.__proto__) // { like: 'apple' }


  回到寄生式组合,它的思路是创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作一样返回对象。

function createAnother(origin) {
  var clone = Object.create(origin) // 根据入参对象创建新对象,将入参对象属性挂到原型上
  clone.sayHi = function() {  // 给对象创建一个实例方法来增强对象
    console.log('hello')
  }
  return clone;  // 返回对象
}

  以上代码的执行思路就是,把“超类”对象 origin传递给一个工厂函数createAnother(),在工厂函数中,利用Object.create()把超类属性都挂到新对象的原型上,再创建一些实例属性来增强对象,最后将其输出。这样由该工厂函数返回的对象中就可以实现我们所需要的实例属性与原型属性。使用createAnother()函数方法如下:

var super = {
  name: 'zfs',
  colors: ['red', 'blue']
}
var person1 = createAnother(super)
person1.sayHi() // 'hello'

【缺陷】创建的实例方法不可复用,与构造函数模式类似

# 寄生组合式继承(最理想)

  组合继承是最常用的继承方式,不过它也有自己的不足,即无论什么情况下,都会调用两次超类构造函数:第一次是在创建子类型原型的时候,第二次是在子类型构造函数内部。也就是说子类型最终会包含超类型对象的全部实例属性在原型对象中,但又不得不调用子类型构造函数来重写这些属性。
  寄生组合式继承通过借用构造函数来实现继承属性,通过原型链的混成形式来继承方法,基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,需要的无非就是超类原型的一个副本而已,实例属性并不需要,因此我们可以构造如下超类实例化的步骤。

function inheritPrototype(sub, super) {
  var prototype = Object.create(super.prototype)  // 创建对象
  prototype.constructor = sub  // 增强对象
  sub.prototype = prototype  // 指定对象
}

  以上代码中,第一步是创建超类型原型的副本,第二步是为创建的副本增加constructor从而弥补因重写原型而失去的默认属性。第三步,将创建的对象赋值给子类的原型。这样,我们就可以调用inheritPrototype()函数的语句,去替换重写子类原型为超类实例的语句了。用法如下:

function Super(name) {
  this.name = name
  this.colors = ['red', 'blue']
}
Super.prototype.sayName = function() {
  console.log(this.name)
}
function Sub(name, age) {
  Super.call(this, name)
  this.age = age
}
inheritPrototype(Sub, Super)
Sub.prototype.sayAge = function() {
  console.log(this.age)
}
var ps1= new Sub('zfs', 25)
var ps2= new Sub('wmm', 18)
ps1.colors.push('pink')
console.log(ps1.colors) // ['red', 'blue', 'pink']
console.log(ps2.colors) // ['red', 'blue']

其原型关系如下所示


  这种模式的高效率体现在它只调用了一次 Super构造函数,避免了建立多余属性,于此同时还能保持原型链不变,因此能够正常使用instanceofisPrototypeOf()。是最理想的继承方式。