继承的7种方式

930 阅读9分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第8天,点击查看活动详情

为什么要实现继承?

基于面向对象一文的铺垫我们了解到:对于同一类型的对象数据,有着很大程度上相同的属性结构和方法的时候,我们每次单独给每个结构都定义时,第一会产生大量的操作和代码量,第二每次定义方法的时候都会在内存中开辟新的空间,造成内存的浪费。但是由于这些对象有着相同的属性和方法,什么就可以把相同部分封装成一个父类,继承改类型的相同属性和方法创造新的子类,并对子类进行自有属性和方法的扩展。

在面向对象语言中,大多支持两者继承方式:1、接口继承,只继承方法签名;2、实现继承:继承实际的方法。在js中函数是没有签名的,所以只支持实现继承,这种继承方式主要通过原型链实现的。

实现继承的几种方法:

image-20220211173758920.png

1. 原型链继承

ECMA将原型链定义为主要继承方式。其思想就是通过原型链继承多个引用类型的属性和方法。

将父类的实例作为子类的原型对象

// 1. 定义一个父类
function Person(name) {
    this.name = name;
    this.friends = ['康康']
    this.say = function() {
        console.log(this.friends)
    }
}
// 2. 在父类原型对象上定义方法
Person.prototype.eat = function(food) {
    console.log(`${this.name}吃了${food}`)
}
// 3. 定义子类
function Student() {}
// 4. 继承:将子类的原型对象改成父类的实例
Student.prototype = new Person()
Student.prototype.name = 'lisa'
var lisa = new Student()  // 创建子类实例无法向父类构造函数传参
lisa.say()       // 子类实例调用父类方法并取到父类值
lisa.eat('苹果')  // 可以调用父类方法
console.log(lisa)

但是如果有多个子类实例时会有问题:

var tom = new Student()
tom.friends.push('楠楠')
console.log(tom.friends, lisa.friends) // 结果一样:['康康', '楠楠']

明明我只改变了tom的friends属性,为什么lisa的也跟着变了呢?很简单,因为两个实例使用的是同一个原型对象。

附:lisa是 Student 类的子对象, Student 类的原型对象是Person类的实例子对象,其关系:

// 原型和实例的关系可以通过 instanceof 和 isPrototypeOf() 方法判断
lisa instanceof Student // true
lisa instanceof Person  // true:lisa是Student的子类也是Person的子类
Student.prototype.isPrototypeOf(tom)  // true
Person.prototype.isPrototypeOf(tom)  // true
lisa.__proto__ === Student.prototype  // true:实例的__proto__是构造函数的prototype
Student.prototype.__proto__ === Person.prototype  // true:Student.prototype是构造函数Person的实例
Person.prototype.__proto__ === Object.prototype
// 所以lisa的原型链就是:
lisa.__proto__.__proto__.__proto__ === Object.prototype

优缺点:

  1. 可以很好的继承父类的方法和属性
  2. 创建子类实例时,无法向父类构造函数传参。
  3. 多个实例会使用的是同个原型对象造成冲突。原型中包含的引用值会在所以的实例间共享。

2. 构造函数继承

这种继承方法又叫盗用构造函数对象伪装经典继承

其实现思路:在子类的构造函数中调用父类的构造函数。因为其函数是在特定的上下文中,所以可以使用call()apply()以新建的对象为上下文执行构造函数。

// 1.定义一个父类
function Person(name) {
    this.name = name;
    this.say = function() {
        console.log(this.name)
    }
}
// 2.定义一个子类,使用call继承父类公共属性和方法
function Student(name, age) {
    Person.call(this, name)
    this.age = age
    this.getAget = function() {
        return this.age;
    }
}
// 3.创建一个子类实例
var lisa = new Student('lisa', 10)
console.log(lisa)
lisa.say()  // lisa

此种继承方法也有问题:给该父类的原型对象上添加公共方法

Person.prototype.eat = function(food) {
    console.log(`${this.name}吃了${food}`)
}
lisa.eat('苹果')  // 报错

因为在子类中只是用了call把子类的this替换调了父类的this,所以在子类中有了父类的属性和方法,但是这些都跟父类的原型没有关系,所以子类的实例无法访问父类原型上的方法。

另外就是子类构造函数中的方法复用问题:

var lisa = new Student('lisa', 10)
var tom = new Student('tom', 12)
console.log(lisa.getAget() == tom.getAget())  // false

虽然每个实例中都有getAget这个方法,但是每次创建实例时调用构造函数会创建一个新的getAget函数。tom和lisa的getAget本质上是不同的函数,在内存中是存在于不同的地址,所以说是没有实现复用。

优缺点:

  1. 可以继承父类的属性和方法,且创建子类时可以传参。
  2. 子类构造函数中定义的方法不能复用。
  3. 不能使用父类原型上的方法。

3. 组合继承

组合继承有时候也叫伪经典继承,综合了原型链和构造函数两种方法。基本思路:使用原型链继承父类原型上的属性和方法,通过盗用构造函数继承实例属性。该继承的方式核心是在子类的构造函数中通过 Parent.call(this) 继承父类的属性,然后改变子类的原型为 new Parent() 来继承父类的函数。

// 1. 定义一个父类
function Person(name) {
    this.name = name;
    this.friends = ['康康']
    this.sayName = function() {
        console.log(this.name)
    }
}
// 2. 在父类原型对象上定义方法
Person.prototype.eat = function(food) {
    console.log(`${this.name}吃了${food}`)
}
// 3. 定义子类
function Student(name, age) {
  // 继承属性
  Person.call(this, name)
  this.age = age
}
// 继承方法
Student.prototype = new Person()
// 定义子类自己的方法
Student.prototype.getAge = function() {
  console.log(this.age)
}

var lisa = new Student('lisa', 12)
lisa.sayName()      // lisa
lisa.eat('苹果')  // lisa吃了苹果
lisa.friends.push('丽丽')
console.log(lisa.friends)  // ["康康", "丽丽"]

var tom = new Student('tom', 10)
tom.getAge()          // 10
tom.eat('香蕉')   // tom吃了香蕉
console.log(tom.friends)  // ["康康"]

组合继承结合了原型链和构造函数两种方法的优点,弥补了两者的不足,是JavaScript中最常用的继承方法。组合继承也保留了instanceofisPrototypeOf()方法识别合成对象的能力。

lisa instanceof Person   // true
Person.prototype.isPrototypeOf(tom)  // true

优缺点:

  1. 优点在于构造函数可以传参,不会与父类引用属性共享
  2. 可以复用父类的函数
  3. 效率问题:父类的构造函数要调用两次:一次是在子类原型通过new调用,一个在子类构造函数中通过call调用
  4. 继承父类函数的时候调用了父类构造函数,导致子类的原型上多了不需要的父类属性,存在内存上的浪费

4. 寄生组合继承

这种继承方式对组合继承进行了优化,组合继承缺点在于继承父类函数时调用了构造函数,我们只需要优化掉这点就行了。本质上,子类原型最终是要包含父类对象的实例属性,子类构造函数只要在执行是重写自己的原型就行了。

基本思路:不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。其实是使用寄生式继承来继承父类的原型。基本模式:

function inheritPrototype(subType, superType) {
  let prototype = Object(superType.prototype)  // 创建父类原型副本
  prototype.constructor = subType  // 增强prototype:父类原型的构造函数指向子类构造函数
  subType.prototype = prototype    // 子类原型指向父类原型
}

具体示例

function Person(name) {
  this.name = name;
  this.friends = ['康康']
}
Person.prototype.eat = function(food) {
  console.log(`${this.name}吃了${food}`)
}

function Student(value) {
  // 继承父类属性
  Person.call(this, value)
}
// 继承父类原型的属性和方法
Student.prototype = Object.create(Person.prototype, {
  constructor: {
    value: Student,
    enumerable: false,
    writable: true,
    configurable: true
  }
})
// 或直接使用上面封装的继承方法
// inheritPrototype(Student, Person)

const lisa = new Student('lisa')
lisa.eat('苹果') // lisa吃了苹果
lisa.friends.push('小明')  // ["康康"]
const tom = new Student('tom')
console.log(tom.friends)
console.log(lisa instanceof Person) // true

以上继承实现的核心就是 将父类的原型赋值给了子类,并且将构造函数设置为子类,这样既解决了无用的父类属性问题,还能正确的找到子类的构造函数。

特点:只调用一次父类的构造函数,避免了子类原型上得到不必要的属性,这样效率也更高。而且原型链保持不变,instanceofisPrototypeOf()方法判读依然有效。寄生式组合继承可以算是引用类型继承的最佳模式。

5. 原型式继承

这种方法不涉及严格意义上构造函数的继承方法。这中方式的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。

const Person = {
  name: 'Person',
  friends: ['康康'],
  say: function() {
    console.log(this.name)
  }
}

const Lisa = Object(Person)
Lisa.name = 'lisa'
Lisa.friends.push('丽丽')
Lisa.say()  // lisa

const Tom = Object(Person)
Tom.name = 'tom'
Tom.friends.push('小明')
console.log(Person.friends)  // ["康康", "丽丽", "小明"]

这种方式适用的场景:比如现在有一个对象,想要在这个对象的继承上创建一个新对象,可以使用Object创建,然后再对新建的对象进行修改。如上例中基于Person进行创建新对象LisaLisa的原型是person,lisa的原型上既有原始值属性name又有引用值属性friends,即friends这个属性是跟personlisatom共享的。这里实际上相当于克隆了两个Person

ECMAScript 5通过增加Object.create()方法将原型式继承的概念规范化了。我们也可以通过Object.create去克隆对象并进行额外的属性扩展

const Person = {
  name: 'Person',
  friends: ['康康'],
  say: function() {
    console.log(this.name)
  }
}

const Lisa = Object.create(Person, {
  name: {
      value: 'lisa'
  }
})
Lisa.say()  // lisa

优缺点:

  1. 原型式继承非常适合直接创建需要共享数据的对象,只需要关注对象不需要去定义构造函数
  2. 与原型链类似,该方法创建的多个对象中有引用类型会共享。

6. 寄生式继承

寄生式继承与原型式继承比较类似。寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。基本模式:

function createInstance(target) {
  let clone = Object(target)  // 通过调用一个函数得到一个新对象
  clone.sayAge = function() {
      console.log(this.age)
  }
    return clone;
}

以上方法就是将一个基准对象target创建出一个新对象,并在新对象上扩展一些新的属性和方法。

const Person = {
  name: 'Person',
  friends: ['康康'],
  say: function() {
    console.log(this.name)
  }
}
const Lisa = createInstance(Person)
Lisa.age = 10
Lisa.sayAge()  // 10

这种方法使得Lisa可以继承Person的属性和方法,还可以具有私有的属性和方法。

  1. 寄生式继承适用于只关注对象不在乎类型和构造函数的场景。
  2. 该方法给对象添加函数会导致函数难以重用,与构造函数模式类似。

【注】寄生式继承不一定非要通过Object创建新对象,任何可以返回新对象的函数都可以。应该关注的是其核心思想:基于一个原有对象创建出新对象并对其进行增强处理并返回它。例如寄生式组合继承中的Object.create的方法

7. class extends继承

class Person{
  constructor(){
  }
  // ...
}
class Student extends Person{
  constructor(){
    super();
  }
}
const Lisa = new Student()
console.log(Lisa instanceof Student)  // true
console.log(Lisa instanceof Person)   // true