原型链的深入理解
想深入理解原型链,首先得知道原型链的基本机制
1. [[Prototype]]
链的属性访问和设置机制
1.1[[Prototype]]
链的属性访问
先浏览下面的代码
var obj = {
a: 2
};
obj.a // 2
var obj2 = {
a: 1,
b: 3
};
obj.__proto__ = obj2;
obj.a; // 2
obj.b; // 3
obj.c; // undefined
我们定义了一个对象obj
,然后通过设置obj
的__proto__
隐式原型,手动地将obj
的原型链委托到obj2
中。
然后我们看看几种情况下的输出结果,如果现在要你书面化表达对象属性的访问原理,你该怎样表达呢?
- 当我们试图引用对象的属性时,JS第一步做的就是:检查这个对象自身有无这个属性,如果有就直接使用它。所以结果就会像我们上面两次访问
obj.a
一样,都输出的是obj
自身的a
属性。 - 如果无法在对象自身找到需要的属性,就会继续访问对象的
[[Prototype]]
链,找到则直接使用,不再查找下去;如果一直找不到,最后就会返回undefined
。结果就像obj.b
和obj.c
一样。
对象这种查找原型链的访问机制,其实被用在很多地方。
使用for ... in
遍历对象的原理和查找[[Prototype]]
链类似,任何可以通过原型链被访问到的属性都会被枚举出来(当然,前提要是对应属性的enumerable
没有被设为false
)。
for(var key in obj) {
console.log("found key: " + key);
}
使用in
操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(in
操作符可以找到enumerable
为false
的属性)。
('b' in obj);
那到底哪里是[[Prototype]]
的尽头呢?
所有未经特别处理的[[Prototype]]
链最终都会指向对象内置的Object.prototype
,正是这个机制,使得我们可以访问对象的很多内置方法,例如.hasOwnProperty()
, .isPrototypeof()
,.toString()
...下文会提到Object.prototype
这个东西。
原型链的这种级级向上的查找方式,被很多人称为委托查找
。
1.2[[Prototype]]
链上的属性设置和属性屏蔽
我们刚才已经知道对象的属性访问机制,JS会在自身与原型链上一层一层地查找直到找到对应的属性为止。
那么,跟访问相对的,如果我们现在给一个对象添加一个新属性或者修改已经有的属性值,会是怎样的机制呢?
var parentObject = {
a: 1,
b: 2
};
var childObject = {};
console.log(childObject); // Object {}
childObject.__proto__ = parentObject;
console.log(childObject); // > Object {}
childObject.c = 3;
childObject.a = 2;
console.log(parentObject); // Object {a: 1, b: 2}
console.log(childObject); // > Object {c: 3, a: 2}
把这段代码复制到浏览器,就可以看到对应的输出
截图反映的过程:刚定义
childObject
时,输出的对象自身是空的,而且没有展开箭头;在设置完继承以后,childObject
自身也是空的,但是有了展开箭头;在设置了childObject.c
和childObject.a
属性以后,再次打印childObject
发现刚刚赋值的两个属性添加在了其自身上,parentObject
上的a
还是原来定义的那个数值。
下面慢慢说明对象属性设置的机制
- 如果属性
c
不是直接存于childObject
上,[[Prototype]]
链就会被遍历,如果[[Prototype]]
链上找不到c
,c
这时就会被直接添加到childObject
上。 - 然而,如果这时属性
a
存在于原型链上层而不存在于childObject
中,赋值语句childObject.a = 2
却不会修改到parentObject
中的a
,而是直接把a
作为一个新属性添加到了childObject
上。
看到这里可能有些人会有疑问:既然两种情况都是赋值到自身,那为何要查找原型链?直接添加到自身不就行了?这个问题下文会提到。
重点来了,赋值完了以后,parentObject
的a
属性没有被修改,而childObject
中新增了一个a
属性,所以现在就会出现一个问题,parentObject
的a
属性再也不能通过childObject.a
的方式被访问到了。
在这里,就发生了属性屏蔽
,childObject
中包含的a
属性会屏蔽原型链上层所有的a
属性,因为childObject.a
总会选择原型链中最底层的a
属性。这个逻辑显而易见,也是理所当然。
但实际上,屏蔽比我们想象中的更复杂。下面我们一起来分析一下a
不直接存在于childObject
中,而是存在于原型链上层时, 执行childObject.a = 2
语句会出现的三种情况。
- 如果在
[[Prototype]]
链上层存在名为a
的普通数据访问属性,并且没有被标记为只读(writable: false
),那就会直接在childObject
中添加一个名为a
的新属性,它是屏蔽属性,这个情况就是上文例子中发生的情况。 - 如果在
[[Prototype]]
链上层存在a
,但它被标记为只读(writable: false
),那么无法修改已有属性或者在childObject
上创建屏蔽属性,严格模式下执行这个操作还会抛出错误。 - 如果在
[[Prototype]]
链上层存在a
并且它被定义成了一个setter
函数,那就一定会调用这个setter
函数。a
不会被添加到anotherObject
,上层的setter
也不会被重新定义。
第 2 种情况对应的演示代码:
var parentObject = {};
Object.defineProperty(parentObject, "a", {
value: 2,
writable: false, // 标记为不可写
enumerable: true
});
var childObject = {
b: 3
};
childObject.__proto__ = parentObject; // 绑定原型
childObject.a = 10;
console.log(childObject.a); // 2
console.log(childObject); // > Object {b: 3}
console.log(parentObject); // Object {a: 2}
第 3 种情况对应的演示代码:
var parentObject = {
set a(val) {
this.aaaaaa = val * 2;
}
};
var childObject = {
b: 3
};
childObject.__proto__ = parentObject;
childObject.a = 10;
console.log(childObject); // ?
console.log(parentObject); // ?
这个代码的输出结果,有请读者自行贴到控制台运行。
以上两种情况的存在,就回答了之前抛的问题:为什么属性设置也需要遍历原型链?
最让人费解的情况,应属情况 2了:我想给一个对象自身设置一个属性,竟然因为它的原型链上有一个writable: false
的同名属性,导致我的对这个对象的赋值失败。
另外,属性屏蔽
还有一个不容易被注意到的影响,仔细阅读下面代码:
var parentObject = {
a: 2
};
var childObject = Object.create( parentObject ); // 这句话相当于先定义一个空对象,再绑定原型
console.log(parentObject.a); // 2
console.log(childObject.a); // 2
console.log(parentObject.hasOwnProperty('a')); // true
console.log(childObject.hasOwnProperty('a')); // false
console.log(parentObject); // > Object {}
childObject.a++; // 这时候迭加的应是原型链上parentObject的a
console.log(parentObject.a); // 2
console.log(childObject) // > Object { a: 3 }
console.log(childObject.a); // 3
console.log(childObject.hasOwnProperty('a')); // true
childObject.a
访问的应是parentObject
上的a
属性,然而执行迭加后却产生了上面这个结果,原型链上的a
并没有被修改到。
原因就是,在执行childObject.a++
时,发生了隐式的属性屏蔽
,因为childObject.a++
实际上就相当于childObject.a = childObject.a + 1
。
以上详细介绍了[[Prototype]]
链的访问和设置机制,接下来我们进一步了解那些和[[Prototype]]
链密切相关的知识。
2. .constructor
的值代表什么?
思考以下代码
function Foo() {
this.name = 'dog';
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true
我们定义了一个构造函数Foo
,然后用Foo
构造了一个对象赋给了变量a
,然后我们判断a.constructor === Foo
,结果为true
,并由此判断a
是由Foo
构造的,因为a.constructor
指向Foo
函数。
然而,以上这个的推理逻辑是非常片面的。
我们先在控制台打印一下a
console.log(a); // Foo {name: "dog"}
可以发现,a
自身并没有.constructor
这个属性。
然后,我们再输出下a.__proto__
…
可以清楚地看到,我们调用的
a.constructor
明显是a.__proto__
上的属性。
实际上,.constructor
的引用同样被委托给了Foo.prototype
,而Foo.prototype.constructor
默认指向Foo
(函数被创建时其constructor
默认会指向自身,这是机制决定的)。
照着这个结论推下来,其实我们在执行a.constructor === Foo
时,得出的结果无论是true
还是false
实际跟a
并无关系,因为判断的并不是a
本身,而是其原型链上的引用。
把a.constructor === Foo
为true
看作是 “a
对象由Foo
构造”非常容易理解,但这只不过是一种虚假的安全感。a.constructor
只是因为默认的[[Prototype]]
链委托指向了Foo
,不能因此坚持认为a
由Foo
'构造'。
为什么不能呢?我们应该要想到一件事,既然.constructor
属性是原型链上的一个属性,那么它会发生属性屏蔽吗?试一试:
a.constructor = '尴尬了';
console.log(a);
console.log(a.constructor);
console.log(a.__proto__);
结果发现,
.constructor
只是一个普通属性,没有writable:false
也不是setter
,完全是一个素人属性。
所以,我们应该如何理解和看待 .constructor
呢?
它并不是什么神秘的属性,Foo.prototype
的.constructor
属性只是Foo
函数在声明时的默认属性。一定程度上可以用.constructor
来判断原型指向,但它并不安全,除了有这个默认行为之外,它和我们平常自定义的属性,再也没什么区别了。
看完这些了再来思考下面的代码:
function Foo() {
this.name = 'dog';
}
Foo.prototype = {
h: 'hhh'
};
var a1 = new Foo();
a1.constructor === Foo; // false
a1.constructor === Object; // true
a1 instanceof Foo // ?
由于这里修改了Foo.prototype
的默认对象(默认对象应该包含一个指向自身的.constructor
和一个.__proto__
),所以Foo.prototype
的.constructor
丢失了,而前面说过,原型链的尽头是Object
,所以最终结果就是引用到了Object.prototype
的.constructor
了。
那么,这个时候如果我们执行a1 instanceof Foo
,结果将是如何呢?
易见,
instanceof
的机制,绝对不是利用.constructor
判断继承的。
3. instanceof
的判断机制
常规用法,判断foo
是否为Foo
的实例
function Foo() {};
var foo = new Foo();
console.log(foo instanceof Foo); // true
在继承关系中的用法,用以判断继承关系
function Aoo() {};
function Foo() {};
Foo.prototype = new Aoo(); // 原型继承
var foo = new Foo();
console.log(foo instanceof Foo) // true
console.log(foo instanceof Aoo) // true
复杂用法,认真对比
console.log(Object instanceof Object); // true #1
console.log(Function instanceof Function); // true #2
console.log(Number instanceof Number); // false #3
console.log(String instanceof String); // false #4
console.log(Function instanceof Object); // true #5
console.log(Foo instanceof Function); // true #6
console.log(Foo instanceof Foo); // false #7
在上面的几个输出中,#1
、#2
、#5
、#6
的输出结果应该是符合我们大多数人的认知的。但是不同于#1
、#2
的是,#3
、#4
、#7
输出的却是false
。显然,这是跟原理instanceof
的判断原理有关的。
在看完instanceof
的ECMA规范以后,可以用下面的伪代码表示出instanceof
的判断原理:
function instance_of(L, R) { //L 表示左表达式,R 表示右表达式
var O = R.prototype; // 取 R 的显式原型
L = L.__proto__; // 取 L 的隐式原型
while (true) { //
if (L === null) // 左表达式为空,即原型链已经到了尽头
return false;
if (O === L) // 这里重点:当 O 严格等于 L 时,返回 true
return true;
L = L.__proto__; // 左表达式(即实例对象),不断迭代原型链对象与右表达式进行对比
}
}
文字化描述上面的伪代码:instanceof
操作符运算时,会将实例对象的原型链迭代与构造函数的.prototype
显式原型进行对比,如果示例对象原型链上有任何一个.__proto__
等于构造函数的.prototype
(即此时原型指向的都是同一个对象),则判真,查找到示例对象的尽头仍未发现相等,则判假。
更简单的定义:A instanceof B
的意思就是,在A
的整个[prototype]]
链中B.prototype
有无出现在其中,有则返回true
,无则返回false
,所以可以用其来判断构造和继承。
4. 调用new
操作符时发生了什么事
点击锚点,一起看回之前的#代码#。
在这里,我们执行了这么一个语句var a = new Foo();
然后我们便可以认为,a
是由Foo()
构造的。但倘若我们这里调用的是var a = Foo();
,或许我们就不会这样认为。
两者的认知异同明显是由new
操作符造成的,原因是很多面向对象的语言,例如java
,都会通过new XXX()
的方式来进行类的实例化。
那其实,Javascript中的new
是什么呢?它的原理是怎么样的?我们通过单一变量的方法就能比对出来:
function SuperType(name) { // 定义了一个超类,供下面的子类继承
this.name = name;
}
function SubType() { // 定义了子类1,继承了超类,无返回值
SuperType.call(this, "Cong1");
this.age = 29;
}
function SubType2() { // 定义了子类2,继承了超类,返回了一个引用类型的值
SuperType.call(this, "Cong2");
this.age = 29;
return { a: 2 };
}
function SubType3() { // 定义了子类3,继承了超类,返回了一个值类型的值
SuperType.call(this, "Cong3");
this.age = 29;
return 3;
}
/* 下面比较有new操作符和无new操作符调用子类的区别 */
var instance1_nonew = SubType();
var instance2_nonew = SubType2();
var instance3_nonew = SubType3();
var instance1_hasnew = new SubType();
var instance2_hasnew = new SubType2();
var instance3_hasnew = new SubType3();
// 依次打印六个变量
console.log(…);
没有
new
操作符的语句,就像我们平常调用函数一样,得到的肯定是函数的返回值,所以前3个_nonew
变量就会得到图示所示的结果。
而看到下面3个_hasnew
变量,行为却有点不同,没有返回值的1_hasnew
就直接构造了一个实例对象,而2_hasnew
和3_hasnew
都是有返回值的,两者的表现却不同了。
下面就说一下在用new
操作符调用一个函数时JS的操作过程:
(1) 首先会新建一个对象:
instance = new Object();
(2) 再给这个对象设置原型链:
instance.__proto__ = SubType.prototype;
(3) 再让 SubType
中的this
指向instance
,执行SubType
的函数体内的语句(上面语句的内容就是定义两个属性并给其赋值)。
(4) 最后,new
调用的函数需要返回什么值还需要判断SubType
的返回值类型:
- 如果是值类型,就丢弃它,直接返回
instance
对象。 - 如果是引用类型,就首选这个引用类型的对象,替换掉
instance
。
跟着这个流程走,就可以得到以上语句的输出结果了。
结语
好了,说的差不多了,本文没有谈及各种继承方式的异同和优劣,只是较为深入地探讨[[Prototype]]
的相关知识。
原型链对新人来说是一个大恶魔,希望大家看完此文,能对原型链有更系统的理解,不再懵圈。