js 继承的几种方式详解

不同于其他面向对象语言,js是一种弱类型语言,它没有interface、implements、constructor、extends等关键字【后续在typescript中有实现,es6中也有constructor、extends的语法糖】,js的继承是利用原型链实现的。为什么这样设计,看这里Javascript继承机制的设计思想

了解js继承前首先应该明确几个概念:

实例:let c1 = new Child() 这步操作叫做实例化,称c1是Child的实例,实例化出来的都是对象。
继承:让子类具有父类的“方法、属性”,当然父类还可以有父类,父类也叫超类、基类,子类也叫派生类。
原型链相关知识彻底理解js的原型链
js继承达到的目标:父类构造函数内定义的属性应该是私有的,也就是每个子类实例维护自己的,实例对象需要共享的属性和方法都放在prototype对象下

方式一、原型链继承:子类原型等于父类的实例

注意需要在子类中添加新的方法或者是重写父类的方法时候,切记一定要放到替换原型的语句之后

// 定义父类
    function Parent() {
        this.name = "parent";
        this.colors = ["red", "blue", "yellow"]; // 父类构造函数内的引用类型数据
    }
    Parent.prototype.proArr = [1,2,3] // 父类原型上的引用类型属性
    Parent.prototype.sayFather = function () {
        console.log("来自父类的呐喊")
    }

    // 几种继承主要是这部分在变动 start ------
   // 定义子类
    function Child() {
        this.type = "child"; // 在继承父类属性的基础上,扩展子类属性
    }
    Child.prototype = new Parent()
    Child.prototype.constructor = Child; // 修复原型链-Child.prototype.constructor应该指向自己
    // 在继承父类属性的基础上,扩展子类方法
    Child.prototype.sayChild = function () {
        console.log("来自子类的呐喊")
    }
    // 几种继承主要是这部分在变动 end------

    // 调用:
    let c1 = new Child()
    let c2 = new Child()
    c1.name = '更改值类型属性'
    c1.colors.push('green')
    c1.proArr.push('xxxxxxxxxx')

    console.log('---c1:', c1,'---c1.name:',c1.name,'---c1.colors:',c1.colors,'---c1.proArr:',c1.proArr)
    console.log('---c2:', c2,'---c2.name:',c2.name,'---c2.colors:',c2.colors,'---c2.proArr:',c2.proArr)
  • 特点:
    1、新实例可继承的有:子类中构造函数定义的属性,子类原型上的方法,父类构造函数中的属性(共享),父类原型的方法。
    2.简单,易于实现
  • 缺点:
    1、新实例无法向父类构造函数传参。(案例中为了简单,没有示范传参数的情况,可自己尝试)
    2、继承单一。
    3、所有新实例都会共享父类实例的属性。(原型上的属性是共享的,因此父类构造函数“引用类型的属性”在一个实例上被修改,另一个实例的原型属性也会被修改!),注意这里致命缺陷是父类构造函数内定义的属性共享了,父类prototype上的属性或者方法共享是没有问题的---致命缺陷
    image.png

那么问题来了,为什么更改引用类型的会影响,而更改值类型不影响?

答:
其实‘修改’共享的引用类型的重点是“怎么修改”,如果采用直接赋值的方式如:
c1.colors=['green'] // 这样只会在c1的自身(非继承)属性下新增一个colors并赋值['green'] ,也就是不会去更改c1.__proto__.colors为colors,因此这样的更改不会影响到其他的子类实例

不知道我描述清楚没,也就是直接赋值的方式是不会影响其他子类实例的,但是采用借助引用地址方式更改,
如:
数组类型的c1.colors[0]、push、splice、pop、unshift等等,
对象(别较真,就这里指一般的对象)如c1.xxx.xxx的方式修改,都会影响其他的

注意:在继承中使用 Child.prototype = xxxx 的时候,需要修复constructor的指向,原因如下:

constructor的指向可以这样理解:假设没有继承父类的操作,只定义子类

   // 定义子类
    function Child() {
        this.type = "child"; // 在继承父类属性的基础上,扩展子类属性
    }

此时:Child.prototype.constructor 是指向的 Child 的引用【注意:prototype是函数的一个属性,是函数的原型对象。prototype只能够被“函数”调用】
然后当执行Child.prototype = new Parent()后,原型链接入父类,此时子类原本的constructor丢失【赋值了嘛,被覆盖了嘛,不就丢了原本的信息】

执行 let c1 = new Child()后不修复的话constructor会指向Parent,也就是c1.__proto__.__proto__.constructor 指向Parent,丢失了原本的c1.__proto__.constructor

网上很多教程在提到继承时都很少去修复constructor的指向,这里建议加上。关于constructor的理解可参考这篇文章:javascript中constructor指向问题,那么constructor有什么用呢?来来来,各位看官看这里JS中原型对象中的constructor的作用

方式二、构造继承:在子类构造函数中使用call、apply或者bind等,以继承父类构造函数中定义的属性

      // 定义父类
    function Parent() {
        this.name = "parent";
        this.colors = ["red", "blue", "yellow"]; // 父类构造函数内的引用类型属性
    }
    Parent.prototype.proArr = [1,2,3] // 父类原型上的引用类型属性
    Parent.prototype.sayFather = function () {
        console.log("来自父类的呐喊")
    }

     // 几种继承主要是这部分在变动 start ------
     // 定义子类
    function Child() {
        Parent.call(this) // 关键就是这行代码,若要传参也是在这里调用
        this.type = "child"; // 在继承父类属性的基础上,扩展子类属性
    }
    // 在继承父类属性的基础上,扩展子类方法
    Child.prototype.sayChild = function () {
        console.log("来自子类的呐喊")
    }
    // 几种继承主要是这部分在变动 end------

   // 调用:
    let c1 = new Child()
    let c2 = new Child()
    c1.name = '更改值类型属性'
    c1.colors.push('green')
    //c1.proArr.push('xxxxxxxxxx') // 父类原型上的没有被继承,这里没有proArr

    console.log('---c1:', c1,'---c1.name:',c1.name,'---c1.colors:',c1.colors,'---c1.proArr:',c1.proArr)
    console.log('---c2:', c2,'---c2.name:',c2.name,'---c2.colors:',c2.colors,'---c2.proArr:',c2.proArr)
  • 特点:
    1、只继承了父类构造函数的属性,没有继承父类原型的属性和方法。
    2、解决了原型链继承缺点1、2、3。
    3、可以继承多个构造函数属性(call多个)。
    4、在实例化子类时可向父类构造方法传参。用call、applay等时可传参。
  • 缺点:
    只能继承父类的实例属性和方法,不能继承原型属性/方法。--- 致命缺陷


    image.png

方式三、构造组合继承:结合原型链继承和构造继承:结合了两种模式的优点,传参和复用

       // 定义父类
    function Parent() {
        this.name = "parent";
        this.colors = ["red", "blue", "yellow"]; // 父类构造函数内的引用类型属性
    }
    Parent.prototype.proArr = [1,2,3] // 父类原型上的引用类型属性
    Parent.prototype.sayFather = function () {
        console.log("来自父类的呐喊")
    }

    // 几种继承主要是这部分在变动 start ------
    // 定义子类
    function Child() {
        Parent.call(this)
        this.type = "child"; // 在继承父类属性的基础上,扩展子类属性
    }
    Child.prototype = new Parent()
    Child.prototype.constructor = Child; // 修复原型链-Child.prototype.constructor应该指向自己
    // 在继承父类属性的基础上,扩展子类方法
    Child.prototype.sayChild = function () {
        console.log("来自子类的呐喊")
    }
    // 几种继承主要是这部分在变动 end------

    // 调用:
    let c1 = new Child()
    let c2 = new Child()
    c1.name = '更改值类型属性'
    c1.colors.push('green')
    c1.proArr.push('xxxxxxxxxx')

    console.log('---c1:', c1,'---c1.name:',c1.name,'---c1.colors:',c1.colors,'---c1.proArr:',c1.proArr)
    console.log('---c2:', c2,'---c2.name:',c2.name,'---c2.colors:',c2.colors,'---c2.proArr:',c2.proArr)
  • 特点:
    1、可以继承父类原型上的属性,可以传参,可复用。
    2、每个新实例引入的构造函数属性是私有的。
  • 缺点:
    1.稍显臃肿,父类构造方法中定义的属性重复挂载到子类的原型上,这部分根本不会被访问。
    2.调用了两次父类构造函数(初始化的时候,Child.prototype = new Parent()一次,后续每次实例化子类的时候调一次),多耗一点内存。
    image.png

    再次强调:父类prototype上的属性或者方法共享是没有问题的,prototype的目的就是实现共享

方式四、寄生组合继承,也叫做组合继承的优化 --- 推荐

寄生组合继承实现方式:通过寄生方式,在组合继承基础上砍掉父类的实例属性,子类的原型指向父类副本的实例从而实现原型共享。

组合继承方法我们已经说了,它的缺点是两次调用父级构造函数,为了解决这个问题只能砍掉一次调用。因为Parent.call(this)就是来解决父类构造函数内的属性和方法共享问题的,因此必不可少,那么只能更改Child.prototype = new Parent()这行代码,如下:

    // 定义父类
    function Parent() {
        this.name = "parent";
        this.colors = ["red", "blue", "yellow"]; // 父类构造函数内的引用类型属性
    }
    Parent.prototype.proArr = [1,2,3] // 父类原型上的引用类型属性
    Parent.prototype.sayFather = function () {
        console.log("来自父类的呐喊")
    }
        
       // 几种继承主要是这部分在变动 start ------
       // 定义子类
        function Child(){
            Parent.call(this);
            this.type = "child"; // 扩展父类属性
        }

//        function createObj(o){
//            function F(){}
//            F.prototype = o;
//            return new F();
//        }
//
//        Child.prototype = createObj(Parent.prototype); 
        Child.prototype = Object.create(Parent.prototype); // 这里只将父类的prototype拿过来并使用Object.create(是一种创建对象的方式,它会创建一个中间对象)
        Child.prototype.constructor = Child; // 修复原型链 Child.prototype.constructor应该指向自己

        Child.prototype.sayChild = function(){console.log("来自子类的呐喊")} // 扩展父类方法
        // 几种继承主要是这部分在变动 end------

    // 调用:
    let c1 = new Child()
    let c2 = new Child()
    c1.name = '更改值类型属性'
    c1.colors.push('green')
    c1.proArr.push('xxxxxxxxxx')

    console.log('---c1:', c1,'---c1.name:',c1.name,'---c1.colors:',c1.colors,'---c1.proArr:',c1.proArr)
    console.log('---c2:', c2,'---c2.name:',c2.name,'---c2.colors:',c2.colors,'---c2.proArr:',c2.proArr)

  • 特点:
    堪称完美
  • 缺点:
    实现较为复杂

Object.create其实是干了这么件事,用上面这段代码是一样的,es5出现的Object.create替代了上面那段代码


image.png

这里有几个问题:
问题1:为什么不直接用 Child.prototype = Parent.prototype,这样既没有调用父类构造也让子类拥有了父类原型上的属性方法?

    Child.prototype = Parent.prototype; 
    Child.prototype.constructor = Child; // 修复原型链 Child.prototype.constructor应该指向自己
    // 在继承父类属性的基础上,扩展子类方法
    Child.prototype.sayChild = function () {
        console.log("来自子类的呐喊")
    }

这个问题比较简单,Child不仅继承了父类原型上的方法和属性,还有自己的方法,如果使用上面的方式,
那么Parent.prototype上也加上了sayChild方法,那么由Parent实例化的其他对象或者Parent的子类都将受到影响

问题2:为什么不直接用 Child.prototype.__proto __ = Parent.prototype,而要使用Object.create?

Object.create的原理就是生成一个新对象,该新对象的 __proto__ 指向现有对象,而且我们使用Child.prototype = Object.create(Parent.prototype)其实也只是把Parent的原型拿去放入函数副本中,并不涉及Parent内部构造函数内的属性。

综上,我认为Child.prototype.__proto __ = Parent.prototype也是可以的,但是通常我们不会这样直接操纵__proto __去赋值,因此了解即可。
(当然也可能我的水平不够没能看出问题,如果哪位小伙伴有准确答案请在评论区告诉我,我也会进行更正)

方式五、实例继承:子类对父类实例进行扩展,子类本身不在原型链上

    // 定义父类
    function Parent() {
        this.name = "parent";
        this.colors = ["red", "blue", "yellow"]; // 父类构造函数内的引用类型属性
    }
    Parent.prototype.proArr = [1,2,3] // 父类原型上的引用类型属性
    Parent.prototype.sayFather = function () {
        console.log("来自父类的呐喊")
    }

    // 几种继承主要是这部分在变动 start ------
    function Child(){
        var instance = new Parent();
        instance.type = "child"; // 扩展父类属性
        instance.sayChild = function(){console.log("来自子类的呐喊")} // 扩展父类方法
        return instance;
    }
    // 几种继承主要是这部分在变动 end ------

    // 调用:
    let c1 = new Child()
    let c2 = new Child()
    c1.name = '更改值类型属性'
    c1.colors.push('green')
    c1.proArr.push('xxxxxxxxxx')

    console.log('---c1:', c1,'---c1.name:',c1.name,'---c1.colors:',c1.colors,'---c1.proArr:',c1.proArr)
    console.log('---c2:', c2,'---c2.name:',c2.name,'---c2.colors:',c2.colors,'---c2.proArr:',c2.proArr)
  • 特点:
    不限制调用方式,不管是new 子类()还是子类(),返回的对象具有相同的效果
  • 缺点:
    实例是父类的实例,不是子类的实例
    不支持多继承

继承的正常思路是子类实例 <- 子类 <-父类,而当前这种方式的思路是子类实例 <-父类,也就是实例其实是父类的实例,不是子类的实例,从原型链上也能看出来。

image.png

方式六、其他继承 --了解思路即可

个人觉得掌握上面的继承方式就行了,下面这几种麻烦也不好用,这里只粗略过一下。

    // 定义父类
    function Parent() {
        this.name = "parent";
        this.colors = ["red", "blue", "yellow"]; // 父类构造函数内的引用类型属性
    }
    Parent.prototype.proArr = [1, 2, 3] // 父类原型上的引用类型属性
    Parent.prototype.sayFather = function () {
        console.log("来自父类的呐喊")
    }
//  ----------  1.原型继承  start------------

    var object = function (obj) {
        function F() {};//临时构造函数
        F.prototype = obj;//传入对象obj作为临时构造函数的原型对象
        return new F();//返回临时构造对象实例
    }

    // 这部分在变动 start ------
    var Child = function (obj) {
        var sub = object(obj)
        sub.type = "child"; // 扩展父类属性
        sub.sayChild = function () {
            console.log("来自子类的呐喊")
        } // 扩展父类方法
        return sub
    }
    var c1 = Child(new Parent())
    var c2 = Child(new Parent())
    // 这部分在变动 end ------

    c1.colors.push('green')
    console.log('c1:', c1)
    console.log('c2:', c2)
/*
个人觉得这个和实例继承差不多,只是中间再加了一层F,而且没有扩展属性和方法
*/
//  ----------  1.原型继承  end------------


//  ----------  2.寄生式继承  start------------
// 寄生式继承就是把原型式继承再次封装,然后在对象上扩展新的方法,再把新对象返回

  var object = function (obj) {
        function F() {};//临时构造函数
        F.prototype = obj;//传入对象obj作为临时构造函数的原型对象
        return new F();//返回临时构造对象实例
    }

   // 这部分在变动 start ------
   var Child = function(obj){
        var sub = object(obj)
        sub.type = "child"; // 扩展父类属性
        sub.sayChild = function(){console.log("来自子类的呐喊")} // 扩展父类方法
       return sub
    }
    var c1 = Child(new Parent())
    var c2 = Child(new Parent())
    // 这部分在变动 end ------

    c1.colors.push('green')
    console.log('c1:', c1)
    console.log('c2:', c2)
/*
寄生式继承和实例继承对比那就真的只是中间再加了一层F的区别
*/
//  ----------  2.寄生式继承  end------------

//  ----------  3.拷贝继承  start------------
    // 这部分在变动 start ------
    function Child() {
        var instance = new Parent();
        for (var p in instance) {
            Child.prototype[p] = instance[p];
        }
        this.type = "child"; // 扩展父类属性
    }
    // 在继承父类属性的基础上,扩展子类方法
    Child.prototype.sayChild = function () {
        console.log("来自子类的呐喊")
    }

    // 调用
    var c1 = new Child()
    var c2 = new Child()
    // 这部分在变动 end------

    c1.colors.push('green')
    console.log('c1:', c1)
    console.log('c2:', c2)
/*
拷贝继承是,子类实例 <-子类,和实例继承类似,不同之处在于子类中是将父类的实例遍历,
这样父类中构造函数内的属性和原型上的属性方法都将挂载到子类的原型上,后面实例化时也是返回子类实例化的对象
特点:
   支持多继承
缺点:
   效率较低,内存占用高(因为要拷贝父类的属性)
   无法获取父类不可枚举的方法(不可枚举方法,不能使用for in 访问到)
*/
//  ----------  3.拷贝继承  end------------

方式七、es6继承

 // 定义父类
    class Parent{
        static proArr = [1,2,3] // 要多个子类实例共享部分数据可以使用 static
        constructor(){
            this.name = "parent";
            this.colors = ["red", "blue", "yellow"];
        }

        sayFather(){
            console.log("来自父类的呐喊")
        }
    }

    // 定义子类
    class Child extends Parent{ // 继承父类且扩展
        constructor(){
            super();
            this.type = "child"; // 扩展父类属性
        }

        sayChild() {
            console.log("来自子类的呐喊")
        }
    }
    // 调用:
    let c1 = new Child()
    let c2 = new Child()
    c1.name = '更改值类型属性'
    c1.colors.push('green')
    Parent.proArr.push('xxxxxxxxxx')
    console.log('Parent.proArr:',Parent.proArr)
    console.log('不知道父类的时候可根据原型链找父类:',c2.__proto__.__proto__.constructor.proArr)


    console.log('---c1:', c1,'---c1.name:',c1.name,'---c1.colors:',c1.colors,'---c1.proArr:',c1.proArr)
    console.log('---c2:', c2,'---c2.name:',c2.name,'---c2.colors:',c2.colors,'---c2.proArr:',c2.proArr)
  • 特点:
    相比es5的原型链方式继承实现方式简单很多,传参也方便,便于理解书写
  • 缺点:
    不支持低版本浏览器,不支持ie,需要借助babel转成es5才能执行
    image.png

    可以看到结构和es5继承发生了些变化,但大体不变。注意:根据原型链找到的constructor不一定可信,因为是可以改的

注意子类中在“this前”调用了super,因为子类没有自己的this,调用super是为了将子类构造函数向上传递给父类,父类调用这个构造函数并生成this对象返回,可参考ES6中派生类的Super为什么一定要在使用this前调用

扩展:typescript中的继承

  // 定义父类
  class Parent{
    name:string;
    colors:Array<string>;
    static proArr:Array<number> = [1,2,3] // 要多个子类实例共享部分数据可以使用 static
    constructor(){
        this.name = "parent";
        this.colors = ["red", "blue", "yellow"];
    }

    sayFather():void{
        console.log("来自父类的呐喊")
    }
}

// 定义子类
class Child extends Parent{ // 继承父类且扩展
    type:string;
    constructor(){
        super();
        this.type = "child"; // 扩展父类属性
    }

    sayChild():void {
        console.log("来自子类的呐喊")
    }
}
// 调用:
let c1 = new Child()
let c2 = new Child()
c1.name = '更改值类型属性'
c1.colors.push('green')
Parent.proArr.push(999)
console.log('Parent.proArr:',Parent.proArr)
// console.log('不知道父类的时候可根据原型链找父类:',c2.__proto__.__proto__.constructor.proArr)

console.log('---c1:', c1,'---c1.name:',c1.name,'---c1.colors:',c1.colors)
console.log('---c2:', c2,'---c2.name:',c2.name,'---c2.colors:',c2.colors)

这样会报错,因此不能访问__proto __


image.png

参考网址:
https://www.cnblogs.com/ranyonsue/p/11201730.html
https://www.cnblogs.com/humin/p/4556820.html

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):