原型与原型链
1.前置知识
在聊原型和原型链之前,我们需要知道几个概念:构造函数和普通函数、函数对象和实例对象。
(1) 构造函数和普通函数
1 |
|
在函数声明的时候,无法判断一个函数是否为构造函数。只有使用new
操作符创建对象时,调用的函数才叫做构造函数。
两者区别:
作用不同
普通函数的作用自然是执行函数体内的代码以实现某种功能。构造函数的作用是用来创建对象。
调用方式不同
普通函数直接调用:
函数名()
构造函数是为了创建对象,所以需要使用
new
关键字来调用:new 函数名()
书写习惯不同
为了区别普通函数和构造函数,构造函数的函数名一般大写开头。
this指向不同
普通函数中的this指向window对象,构造函数中的this则是指向它创建的对象。
写法不同
构造函数中一般不写
return
。
所以上述代码中test
为普通函数,Test
为构造函数。
(2) 函数对象和实例对象
以下面的代码为例:
1 |
|
在JavaScript中,函数也是对象,所以从这个角度讲,上述代码中Student
构造函数也称为函数对象,而通过new
操作符创建的对象,称为实例对象,上述代码中st1
就是通过Student
构造函数创建的实例对象。
不仅仅是构造函数,任何函数从对象的角度讲或者当做对象去使用时,都可以称为函数对象。
这里所说的函数对象不是JS内置的Function对象。
2.原型
什么是原型?我们还是通过上面的代码来讲解:
1 |
|
假设现在我们需要给st1
添加一个speak
方法,可以这么做:
1 |
|
但假如我要给每一个Student
创建出来的实例对象都添加上这个speak
方法,可以这么做:
1 |
|
这种方式每创建一个实例对象,就会创建一个新的speak
方法,也就是说每个实例对象的speak
方法都是唯一的。
但这种方式有个问题:如果我们创建出来的实例对象越来越多,在内存中占用的空间是不是也越来越多。
我们仔细想想,这个speak
方法需要每个实例对象都唯一吗?能不能共用一个?答案是可以共用,因为每个实例对象的speak
方法做的事情都是一样的。
那么我们要怎么修改呢?可以这么做:
1 |
|
这时候的内存空间:
这种方式看上去很不错,但是仍然会带来一些问题:
_speak
是在全局作用域下声明的,可能会污染全局作用域(变量冲突)。- 随着全局作用域下的函数声明越来越多,全局作用域会变得越来越臃肿。
因此,JS就提出了原型的概念。每一个函数对象身上都有一个prototype
属性,该属性指向一个对象。
1 |
|
这个对象叫做原型对象,每个函数对象都有自己的原型对象。
不管是构造函数还是普通函数,都有
prototype
属性,只不过在普通函数上这一个属性没有什么作用。所以在讲原型时,我们比较强调构造函数,下文中的函数对象也都指构造函数。
另外,创建实例对象时,实例对象身上会有一个属性__proto__
,它指向创建该实例对象的函数对象的原型对象。同一个函数对象创建的每一个实例对象,它们的__proto__
属性都指向同一个原型对象。
1 |
|
这就意味着,如果我们在函数对象的原型对象中添加属性和方法,该函数对象创建的实例对象也可以访问到。JS就是这么做的,JS中对于对象属性和方法的访问顺序是从对象本身到对象的原型,如果对象本身中就有要找的属性或方法,直接使用对象本身中的属性或方法,否则从对象的原型中找。比如:
1 |
|
那么为了实现我们上面所说的对于speak
方法的操作,可以这么做:
1 |
|
再补充一点,原型对象在函数声明时一同创建,然后挂载到函数对象的prototype
属性上,另外原型对象上有一个constructor
属性,指向创建它的函数对象,这样两者的关系就紧密结合起来了。
完整关系图:
总结:
- 函数对象上有一个
prototype
属性,该属性称为显示原型。它指向一个对象,该对象称为原型对象。 - 函数对象创建的每个实例对象都有一个
__proto__
属性 ,该属性称为隐式原型。它指向创建该实例对象的函数对象的原型对象。 - 原型对象上有一个
constructor
属性,它指向创建该原型对象的函数对象。
所以原型就是一个对象,它的作用是所有实例对象共享属性和方法。
一般是共享方法,因为属性一般都是实例对象独有的,不同实例对象他们的属性不同,比如上面的
st1
和st2
,他们的name
属性各不相同,不应该把name
放入Student
原型对象中。
有些人可能会问:为什么一定要通过函数对象的
prototype
往原型对象上添加方法和属性,能不能通过实例对象的__proto__
去添加呢?答案是不行的,实例对象的__proto__
属性的意义在于为对象的查找机制提供一个方向或者说一条线路,但它是一个非标准属性,因此实际开发中不可以使用这个属性,这也是为什么称它为隐式原型的原因。
3.原型链
理解了原型之后,原型链理解起来就比较容易。我们还是从上面的例子入手:
1 |
|
我们会发现上述第九行代码,会输出[object Object]
,也就是说st1
能够执行toString()
方法。但是st1
这个实例对象本身和它__proto__
所指向的原型对象上都没有这个方法。那这个方法是哪来的呢?
我们知道,Object
对象是所有对象的“祖先”,既然原型对象也是一个对象,那它也不例外,所以其实原型对象是Object对象的实例对象。既然是实例对象,那它身上就会有一个__proto__
属性,指向Object
对象的原型对象。
我们简化一下这张图:
接着我们来看下Object
对象的原型对象上有没有toString()
方法:
果然有,这说明了什么?说明JS对于对象属性和方法的查找规则是这样的:
先从对象本身去找,如果找不到,就去该对象的原型对象上去找,如果还是没有找到,就去原型对象的原型对象上去找……但原型对象不可能永无止境,Object
对象的原型对象__proto__
属性值为null
,也就是说查找到Object
对象的原型对象就结束了,如果还是没有找到,则返回undefined
。
像图上这条被__proto__
链接起来的链式关系,就叫原型链。它直接反应了JS对于对象属性和方法的查找顺序。
4.补充
理解完了原型和原型链,我们再想一想,所有的函数都可以通过new Function()
的方式创建,那么也就是说所有的函数都是Function
对象的实例对象。既然是实例对象,那它身上就会有一个__proto__
属性,并且这一属性指向Function
对象的原型对象。
那么Function
对象是谁的实例对象呢?
我们刚说所有函数都是Function
对象的实例对象,而Function
对象也是构造函数,那么不就成了Function
对象创建了Function
对象?其实不必太过纠结这一点,因为Function
对象是JS的内置对象,在脚本还没开始执行,就已经创建好了。所以Function.__proto__ === Function.prototype
,记住这一个特殊情况就好了。
Object
对象和Function
对象都是函数对象,也就是构造函数。只不过它们两个都是JS的内置对象,所以没有特别标注函数对象。
这里有一张非常流行的关于原型的图,就是我们刚刚讲到的所有内容:
5.继承
继承是面向对象编程的一个概念。继承可以使子类具有父类的属性和方法,同时可以在子类中重新定义或追加属性和方法。继承是类与类之间的关系。
但是JavaScript并没有类的概念,只有对象。那怎么会有继承呢?因为JavaScript是非常灵活的,我们可以通过构造函数和原型来模拟类的继承。
如果熟悉面向对象编程的语言,我们会发现JS中的构造函数和类有点相似。在ES6中,JS也提出了类的概念,但ES6中的类其实是一个语法糖,它的本质还是构造函数,感兴趣的可以自行了解。
(1) 通过构造函数继承属性
假如我们现在有一个Father
构造函数,并且希望有另一个构造函数继承Father
构造函数,我们可以这么做:
1 |
|
但是这样还不够,因为这样Son
构造函数只继承了Father
构造函数的属性,没有继承Father
构造函数的方法:
1 |
|
接下去,我们就需要让Son
构造函数继承Father
构造函数的方法。
(2) 通过原型对象继承方法
由上面的图像结合原型链的知识,我们可以发现,如果让Son
原型对象链接到Father
原型对象,那我们Son
构造函数创建的实例对象,是不是就可以使用Father
原型对象上的方法了?
原型对象的链接是依靠__proto__
属性去操作的,所以最简单的方式:
1 |
|
但是我们说过__proto__
属性不能直接使用。那有没有什么办法同样能实现上述代码的效果?我们可以这么做:
1 |
|
这样Father
构造函数创建的实例对象就变成了Son
构造函数的原型对象,Son
构造函数new
出来的实例对象,也指向这一个Father
构造函数创建的实例对象。从而达到我们预先的目的。
除此之外还差一步,我们原先说过原型对象中有一个constructor
属性指向构造函数,我们修改了构造函数的原型对象,那就需要将新的原型对象中的constructor
属性指向构造函数。
1 |
|
上述这种继承方式叫做组合继承。
参考: