JavaScript:原型(prototype) - Journing
source link: https://www.cnblogs.com/Journing/p/17000813.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
面向对象有一个特征是继承,即重用某个已有类的代码,在其基础上建立新的类,而无需重新编写对应的属性和方法,继承之后拿来即用;
在其他的面向对象编程语言比如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 = {}; //创建字面量对象,并赋值给变量tomtom.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
;
这是一条完整的原型链,从中也能看出继承是什么样的一个形式;
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK