ES 面向对象 —— 继承

published

与常见的面向对象语言不同,ES 的继承模型基于原型,而不是基于类。

许多 OO 语言都支持两种继承方式:

  • 接口继承 —— 继承方法签名
  • 实现继承 —— 继承实际的方法

但由于 ES 的函数没有签名,所以无法实现接口继承,只支持实现继承,而且其实现继承主要依靠原型链来实现。

ES2015 新增了 class 关键字,但只是语法糖而已,ES 仍旧是基于原型的。

完全基于原型链的继承

基于原型链的继承虽然强大,但存在一些问题。

问题一:包含引用类型值的原型属性会被所有实例共享。 在通过原型来实现继承时,原型实际上会变成另一个类型的实例。于是,原先的实例属性也就顺理成章地变成了现在的原型属性了。这个变成原型属性的实例属性就被之后创建的实例共享了。

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

function SubType() {
}

SubType.prototype = new SuperType()

var instance1 = new SubType()
instance1.colors.push('black')
console.log(instance1.colors)  // ['red', 'blue', 'green', 'black']

var instance2 = new SubType()
console.log(instance2.colors)  // ['red', 'blue', 'green', 'black']

问题二:创建子类的实例时,不能向超类的构造函数中传递参数。 实际上,应该说是没有办法在不影响所有对象实例的情况下,给超类的构造函数传递参数。 由于这两个问题,实践中很少单独使用完全基于原型链的继承。

借用构造函数的继承

为了解决原型中包含引用类型值所带来的问题以及参数传递的问题,开发人员开始使用一种叫做借用构造函数(Constructor Stealing,也叫伪造对象或经典继承)的技术。

基本思想:在子类型构造函数的内部调用超类的构造函数。

函数只不过是在特定环境中执行代码的对象,因此通过使用 apply()call() 方法可以在将来新创建的对象上执行构造函数。

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

function SubType() {
  SuperType.call(this)
}

SubType.prototype = new SuperType()

var instance1 = new SubType()
instance1.colors.push('black')
console.log(instance1.colors)  // ['red', 'blue', 'green', 'black']

var instance2 = new SubType()
console.log(instance2.colors)  // ['red', 'blue', 'green']

再举一个向构造函数传递参数的例子:

function SuperType(name) {
  this.name = name
}

function SubType(name, age) {
  SuperType.call(this, name)

  this.age = age
}

var instance = new SubType('spike', 25)

console.log(instance.name)  // 'spike'
console.log(instance.age)  // 25

可以看到,这个模式解决了先前提到的两个问题,但新的问题又来了 —— 如果只利用这种模式,方法在构造函数中定义,函数复用就无从谈起了。而且,在超类的原型中定义的方法,对子类是不可见的,这就强制所有类型都要使用构造函数模式。由于这些原因,这个模式也很少单独使用。

组合继承(常用)

组合继承(也叫伪经典继承)指的是借用构造函数的模式和原型链的模式组合到一起,以发挥二者之长的一种继承模式。

基本思想:通过借用构造函数来实现对实例属性的继承,使用原型链实现对原型属性和方法的继承。

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

SuperType.prototype.sayName = function () {
  console.log(this.name)
}

function SubType(name, age) {
  // 创建实例属性
  SuperType.call(this, name)
  this.age = age
}

// 继承原型属性和方法
SubType.prototype = new SuperType()
Object.defineProperty(SubType.prototype, 'constructor', {
  enumerable: false,
  value: SubType
})
SubType.prototype.sayAge = function () {
  console.log(this.age)
}

var instance1 = new SubType('spike', 25)
instance1.colors.push('black')
console.log(instance1.colors)  // [ 'red', 'blue', 'green', 'black' ]
instance1.sayName()  // spike
instance1.sayAge()  // 25

var instance2 = new SubType('billy', 27)
console.log(instance2.colors)  // [ 'red', 'blue', 'green' ]
instance2.sayName()  // billy
instance2.sayAge()  // 27

组合继承避免了借用构造函数模式和原型链模式的缺陷,融合了它们的优点,成为 ES 中最常用的继承模式。而且, instanceofisPrototypeOf() 也能够用于识别基于组合继承创建的对象。

原型式继承

Douglas Crockford 在 2006 年写了一篇文章 —— Prototypal Inheritance in JavaScript,文章中介绍了一种没有使用严格意义上的构造函数的实现继承的方法。 基本思想:借助原型,基于已有的对象来创建新对象,并且不必创建自定义类型。

function object(o) {
  function F() {
  }

  F.prototype = o;
  return new F();
}

object() 函数内部,先创建了一个临时的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回这个临时类型的一个实例。从本质上讲, object() 对传入其中的对象执行了一次浅复制。看下面的例子:

var person = {
  name: 'spike',
  friends: ['billy', 'jilly']
}

var anotherPerson = object(person);
anotherPerson.name = 'bobo';
anotherPerson.friends.push('ken');

var yetAnotherPerson = object(person)
yetAnotherPerson.name = 'linda'
yetAnotherPerson.friends.push('babby')

console.log(person.friends)  // [ 'billy', 'jilly', 'ken', 'babby' ]

这种继承方式,要求必须有一个对象可以作为另一个对象的基础。如果有这个一个对象,先把它传递给 object() 函数,然后再根据具体需求对得到的对象进行修改。 ES5 通过新增 Object.create() 方法规范了原型式继承。这个方法接受两个参数:

  • 用作新对象原型的对象
  • 为新对象定义额外属性的对象(可选)

在传入一个参数的情况下, Object.create() 与上述的 object() 的行为相同:

var person = {
  name: 'spike',
  friends: ['billy', 'jilly']
}

var anotherPerson = Object.create(person)
anotherPerson.name = 'bobo'
anotherPerson.friends.push('ken')

var yetAnotherPerson = Object.create(person)
yetAnotherPerson.name = 'linda'
yetAnotherPerson.friends.push('babby')

console.log(person.friends)  // [ 'billy', 'jilly', 'ken', 'babby' ]

Object.create() 方法的第二个参数的说明见 MDN。 在没有必要创建构造函数,而只想让一个对象与另一个对象保持类似的情况下,原型式继承是可以胜任的。不过,别忘了,包含引用类型值的属性始终都会被共享。

寄生式继承

寄生式继承同样由 Douglas Crockford 推广,它与原型式继承类似,不同之处是它将具体步骤封装在了函数内部,通过返回值返回对象。 基本思想:创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后返回对象。

下面的代码示范了寄生式继承的使用:

function createWaiter(origin) {
  var o = Object.create(origin)  // 以 origin 为原型创建一个新对象
  o.sayHi = function () {        // 以某种方式来增强这个函数
    console.log('Hi')
  }

  return o                       // 返回这个对象
}

var person = {
  name: 'spike',
  friends: ['billy', 'jilly']
}

var aWaiter = createWaiter(person)
aWaiter.sayHi()  // hi

寄生式继承有缺点:在为对象添加函数时,由于不能做到函数复用而效率低下。

寄生组合式继承(最优)

之前提过,组合继承是最常用的继承模式,不过,它也有缺点。 组合继承最大的问题就是无论在什么情况下,都会调用两次超类的构造函数:

  1. 在创建子类的原型时,要调用超类的构造函数实例化一个超类的对象
  2. 在创建子类的实例时,要调用子类内部的超类的构造函数来创建实例属性

这两次调用,有重复的部分 —— 子类的原型会包含超类的全部实例属性,但之后在调用子类构造函数时,又会重新创建这些实例属性。

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

SuperType.prototype.sayName = function () {
  console.log(this.name)
}

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

// 继承
SubType.prototype = new SuperType();  // 第一次调用 SuperType()
Object.defineProperty(SubType.prototype, 'constructor', {
  enumerable: false,
  value: SubType
})

SubType.prototype.sayAge = function () {
  console.log(this.age)
}

第一次调用 SuperType 构造函数时,SubType.prototype 会得到两个属性:namecolors,它们都是 SuperType 实例的属性。当调用 SubType 构造函数时,又调用了一次 SuperType 的构造函数,这一次又在新对象上创建了实例属性 namecolors,这两个属性屏蔽了原型中的两个同名属性。重复的创建同名属性,显然是没有必要的。思考一下,第一次调用 SuperType 目的是什么?为了构建原型链。

这里使用的方法是「调用超类的构造函数来创建一个以超类原型为原型的对象,以作为子类的原型,从而构建原型链」,这个方法有上面提到的副作用 —— 重复地创建同名属性。有没有一种更纯粹的构建原型链的方式呢?寄生组合式继承 降临。

基本原理:本质上和组合继承类似,仍然是通过借用构造函数来继承属性,通过原型链来继承方法。不同之处在于,原型链的构建使用寄生式继承来完成。

寄生组合式继承的基本模式如下:

function inheritPrototype(subType, superType) {
  var prototype = Object.create(superType.prototpe);

  /**
   * prototype.constructor = subType;
   * 以这种方式重设 constructor 属性会让它的 [[Enumerable]] 特性被设置为 true
   * 然而,原生 constructor 属性是不可枚举的。
   */
   Object.defineProperty(prototype, 'constructor', {
     enumerable: false,
     value: subType
   })

   subType.prototype = prototype;
}

上述代码中的 inheritPrototype() 函数实现了寄生组合式继承的最简单形式。这个函数接受两个参数:子类构造函数和超类构造函数。在函数内部:

  1. 创建一个以超类原型为原型的对象
  2. 为创建的对象添加 constructor 属性,从而弥补因重写原型而失去的默认的 constructor 属性
  3. 将刚刚创建的对象赋值给子类型的原型

inheritPrototype() 改写之前的代码:

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

SuperType.prototype.sayName = function () {
  console.log(this.name)
}

function SubType(name, age) {
  SuperType.call(this, name)
  this.age = age
}

inheritPrototype(SubType, SuperType)

SubType.prototype.sayAge = function () {
  console.log(this.age)
}

这段代码只调用了一次 SuperType 的构造函数,也避免了在 SubType.prototype 中创建不必要的属性。同时,原型链还能保持不变,因此 instanceof 运算符和 isPrototypeOf() 函数也能正常使用。

开发人员普遍认为寄生组合式继承是实现基于类型继承的最佳方式。