一文梳理JavaScript中常见的七大继承方案
Posted 星期一研究室
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文梳理JavaScript中常见的七大继承方案相关的知识,希望对你有一定的参考价值。
📖序言
在前端的面试中,继承是一道很常考的题目,面试官总会变着法来问你。比如说,你知道哪几种继承方式?这几种继承方式有什么区别?这几种继承方式的优缺点是啥?又各有什么特点呢?
一招致命,就像周一第一次遇到这道题的时候,只记得有那么几种继承方式,但是我也说不上个所以然,知识还是太浮动于表面了。
因此,写下这篇文章,来总结各种继承方式的知识点。一起来学习叭~✨
📔文章内容抢先看
在真正进入本文的讲解之前,我们先用一张思维导图来了解本文所涉及到的内容。详情见下图👇
下面开始进入本文的讲解~
📝一、基础知识预备
1. 继承的定义
引用 Javascript
高级语言程序设计中的一句话:
继承是面向对象编程 (即 object-oriented language
,下面简称OO语言) 中讨论的最多的一个话题。很多 OO语言
都支持两种继承:接口继承和实现继承。前者只能继承方法签名,而后者可以继承实际的方法。
然后,接口继承在 ECMAScript
中是不太可能存在的,原因在于函数没有签名。
因此呢,实现继承是 ECMAScript
唯一支持的继承方式,且这主要通过原型链来实现。
2. 继承的方式
了解完定义,我们来看一下,继承有多少种方法。详情见下图👇
📚二、6大常见继承方式
1. 原型链继承 💡
(1)构造函数、原型和实例的关系
对于构造函数、原型和实例三者之间,有以下关系:每个构造函数都有一个原型对象,原型上有一个属性指回构造函数,而实例又有一个内部指针指向原型。
(2)基本思想
原型链继承的基本思想是通过原型来继承多个引用类型的属性和方法,其核心在于将父类的实例作为子类的原型。
(3)实现原型链继承
上面的内容可能看起来有点抽象,我们先用一个例子来体验一下原型链继承。具体代码如下:
// 父类函数
function SuperType() {
// 父类定义属性
this.property = true;
}
// 父类定义方法
SuperType.prototype.getSuperValue = function() {
return this.property;
}
// 子类函数
function SubType() {
this.subproperty = false;
}
/**
* 关键点:
* 通过创建父类SuperType的实例,
* 并将该实例赋值给子类SubType.prototype
*/
SubType.prototype = new SuperType();
// 子类定义新方法
SubType.prototype.getSubValue = function() {
return this.subproperty;
}
// 创建一个子类的实例
let instance = new SubType();
// 子类调用父类的方法
console.log(instance.getSuperValue()); // true
(4)图例阐述
依据以上代码,我们来用一张图表示这段代码的继承关系。如下图所示:
大家定位到图片的最右上角区域,同时我们也将由右上到左下进行分析。
首先,我们会创建构造函数 SuperType
的实例,并将其作为子类的原型,也就是 SuperType.prototype
。之后,实例创建完了,该实例上有一个内容指针指向父类的原型 SuperType.prototype
。完成之后,来到了第三步。该原型上有一个属性将指回构造函数 SuperType
。第四步,对于构造函数来说,每个构造函数又有它自己的原型,所以它又会指回 SuperType.prototype
。
依据上面这段描述,大家再看下(1)和(2)的关系和基本思想,是不是就清晰许多了呢。
同样地,下面的⑤~⑧步也是和上面一样的步骤,大家可以自行再理解对应,这里不再细述。
(5)破坏原型链
还有一个要理解的重点是,以对象字面量的方式去创建原型方法回破坏之前的原型链,因为这相当于重写了原型链。如下例子所示:
function SuperType() {
this.property = true;
}
SuperType.prototype.getSuperValue = function() {
return this.property;
};
function SubType() {
this.subproperty = false;
}
// 继承SuperType
SubType.prototpe = new SuperType();
// 通过对象字面量添加新方法,这将导致上一行无效
SubType.prototype = {
getSubValue() {
return this.subproperty;
},
someOtherMoethod(){
return false;
}
}
let instance = new SubType();
console.log(instance.getSuperValue());
// TypeError: instance.getSuperValue is not a function
在上面这段代码中,子类 SubType
的原型在被赋值为 SuperType
的实例后,又被一个对象字面量覆盖了。而覆盖后的原型其实已经变成了一个 Object
的实例,而不再是 SuperType
的实例,因此在赋值为对象字面量之后,原型链就被断掉了。这个时候 SubType
和 SuperType
也就不再有任何关系。
(6)优缺点
1)优点:
- 父类的方法可以复用。
2)缺点:
- 父类的所有引用类型数据将会被所有子类共享,也就是说,一旦有一个子类的引用类型数据更改了,那么其他子类也会受到影响。
- 子类型实例不能给父类型构造函数传参。原因在于,一旦传参的话,由于第一个问题的因素,会导致当传相同的参数时会覆盖之前的。
2. 盗用构造函数继承💡
(1)基本思想
对于以上原型链继承存在的两个问题,所以导致原型链基本不会被单独使用。因此,为了解决原型包含引用值导致的问题,我们引入一种新的继承方式,为 “盗用构造函数” 。这种技术也被称为对象伪装或者经典继承。
其基本思路为: 使用父类的构造函数来增强子类实例,等同于赋值父类的实例给子类(不使用原型)。
(2)使用方式
函数是在特定上下文中执行代码的简单对象,因此,可以使用 apply()
和 call()
方法,以新创建的对象为上下文来执行构造函数。
(3)实现原型链继承
我们用一个例子来实现原型链继承。具体代码如下:
function SuperType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
function SubType(name, age) {
// 继承SuperType并传参,这里是核心
SuperType.call(this, 'monday');
// 实例属性
this.age = age;
}
let instance1 = new SubType("monday", 18);
instance1.colors.push("gray");
console.log(instance1.name); // monday
console.log(instance1.age); // 18
console.log(instance1.colors); // [ 'red', 'blue', 'green', 'gray' ]
let instance2 = new SubType();
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]
大家可以看到,在上面的这个例子中, 通过使用 call()
方法,借用了 SuperType
构造函数,这样,在创建 SubType
的实例时,都会将 SuperType
中的属性复制一份出来。
同时,相对于原型链来说,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传递参数。大家可以看到上面代码,我们在使用 SuperType.call()
时,就可以将参数直接传递给父类 SuperType
。
(4)优缺点
1)优点:
- 子类的构造函数可以向父类的构造函数传递参数。
2)缺点:
- 只能继承父类的实例属性和方法,不能继承父类的原型属性和方法。
- 无法实现复用,每新创建一个子类,都会产生父类实例函数的副本,非常影响性能。
3. 组合继承💡
(1)基本思想
由于原型链和盗用构造函数继承的方式都存在一定的缺陷,所以它们俩基本上不能单独使用。为此呢,我们引入了一种新的继承方式,组合继承。组合继承也叫做伪经典继承,它综合了原型链和盗用构造函数,将两者的优点结合了起来。
其实现的基本思路为:使用原型链来继承原型上的属性和方法,然后呢,通过盗用构造函数来继承实例上的属性。
这样,通过在原型上定义方法实现了函数的复用,又能保证每个实例都有它自己的属性。
(2)实现组合继承
下面我们用一个例子来实现组合继承。具体代码如下:
function SuperType(name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
}
function SubType(name, age) {
// 继承属性→借用构造函数继承实例上的属性
SuperType.call(this, name);
this.age = age;
}
// 继承方法→通过原型的方式继承原型的属性和方法
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
console.log(this.age);
}
let instance1 = new SubType('Monday', 18);
instance1.colors.push('gray');
console.log(instance1.colors); // [ 'red', 'blue', 'green', 'gray' ]
instance1.sayName(); // Monday
instance1.sayAge(); // 18
let instance2 = new SubType('Tuesday', 24);
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]
instance2.sayName(); // Tuesday
instance2.sayAge(); // 24
大家可以看到,在以上的代码中,通过盗用构造函数,继承了父类 SuperType
实例上的属性。同时,通过原型的方式,也成功继承了父类 SuperType
原型上的属性和方法。
(3)图例
我们来用一张图展示一下上述的结果。具体如下:
(4)优缺点
1)优点:
- 避开原型链和盗用构造函数继承的缺陷,实现对实例和原型的继承。
- 组合继承是
javascript
常用的一种继承方式,且保留了instanceof
操作符和isPrototypeOf()
方法识别合成对象的能力。
2)缺点:
- 父类中的实例属性和方法,既存在于子类的实例中,又存在于子类的原型中,这将会占用更多的内存。
- 所以,在使用子类创建实例对象时,其原型中会存在两份相同的属性和方法。
4. 原型式继承💡
(1)基本思想
原型式继承实现的基本思路是,将某个对象直接赋值给构造函数的原型。如下代码所示:
function object(obj) {
function F(){}
F.prototype = obj;
return new F();
}
以上这段代码是 Douglas Crockford
在2006年写的一篇文章中所谈及。这篇文章介绍了一种不涉及严格意义上构造函数的继承方法,且他的出发点是即使不自定义类型,也可以通过原型来实现对象之间的信息共享。
在上述代码中, object()
对传入其中的对象执行了一次,并将 F
的原型直接指向传入的对象。
(2)实现原型式继承
下面我们用一个例子来实现原型式继承。具体代码如下:
function object(obj){
function F(){}
F.prototype = obj;
return new F();
}
let person = {
name: 'Monday',
friends: ['January', 'February', 'March']
};
let otherPerson = object(person);
otherPerson.name = 'Friday';
otherPerson.friends.push('April');
let anotherPerson = object(person);
anotherPerson.name = 'Sunday';
anotherPerson.friends.push('May');
console.log(person.friends); // [ 'January', 'February', 'March', 'April', 'May' ]
大家可以看到,最终所有的 friend
都拷贝到了 person
对象上。但是这种方法其实用的比较少,有点类似于原型链继承的模式,所以一般也不会单独使用。
(3)优缺点
1)优点:
- 适用于不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。
2)缺点:
- 当继承多个实例的引用类型数据时,指向是相同的,所以存在篡改的可能。
- 无法传递参数。
ES5
中已经存在Object.create()
的方法,能够代替上面的object
方法。
5. 寄生式继承💡
(1)基本思想
寄生式继承的基本思想是,在上面原型式继承的基础上,以某种方式来增强对象,并返回这个对象。
(2)实现寄生式继承
下面我们用一个例子来实现寄生式继承,具体代码如下:
// object函数
function object(obj){
function F(){}
F.prototype = obj;
return new F();
}
// 函数的主要作用是为构造函数新增属性和方法,以增强函数
function createAnother(original) {
// 通过调用函数来创建一个新对象
let clone = object(original);
// 以某种方式增强这个对象
clone.sayHello = function() {
console.log('hello');
}
// 返回对象
return clone;
}
let person = {
name: 'Monday',
friends: ['January', 'February', 'March']
};
let anotherPerson = createAnother(person);
anotherPerson.sayHello(); // hello
大家可以看到,通过创建一个新的构造函数 createAnother
,来增强对象的内容。并在之后对这个对象进行返回,供给我们使用。
(3)优缺点
1)优点:
- 适合只关注对象本身,而不在乎数据类型和构造函数的场景。
2)缺点:
- 给对象添加函数会导致函数难以重用,与上面第2中的盗用构造函数继承类似。
- 当继承多个实例的引用类型数据时,指向是相同的,所以存在篡改的可能。
- 无法传递参数。
6. 寄生式组合继承💡
(1)基本思想
寄生式组合继承的基本思想是,结合盗用构造函数和寄生式模式来实现继承。
(2)实现寄生式组合继承
我们用一个例子,来展示寄生式组合继承。具体代码如下:
function inheritPrototype(subType, superType) {
// 创建对象
let prototype = Object.create(superType.prototype);
// 增强对象
prototype.constructor = subType;
// 指定对象
subType.prototype = prototype;
}
// 父类对实例属性和原型属性进行初始化
function SuperType(name) {
this.name = name;
this.friends = ['January', 'February', 'March'];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
}
// 借用构造函数对子类实例属性进行传参(支持传参和避免篡改)
function SubType(name, age) {
SuperType.call(this, name);
this.age = age;
}
// 运用寄生式继承的特点,将父类的原型指向子类
inheritPrototype(SubType, SuperType);
// 新增子类的原型属性
SubType.prototype.sayAge = function() {
console.log(this.age);
}
let instance1 = new SubType('Ida', 18);
let instance2 = new SubType('Hunter', 23);
instance1.friends.push('April');
console.log(instance1.friends); // [ 'January', 'February', 'March', 'April' ]
instance1.friends.push('May');
console.log(instance2.friends); // [ 'January', 'February', 'March' ]
大家可以看到,对于寄生式组合继承来说,它借用了构造函数来对子类的实例属性进行传参,同时通过运用寄生式继承的特点,使用 inheritPrototype
构造函数来将父类的原型指向子类,这样,子类就继承了父类的原型 。
同时,子类还可以在自己的原型上新增自己想要的原型属性,达到继承自己的原型方法的效果。
(3)图例
我们再来用一张图展示一下上述的结果。具体如下:
(4)优缺点
1)优点:
- 寄生式组合继承可以算是引用类型继承的最佳方式。
- 几乎避免了上述所有继承方式中所存在的缺陷,也是执行效率最高且应用面最广的。
2)缺点:
- 实现的过程相对较繁琐,要捋好父子之间的关系,避免跳入原型的漩涡中。这其实也不算啥缺点,因为它值得!
🗞️三、Class的继承
1. 基本概念
在上述中我们讲到了各种类型的继承方式,但这似乎都逃不开原型链的闭圈中。到了 2015
年,ES6
的出现解决了这个问题。 ES6
的 class
可以通过 extends
关键字来实现继承,这间接地,比 ES5
通过修改原型链实现继承的方式更加清晰和方便了许多。
我们用一个例子来先看一下 class
是如何实现继承的。具体代码如下:
class Point{
constructor(x, y) {
this.x = x;
this.y = y;
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
// 调用父类的 constructor(x, y)
// 只有super方法才能返回父类的实例
super(x, y);
this.color = color;
}
toString() {
return this.color + ' ' + super.toString() {
// 调用父类的toString()
}
}
}
大家可以看到,通过 extends
关键字,实现了对父类的属性和方法进行继承,这似乎看起来就比上面的寄生式组合继承要方便的多了。
接下来,我们继续看其他用法。
2. Object.getPrototypeOf()
在 ES6
中, Object.getPrototypeof
方法可以用来从子类上获取父类。比如:
Object.getPrototypeof(ColorPoint) === Point
// true
所以,可以使用这一方法来判断一个类是否继承了另外一个类。
3. super关键字
上面我们有看到 super
这个关键字,它主要是用来返回父类的实例。那在实际的应用中, super
可以当作函数使用,也可以当作对象使用。接下来我们来谈谈这两种情况。
(1)作为函数使用
第一种情况,当 super
作为函数调用时,代表父类的构造函数。 ES6
要求,子类的构造函数必须执行一次 super
函数。如下例子:
class A {}
class B extends A {
constructor() {
super();
}
}
上述代码中的 super()
代表调用父类 A
的构造函数,但是返回的是子类 B
的实例,即 super
内部的 this
指的是 B
。因此,这里的 super()
相当于 A.prototype.constructor.call(this)
。
值得注意的是, 作为函数时, super()
只能用在子类的构造函数之中,用在其他地方会报错。比如:
class A {}
class B extends A {
m() {
super(); // 报错
}
}
大家可以看到,像上面这种情况, super()
用在 B
类的 m
方法之中就会造成句法错误。
(2)作为对象使用
第二种情况,当 super
作为对象时,在普通方法中指向父类的原型对象,在静态方法中指向父类。我们来一一分析这两种情况。
1)在普通方法中
先来看一段代码,具体如下:
class A {
p() {
return 2;
}
}
class B extends A {
constructor() {
super();
console.log(super.p()); 以上是关于一文梳理JavaScript中常见的七大继承方案的主要内容,如果未能解决你的问题,请参考以下文章
Spring boot 梳理 -@SpringBootApplication@EnableAutoConfiguration与(@EnableWebMVCWebMvcConfigurationSu(代