JS的组合继承和寄生组合继承

1,226 阅读4分钟

《JS高级编程》有一节讲继承,谈及组合继承和寄生组合继承两种方案,最后说寄生组合继承是最佳实践,为加深理解,本文梳理一下这两种方案。

继承要解决两个问题:公共属性的共享继承和实例属性的私有继承,共享继承是说所有实例共享一套属性,私有继承是说每个实例拥有自己的属性。

为了解决这两个问题,JS给出了两套技术:

  1. 通过「原型链」技术解决公共属性的共享继承问题。
  2. 通过「盗用构造函数」技术解决实例属性的私有继承问题。

原型链技术是指每个对象身上都有一个属性指向自己的上一级原型对象,原型对象还有原型对象,如此形成一个链条。JS在查询属性和方法时会按照这个链条依次向上查询,直到找到为止。

每个对象身上的这个指向原型对象的属性通常是__proto__,那这个属性是怎么来的呢?

JS中每个函数身上会有一个prototype属性,当new Function()创建对象时,JS会将函数的prototype属性赋给对象的__proto__

于是我们就可以通过如下的方式构建原型链:

function A() {}
function B() {}
function C() {}
B.prototype = new A();
C.prototype = new B();

不过,原型链不能解决实例属性的私有继承问题。构造函数Function内部会有很多的this.xxx=xxx用来设置实例属性,而父对象的实例属性构造逻辑无法在子对象身上得到复用,子对象如果要用就需要自己重新写一遍这些逻辑。

function A() {
  this.a = 1
}

function B() {
  this.a = 1
  this.b = 2
}

function C() {
  this.a = 1;
  this.b = 2;
  this.c = 3;
}

这样福对象的实例属性就不能很好的继承到子对象身上,于是JS社区开发出「盗用构造函数」技术来解决这个问题。在子对象的构造函数中调用父对象的构造函数实现逻辑复用。

function A() {
  this.a = 1;
}

function B() {
  A.call(this);
  this.b = 2;
}

function C() {
  B.call(this);
  this.c = 3;
}

当我们把「原型链」和「盗用构造函数」结合起来,就形成了第一种继承方法:组合继承。

function A() {
  this.a = 1;
}

function B() {
  A.call(this);
  this.b = 2;
}

function C() {
  B.call(this);
  this.c = 3;
}

B.prototype = new A()
C.prototype = new B()

不过,如果我们打印组合继承得到的对象原型,我们就会发现对象和原型对象重复出现了一些属性。

const b = new B();
const c = new C();

console.log(b, b.__proto__); // A { a: 1, b: 2 } A { a: 1 }
console.log(c, c.__proto__); // A { a: 1, b: 2, c: 3 } A { a: 1, b: 2 }

于是就造成了浪费,组合继承也被认为是效率较低的继承方式。那怎么解决呢?

观察之后不难发现,核心问题在于我们为了实现「原型链」执行了子.prototype = new 父()。但是这一步不是必须的,我们之所以这么执行是因为父.prototype = new 爷(),所以只要能够设计一个「替身函数」也能够实现替身.prototype = new 爷(),我们就可以执行子.prototype = new 替身()来实现原型链的构造。

JS社区提供了一种函数来实现new 替身()的构造。

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

这里的F()就是我们的替身函数,而传入的o就是我们的new 爷(),也就是父.prototype。仔细观察我们发现,这其实就是在「创建一个以某个对象为原型的对象」。后来JS原生实现了这个方法: Object.create(o)

万事具备,接下来只需要完成子.prototype = new 替身()也就是子.prototype = create(父.prototype)就可以了。

function setPrototype(subType, superType) {
  const prototype = Object.create(superType.prototype);
  subType.prototype = prototype;
}

于是,总体的继承过程就变成了这样:

function A() {
  this.a = 1;
}

function B() {
  A.call(this);
  this.b = 2;
}

function C() {
  B.call(this);
  this.c = 3;
}

// B.prototype = new A();
// C.prototype = new B();
setPrototype(B, A);
setPrototype(C, B);

const b = new B();
const c = new C();

console.log(b, b.__proto__); // A { a: 1, b: 2 } A {}
console.log(c, c.__proto__); // A { a: 1, b: 2, c: 3 } A {}

这就是所谓的「寄生组合继承」,结合了「盗用构造函数」和改良后的「原型链」构造方法,成为当前JS继承的最佳实践。

组合继承和寄生组合继承的原型链示意图如下:

image.png

大家可能会注意到console.log(b, b.__proto__); // A { a: 1, b: 2 } A {}打印出来的为什么是A {},因为subType.prototype = Object.create(superType.prototype)之后没有重新设置constructor属性。只要重置一下就可以解决,本文为了聚焦刻意舍弃了这部分,大家可以自行尝试。

function setPrototype(subType, superType) {
  const prototype = Object.create(superType.prototype);
  prototype.constructor = subType; // 重置constructor属性
  subType.prototype = prototype;
}