面向对象有一个特征是继承,即重用某个已有类的代码,在其基础上建立新的类,而无需重新编写对应的属性和方法,继承之后拿来即用;
在其他的面向对象编程语言比如Java中,通常是指,子类继承父类的属性和方法;
我们现在来看看,JS是如何实现继承这一个特征的;
要说明这个,我们首先要看看,每个对象都有的一个隐藏属性[[Prototype]]
;
对象的隐藏属性[[Prototype]]
在JS中,每个对象obj
,都有这样一个隐藏属性[[Prototype]]
,它的值要么是null,要么是对另一个对象anotherObj
的引用(不可以赋值为其他类型值),这另一个对象anotherObj
,就叫做对象obj
的原型;
通常说一个对象的原型,就是在说这个隐藏属性[[Prototype]]
,也是在说它引用的那个对象,毕竟二者一致;
现在来创建一个非常简单的字面量对象,来查看一下这个属性:
可以看到,对象obj
没有自己的属性和方法,但是它还有一个隐藏属性[[Prototype]]
,数据类型是Object
,说明它指向了一个对象(即原型),这个原型对象里面,有很多方法和一个属性;
其他的暂且不论,我们先重点看一下,红框的constructor()
方法和__proto__
属性;
访问器属性(__proto__
)
访问[[Prototype]]
从红框可以看到,属性__proto__
是一个访问器属性,有getter/setter特性(这个属性名前后各两个下划线);
问题是,它是用来访问哪个属性的?
我们来调用一下看看:
可以看到,__proto__
访问器属性,访问的正是隐藏属性[[Prototype]]
,或者说,它指向的正是原型对象;
值得一提的是,这是一个老式的访问原型对象的方法,现代编程语言建议使用Object.getPrototypeOf/setPrototypeOf
来访问原型对象;
但是考虑兼容性,使用__proto__
也是可以的;
请注意,__proto__
不能代表[[Prototype]]
本身,它只是其一个访问器属性;
设置[[Prototype]]
正因为它是访问器属性,也即具有getter和setter功能,我们现在可以控制对象的原型对象的指向了(并不建议这样做):
如上图,现在将其赋值为null,好了,现在obj
对象没有原型了;
如上图,创建了两个对象,并且让obj1
没有了原型,让obj2
的原型是obj1
;
看看,此时obj2.name
读取到obj1
的属性name
了,首先obj2
在自身属性里找name
没有找到,于是去原型上去找,于是找到了obj1
的name
属性了,换句话说,obj2
继承了obj1
的属性了;
这就是JS实现继承的方式,通过原型这种机制;
让我们看看下面的代码:
正常的obj2.name = 'Jerry'
的添加属性的语句,会成为obj2
对象自己的属性,而不会去覆盖原型的同名属性,这是再正常不过了,继承得来的东西。只能读取,不能修改(访问器属性__proto__
除外);
现在的问题是,为什么obj2.__proto__
是undefined
?上面不是刚刚赋值为obj1
了吗?
原因就在于__proto__
是访问器属性,我们读取它实际上是在调用对应的getter/setter方法,而现在obj2
的原型(即obj1
)并没有对应的getter/setter方法,自然是undefined
了;
现在综合一下,看下面代码:
为什么最后obj2.__proto__
输出的是hello world
,为什么__proto__
成了obj2
自己的属性了?
关键就在于红框的三句代码:
第一句let obj2 = {}
,此时obj2
有原型,有访问器属性__proto__
,一切正常;
第二句obj2.__proto__ = obj1
,这句调用__proto__
的setter方法,将[[Prototype]]
的引用指向了obj1
;
这一句完成以后,obj2
因为obj1
这个原型而没有访问器属性__proto__
了;
所以第三句obj2.__proto__ = 'hello world'
的__proto__
已经不再是访问器属性了,而是一个普通的属性名了,所以这句就是一个普通的添加属性的语句了;
构造器(constructor)
在隐藏属性[[Prottotype]]
那里,看到其有一个constructor()
方法,顾名思义,这就是构造器了;
类对象与函数对象
- 类对象
在其他编程语言比如Java中,构造方法通常是和类名同名的函数,里面定义了对象的一些初始化代码;
当需要一个对象时,就通过new
关键字去调用构造方法创建一个对象;
那在JS中,当我们let obj = {}
去创建一个字面量对象的时候,发生了什么?
上面这句代码,其实就是let obj = new Object()
的简写,也是通过new
关键字去调用一个和类名同名的构造方法去创建一个对象,在这里就是构造方法Object()
;
这种通过new className()
调用构造方法创造的对象,称为类对象;
- 函数对象
但是,再等一下,JS早期是没有类的概念的,那个时候大家又是怎么去创建对象的呢?
想一下,创建对象是不是需要一个构造方法(即一个函数),本质上是不是new Function()
的形式去创建对象?
对咯,早期就是new Function()
去创建对象的,这个Function
就叫做构造函数;
这种通过new Function()
调用构造函数创造的对象,称为函数对象;
构造函数和普通函数又有什么区别呢?除了要求是用function
关键字声明的函数,并且命名建议大驼峰以外,几乎是没有区别的:
看,我们声明了一个构造函数Cat()
,并通过new Cat()
创造了一个对象tom
;
打印tom
发现,它有一个原型,这个原型和字面量对象的原型不一样,它有一个方法一个属性;
方法是constructor()
构造器,指向的正是Cat()
函数;
属性是另一个隐藏属性[[Prototype]]
,暂时不去探究它是谁;
也就是说,函数对象的原型,是由另一个原型和constructor()
方法组成的对象;
我们可以用代码来验证一下,类对象和函数对象的原型的异同点:
如上所示,创建了一个函数对象tom
和一个类对象obj
;
可以看出:
函数对象的原型的方法constructor()
指向构造函数本身;
函数对象的原型的隐藏属性[[Prototype]]
和字面量对象(Object对象)的隐藏属性,他们两的引用相同,指向的是同一个对象,暂时不去探究这个对象是什么,就认为它是字面量对象的原型即可;
还可以看到,无论是类对象,还是函数对象,其原型都有constructor()
构造器;
这个构造器在创建对象的过程中,具体起了什么样的作用呢?
让我们先看看函数对象tom
的这个原型是怎么来的?我们之前一直都是在说对象有一个隐藏属性[[Prototype]]
指向原型对象,究竟是哪一步,让这个隐藏属性指向了原型对象呢?
函数的普通属性prototype
事实上,每个函数都有一个属性prototype
,默认情况下,这个属性prototype
是一个对象,其中只含有一个方法constructor
,而这个constructor
指向函数本身(还有一个隐藏属性[[Prototype]]
,指向字面量对象的原型);
可以用代码佐证,如下所示:
注意,prototype
要么是一个对象类型,要么是null,不可以是其他类型,这听起来很像隐藏属性[[Prototype]]
,不过prototype
只是函数的一个普通属性,对象是没有这个属性的;
来看下这个属性的特性吧:
可以看到,它不是一个访问器属性,只是一个普通属性,但是它不可配置不可枚举,只能修改值;
它的value
值,眼熟吗?正是构造函数创建的函数对象的原型啊;
它居然还有一个特性[[Prototype]]
,不要把它和value
值里面的属性[[Prototype]]
弄混,前者是prototype
属性的特性,后者是prototype
属性的一个隐藏属性,虽然此刻他们都指向字面量对象的原型,但是前者始终指向字面量对象的原型,后者则始终指向原型(而原型是会变的);
这里也不再去追究为什么它会有这样一个特性了,让我们把重点放在prototype
属性本身;
new Function()的时候发生了什么
事实上,只有在调用new Function()
作为构造函数的时候,才会使用到这个prototype
属性;
我们来仔细分析一下上面代码具体发生了什么:
let tom = new Cat()
这句代码的执行流程如下:
- 先调用
Cat.prototype
属性的特性[[Prototype]]
(我们知道它指向字面量对象的原型)里面的constructor()
构造器,创建一个字面量空对象,当然此时这个对象的隐藏属性[[Prototype]]
也都已经存在了,将这个对象分配给this
指针; - 然后返回
this
指针给tom
,即tom
引用了这个字面量空对象,同时this
指向了tom
; - 然后执行构造函数
Cat()
本身的语句,即this.name = "Tom"
,于是tom
就有了一个属性name
; - 然后将
Cat.prototype
属性值value
,复制(注意,这里是复制,不是赋值,这意味着这里不是传引用,而是传值)给tom
的隐藏属性[[Prototype]]
,即tom.__proto__ = Cat.prototype
;
如果我们用代码去描述上面整个过程,就类似于下面这样:
// let tom = new Cat()的整个具体流程,类似于下面这样
let tom = {}; //创建字面量对象,并赋值给变量tom
tom.name = "Tom"; // 执行Cat()函数
tom.__proto__ = Cat.prototype; // 将Cat的prototype的属性值赋值给tom的隐藏属性[[Prototype]]
现在已经说清楚了new Function()
发生的具体过程,上面代码的输出结果也佐证了我们所说的:
函数对象tom
的原型正是Cat
函数的属性prototype
的值value
,可以看到他们的constructor()
构造器都指向Cat
函数本身,并且tom.name
的值Tom
;
然后我们修改了Cat
函数的prototype
的值value
,Cat.prototype = Dog.prototype
语句将其设置成了Dog
函数的prototype
的值value
;
让我们顺着刚刚说的流程,看看let newTom = new Cat()
的执行过程:
- 先创建字面量空对象;
- 然后赋值给
newTom
; - 然后调用
Cat()
函数本身,即newTom.name = "Tom"
; - 然后执行语句
newTom.__proto__ = Cat.prototype
,而Cat.prototype = Dog.prototype
,所以newTom.__proto__ = Dog.prototype
;
输出结果佐证了我们的执行过程,函数newTom
的原型正是Dog
函数的属性prototype
的值value
,他们的constructor()
构造器都指向了Dog
函数本身,但是newTom.name
的值依然是"Tom";
从上面前后两个输出结果也可以看出来,最后一步的tom.__proto__ = Cat.prototype
确实是复制而不是赋值,否则在Cat.prototype = Dog.prototype
语句之后,tom.__proto__ = Cat.prototype = Dog.prototype
了,但是输出结果表面并没有改变;
现在我们已经明白了函数对象的原型为什么是这个样子的,也明白了函数对象的constructor()
构造器指向了构造函数本身;
现在让我们像下面这样,使用一下函数对象的constructor()
构造器吧:
看上面的代码,我们现在已经知道let tom = new Cat()
的时候都发生了什么,也知道此时tom
的原型的constructor()
构造器指向的是Dog
函数;
所以let spike = new tom.constructor()
这句代码,当tom
去自己的属性里没有找到constructor()
方法的时候,就去原型里面去找,于是找到了指向Dog
函数的constructor()
构造器,所以这句代码就等于let spike = new Dog()
;
通过这段代码,好好体会一下函数对象的构造器吧。
构造函数和普通函数的区别
其实从技术上来讲,构造函数和普通函数没有区别;
只是默认构造函数采用大驼峰命名法,并通过new
操作符去创建一个函数对象;
-
new.target
我们怎样去判断一个函数的调用是普通调用,还是
new
操作符调用的呢?如上所示,通过
new.target
,可以判断该函数是被普通调用的还是通过new
关键字调用的; -
构造函数的返回值
构造函数从技术上说,就是一个普通函数,所以当然也可能有
return
返回值(通常构造函数于情于理都是不会有return
语句的);之前说过
new Function()
的时候的具体流程,我们来看一下:-
先创建一个字面量空对象;
-
将空对象赋值给
tom
; -
执行
Cat()
函数,让tom
有了属性name
;但是
Cat()
函数有return
语句,返回了一个空对象{}
,由tom
接收了,也就是说tom
被覆盖赋值了; -
所以最后
tom
指向的是return
语句的空对象,而不是最开始创建的空对象;
-
字面量对象的原型
new Object()的时候发生了什么
我们刚刚说了new Function()
创建函数对象的时候,具体发生了什么,现在来看看创建类对象的时候,具体发生了什么;
以Object
为例,因为它是一个类,是JS其他所有类的祖先,这一点与Java类似;
我们先看一下Object
的prototype
属性吧,是的,类和函数一样,也有这个属性(注意,是类有这个属性,而不是类的实例即对象有这个属性);
看上图,是不是很眼熟,这不就是字面量对象的原型吗?
是的,如上图所示,就是它;
还记得原型链吧,那么这个原型对象还有原型吗?
如上所示,没有了,指向null了,看样子我们已经走到了原型链的原点了,为了方便,我们就称呼Object.prototype
为原始原型吧;
看看它的特性吧:
和函数的prototype
属性的特性,如出一辙,但是注意,它的writable
属性是false
了,这意味着我们再也无法对这个属性做任何操作了;
这是当然,它可是所有类的祖先,怎么能随意更改呢;
这下我们就能明白new ClassName()
的时候大概流程是什么样子了;
以let obj = {}
为例(其实就是let obj = new Object()
):
- 先调用
Objecet.prototype
属性的特性[[Prototype]]
里面的constructor()
构造器(不再继续深究这个构造器了),创建一个字面量空对象,当然此时这个对象的隐藏属性[[Prototype]]
也都已经存在了; - 然后将这个对象赋值给
obj
,即obj
引用了这对象,同时this
指针也就指向了obj
; - 然后执行构造方法
Object()
本身的语句,就不再进一步去研究这个构造方法了,总之此时obj
已经是一个有着很多内置方法的字面量对象了; - 然后将
Object.prototype
属性值value
,复制给obj
的隐藏属性[[Prototype]]
,即obj.__proto__ = Object.prototype
;
注意,其实流程不完全是上面这样子,与构造函数的流程还有一点点区别,主要是第三步,还有一个构造器的执行,这和类的继承有关系,详细的在后面new className()的时候发生了什么里面具体说明;
更改原始原型
我们刚刚说了,Object.prototype
属性的所有特性都是false
,意味着我们对这个属性无法再做任何操作了;
这只是再说,我们不能对其本身做任何删改的操作了,但是它本身依然是一个对象,这意味着我们可以正常的向其添加属性和方法;
如上图所示,我们向Object.prototype
属性对象里添加了hello()
方法,并且由obj
对象通过原型调用了这个方法;
类对象的原型
我们已经了解了函数对象的原型,和原始原型,再来看看类对象的原型;
我们把这三种放一起做个比较吧:
我们自定义了类classA
,自定义了函数functionA
,并创建了类对象clsA
和函数对象funcA
,以及字面量对象;
可以看出,类对象与函数对象的原型的形式,是一致的,只是各自原型里的constructor()
指向各自的类/函数,即红框部分不同;
而他们的原型的原型则是一致的,和字面量对象的原型一样,都指向了原始原型,即绿框部分相同;
上面的输出结果佐证了这一点;
从这也可以看出来,其他类都是继承自原始类Object
的,只是原型链的长短罢了,最终都可以溯源到原始类Object
;
很显然,类与构造函数,很类似;
类与构造函数的区别
尽管类对象和函数对象有相似的原型,但是不代表类与构造函数就完全一样了,他们之间的区别还是很大的:
-
类型不同,定义形式不同
类名后不需要括号,构造函数名后需要加括号;
类的方法声明形式和构造函数的方法不一样;
打印类和构造函数,类前的类型是
class
,构造函数前的类型是f
,即function
;注意,不能使用
typeof
操作符,它会认为类和构造函数都是function
-
prototype不一样
如上所示,类的方法,会成为
prototype
的方法,但是构造函数的方法不会成为prototype
的方法;也即构造函数的
prototype
始终由constructor()
和原始原型组成,函数对象无法通过原型去调用在构造函数里定义的方法;函数对象如果想要调用
method1()
方法,就不能写成let method1 = function(){}
,而是this.method1 = function(){}
,将其变为函数对象自己的方法; -
prototype的特性不一样
类的
prototype
是不可写的,但是构造函数的prototype
是可写的; -
方法的特性不一样
由于函数对象不能通过原型继承方法,这里只展示类的方法的特性,如上所示,类的方法,是不可枚举的,也即不会被
for-in
语法遍历到; -
模式不同
由于类是后来才有的概念,所以类总是使用严格模式,即不需要显示使用
use strict
,类总是在严格模式下执行;而构造函数则不同,默认是普通模式,需要显示使用
use strict
才会在严格模式下执行; -
[[IsClassConstructor]]
类有隐藏属性
[[IsClassConstructor]]
,其值为true;这要求必须使用
new
关键字去调用它,像普通函数一样调用会出错:但是很显然,构造函数本身就是一个函数,是可以像普通函数一样去调用的;
-
构造器
constructor
由于函数对象不能通过原型继承方法,所以无法自定义构造器;
但是类对象可以继承啊,所以可以自定义构造器并在
new
的时候调用;从图上可以看出,我们是无法去自定义构造函数的构造器的,它依然还是按照我们所说的流程去创建函数对象的;
我们现在看看,类自定义构造器,是怎么按照我们的流程去创建类对象的:
-
先调用
classA.prototype
的特性[[Prototype]]
里的构造器去创建一个字面量空对象; -
将空对象赋值给变量
clsA
; -
然后执行构造方法
classA()
本身的语句;首先添加了属性
outterName
;然后又遇到了
constructor()
方法(注意该构造器与classA.prototype.constructor
不是同一个东西),于是又执行了这个构造器的语句,添加了属性innerName
;
由此我们可以得出,类在创建类对象的时候,流程依然是我们所述的流程;
但是在遇到类里面的同名方法
constructor()
时候,不会将其作为原型方法,而是会立即运行该构造器;另外,像
outterName
这样的属性,不会成为prototype
的属性,也就是说,类只有定义的方法(除了constructor
构造器)会进入prototype
的属性,成为原型被继承; -
new className()的时候发生了什么
上面刚刚描述了类自定义构造器之后,创建对象是一个什么样的流程;
现在来仔细理解一下类的构造器,事实上,如果我们不显式自定义构造器,类也会默认提供一个下面这样的构造器:
constructor() {
super();
}
这里的super()
实际上就是在调用其父类的构造方法(注意不是指父类的构造器constructor()
,而是指父类自身);
用代码来验证一下吧:
我们先来看一下let c = new classC()
的时候,具体流程是什么样的吧:
- 首先调用
classC.prototype
属性的特性[[Prototype]]
(它总是指向原始原型),创建一个字面量空对象; - 然后将其赋值给变量
c
; - 然后执行构造方法
classC()
的语句,通常会有添加对象的属性和方法的语句,这里没有; - 接着查看是否显式声明了
constructor()
构造器(如果没有就提供一个默认的构造器),这里有,于是立即执行这个构造器;- 首先是
super()
,实际上就是执行构造函数classA()
的语句,于是添加了属性nameA
; - 然后是
this.nameB = 'C'
,于是添加了属性nameC
;
- 首先是
- 最后,将
classC.prototype
的value
值,复制给c
的隐藏属性[[Prototype]]
,即c.__proto__ = classC.prototype
;
整个完整流程如上所示;
现在来试着对着流程看看let b = new classB()
吧:
- 首先创建字面量空对象;
- 赋值给变量
b
; - 执行
classB()
的语句,添加了属性nameB
; - 没有构造器,提供默认的构造器,执行
super()
即执行classA()
的语句,于是添加了属性nameA
; - 最后,复制
b
的原型为classB.prototype
的value
值;
输出结果也验证了我们所说的;
操作原型的现代方法
之前已经说过,通过__proto__
属性去操作原型的方法,是历史的过时的方法,实际上并不推荐;
现代JS有以下方法,供我们去操作原型:
-
Object.getPrototypeOf(obj)
此方法,返回对象
obj
的隐藏属性[[Prototype]]
; -
Object.setPrototypeOf(obj, proto)
此方法,将对象
obj
的隐藏属性[[Prototype]]
指向新的对象proto
; -
Object.create(proto, descriptors)
此方法,创建一个空对象,并将其隐藏属性
[[Prototype]]
指向proto
;同时,可选参数
descriptors
可以给空对象添加属性,如下所示:
原型链与继承
现在应该已经理解了原型是一个什么样的概念,以及如何去访问原型;
正如继承有儿子继承父亲,父亲继承爷爷一样,有这样一个往上溯源的关系,原型也可以这样往上溯源,这就是原型链的概念;
用代码去理解一下吧:
我们定义了三个对象A/B/C,并且设置C的原型是B,B的原型是A;
读取C.nameA
的时候,首先在C自己的属性里去找,没有找到;
于是去原型B的属性里去找,没有找到;
再去B的原型A的属性里去找,找到并输出;
可以看C展开的一层层结构,可以很清晰的看到原型链的存在;
由此也可以看出,JS是单继承的,同Java一致;
但是正常的继承,肯定不是这样手动去设置对象的原型的,而是自动去设置的;
在JS中,继承的关键字也是extends
,也是描述类的父子关系的;
上面代码,classC
继承classB
,而classB
继承classA
;
所以classC
的对象,继承了他们的属性,便有了三个属性nameA/nameB/nameC
,这也说明,属性是不放在原型里的,而是会在创建对象的时候,直接成为classC
的属性;
classC
的原型,有一个属性一个方法,方法是constructor()
构造器指向自己,属性是另一个原型;
注意,打印出来的原型后面标注的classX
,原型指的是对象,不是类,所以classC
的原型不是指classB
这个类本身,而是指其来源于classB
;
紫色框:对象c
的原型,即c.__proto__ == classC.prototype
;
橘色框:classB.prototype
,即对象c
的原型的原型c.__proto__.__proto__ == classB.prototype
;
绿色框:classA.prototype
,即对象c
的原型的原型的原型c.__proto__.__proto__.__proto__ == classA.prototype
;
红色框:Object.prototype
,也即原始原型c.__proto__.__proto__.__proto__.__proto__ == Object.prototype
;
这是一条完整的原型链,从中也能看出继承是什么样的一个形式;