2022年,在学一遍JS继承、class类

·  阅读 729

原型和继承是JS中比较重要的知识,学会并且熟练运用对我们的日常开发和面试都有很大的帮助。在之前的文章已经详细介绍了原型相关的内容,学好原型、原型链,是掌握继承的基础前提,感兴趣的可以查看 # 2022年在学一遍JS原型、原型链,这里就不过多的介绍原型了。本篇主要介绍继承,包括es5中常见的继承方式,和es6新增的class类的继承,以及new操作符在创建一个对象时,内部做了什么。

原型链继承:

熟悉原型、原型链的都清楚:

  • 每个对象都会有一个__proto__属性,指向实例化这个对象的构造函数的原型对象
  • 当访问一个对象的属性或者方法时,实例本身没有,首先会通过对象的__proto__属性,沿着原型链去查找构造函数的原型对象。
  • 如果构造函数的原型对象也没有找到,会继续访问原型对象的__proto__属性去查找,一直找到原型链的尽头Object.prototype.__proto__,也就是null。

根据上面讲到的原型的特性,如果把子类构造函数的原型对象赋值成想要继承的父类构造函数的实例,那子类实例,访问本身没有属性的时候,去访问构造函数的原型对象,这个时候的原型对象已经赋值成了父类构造出来的父类实例对象了,如果还没有属性,就会访问父类实例对象的构造函数的原型对象。这样就实现了原型链继承。

代码演示:

  function Father() {
    this.name = 'Jack'
    this.like = ['play', 'sleep']
  }
  Father.prototype.getName = function() {
    console.log(this.name)
  }

  function Son() {
    this.age = 18
  }
  Son.prototype = new Father() // 关键步骤,把Son的原型对象指向Father的实例对象

  let son = new Son()

  // 对象son本身自带的属性
  console.log(son.age)  

  // 对象son本身没有,通过son.__proto__访问Son.prototype,这时Son.prototype已经指向了new Father()创建出来的实例对象,找到name属性
  console.log(son.name) 

  // 对象son本身没有,通过son.__proto__访问Son.prototype也没有,继续访问Son.prototype.__proto__,因为Son.prototype等于new Father()创建出来的实例对象,所以Son.prototype.__proto__指向Father.prototype,找到getName方法。
  console.log(son.getName())  

  // 子类从父类继承的引用类型的属性,因为是堆内存中的同一个值,会互相影响
  let son1 = new Son()
  son1.like.push('eat')
  let son2 = new Son()
  console.log(son1.like) // ['play', 'sleep', 'eat']
  console.log(son2.like) // ['play', 'sleep', 'eat']
复制代码

缺点:

  1. 父类构造函数中,引用类型的属性会共享,某个子类修改后,会影响其他子类。
  2. 在实例化子类时,无法向父类传参。

借用构造函数继承:

在子类构造函数中,调用父类的构造函数,并且通过改变父类构造函数执行时的this指向,继承父类实例上的属性。

代码演示:

  function Father(name) {
    this.name = name
    this.like = ['play', 'sleep']
  }
  Father.prototype.getName = function() {
    console.log(this.getName)
  }

  function Son(name, age) {
    Father.call(this, name) // 调用父类构造函数,改变this指向,传入需要的参数
    this.age = age
  }

  let son = new Son('Jack', 18) 

  // 对象son本身就具有age和name属性
  console.log(son.age) // 18
  console.log(son.name) // Jack

  // 访问不到方法,报错 ,没有继承父类原型上的属性
  console.log(son.getName())
  
  // 每次实例化对象,都会重新执行构造函数,引用类型的属性不会共享
  let son1 = new Son('Jack', 18)
  let son2 = new Son('Mary', 18)
  son1.like.push('eat')
  console.log(son1.like) // ['play', 'sleep', 'eat']
  console.log(son2.like) // ['play', 'sleep']
复制代码

由于每次创建子类实例,都会去执行一遍父类的构造函数,所以子类从父类继承的属性是独有的,解决了原型链继承中,子类继承父类中引用类型的属性时,相互影响的问题。

缺点:

  1. 无法继承父类原型链的属性 (执行son.getName()时报错)

组合继承:

把原型链继承和借用构造函数继承组合在一起,就是组合继承的实现方式。

代码演示:

  function Father(name) {
    this.name = name
    this.like = ['play', 'sleep']
  }
  Father.prototype.getName = function() {
    console.log(this.name)
  }

  function Son(name, age) {
    Father.call(this, name)  // 调用Father构造函数,继承Father实例对象的属性。实例子类时,支持传参
    this.age = age
  }
  Son.prototype = new Father('Jack') // 调用Father构造函数,继承原型对象上的属性

  let son = new Son('Jack', 18)
  console.log(son.age)
  console.log(son.name)
  console.log(son.getName())
复制代码

组合继承解决了原型链继承中,子类实例的对象修改父类的引用类型的值时,影响其他子类实例。同时解决了借用构造函数继承中,无法继承父类原型对象的属性问题。

但是因为执行了两次父类构造函数,部分属性会同时存在在对象son上和son.__proto__上,控制台打印son:

wecom-temp-48af918bb287958dd20a7e728d8644c0.png

缺点:

  1. 调用两次父类构造函数,造成了不必要的开销,一些属性在子类实例上和原型上重复。

原型式继承:

这种继承方式,还是基于原型、原型链的知识。借助原型,可以基于已有的对象创建一个新的对象:

  function objectCreate(o) {
    function F() {}
    F.prototype = o
    let obj = new F()
    return obj
  }
复制代码

在objectCreate函数内部,有一个构造函数F,把传入的对象作为F的原型,在实例化一个新对象,最终返回。这样就创建了一个以传入的对象作为原型的新对象。实际上,objectCreate函数对传入的对象执行了一次浅复制,看下面的例子:

let person = {
  name: 'Tom',
  like: ['play', 'sleep']
}

let person1 = objectCreate(person)
person1.name = 'Jerry'
person1.like.push('eat')

let person2 = objectCreate(person)
person2.name = 'Jack'
person2.like.push('cry')

console.log(person.like)  // ['play', 'sleep', 'eat', 'cry']
复制代码

es6中的Object.create方法规范原型式继承。这个方法接受两个参数:

  1. 作为新对象原型的对象。
  2. 作为新对象定义额外属性的对象。
    当只传入第一个参数时,作用和objectCreate函数一样
let person = {
  name: 'Tom',
  like: ['play', 'sleep']
}

let person1 = Object.create(person)
person1.name = 'Jerry'
person1.like.push('eat')

let person2 = Object.create(person)
person2.name = 'Jack'
person2.like.push('cry')

console.log(person.like)  // ['play', 'sleep', 'eat', 'cry']
复制代码

Object.create第二个参数和Object.defineProperties方法的第二个参数一样,每个属性的描述符。用这种方式定义的属性,会覆盖原型对象上的同名属性。

let person = {
  name: 'Tom',
  like: ['play', 'sleep']
}

let person1 = Object.create(person, {
  name: {
    value: 'Jerry'
  }
})
console.log(person1.name)  // Jerry
复制代码

寄生式继承:

寄生式继承于原型式继承类似,同样是利用一个对象作为新对象的原型,在以某种方式增强新对象。

  function objectCreate(o) {
    function F() {}
    F.prototype = o
    return new F()
  }
  function createAnother(o) {
    var obj = objectCreate(o) // 把o作为新对象的原型
    obj.sayHi = function() {  // 增强新对象
      console.log('Hi')
    }
    return obj
  }

  let person = {
    name: 'Tom',
    like: ['play', 'sleep']
  }
  let person1 = createAnother(person)
  person1.sayHi()
复制代码

使用createAnother创建一个对象,新对象不仅继承了person上面的属性,还有一个sayHi方法。

寄生组合式继承:

上面提到了组合继承解决了原型链继承和借用构造函数继承的一些缺点,是一种比较完善的继承方式。但是它也存在缺点,就是调用了两次父类构造函数,一次是在创建子类的时候,一次是在子类构造函数内部。
虽然做到了同时继承父类实例属性,和父类原型属性,但是由于调用了两次父类构造函数,做了没有必要的开销,同时在子类实例和原型上存在重复属性。

寄生组合式继承就可以解决这一问题:

  • 通过借用构造函数继承,来继承父类实例上的属性。
  • 子类不必在通过将原型指向父类的实例。直接使用寄生式继承,用父类构造函数的原型创建一个临时对象,在把子类的原型指向临时对象。
function objectCreate(o) {
  function F() {}
  F.prototype = o
  return new F()
}

function extendsPrototype(subType, superType) {
  // 用father的原型,创建一个临时对象, 等价于:Object.create(superType.prototype)
  let prototype = objectCreate(superType.prototype) 

  // 增强对象,把临时对象的constructor 指向子类构造函数
  prototype.constructor = subType  

  // 子类把原型对象指向 新对象
  subType.prototype = prototype 
}

function Father(name) {
  this.name = name
  this.like = ['play', 'sleep']
}

Father.prototype.getName = function() {
  console.log(this.name)
}

function Son(name, age) {
  Father.call(this, name)
  this.age = age
}

extendsPrototype(Son, Father)
Son.prototype.getAge = function() {
  console.log(this.age)
}

let son = new Son('Tom', 18)
复制代码

在extendsPrototype函数中,为了继承父类原型的属性,通过寄生式继承创建了一个临时对象,再让子类的原型指向这个临时对象,这样就继承了父类原型上的属性。为什么不直接把子类原型指向父类原型呢?这样同样可以让子类访问父类的原型属性,像这样:

function extendsPrototype(subType, superType) {
  subType.prototype = superType.prototype 
}
复制代码

通过寄生式继承创建一个临时对象来继承父类,主要目的是为了保证继承这一链条会正确的沿着原型链查找属性,并且能正常使用instanceof和isPrototypeOf()判断对象和对象之间的继承关系。

new操作符具体步骤:

我们通常使用new操作符来创建一个对象,他的原理就是通过上面说到继承方式来实现的。我们通过实现一个简易的new来理解一下:

function myNew(Con, ...args) {
  let obj = {}
  obj.__proto__ = Con.prototype // 等同于:Object.setPrototypeOf(obj, Con.prototype)
  let result = Con.apply(obj, args)
  return result instanceof Object ? result : obj
}
复制代码

通过实现一个简易的new功能,我们知道,new操作符主要做了:

  1. 创建一个新对象。
  2. 把新对象的__proto__属性指向构造函数的原型对象,实现对构造函数原型属性的继承。
  3. 执行构造函数,把this指向新对象,实现对构造函数实例属性的继承。
  4. 判断构造函数执行结果,如果是对象,返回这个对象,否则返回第一步创建的新对象。

class类中的extends继承:

在es6的class类出现之前,JS中实现继承通常使用寄生组合式继承来实现,这种方式步骤复杂、不易理解。class出现后,JS编写继承,就可以像Java中的实现继承一样,步骤清晰而且容易理解。
不过class的本质还是构造函数,extends也是上面说到的继承方式的组合。它可以看作一个语法糖,让对象原型的写法更加清晰、更像面向对象编程的语法。

class和构造函数对比:

先对比一下class和构造函数写法的区别:

  class Person {
    constructor(name) {
      this.name = name
    }
    getName() {
      return this.name
    }
  }
复制代码

上面的代码用构造函数实现:

  function Person(name) {
    this.name = name
  }
  Person.prototype.getName() {
    return this.name
  }
复制代码

通过上面的对比可以看出:

  • class声明语句会定义一个变量Person,并把内部的constructor赋值给Person
  • 在class中定义的方法,实际是绑定到了构造函数的原型对象上

class继承和es5继承对比:

  class Father {
    constructor(name) {
      this.name = name
    }

    getName() {
      return this.name
    }
    static getStatic() {
      console.log('我是一个静态方法')
    }
  }

  class Son extends Father{
    constructor(name, age) {
      super(name)
      this.age = age
    }

    getAge() {
      return this.age
    }
  }
  Son.getStatic() // 我是一个静态方法

  let son = new Son('Tom', 18)
复制代码

上面代码用构造函数实现:

  function extendsPrototype(subType, superType) {
    // 用father的原型,创建一个临时对象
    let prototype = Object.create(superType.prototype)

    // 增强对象,把临时对象的constructor 指向子类构造函数
    prototype.constructor = subType  

    // 子类把原型对象指向 新对象
    subType.prototype = prototype 

    
    subType.__proto__ = superType
  }

  function Father(name) {
    this.name = name
  }
  Father.prototype.getName = function() {
    return this.name
  }
  Father.getStatic = function() {
    console.log('我是一个静态方法')
  }

  function Son(name, age) {
    Father.call(this, name)
    this.age = age
  }

  extendsPrototype(Son, Father)
  Son.prototype.getAge = function() {
    return this.age
  }

  let son = new Son('Tom', 18)
复制代码

通过比较可以看出,es6的class的继承十分类似于寄生组合式继承:

  • 关键字extends是把子类的prototype赋值成了一个继承自父类原型的临时对象,并子类的__proto__赋值成父类(subType.__proto__ = superType)。
  • 调用super,就是调用父类构造函数(Father.bind(this))

但是和寄生组合式继承有一些区别:

  • 寄生组合式继承中,Son.__proto__ === Function.prototype,而class继承中,Son.__proto__ === Father,这也是为什么Son继承了getStatic静态方法的原因。

  • class继承中,在通过super调用父类构造函数之前 不能再构造函数中使用this。为了确保父类先于子类得到初始化。es5中借用构造函数继承,是先创建子类实例的this,在把父类属性方法加到this上。

总结

本文介绍了es5常见的几种继承方式,以及他们的优缺点。并从继承和原型方面分析了new操作符具体做了哪些事情。最后介绍了es6中的class继承方式、和es5继承的区别。

如果文章有什么错误或者大家有什么疑问,欢迎在评论区指出、留言。我们一起学习,一起进步!

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改