Bootstrap

JS入门难点解析12-继承的实现方式与优缺点

(注1:如果有问题欢迎留言探讨,一起学习!转载请注明出处,喜欢可以点个赞哦!)
(注2:本文首发于我的简书,更多内容请查看我的目录。)

1. 简介

在前面两节,我们花了大量的篇幅来介绍如何创建对象(JS入门难点解析10-创建对象)以及构造函数,原型对象和实例对象三者的定义和关系(JS入门难点解析11-构造函数,原型对象,实例对象)。如果你能好好理解体会这两篇文章中的内容,那么对于本章所述的知识点,你将会感觉清晰易懂。

2. 关于继承

在详细讲述继承前,我们有必要理解继承的概念和JS为什么要实现继承。

关于继承的概念,我们来看一段引自百度百科(百度百科-继承性)的解释:

“继承”是面向对象软件技术当中的一个概念。如果一个类A继承自另一个类B,就把这个A称为”B的子类”,而把B称为”A的父类”。继承可以使得子类具有父类的各种属性和方法,而不需要再次编写相同的代码。在令子类继承父类的同时,可以重新定义某些属性,并重写某些方法,即覆盖父类的原有属性和方法,使其获得与父类不同的功能。另外,为子类追加新的属性和方法也是常见的做法。

通过继承可以提高代码复用性,方便地实现扩展,降低软件维护的难度。我们知道,JavaScript是一种基于对象的脚本语言,而在ES6之前JS没有类的概念。如何将所有的对象区分与联系起来?如何更好地组织JS的代码呢?

JS借鉴C++和Java使用new命令时调用”类”的构造函数(constructor)的思路,做了一个简化的设计,在Javascript语言中,new命令后面跟的不是类,而是构造函数。构造函数中的this关键字,代表了新创建的实例对象。每一个实例对象,都有自己的属性和方法的副本。而所有的实例对象共享同一个prototype对象,prototype对象就好像是实例对象的原型,而实例对象则好像”继承”了prototype对象一样。

当然,利用构造函数和原型链,只是其中一种思路。下面我们详细介绍实现JS继承的两类四种方式和这几种方式的组合,以及他们各自的优缺点。

3. 模拟类的继承

正如第2节所述,JS的设计者为我们提供了一个最直接的思路。通过构造函数实例化对象,并通过原型链将实例对象关联起来。

3.1 原型链继承

基本思想:使用父类实例对象作为子类原型对象。

// demo3.1
// 声明父类构造函数
function SuperType() {
    this.superValue = 'super';
}
// 为父类原型对象添加方法
SuperType.prototype.getSuperValue = function() {
    return this.superValue;
};

// 声明子类构造函数
function SubType() {
    this.subValue = 'sub';
}
// 将父类实例对象作为子类原型对象-关键就在这里
SubType.prototype = new SuperType();
// 为子类原型对象添加方法
SubType.prototype.getSubValue = function () {
    return this.subValue;
};

// 新建子类实例对象
var instance = new SubType();

console.log(instance.superValue);   // super 
console.log(instance.getSuperValue());  // super
console.log(instance.subValue); // sub
console.log(instance.getSubValue());    // sub

其构造函数,实例对象和原型对象关系如下:
1

注意:
1. 将父类实例对象赋值给子类构造函数的prototype 属性以后,重写了子类原型对象,此时新的子类原型对象是没有属于自己的constructor属性的,而是继承了SuperType.protoType的constructor属性。

// 接代码demo3.1
console.log(SubType.prototype.constructor === SubType);  // false
console.log(SuperType.prototype.constructor === SuperType);  // true
console.log(SubType.prototype.constructor === SuperType);  // true
  1. 所有的对象默认都继承了Object,这个继承是通过原型链实现的。
// 接代码demo3.1
console.log(SuperType.prototype.__proto__ === Object.prototype);  // true
  1. 在对子类原型对象的属性和方法进行改动(增加,删除,重写)时,需要在将父类实例对象赋值给子类构造函数的prototype 属性以后。
// demo3.2
function SuperType() {
    this.superValue = 'super';
}
SuperType.prototype.getSuperValue = function() {
    return this.superValue;
};

function SubType() {
    this.subValue = 'sub';
}
// 将父类实例对象作为子类原型对象之前为子类原型对象添加属性
SubType.prototype.beforeNewSuperType = 'beforeNewSuperType';
// 将父类实例对象作为子类原型对象-关键就在这里
SubType.prototype = new SuperType();
// 将父类实例对象作为子类原型对象之后为子类原型对象添加属性
SubType.prototype.afterNewSuperType = 'afterNewSuperType';

// 新建子类实例对象
var instance = new SubType();

// 新建子类实例对象后之后为子类原型对象添加属性
SubType.prototype.afterNewSubType = 'afterNewSubType';

console.log(instance.beforeNewSuperType); // undefined
console.log(instance.afterNewSuperType); // afterNewSuperType
console.log(instance.afterNewSubType); // afterNewSubType
  1. 在对子类原型对象的属性和方法进行改动时,不可以用对象字面量改写子类原型对象。
// demo3.3
// 声明父类构造函数
function SuperType() {
    this.superValue = 'super';
}
// 为父类原型对象添加方法
SuperType.prototype.getSuperValue = function() {
    return this.superValue;
};

// 声明子类构造函数
function SubType() {
    this.subValue = 'sub';
}
// 将父类实例对象作为子类原型对象-关键就在这里
SubType.prototype = new SuperType();
// 为子类原型对象添加方法
SubType.prototype= {
    getSubValue : function () {
        return this.subValue;
}
};

// 新建子类实例对象
var instance = new SubType();

console.log(instance.superValue);   // undefined
console.log(instance.getSuperValue);  // undefined
console.log(instance.subValue); // sub
console.log(instance.getSubValue());    // sub

优点:
1. 当原型进行属性和方法的改动时,对所有继承实例能够即时生效。(参见demo3.2)

  1. 方便判断对象类型(这一块以后会开单章详细讲述其原理)。
    • 方法1:用instanceof操作符来判断原型链中是否有某构造函数,操作符右边必然是构造函数,而左边是在该构造函数所处原型链位置之前的实例或者原型对象时会返回true。
    • 方法2:用isPrototypeOf方法来判断原型链中是否有某原型对象,方法调用者必然是原型对象,而参数是在该原型对象所处原型链位置之前的实例或者原型对象时时会返回true。
// 接demo3.1
// 方法一:用instanceof操作符来判断
// 左边实例右边构造函数
console.log(instance instanceof SubType);  // true
console.log(instance instanceof SuperType);  // true
console.log(instance instanceof Object);  // true
// 左边原型对象右边构造函数
console.log(SubType.prototype instanceof SuperType);  // true

// 方法二:用isPrototypeOf方法来判断
// 调用者原型对象参数是实例
console.log(SubType.prototype.isPrototypeOf(instance)); // true
console.log(SuperType.prototype.isPrototypeOf(instance)); // true
console.log(Object.prototype.isPrototypeOf(instance)); // true
// 调用者原型对象参数是原型对象
console.log(SuperType.prototype.isPrototypeOf(SubType.prototype)); // true

缺点:
1. 父类构造函数的属性,被子类原型拥有之后,由子类实例对象共享。

// demo3.4
// 声明父类构造函数
function SuperType() {
    this.value = [1, 2, 3];
}

// 声明子类构造函数
function SubType() {
}
// 将父类实例对象作为子类原型对象-关键就在这里
SubType.prototype = new SuperType();

// 新建子类实例对象
var instance1 = new SubType();
var instance2 = new SubType();
console.log(instance1.value);   // [1, 2, 3]
console.log(instance2.value);   // [1, 2, 3]
instance1.value.push(4);
console.log(instance1.value);   // [1, 2, 3, 4]
console.log(instance2.value);   // [1, 2, 3, 4]
  1. 在创建继承关系时,无法对父类型的构造函数传参。理由同缺点1,如果传参,会影响到所有的实例。

  2. 无法实现多继承。因为将父类实例对象作为子类原型对象时,是一对一的。

3.2 借用构造函数继承

基本思想:在子类构造函数内部调用父类构造函数。抛开父类的原型对象,直接通过在子类构造函数内部借用父类构造函数来增强子类构造函数,此时子类实例会拥有子类和父类定义的实例属性与方法。

// demo3.5
// 声明父类构造函数
function SuperType(value) {
    this.superValue = value;
    this.arr = [1, 2, 3];
}

// 声明子类构造函数
function SubType() {
    // 借用父类构造函数,继承父类并且可以传参-关键就在这里
    SuperType.call(this, 'super');
    this.subValue = 'sub';
}

// 新建子类实例对象
var instance1 = new SubType();
var instance2 = new SubType();
console.log(instance1.arr);   // [1, 2, 3]
console.log(instance2.arr);   // [1, 2, 3]
instance1.arr.push(4);
console.log(instance1.arr);   // [1, 2, 3, 4]
console.log(instance2.arr);   // [1, 2, 3]

优点:
1. 由父类构造函数定义的实例属性被子类实例继承以后仍然是独立的实例属性。(参见demo3.5)

  1. 在创建继承关系时,可以传参。理由同优点1,传参不会影响所有实例。(参见demo3.5)

  2. 可以实现多继承。因为在子类构造函数内部可以借用多个父类构造函数。

缺点
1. 父类原型定义的公共属性和方法无法被继承。

  1. 父类构造函数发生改动时,可能会影响到子类构造函数以及实例的构造方法,而且这种变动不会影响到之前已经生成的实例。

  2. 继承关系难以判定,只能判断实例与子类的直接继承关系,实例与父类的继承关系无法判定。

// 接demo3.5
console.log(instance1 instanceof SubType);  // true
console.log(instance1 instanceof SuperType);  // false

console.log(SubType.prototype.isPrototypeOf(instance1)); // true
console.log(SuperType.prototype.isPrototypeOf(instance1)); // false
  1. 方法都定义在构造函数内部,无法实现方法复用。

3.3 组合继承(原型链 + 借用构造函数)—— 最常用的继承模式

主要思路:利用原型链实现对父类原型属性的继承,借用构造函数实现对父类实例属性的继承。

//  demo3.6
// 声明父类构造函数
function SuperType(value) {
    this.superValue = value;
    this.arr = [1, 2, 3];
}

// 为父类原型对象添加方法
SuperType.prototype.getSuperValue = function() {
    return this.superValue;
};

// 声明子类构造函数
function SubType() {
    // 借用父类构造函数,继承父类并且可以传参-第二次调用父类构造函数
    SuperType.call(this, 'super');
    this.subValue = 'sub';
}
// 将父类实例对象作为子类原型对象,第一次调用父类构造函数
SubType.prototype = new SuperType();
// 将子类原型对象的constructor属性指向子类本身
SubType.prototype.constructor = SubType;
// 为子类原型对象添加方法
SubType.prototype.getSubValue = function () {
    return this.subValue;
};

// 新建子类实例对象
var instance = new SubType();

console.log(instance.superValue);   // super 
console.log(instance.getSuperValue());  // super
console.log(instance.subValue); // sub
console.log(instance.getSubValue());    // sub

var instance2 = new SubType();
instance.arr.push(4);
console.log(instance.arr);   // [1, 2, 3, 4]
console.log(instance2.arr);   // [1, 2, 3]

优点:
拥有原型链继承和借用构造函数继承的所有优点,却没有两者的缺点。
缺点:
调用了两次父类构造函数,父类的实例属性被复制了两份,一份放在子类原型,一份放在子类实例,而且最后子类实例继承自父类的实例属性覆盖了子类原型继承自父类的实例属性。

4. 委托继承

委托继承,并不需要使用者去调用构造函数。本质上其实是选一个原始对象作为其他对象的原型来继承,这样在其他对象中找不到的属性和方法,会委托该原始对象去寻找,也就实现了继承。

4.1 原型式继承

主要思路:利用一个空的构造函数为桥梁,将一个对象作为原型创建新对象,这样新生成的对象都可以通过原型链共享这个原型对象的属性。

可以用如下函数来阐释该思路:

// demo4.1
function object(o) {
    function F() {}     // 建一个空的构造函数
    F.prototype = o;    // 将F的原型对象指向o
    return new F();     // 返回F的实例,这样返回的实例原型即为传入的o
}

下面我们来看一个具体的例子:

// 接demo4.1
// demo4.2
// person就是原始对象,用来作为其他新对象的原型对象
var person = {
    name: 'ZhangSan',
    hobbies: ['painting', 'running'],
    friends: ['LiSi', 'WangWu']
};

var anotherPersonOne = object(person);
anotherPersonOne.name = 'LiSi';
anotherPersonOne.hobbies.push('singing');
anotherPersonOne.friends = ['ZhangSan', 'WangWu'];

var anotherPersonTwo = object(person);
anotherPersonTwo.name = 'WangWu';
anotherPersonTwo.hobbies.push('dancing');
anotherPersonTwo.friends = ['ZhangSan', 'LiSi'];

console.log(person.name);       // 'ZhangSan'
console.log(person.hobbies);    // ['painting', 'running', 'singing', 'dancing']
console.log(person.friends);    // ['LiSi', 'WangWu']

console.log(anotherPersonOne.name);     // 'ZhangSan'
console.log(anotherPersonOne.hobbies);  // ['painting', 'running', 'singing', 'dancing']
console.log(anotherPersonOne.friends);  // ['ZhangSan', 'WangWu']

console.log(anotherPersonTwo.name);     // 'ZhangSan'
console.log(anotherPersonTwo.hobbies);  // ['painting', 'running', 'singing', 'dancing']
console.log(anotherPersonTwo.friends);  // ['ZhangSan', 'LiSi']

console.log(anotherPersonOne.__proto__ === person); // true
console.log(anotherPersonTwo.__proto__ === person); // true

注意:
ECMAScript5通过新增方法Object.create()方法规范化了原型式继承。这个方法接收两个参数:一个用作新对象原型的对象和(可选的)一个可选的为新对象定义额外属性的对象。其实就是一种语法糖,帮助我们实现继承的同时,方便地定义了新对象的属性。在只传入一个参数的情况下,Object.create()和我们定义的object()方法效果相同。

// demo4.3
// person就是原始对象,用来作为其他新对象的原型对象
var person = {
    name: 'ZhangSan',
    hobbies: ['painting', 'running'],
    friends: ['LiSi', 'WangWu']
};

var anotherPersonOne = Object.create(person, {
    name: {
        value: 'LiSi'
    },
    friends: {
       value: ['ZhangSan', 'WangWu']
    }});
anotherPersonOne.hobbies.push('singing');

var anotherPersonTwo = Object.create(person, {
    name: {
        value: 'WangWu'
    },
    friends: {
        value: ['ZhangSan', 'LiSi']
    }});

anotherPersonTwo.hobbies.push('dancing');

console.log(person.name);       // 'ZhangSan'
console.log(person.hobbies);    // ['painting', 'running', 'singing', 'dancing']
console.log(person.friends);    // ['LiSi', 'WangWu']

console.log(anotherPersonOne.name);     // 'ZhangSan'
console.log(anotherPersonOne.hobbies);  // ['painting', 'running', 'singing', 'dancing']
console.log(anotherPersonOne.friends);  // ['ZhangSan', 'WangWu']

console.log(anotherPersonTwo.name);     // 'ZhangSan'
console.log(anotherPersonTwo.hobbies);  // ['painting', 'running', 'singing', 'dancing']
console.log(anotherPersonTwo.friends);  // ['ZhangSan', 'LiSi']

console.log(anotherPersonOne.__proto__ === person); // true
console.log(anotherPersonTwo.__proto__ === person); // true

优点:
1. 不需要使用者调用构造函数,不必额外创建自定义类型。

  1. 支持传参。
// 接demo4.1
// demo4.4
var superObj = {
    init: function(value){
       this.value = value;
    },
    getValue: function(){
        return this.value;
    }
}

var subObj = object(superObj);
subObj.init('sub');
console.log(subObj.getValue());  // 'sub'
  1. 可以用用isPrototypeOf方法来判断继承关系。
console.log(superObj.isPrototypeOf(subObj));  // true

缺点:
1. 由于引用属性是被共享的,对引用属性的改动会影响到其他对象。(参见demo4.2)

  1. 无法用instanceof操作符来判断继承关系,因为没有构造函数。

4.2 寄生式继承

主要思路:在原型式继承的基础上,对返回的原型进行了增强。

// demo4.5
function object(o) {
    function F() {}     // 建一个空的构造函数
    F.prototype = o;    // 将F的原型对象指向o
    return new F();     // 返回F的实例,这样返回的实例原型即为传入的o
}
function createAnother(obj) {
    var clone = object(obj);    // 通过调用函数来创建一个新对象
    clone.favouriteColors = ['red'];  // 以某种方式来增强这个对象
    clone.sayHi = function() {
        console.log('hi');
    }
    return clone;      // 返回这个对象
}
var person = {
    name: 'ZhangSan',
    hobbies: ['painting', 'running'],
    friends: ['LiSi', 'WangWu']
};

var anotherPersonOne = createAnother(person);
console.log(anotherPersonOne.favouriteColors); // ['red']
anotherPersonOne.sayHi();  // hi
var anotherPersonTwo = createAnother(person);
anotherPersonTwo.favouriteColors.push('white'); 
console.log(anotherPersonOne.favouriteColors);  // ['red']
console.log(anotherPersonTwo.favouriteColors);  // ['red', 'white']

注意:
有的人可能想到了,我们前面说过Object.create()在只有一个参数时与object效果相同。所以上述代码可以写成:

// demo4.6
function createAnother(obj) {
    var clone = Object.create(obj, {
        favouriteColors: {
            value: ['red']
        },
        sayHi: {
            value: function() {
                console.log('hi');
            }
        }
    });
    return clone;
}
var person = {
    name: 'ZhangSan',
    hobbies: ['painting', 'running'],
    friends: ['LiSi', 'WangWu']
};

var anotherPersonOne = createAnother(person);
console.log(anotherPersonOne.favouriteColors); // ['red']
anotherPersonOne.sayHi();  // hi
var anotherPersonTwo = createAnother(person);
anotherPersonTwo.favouriteColors.push('white'); 
console.log(anotherPersonOne.favouriteColors);  // ['red']
console.log(anotherPersonTwo.favouriteColors);  // ['red', 'white']

不过哪种写法更优,需要使用者自己抉择。

优点:
1. 为原型添加属性和方法更加方便。

  1. 新增加的属性和方法是独立的。(参见demo4.5和demo4.6)

缺点
新增加的函数无法复用。

4.3 寄生组合式继承(组合 + 寄生)—— 最完美的继承模式

还记得使用最广泛的组合继承模式么,唯一的缺点就是需要两次调用父类构造函数。而寄生模式不需要调用构造函数,那么想办法将组合模式其中一次调用改成使用寄生模式即可。

基本思路:父类构造函数定义的实例属性通过借用构造函数来继承,而父类原型定义的共享属性通过寄生模式来继承。

// demo 4.6
// 寄生继承方法,将父类原型复制一份给子类原型,并且将constructor变成指向子类原型
function inheritPrototype(subType, superType) {
    var prototype = superType.prototype;
    prototype.constructor = subType;
    subType.prototype = prototype;
}
// 父类构造函数定义父类实例属性
function SuperType(name) {
    this.name = name;
    this.colors = ['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);
};

var instanceOne = new SubType('张三', 22);
var instanceTwo = new SubType('李四', 26);

instanceOne.sayName();  // 张三
instanceOne.sayAge();    // 22
console.log(instanceOne.colors);  // ['blue', 'green']
instanceTwo.colors.push('white');
console.log(instanceTwo.colors);// ['blue', 'green', 'white']
console.log(instanceOne.colors);// ['blue', 'green']

注意:
此时,是可以用instanceof操作符和isPrototypeOf方法来判断继承关系的,但是并不是从原型链找到父类原型来判断的,而是子类原型和父类原型的引用是同一个对象。

// 接 demo4.6
console.log(instanceOne instanceof SubType);  // true
console.log(instanceOne instanceof SuperType);  // true

console.log(SubType.prototype.isPrototypeOf(instanceOne)); // true
console.log(SuperType.prototype.isPrototypeOf(instanceOne)); // true

console.log(SubType.prototype === SuperType.prototype);  // true

优点:
近乎完美,父类的实例属性不会出现在子类的原型而是独立出现在各个子类实例,而父类的原型属性被copy到了子类中,子类可以共享父类和子类原型定义的属性。

缺点:
对子类原型的修改影响了父类原型,事实上现在他们使用的是同一个引用。

思考:
当然,为了解决该缺点,我们在inheritPrototype()方法中,可以将superType.prototype拷贝一份给subType.prototype,而不是指向同一个引用。但是如此一来,又会引发另一个缺点,那就是不能判断实例与父类型的继承关系。如何抉择,可以根据实际需要来定。

6. 总结

其实理解继承,主要是理解构造函数,实例属性和原型属性的关系。要想实现继承,将不同的对象或者函数联系起来,总共就以下几种思路:
1. 原型链:父类的实例当做子类的原型。如此子类的原型包含父类定义的实例属性,享有父类原型定义的的属性。
2. 借用构造函数:子类直接使用父类的构造函数。如此子类的实例直接包含父类定义的实例属性。
3. 原型式:复制父类原型属性给子类原型。如此,子类实例享有父类定义的原型属性。
4. 寄生式:思路与3一样,只是利用工厂模式对复制的父类原型对象进行增强。

然后,1,2思路结合,实例属性继承用借用构造函数保证独立性,方法继承用原型链保证复用性,就是组合模式。
4,2思路结合,或者说3,4与1,2思路结合,实例属性继承用借用构造函数保证独立性,方法继承用原型复制增强的方式,就是寄生组合模式。

参考

JS入门难点解析10-创建对象
JS入门难点解析11-构造函数,原型对象,实例对象
javascript面向对象系列第三篇——实现继承的3种形式
一张图理解prototype、proto和constructor的三角关系
JS实现继承的几种方式
重新理解JS的6种继承方式
Javascript继承机制的设计思想
经典面试题:js继承方式上
经典面试题:js继承方式下
闲说继承
Javascript中的几种继承方式比较
JS实现继承的几种方式详述(推荐)
百度百科-面向对象程序设计
廖雪峰的官方网站-原型继承
百度百科-javascript
百度百科-继承性

BOOK-《JavaScript高级程序设计(第3版)》第6章
BOOK-《你不知道的JavaScript》 第2部分

;