做前端开发有段时间了,遇到过很多坎,若是要排出个先后顺序,那么JavaScript的原型与对象绝对逃不出TOP3。
如果说前端是海,JavaScript就是海里的水
一直以来都想写篇文章梳理一下这块,为了加深自己的理解,也为了帮助后来者尽快出坑,但总觉缺少恰当的切入点,使读者能看到清晰的路径而非生硬的教科书。最近看到句话“好的问题如庖丁之刃,能帮你轻松剖开现象直达本质”,所以本文以层层探问解答的方式,试图提供一个易于理解的角度。
现在的软件开发,很少有不是面向对象的,那么JavaScript如何创建对象?
一、 创建对象的方法
在传统的面向对象编程语言(如:C++,Java等)中,都用定义类的关键字class
,首先声明一个类,然后再通过类实例化出对象实例。但在JavaScript中若实现这样逻辑的对象创建,需要先定义一个代表类的构造函数,再通过new
运算符执行构造函数实例化出对象。
对象字面量
var object1 = { name: "object1" }
构造函数法
var ClassMethod = function() { this.name = "Class" } var object2 = new ClassMethod() // 这种方式创建的对象字面量 var object3 = new Object({ name: "object3" })
这里提到的
new
运算符,后面会详述Object.create(proto)
创建一个新对象,使用入参proto
对象来提供新创建的对象的__proto__
,也就入参对象时新创建对象的原型对象。var Parent = { name: "Parent" } var object4 = Object.create(Parent)
想要明白JavaScript原型继承的幺蛾子,势必要搞清楚原型对象、实例对象、构造函数以及原型链的概念和关系,接下来我尽量做到表述地结构清晰,言简意赅。
二、原型继承
暂时搁置一下原型链,我先讲清楚其余三个概念的门门道道,如果你手边有纸笔最好,没有在脑中想象也不复杂。
- 画一个等边三角形,从顶点顺时针为每个角编号(1)、(2)、(3)
- 其中(1)旁边标注“原型对象”,(2)构造函数,(3)实例对象
- 从(2)构造函数(如上节例中的
ClassMethod
)指向(3)实例对象(上节例中的object2
)画一条带箭头的线。线上注明new
运算符,表示var object2 = new ClassName()
。 - 从(2)构造函数指向(1)原型对象画一条带箭头的线。线上标注
prototype
,表示该构造函数的原型对象等于ClassName.prototype
。(函数都有prototype
属性,指向它的原型对象) - 从(3)实例对象指向(1)原型对象画一条带箭头的线。线上标注
__proto__
,表示该实例对象的原型对象等于object2.__proto__
,结合第4步,便有ClassName.prototype === object2.__proto__
。 - 从(1)原型对象指向(2)构造函数画一条带箭头的线。线上标注
constructor
,表示该原型对象的构造函数等于ClassName === object2.__proto__.constructor
。
关于JavaScript函数与对象自带的属性有一句需要画重点的话:所有的对象都有一个__proto__
属性指向其原型对象,所有的函数都有prototype
属性,指向它的原型对象。函数其实也是一种对象,那么函数便有两个原型对象。由于平时更关注对象依据__proto__
属性,指向的原型对象所构成的原型链,为了区分函数的两个原型,便将__proto__
所指的原型对象称作隐式原型,而把prototype
所指向的原型对象称作显示原型。
看到这里你应该已经知道原型对象、实例对象、构造函数以及原型链是什么了,但是对于为什么是这样应该还比较懵,因为我也曾如此,用以往类与对象,父类与子类的概念对照原型与实例,试图想找出一些熟悉的关系,让自己能够理解。
人们总是习惯通过熟悉的事物,类比去认识陌生的事物。这或许是一种快速的方式,但这绝对不是一种有效的方式。类比总会让我们轻视逻辑推理
三、从instanceof
再看原型链
语法格式为object instanceof constructor
,从字面上理解instanceof
,是用来判断object
是否为constructor
构造函数实例化出的对象。但除此之外,若构造函数所指的显示原型对象constructor.prototype
存在于object
的原型链上,结果也都会为true
。
字面理解多少会有些偏差,请及时
查阅MDN文档
原型链就是JavaScript相关对象之间,由__proto__
属性依次引用形成的有向关系链,原型对象上的属性和方法可以被其实例对象使用。(这种有向的父子关系链就具有了实现类继承的特性)
四、new
运算符
new Foo()
执行过程中,都发生了什么?
以下三步:
- 创建一个继承自
Foo.prototype
的新对象。 - 执行构造函数
Foo
,并将this
指针绑定到新创建的对象上。 - 如果构造函数返回一个对象,则这个对象就是
new
运算符执行的结果;如果没返回对象,则使用第一步创建出的新对象。
为了直观的理解,这里自定义一个函数myNew
来模拟new
运算符
function myNew(Foo){
var tmp = Object.create(Foo.prototype)
var ret = Foo.call(tmp)
if (typeof ret === 'object') {
return ret
} else {
return tmp
}
}
五、实现继承
在ES6中,出现了更为直观的语法糖形式:
class Child extends Parent{}
,但这里我们只看看之前没有这种语法糖是怎么实现的。我一直有一个体会:
要想快速的了解一个事物,就去了解它的源起流变。
首先定义一个父类Parent,以及它的一个属性name:
function Parent() {
this.name = 'parent'
}
接下来如何定义一个继承自Parent
的子类Child
:
构造函数方式
function Child() { Parent.call(this) this.type = 'subClass' // ... 这里还可定义些子类的属性和方法 }
这种方式的缺陷是:父类原型链上的属性和方法不会被子类继承。
原型链方式
function Child() { this.type = 'subClass' } Child.prototype = new Parent()
这种方式弥补了子类没法继承父类原型链上属性和方法的缺陷,与此同时又引入一个新的问题:父类上的对象或数组属性会引用传递给子类实例。
比如父类上有一个数组属性arr
,现通过new Child()
实例化出两个实例对象c1
和c2
,那么c1
对其arr
属性的操作同时也会引起c2.arr
的改变,这当然不是我们想要的。组合方式(综合1,2两种方式)
function Child() { Parent.call(this) this.type = 'subClass' } Child.prototype = new Parent()
虽然解决了上述问题,但明显看到这里构造函数执行了两遍,显然有些多余。
组合优化方式
function Child() { Parent.call(this) this.type = 'subClass' } Child.prototype = Parent.prototype
这种方式减少了多余的父类构造函数调用,但子类的显示原型会被覆盖。此例中通过子类构造函数实例化一个对象:
var cObj = new Child()
,可以验证出实例对象的原型对象,是父类构造函数的显示原型:cObj.__proto__.constructor === Parent
,显然这种方式依旧不很完美。终极方式
function Child() { Parent.call(this) this.type = 'subClass' } Child.prototype = Object.create(Parent.prototype) Child.prototype.constructor = Child
实例对象的
__proto__
属性值总是该实例对象的构造函数的prototype
属性。这里关于构造函数的从属关系存在一个易混淆的点,我多啰嗦几句来试图把这块讲清楚:还记的上面我们画的那个三角形么?三个角分别代表构造函数、实例对象和原型对象,三条有向边分别代表new
,__proto__
,prototype
,根据__proto__
有向边串联起来链便是原型链。要解释清楚构造函数的从属关系,我们先在上面所画的原型链三角形中的每个三角形中,添加一条有向边:从原型对象指向构造函数,这表示原型对象有一个
constructor
属性指向它的构造函数,而该构造函数的
prototype
属性又指向这个构造函数,于是便在局部形成了一个有向环。现在一切都协调了,唯独还有一点,就是原型链末端的实例对象构造函数的指向,不论通过
new
运算符还是通过Object.create
创建出来的实例对象的constructor
属性,都和其原型对象的constructor
相同。所以为了保持一致性便有了上面那句Child.prototype.constructor = Child
,为的是在你想要知道一个对象是由哪个构造函数实例化出来的,可以根据obj.__proto__.constructor
获取到。多继承
function Child() { Parent1.call(this) Parent2.call(this) } Child.prototype = Object.create(Parent1.prototype) Object.assign(Child.prototype, Parent2.prototype) Child.prototype.constructor = Child
利用
Obejct.assign
方法将Parent2
原型上的方法复制到Child
的原型。