js基础之六种继承方式

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/qq_41694291/article/details/97488644

概述

在JavaScript中,存在六种继承方式,分别是基本继承(即前文js基础之原型链提到的最原始的方式,暂未查到官方称谓)、借用构造函数、组合继承、原型式继承、寄生式继承和寄生式组合继承。其中组合继承是基本继承和借用构造函数的组合版本,也是最常用的继承;原型式继承是基本继承的“干净”版本(只继承原型);寄生式继承是原型式继承的工厂化版本;寄生组合式继承是组合继承融入原型式继承思想后的优化版本,也是目前认为最理想的继承方式。它们的关系如下图:
在这里插入图片描述
下面我们就依次来介绍这六种继承方式。

1. 基本继承

该继承方式的实现原理主要是原型链那篇文章中讲到的基于原型链的继承,简单回顾一下:

function Child(){}
function Parent(){}
Parent.prototype.run = function(){}
Child.prototype = new Parent();
Child.prototype.constructor = Child;

通过将Parent的一个实例直接作为子类Child的原型对象,所有Child的实例都拥有一个内部指针__proto__指向这个Parent实例。借助浏览器的原型链查找机制,当调用的某个方法或属性在Child实例中不存在时,执行引擎就会去Child的原型对象(即上述Parent实例)中查找,如果仍然找不到,就会继续向上,到Parent的原型对象中查找。由于在JavaScript中,所有的对象都继承自Object,因此该查找过程会一直持续到Object的原型对象,如果仍然没有找到,就会报属性或方法不存在。

前文也提到了该继承方式的两大弊端,一个是原型对象中所有引用类型的值都会被子类共享,如(承接上面的代码):

//父类原型对象上新增一个引用类型的数据
Parent.prototype.color = ['black', 'green'];
//构造两个子类对象
var child1 = new Child();
var child2 = new Child();
//通过child1向上述属性中添加一个元素
child1.color.push('white');

console.log(child2.color);  //['black', 'green', 'white']

通过上述代码可以看出,我们通过child1修改了Parent原型中的color,但是此时child2引用到的color值也发生了变化(除非真的要共享该属性,否则这种行为是无法接受的)。这其中的原因也很简单,我们通过上述方式实现的继承,并没有把父类的方法和属性复制到子类实例中,而是借助原型链的查找机制上溯查找到的,因此在内存中,color属性只在Parent的原型中存在一份,显然对它的操作会影响所有引用它的实例(如果修改的是基本数据类型的属性,则会在子类实例上创建该属性(因为基本数据类型的值是无法改变的,参考
V8引擎的内存管理分析
),因此不会影响其他实例)。

另一个弊端就是无法在构造子类的时候向父类的构造函数动态传值。比如上面的代码中,如果我们执行:

var child3 = new Child('夕山雨');

那么很显然,“夕山雨”这个参数将用于构造子类实例,是无法传递给Parent构造函数的,我们只能在实现继承的时候,向父类构造函数传入固定的参数,即:

Child.prototype = new Parent("夕山雨");

这样所有Child的实例的原型对象值都是完全一样的,不能满足我们需要动态向父类构造函数传值的实际需要。

由于存在上述两个弊端,这种继承方式几乎很少单独使用(但当父类只是定义了一些方法时,仍然可以使用该方式,因为我们不会去修改方法本身,也不需要在构造父类时对方法赋值)。基本继承的原理图可以在前文js基础之原型链中找到。

下面就来看第二种继承方式 - 借用构造函数,它以另外一种思路来解决上述两个问题。

2. 借用构造函数

顾名思义,借用构造函数就是去借用别人的构造函数来构造自己的实例对象。在JavaScript中,构造函数实际上是定义了一组构造对象的规则,我们使用该规则,根据传入的参数不同,批量地构造大同小异的实例对象。如:

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

我们现在定义的规则就是,如果你传给我name和age两个参数,我就在this对象上添加name和age属性来接收这两个参数(该过程就是在构造this对象)。调用这组规则有两种方式:

  1. 如果你使用new来调用该函数,那么浏览器就先创建一个空对象,让this指向这个空对象,再去构造这个空对象。
  2. 如果你以对象为调用者调用Parent,那么它不会自动生成空对象,而是以调用者作为this,去构造这个调用者。

对于第二种情况,当我们使用对象调用一个函数时,其内部的this指的是调用该函数的对象。如果该函数没有被任何对象显式地调用,那么在浏览器中,它就被认为是被全局对象window调用的。

//此时浏览器会先创建一个空对象,然后将函数内的this指向这个空对象,并构造它
var parent = new Parent("夕山雨", 24);
//现在我们在window上调用Parent,name和age属性将被添加到window上,
//于是变成了全局变量
Parent("夕山雨", 24);
window.age === 24;  //true

因此一个构造函数被用来构造哪个对象,取决于其内部的this指向哪个对象。而这正是借用构造函数的基本思路。比如我们写出下面的代码:

var parent = {};
Parent.call(parent, "夕山雨", 24);

我们手动创建了一个空对象,然后调用Parent函数,并使用call将函数的this手动绑定到该空对象上,这时构造函数Parent将会把构造规则应用在该空对象上,构造出一个与使用new完全一致的Parent实例。

那么我们想一下,既然构造函数可以用来构造空对象,为什么不能用来构造子类实例呢?比如:

function Child(name, age){
  Parent.call(this, name, age);
}
var child = new Child("夕山雨", 24);

神奇的情况出现了!我们“借用”了Parent构造函数作为构造规则,来构造Child实例(使用new调用Child时,内部的this就是一个Child实例)。现在我们在Parent构造函数中定义的所有属性和方法都会给当前Child实例重新定义一遍,从而实现了继承。

该方式刚好解决了第一种继承方式的两大弊端。其一,引用类型属性的共享。现在我们每个子类的属性都只是我们借用父类的构造函数新定义的,因此相互独立,不存在共享导致的冲突。其二,无法向父类构造函数动态传值。显然,我们现在向Child传入的参数又被传给了父类构造函数,因此该问题也得到了解决。

但这种继承方式的缺点却更为明显,父类的所有属性和方法都需要重新构造一遍。虽然这样对引用类型的属性来说,可以解决共享导致的冲突,却也导致方法无法被共享,每个子类都需要维护一个相同的方法,失去了继承的本质。因此单独使用该继承方式的频率就更低了。

现在让我们回顾以上两种继承方式,我们发现,基本继承成功实现了方法的共享,但却导致了不应被共享的引用属性的共享;而借用构造函数成功解决了引用属性共享的问题,却导致了方法无法被共享。

按照惯例,我们是不是该融合两者的优点,创造一种更好的继承方式了?

3. 组合继承

和它的名字一样直白,组合继承就是组合使用上述两种继承方式,来实现方法的共享和属性的独立构造。使用该继承方式时我们有一个通用习惯,就是把属性放在父类的构造函数中,而把方法放在父类的原型上。首先,我们仍然使用借用构造函数来进行属性的构造:

function Child(name, age){
  Parent.call(this, name, age);
}

现在每次调用该构造函数,都会先借用父类的构造函数来构造子类,创建独立的子类属性。然后,我们再借助基本继承实现方法的继承:

Child.prototype = new Parent();
//修正Child.constructor的指向
Child.constructor = Child;

现在我们就实现了组合继承。

由于我们把属性放在父类的构造函数中,借用构造函数会以父类构造函数为规则,为子类实例创建这些属性。然后通过将Child.prototype指向父类的对象,我们又借助原型链实现了对父类方法的继承。该继承方式完全解决了上述两种继承方式带来的问题,也是目前最为常用的一种继承方式。

当然了,该继承方式并非完美的,实际上该继承方式需要调用父类构造函数两次,如下:

function Child(name, age){
  //第二次父类构造函数,来构造子类对象(因为该函数是在执行new时才调用的,
  //因此调用次序靠后)
  Parent.call(this, name, age);
}
//第一次调用构造函数,目的是借助该父类实例继承其原型
Child.prototype = new Parent();  //我们将这个对象暂即为temp
//修正Child的constructor指向
Child.constructor = Child;

var child = new Child("夕山雨", 24);

作为子类原型对象的那个temp父类实例拥有父类所有的属性(以及可能在构造函数中定义的方法)。但是当我们借用父类构造函数构造子类后,子类实例上同样拥有所有的父类属性。根据原型链查找规则,子类自身的属性优先级高于原型上的同名属性,结果就是我们访问到的永远是子类的属性,原型上的属性被“屏蔽”了。既然这些属性根本访问不到,那为什么要浪费时间和内存去构造它呢?这就是组合继承的问题所在。为了解决这个问题,又衍生出下面的继承方式。

4. 原型式继承

看到这个名字,我们可能想到,基本继承不也是一种原型继承吗?没错,原型式继承的思想就来自于基本继承,但它是一种更“干净”的实现。它的“干净”之处在于,它只继承父类的原型,而不继承父类构造函数中的属性(及可能存在的方法)。看下面的例子:

function createObject( prototype ){
  //创建一个空构造函数
  function F(){};
  //将该构造函数的原型替换为传入的原型对象
  F.prototype = prototype;
  //创建并返回一个空对象,但该对象的__proto__指向传入的prototype
  return new F();
}
//child是个空对象,但可以借助原型链访问Parent的原型属性和方法
var child = createObject( Parent.prototype );

上面的方式同样实现了继承,但并不是完整的继承,而是只继承父类的原型对象,丢弃了父类构造函数中的所有属性和方法。该继承方式在ES5中得到了原生实现,即Object.create()方法,你可以传入一个原型对象进去,构造一个以该原型为原型的空对象,这样在某些轻量级的继承场景中是十分便捷的。如:

var child = Object.create( Parent.prototype );
child.name = "夕山雨";
child.age = 24;

不难发现,如果我们给Object.create传入一个父类的实例对象(即Object.create( new Parent() ) ),那么它就是基本继承(所以说它是基本继承更“干净”的版本)。

该继承方式显然有着自己的缺陷,但它却为解决组合继承的重复构造问题提供了思路。在介绍如何通过原型式继承来解决组合继承遇到的问题之前,我们再介绍另外一种继承方式 - 寄生式继承,它是原型式继承的工厂化版本。

5. 寄生式继承

在上面的原型式继承中,我们创建了一个空的子类对象之后,需要手动为其添加自有的属性和方法,如:

var child = Object.create( Parent.prototype );
child.name = "夕山雨";
child.age = 24;

上述三条语句都是用于构造子类对象的,但却是独立的,我们认为这样耦合性较差,封装程度不够,因此我们通常会将其封装为一个函数:

function createChild( Parent ){
  var child = Object.create( Parent.prototype );
  child.name = "夕山雨";
  child.age = 24;
}

var child = createChild( Parent );

当我们定义了上述函数之后,每次构造一个子类对象,只需要写下面的一行语句即可,代码看上去也优雅了很多,这就是所谓的寄生式继承。所以,寄生式继承就是原型式继承的一个工厂化(将一系列流程封装在一起,进行快速批量生产)版本。

显然寄生式继承并不是为了解决原型式继承的问题而存在的。接下来我们就来了解一种更加优雅的继承方式 - 寄生式组合继承。

6. 寄生组合式继承

该继承方式的主要思路就是,用原型式继承替换组合继承中的基本继承。

Child.prototype = new Parent();  //该父类实例记为temp

这里生成的中间父类对象temp是一个完整的父类对象,但是由于子类仍会调用一次父类的构造函数,生成同名的属性和方法。这样在访问这些属性和方法时,实际上都来自于子类自身,而temp中的则不会被访问(除非通过delete删除了子类的同名属性)。那么我们何不在这里通过原型式继承创建一个“干净”的对象作为子类的原型呢?代码如下:

Child.prototype = Object.create( Parent.prototype );

这里Object.create创建的是一个空对象,但是以Parent的原型为原型,我们将这个对象作为Child的prototype,就避免了在这里调用父类的构造函数,因此组合继承的性能问题也得到了解决。下面是完整的寄生组合式继承的实现过程:

function Parent(name, age){
  this.name = name;
  this.age = age;
}
Parent.prototype.run = function(){ ... };

function Child(name, age){
  //第二次父类构造函数,来构造子类对象(因为该函数是在执行new时才调用的,
  //因此调用次序靠后)
  Parent.call(this, name, age);
}
//将Child.prototype替换为一个继承了父类原型的空对象
Child.prototype = Object.create( Parent.prototype ); 
//修正Child的constructor指向
Child.constructor = Child;

var child = new Child("夕山雨", 24);

目前,寄生组合式继承被认为是JavaScript实现继承的一种比较理想的方式。

总结

介绍完所有的继承方式,我们可以重新梳理一下它们之前的关系。这六种继承方式中,基本继承和借用构造函数可以认为是最基础的版本,前者借助原型链实现方法共享,后者借用构造函数实现属性的独立构造,结合两者的优势衍生出组合继承。原型式继承是基本继承的“干净”版本,将其工厂化就是寄生式继承,而将它与借用构造函数结合就得到了理想的继承实现 - 寄生组合式继承。

现在我们来思考一下,为什么React中组件在继承时需要在子组件的构造函数中写super(props)?很简单,就是将借用构造函数的过程封装在super函数中,通过传入参数,以父类定义的构造规则去构造子类对象。而ES6的extends关键字,其实就是原型式继承的语法糖。如:

//React的继承
class Child extends Parent{
  constructor(name, age){
    super(name, age);
  }
} 
//寄生组合式继承
Child.prototype = Object.create( Parent.prototype );
function Child(name, age){
  Parent.call(name, age);
}

浏览器在遇到extends关键字时,会以原型式继承的方式为子类的prototype赋值,然后借用父类构造函数构造子类。因此这种继承方式虽然看上去与java的继承方式类似,但实现原理却完全不同。

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/97488644