JavaScript的继承详解(ES5和ES6)--每天进步一点点

816 阅读11分钟

继承

  1. 定义:继承是一个类从另一个类获取方法和属性的过程。

  2. js的继承:复制父类的属性和方法来重写子类原型对象。

    记住这个概念,你会发现JS中的继承都是在实现这个目的,差异是它们的实现方式不同。

实现方式

  1. 原型链继承
  2. 借用构造函数继承
  3. 组合继承
  4. 原型式继承
  5. 寄生式继承
  6. 寄生组合继承(组合继承优化2)---完美方式
  7. ES6 Class extends

准备

既然要继承,首先需要一个父类,定义一个Person 父类:


function Person (name) {
  this.name = name || 'xuxu' // 实例基本属性 (该属性,强调私有,不共享)
  this.like = ['eat'] // 私有,不共享

  this.say = function() { // 实例引用属性 (该属性,强调复用,需要共享)
    console.log('hello')
  }
}

Person.prototype.getInfo = function () { // 将需要复用、共享的方法定义在父类原型上
  console.log(this.name + this.sex)
}

1. 原型链继承

  1. 核心:将父类的实例作为子类的原型

  2. 构造函数、原型和实例之间的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个原型对象的指针。

  3. 代码:

    
    function p (sex) {
      this.sex = sex
    }
    
    p.prototype = new Person()
    
    let p1 = new p('man')
    let p2 = new p('woman')
    
    // 优点:父类的方法可以被复用,p1和p2共用同一个方法
    console.log(p1.say === p2.say) // true
    // 原型上的方法也可以
    console.log(p1.getInfo === p2.getInfo) // true
    console.log(p1.getInfo()) // xuxuman
    console.log(p2.getInfo()) // xuxuwoman
    
    // 缺点1:父类的引用数据类型被所有子类实例共享
    p1.link.push('sleep')
    console.log(p1.link) // ['eat', 'sleep']
    console.log(p2.link) // ['eat', 'sleep']
    
    
  4. 优点:父类的方法可以被复用,原型上的方法也可以。

  5. 缺点:

    • 父类的引用数据类型被所有子类实例共享。
    • 子类在实例化的时候不能给父类构造函数传参。

2. 借用构造函数继承

  1. 使用父类的构造函数来增强子类实例,等同于复制父类的实例给子类(不使用原型)。

  2. 借用构造函数实现继承解决了原型链继承的 2 个问题:引用类型共享问题以及传参问题。 但原型链继承的优点也变成它的缺点。

  3. 代码:

    
    function p(name, sex) {
      Person.call(this, name)  // 核心
      this.sex = sex
    }
    
    let p1 = new p('小红', 'man')
    let p2 = new p('小明', 'woman ')
    
    // 优点1:可以向父类构造函数传参数
    console.log(p.name, p.name) // 小红, 小明
    
    // 优点2:子类实例不共享父类构造函数的引用属性
    p1.like.push('sleep')
    console.log(p1.like,p2.like)// ['eat', 'sleep'] ['eat']
    
    // 缺点1:方法不能复用,每次创建子类实例都要创建一遍方法
    console.log(p1.say === p2.say) // false (说明,p1和p2 的say方法是独立,不是共享的)
    
    // 缺点2:不能继承父类原型上的方法
    Person.prototype.walk = function () {   // 在父类的原型对象上定义一个walk方法。
      console.log('我会走路')
    }
    p1.walk  // undefined (说明实例,不能获得父类原型上的方法)
    
    
  4. 优点:和原型链继承完全反过来。

    • 可以向父类构造函数传参数。
    • 子类实例不共享父类构造函数的引用属性。
  5. 缺点:

    • 方法不能复用;由于方法必须定义在构造函数中,所以会导致每次创建子类实例都会创建一遍方法。
    • 不能继承父类原型上的方法,只能继承父类的实例属性和方法。

3. 组合继承

  1. 通过调用父类构造函数,继承父类的属性并保留传参的优点;然后通过将父类实例作为子类原型,实现函数复用。

  2. 代码:

    
    function p(name, sex) {
      Person.call(this, name)  // 核心  第二次
      this.sex = sex
    }
    
    p.prototype = new Person() // 核心  第一次
    // 修复构造函数的指向
    p.prototype.constructor = p
    
    let p1 = new p('小红', 'man')
    let p2 = new p('小明', 'woman ')
    
    // 优点1:可以向父类构造函数传参数
    console.log(p.name, p.name) // 小红, 小明
    
    // 优点2:子类实例不共享父类构造函数的引用属性
    p1.like.push('sleep')
    console.log(p1.like,p2.like)// ['eat', 'sleep'] ['eat']
    
    // 优点3:父类原型上的方法可以被复用
    console.log(p1.getInfo === p2.getInfo) // true
    
    
  3. 注意:p.prototype 指向的是Person 的实例,p.prototype 上面是没有constructor属性的,就会往上找,这样就找到了Person.prototype上面的constructor属性,导致 p.prototype.constructor === Person。这就需要将constructor 指向子类的构造函数p。

    原因:不能判断子类实例的直接构造函数,到底是子类构造函数还是父类构造函数。

  4. 优点:

    • 保留构造函数的优点:1. 可以向父类构造函数传参数。2. 子类实例不共享父类构造函数的引用属性。
    • 保留原型链的优点:父类的实例方法定义在父类的原型对象上,可以实现方法复用。
  5. 缺点:两次调用父类的构造函数,存在多余的一份父类的实例属性。

    • 第一次执行:Person.call(this, name) p 调用了Person 拷贝一份父类实例属性,作为子类的实例属性。
    • 第二次执行:p.prototype = new Person() 创建父类实例作为子类原型。
    • 第二次执行完后,父类实例就又有了一份实例属性,但这份会被第一次拷贝来的实例属性屏蔽掉,所以多余。

组合继承优化1

  1. 核心:砍掉父类的实例属性,这样在调用父类的构造函数的时候,就不会初始化两次实例,避免组合继承的缺点。

  2. 代码:

    
    function p(name, sex) {
      Person.call(this, name)  // 核心
      this.sex = sex
    }
    
    p.prototype = Person.prototype // 核心  子类原型和父类原型,实质上是同一个
    // 修复构造函数的指向
    p.prototype.constructor = p
    
    let p1 = new p('小红', 'man')
    let p2 = new p('小明', 'woman ')
    
    let p3 = new Person()
    
    // 缺点:当修复子类构造函数的指向后,父类实例的构造函数指向也会跟着变了。
    console.log(p1.constructor)  // p
    console.log(p3.constructor) // p 这里就是存在的问题(我们希望是Person)
    
    
  3. 优点:

    • 只调用一次父类构造函数。
    • 保留组合继承的优点:
      • 保留构造函数的优点:1. 可以向父类构造函数传参数。2. 子类实例不共享父类构造函数的引用属性。
      • 保留原型链的优点:父类的实例方法定义在父类的原型对象上,可以实现方法复用。
  4. 缺点:

    • 修正构造函数的指向之后,父类实例的构造函数指向,同时也发生变化(这是我们不希望的)

      原因:p.prototype = Person.prototype 子类的原型和父类的原型是一个,所以修改了子类实例的construtor属性,所有的constructor的指向都会发生变化。

4. 原型式继承

  1. 核心:原型式继承的createObj 方法本质上是对参数对象的一个浅复制。

  2. 利用一个空对象作为中介,将某个对象直接赋值给空对象构造函数的原型。

    
    function createObj(o) {
      function F() {}
      F.prototype = o // 将被继承的对象作为空函数的prototype
      return new F() // 返回new期间创建的新对象,此对象的原型为被继承的对象, 通过原型链查找可以拿到被继承对象的属性
    }
    
    

    就是 ES5 Object.create 的模拟实现,将传入的对象作为创建的对象的原型。

    现在都是使用 Object.create() 来做对象的原型继承。

  3. 代码:

    
    let p1 = createObj(Person)
    let p2 = createObj(Person)
    
    // 优点:父类的方法可以被复用,p1和p2共用同一个方法
    console.log(p1.say === p2.say) // true
    // 原型上的方法也可以
    console.log(p1.getInfo === p2.getInfo) // true
    console.log(p1.getInfo()) // xuxuman
    console.log(p2.getInfo()) // xuxuwoman
    
    // 缺点1:父类的引用数据类型被所有子类实例共享
    p1.link.push('sleep')
    console.log(p1.link) // ['eat', 'sleep']
    console.log(p2.link) // ['eat', 'sleep']
    
    p1.name = 'p1';
    console.log(p2.name); // xuxu
    
    
  4. 与原型链继承一样,修改p1.name 的值,p2.name 的值并未发生改变,并不是因为p1 和p2 有独立的 name 值,而是因为p1.name = 'p1',给p1 添加了 name 值,并非修改了原型上的 name 值。

  5. 优点:与原型链一样

    • 父类的方法可以被复用,原型上的方法也可以。
  6. 缺点:与原型链一样

    • 父类的引用数据类型被所有子类实例共享。
    • 子类在实例化的时候不能给父类构造函数传参。

5. 寄生式继承

  1. 使用原型式继承的浅复制,创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回对象。

  2. 代码:

    
    function createObj (o) {
      let clone = Object.create(o)
      clone.sayName = function () {
        console.log('hi')
      }
      return clone
    }
    
    
  3. 使用场景:专门为对象来做某种固定方式的增强。

6. 寄生组合继承(组合继承优化2)--- 完美方式

  1. 解决组合继承 调用两次父构造函数 的缺点。

  2. 通过寄生式封装函数设置父类prototype为子类prototype的原型来继承父类的prototype声明的属性/方法。

  3. 代码:

    
    function p(name, sex) {
      Person.call(this, name)  // 核心
      this.sex = sex
    }
    
    ---------------------------------------------------
    
    // p.prototype = new Person() // 核心  第一次
    
    // 1. 最简单的方式,修改上面这一行
    p.prototype = Object.create(Person.prototype) // 核心 不使用  p.prototype = new Person() ,而是间接的让 p.prototype 访问到 Person.prototype 
    
    // 修复构造函数的指向
    p.prototype.constructor = p
    
    ---------------------------------------------------
    
    // 2. 寄生式继承:封装了p.prototype对象原型式继承Person.prototype的过程,并且增强了传入的对象。
    function inheritPrototype(son, father) {
      const fatherFnPrototype = Object.create(father.prototype) // 原型式继承:浅拷贝father.prototype对象 father.prototype为新对象的原型
      son.prototype = fatherFnPrototype // 设置father.prototype为son.prototype的原型
      son.prototype.constructor = son // 修正constructor 指向
    }
    inheritPrototype(p, Person)
    
    ---------------------------------------------------
    
    let p1 = new p('小红', 'man')
    let p2 = new p('小明', 'woman ')
    
    // 优点1:可以向父类构造函数传参数
    console.log(p.name, p.name) // 小红, 小明
    
    // 优点2:子类实例不共享父类构造函数的引用属性
    p1.like.push('sleep')
    console.log(p1.like,p2.like)// ['eat', 'sleep'] ['eat']
    
    // 优点3:父类原型上的方法可以被复用
    console.log(p1.getInfo === p2.getInfo) // true
    
    
  4. 最成熟的继承方法, 也是现在最常用的继承方法,众多JS库采用的继承方案也是它。

  5. 引用《JavaScript高级程序设计》中对寄生组合式继承的夸赞就是:

    这种方式的高效率体现它只调用了一次 Parent 构造函数,并且因此避免了在 Parent.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

7. ES6 Class extends

  1. ES6继承的原理跟寄生组合式继承是一样的。

  2. extends 关键字主要用于类声明或者类表达式中,以创建一个类,该类是另一个类的子类。其中constructor 表示构造函数,一个类中只能有一个构造函数,有多个会报出 SyntaxError 错误,如果没有显式指定构造方法,则会添加默认的 constructor 方法,使用例子如下。

  3. 继承代码:

    
    class Person {
    
      // constructor
      constructor(name) {
        this.name = name
        this.like = ['eat']
      }
      
      // Getter
      get say() {
        return this.getInfo()
      }
      
      // Method
      getInfo() {
        return this.name + this.like
      }
    }
    
    const pp = new Person('xuxu')
    console.log(pp.say) // xuxueat
    
    -----------------------------------------------------------------
    // 继承
    class p extends Person {
    
      constructor(name, sex) {
        super(name)
        
        // 如果子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
        this.sex = sex;
      }
    
      get say() {
        return this.name + this.like + this.sex
      }
    }
    
    const p1 = new p('xx', 'man')
    console.log(p1.say) // xxeatman
    
    
  4. extends继承的核心代码(通过babel在线编译成es5):

    
    // 寄生式继承 封装继承过程
    function _inherits(son, father) {
    
      // 原型式继承: 设置father.prototype为son.prototype的原型 用于继承father.prototype的属性/方法
      son.prototype = Object.create(father && father.prototype)
      son.prototype.constructor = son // 修正constructor 指向
    
      // 将父类设置为子类的原型 用于继承父类的静态属性/方法(father.some)
      if (father) {
        Object.setPrototypeOf
          ? Object.setPrototypeOf(son, father)
          : son.__proto__ = father
      }
    }
    
    
    • 另外子类是通过借用构造函数继承(call)来继承父类通过this声明的属性/方法,也跟寄生组合式继承一样。

    • Object.setPrototypeOf() 方法设置一个指定的对象的原型 ( 即, 内部[[Prototype]]属性)到另一个对象或 null。

ES5继承与ES6继承的区别:

  1. ES5的继承实质上是先创建子类的实例对象,再将父类的方法添加到this上。

  2. ES6的继承是先创建父类的实例对象this,再用子类的构造函数修改this。

    因为子类没有自己的this对象,所以必须先调用父类的 super() 方法。

总结:

  1. ES6 Class extends是ES5继承的语法糖。

  2. JS的继承除了构造函数继承之外都基于原型链构建的。

  3. 可以用寄生组合继承实现ES6 Class extends,但是还是会有细微的差别。

参考文献

  1. JavaScript深入之继承的多种方式和优缺点
  2. JavaScript常用八种继承方案
  3. 一篇文章理解JS继承——原型链/构造函数/组合/原型式/寄生式/寄生组合/Class extends
  4. js继承、构造函数继承、原型链继承、组合继承、组合继承优化、寄生组合继承)