💦【何不三连】做完这48道题彻底弄懂JS继承(1.7w字含辛整理-返璞归真)

27,695 阅读56分钟

JavaScript对象封装、多态、继承

前言

你盼世界,我盼望你无bug。Hello 大家好!我是霖呆呆!

怎么样?小伙伴们,上一章《封装篇(牛刀小试)》里的十几道题是不是做着不过瘾啊。

内心活动:就这点水平的东西?还号称魔鬼题

可以,小伙子(姑娘),很膨胀,我喜欢。哈哈哈哈。

既然这样的话,就来看看这系列的大头——继承

这篇文章的继承题可是有点东西的啊,基本覆盖了所有主流的继承情况,而且都比较细节,如果你原来只是浅浅的看了一些教材,跟着手写实现了一下而已的话,那你看完保证是会有收获的!那样的话还请给个三连哦 😊。

☑️点赞➕收藏➕关注

❌ 闪现➕大招➕引燃

老规矩,否则在评论区给我一个臭臭的👎。全文共有1.7w字,前前后后整理了快两个星期(整理真的很容易掉头发😂)。

所以还请你找个安静的地方,在一个合适的时间来细细品味它 😊。

OK👌,废话不多说,咱走着,卡加(韩语)~

JS继承系列介绍

通过阅读本篇文章你可以学习到:

  • 封装
    1. ES6之前的封装-构造函数
    2. ES6之后的封装-class
  • 继承(本篇)
    1. 原型链继承
    2. 构造继承
    3. 组合继承
    4. 寄生组合继承
    5. 原型式继承
    6. 寄生继承
    7. 混入式继承
    8. class中的extends继承
  • 多态

(在正式阅读本篇文章之前还请先查看封装篇,也就是目录的第一章节,之后观看舒适感更高哦 😁)

继承

好滴👌,还是让我们先来了解一下继承的概念哈。

继承 🤔️?

"嗯...我爸在深圳福田有一套房,以后要继承给我"

"啪!"

"我提莫的在想什么?我还有个弟弟,所以我爸得有两套"

"啪!"

"你提莫还在睡,该搬砖了!"

正经点的,其实一句话来说:

继承就是子类可以使用父类的所有功能,并且对这些功能进行扩展。

比如我有个构造函数A,然后又有个构造函数B,但是B想要使用A里的一些属性和方法,一种办法就是让我们自身化身为CV侠,复制粘贴一波。还有一种就是利用继承,我让B直接继承了A里的功能,这样我就能用它了。

今天要介绍的八种继承方式在目录中都已经列举出来了。

不着急,从浅到深咱一个个来看。

1. 原型链继承

将子类的原型对象指向父类的实例

1.1 题目一

(理解原型链继承的概念)

function Parent () {
  this.name = 'Parent'
  this.sex = 'boy'
}
Parent.prototype.getName = function () {
  console.log(this.name)
}
function Child () {
  this.name = 'child'
}
Child.prototype = new Parent()

var child1 = new Child()
child1.getName()
console.log(child1)

好了,快告诉我答案吧,会打印出什么 🤔️ ?

'child'
Child {name: "child"}

这...这很好理解呀

  • child1是通过子类构造函数Child生成的对象,那我就有属性name,并且属性值也是自己的child
  • 然后子类构造函数Child它的原型被指向了父类构造函数Parent创建出来的"无名实例"
  • 这样的话,我child1就可以使用你这个"无名实例"里的所有属性和方法了呀,因此child1.getName()有效。并且打印出child
  • 另外由于sex、getName都是Child原型对象上的属性,所以并不会表现在child1上。

这看着不就是之前都讲到过的内容嘛?

就像是题目1.61.7一样(《封装篇(牛刀小试)》里的)。

所以现在你知道了吧,这种方式就叫做原型链继承

将子类的原型对象指向父类的实例。

我们来写个伪代码,方便记忆:

Child.prototype = new Parent()

当然,更加严谨一点的做法其实还有一步:Child.prototype.constructor = Child,不过这边霖呆呆先卖个关子,到题目4.2中我们再来详细说它。

1.2 题目二

不知道你们在看到原型链继承这个词语的时候,第一时间想到的是什么?

有没有和我一样,想到的是把子类的原型对象指向父类的原型对象的😂:

Child.prototype = Parent.prototype

和我一样的举个手给我看下🙋‍♂️,😂

之后我就为我xx似的想法感到惭愧...

如果我只能拿到父类原型链上的属性和方法那也太废了吧,我可不止这样,我还想拿到父类构造函数上的属性。

所以这道题:

function Parent () {
  this.name = 'Parent'
  this.sex = 'boy'
}
Parent.prototype.getSex = function () {
  console.log(this.sex)
}
function Child () {
  this.name = 'child'
}
Child.prototype = Parent.prototype

var child1 = new Child()
child1.getSex()
console.log(child1)

结果为:

undefined
Child {name: "child"}

你可以结合上面👆的那张图,自个儿脑补一下,child1它的原型链现在长啥样了。

解析:

  • child1上能使用的属性和方法只有name、getSex,所以getSex打印出的会是undefined
  • 打印出的child1只有name属性,getSex为原型上的方法所以并不会表现出来。

这道题是个错误的做法啊 😂

我只是为了说明一下,为什么原型链继承是要用Child.prototype = new Parent()这种方式。

1.3 题目三

(理解原型链继承的优点和缺点)

这道题的结果大家能想到吗?

请注意对象是地址引用的哦。

function Parent (name) {
  this.name = name
  this.sex = 'boy'
  this.colors = ['white', 'black']
}
function Child () {
  this.feature = ['cute']
}
var parent = new Parent('parent')
Child.prototype = parent

var child1 = new Child('child1')
child1.sex = 'girl'
child1.colors.push('yellow')
child1.feature.push('sunshine')

var child2 = new Child('child2')

console.log(child1)
console.log(child2)

console.log(child1.name)
console.log(child2.colors)

console.log(parent)

答案:

Child{ feature: ['cute', 'sunshine'], sex: 'girl' }
Child{ feature: ['cute'] }

'parent'
['white', 'black', 'yellow']

Parent {name: "parent", sex: 'boy', colors: ['white', 'black', 'yellow'] }

解析:

  • child1在创建完之后,就设置了sex,并且给colorsfeaturepush了新的内容。
  • child1.sex = 'girl'这段代码相当于是给child1这个实例对象新增了一个sex属性。相当于是:原本我是没有sex这个属性的,我想要获取就得拿原型对象parent上的sex,但是现在你加了一句child1.sex就等于是我自己也有了这个属性了,就不需要你原型上的了,所以并不会影响到原型对象parent上😊。
  • 但是child1.colors这里,注意它的操作,它是直接使用了.push()的,也就是说我得先找到colors这个属性,发现实例对象parent上有,然后就拿来用了,之后执行push操作,所以这时候改变的是原型对象parent上的属性,会影响到后续所有的实例对象。(这里你会有疑问了,凭什么sex就是在实例对象child上新增,而我colors不行,那是因为操作的方式不同,sex那里是我不管你有没有,反正我就直接用=来覆盖你了,可是push它的前提是我得先有colors且类型是数组才行,不然你换成没有的属性,比如一个名为clothes的属性,child1.clothes.push('jacket')它直接就报错了,如果你使用的是child1.colors = ['yellow']这样才不会影响parent)
  • feature它是属于child1实例自身的属性,它添加还是减少都不会影响到其他实例。
  • 因此child1打印出了featuresex两个属性。(namecolors属于原型对象上的属性并不会被表现出来)
  • child2没有做任何操作,所以它打印出的还是它自身的一个feature属性😁。
  • child1.name是原型对象parent上的name,也就是'parent',虽然我们在new Child的时候传递了'child1',但它显然是无效的,因为接收name属性的是构造函数Parent,而不是Child
  • child2.colors由于用的也是原型对象parent上的colors,又由于之前被child1给改变了,所以打印出来的会是['white', 'black', 'yellow']
  • 将最后的原型对象parent打印出来,namesex没变,colors却变了。

分析的真漂亮,漂亮的这么一大串我都不想看了...

咳咳,不过你要是能静下来认真的读一读的话就会觉得真没啥东西,甚至不需要记什么,我就理解了。

总结-原型链继承

现在我们就可以得出原型链继承它的优点和缺点了

优点:

  • 继承了父类的模板,又继承了父类的原型对象

缺点:

  • 如果要给子类的原型上新增属性和方法,就必须放在Child.prototype = new Parent()这样的语句后面
  • 无法实现多继承(因为已经指定了原型对象了)
  • 来自原型对象的所有属性都被共享了,这样如果不小心修改了原型对象中的引用类型属性,那么所有子类创建的实例对象都会受到影响(这点从修改child1.colors可以看出来)
  • 创建子类时,无法向父类构造函数传参数(这点从child1.name可以看出来)

这...这看到没,压根就不需要记,想想霖呆呆出的这道变态的题面试的时候被问到脱口就来了。

2. instanceof

2.1 题目一

这道题主要是想介绍一个重要的运算符: instanceof

先看看官方的简介:

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

再来看看通俗点的简介:

a instanceof B

实例对象a instanceof 构造函数B

检测a的原型链(__proto__)上是否有B.prototype,有则返回true,否则返回false

上题吧:

function Parent () {
  this.name = 'parent'
}
function Child () {
  this.sex = 'boy'
}
Child.prototype = new Parent()
var child1 = new Child()

console.log(child1 instanceof Child)
console.log(child1 instanceof Parent)
console.log(child1 instanceof Object)

结果为:

true
true
true

这里就利用了前面👆提到的原型链继承,而且三个构造函数的原型对象都存在于child1的原型链上。

也就是说,左边的child1它会向它的原型链中不停的查找,看有没有右边那个构造函数的原型对象。

例如child1 instanceof Child的查找顺序:

child1 -> child1.__proto__ -> Child.prototype

child1 instanceof Parent的查找顺序:

child1 -> child1.__proto__ -> Child.prototype
-> Child.prototype.__proto__ -> Parent.prototype

还不理解?

没关系,我还有大招:

我在上面👆原型链继承的思维导图上加了三个查找路线。

被⭕️标记的1、2、3分别代表的是Child、Parent、Object的原型对象。

好滴,一张图简洁明了。以后再碰到instanceof这种东西,按照我图上的查找路线来查找就可以了 😁 ~

(如果你能看到这里,你就会发现霖呆呆的美术功底,不是一般的强)

[表情包害羞~]

2.2 题目二

(了解isPrototypeOf()的使用)

既然说到了instanceof,那么就不得不提一下isPrototypeOf这个方法了。

它属于Object.prototype上的方法,这点你可以将Object.prototype打印在控制台中看看。

isPrototypeOf()的用法和instanceof相反。

它是用来判断指定对象object1是否存在于另一个对象object2的原型链中,是则返回true,否则返回false

例如还是上面👆这道题,我们将要打印的内容改一下:

function Parent () {
  this.name = 'parent'
}
function Child () {
  this.sex = 'boy'
}
Child.prototype = new Parent()
var child1 = new Child()

console.log(Child.prototype.isPrototypeOf(child1))
console.log(Parent.prototype.isPrototypeOf(child1))
console.log(Object.prototype.isPrototypeOf(child1))

这里输出的依然是三个true

true
true
true

判断的方式只要把原型链继承instanceof查找思维导图这张图反过来查找即可。

3. 构造继承

了解了最简单的原型链继承,再让我们来看看构造继承呀,也叫做构造函数继承

在子类构造函数内部使用call或apply来调用父类构造函数

为了方便你查看,我们先来复习一波.callapply方法。

  • 通过call()、apply()或者bind()方法直接指定this的绑定对象, 如foo.call(obj)

  • 使用.call()或者.apply()的函数是会直接执行的

  • bind()是创建一个新的函数,需要手动调用才会执行

  • .call().apply()用法基本类似,不过call接收若干个参数,而apply接收的是一个数组

3.1 题目一

(构造继承的基本原理)

所以来看看这道题?

function Parent (name) {
  this.name = name
}
function Child () {
  this.sex = 'boy'
  Parent.call(this, 'child')
}
var child1 = new Child()
console.log(child1)

child1中会有哪些属性呢?

首先sex我们知道肯定会有的,毕竟它就是构造函数Child里的。

其次,我们使用了Parent.call(this, 'child').call函数刚刚已经说过了,它是会立即执行的,而这里又用了.call来改变Parent构造函数内的指向,所以我们是不是可以将它转化为伪代码:

function Child () {
	this.sex = 'boy'
	// 伪代码
	this.name = 'child'
}

你就理解为相当于是直接执行了Parent里的代码。使用父类的构造函数来增强子类实例,等于是复制父类的实例属性给子类。

所以构造继承的原理就是:

在子类构造函数内部使用call或apply来调用父类构造函数

同样的,来写下伪代码:

function Child () {
    Parent.call(this, ...arguments)
}

arguments表示的是你可以往里面传递参数,当然这只是伪代码)

3.2 题目二

如果你觉得上面👆这道题还不具有说明性,我们来看看这里。

现在我在子类和父类中都加上name这个属性,你觉得生出来的会是好孩子还是坏孩子呢?

function Parent (name) {
  this.name = name
}
function Child () {
  this.sex = 'boy'
  Parent.call(this, 'good boy')
  this.name = 'bad boy'
}
var child1 = new Child()
console.log(child1)

其实是好是坏很好区分,只要想想3.1里,把Parent.call(this, 'good boy')换成伪代码就知道了。

换成了伪代码之后,等于是重复定义了两个相同名称的属性,当然是后面的覆盖前面的啦。

所以结果为:

Child {sex: "boy", name: "bad boy"}

这道题如果换一下位置:

function Child () {
  this.sex = 'boy'
  this.name = 'bad boy'
  Parent.call(this, 'good boy')
}

这时候就是好孩子了。

(哎,霖呆呆的产生可能就是第二种情况...)

3.3 题目三

(构造继承的优点)

解决了原型链继承中子类共享父类引用对象的问题

刚刚的题目都是一些基本数据类型,让我来加上引用类型看看

function Parent (name, sex) {
  this.name = name
  this.sex = sex
  this.colors = ['white', 'black']
}
function Child (name, sex) {
  Parent.call(this, name, sex)
}
var child1 = new Child('child1', 'boy')
child1.colors.push('yellow')

var child2 = new Child('child2', 'girl')
console.log(child1)
console.log(child2)

这道题看着和1.3好像啊,没错,在父类构造函数中有一个叫colors的数组,它是地址引用的。

原型链继承中我们知道,子类构造函数创建的实例是会查找到原型链上的colors的,而且改动它会影响到其它的实例,这是原型链继承的一大缺点。

而现在呢?你看看使用了构造继承,结果为:

Child{ name: 'child1', sex: 'boy', colors: ['white', 'black', 'yellow'] }
Child{ name: 'child2', sex: 'girl', colors: ['white', 'black'] }

我们发现修改child1.colors并不会影响到其它的实例(child2)耶。

这里的原因其实我们前面也说了:

使用父类的构造函数来增强子类实例,等于是复制父类的实例属性给子类。

所以现在child1child2现在分别有它们各自的colors了,就不共享了。

而且这种拷贝属于深拷贝,验证的方式是你可以把colors数组中的每一项改为一个对象,然后修改它看看。

function Parent () {
	//...
	this.colors = [{ title: 'white' }, { title: 'black' }]
}

因此我们可以得出构造继承的优点:

  • 解决了原型链继承中子类实例共享父类引用对象的问题,实现多继承,创建子类实例时,可以向父类传递参数

3.4 题目四

(构造继承的缺点一)

在了解继承的时候,我们总是会想到原型链上的属性和方法能不能被继承到。

采用了这种构造继承的方式,能不能继承父类原型链上的属性呢?

来看下面👇这道题目

function Parent (name) {
  this.name = name
}
Parent.prototype.getName = function () {
  console.log(this.name)
}
function Child () {
  this.sex = 'boy'
  Parent.call(this, 'good boy')
}
Child.prototype.getSex = function () {
  console.log(this.sex)
}
var child1 = new Child()
console.log(child1)
child1.getSex()
child1.getName()

我给子类和父类的原型对象上都分别加了一个方法,然后调用它们。

结果竟然是:

Child {sex: "boy", name: "good boy"}
'boy'
Uncaught TypeError: child1.getName is not a function
  • sex、name属性都有这个我们都可以理解
  • getSex属于Child构造函数原型对象上的方法,我们肯定是能用它的,这个也好理解
  • getName呢?它属于父类构造函数原型对象上的方法,报错了?怎么滴?我子类不配使用你啊?

你确实是不配使用我。

你使用Parent.call(this, 'good boy')只不过是让你复制了一下我构造函数里的属性和方法,可没说能让你复制我原型对象的啊~年轻人,不要这么贪嘛。

所以我们可以看出构造继承一个最大的缺点,那就是:

小气!

"啪!"

"你给我正经点"😂

其实是:

  • 构造继承只能继承父类的实例属性和方法,不能继承父类原型的属性和方法

"那不就是小气嘛..."

"..."

3.5 题目五

(构造继承的缺点二)

它的第二个缺点是:实例并不是父类的实例,只是子类的实例。

停一下,让我们先来思考一下这句话的意思,然后想想怎样来验证它呢 🤔️ ?

一分钟...二分钟...三分钟...

啊,我知道了,刚刚不是才学的一个叫instanceof的运算符吗?它就能检测某个实例的原型链上能不能找到构造函数的原型对象。

换句话说就能检测某个对象是不是某个构造函数的实例啦。

所以让我们来看看:

function Parent (name) {
  this.name = name
}
function Child () {
  this.sex = 'boy'
  Parent.call(this, 'child')
}
var child1 = new Child()

console.log(child1)
console.log(child1 instanceof Child)
console.log(child1 instanceof Parent)
console.log(child1 instanceof Object)

结果为:

Child {sex: "boy", name: "child"}
true
false
true
  • 第一个true很好理解啦,我就是你生的,你不truetrue
  • 第二个为false其实也很好理解啦,想想刚刚的5.3,我连你父类原型上的方法都不能用,那我和你可能也没有关系啦,我只不过是复制了你函数里的属性和方法而已。
  • 第三个true,必然的,实例的原型链如果没有发生改变的话最后都能找到Object.prototype啦。

(虽说构造继承出来的实例确实不是父类的实例,只是子类的实例。但我其实是不太明白教材中为什么要说它是一个缺点呢?鄙人愚昧,想的可能是:子类生成的实例既然能用到父类中的属性和方法,那我就应该也要确定这些属性和方法的来源,如果不能使用instanceof检测到你和父类有关系的话,那就会对这些凭空产生的属性和方法有所质疑...)

因此构造继承第二个缺点是:

  • 实例并不是父类的实例,只是子类的实例

总结-构造继承

构造继承总结来说:

优点:

  • 解决了原型链继承中子类实例共享父类引用对象的问题,实现多继承,创建子类实例时,可以向父类传递参数(见题目3.3)

缺点:

  • 构造继承只能继承父类的实例属性和方法,不能继承父类原型的属性和方法(见题目3.4)
  • 实例并不是父类的实例,只是子类的实例(见题目3.5)
  • 无法实现函数复用,每个子类都有父类实例函数的副本,影响性能

(最后一个缺点‘无法实现函数复用’经过评论区小伙伴matteokjh的提醒,我理解的大概是这个意思:父类构造函数中的某个函数可能只是一个功能型的函数,它不论被复制了多少份,输出的结果或者功能都是一样的,那么这类函数是完全可以拿来复用的。但是现在用了构造函数继承,由于它是复制了父类构造函数中的属性和方法,这样产生的每个子类实例中都会有一份自己各自的方法,可是有的方法完全没有必要复制,可以用来共用的,所以就说不能够「函数复用」。)

4. 组合继承

既然原型链继承构造继承都有这么多的缺点,那我们为何不阴阳结合,把它们组合在一起呢?

咦~

好像是个好想法。

把我们前面的伪代码拿来用用,想想该如何组合呢?

// 原型链继承
Child.prototype = new Parent()
// 构造继承
function Child () {
  Parent.call(this, ...arguments)
}

...思考中🤔...

看到这两段伪代码,我好像有所顿悟了,不就是按照伪代码里写的,把这两种继承组合在一起吗?

哇!这都被我猜中了,搜索一下组合继承的概念,果然就是这样。

组合继承的概念:

组合继承就是将原型链继承与构造函数继承组合在一起,从而发挥两者之长的一种继承模式。

思路:

  • 使用原型链继承来保证子类能继承到父类原型中的属性和方法
  • 使用构造继承来保证子类能继承到父类的实例属性和方法

基操:

  • 通过call/apply在子类构造函数内部调用父类构造函数
  • 将子类构造函数的原型对象指向父类构造函数创建的一个匿名实例
  • 修正子类构造函数原型对象的constructor属性,将它指向子类构造函数

基操中的第一点就是构造继承,第二点为原型链继承,第三点其实只是一个好的惯例,在后面的题目会细讲到它。

4.1 题目一

(理解组合继承的基本使用)

现在我决定对你们不再仁慈,让我们换种想法,逆向思维来解解题好不好。

阴笑~

既然我都已经说了这么多关于组合继承的东西了,那想必你们也知道该如何设计一个组合继承了。

我现在需要你们来实现这么一个ChildParent构造函数(代码尽可能地少),让它们代码的执行结果能如下:

(请先不要着急看答案哦,花上2分钟来思考一下,弄清每个属性在什么位置上,都有什么公共属性就好办了)

var child1 = new Child('child1')
var parent1 = new Parent('parent1')
console.log(child1) // Child{ name: 'child1', sex: 'boy' }
console.log(parent1)// Parent{ name: 'parent1' }
child1.getName()    // 'child1'
child1.getSex()     // 'boy'
parent1.getName()   // 'parent1'
parent1.getSex()    // Uncaught TypeError: parent1.getSex is not a function

解题思路:

  • 首先来看看俩构造函数产生的实例(child1和parent1)上都有name这个属性,所以name属性肯定是在父类的构造函数里定义的啦,而且是通过传递参数进去的。
  • 其次,sex属性只有实例child1才有,表明它是子类构造函数上的定义的属性(也就是我们之前提到过的公有属性)
  • 再然后child1parent1都可以调用getName方法,并且都没有表现在实例上,所以它们可能是在Parent.prototype上。
  • getSex对于child1是可以调用的,对于father1是不可调用的,说明它是在Child.prototype上。

好的👌,每个属性各自在什么位置上都已经找到了,再来看看如何实现它吧:

function Parent (name) {
  this.name = name
}
Parent.prototype.getName = function () {
  console.log(this.name)
}
function Child (name) {
  this.sex = 'boy'
  Parent.call(this, name)
}
Child.prototype = new Parent()
Child.prototype.getSex = function () {
  console.log(this.sex)
}

var child1 = new Child('child1')
var parent1 = new Parent('parent1')
console.log(child1)
console.log(parent1)
child1.getName()
child1.getSex()
parent1.getName()
parent1.getSex()

不知道是不是和你构想的一样呢 🤔️?

其实这是一道开放式题,如果构想的不一样也是正常了,不过你得自己把自己构想的用代码跑一边看看是不是和需求一样。

为什么说它比较开放呢?

就比如第一点,name属性,它不一定就只存在于Parent里呀,我Child里也可以有一个自己的name属性,只不过题目要求代码尽可能地少,所以最好的就是存在与Parent中,并且用.call来实现构造继承

另外,getName方法也不一定要在Parent.prototype上,它只要存在于parent1的原型链中就可以了,所以也有可能在Object.prototype,脑补一下那张原型链的图,是不是这样呢?

这就是组合继承带来的魅力,如果你能看懂这道题,就已经掌握其精髓了 👏。

4.2 题目二

(理解constructor有什么作用)

拿上面👆那道题和最开始我们定义组合继承的基操做对比,发现第三点constructor好像并没有提到耶,但是也实现了我们想要的功能,那这样说来constructor好像并没有什么软用呀...

你想的没错,就算我们不对它进行任何的设置,它也丝毫不会影响到JS的内部属性。

它不过是给我们一个提示,用来标示实例对象是由哪个构造函数创建的。

先用一张图来看看constructor它存在的位置吧:

可以看到,它实际就是原型对象上的一个属性,指向的是构造函数。

所以我们是不是可以有这么一层对应关系:

guaiguai.__proto__ = Cat.prototype
Cat.prototype.constructor = Cat
guaiguai.__proto__.constructor = Cat

(结合图片来看,这样的三角恋关系俨然并不复杂)

再结合题目4.1来看,你觉得以下代码会打印出什么呢?题目其实还是4.1的题目,要求打印的东西不同而已。

function Parent (name) {
  this.name = name
}
Parent.prototype.getName = function () {
  console.log(this.name)
}
function Child (name) {
  this.sex = 'boy'
  Parent.call(this, name)
}
Child.prototype = new Parent()
Child.prototype.getSex = function () {
  console.log(this.sex)
}

var child1 = new Child('child1')
var parent1 = new Parent('parent1')
console.log(child1.constructor)
console.log(parent1.constructor)

一时不知道答案也没关系,我直接公布一下了:

f Parent () {}
f Parent () {}

打印出的两个都是Parent函数。

parent1.constructorParent函数这个还好理解,结合上面👆的图片来看,只要通过原型链查找,我parent1实例自身没有constructor属性,那我就拿原型上的constructor,发现它指向的是构造函数Parent,因此第二个打印出Parent函数。

而对于child1,想想组合继承用到了原型链继承,虽然也用到了构造继承,但是构造继承对原型链之间的关系没有影响。那么我组合继承的原型链关系是不是就可以用原型链继承那张关系图来看?

如下:

就像上面看到的一样,原型链继承切断了原本ChildChild原型对象的关系,而是重新指向了匿名实例。使得实例child1能够使用匿名实例原型链上的属性和方法。

当我们想要获取child1.constructor,肯定是向上查找,通过__proto__找它构造函数的原型对象匿名实例

但是匿名实例它自身是没有constructor属性的呀,它只是Parent构造函数创建出来的一个对象而已,所以它也会继续向上查找,然后就找到了Parent原型对象上的constructor,也就是Parent了。

所以回过头来看看这句话:

construcotr它不过是给我们一个提示,用来标示实例对象是由哪个构造函数创建的。

从人(常)性(理)的角度上来看,child1Child构建的,parent1Parent构建的。

那么child1它的constructor就应该是Child呀,但是现在却变成了Parent,貌似并不太符合常理啊。

所以才有了这么一句:

Child.prototype.constructor = Child

用以修复constructor的指向。

现在让我们通过改造原型链继承思维导图来画画组合继承的思维导图吧。

(至于为什么在组合继承中我修复了constructor,在原型链继承中没有,这个其实取决于你自己,因为你也看到了constructor实际并没有什么作用,不过面试被问到的话肯定是要知道的)

总结来说:

  • constructor它是构造函数原型对象中的一个属性,正常情况下它指向的是原型对象。
  • 它并不会影响任何JS内部属性,只是用来标示一下某个实例是由哪个构造函数产生的而已。
  • 如果我们使用了原型链继承或者组合继承无意间修改了constructor的指向,那么出于编程习惯,我们最好将它修改为正确的构造函数。

4.3 题目三

constructor的某个使用场景)

先来看看下面👇这道题:

var a;
(function () {
  function A () {
    this.a = 1
    this.b = 2
  }
  A.prototype.logA = function () {
    console.log(this.a)
  }
  a = new A()
})()

a.logA()

这里的输出结果:

1

乍一看被整片的a给搞糊了,但是仔细分析来,就能得出结果了。

  • 定义了一个全局的变量a,和一个构造函数A
  • 在立即执行函数中,是可以访问到全局变量a的,因此a被赋值为了一个构造函数A生成的对象
  • 并且a对象中有两个属性:ab,且值都是1
  • 之后在外层调用a.logA(),打印出的就是a.a,也就是1

难度升级:

现在我想要在匿名函数外给A这个构造函数的原型对象中添加一个方法logB用以打印出this.b

你首先想到的是不是B.prototype.logB = funciton() {}

但是注意咯,我是要你在匿名函数外添加,而此时由于作用域的原因,我们在匿名函数外是访问不到A的,所以这样的做法就不可行了。

解决办法:

虽然我们在外层访问不到A,但是我们可以通过原型链查找,来获取A的原型对象呀。

还是这张图:

这里我们就有两种解决办法了:

  1. 通过a.__proto__来访问到原型对象:
a.__proto__.logB = function () {
  console.log(this.b)
}
a.logB()
  1. 通过a.constructor.prototype来访问到原型对象:
a.constructor.prototype.logB = function () {
  console.log(this.b)
}
a.logB()

想想是不是这样的?

虽然我a实例上没有constructor,但是原型对象上有呀,所以a.construtor实际拿的是原型对象上的construtor

(个人愚见感觉并没什么软用...我用__proto__就可以了呀 😂)

4.4 题目四

(理解组合继承的优点)

function Parent (name, colors) {
  this.name = name
  this.colors = colors
}
Parent.prototype.features = ['cute']
function Child (name, colors) {
  this.sex = 'boy'
  Parent.apply(this, [name, colors])
}
Child.prototype = new Parent()
Child.prototype.constructor = Child

var child1 = new Child('child1', ['white'])
child1.colors.push('yellow')
child1.features.push('sunshine')
var child2 = new Child('child2', ['black'])

console.log(child1)
console.log(child2)
console.log(Child.prototype)

console.log(child1 instanceof Child)
console.log(child1 instanceof Parent)

有了前面几题作为基础,这道题也就不难了。

答案:

Child{ sex: "boy", name: "child1", colors: ["white", "yellow"] }
Child{ sex: "boy", name: "child2", colors: ["black"] }
Parent{ name: undefined, colors: undefined, constructor: f Child () {} }

true
true

解析思路:

  • 两个childsexname都没啥问题,而colors可能会有些疑问,因为colors是通过构造继承于父类的,并且是复制出来的属性,所以改变child1.colors并不会影响child2.colors。(类似题目3.3)
  • Child.prototype,是使用new Parent生成的,并且生成的时候是没有传递参数进去的,因此namecolors都是undefined。而且题目中又将constructor给修正指向了Child
  • 最后两个true,是因为child1可以沿着它的原型链查找到Child.prototypeParent.prototype。(类似题目2.1)

现在你就可以看出组合继承的优点了吧,它其实就是将两种继承方式的优点给结合起来。

  • 可以继承父类实例属性和方法,也能够继承父类原型属性和方法
  • 弥补了原型链继承中引用属性共享的问题
  • 可传参,可复用

4.5 题目五

(理解组合继承的缺点)

人无完人,狗无完狗,就算是组合继承这么牛批的继承方式也还是有它的缺点 😁。

一起来看看这里:

function Parent (name) {
  console.log(name) // 这里有个console.log()
  this.name = name
}
function Child (name) {
  Parent.call(this, name)
}
Child.prototype = new Parent()
var child1 = new Child('child1')

console.log(child1)
console.log(Child.prototype)

执行结果为:

undefined
'child1'

Child{ name: 'child1' }
Parent{ name: undefined }

我们虽然只调用了new Child()一次,但是在Parent中却两次打印出了name

  • 第一次是原型链继承的时候,new Parent()
  • 第二次是构造继承的时候,Parent.call()调用的

也就是说,在使用组合继承的时候,会凭空多调用一次父类构造函数。

另外,我们想要继承父类构造函数里的属性和方法采用的是构造继承,也就是复制一份到子类实例对象中,而此时由于调用了new Parent(),所以Child.prototype中也会有一份一模一样的属性,就例如这里的name: undefined,可是我子类实例对象自己已经有了一份了呀,所以我怎么也用不上Child.prototype上面的了,那你这凭空多出来的属性不就占了内存浪费了吗?

因此我们可以看出组合继承的缺点:

  • 使用组合继承时,父类构造函数会被调用两次
  • 并且生成了两个实例,子类实例中的属性和方法会覆盖子类原型(父类实例)上的属性和方法,所以增加了不必要的内存。

4.6 题目六

(考察你是否理解实例对象上引用类型和原型对象上引用类型的区别)

这里可就有一个坑了,得注意了⚠️:

function Parent (name, colors) {
  this.name = name
  this.colors = colors
}
Parent.prototype.features = ['cute']
function Child (name, colors) {
  Parent.apply(this, [name, colors])
}
Child.prototype = new Parent()
Child.prototype.constructor = Child

var child1 = new Child('child1', ['white'])
child1.colors.push('yellow')
child1.features.push('sunshine')
var child2 = new Child('child2', ['black'])

console.log(child1.colors)
console.log(child2.colors)
console.log(child1.features)
console.log(child2.features)

题目解析:

  • colors属性虽然定义在Parent构造函数中,但是Child通过构造继承复制了其中的属性,所以它存在于各个实例当中,改变child1里的colors就不会影响其它地方了
  • features是定义在父类构造函数原型对象中的,是比new Parent()还要更深一层的对象,在child实例还有Child.prototype(也就是new Parent()产生出了的匿名实例)上都没有features属性,因此它们只能去它们共有的Parent.prototype上面拿了,所以这时候它们就是共用了一个features,因此改变child1.features就会改变child2.features了。

结果为:

["white", "yellow"]
["black"]
["cute", "sunshine"]
["cute", "sunshine"]

可是霖呆呆不对呀,你刚刚不是还说了:

组合继承弥补了原型链继承中引用属性共享的问题

就在题4.4中,都还热乎着呢?怎么这里的features还是没有被解决啊,它们还是共享了。

"冤枉啊!我从来不骗人"

它确实是解决了原型链继承中引用属性共享的问题啊,你想想这里Child.prototype是谁?

是不是new Parent()产生的那个匿名实例?而这个匿名实例中的引用类型是不是colors?而colors是不是确实不是共享的?

那就对了呀,我已经帮你解决了原型(匿名实例)中引用属性共享的问题了呀。

至于featuresParent.prototype上的属性,相当于是爷爷那一级别的了,这我可没法子。

总结-组合继承

同样的,让我们对组合继承也来做个总结吧:

实现方式:

  • 使用原型链继承来保证子类能继承到父类原型中的属性和方法
  • 使用构造继承来保证子类能继承到父类的实例属性和方法

优点:

  • 可以继承父类实例属性和方法,也能够继承父类原型属性和方法
  • 弥补了原型链继承中引用属性共享的问题
  • 可传参,可复用

缺点:

  • 使用组合继承时,父类构造函数会被调用两次
  • 并且生成了两个实例,子类实例中的属性和方法会覆盖子类原型(父类实例)上的属性和方法,所以增加了不必要的内存。

constructor总结:

  • constructor它是构造函数原型对象中的一个属性,正常情况下它指向的是原型对象。
  • 它并不会影响任何JS内部属性,只是用来标示一下某个实例是由哪个构造函数产生的而已。
  • 如果我们使用了原型链继承或者组合继承无意间修改了constructor的指向,那么出于编程习惯,我们最好将它修改为正确的构造函数。

5. 寄生组合继承

唔...寄生这个词听着有点可怕啊...

它比组合继承还要牛批一点。

刚刚我们提了组合继承的缺点无非就是:

  1. 父类构造函数会被调用两次
  2. 生成了两个实例,在父类实例上产生了无用废弃的属性

那么有没有一种方式让我们直接跳过父类实例上的属性,而让我直接就能继承父类原型链上的属性呢?

也就是说,我们需要一个干净的实例对象,来作为子类的原型。并且这个干净的实例对象还得能继承父类原型对象里的属性。

咦~说到干净的对象,我就想到了一个方法:Object.create()

让我们先来回忆一波它的用法:

Object.create(proto, propertiesObject)
  • 参数一,需要指定的原型对象
  • 参数二,可选参数,给新对象自身添加新属性以及描述器

在这里我们主要讲解一下第一个参数proto,它的作用就是能指定你要新建的这个对象它的原型对象是谁。

怎么说呢?

就好比,我们使用var parent1 = new Parent()创建了一个对象parent1,那parent1.__proto__就是Parent.prototype

使用var obj = new Object()创建了一个对象obj,那obj.__proto__就是Object.prototype

而这个Object.create()屌了,它现在能指定你新建对象的__proto__

哈哈哈哈~

这正不是我们想要的吗?我们现在只想要一个干净并且能链接到父类原型链上的对象。

来看看题目一。

5.1 题目一

(理解寄生组合继承的用法)

function Parent (name) {
  this.name = name
}
Parent.prototype.getName = function () {
  console.log(this.name)
}
function Child (name) {
  this.sex = 'boy'
  Parent.call(this, name)
}
// 与组合继承的区别
Child.prototype = Object.create(Parent.prototype)

var child1 = new Child('child1')

console.log(child1)
child1.getName()

console.log(child1.__proto__)
console.log(Object.create(null))
console.log(new Object())

可以看到,上面👆这道题就是一个标准的寄生组合继承,它与组合继承的区别仅仅是Child.prototype不同。

我们使用了Object.create(Parent.prototype)创建了一个空的对象,并且这个对象的__proto__属性是指向Parent.prototype的。

来看看寄生组合继承的思维导图:

(灵魂画手再次上线)

可以看到,现在Parent()已经和child1没有关系了,仅仅是用了Parent.call(this)来复制了一下Parent里的属性和方法 😁。

因此这道题的答案为:

function Parent (name) {
  this.name = name
}
Parent.prototype.getName = function () {
  console.log(this.name)
}
function Child (name) {
  this.sex = 'boy'
  Parent.call(this, name)
}
// 与组合继承的区别
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child

var child1 = new Child('child1')

console.log(child1) // Child{ sex: "boy", name: "child1" }
child1.getName() // "child1"

console.log(child1.__proto__) // Parent{}
console.log(Object.create(null)) // {}
console.log(new Object()) // {}

题目解析:

  • 使用寄生组合继承child1不仅仅有自己的实例属性sex,而且还复制了父类中的属性name
  • 寄生组合继承使得实例child1能通过原型链查找,使用到Parent.prototype上的方法,因此打印出child1

最后的三个空对象,我们就需要展开来看看了:

  • child1.__proto__也就是Child.prototype,也就是Object.create(Parent.prototype),这个空对象它的__proto__指向的就是我们想要的父类的原型对象,所以child1就能使用Parent.prototype上的方法了。
  • 而通过Object.create(null)创建的对象呢?哇,这可真的是空的不能再空了,因为我们创建它的时候传递的参数是null,也就是将它的__proto__属性设置为null,那它就相当于是没有原型链了,连Object.prototype上的方法它都不能用了(比如toString()、hasOwnProperty())
  • 再来看看new Object(),这个其实很好理解了,Object本身就是一个构造函数,就像Parent、Child这种,只不过它的原型对象是我们常用的Object.prototype

(看看,大家在学继承的同时,还顺便学习了一波Object.create(),多好啊 😁)

5.2 题目二

虽然寄生组合继承组合继承非常像,不过我们还是来看一道题巩固巩固吧。

执行结果:

Child{ name: 'child1', face: 'smile', sex: 'boy', colors: ['white', 'black', 'yellow'] }
Child{ name: 'child2', face: 'smile', sex: 'boy', colors: ['white', 'black'], features: ['sunshine'] }

["cute"]
["sunshine"]

哈哈哈,小伙伴们的答案和这里是否有出入呢?

是不是发现一不小心就会做错 😂。

让我们来看看解题思路:

  • name、face、sex三个属性都没有啥问题,要注意的只是face属性,后面写的会覆盖前面的(类似题目3.2)
  • colors属性是通过构造继承复制过来的,所以改变child1.colors对其他实例没有影响,这个说过很多次了。
  • 要注意的就是这里的features,在没有执行child2.features = ['sunshine']这段代码之前,child1child2都是共用原型链上的features,但是执行了这段代码之后,就相当于是给child2对象上新增了一个名为features属性,所以这时候child2取的就是它自身的了。

(这道题我是使用VSCode插件Polacode-2019做的代码截图,不知道大家是喜欢这种代码截图还是喜欢源代码的形式呢?可以留言告诉霖呆呆 😁)

(另外,关于更多美化工具的使用可以查看我的这篇文章:你的掘金文章本可以这么炫(博客美化工具一波带走)

总结-寄生组合继承

寄生组合继承算是ES6之前一种比较完美的继承方式吧。

它避免了组合继承中调用两次父类构造函数,初始化两次实例属性的缺点。

所以它拥有了上述所有继承方式的优点:

  • 只调用了一次父类构造函数,只创建了一份父类属性
  • 子类可以用到父类原型链上的属性和方法
  • 能够正常的使用instanceOfisPrototypeOf方法

6. 原型式继承

算是翻了很多关于JS继承的文章吧,其中百分之九十都是这样介绍原型式继承的:

该方法的原理是创建一个构造函数,构造函数的原型指向对象,然后调用 new 操作符创建实例,并返回这个实例,本质是一个浅拷贝。

伪代码如下:

(后面会细讲)

function objcet (obj) {
    function F () {};
    F.prototype = obj;
    F.prototype.constructor = F;
    return new F();
}

开始以为是多神秘的东西,但后来真正了解了它之后感觉用的应该不多吧... 😢

先来看看题目一。

6.1 题目一

在真正开始看原型式继承之前,先来看个我们比较熟悉的东西:

var cat = {
  heart: '❤️',
  colors: ['white', 'black']
}

var guaiguai = Object.create(cat)
var huaihuai = Object.create(cat)

console.log(guaiguai)
console.log(huaihuai)

console.log(guaiguai.heart)
console.log(huaihuai.colors)

这里的执行结果:

{}
{}

'❤️'
['white', 'black']

这里用到了我们之前提到过的Object.create()方法。

在这道题中,Object.create(cat)会创建出一个__proto__属性为cat的空对象。

所以你可以看到乖乖坏坏都是一只空猫,但是它们却能用猫cat的属性。

6.2 题目二

不怕你笑话,上面👆说的这种方式就是原型式继承,只不过在ES5之前,还没有Object.create()方法,所以就会用开头介绍的那段伪代码来代替它。

将题目6.1改造一下,让我们自己来实现一个Object.create()

我们就将要实现的函数命名为create()

想想Object.create()的作用:

  • 它接受的是一个对象
  • 返回的是一个新对象,
  • 新对象的原型链中必须能找到传进来的对象

所以就有了这么一个方法:

function objcet (obj) {
    function F () {};
    F.prototype = obj;
    F.prototype.constructor = F;
    return new F();
}

它满足了上述的几个条件。

来看看效果是不是和题6.1一样呢?

function objcet (obj) {
    function F () {};
    F.prototype = obj;
    F.prototype.constructor = F;
    return new F();
}
var cat = {
  heart: '❤️',
  colors: ['white', 'black']
}

var guaiguai = create(cat)
var huaihuai = create(cat)

console.log(guaiguai)
console.log(huaihuai)

console.log(guaiguai.heart)
console.log(huaihuai.colors)

执行结果为:

效果是和Object.create()差不多(只不过我们自定义的create返回的对象是构造函数F创建的)。

这就有小伙伴要问了,既然是需要满足

  • 新对象的原型链中必须能找到传进来的对象

这个条件的话,我这样写也可以实现啊:

function create (obj) {
    var newObj = {}
    newObj.__proto__ = obj
    return newObj;
}

请注意了,我们是要模拟Object.create()方法,如果你都能使用__proto__,那为何不干脆使用Object.create()呢?(它们是同一时期的产物)

总结-原型式继承

由于它使用的不太多,这里就不多说它了。

(霖呆呆就是这么现实)

不过还是要总结一下滴:

实现方式:

该方法的原理是创建一个构造函数,构造函数的原型指向对象,然后调用 new 操作符创建实例,并返回这个实例,本质是一个浅拷贝。

ES5之后可以直接使用Object.create()方法来实现,而在这之前就只能手动实现一个了(如题目6.2)。

优点:

  • 再不用创建构造函数的情况下,实现了原型链继承,代码量减少一部分。

缺点:

  • 一些引用数据操作的时候会出问题,两个实例会公用继承实例的引用数据类
  • 谨慎定义方法,以免定义方法也继承对象原型的方法重名
  • 无法直接给父级构造函数使用参数

(呀!好久没用表情包了,此处应该有个表情包)

7. 寄生式继承

cccc...

怎么又来了个什么寄生式继承啊,还有完没完...

心态放平和...

其实这个寄生式继承也没啥东西的,它就是在原型式继承的基础上再封装一层,来增强对象,之后将这个对象返回。

来看看伪代码你就知道了:

function createAnother (original) {
    var clone = Object.create(original);; // 通过调用 Object.create() 函数创建一个新对象
    clone.fn = function () {}; // 以某种方式来增强对象
    return clone; // 返回这个对象
}

7.1 题目一

(了解寄生式继承的使用方式)

它的使用方式,唔...

例如我现在想要继承某个对象上的属性,同时又想在新创建的对象中新增上一些其它的属性。

来看下面👇这两只猫咪

var cat = {
  heart: '❤️',
  colors: ['white', 'black']
}
function createAnother (original) {
    var clone = Object.create(original);
    clone.actingCute = function () {
      console.log('我是一只会卖萌的猫咪')
    }
    return clone;
}
var guaiguai = createAnother(cat)
var huaihuai = Object.create(cat)

guaiguai.actingCute()
console.log(guaiguai.heart)
console.log(huaihuai.colors)
console.log(guaiguai)
console.log(huaihuai)

题目解析:

  • guaiguai是一直经过加工的小猫咪,所以它会卖萌,因此调用actingCute()会打印卖萌
  • 两只猫都是通过Object.create()进行过原型式继承cat对象的,所以是共享使用cat对象中的属性
  • guaiguai经过createAnother新增了自身的实例方法actingCute,所以会有这个方法
  • huaihuai是一只空猫,因为heart、colors都是原型对象cat上的属性

执行结果:

'我是一只会卖萌的猫咪'
'❤️'
['white', 'black']
{ actingCute: ƒ }
{}

总结-寄生式继承

实现方式:

  • 原型式继承的基础上再封装一层,来增强对象,之后将这个对象返回。

优点:

  • 再不用创建构造函数的情况下,实现了原型链继承,代码量减少一部分。

缺点:

  • 一些引用数据操作的时候会出问题,两个实例会公用继承实例的引用数据类
  • 谨慎定义方法,以免定义方法也继承对象原型的方法重名
  • 无法直接给父级构造函数使用参数

8. 混入方式继承多个对象

过五关斩六将,咱终于到了ES5中的要讲的最后一种继承方式了。

这个混入方式继承其实很好玩,之前我们一直都是以一个子类继承一个父类,而混入方式继承就是教我们如何一个子类继承多个父类的。

在这边,我们需要用到ES6中的方法Object.assign()

它的作用就是可以把多个对象的属性和方法拷贝到目标对象中,若是存在同名属性的话,后面的会覆盖前面。(当然,这种拷贝是一种浅拷贝啦)

来看看伪代码:

function Child () {
    Parent.call(this)
    OtherParent.call(this)
}
Child.prototype = Object.create(Parent.prototype)
Object.assign(Child.prototype, OtherParent.prototype)
Child.prototype.constructor = Child

8.1 题目一

(理解混入方式继承的使用)

额,既然您都看到这了,说明实力以及很强了,要不?咱直接就上个复杂点的题?

function Parent (sex) {
  this.sex = sex
}
Parent.prototype.getSex = function () {
  console.log(this.sex)
}
function OtherParent (colors) {
  this.colors = colors
}
OtherParent.prototype.getColors = function () {
  console.log(this.colors)
}
function Child (sex, colors) {
  Parent.call(this, sex)
  OtherParent.call(this, colors) // 新增的父类
  this.name = 'child'
}
Child.prototype = Object.create(Parent.prototype)
Object.assign(Child.prototype, OtherParent.prototype) // 新增的父类原型对象
Child.prototype.constructor = Child

var child1 = new Child('boy', ['white'])
child1.getSex()
child1.getColors()
console.log(child1)

这里就是采用了混入方式继承,在题目中标出来的地方就是不同于寄生组合继承的地方。

现在的child1不仅复制了Parent上的属性和方法,还复制了OtherParent上的。

而且它不仅可以使用Parent.prototype的属性和方法,还能使用OtherParent.prototype上的。

结果:

'boy'
['white']
{ name: 'child', sex: 'boy', colors: ['white'] }

8.2 题目二

(理解混入方式继承的原型链结构)

同是上面👆的题,我现在多加上几个输出:

function Parent (sex) {
  this.sex = sex
}
Parent.prototype.getSex = function () {
  console.log(this.sex)
}
function OtherParent (colors) {
  this.colors = colors
}
OtherParent.prototype.getColors = function () {
  console.log(this.colors)
}
function Child (sex, colors) {
  Parent.call(this, sex)
  OtherParent.call(this, colors) // 新增的父类
  this.name = 'child'
}
Child.prototype = Object.create(Parent.prototype)
Object.assign(Child.prototype, OtherParent.prototype) // 新增的父类原型对象
Child.prototype.constructor = Child

var child1 = new Child('boy', ['white'])
// child1.getSex()
// child1.getColors()
// console.log(child1)

console.log(Child.prototype.__proto__ === Parent.prototype)
console.log(Child.prototype.__proto__ === OtherParent.prototype)
console.log(child1 instanceof Parent)
console.log(child1 instanceof OtherParent)

这四个输出你感觉会是什么 🤔️?

先不要着急,如果有条件的,自己动手在纸上把现在的原型链关系给画一下。

反正呆呆是已经用XMind的画好了:

可以看到,其实它与前面我们画的寄生组合继承思维导图就多了下面OtherParent的那部分东西。

  • Child内使用了call/apply来复制构造函数OtherParent上的属性和方法
  • Child.prototype使用Object.assign()浅拷贝OtherParent.prototype上的属性和方法

根据这这幅图,我们很快就能得出答案了:

true
false
true
false

9. class中的继承

构造函数中主要的几种继承方式都已经介绍的差不多了,接下来就让我们看看ES6class的继承吧。

class 中继承主要是依靠两个东西:

  • extends
  • super

而且对于该继承的效果和之前我们介绍过的寄生组合继承方式一样。(没错,就是那个最屌的继承方式)

一起来看看题目一 😁。

9.1 题目一

(理解class中的继承)

既然它的继承和寄生组合继承方式一样,那么让我们将题目5.1的题目改造一下,用class的继承方式来实现它。

class Parent {
  constructor (name) {
    this.name = name
  }
  getName () {
    console.log(this.name)
  }
}
class Child extends Parent {
  constructor (name) {
    super(name)
    this.sex = 'boy'
  }
}
var child1 = new Child('child1')
console.log(child1)
child1.getName()

console.log(child1 instanceof Child)
console.log(child1 instanceof Parent)

结果如下:

Child{ name: 'child1', sex: 'boy' }
'child1'
true
true

再让我们来写一下寄生组合继承的实现方式:

function Parent (name) {
  this.name = name
}
Parent.prototype.getName = function () {
  console.log(this.name)
}
function Child (name) {
  this.sex = 'boy'
  Parent.call(this, name)
}
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child

var child1 = new Child('child1')
console.log(child1)
child1.getName()

console.log(child1 instanceof Child)
console.log(child1 instanceof Parent)

结果如下:

Child{ name: 'child1', sex: 'boy' }
'child1'
true
true

这样好像看不出个啥,没事,让我们上图:

class继承

寄生组合继承

可以看到,class的继承方式完全满足于寄生组合继承。

9.2 题目二

(理解extends的基本作用)

可以看到上面👆那道题,我们用到了两个关键的东西:extendssuper

extends从字面上来看还是很好理解的,对某个东西的延伸,继承。

那如果我们单单只用extends不用super呢?

class Parent {
  constructor (name) {
    this.name = name
  }
  getName () {
    console.log(this.name)
  }
}
class Child extends Parent {
  // constructor (name) {
  //   super(name)
  //   this.sex = 'boy'
  // }
  sex = 'boy' // 实例属性sex放到外面来
}
var child1 = new Child('child1')
console.log(child1)
child1.getName()

其实这里的执行结果和没有隐去之前一样。

执行结果:

那我们是不是可以认为:

class Child extends Parent {}

// 等同于
class Child extends Parent {
    constructor (...args) {
        super(...args)
    }
}

OK👌,其实这一步很好理解啦,还记得之前我们就提到过,在class中如果没有定义constructor方法的话,这个方法是会被默认添加的,那么这里我们没有使用constructor,它其实已经被隐式的添加和调用了。

所以我们可以看出extends的作用:

  • class可以通过extends关键字实现继承父类的所有属性和方法
  • 若是使用了extends实现继承的子类内部没有constructor方法,则会被默认添加constructorsuper

9.3 题目三

(理解super的基本作用)

通过上面那道题看来,constructor貌似是可有可无的角色。

那么super呢,它在 class中扮演的是一个什么角色 🤔️?

还是上面的题目,但是这次我不使用super,看看会有什么效果:

class Parent {
  constructor () {
    this.name = 'parent'
  }
}
class Child extends Parent {
  constructor () {
    // super(name) // 把super隐去
  }
}
var child1 = new Child()
console.log(child1)
child1.getName()

哈哈哈,现在你保存刷新页面,就会发现它报错了:

Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
    at new Child

你品你细细品。

大致意思就是你必须得在constructor中调用一下super函数。

这样说来,constructorsuper是一对好基友啊...

super函数咱还是不能省,很重要啊。

然后再看了看它的写法,有点像是给父级类中传递参数的感觉啊 😄。

唔...如果你这样想的话算是猜对了一部分吧。这其实和ES6的继承机制有关。

  • 我们知道在ES5中的继承(例如构造继承、寄生组合继承) ,实质上是先创造子类的实例对象this,然后再将父类的属性和方法添加到this上(使用的是Parent.call(this))。
  • 而在ES6中却不是这样的,它实质是先创造父类的实例对象this(也就是使用super()),然后再用子类的构造函数去修改this

通俗理解就是,子类必须得在constructor中调用super方法,否则新建实例就会报错,因为子类自己没有自己的this对象,而是继承父类的this对象,然后对其加工,如果不调用super的话子类就得不到this对象。

哇哦~

[果然是好基友~]

这道题介绍的是super的基本作用,下面来说说它的具体用法吧。

9.4 题目四

(super当作函数调用时)

super其实有两种用法,一种是当作函数来调用,还有一种是当做对象来使用。

之前那道题就是将它当成函数来调用的,而且我们知道在constructor中还必须得执行super()

其实,super被当作函数调用时,代表着父类的构造函数

虽然它代表着父类的构造函数,但是返回的却是子类的实例,也就是说super内部的this指向的是Child

让我们来看道题验证一下:

(new.target指向当前正在执行的那个函数,你可以理解为new后面的那个函数)

class Parent {
  constructor () {
    console.log(new.target.name)
  }
}
class Child extends Parent {
  constructor () {
    var instance = super()
    console.log(instance)
    console.log(instance === this)
  }
}
var child1 = new Child()

var parent1 = new Parent()

console.log(child1)
console.log(parent1)

这道题中,我在父类的constructor中打印出new.target.name

并且用了一个叫做instance的变量来盛放super()的返回值。

而刚刚我们已经说了,super的调用代表着父类构造函数,那么这边我在调用new Child的时候,它里面也执行了父类的constructor函数,所以console.log(new.target.name)肯定被执行了两遍了(一遍是new Child,一遍是new Parent)

所以这里的执行结果为:

'Child'
Child{}
true

'Parent'

Child{}
Parent{}
  • new.target代表的是new后面的那个函数,那么new.target.name表示的是这个函数名,所以在执行new Child的时候,由于调用了super(),所以相当于执行了Parent中的构造函数,因此打印出了'Child'
  • 另外,关于super()的返回值instance,刚刚已经说了它返回的是子类的实例,因此instance会打印出Child{};并且instance和子类construtor中的this相同,所以打印出true
  • 而执行new Parent的时候,new.target.name打印出的就是'Parent'了。
  • 最后分别将child1parent1打印出来,都没什么问题。

通过这道题我们可以看出:

  • super当成函数调用时,代表父类的构造函数,且返回的是子类的实例,也就是此时super内部的this指向子类。
  • 在子类的constructorsuper()就相当于是Parent.constructor.call(this)

9.5 题目五

(super当成函数调用时的限制)

刚刚已经说明了super当成函数调用的时候就相当于是用call来改变了父类构造函数中的this指向,那么它的使用有什么限制呢?

  • 子类constructor中如果要使用this的话就必须放到super()之后
  • super当成函数调用时只能在子类的construtor中使用

来看看这里:

class Parent {
  constructor (name) {
    this.name = name
  }
}
class Child extends Parent {
  constructor (name) {
    this.sex = 'boy'
    super(name)
  }
}
var child1 = new Child('child1')
console.log(child1)

你觉得这里会打印出什么呢 🤔️?

其实这里啥都不会打印,控制台是红色的。

报了个和7.3一样的错:

Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
    at new Child

这也就符合了刚刚说到的第一点:子类constructor中如果要使用this的话就必须放到super()之后。

这点其实非常好理解,还记得super的作用吗?在constructor中必须得有super(),它就是用来产生实例this的,那么再调用它之前,肯定是访问不到this的啦。

也就是在this.sex = 'boy'这一步的时候就已经报错了。

至于第二点,super被当成函数来调用的话就必须得放到constructor中,在其它的地方使用它就是我们接下来要说的super当成对象使用的情况。

9.6 题目六

(super当成对象来使用时)

super如果当成一个对象来调用的话,唔...那也可能存在于class里的不同地方呀。

比如constructor、子类实例方法、子类构造方法,在这些地方它分别指代的是什么呢?

我们只需要记住:

  • 在子类的普通函数中super对象指向父类的原型对象
  • 在子类的静态方法中super对象指向父类

依靠着这个准则,我们来做做下面👇这道题:

class Parent {
  constructor (name) {
    this.name = name
  }
  getName () {
    console.log(this.name)
  }
}
Parent.prototype.getSex = function () {
	console.log('boy')
}
Parent.getColors = function () {
  console.log(['white'])
}
class Child extends Parent {
  constructor (name) {
    super(name)
    super.getName()
  }
  instanceFn () {
    super.getSex()
  }
  static staticFn () {
    super.getColors()
  }
}
var child1 = new Child('child1')
child1.instanceFn()
Child.staticFn()
console.log(child1)

通过学习《【何不三连】比继承家业还要简单的JS继承题-封装篇(牛刀小试)》我们知道各个方法所在的位置:

  • getName为父类原型对象上的方法
  • getSex为父类原型对象上的方法
  • getColors为父类的静态方法
  • instanceFn为子类原型对象上方法
  • staticFn为子类的静态方法

题目分析:

  • 在使用new Child('child1')创建child1的时候,会执行子类constructor中的方法,因此会执行super.getName(),而依靠准则一,此时的constructor中的第二个super指向的是父类的原型对象,因此此时super.getName()会被成功调用,并打印出'child1'。(第一个super是当成函数来调用)
  • child1创建完之后,执行了child1.instanceFn(),这时候依据准则一,instanceFn函数中的super指向的还是父类的原型对象,因此super.getSex()也会被成功调用,并打印出'boy'
  • staticFn属于子类的静态方法,所以需要使用Child.staticFn()来调用,且依据准则二,此时staticFn中的super指向的是父类,也就是Parent这个类,因此调用其静态方法getColors成立,打印出['white']
  • 最后需要打印出child1,我们只需要知道哪些是child1的实例属性和方法就可以了,通过比较很容易就发现,child1中就只有一个name属性是通过调用super(name)从父级那里复制来的,其它方法都不能被child1"表现"出来,但是可以调用。

所以执行结果为:

'child1'
'boy'
['white']
Child{ name: 'child1' }

"Good for you! 我貌似已经掌握它嘞"

9.7 题目七

(super当成对象调用父类方法时this的指向)

在做刚刚那道题的时候,额,你们就对super.getName()的打印结果没啥疑问吗 🤔️?

(难道是我吹的太有模有样让你忽略了它?)

既然super.getName()getName是被super调用的,而我却说此时的super指向的是父类原型对象。那么getName内打印出的应该是父类原型对象上的name,也就是undefined呀,怎么会打印出child1呢?

带着这个疑问我写下了这道题:

class Parent {
  constructor () {}
}
Parent.prototype.sex  = 'boy'
Parent.prototype.getSex = function () {
  console.log(this.sex)
}
class Child extends Parent {
  constructor () {
    super()
    this.sex = 'girl'
    super.getSex()
  }
}
var child1 = new Child()
console.log(child1)

现在父类原型对象和子类实例对象child1上都有sex属性,且不相同。

如果按照this指向来看,调用super.getSex()打印出的应该是Parent.prototype上的sex'boy'

就像是这样调用一样:Parent.prototype.getSex()

但是结果却是:

'girl'
Child{ sex: 'girl' }

唔...其实扯了这么一大堆,我只是想告诉你:

  • ES6规定,通过super调用父类的方法时,super会绑定子类的this

也就是说,super.getSex()转换为伪代码就是:

super.getSex.call(this)
// 即
Parent.prototype.getSex.call(this)

(别看这里扯的多,但是多看点例子🌰的话理解一定会加深刻的)

而且super其实还有一个特性,就是你在使用它的时候,必须得显式的指定它是作为函数使用还是对象来使用,否则会报错的。

比如下面这样就不可以:

class Child extends Parent {
    constructor () {
        super() // 不报错
        super.getSex() // 不报错
        console.log(super) // 这里会报错
    }
}

9.8 题目八

(了解extends的继承目标)

extends后面接着的继承目标不一定要是个class

class B extends A {},只要A是一个有prototype属性的函数,就能被B继承。

由于函数都有prototype属性,因此A可以是任意函数。

来看看这一题:

function Parent () {
  this.name = 'parent'
}

class Child1 extends Parent {}
class Child2 {}
class Child3 extends Array {}
var child1 = new Child1()
var child2 = new Child2()
var child3 = new Child3()
child3[0] = 1

console.log(child1)
console.log(child2)
console.log(child3)

执行结果:

Child1{ name: 'parent' }
Child2{}
Child3[1]
  • 可以继承构造函数Parent
  • 不存在任何继承,就是一个普通的函数,所以直接继承Function.prototype
  • 可以继承原生构造函数

(其实这里只要作为一个知道的知识点就可以了,真正使用来说貌似不常用)

总结-class继承

我滴个乖乖...

class继承咋有这么多讲的啊。

不过总算是我也说完,你也看完了...

OK👌,来个总结呗。

ES6中的继承:

  • 主要是依赖extends关键字来实现继承,且继承的效果类似于寄生组合继承
  • 使用了extends实现继承不一定要constructorsuper,因为没有的话会默认产生并调用它们
  • extends后面接着的目标不一定是class,只要是个有prototype属性的函数就可以了

super相关:

  • 在实现继承时,如果子类中有constructor函数,必须得在constructor中调用一下super函数,因为它就是用来产生实例this的。
  • super有两种调用方式:当成函数调用和当成对象来调用。
  • super当成函数调用时,代表父类的构造函数,且返回的是子类的实例,也就是此时super内部的this指向子类。在子类的constructorsuper()就相当于是Parent.constructor.call(this)
  • super当成对象调用时,普通函数中super对象指向父类的原型对象,静态函数中指向父类。且通过super调用父类的方法时,super会绑定子类的this,就相当于是Parent.prototype.fn.call(this)

ES5继承和ES6继承的区别:

  • ES5中的继承(例如构造继承、寄生组合继承) ,实质上是先创造子类的实例对象this,然后再将父类的属性和方法添加到this上(使用的是Parent.call(this))。
  • 而在ES6中却不是这样的,它实质是先创造父类的实例对象this(也就是使用super()),然后再用子类的构造函数去修改this

所有继承总结

唔...写到最后我感觉还是要将所有的继承情况来做一个总结,这边只总结出实现方式的伪代码以及原型链思维导图,具体的优缺点在各个模块中已经总结好了就不重复了。

1. 原型链继承

伪代码:

Child.prototype = new Parent()

思维导图:

2. 构造继承

伪代码:

function Child () {
    Parent.call(this, ...arguments)
}

3. 组合继承

伪代码:

// 构造继承
function Child () {
  Parent.call(this, ...arguments)
}
// 原型链继承
Child.prototype = new Parent()
// 修正constructor
Child.prototype.constructor = Child

思维导图:

4. 寄生组合继承

伪代码:

// 构造继承
function Child () {
  Parent.call(this, ...arguments)
}
// 原型式继承
Child.prototype = Object.create(Parent.prototype)
// 修正constructor
Child.prototype.constructor = Child

思维导图:

5. 原型式继承

伪代码:

var child = Object.create(parent)

6. 寄生式继承

伪代码:

function createAnother (original) {
    var clone = Object.create(original);; // 通过调用 Object.create() 函数创建一个新对象
    clone.fn = function () {}; // 以某种方式来增强对象
    return clone; // 返回这个对象
}

7. 混入方式继承

伪代码:

function Child () {
    Parent.call(this)
    OtherParent.call(this)
}
Child.prototype = Object.create(Parent.prototype)
Object.assign(Child.prototype, OtherParent.prototype)
Child.prototype.constructor = Child

思维导图:

8. class中的继承

伪代码:

class Child extends Parent {
    constructor (...args) {
        super(...args)
    }
}

后语

知识无价,支持原创。

参考文章:

你盼世界,我盼望你无bug。这篇文章就介绍到这里。

其实实现继承的方式真的有好多种啊~

我在写之前还考虑要不要把这些情况都写进去,因为那样题目势必会很多。

但是后来我反思了一下自己

"啪!"

"我提莫在想什么?"

霖呆呆我出这些题不就是为了难为你嘛,那我还在顾虑什么~

另外细心的小伙伴数了数总题数,这也就只有31道啊,哪来的48道题。

(我把《封装篇》里的那17道也算进来了,怎么滴...你又不是不知道霖呆呆我是标题党)

现在将题目全部弄懂之后是不是对面向对象以及原型链更加熟悉了呢 😁。

没点赞的小伙伴还请给波赞哦👍,你的每个赞对我都很重要 😊。

喜欢霖呆呆的小伙还希望可以关注霖呆呆的公众号 LinDaiDai 或者扫一扫下面的二维码👇👇👇.

我会不定时的更新一些前端方面的知识内容以及自己的原创文章🎉

你的鼓励就是我持续创作的主要动力 😊.

相关推荐:

《全网最详bpmn.js教材》

《【建议改成】读完这篇你还不懂Babel我给你寄口罩》

《【建议星星】要就来45道Promise面试题一次爽到底(1.1w字用心整理)》

《【建议👍】再来40道this面试题酸爽继续(1.2w字用手整理)》

《【何不三连】比继承家业还要简单的JS继承题-封装篇(牛刀小试)》