一步一步读懂JS继承模式

2,616

JavaScript作为一种弱类型编程语言被广泛使用于前端的各种技术中,由于JS中并没有“类”的概念,所以js的OOP特性一直没有得到足够的重视,而且有相当一部分使用js的项目中采用的都是面向过程的编程方式。但是随着项目规模的不断扩大,代码量的不断增加,这种方式会让我们编写很多重复的、无用的代码,并使得项目的扩展性、可读性、可维护性变得脆弱。因此,js的OOP编程技巧则成为进阶的一条必经之路。

开始之前
由于js在ES6之前并没有 “类” 的概念,因此我们必须要了解这些特性(关系)。

  1. 在每使用function声明一个函数的时候,我们称这个函数为构造函数,js都会为我们自动创建一个原型对象。函数称这个对象叫做prototype(老公),原型对象称这个函数叫constructor(老婆)。
  2. 通过new关键字生成的对象就是这个函数的实例(Instance),这个实例称原型对象为__proto__(爸爸),同时也继承了原型对象的称呼constructor(孩儿他娘)。
  3. 实例能够继承原型对象自身和继承来的的所有属性以及方法,同样继承到的constructor属性指向构造函数。

至此,一个完整的家庭成员的关系已经构造出来了,并可以通过new关键字不断繁衍生息,后代总是能继承先辈的属性与方法。看以下这段代码:

function SomeClass(value){
    this.value = value;
}
SomeClass.prototype.protoValu = 'prototype value';

var  Instance = new SomeClass('some value');

这段代码通过new关键字实例化了一个对象Instance,这个对象继承了原型对象的protoValue属性,并拥有自身的value属性。那么实例化的new关键字到底起了什么作用呢?

  • 新建一个空对象o,并将函数的运行时上下文绑定为这个对象。(使得this指向o)
  • 使得o的__proto__指向SomeClass.prototype。(emmm应该是认爸爸)
  • 执行构造函数内容,给对象o添加属性与方法。(长出手和脚)
  • 判断构造函数是否有return语句,如果有执行return,如果没有则执行return o。(出生)

new关键字实际上起的作用就是,创造实例、繁衍后代的作用。

类式继承

function SupperClass(value){
    this.value = value;
    this.fn = function(){
        console.log(this.value);
    }
}
SupperClass.prototype.otherValue = 'other value';
//声明父类

function SubClass(value){
    this.subValue = value;
}
SubClass.prototype = new SupperClass("I'm supper value");
//声明子类,并使得子类继承自SupperClass
//以上为声明阶段

//通过以下方式使用
var Instance = new SubClass("I'm sub value");
console.log(Instance.value);
console.log(Instance.otherValue);
console.log(Instance.subValue);
Instance.fn();

但是这种方式存在着一些问题:

  • 子类继承自父类的实例,而实例化父类的过程在声明阶段,因此在实际使用过程中无法根据实际情况向父类穿参。因此,这种方式的的可扩展性不理想。
  • 子类的家庭关系不完善。Instance.constructor = SupperClass,因为SubClass并没有constructor属性,所以最终会从SupperClass.prototype处继承得到该属性。
  • 不能为SubClass.prototype设置constructor属性,该属性会造成属性屏蔽,导致SubClass.prototype不能正确获取自己的constructor属性,毕竟SubClass.prototype实际上也是SupperClass的实例。

构造函数继承

function SupperClass(value1){
    this.xx = value1;
}
function SubClass(value1,value2){
    SupperClass.call(this,value1);
    this.xx = value2;
}

//实际使用
var Instance = new SubClass('value1','value2');

构造函数继承方式的本质就是将父类的构造方法在子类的上下文环境运行一次,从而达到复制父类属性的目的,在这个过程中并没有构造出一条完整的原型链。

虽然构造函数继承解决了类式继承的不能实时向父类传参的问题,但是由于其没有一条完整的原型链,因此 子类不能继承父类的原型属性与原型方法 。我认为它只是一个实现了继承功能的一种方式,并非真正的继承。

组合式继承--完美的继承方式

function SupperClass(value){
    this.value = value;
    this.fn = function(){
        console.log(this.value);
    }
}
SupperClass.prototype.otherValue = 'other value';
//声明父类

function SubClass(value1,value2){
    SupperClass.call(this,value1)
    this.subValue = value2;
}
SubClass.prototype = new SupperClass("I'm supper value");
//声明子类,并使得子类继承自SupperClass
//以上为声明阶段

//通过以下方式使用
var Instance = new SubClass("I'm supper value","I'm sub value");

组合式继承集合了以上两种继承方式的优点,从而实现了“完美”继承所有属性并能动态传参的功能。但是这种方式仍然不能补齐子类的家庭成员关系,因为SubClass.prototype仍然是父类的实例。

另外一点,相信大家也已经发现了,整个继承过程中实际上调用了两次父类的构造方法,使得SubClass.prototype与Instance都有一份父类的自有属性/方法,这样会造成额外的性能开销,但是好在能够完整的实现继承的目的了。

原型式继承

原型式继承又被成为纯洁继承,它的重点只关注对象与对象之间的继承关系,淡化了类与构造函数的概念,这样能避免开发者花费过多的精力去维护类与类/类与原型之间的关系,从而将重心转移到开发业务逻辑上面来。

var supperObj = {
    key1: 'value',
    func: function(){
        console.log(this.key1);
    }
}

function Factory(obj){
    function F(){}
    F.prototype = obj;
    return new F()
}

//实际使用方法
//var Instance = new Factory(supperObj);
var Instance = Factory(supperObj);

原型式继承因为只关注与对象与对象之间的关系,因此大多数都是使用工厂函数的方法生成继承对象。在工厂函数中我们 定义了一个中间函数(会被释放),并将这个函数的原型指向被继承的对象,因此通过这个函数生成的对象的__proto__也就指向了被继承对象。

在工厂函数内部实现继承的方式与类式继承实现的原理是一样的,区别在于原型式继承更加纯净,因此原型继承方式具有类式继承方式所有的缺点:

  • 无法根据使用的实际情况动态生成supperObj(无法动态传参)。
  • 虽然实现了对象的继承,但是生成的子类还没有添加自己的属性与方法。

同时原型继承也有以下优点:

  • 由于其纯洁性,开发者不必再去维护constructor与prototype属性,仅仅只需要关注原型链。
  • 更少的内存开销。

寄生式继承--原型式继承的二次封装

在原型继承中,每执行一次工厂函数都会重新生成一个新的中间函数F,并在函数结束时被回收,像我这种强迫症患者是不太能接受这种方式的。所幸,ES5提供了Object.create(),并且在原型式继承,以及多继承中起着重要的作用。在寄生式继承中我们会对原型继承做一次优化。

var supperObj = {
    key1: 'value',
    func: function(){
        console.log(this.key1);
    }
}
function inheritPrototype(obj,value){
    //var subObj = Factory(obj);
    var subObj = Object.create(obj);
    subObj.name = value;
    subObj.say = function(){
        console.log(this.name);
    }
    return subObj;
}

var Instance = inheritPrototype(supperObj,'sub');
Instance.func();
Instance.say();

寄生式继承实际上就是对原型式继承的二次封装,在这次封装过程中实现了根据提供的参数添加子类的自定义属性。但是缺点仍然存在,被继承对象无法动态生成

因为原型式继承是基于对象的继承,对象是无法接收参数的,因此要解决这个问题还要回到构造函数的问题上面来。

将类式继承与寄生式继承结合

function inheritPrototype(sub,sup){
    var obj = Object.create(sup.prototype);
    sub.prototype = obj;
    obj.constructor = sub;
    Object.defineProperty(obj,'constructor',{enumerable: false});
    //将constructor属性变为不可遍历,避免多继承时出现问题
}

function SupperClass(value1){
    this.supperValue = value1;
    this.func1 = function(){
        console.log(1);
    }
}
SupperClass.prototype.func2 = function(){
    console.log(this.supperValue);
}
//声明父类

function SubClass(value2){
    this.subValue = value2;
    this.func3 = function(){
        console.log(this.subValue);
    }
}
//声明子类

inheritPrototype(SubClass,SupperClass);
var Instance = new SubClass('sub');
console.log(Instance.supperValue);  //undefined
console.log(Instance.subValue); //sub
Instance.func1();   //Error
Instance.func2();   //undefined
Instance.func3();   //sub

在这种方式中,由于obj对象并不是SupperClass的实例,因此可以与SubClass维护一个完整的关系(prototype与constructor),在维护关系的同时 一定要修改constructor的可枚举属性

在维护了构造函数与原型之间的完整关系的同时,也有一个致命的缺陷----由于obj对象不是SupperClass的实例,所以在实例化子类的时候父类构造函数从未被调用过,因此 子类只能继承到父类原型属性与方法,无法继承到父类自有方法。

寄生组合继承

寄生组合继承就是将经过改良之后的寄生继承与构造函数继承方式组合,从而弥补寄生继承无法继承父类自有属性与方法的缺陷。

function SubClass(value1,value2){
    SupperClass.call(this,value1);
    this.subValue = value2;
    this.func3 = function(){
    	console.log(this.subValue);
    }
}
//声明子类

var Instance = new SubClass('sup','sub');

组合之后,只用在SubClass中调用一次SupperClass的构造函数。本质上父类原型属性与原型方法是通过原型链来继承的,父类的自有方法是通过调用构造函数复制到自身实现继承的。

寄生组合继承不仅完美的实现了属性与方法的继承,也避免了组合继承产生重复属性造成性能浪费,另外也支持创建子类时动态向父类传参。在大型项目中合理运用这种方式实现类的继承能够显著提升代码的可阅读性,以及可扩展性。

参考

《JavaScript设计模式》
《你不知道的JavaScript》