继承

166 阅读6分钟

很多面向对象语言都支持两种继承:接口继承 和 实现继承。前者只继承方法签名,后者继承实际的方法

接口继承在ECMAScript中是不可能的,因为函数没有签名

实现继承ECMAScript唯一支持的继承方式,而这主要是通过原型链实现的。其基本思想是通过原型继承多个引用类型的属性和方法。

原型链继承

一个实例的原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数,这样就在实例和原型之间构造了一条原型链。

原型与继承关系

原型与实例的关系可以通过两种方式来确定

  • instanceof操作符

  • isPrototypeOf()方法

原型链继承的问题

  • 在使用原型实现继承时,原型实际上变成了另一个类型的实例。

    这意味着原先的实例属性摇身一变成为了原型属性。

    function SuperType() {
        this.colors = ['red', 'blue', 'green'];
    }
    
    function SubType() {}
    
    // 继承SuperType
    SubType.prototype = new SuperType();
    
    let type1 = new SubType();
    instance1.color.push('black');
    console.log(type1.colors); // ['red', 'blue', 'green', 'black']
    
    let type2 = new SubType();
    console.log(type2.colors); // ['red', 'blue', 'green', 'black']
    

    SubType通过原型继承SuperType后,SubType.prototype变成了SuperType的一个实例,因而也获得了自己的colors属性。最终结果是,SubType的所有实例都会共享这个colors属性。

  • 子类型在实例化时不能给父类型的构造函数传参。

    事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。

    再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用

盗用构造函数(“对象伪装”或“经典继承”)

为了解决原型包含引用值导致的继承问题,出现了“盗用构造函数”

基本思路是,通过apply()call(),在子类构造函数中调用父类构造函数。

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

function SubType() {
    // 继承SuperType
    SuperType.call(this);
}

let type1 = new SubType();
type1.colors.push('black');
console.log(type1.colors); // ['red', 'blue', 'green', 'black']

let type2 = new SubType();
console.log(type2.colors); // ['red', 'blue', 'green']

通过callapply方法,SuperType构造函数 在 为SubType的实例 创建的新对象的上下文 中执行了。这相当于新的SubType对象上运行了SuperType函数中的所有初始化代码。结果就是每个实例都会有自己的colors属性。

相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参

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

function SubType() {
    SuperType.call(this, 'echo');
}

为确保SuperType构造函数不会覆盖SubType定义的属性,可以在调用父类构造函数之后再给子类实例添加额外的属性。

咦,怎么脑中突然浮现了以下代码?

...
    constructor() {
        super();
        this.name = 'echo';
        ...
    }
...

盗用构造函数的问题

主要缺点,也是使用构造函数模式自定义类型的问题:

  • 必须在构造函数中定义方法,因此函数不能重用。

  • 子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。

这也导致,盗用构造函数基本上也不能单独使用

组合继承(伪经典继承)

组合继承综合了原型链继承和盗用构造函数,将两者的优点集中了起来。

基本思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性

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

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

function SubPerson(name, age) {
    // 继承属性
    SuperPerson.call(this, name);
    this.age = age;
}

// 继承方法
SubPerson.prototype = new SuperPerson();

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

const person = new SubPerson('echo', 12);
person.colors.push('black'); 
console.log(person.colors); // ['red', 'blue', 'green', 'black'] 
person.sayName(); // 'echo'
person.sayAge(); // 12

组合继承弥补了原型链和盗用构造函数的不足,是JS中使用最多的继承模式。而且组合继承也保留了instanceof操作符和isPrototypeOf()方法识别合成对象的能力

但是,有没有发现SuperPerson构造函数被调用了两次,而这个问题将在寄生式组合继承中得以解决。

原型式继承

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

本质上,object()是对传入的对象执行了一次浅复制。

let person = {
    name: 'echo',
    friends: ['Jon', 'Van']
};

let person1 = object(person);
person2.name = 'Greg';
person2.friends.push('Rob');

let person2 = object(person);
person2.name = 'Linda';
person2.friends.push('Barbie);

console.log(person.friends); // ['Jon', 'Van', 'Rob', 'Barbie']

这种继承适合在一个已有对象的基础上再创建一个新对象的场景。

ECMAScript5通过增加Object.create(obj, properties)方法将原型式继承的概念规范化了。

当只有一个参数时,Object.create()与上文的object()方法效果相同

Object.create()的第二个参数与Object.definedProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。

以这种方式添加的属性会遮蔽原型对象上的同名属性

let person = {
    name: 'echo',
    friends: ['Jon', 'Van']
};
let person1 = Object.create(person, {
    name: {
        value: 'Greg'
    }
});
console.log(person1.name); // 'Greg'

原型式继承非常适合 不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。

属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的

寄生式继承

与原型式继承比较接近,其背后的思路类似于 寄生构造函数和工厂模式:

创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象

function createAnother(origin) {
    let clone = object(origin); // 通过调用函数创建一个新对象
    clone.sayHi = function() { // 以某种方式增强对象
        console.log('Hi');
    };
    return clone; // 返回这个对象
}

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。

object()函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用

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

寄生式组合继承

组合继承其实也存在效率问题,就是我们上文提到过的 —— 父类构造函数始终会被调用两次。

本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。

基本思路是使用寄生式继承来 继承父类原型, 然后将返回的新对象 赋值给子类原型

function inheritPrototype(subPerson, superPerson) {
    let prototype = object(superPerson.prototype); // 创建父类原型的副本
    prototype.constructor = subPerson;             // 解决由于重写原型导致默认constructor丢失的问题
    subPerson.prototype = prototype;               // 将新创建的对象赋值给子类的原型
}

以上函数实现了寄生式组合继承的核心逻辑。

function SuperPerson(name) {
    this.name = name;
    this.friends = ['Jon', 'Job'];
}

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

function SubPerson(name, age){
    SuperPerson.call(this, name);
    this.age = age;
}

inheritPrototype(SubPerson, SuperPerson);

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

这里只调用了一次父类构造函数,避免了SubPerson.prototype上不必要也用不到的属性。

而且,原型链仍然保持不变,因此instanceof操作符和isPrototypeOf()方法正常有效。

寄生式组合继承可以算是引用类型继承的最佳模式。