原型链的深入理解

想深入理解原型链,首先得知道原型链的基本机制

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中。

然后我们看看几种情况下的输出结果,如果现在要你书面化表达对象属性的访问原理,你该怎样表达呢?

对象这种查找原型链的访问机制,其实被用在很多地方。

使用for ... in遍历对象的原理和查找[[Prototype]]链类似,任何可以通过原型链被访问到的属性都会被枚举出来(当然,前提要是对应属性的enumerable没有被设为false)。

for(var key in obj) {
  console.log("found key: " + key);
}

使用in操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(in操作符可以找到enumerablefalse的属性)。

  ('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.cchildObject.a属性以后,再次打印childObject发现刚刚赋值的两个属性添加在了其自身上,parentObject上的a还是原来定义的那个数值。

下面慢慢说明对象属性设置的机制

看到这里可能有些人会有疑问:既然两种情况都是赋值到自身,那为何要查找原型链?直接添加到自身不就行了?这个问题下文会提到。

重点来了,赋值完了以后,parentObjecta属性没有被修改,而childObject中新增了一个a属性,所以现在就会出现一个问题,parentObjecta属性再也不能通过childObject.a的方式被访问到了。

在这里,就发生了属性屏蔽childObject中包含的a属性会屏蔽原型链上层所有的a属性,因为childObject.a总会选择原型链中最底层的a属性。这个逻辑显而易见,也是理所当然。

但实际上,屏蔽比我们想象中的更复杂。下面我们一起来分析一下a不直接存在于childObject中,而是存在于原型链上层时, 执行childObject.a = 2语句会出现的三种情况

  1. 如果在[[Prototype]]链上层存在名为a的普通数据访问属性,并且没有被标记为只读(writable: false),那就会直接在childObject中添加一个名为a的新属性,它是屏蔽属性,这个情况就是上文例子中发生的情况。
  2. 如果在[[Prototype]]链上层存在a,但它被标记为只读(writable: false),那么无法修改已有属性或者在childObject上创建屏蔽属性,严格模式下执行这个操作还会抛出错误。
  3. 如果在[[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 === Footrue看作是 a对象由Foo构造”非常容易理解,但这只不过是一种虚假的安全感。a.constructor只是因为默认的[[Prototype]]链委托指向了Foo,不能因此坚持认为aFoo'构造'。

为什么不能呢?我们应该要想到一件事,既然.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_hasnew3_hasnew都是有返回值的,两者的表现却不同了。

下面就说一下在用new操作符调用一个函数时JS的操作过程:

(1) 首先会新建一个对象:

instance = new Object();

(2) 再给这个对象设置原型链:

instance.__proto__ = SubType.prototype;

(3) 再让 SubType中的this指向instance,执行SubType的函数体内的语句(上面语句的内容就是定义两个属性并给其赋值)。

(4) 最后,new调用的函数需要返回什么值还需要判断SubType的返回值类型:

跟着这个流程走,就可以得到以上语句的输出结果了。

结语

好了,说的差不多了,本文没有谈及各种继承方式的异同和优劣,只是较为深入地探讨[[Prototype]]的相关知识。

原型链对新人来说是一个大恶魔,希望大家看完此文,能对原型链有更系统的理解,不再懵圈。