这是JS 原生方法原理探究系列的第四篇文章。本文会介绍如何实现 JS 中常见的几种继承方式,同时简要它们的优缺点。

实现继承的方法

实现继承的方法共有 7 种,这 7 种方法并不是互相独立的,它们之间更像是一种互补或者增强的关系。

  • 原型链继承和借用构造函数继承分别解决了继承父类方法继承父类属性的问题,这两个方法结合就得到了组合继承;
  • 原型式继承的核心是实现对象的浅拷贝并进行增强,寄生式继承则将这个过程封装成一个返回对象的函数;
  • 寄生组合式继承结合了寄生式继承和组合式继承,是相对比较完美的方案。
  • Class extends 继承是 ES6 的,本质上是寄生组合式继承的一种运用

下面的示例中,SuperType 表示父类,SubType 表示继承父类的子类。

1)原型链继承

function SuperType(){
    this.names = []
}
SuperType.prototype.getNames = function(){}
function SubType(){
    this.ages = []
}
SubType.prototype = new SuperTye()
const obj = new SubType()

原型链继承的核心就一句话:用父类实例作为子类原型,这使得子类实例最终可以访问父类上的属性和其原型上的方法。而它的缺点也很明显:

第一:由于父类构造函数只调用了一次,导致子类的原型都统一指向了这次调用所创建的父类实例,所以子类实例在访问一些自身没有的引用类型的属性时,实际上访问的都是那同一个父类实例上的属性。但通常,实例和实例之间应该都有自己的属性副本,不应该共享属性

第二:同样是由于只调用了一次父类构造函数,所以子类无法向父类传参

2)借用构造函数继承

function SupterTye(names){
    this.names = names
    this.getNames = function(){}
}
function SubType(){
    SuperType.call(this,[])
    this.ages = []
}
const obj = new SubType()

借用构造函数继承也称为经典继承,这里所谓的借用指的是借用父类构造函数,它的核心就是完全不使用原型,而是在子类构造函数中通过 call 调用父类构造函数,从而增强子类实例 —— 相当于把父类实例上的属性都搬到子类实例这里来。

这种继承方法的优点就在于,它解决了原型链继承的缺点,我们现在可以往父类传参了,而且每次 new 子类的时候都会重新调用一次父类,这使得子类的所有实例都有自己的属性副本。

属性是没问题了,方法的继承又有了问题。由于父类构造函数是重复调用的,所以每个实例都有自己的方法副本,但问题是,方法并不需要副本,所有实例完全应该共享同一个方法,所以这里为每个实例重复创建同一个方法,就存在一定的性能问题。此外,对于父类原型上的方法,子类是无法继承的,因为这种继承方式并没有使用到原型。

3)组合继承

看起来,原型链继承擅长方法继承,而借用构造函数继承擅长属性继承,那么能不能取二者之长呢?实际上,结合两者的优点,就是所谓的组合继承了。

function SuperType(names){
    this.names = names
}
SuperType.prototype.getNames = function(){}
function SubType(){
    SuperType.call(this,[])
    this.ages = []
}
SubType.prototype = new SuperType()
const obj = new SubType()

组合继承使用原型链继承的方式去继承方法,使用构造函数继承的方式去继承属性。

PS:组合继承和原型链继承都重写了子类的原型,在重写之前,子类的原型的 constructor 是指向子类的,重写后就不是了,因为子类的原型被代之以一个 new 创建的对象字面量。这里可以通过 SubType.prototype.constructor = SubType 修复 constructor 的指向。

4) 原型式继承

原型式继承所做的事情类似于浅拷贝一个对象,再通过自定义的方式增强新对象。它能够方便地实现在不同对象之间共享信息,同时又不需要额外创建构造函数(内部做了处理)。

const obj = {
    name: 'jack',
    friends: [1,2]
}
fucntion createObject(o){
    function F(){}
    F.prototype = o
    return new F()
}
const anotherObj = createObject(obj)
anotherObj.name = 'Tom'
anotherObj.friends = [3,4]

ES5 在规范层面实现了原型式继承,也就是所谓的 Object.create() 方法,上面代码可以改为:

const obj = {
    name: 'jack',
    friends: [1,2]
}
const anotherObj = Object.create(obj)

这个方法所做的事情和 createObject 方法是一样的,它最终会返回一个新对象,而这个新对象的原型是传入的参数(我们传入的参数一般充当一个原型对象)。而且,当我们传参 null 的时候,它最终会返回一个没有原型的纯粹的对象,也就是所谓的裸对象(naked object)。

5) 寄生式继承

寄生式继承在原型式继承的基础上,为新对象增加了方法:

const obj = {
    name: 'jack',
    friends: []
}
function createObject(o){
    // 对象浅拷贝
    let anotherObj = Object.create(o)
    // 对象增强
    anotherObj.getFriends = function(){}
    return anotherObj
}
const anotherObj = createObject(obj)

6)寄生组合式继承

寄生组合式继承的出现是为了解决组合继承存在的一些问题,这种继承基本上是完美的了。

组合继承最大的问题在于,它两次调用了父类构造函数。第一次是在子类构造函数中 call 调用父类构造函数,这个时候实际上已经使得子类实例拥有了父类的属性;第二次是 new 调用父类构造函数并作为子类的原型,这时候又使得子类原型上也有了父类的属性。因此这两次调用带来的开销问题不说,更关键的是出现了两组重复的属性,这完全是不必要的。所以,利用寄生组合式继承,我们可以做到只调用一次父类构造函数

假设我们现在有一个父类,然后需要实现一个继承父类的子类。用寄生组合式继承的话,代码如下:

function SuperType(){
    this.name = 'jack'
    this.friends = []
}
SuperType.prototype.getFriends = function(){}

function SubType(){
    // 属性继承
    SuperType.call(this)
}
function inherit(sup,sub){
    sub.prototype = Object.create(sup.prototype)
    sub.prototype.constructor = sub
    // 或者直接
    sub.prototype = Object.create(sup.prototype,{
        constructor: {
            value: sub
            // enumerable 默认为 false
        }
    })
}
// 方法继承
inherit(SuperType,SubType)
const obj = new SubType()

注意几个要点:

  • 属性继承仍然是采用借用构造函数继承的方式,关键是方法继承。这里通过一个 inherit 函数接受父类和子类,让子类继承父类的方法。在具体实现中,我们不再像原型链继承或者组合继承那样,new 一个父类构造函数作为子类的原型 —— 虽然效果看起来一样,但这是一次多余的、应该避免的父类调用。相反,我们借鉴了寄生式继承的做法,创建了一个父类原型的副本作为子类的原型。子类原型和父类原型之间其实是通过 __proto__ 联系起来的,因此在通过子类实例访问相关方法的时候,可以确保是沿着 子类实例 => 子类实例.__proto__ = 子类原型 => 子类原型.__proto__ = 父类原型 这样的原型链查找,最终一定可以找到父类原型上的方法,因此就实现了方法继承。
  • 寄生组合式继承同样重写了子类原型,所以需要修复 constructor 的指向,指回子类本身。因为 Object.create 本身接受两个参数,第二个参数可以设置其返回对象的属性的特性,所以也可以在传参时顺便修复 constructor 的指向