带你理解【JavaScript】中的继承机制

技术分享4年前 (2020)更新 技术分享
1,846 0 0

前文

总所周知,继承是所有OO语言中都拥有的一个共性。在JavaScript中,它的继承机制与其他OO语言有着很大的不同,尽管ES6为我们提供了像面向对象继承一样的语法糖,但是其底层依然是构造函数,所以理解继承的底层原理非常重要,所以今天让我们来探讨一下JavaScript中的继承机制。

原型与原型链

要理解继承,必须理解JavaScript中的原型与原型链,我在之前的上一篇文章对原型进行了深入的探讨,有兴趣的小伙伴可以看看~

《理解原型与原型链》

继承

在JavaScript中,有六种主要常见的继承方式,下面我会对每一种继承方式进行分析并总结它们的优缺点

1.原型链继承

原型链继承的概念

在JavaScript中,实现继承主要是依靠原型链来实现的。其基本思想是是利用原型让一个引用类型继承另一个引用类型的属性和方法。

让我们简单回顾一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象prototype,原型对象都包含一个指向构造函数的指针constructor,而实例都包含一个指向原型对象的内部指针__proto__

假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?让我们来看下面这段代码。

function Father() {
  this.name = 'zhang';
}

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

function Son() {
  this.age = 18;
}

// 继承了Father
Son.prototype = new Father();
Son.prototype.sayAge = function() {
  console.log(this.age);
}

const xiaoming = new Son();
console.log(xiaoming.sayName()) // 'zhang'

以上代码,Son继承了Father,而继承是通过创建Father的实例,并将Son.prototype指向new出来的Father实例。实现的本质是重写了原型对象,待之是一个新类型的实例,也就是说,原来存在于Father构造函数中的所有属性和方法,现在也存在于Son.prototype中。

通过上图可知,我们没有使用Son默认提供的原型,而是给它换了一个新原型,这个原型就是Father的实例,其内部还有一个指针,指向Father的原型。由于Son的原型被重写了,所以xiaoming这个实例的constructor属性现在指向的是Father。一句话总结就是Son继承了Father,而Father继承Object,当调用xiaoming.toString()方法时,实际上是调用Object.prototype中的toString方法。

注意:给子类原型添加方法的代码一定要放到替换原型的语句之后

带你理解【JavaScript】中的继承机制

还有一点需要提醒各位小伙伴们,在使用原型链继承时,千万不能使用对象字面量创建原型方法,因为这样做会重写原型链,来看下面这段代码。

function Father() {
  this.name = 'zhang';
}

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

function Son() {
  this.age = 18;
}

// 继承了Father
Son.prototype = new Father();
Son.prototype = {
  sayAge: function() {
    console.log(this.age)
  }
}

const xiaoming = new Son();
console.log(xiaoming.sayName()) // '报错'

使用对象字面量创建原型方法,会切断FatherSon之间的继承关系哦~

原型链继承的优点

子类型的实例对象拥有超类型的全部属性和方法。

原型链继承的缺点

我在上面的那篇文章提到过,包含引用类型值的原型属性会被所有实例共享。在通过原型实现继承时,原型实际上会变成另一个类型的实例原先的实例属性也就顺理成章地变成了现在的原型属性了。

function Father() {
  this.cars = ['奔驰', '宝马', '兰博基尼'];
}

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

function Son() {
  this.age = 18;
}

// 继承了Father
Son.prototype = new Father();

const xiaoming = new Son();
xiaoming.cars.push('五菱宏光');
console.log(xiaoming.cars); //'奔驰, 宝马, 兰博基尼, 五菱宏光'

const xiaohong = new Son();
console.log(xiaohong.cars); //'奔驰, 宝马, 兰博基尼, 五菱宏光'

可以从上述代码中发现,当Father中的属性是引用类型的时候,当然Father的每个实例都会有各自的数组cars属性。当Son继承Father之后,Son.prototype就变成了Father的一个实例,结果就是xiaomingxiaohong两个实例对象共享一个cars属性,这是在继承中我们不希望出现的。

第二个问题是创建Son的实例时,不能向Father的构造函数中传递参数,也就是说,没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。

接下来我要将的第二种继承方式是构造函数继承,它可以解决包含引用类型值所带来的问题。

2.构造函数继承

构造函数继承的概念

实现构造函数继承的基本思想相当简单,即在子类型构造函数的内部调用超类型构造函数。

让我们来看下面这段代码:

function Father() {
  this.cars = ['奔驰', '宝马', '兰博基尼'];
}

function Son() {
  // 继承Father
  Father.call(this);
}

const xiaoming = new Son();
xiaoming.cars.push('五菱宏光');
console.log(xiaoming.cars); //'奔驰, 宝马, 兰博基尼, 五菱宏光'

const xiaohong = new Son();
console.log(xiaohong.cars); //'奔驰, 宝马, 兰博基尼'

通过使用call()方法(或apply()方法),在创建xiaoming实例的同时,调用了Father构造函数,这样一来,就会在Son的实例对象上执行Father构造函数所定义的所有对象初始化代码,因此xiaomingxiaohong就具有属于自己的cars属性了。

构造函数继承还有一个优点是可以给超类型构造函数传参,让我们来看下面这段代码。

function Father(name) {
  this.name = name;
}

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

const xiaoming = new Son('小明', 19);
console.log(xiaoming.name); //'小明'
console.log(xiaoming.age); //19

我们创建了xiaoming实例并传递两个参数nameagename参数通过调用Father构造函数传递参数给了Father构造函数中的name,因此xiaoming实例拥有nameage两个实例属性。

构造函数继承的优点

可以在子类型构造函数中向超类型构造函数传参;子类型构造函数创建的对象都拥有各自的属性和方法(引用类型)

构造函数继承的缺点

很明显,方法都在构造函数中定义的话,函数复用就无从谈起了,因此构造函数继承很少单独使用。接下来介绍的这种继承方式,通过原型链构造函数结合实现的继承,叫做组合继承

3.组合继承

组合继承的概念

组合继承的基本思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。

使用组合继承的优点是即通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性,来看下面这段代码。

function Father(name) {
  this.name = name;
  this.cars = ['奔驰', '宝马', '兰博基尼'];
}

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

function Son(name, age) {
  // 继承属性
  Father.call(this, name); //第二次调用Father()
  this.age = age;
}

// 继承方法
Son.prototype = new Father(); //第一次调用Father()
Son.prototype.constructor = Son;
Son.prototype.sayAge = function() {
  console.log(this.age);
}

const xiaoming = new Son('xiaoming', 18);
xiaoming.cars.push('五菱宏光');
console.log(xiaoming.cars); //'奔驰, 宝马, 兰博基尼, 五菱宏光'
xiaoming.sayName(); //'xiaoming'
xiaoming.sayAge(); //18

const xiaohong = new Son('xiaohong', 20);
console.log(xiaohong.cars); //'奔驰, 宝马, 兰博基尼'
xiaohong.sayName(); //'xiaohong'
xiaohong.sayAge(); //20

console.log(xiaoming instanceof Son) //true
console.log(xiaoming instanceof Father) //true
console.log(xiaoming instanceof Object) //true

组合继承的优点

组合继承避免了原型链继承构造函数继承的缺陷,融合它们的优点,成为JavaScript中最常用的继承模式。

组合继承的缺点

组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数。一次是在创建子类型原型的时候,另一次是在子类型构造函数内部

4.原型式继承

原型式继承的概念

原型式继承的就是借助原型可以基于已有的对象创建新对象

我们来看下面这段代码。

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

const person = {
  name: 'zhangsan',
  cars: ['奔驰', '宝马', '兰博基尼']
}

const anotherPerson = object(person);
anotherPerson.name = 'lisi';
anotherPerson.cars.push('五菱宏光');
console.log(anotherPerson.name); //'lisi'
console.log(anotherPerson.cars); //'奔驰, 宝马, 兰博基尼, 五菱宏光'

const yetAnotherPerson = object(person);
yetAnotherPerson.name = 'wangwu';
console.log(yetAnotherPerson.name); //'wangwu'
console.log(yetAnotherPerson.cars); //'奔驰, 宝马, 兰博基尼, 五菱宏光'

object()实际上是对对象的一次浅复制,实现原型式继承的前提是要求你必须有一个对象可以作为另一个对象的基础。

ES5新增了Object.create()方法,这个方法规范化了原型式继承。这个方法我在这里不多介绍,感兴趣的小伙伴可以参考MDN的说明文档Object.create()

原型式继承优点

如果只想让一个对象与另外一个对象保持类似的情况下,原型式继承可以完全胜任。

原型式继承缺点

原型式继承的缺点相信各位小伙伴们已经看出来了,包含引用类型值的属性始终都会共享相应的值,就像使用原型链继承一样。

5.寄生式继承

寄生式继承的概念

寄生式(parasitic)继承是与原型式继承紧密相关的一种思路,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再返回对象。

废话不多说,让我们来看下面这段代码。

function createAnother(original) {
  const clone = Object.create(original);
  clone.sayHi = function() {
    console.log('hi');
  }
  return clone;
}

const person = {
  name: 'zhangsan',
  cars: ['奔驰', '宝马', '兰博基尼']
}

const anotherPerson = createAnother(person);
anotherPerson.sayHi(); //'hi'

const yetAnotherPerson = createAnother(person);
yetAnotherPerson.sayHi(); //'hi'
console.log(anotherPerson.sayHi == yetAnotherPerson.sayHi) //false

这个例子中,封装了一个createAnother的函数,这个函数接收一个参数,也就是将要作为新对象的基础对象,我们可以看到,anotherPersonyetAnotherPerson两个对象拥有各自的sayHi方法。

在主要考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。

寄生式继承优点

继承的对象都拥有各自的属性和方法(引用类型)。

寄生式继承缺点

使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率,这一点与构造函数继承模式类似。

6.寄生组合式继承

寄生组合继承的概念

所谓寄生组合式继承,就是通过构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需的无非就是超类型原型的一个副本而已。

本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。让我们来看下面这段代码。

function inheritPrototype(Son, Father) {
  const prototype = Object.create(Father.prototype);
  prototype.constructor = Son;
  Son.prototype = prototype;
}

function Father(name) {
  this.name = name;
  this.cars = ['奔驰', '宝马', '兰博基尼'];
}

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

function Son(name, age) {
  Father.call(this, name); //调用Father
  this.age = age;
}

inheritPrototype(Son, Father);

Son.prototype.sayAge = function() {
  console.log(this.age);
}

这个例子的高效率体现在它只调用了一次Father构造函数,并且因此避免在Son.prototype上面创建不必要、多余的属性。

寄生组合式继承优点

寄生组合式继承只调用了一次超类型构造函数,是被开发人员普遍认为是引用类型最理想的继承范式。

寄生组合式继承无缺点

总结

前端的学习之路还有很长很长,这篇文章只不过是冰山一角,希望前端cc写的这篇文章能给小伙伴们带来新的知识拓展,愿前端cc与各位前端小伙伴们在前端生涯中一起共同成长,冲鸭!

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...