今天是系列第四篇,主要讲一下继承相关的问题。我发现上周原型链部分还有几个概念没有说清楚,为了不影响继承知识点的学习,我决定先把上周原型链中的prototype、constructor和__proto__这几个概念再做一下补充,也当做是前期回顾吧。

prototype是什么?

prototype对象用于存放同一类型实例的共享属性和方法,目的是为了减少内存消耗。举个生活中的例子来理解这个概念:我们每一个家庭都有购物和治病的需求,但是不可能每个家庭都建造一个超市和医院,这样会造成很大的资源浪费。现代化做法是在公共区域建立一个可以共用的超市和医院,满足所有当地人的需要,这样让人们得到了实惠,资源也被很好的使用了。如下图:
prototype图解

constructor是什么?

constructor就是一个指向自身构造函数引用的属性。一般存在对象.constructor === 构造函数,这个概念在接下来的继承中会有涉及。并且constructor实际上是被当做共享属性放在它的原型对象中。所以我们可以看做prototype.constructor === 构造函数,这个对象就是当前构造函数的原型。如下图:
constructor图解

__proto__是什么?

我之前看到过一个结论,即:构造函数.prototype.constructor === 实例对象.__proto__.constructor === 构造函数。
我们已经知道了原型和构造函数之间的关系,现在可以看作有了实例对象之后怎么跟原型产生一种关系来实现与构造函数之间的联系。那么我们可能会设置一个属性指向原型,那么这个属性就是__proto__。
有了这个基础我们就能得出下面的结论:

实例对象.__proto__ === 构造函数.prototype
实例对象.__proto__.constructor === 构造函数

如果运用原型链的知识,还有如下结论:

实例对象.constructor === 实例对象.__proto__.constructor

查找对象属性时会先看当前对象是否存在该属性,如果不存在则会去其原型链上找,如果原型链上没有,则返回undefined。实例对象不存在constructor属性,则会去原型链上找,所以和实例对象.__proto__.constructor找的是同一个值。
__proto__的基本解释如下图:
__proto__图解

有了上面的知识之后,可以梳理继承的知识了。以下是市面上常见的5种继承方式:

  • 原型链继承
  • 借用构造函数继承
  • 组合式继承
  • 寄生组合式继承
  • es6继承

原型链继承

  • 优点:能够继承父类的原型方法
  • 缺点:

    • 不能给超类型的父类传参(即使传参了,所有的实例得到的属性是同一个,例子见demo1)
    • 超类型的父类属性是引用类型时,该属性会被所有实例共享(因为继承父类的所有属性都是共享属性,所有实例访问到的这个属性都是同一个内存空间,例子见demo2)
// demo1
function Parent(name){
    this.name = name
}
function Son(){}
Son.prototype = new Parent('凉凉')
Son.prototype.constructor=Son
const son1 = new Son()
const son2 = new Son()
console.log(son1.name, son2.name) // 凉凉 凉凉


// demo2
function Parent(){
    this.animals = ['老虎', '狮子']
}
function Son(){}
Son.prototype = new Parent()
Son.prototype.constructor=Son
const son1 = new Son()
const son2 = new Son()
son1.animals.push('猴子')
son2.animals.push('猪')
console.log(son1.animals) // ["老虎", "狮子", "猴子", "猪"]

本质是:将父类的实例赋值给子类的原型,让子类的原型拥有父类的所有属性和原型。但是原型上的所有属性都是共享的,所以任何一个子类实例修改了原型中的属性,其他实例获取到的属性值也会引起变化。
另外还注意一点:上面的例子Son.prototype.constructor在默认情况下是指向函数Parent,所以需要重新设置一下指向Son.prototype.constructor=Son

借用构造函数继承

  • 优点:解决父类属性是引用类型被所有实例共享的问题和给子类传参的问题
  • 缺点:不能继承父类超类型的原型方法

看例子:

function Parent(name){
    this.name = name
    this.animals = ['老虎', '狮子']
}
Parent.prototype.getName = function(){
    return this.name
}
function Son(name){
    Parent.call(this, name)
}
const son1 = new Son('小李')
const son2 = new Son('小王')
son1.animals.push('猴子')
son2.animals.push('猪')
console.log(son1.name) // 小李
console.log(son1.animals) //  ["老虎", "狮子", "猴子"]
console.log(son1.getName()) // throw error

本质是执行了一遍父类的构造函数,并让父类构造函数的this指向子类构造函数的this(即this指向子类的实例),所以子类的实例拥有和父类实例同名属性,但是没有继承原型对象。

组合式继承

  • 优点:集合了原型继承和借用构造函数继承的优点
  • 缺点:父类构造函数会执行两遍。子类的原型对象和原型链中会出现两个相同的同名属性

看例子:

function Parent(name){
    this.name = name
    this.animals = ['老虎', '狮子']
}
Parent.prototype.getName = function(){
    return this.name
}
function Son(name){
    Parent.call(this, name)
}
Son.prototype = new Parent()
const son1 = new Son('小李')
const son2 = new Son('小王')
son1.animals.push('猴子')
son2.animals.push('猪')
console.log(son1.name) // 小李
console.log(son1.animals) //  ["老虎", "狮子", "猴子"]
console.log(son1.getName()) // 小李

从结果的输出来看挺完美。但是在继承的过程中Parent函数执行了两次,并且子类的原型对象和原型链中会出现两个相同的同名属性。因为原型链继承和借用构造函数继承都分别执行了一次。
说到这里我有一个疑问:为什么组合继承能够把上面两个继承的优点都发挥出来呢?
答:借用构造函数的方式会在子类的实例对象上创建父类的同名属性,原型链继承的方式会在子类的原型上拥有父类的属性和原型。但是在访问某个对象的属性时,会先在当前对象中找有没有该属性,如果不存在就会去它的原型上找。所以会先去找通过call/apply绑定在当前对象上的属性,而不是原型中的共享属性。所以可以获取到子类实例的属性和原型。

为了解决父类构造函数执行两次的问题,又推出了寄生组合继承方法。

寄生组合式继承

  • 优点:降低调用父类构造函数的开销,只调用父类构造函数一次(原理是:将原型链继承的那部分进行改造)
  • 缺点: /

看例子:

function Parent(name){
    this.name = name
    this.animals = ['老虎', '狮子']
}
Parent.prototype.getName = function(){
    return this.name
}
function Son(name){
    Parent.call(this, name)
}

Son.prototype = Object.create(Parent.prototype)
Son.prototype.constructor = Son

const son1 = new Son('小李')
const son2 = new Son('小王')
son1.animals.push('猴子')
son2.animals.push('猪')
console.log(son1.name) // 小李
console.log(son1.animals) //  ["老虎", "狮子", "猴子"]
console.log(son1.getName()) // 小李

实质是:通过Object.create(obj)创建一个原型是obj的空对象赋值给子类的原型。还有一点需要注意:所有基于原型链继承的都需要记住constructor的指向问题,寄生继承相当于是原型链继承的一种变形。

基于原型链的继承如果constructor没有重新设置指向的话,它指向的是超类型构造函数。因为constructor是原型的一个共享属性,所以在子类原型中查找constructor属性时其实会在原型链上去找constructor指向的值,最后指向了超类型构造函数。看例子:

function Parent(){}
function Son(){}
Son.prototype = new Parent()
console.log(Son.prototype.constructor) // Parent

es6继承

最后一个是通过class,extends关键字实现继承的方式。es6继承具体函数和关键字的作用是什么,下一篇文章会单独拎出来讲,先看一个class继承的例子:

class Parent{
    constructor(name){
        this.name = name
    }
    getName(){
        return this.name
    }
}

class Son extends Parent{
    constructor(name, age){
        super(name)
        this.age = age
        console.log(new.target)
    }
    introduce(){
        return `我叫做${this.name},今年${this.age}岁了`
    }
}
const s = new Son("小李", 8)
console.log(s.introduce()) // 我叫做小李,今年8岁了
console.log(s.getName()) // 小李

总结

  • 原型继承

    • 优点:能够继承父类的原型方法
    • 缺点:

      • 不能给超类型的父类传参
      • 超类型的父类属性是引用类型时,该属性会被所有实例共享
  • 借用构造函数的继承

    • 优点:解决父类属性是引用类型被所有实例共享的问题和给子类传参的问题
    • 缺点:不能继承父类超类型的原型方法
  • 组合继承

    • 优点:集合了原型继承和借用构造函数继承的优点
    • 缺点:父类构造函数会执行两遍。子类的原型对象和原型链中会出现两个相同的同名属性
  • 寄生组合式继承

    • 优点:降低调用父类构造函数的开销,只调用父类构造函数一次(原理是:将原型链继承的那部分进行改造)
    • 缺点: /
  • es6继承

    • 优点:引入了类的概念,使用较多语法糖,让继承书写更贱简单灵活
    • 缺点: /

参考文章


摩根
11 声望2 粉丝

前端工程师