阅读 23

JS中“继承”的合成公式

文前预告

  今天咱们来简单聊聊JS的继承,咱们按这个顺序来:原型链继承、借用构造函数继承、组合继承原型式继承、寄生式继承、寄生组合式继承。

  为啥要按这个顺序?合成类游戏往往都把合成原料放在开头,是吧~

文前准备

  需要先认识一下“啥是prototype”,“原型链是什么”,说到这,巧了,我上一篇文章讲的便是这方面的知识,所以建议先看完《用自己的方式(图)理解constructor、prototype、__proto__和原型链》这篇文章后才继续往下,当然如果你都足够了解了,可以继续往下。

正文开始

下方例子全部都以下面的Parent类为父类。

function Parent () {
    // 设置私有属性
    this.nick = '父亲',
    this.identity = ['码农', '奶爸']
}
// Parent放在prototype中的共享方法
Parent.prototype.say = function () {
  console.log('Hello!')
}复制代码

【合成原料一】原型链继承

// 即将作为子类
function Child () {}

// 【关键语句】将Child构造函数的prototype属性指向Parent实例对象
Child.prototype = new Parent()

var child = new Child()

// 【效果一】可以看到Child类的实例对象child继承了Parent类的属性
console.log(child.nick) // '父亲'
// 【效果二】同时也能够使用父类放在prototype中的共享方法(原型链的作用)
child.say() // Hello!复制代码

【图片过程展示】


【解析】这个不深入讲了,了解prototype、原型对象是了解大部分继承的前提,如果看不懂上面一张图,还是建议看上面提到的文章,否则下面是看不下去的。

【缺陷分析】这种继承方式被我当成“合成原料”,原因很简单,它的缺陷非常明显,也导致很多人是不会直接拿来就用的。下面举个列子让你知道这问题有多严重:

// 假如创建了两个child实例
var child1 = new Child()
var child2 = new Child()

// 然后只给其中的child2添加了新身份
child2.identity.push('财务')
console.log(child2.identity) // [ '码农', '奶爸', '财务' ]
console.log(child1.identity) // [ '码农', '奶爸', '财务' ]

// 【缺陷】什么?为什么child1中的identity也变了,明明没有动过呀!
复制代码

【图片过程展示】


【解析】可以看到这种继承方式使得所谓子类实例出的两个对象中的__proto__都会指向同一个Parent实例对象,所以只要有一个改变了identity这种引用类型的值,会导致所有实例取得的identity都发生变化。如果你还没有意识到问题的严重性,那再举个例子,你和舍友共用一台冰箱(里面吃的也是共享的),结果转眼间你舍友将零食全吃了,然后你就只剩下一台空冰箱了。(这里的冰箱可是引用类型数据)。

但注意噢,直接赋值不会影响到自己的原型对象,而是直接在对象自身添加该属性和值。

  所以原型链继承除了你自己确切知道怎么巧用,别的情况下还是要慎重呀。

【合成原料二】借用构造函数继承

  上面原型链继承的明显缺陷也自然会让人开始思考:那怎么能够继承并拥有独立属性呢?

// 有的人在想下面这两句是给父类添加私有属性的语句,那要是儿子们都各自调用呢?
this.nick = '父亲',
this.identity = ['码农', '奶爸']复制代码

  有道理!相信call方法大伙都懂,A.call(B)以B的执行上下文环境执行A的函数执行语句,在我们这里呢,其实就是拿父类函数中的执行句子在子类中执行,同时将内部this指向子类。

// 即将作为子类
function Child() {
  // 【关键语句】在Child自己的执行上下文环境中执行Parent方法
  Parent.call(this)
}

// 验证
var child1 = new Child()
var child2 = new Child()

// 然后只给其中的child2添加了新身份
child2.identity.push('财务')
console.log(child2.identity) // [ '码农', '奶爸', '财务' ]
console.log(child1.identity) // [ '码农', '奶爸' ]

// 【效果】让人兴奋,child的实例对象真的有独立属性了,之间互不影响!复制代码

  问题又来了,那Child实例对象仍能使用Parent原型对象中的共享方法吗?调用看看:

child1.say() // TypeError: child1.say is not a function
// 【缺陷】根本找不到父类放在prototype中的共享方法复制代码

【图片过程展示】


【解析】相信了解原型链的人都知道,实例出来的child1(2)对象的原型对象还是Child的prototype,这里面可没有放着Parent类共享的方法,所以自然找不到,这么严重的缺陷,所以自然这种继承方式也被我当做合成原料。

  看到这里有些小伙伴估计想到了,既然原型链继承和借用构造函数继承的优点互补(一个方便共享、一个支持私有),那可以结合着用吗?是的,的确可以,并且这还被认为是一种新的继承方式——组合继承。

【中级合成物】组合继承 = 原型链继承 + 借用构造函数继承

// 即将作为子类
function Child() {
  // 【关键语句一】在Child自己的执行环境中执行Parent方法(借用构造函数继承)
  Parent.call(this)
}
// 【关键语句二】将Child构造函数的prototype属性指向Parent实例对象(原型链继承)
Child.prototype = new Parent()

// 验证
var child1 = new Child()
var child2 = new Child()

// 然后只给其中的child2添加了新身份
child2.identity.push('财务')
console.log(child2.identity) // [ '码农', '奶爸', '财务' ]
console.log(child1.identity) // [ '码农', '奶爸' ]

child1.say() // Hello!(这是通过原型链找到的共享方法)
// 【效果】让人兴奋,child的实例对象真的有独立属性了,之间互不影响!同时共享方法也能用了!复制代码

  既有独立属性又能使用共享方法,该继承方法多棒!

  真的是吗?咱们来看看它的图解:

【图片过程展示】


【解析】优点咱们不讲了。问题呢,有没有发现作为Child的原型对象的Parent实例对象中的属性(nick,identity)是多余的?当然现在所使用的例子问题是不大的,但是如果Parent构造函数中的执行过程非常复杂或者属性设置非常多,那Parent实例对象的创建开销就非常大了,真有必要拿这个实例对象作为原型对象吗?

  这种继承方式被作为中级合成物,是因为其虽带有小缺陷但用也是没问题的,不过相信咱们一定对高级合成物更感兴趣。

  后来有人提出了原型式继承,给这个问题带来了解决方法。下面来谈谈啥是原型式继承。

【合成原料三】原型式继承

  这种继承方式与“原型链继承”差了一个字,实际上的确差别也不大。

  原型式继承就是拿已有对象作为原型对象(不完全对,请继续往下看)。举例:

// 【关键】存在一个已有对象
var obj = {
  nick: '对象',
 identity: [ '码农', '奶爸' ],
  say: function () {
    console.log('Hello!')
  }
}
// 即将作为子类
function Child() {}
// 【关键语句】拿已有对象作为原型对象
Child.prototype = obj

// 验证
var child = new Child()
console.log(child.nick) // 对象
console.log(child.identity) // [ '码农', '奶爸' ]
child.say() // Hello!复制代码

  为何说不完全对?如果这时改变原型对象内部的属性,那原有的对象obj就会被影响到,假设咱们把这个已有对象当成父类,那子类改值把父类的值给改了,这可不就乱套了!

  所以需要在中间多一层过渡,保护原有对象同时方便扩展。

// 【关键】多一层过渡
function inheritObject (o) {
  function F() {}
  F.prototype = o
  // 在这里方便添加扩展
  F.prototype.a = -1
  // 这样使得进行属性操作的是内部的F实例出的对象,而不是原有对象o
  // 同时这能通过原型链继承原有对象o中的共享属性
  return new F()
}
// 即将作为子类
function Child() {}
// 【关键语句】拿过渡对象作为原型对象
Child.prototype = inheritObject(obj)

// 验证
var child = new Child()
console.log(child.nick) // 对象
console.log(child.identity) // [ '码农', '奶爸' ]
child.say() // Hello!复制代码

【图片过程展示】


【解析】所以原型式继承正确的是在原有对象基础上构建新对象作为函数原型。

   但是有些小伙伴很会快指出这种继承的问题,它和原型链继承如出一辙嘛,并且问题都出在引用属性的继承上,一旦修改所有子类都会被影响。所以这也是我将其定义为合成原料的原因,不过这种继承方法可是合成高级合成物的关键!

【扩展】Object.create()其实就是inheritObject函数的官方版。

// 第一个参数就是已有函数
var obj = Object.create({}, {
  // 第二个参数用于扩展新的属性,是一个key:value键值对对象
  // 内部属性定义属性定义跟Object.definePropeties()方法定义对象的属性一样
  a: {
    vwritable: false,
    configurable: true,
    value: -1,
    enumerable: true
  }
})
console.log(obj) // {a: -1}复制代码

  下面用Object.create()替代inheritObject函数。

【中级合成物】寄生式继承 = 原型式继承 + “调料”

  这个其实没什么好讲的,其实就是原型式继承加强版。

var obj = Object.create({})
// 【关键】其实就是给原型式继承生成的过渡对象扩展功能,添加一些共享方法什么的
function enhance (p) {
  p.jump = function () {
    console.log('jumping')
  }
  // 扩展完就返回扩展后的对象
  return p
}
// 【关键语句】拿扩展后的过渡对象作为原型对象
Child.prototype = obj复制代码

  所以没什么好讲,就名字高大上。

【高级合成物】寄生组合式继承 = 借用构造函数继承 + 寄生式继承

  好了,关键来了,刚刚有提到说原型式继承可以解决组合继承的缺点:造成不必要开销。

  怎么解决?先分析问题:

一、子类拿父类实例对象作为原型对象,这便意味着该原型对象中会含有父类Parent给自己子类的留下的私有属性;而对于子类而言,在借用构造函数继承中就已经自行创建了这些私有属性,所以原型对象中的私有属性是相当多余的(懵了回去看组合继承相关图解)。

二、如果Parent构造函数执行过程非常复杂,那对于只需要共享数据的我们来说,创建一个实例对象就是一件得不偿失的事情了。

三、Parent构造函数将共享数据就放在它自身的原型对象上,摁~

四、Parent的原型对象在Parent函数声明时就创建了,可以直接拿来用,摁?!!!

  这问题不就解决了吗!Parent的原型对象是已存在的对象,然后又存放着父类Parent的共享数据,这不恰好能用来原型式继承吗!而且又不会造成不必要开销,代码走起:

// 即将作为子类
function Child() {
  // 【关键语句一】在Child自己的执行环境中执行Parent方法(借用构造函数继承)
  Parent.call(this)
}
// 【关键语句二】将Child构造函数的prototype属性指向基于Parent原型对象构建的新对象(原型式继承)
Child.prototype = Object.create(Parent.prototype, {
  // 修正因为重写子类原型对象而导致constructor指向错误的问题,上面都没加,其实是懒啦,应该加上的
  constructor: {
    vwritable: false,
    configurable: true,
     value: Child,
     enumerable: false
  }
})

// 验证
var child1 = new Child()
var child2 = new Child()

// 然后只给其中的child2添加了新身份
child2.identity.push('财务')
console.log(child2.identity) // [ '码农', '奶爸', '财务' ]
console.log(child1.identity) // [ '码农', '奶爸' ]

child1.say() // Hello!(这是通过原型链找到的共享方法)
// 【效果一】让人兴奋,child的实例对象有其独立属性了,之间互不影响!并且能够使用共享方法!
console.log(Child.prototype) // Child {}// 【效果二】Child的原型对象上没有多余的属性,也不需要再new一个新的Parent实例了,这少了很多开销复制代码

  图就不画了,能理解前面的这里必然也没有问题。不过其实这里还是有点小问题的,名字叫“寄生”组合式继承,那“寄生”呢?所以其实咱们不使用简单的原型式继承,而是使用原型式继承加强版——寄生式继承。

// 【关键】改成这样就OK了
function enhance (p) {
  // 在此处扩展...
  return p
}
// 加一个扩展函数
Child.prototype = enhance(
  Object.create(Parent.prototype, {
    // 修正因为重写子类原型对象而导致constructor指向错误的问题
    constructor: {
      vwritable: false,
      configurable: true,
      value: Child,
      enumerable: false
    }
  })
)复制代码

文后总结

  于我而言,JS中的“继承”是其高灵活度的产物,这六种继承方式的设计也是值得我们好好研究和思索的。难道你不觉得JS这门语言越探索越有意思吗?反正我觉得是。

  最后你觉得ES6的extends是用哪种继承方式的呢?

class Parent {
  constructor() {
    this.nick = '父类'
    this.identity = ['码农', '奶爸']
  }
  say() {
    console.log('Hello!')
  }
}
class Child extends Parent {
  constructor() {
    super()
  }
}

let child1 = new Child()
let child2 = new Child()
// 【验证一】看看引用类型属性是不是公共的 => (有私有属性)
console.log(child1.identity === child2.identity) // false
// 【验证二】看看两个对象中的say方法是不是同一个 => (有共享方法)
console.log(child1.say === child2.say) // true
// 【验证三】看子类原型对象中有没有多余的私有属性 => (子类原型对象中没有多余的私有属性)
console.log(Child.prototype) // Child {}
// 【验证四】看看子类原型对象中的__proto__是不是指向Parent原型对象
console.log(Child.prototype.__proto__ === Parent.prototype) // true复制代码

  你觉得符合哪种呢?

End

  如有错漏之处,敬请指正。


关注下面的标签,发现更多相似文章
评论