JavaScript 面向对象专题-继承的多种方式

308 阅读11分钟

前言

JavaScript 是一种灵活的语言,兼容并包含面向对象风格、函数式风格等编程风格。我们知道面向对象风格有三大特性和六大原则,三大特性是封装、继承、多态,其中继承是面向对象编程中讨论最多的话题。很多面向对象语言都只是两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为没有签名。实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。

原型链是所有继承的基础

修改原型是 JavaScript 中最常用的构建对象系统的方法,它的好处是可以在实例构造之后,动态的影响这些实例。也就是说,对象实例的特性不但可以通过 new 运算符构造出来,也可以通过原型修改来持续获得。

原型继承的实质就是对原型修改效果的传递,它基于原型链的特性:访问属性时,如果子对象没有该属性,则将访问其原型的属性类。实际上,所有继承方式的本质都是原型链

下面我们就来介绍一下基于原型链的多种继承方式。

1. 原型链继承

原型链继承就是通过构造函数的方式,将父类的实例赋值给子类的显示原型(prototype`)上,以此类推,每一层都可以继承上一层的属性。

function Parent() {}
Parent.prototype.package = ['书']

function Children() {}
// 继承了 Parent
Children.prototype = new Parent()

var instance1 = new Children()
var instance2 = new Children()
instance1.package.push('笔')

console.log(instance1.package) // ['书', '笔']
console.log(instance2.package) // ['书', '笔']
console.log(instance1.package === instance2.package) // true 

上例中,一开始 Children.prototype 是 Parent 函数的实例,所以继承了 Parent.prototype 上的属性,此时 Children.prototype 的结果为 ['书'],当实例 instance1 向数组中添加值的时候,发生了浅拷贝,所以 Children.prototype 的结果就变成了 ['书', '笔'],所以 instance2 创建之后,就继承了数组添加后的结果。

原型链继承的问题

  1. 通过原型继承时,原型上的所有属性都是共享的,如果这个属性(prototype.package)是引用类型的值,那么中途继承的实例(parent)一旦将属性修改,修改后的值也会在新继承的实例上获取到。
  2. 子类型在实例化时,无法在不影响所有对象实例的情况下,给父类型的构造函数传参。

2. 借用构造函数

借用构造函数也称之为“经典继承”或“对象伪装”。基本思路很简单:在子类构造函数中调用父类构造函数。因为毕竟函数就是特定上下文中执行代码的简单对象,所以可以使用 apply() 和 call() 方法以新创建的对象为上下文执行构造函数。

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

function Children () {
  // 继承了 Parent,同时传递了参数
  Parent.call(this, 'sunshine')
  // this.name = 'sunshine'

  // 实例属性
  this.age = 25
}

var instance = new Children()
console.log(instance) // { age: 25, name: "sunshine" }

Parent 接收一个参数 name,然后将它赋值给一个属性。在 Children 构造函数中调用 Parent 构造函数时传入这个参数,实际上会在 Children 的实例上定义 name 属性。为确保 Parent 函数不会覆盖 Children 定义的属性,所以在调用父函数再给子函数添加了 age 属性。

借用构造函数的问题

  1. 必须在构造函数中定义方法,函数不能重用
  2. 子类不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式

3. 对象继承

对象继承也叫作原型式继承,就是即使不自定义类型也可以通过原型实现对象之间的信息共享。在 ECMAScript5 之前的版本中是这样实现的:

function object(o) {
  // 创建一个临时构造函数
  function F () {}
  // 将传入的对象赋值给这个构造函数的原型
  F.prototype = o
  // 返回这个临时类型的一个实例
  return new F()
}

var parent = {
  name: '父亲',
  getName: function() {
    console.log(this.name)
  }
}

var children = object(parent)

parent.getName() // "父亲"
children.getName() // "儿子"

ECMAScript5 通过增加 Object.create 方法将原型式继承的概念规范化了。该方法接受两个参数:第一个参数为新对象的 prototype,第二个参数描述了新对象的属性,格式如在 Object.defineProperties() 中使用的一样。

var parent = {
  name: '父亲',
  getName: function() {
    console.log(this.name)
  }
}

var children = Object.create(parent, {
  name: { value: '儿子' }
})

parent.getName() // "父亲"
children.getName() // "儿子"

综上所述,对象继承非常不适合单独创建构造函数,但是仍然需要再对象之间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

4. 寄生式继承

与对象继承最接近的一种继承方式是寄生式继承。寄生式继承的思路:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。实际上,寄生式继承就是将创建对象的过程做了一个封装。

function inherit (obj) {
  var cloneObj = Object.create(obj)
  cloneObj.getName = function() {
    console.log(this.name)
  }
  return cloneObj
}

var parent = {
  name: 'sunshine'
}

var children = inherit(parent)
children.getName() // 'sunshine'

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object() 函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。

寄生式继承的问题

通过寄生式继承给对象添加函数会导致函数难以重用,与借用构造函数模式类似。

5. 组合继承

组合继承也叫伪经典继承,指的是将原型链继承和经典继承的技术组合到一块,从而发挥二者之长的一种继承模式。组合继承的思路:使用原型链实现对原型属性和方法的继承,通过构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。

function Parent(name) {
  this.name = name
  this.package = ['书']
}

Parent.prototype.getName = function () {
  console.log(this.name)
}

function Children (name, age) {
  // 继承属性
  Parent.call(this, name) // 第2次调用Parent

  this.age = age
}

// 继承方法
Children.prototype = new Parent() // 第1次调用Parent
Children.prototype.getAge = function () {
  console.log(this.age)
}

// 使用
var instance1 = new Children('sunshine', 20)
var instance2 = new Children('colorful', 30)
instance1.package.push('笔')

console.log(instance1.package)
instance1.getName()
instance1.getAge()

console.log(instance2.package)
instance2.getName()
instance2.getAge()

console.log(instance1 instanceof Parent) // true
console.log(Parent.prototype.isPrototypeOf(instance1)) // true

组合继承弥补了原型链和借用构造函数的不足,也保留了 instanceof 操作符和 isPrototypeOf() 方法的识别能力。

组合继承的问题

效率问题:父类构造函数始终会被调用2次,一次是在创建子类型时调用,另一次是在子类构造函数中调用。这样就会导致子类和实例上都有相同的属性

6. 寄生式组合继承

寄生式组合继承通借用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本,即:使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。

function inherit(children, parent) {
	children.prototype = Object.create(parent.prototype)
  children.prototype.constructor = children
  children.__proto__ = parent
}

function Parent(name) {
  this.name = name
  this.package = ['书']
}

Parent.prototype.getName = function () {
  console.log(this.name)
}

function Children (name, age) {
  // 继承属性
  Parent.call(this, name)

  this.age = age
}

inherit(Children, Parent)
Children.prototype.getAge = function () {
  console.log(this.age)
}

// 使用
var instance1 = new Children('sunshine', 20)
instance1.getName()
instance1.getAge()

这里的 inherit 函数实现了寄生式组合的核心逻辑。这个函数接收两个参数:子构造函数和父构造函数。

  1. 创建父类原型的一个副本
  2. 给返回 prototype 对象设置 constructor 属性,解决由于重写原型导致的 constructor 丢失问题
  3. 将创建的对象赋值给子类型的原型

寄生式组合继承可以算是引用类型继承的最佳模式。但是它还有一些不足之处,就是没有对静态属性的继承。

function inherit(Child, Parent) {
	var prototype = Object.create(Parent.prototype)
  prototype.constructor = Child
  Child.prototype = prototype
  
  Child.super = Parent
  
  // 静态属性继承
  if (Object.setPrototypeOf) {
    Object.setPrototypeOf(Child, Parent)
	} else if (children.__proto__) {
    Child.__proto__ = Parent
  } else {
    for (var k in Parent) {
      if (Parent.hasOwnProperty(k) && !(k in Child)) {
        Child[k] = Parent[k]
      }
    }
  }
}

function Parent(name) {
  this.name = name
  this.package = ['书']
}

Parent.prototype.getName = function () {
  console.log(this.name)
}

function Children (name, age) {
  // 继承属性
  Parent.call(this, name)

  this.age = age
}

inherit(Children, Parent)
Children.prototype.getAge = function () {
  console.log(this.age)
}

// 使用
var instance1 = new Children('sunshine', 20)
instance1.getName()
instance1.getAge()

7. 内置对象继承

我们知道,浏览器的内置对象有很多种,其中包括 Date,Number,String等,对于这种内置的构造器而言,是不能用上面的方式继承的。我们以Date为例:

function inherit(Child, Parent) {
	var prototype = Object.create(Parent.prototype)
  prototype.constructor = Child
  Child.prototype = prototype
  
  Child.super = Parent
  
  // 静态属性继承
  if (Object.setPrototypeOf) {
    Object.setPrototypeOf(Child, Parent)
	} else if (children.__proto__) {
    Child.__proto__ = Parent
  } else {
    for (var k in Parent) {
      if (Parent.hasOwnProperty(k) && !(k in Child)) {
        Child[k] = Parent[k]
      }
    }
  }
}

function Children () {
  // 继承属性
  Date.call(this, arguments)
  this.foo = 'bar'
}

inherit(Children, Date)
Children.prototype.getMyTime = function () {
  return this.getTime()
}

// 使用
var instance1 = new Children()
instance1.getMyTime() // Uncaught TypeError: this is not a Date object.

执行以上代码会报错,原因是日期对象只能通过 Date 构造函数来实例化生成。因此,V8引擎的实现代码中对getTime 方法的调用有所限制,如果发现调用 getTime 方法的对象不是 Date 构造函数构造出来的实例,则抛出错误。

所以上面的代码可以这样改写:

function DateConstructor () {
  var arr = Array.prototype.slice.call(arguments)
  var date = Function.prototype.bind.apply(Date, [Date].concat(arr))
  var dateObj = new(date)()

  Object.setPrototypeOf(dateObj, DateConstructor.prototype)

  dateObj.foo = 'bar'
  return dateObj
}

Object.setPrototypeOf(DateConstructor.prototype, Date.prototype)
DateConstructor.prototype.getMyTime = function () {
  return this.getTime()
}

// 使用
var instance1 = new DateConstructor()
console.log(instance1.getMyTime()) // 1608286470528

我们来分析一下以上代码:调用new DateConstructor()返回 dateObj ,此时 dateObj.__proto__ === DateConstructor.prototype ,由此可以得出 dateObj.__proto__.__proto__ === Date.prototype 。也就是说 DateConstructor 返回的是一个真正的 Date 对象。原因如下:

var arr = Array.prototype.slice.call(arguments)
var date = Function.prototype.bind.apply(Date, [Date].concat(arr))
var dateObj = new(date)()

这段代码中,dateObj 终究由 Date 构造函数实例化出来的,因此它有权调用 Date 原型上的方法,而不会被引擎所限制。

整个实现过程通过更改原型关系,在构造函数中调用原生构造函数 Date,并返回其实例的方法,"欺骗"了浏览器。当然,这样的做法比较取巧,其副作用是更改了原型关系,同时会干扰浏览器进行某些优化操作。

那么,有没有更好的方式呢?答案——类继承

8. 类继承

在ES6时代,我们可以使用 class extends 进行继承。上面继承 Date 的代码可以改写如下:

class DateConstructor extends Date {
  constructor () {
    super()
    this.foo = 'bar'
  }
  getMyTime () {
    return this.getTime()
  }
}

var date = new DateConstructor()
console.log(date.getMyTime())

直接在支持 ES6 class 的浏览器中运行该方法完全没有问题。可是我们的项目大部分都是使用 Babel 进行编译的,读者可以观察 Babel 编译 Class 的过程,分析并运行编译后产出的结果。

这里附上Babel在线测试的链接:

babeljs.io/repl#?brows…

我们都知道 ES6 中的 class 其实就是 ES5 中的语法糖。下面写一个简单的类继承的例子,然后通过 Babel 编译结果来分析它。

class Parent {
  constructor () {
    this.type = 'parent'
  }
}

class Children extends Parent {
  constructor () {
    super()
    this.type = 'children'
  }
}

let child = new Children()
child.type // 'parent'

child instanceof Children // true
child instanceof Parent // true
child.hasOwnProperty('type') // true

首先我们定义了一个 Parent 类,其中只包含了 type这个属性,不包含方法,Children 类也继承了同样的属性,通过验证原型链也是没有问题的。下面我们来分析以上代码编译后的结果:

function _inheritsLoose(subClass, superClass) {
  // 创建新对象,继承父类显示原型
  subClass.prototype = Object.create(superClass.prototype)
  // 修复构造器
  subClass.prototype.constructor = subClass
  // 继承父类隐式原型
  subClass.__proto__ = superClass
}

var Parent = function Parent() {
  "use strict"

  this.type = 'parent'
}

// 立即执行函数
var Children = /*#__PURE__*/function (_Parent) {
  "use strict"

  _inheritsLoose(Children, _Parent)

  function Children() {
    var _this

    _this = _Parent.call(this) || this
    _this.type = 'children'
    return _this
  }

  return Children
}(Parent)

var child = new Children()

不难发现,class编译后的结果和寄生式组合继承是很相似的。唯一的区别是,Children是一个立即执行函数,这是因为Children 中使用了 _inheritsLoose 方法,这个方法是不需要暴露给实例的,这个方法叫做实例方法,而 Children 返回的方法叫实例方法,也叫原型方法。早期的JS中没有真正意义上的类,所以很多开发者都是用这种闭包的方式模拟的。

总结

通过上面的多种继承方式我们知道,寄生式组合继承可以算是引用类型继承的最佳模式,ES6 中的 class extends 采用了这种方式实现了继承。

最后

文中如有错误,欢迎在评论区指正。

如果这篇文章帮助到了你,欢迎点赞加关注,让更多的人也能看到这篇内容。

欢迎关注微信公众号—阳姐讲前端,每天推送高质量文章,我们一起交流成长。