深刻 JavaScript 经常使用的8种继承方案

本文基于《JavaScript 经常使用八种继承方案》,细化了原理分析和代码注释,从原型链开始逐渐深刻至 ES6 的 extendsvue

原型链继承

这个是你们都知道的:react

function Parent(name) {
  this.name = name
  this.relation = ['grandpa', 'grandma']
}
Parent.prototype.say = function () {/*...*/}

function Child() {}
// 继承
p = new Parent('father')
Child.prototype = p

c1 = new Child()
c2 = new Child()
// 能够调用原型链上的方法
c1.say()
// 也能够获取父类实例的属性
console.log(c1.name, c2.relation)
// 直接修改父类实例属性
p.name = 'mother'
// 或者经过子类实例修改父类上的引用类型
c1.relation.push('grandson')
// 子类实例都会被影响
console.log(c1.name, c2.relation)
复制代码

原型链继承的不足:es6

  • 修改父类实例上的属性时,全部在此原型链上的对象的属性都会受影响
  • 当父类实例上有属性为引用类型时,全部在此原型链上的对象修改该属性时其余对象都会受影响
  • 调用子类构造函数时,不能向父类的构造函数传递参数

虽然这里只是构造函数,不是真正的类 class,不过姑且使用这个叫法算法

实践中,不多直接用原型链实现继承。express

借用构造函数继承

constructor stealingbabel

在子类构造函数中使用 applycall 调用父类构造函数。app

原本,父类构造函数中的 this 将会指向父类的实例,可是在子类构造函数中 call(this) 把上下文修改成了子类实例,至关于把父类实例的属性给子类实例复制了一份编辑器

function Parent(name) {
  this.name = name
}
function Child(name) {
  Parent.call(this, name)
}
c = new Child('child')
// c 自己就有 name 属性
console.log(c)
复制代码

使用原型链继承时,若是访问一个子类实例的属性,可是子类实例并无这个属性,那么会在子类实例的原型链上寻找,若是发现父类实例有这个属性,那么访问到的值是父类实例的,即原型链上的。同理,若是修改,也是修改的原型链上的。
而借用构造函数的方式,使得子类实例自己就有了这个属性,不须要再去原型链上找了。函数

这样一来:post

  • 能够在 call() 中向父类构造函数传递参数
  • 仍然能够访问父类实例上的属性,可是这些属性已经复制给了 c 本身,不是 c.__proto__ 上的,因此修改时不会影响其余子类实例
  • 由于没有使用原型链,因此子类实例不能访问父类原型对象上的属性和方法

实践中也不多使用。

到这里应该能够发现,当实现继承的时候,主要是针对下面两部分:

  • 父类实例上的实例属性和方法
  • 父类原型对象上的属性和方法

《当我谈继承时,我谈些什么》

组合继承

就是原型链继承+借用构造函数。

既然原型链继承让子类实例能够访问父类的原型对象;而借用构造函数让子类实例能够访问父类实例,而且修改父类实例属性时不影响其余子类实例,那么把二者结合一下岂不是美滋滋?

组合继承的原理就是这样:

  • 使用借用构造函数的方法,复制一份父类实例 p 的属性到子类实例 c
  • 使用原型链的方法,把子类实例添加到原型链上,使得子类实例也可以访问父类原型对象上的属性和方法,固然,这些属性方法仍然是位于 c.__proto__.__proto__ 上的

实现:

function Father(name) {
  // 父类实例属性
  this.first_name = name
  this.last_name = 'vue'
  this.age = 40
  this.address = {
    country: 'china',
    province: 'shanghai'
  }
}
// 父类原型方法
Father.prototype.say = function () {
  console.log(`I am ${this.last_name} ${this.first_name}`)
}
f = new Father('js')

// 子类
// 1. 借用构造函数
function Child1(name) {
  Father.call(this, name)
  // 注意,要先 call 父构造函数,再定义子类实例本身的属性
  // 不然子类实例属性会被父类实例同名属性覆盖
  this.age = 10
}
// 2. 原型链
// 修改原型对象
Child1.prototype = f
// 修改原型对象的构造函数
Child1.prototype.constructor = Child1

// 一样方法再建一个子类
function Child2(name) {
  Father.call(this, name)
  this.age = 9
}
Child2.prototype = f
Child2.prototype.constructor = Child2

c1 = new Child1('router')
c2 = new Child2('x')

print()
// 修改一下,不会对其余实例有影响
c1.address.country = 'usa'
f.last_name = 'react'
print()

function print() {
  console.log(c1)
  console.log(c2)
  console.log(f)
  // 子类实例也能访问父类原型对象上的方法
  c1.say()
}
复制代码

不过这里有一点瑕疵:一个子类实例将会持有两份父类实例的数据。

由于使用了原型链。
一份是 Father.call(this) 复制到子类实例 c 上的数据,一份是父类实例本来的数据,位于 c.__proto__ 上。

虽然冗余,不过使用效果上没有太大影响。
也有处理方案,就是后面的寄生组合式继承。

这是实践中经常使用的继承方式。

原型式继承

下面是《继8》中原型式继承的例子,附加了一些注释:

// 为一个对象生成子类实例的函数。其实 Object.create() 就是这样实现的
function object(obj){
  // 传入的参数 obj 就至关因而父类实例
  // F 就至关于子类构造函数,不过是空的,啥也没
  function F(){}
  // 把子类构造函数的原型对象设置为父类实例
  F.prototype = obj
  // 调用子类构造函数,建立一个实例并返回
  return new F()
}
// 至关于父类实例
var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
}
// 子类实例
var anotherPerson = object(person)
// 为子类实例添加实例属性
anotherPerson.name = "Greg"
// 再建立一个子类实例
var yetAnotherPerson = object(person)
yetAnotherPerson.name = "Linda"
// 修改子类实例的一个引用类型属性
anotherPerson.friends.push("Rob")
yetAnotherPerson.friends.push("Barbie")
// 父类实例上的属性也变了
console.log(person.friends) // "Shelby,Court,Van,Rob,Barbie"
复制代码

上面的 object() 函数其实就是 Object.create()
MDN 提供的 Object.create()polyfill 的核心代码就是上面 object() 的代码。

目前看来,感受跟原型链继承好像是没多大差异的。尤为是 object() 函数内部的代码,彻底就是原型链继承的套路。

以上面的代码为例分析一下的话:

  • 原型链继承,是先在子类构造函数中定义好了实例属性等等,而后 new 一个父类实例,把子类构造函数的原型指向该实例
  • 而原型式继承,已经有了一个父类实例,最后也一样是把子类构造函数的原型指向该实例,只不过在中间定义子类构造函数的时候,定义了一个空的函数

实际上,这个“只不过定义了一个空函数”正是跟原型链继承最大的区别。
后面的寄生组合式继承就会体现出它的做用了。

寄生式继承

是原型式继承的加强版。

在经过原型式继承生成了子类实例后,在返回以前处理了一会儿类实例,添加了一些属性或方法:

function createAnother(original){
  // 使用前面的 object 函数,生成了一个子类实例
  var clone = object(original)
  // 先在子类实例上添加一点属性或方法
  clone.sayHi = function(){
    console.log("hi")
  }
  // 再返回
  return clone
}
var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
}
var anotherPerson = createAnother(person)
anotherPerson.sayHi()
复制代码

寄生组合式继承

就是寄生式继承+借用构造函数继承。

前面在借用构造函数部分的结尾,总结了一下“究竟要继承哪些东西”,得出了两点:

  • 父类实例上的属性和方法
  • 父类原型对象上的属性和方法

借用构造函数实现了第一点,那么这里寄生式继承只要实现第二点就行了。

不对,不该该是“只要实现第二点就行了”,前面的原型链继承也能够实现第二点。
寄生式继承须要比原型链继承更优秀,否则就没什么意义了。

怎么才能“优秀”呢?
组合继承的结尾也提到了,它的一个缺点是会有两份父类实例的数据。
那么是否是能够把这一点优化掉?

这两份数据中,经过 Father.call(this) 复制到子类实例 c 上的这一份是真正须要的,而 c.__proto__ 上的这一份是多余的,是把子类实例放到原型链上时产生的反作用。

也就是说,须要让子类实例位于原型链上,可是不能让父类实例的属性位于原型链上

能够想到两个方法:

  • 通常来讲,为了把子类实例挂到原型链上,是须要一个父类实例的,若是能建立一个没有实例属性的父类实例就行了
  • 或者让子类实例绕过父类实例,直接继承父类的原型对象

寄生组合式继承使用了第一种方法。

对于一个构造函数 Test() 及其原型对象 Test.prorotype,使用 new Test()Object.create(Test.prototype) 均可以生成继承了该原型对象 Test.prorotype 的实例。
可是不一样的是,Object.create() 生成的实例能够没有实例属性:

function Test(name) {
  this.name = name
  this.age = 20
}

t1 = new Test()
t2 = Object.create(Test.prototype)

console.log(t1) // Test {name: undefined, age: 20}
console.log(t2) // Test {}
复制代码

构造函数只是创建原型链的途径,就算不经过构造函数也能够生成原型链。
MDN 关于 Object.create()介绍正是“使用现有的对象来提供新建立的对象的 __proto__”。

那么,至关因而把原型链继承中使用 new 建立父类实例改成使用 Object.create()

实现一下:

function Parent(name) {
  this.name = name
  this.age = 40
  this.relation = ['grandma', 'grandpa']
}
Parent.prototype.say = function () {
  console.log(this.name)
}
function Child(name) {
  Parent.call(this, name)
}

// 开始实现继承
// Object.create 建立没有实例属性的父类实例
p = Object.create(Parent.prototype)
// 修改子类构造函数原型对象
Child.prototype = p
// 这里的 p 只是个普通对象,没有 constructor 属性,手动添加一下
p.constructor = Child

// 测试一下
p1 = new Parent('father')
c1 = new Child('child 1')
c2 = new Child('child 2')
// 能够发现没有两份重复数据了
print()
// 修改父类实例,对子类实例没有影响
p1.age = 50
p1.relation.push('child 3')
// 修改父类原型对象,子类实例可以访问到新方法 speak
Parent.prototype.speak = function () {
  console.log('speak')
}
// 修改子类原型对象,其余子类实例也可以访问到新方法 marry
Child.prototype.marry = function () {
  console.log('married')
}
// 修改一个子类实例,对其余子类实例没有影响
c1.name = 'child 2 plus'
c1.relation.push('grandson')
print()

function print() {
  console.log(p1)
  console.log(Parent)
  console.log(c1)
  console.log(c2)
}
复制代码

这是最成熟的方法,也是如今库实现的方法。
ES6 的 extends 实现与寄生组合式继承基本一致。

上面还提到另外一种方法,让子类实例绕过父类实例,直接继承父类的原型对象。

首先,这里关于“父类”和“子类”的叫法不够严谨。
仅仅是在所谓的子类的构造函数中执行了一行 Parent.call(this) ,并不能让两个函数产生继承关系。并且这里目的只是想把 Parent() 实例的属性复制一份到 Child() 的实例中,原本跟继承也没有半点关系。

父类和子类的区分是在设置原型对象以后才产生的。

因此,若是把 Child() 的原型对象设置为 Parent.prototype,固然能够,不过从代码上来讲,Child() 其实变成了 Parent() 的兄弟;而从表现上来讲,由于 Child() 的实例持有一份 Parent() 的实例属性,倒也能算是 Parent() 的子类。

说到底,这第二种方法到底可不可行,会有什么问题,期待你们留言。

ES6 extends

这一部分只讲解一下 extends 的原理,至于 class 和 extends 的使用,看阮一峰的《ES6 入门 - Class 的继承》就好。

不过,看过这部分以后,必定会对 class 和 extends 的使用有更深刻的认识。

前面说,ES6 的 extends 核心代码与寄生组合式继承基本一致。
那么先看看下面的代码,是使用 Babel 解析后的 extends 的部分实现:

能够去 Babel 的在线编辑器上本身试一下

function _inherits(subClass, superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function")
  }
  // 这里其实就是寄生式继承,使得子类实例可以访问父类原型对象上的属性和方法
  // 建立了一个没有实例属性的父类实例,添加一个 constructor 属性,而后赋值给子类的原型对象
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      writable: true,
      configurable: true
    }
  })
  // 若是是寄生组合式继承,还须要使得父类的实例属性在子类上也有一份
  // 这里应该须要借用构造函数了,可是好像跟前面的借用构造函数不太像?
  if (superClass) _setPrototypeOf(subClass, superClass)
}
function _setPrototypeOf(subClass, superClass) {
  // 判断当前环境是否是有 Object.setPrototypeOf 方法,没有的话就实现一个
  _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(subClass, superClass) {
    // 把子类的 __proto__ 设置为父类
    subClass.__proto__ = superClass
    return subClass
  }
  return _setPrototypeOf(subClass, superClass)
}
复制代码

仍是像前面说的同样,要继承的内容有两部分:父类原型对象上的和父类实例上的
寄生式继承已经实现了前者,那么这个 _setPrototypeOf() 函数按道理应该就是实现了后者了。

可是我寻思这也不像以前的借用构造函数方法的 Father.call(this) 啊。

继续看 Babel 解析的 extends 的其余部分,还有这么一段:

// ...
_inherits(subClass, superClass); // 这一步执行完时,subClass.__proto__ = superClass
function subClass() {
  _classCallCheck(this, subClass);
  // 有了
  // 在这里经过 _getPrototypeOf 取出了 superClass,而后执行了 apply
  return _possibleConstructorReturn(this, _getPrototypeOf(subClass).apply(this, arguments));
}
// ...
复制代码

看到这里就足够了,说明 extends 的实现确实跟寄生组合式继承基本一致。

混入式继承

mixin

说白了就是把一个对象的属性复制到另外一个对象上去。

好比使用 Object.assign(target, source)。这个方法将全部可枚举的属性的值从一个或多个源对象复制到目标对象,并返回目标对象。

是浅拷贝。

《继8》里的例子经过借用构造函数的方式为子类实例添加父类实例的属性,经过混入的方式为子类实例添加父类原型对象的属性:

function Mother() {
  this.a = 'mom'
}
Mother.prototype.comfort = function () {
  console.log("that's ok")
}
function Father() {
  this.b = 'dad'
}
Father.prototype.hit = function () {
  console.log("you bastard!")
}
function Me() {
  // 借用构造函数,得到了 a 和 b 两个实例属性
  Mother.call(this)
  Father.call(this)
}

// 建立一个没有实例属性的 Mother 的实例
m = Object.create(Mother.prototype)
// 修改 Me 的原型对象,如今 Me 位于 Mother 实例的原型链上了
Me.prototype = m
// 修改构造函数
Me.prototype.constructor = Me
// 再把 Father 原型对象上的属性方法复制到 Me 的原型对象 m 上
// 如今,虽然 Me 的实例并不在 Father 实例的原型链上
// 可是也能够访问 Father.prototype 上的属性方法
Object.assign(Me.prototype, Father.prototype)

me = new Me()
console.log(me)
复制代码

实际上,考虑到父类的实例和父类的原型对象都是对象,因此在为子类实例添加父类实例的属性的时候,也能够直接使用混入。上面的代码能够修改成:

/** * Father Mother Me 的构造函数 */
// 跳过 Object.create,直接放在 Object.assign 里
m = Object.assign({}, Mother.prototype, Father.prototype)
Me.prototype = m

me = new Me()
console.log(me)
复制代码

打个广告

个人其余文章:

超详细的10种排序算法原理及 JS 实现》
《免费为网站添加 SSL 证书》
《详解 new/bind/apply/call 的模拟实现》

相关文章
相关标签/搜索