37

前言

趁周末结束之前赶紧先把坑填上。上回我们说到了原型链,并且留下了几个思考题,先把答案公布一下。

  1. 在最后一个例子里,console.log(b1.constructor),结果是什么?
    答案:function A,因为b1本身没有constructor属性,会沿着原型链向上找到B prototype对象,然后再往上找到A prototype对象,此时找到了constructor属性,也就是指向函数对象A,可参见上文最后一张图片

  2. B.prototype = new A();B.prototype.sayB = function(){ console.log("from B") }这两句的执行顺序能不能交换?
    答案:不能,因为我们说过了,第一句是把改写B函数对象的prototype指向的原型对象,如果我们交换了顺序,是在原先的B的原型对象上绑定了方法,然后再把指针指向新的原型对象,那新的原型对象上自然就没有绑定sayB方法,接下来的b1.sayB()就会报函数未定义错误,

  3. 在最后一个例子里,A看似已经是原型链的最顶层,那A还能再往上吗?
    答案,可以,因为其实所有的引用类型都默认继承了了Object,也就是说,完整的原型链应该是A prototype[Prototype]属性指向Object prototype。如图:

完整的原型链
顺便补充一下,Object prototype上的原生方法,包括我们常用的hasOwnProperty()isPropertyOf()等。

接着谈继承

在上一篇我们讲解了原型链的原理,建议没有理解清楚的读者朋友先理解之前的知识点,避免难点叠加

原型链的缺陷

  1. 引用类型的值在原型链传递中存在的问题
    我们知道js中有值类型和引用类型,其中引用类型包括Object.Array等,引用类型的值有一个特点:在赋值的时候,赋给变量的是它在内存中的地址。换句话说,被赋值完的变量相当于一个指针,这会有什么问题呢?看例子:

       function A() {
            this.name = "a" 
            this.color = ['red','green'];         
        }
        function B(){
    
        }
         //让B的原型对象指向A的一个实例
         B.prototype = new A();
         
         //生成两个个B的实例
         var b1 = new B();
         var b2 = new B();
         //观察color属性
         console.log(b1.name)//a
         console.log(b2.name)//a
         console.log(b1.color)//[red,green]
         console.log(b2.color)//[red,green]
         //改变b1的name和color属性
         b1.name = 'b'
         b1.color.push('black')
         
         //重新观察color属性
         console.log(b1)//b
         console.log(b2)//a
         console.log(b2.name)
         console.log(b1.color)//["red", "green", "black"]
         console.log(b2.color)//["red", "green", "black"]

    发现问题了吗?我们修改了b1的color和name属性,但是b2name属性不变,color属性发生了改变。为了搞清楚这里问题,请尝试回答我的问题(想不出来的话,可以自己通过在控制台打印出来验证):

    1. b1b2有自己的color属性吗?
      答案:没有,只是B prototype上有color属性,因为它是A的一个实例,b1b2其实是通过[Proto]属性访问B prototype上的color属性(指针),从而访问和操作color数组的;

    2. b1b2有自己的name属性吗?
      答案:一开始都没有,当执行了b1.name = 'b'时,相当于b1有了自己的name属性,而b2依然没有name属性。

    所以以上问题的原因来源就是我们前面说的:引用类型的值在赋值的时候,赋给变量的是它在内存中的地址。(如果关于值类型和引用类型有没掌握的同学可以先去看看或者私下问我,这里默认这个是已经了解的。)
    所以在原型链中如果A(其实就是继承中的父类型)含有引用类型的值,那么子类型的实例共享这个引用类型得值,也就是上面的color数组,这就是原型链的第一个缺陷。

  2. 第二个缺陷是:在创建子类型的实例(如b1,b2)时,无法向父类型的构造函数中传递参数。比如在上面的例子中,如果Aname属性是要传递参数的而不是写死的,那么我们在实例化b1b2的时候根本没法传参

借用构造函数继承

为了解决引用类型值带来的问题,我们会采用借用构造函数继承的方式,又名*伪造对象或者经典继承,核心思路是:我们在子类型的构造函数中调用父类型的构造函数,这里要用到一个方法call()或者apply()函数,关于这个函数,我这里简单介绍一下,可以简单的理解功能就是,允许一个对象调用另一个对象的方法。具体的作用如果大家觉得需要可以在评论区回复,我会后面单独写一下这两个函数。在这里就不展开了。具体实现如下:

        function A() {
            this.name = "a" 
            this.color = ['red','green'];         
        }
        function B(){
          //“借用”|就体现在这里,子类型B借用了父类型A的构造函数,从而在这里实现了继承
          A.call(this);
        }
       
         
         //生成两个个B的实例
         var b1 = new B();
         var b2 = new B();
         //观察color属性
         console.log(b1.name)//a
         console.log(b2.name)//a
         console.log(b1.color)//['red','green']
         console.log(b2.color)//['red','green']
         //改变b1的name和color属性
         b1.name = 'b'
         b1.color.push('black')
         
         //重新观察属性
         console.log(b1.name)//b
         console.log(b2.name)//a
         console.log(b1.color)//['red','green','black']
         console.log(b2.color)//["red", "green"]

在这里我们没有采用原型链,而是利用call()方法来实现在子类型的构造函数中借用父类型的构造函数,完成了继承,这样继承的结果就是:b1,b2都分别拥有自己的namecolor属性(可以直接console.log(b1)查看对象的属性),也就是b1b2完全独立的。这就解决了之前的第一个问题,而且传递参数的问题其实也可以解决,再稍微改一下A函数:

  //这里name改成传递参数的
        function A(name) {
            this.name = name 
            this.color = ['red','green'];         
        }
        function B(name){
          //在这里我们接受一个参数,并且通过call方法传递到A的构造函数中
          A.call(this,name);
        }
       
         
         //生成两个个B的实例
         var b1 = new B('Mike');
         var b2 = new B('Bob');
         //观察属性
         console.log(b1.name)//Mike
         console.log(b2.name)//Bob
         console.log(b1.color)//['red','green']
         console.log(b2.color)//['red','green']
      

其实上面就可以直接写成这样,但是为了让大家更容易理解,故意分开,隔离变量(大家看我这么用心真的不考虑点个赞吗?),顺便再解释一下A.call(this,name);就是让this对象(这里是指B)调用构造函数A,同时传入一个参数name

可以看到,借用构造函数继承不会有原型链继承的问题,那为什么不都借用采用构造函数继承的方法呢?原因在于:这种继承方式,所有的属性和方法都要在构造函数中定义,比如我们这里也要绑定之前的sayA()方法并继承,就只能写在A的构造函数里面,而写在A prototype的的方法,没法通过这种方式继承,而把所有的属性和方法都要在构造函数中定义的话,就不能对函数方法进行复用.

组合继承

学习了原型链的继承和借用构造函数的继承后,我们可以发现,这两种方法的优缺点刚好互补:

  • 原型链继承可以把方法定义在原型上,从而复用方法

  • 借用构造函数继承法可以解决引用类型值的继承问题和传递参数问题

因此,就自然而然的想到,结合这两种方法,于是就有了下面的组合继承,也叫伪经典继承,(前面的借用构造函数是经典继承,可以联系起来),具体实现如下:

    function A(name) {
            this.name = name 
            this.color = ['red','green'];     
        }
        A.prototype.sayA = function(){
          console.log("form A")
        }
        function B(name,age){
          //借用构造函数继承
          A.call(this,name);
          this.age = age;
        }

        //原型链
        B.prototype = new A();
        B.prototype.sayB = function(){
          console.log("form B")
        }
         
         //生成两个个B的实例
         var b1 = new B('Mike',12);
         var b2 = new B('Bob',13);
         //观察color属性
         console.log(b1)//{name:'Mike'...}
         console.log(b2)//{name:'Bob'...}
         b1.sayA()//from A
         b2.sayB()//from B

这个例子只是对上面的例子稍作修改:

  1. 我们在A prototype上定义了sayA() ,在B prototype 定义了sayB()

  2. 我们增加了B.prototype = new A();原型链

最终实现的效果就是,b1和b2都有各自的属性,同时方法都定义在两个原型对象上,这就达到了我们的目的:属性独立,方法复用,这种继承的理解相对简单,因为就是把前两种继承方式简单的结合一下,原型链负责原型对象上的方法,call借用构造函数负责让子类型拥有各自的属性。
组合继承是js中最常用的继承方式

原型式继承

原型式继承与之前的继承方式不太相同,原理上相当于对对象进行一次浅复制,浅复制简单的说就是:把父对像的属性,全部拷贝给子对象。但是我们前面说到,由于引用类型值的赋值特点,所以属性如果是引用类型的值,拷贝过去的也仅仅是个指针,拷贝完后父子对象的指针是指向同一个引用类型的(关于深复制和浅复制如果需要细讲的同样可以在评论区留言。)原型式继承目前可以通过Object.create()方式来实现,(这个函数的原理我不想在这里提,因为我希望读者在看完这里内容以后自己去查阅一下这个内容)本文只讲实现方式:
Object.create()接收两个参数:

  • 第一个参数是作为新对象的原型的对象

  • 第二个参数是定义为新对象增加额外属性的对象(这个是可选属性)

  • 如果没有传递第二个参数的话,就相当于直接运行object()方法(这个方法如果不懂直接百度就好)
    上面的说法可能有点拗口,换句话说:

比如说我们现在要创建一个新对象B,那么要先传入第一个参数对象A,这个A将被作为B prototype;然后可以再传入一个参数对象CC对象中可以定义我们需要的一些额外的属性。来看例子

    var A  = {
        name:'A',
        color:['red','green']
    }

    //使用Object.create方法先复制一个对象
    var B = Object.create(A);
    B.name = 'B';
    B.color.push('black');

    //使用Object.create方法再复制一个对象
    var C = Object.create(A);
    C.name = 'C';
    B.color.push('blue');
    console.log(A.name)//A
    console.log(B.name)//B
    console.log(C.name)//C
    console.log(A.color)//["red", "green", "black", "blue"]
         

在这个例子中,我们只传入第一个参数,所以BC都是对A浅复制的结果,由于name是值类型的,color是引用类型的,所以ABC的name值独立,color属性指向同一个对象。接下来举个传递两个参数的例子:

    var A  = {
        name:'A',
        color:['red','green'],
        sayA:function(){
            console.log('from A');
        }
    };

    //使用Object.create方法先复制一个对象
    var B = Object.create(A,{
        name:{
          value:'B'
        }
    });
    console.log(B)//Object{name:'B'}
    B.sayA()//'from A'

这个例子就很清楚的表明了这个函数的作用了,传入的A对象被当做B的原型,所以生成B对象没有sayA()方法,却可以调用该方法(类似于通过原型链),同时我们在第二个参数中修改了B自己的name,所以就实现了这种原型式继承。原型式继承的好处是:如果我们只是简单的想保持一个对象和另一个对象类似,不必大费周章写一堆代码,直接调用就能实现

寄生式继承

寄生式继承和原型继承联系紧密,思路类似于工厂模式,即创建一个只负责封装继承过程的函数,在函数中根据需要增强对象,最后返回对象

 function createA(name){
    //创建新对象
    var obj = Object(name);
    //增强功能
     obj.sayO = function(){
         console.log("from O")
     };
    //返回对象
    return obj;
     
}
var A = {
    name:'A',
    color:['red','green','blue']
};
//实现继承
var  B = createA(A);
console.log(B)//Object {name: "A", color: Array[3]}
B.sayO();//from O

继承的结果是B拥有A的所有属性和方法,而且具有自己的sayO()方法,效果和原型式继承很相似,读者可以比较一下寄生式继承和原型式继承的相似和区别。

寄生组合式继承

终于写到最后一个继承了,我们在之前讲了5种继承方式,分别是原型链借用构造函数继承组合继承原型式继承寄生式继承,其中,前三种联系比较紧密,后面两种也比较紧密,而我们要讲的最后一种,是和组合继承还有寄生式继承有关系的。(看名字就知道了嘛)

友情提示:如果看到这里有点累的读者可以先休息一下,因为虽然已经分了一二两篇,本文的篇幅还是稍长(我都打了两个多小时了),而且如果先把之前的理解清楚,比较容易理解最后一种继承。

组合继承仍有缺陷

我们在之前说过,最常用的继承方式就是组合继承,但是看似完美的组合继承依然有缺点:子类型会两次调用父类型的构造函数,一次是在子类型的构造函数里,另一次是在实现原型链的步骤,来看之前的代码:

    function A(name) {
            this.name = name 
            this.color = ['red','green'];     
        }
        A.prototype.sayA = function(){
          console.log("form A")
        }
        function B(name,age){
         //第二次调用了A
          A.call(this,name);
          this.age = age;
        }

        //第一次调用了A
        B.prototype = new A();
        B.prototype.sayB = function(){
          console.log("form B")
        }
         

         var b1 = new B('Mike',12);
         var b2 = new B('Bob',13);
          console.log(B.prototype)//A {name: undefined, color: Array[2]}
       

在第一次调用的时候,生成了B.prototype对象,它具有namecolor属性,因为它是A的一个实例;第二次调用的时候,就是实例化b1b2的时候,这时候b1b2也具有了namecolor属性,我们之前说过,原型链的意义是:当对象本身不存在某个属性或方法的时候,可以沿着原型链向上查找,如果对象自身已经有某种属性或者方法,就访问自身的,但是我们现在发现,通过组合继承,只要是A里面原有的属性,B prototype对象一定会有,b1b2肯定也会有,这样就造成了一种浪费:B prototyope上的属性其实我们根本用不上,为了解决这个问题,我们采用寄生组合式继承。
寄生组合式继承的核心思路是其实就是换一种方式实现 B.prototype = new A();从而避免两次调用父类型的构造函数,官方定义是:使用寄生式继承来继承父类型的原型,然后将结果指定给子类型的原型,。`这句话不容易理解,来看例子:

//我们一直默认A是父类型,B是子类型
function inheritPrototype(B,A){
    //复制一个A的原型对象
    var pro  = Object(A.prototype);
    //改写这个原型对象的constructor指针指向B
    pro.constructor = B;
    //改写B的prototype指针指向这个原型对象
    B.prototype = pro;
}

这个函数很简短,只有三行,函数内部发生的事情是:我们复制一个A的原型对象,然后把这个原型对象替换掉B的原型对象。为什么说这样就代替了 B.prototype = new A();,不妨思考一下,我们最初为什么要把B的prototype属性指向A的一个实例?无非就是想得到A的prototype的一个复制品,然后实现原型链。而现在我们这样的做法,同样达到了我们的母的目的,而且,此时B的原型对象上不会再有A的属性了,因为它不是A的实例。因此,只要把将上面的 B.prototype = new A();,替换成inheritPrototype(B,A),就完成了寄生组合式继承。

寄生组合式继承保持了组合继承的优点,又避开了组合继承会有无用属性的缺陷,被认为是最理想的继承方式。

小结

终于写完了!! 明天还得起早去上班,下一次更新可能会放在这一周的周末。关于这一篇内容,建议的阅读方式是先读前三种继承方式,再看后两种继承,都理解的差不多了,就可以看最后一种继承方式了。中间注意消化和休息。最后再提一下吧:如果喜欢本文,请大方的点一下右上角的推荐和收藏(反正你们还是喜欢只收藏不推荐),虽然说写这个一方面是为了自己巩固知识,但是为了让读者更容易理解,我尽量都是采用拆解的方式来讲,而且穿插了新知识的时候都会给出解释,并不是直接搬运书本知识过来,那样毫无意义。这么做还是希望写的文章能够更有价值,让更多人能够得到帮助!以上内容属于个人见解,如果有不同意见,欢迎指出和探讨。请尊重作者的版权,转载请注明出处,如作商用,请与作者联系,感谢!


安歌
7k 声望5.5k 粉丝

目前就职于Ringcentral厦门,随缘答题, 佛系写文章,欢迎私信探讨.