📖序言
在前端的面试中,继承是一道很常考的题目,面试官总会变着法来问你。比如说,你知道哪几种继承方式?这几种继承方式有什么区别?这几种继承方式的优缺点是啥?又各有什么特点呢?
一招致命,就像周一第一次遇到这道题的时候,只记得有那么几种继承方式,但是我也说不上个所以然,知识还是太浮动于表面了。
因此,写下这篇文章,来总结各种继承方式的知识点。一起来学习叭~✨
📝一、基础知识预备
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
的实例,并将其作为子类的原型,也就是 SubType.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, name); // 实例属性 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()); // 2 } } let b = new B(); 复制代码
在上面的代码中,子类 B
中的 super.p()
就是将 super
当作一个对象来使用。依据上面所描述的, super
在普通方法中指向父类的原型对象,因此,这里的 super.p()
就等于 A.prototype.p()
。所以最终输出为 2
。
继续,由于 super
指向父类的原型对象,所以,定义在父类实例上的方法或属性时无法通过 super
进行调用的。比如:
class A { constructor() { this.p = 2; } } class B extends A { get m() { return super.p; // 定义在普通方法上,所以super指向父类的原型对象 } } let b = new B(); b.m // undefined 复制代码
在上述代码中, super
在普通方法中调用,所以也就意味着 super
指向父类的原型对象。而 b.m
却想要直接指向父类 A
实例的属性上,自然就是搜索不到它的。
我们可以对代码进行修改,将属性定义在父类的原型对象上,让 super
可以找到具体的属性。具体如下:
class A {} A.prototype.x = 2; class B extends A { constructor() { super(); console.log(super.x); // 2 } } let b = new B(); 复制代码
大家可以看到,现在属性 x
是定义在 A.prototype
上面的,所以 super.x
就可以顺利的取到它的值了。
2)在静态方法中
上面我们谈到了 super
作为对象时在普通方法中的调用,现在我们来看一下在静态方法中的的调用是什么样的。
首先,我们谈到的一个点就是, super
在静态方法中被调用时,指向的是其父类。我们来看一段代码:
class Parent { static myMethod(msg) { console.log('static', msg); } myMethod(msg) { console.log('instance', msg); } } class Child extends Parent { static myMethod(msg) { super.myMethod(msg); } myMethods(msg) { super.myMethods(msg); } } Child.myMethod(1); // static 1 let child = new Child(); child.myMethod(2); // instance 2 复制代码
大家可以看到,当直接使用 Child.myMethod(1)
来进行调用时,表明直接调用静态方法。而当 super
在静态方法中被调用时,指向的就是其父类中的静态方法,所以打印 static 1
。
继续,下面的 child
,通过 new
的方式实例化了一个对象,之后对实例进行调用。所以,此时调用的是普通方法,所以最终打印出 instance 1
。
在使用 super
的时候,必须要注意的一个点是,必须显式地指定是作为函数还是作为对象使用,否则就会出现报错的情况。比如:
class A {} class B extends A { constructor() { super(); console.log(super); // 报错 } } 复制代码
大家可以看到,如果像上面这样引用的话,根本无法看出是作为函数还是作为对象来引用,此时 js
引擎解析代码的时候,就会报错。
那么我们就得通过清晰的表明 super
的数据类型,来判断我们的结果。比如:
class A {} class B extends A { constructor() { super(); // object.valueOf() 表示返回对象的原始值 console.log(super.valueOf() instanceof B); // true } } 复制代码
上述代码中,我们通过 object.valueOf()
来指明 super
就是一个对象,之后 js
引擎识别到了,最后也就成功打印出来了。
4. 类的prototype属性和 __ proto __ 属性
(1)class的继承链
在大多数浏览器的 ES5
实现之中,每一个对象都有 __proto__
属性,指向对应的构造函数的 prototype
属性。
而 class
作为构造函数的语法糖,同时有 prototype
属性和 __proto__
属性,因此同时存在两条继承链。分别是:
- 子类的
__proto__
属性表示构造函数的继承,总是指向父类。 - 子类
prototype
属性的__proto__
属性表示方法的继承,总是指向父类的prototype
属性。
我们用一段代码来演示一下:
class A { } class B extends A { } B.__proto__ === A // true B.prototype.__proto__ === A.prototype // true 复制代码
在上面的代码中,子类 B
的 __proto__
属性总是指向父类 A
,且子类 B
的 prototype
属性的 __proto__
属性指向父类 A
的 prototype
属性。
这两条原型链可以理解为:
- 当作为一个对象时,子类
B
的原型(__proto__
属性 )是父类A
; - 当作为一个构造函数时,子类
B
的原型(prototype
属性 )是父类的实例。
(2)特殊情况继承
下面讨论三种特殊的继承情况。具体如下:
第一种情况: 子类继承 Object
类。先来看一段代码:
class A extends Object { } A.__proto__ === Object // true A.prototype.__proto__ === Object.prototype // true 复制代码
在这种情况下, A
其实就是构造函数 Object
的复制, A
的实例就是 Object
的实例。
第二种情况: 不存在任何继承。先来看一段代码:
class A { } A.__proto__ === Function.prototype // true B.prototype.__proto__ === Object.prototype // true 复制代码
在以上这种情况下, A
作为一个基类,它不存在任何继承关系。且 class
是构造函数的语法糖,所以这个时候可以说 A
就是一个普通函数。所以, A
直接继承 Function.prototype
。
值得注意的是, A
调用后返回的是一个空对象(即 Object
实例),所以 A.prototype.__proto__
指向构造函数 Object
的 prototype
属性。
第三种情况: 子类继承 null
。先来看一段代码:
class A extends null { } A.__proto__ === Function.prototype // true A.prototype.__proto__ === undefined // true 复制代码
以上这种情况与第二种情况非常相似。 A
也是一个普通函数,所以直接继承 Function.prototype
。
值得注意的是, A
调用后返回的对象不继承任何方法,所以它的 __proto__
指向 Function.prototype
,即实际上是执行了下面这段代码:
class C extends null { constructor() { return Object.create(null); } } 复制代码
(3)实例的 __ proto __ 属性
对于子类来说,其实例的 __proto__
属性的 __proto__
属性总是指向父类实例的 __proto__
属性。也就是说,子类的原型的原型是父类的原型。
我们来看一段代码,具体如下:
let p1 = new Point(2, 3); let p2 = new ColorPoint(2, 3, 'green'); p2.__proto__ === p1.__proto__ // false p2.__proto__.__proto__ === p1.__proto__ // true 复制代码
在上面的代码中, 子类 ColorPoint
继承了 Point
,所以导致了前者原型的原型是后者的原型。
因此,我们可以通过子类实例的 __proto__.__proto__
属性来修改父类实例的行为。具体如下:
p2.__proto__.__proto__.printName = function() { console.log('Monday'); }; p1.printName(); // 'Monday' 复制代码
大家可以看到,通过在 ColorPoint
的实例 p2
上向 Point
类中添加方法,从而影响到了 Point
实例 p1
。
📔四、温故而知新
最后,我们再用一张思维导图来回顾本文的全部内容。详情见下图👇
📑五、结束语
上文我们讲到了6大继承方式以及 class
的继承,在现如今的开发场景中,基本上也是寄生式组合继承和 class
的继承用的比较多。相信通过上文的了解,大家对 javascript
的继承又有了一个新的认识。
到这里,关于 js
的继承讲解就结束啦!希望对大家有帮助!