JavaScript之原型链的解读

JavaScript中,原型链作为一个基础,老生长谈,今天我们就来深入的解读一下原型链。

本章主要讲的是下面几点,可以根据需要进行阅读:

  • 函数与对象
  • 对于prototype的认识
  • 对于<code>__proto__</code>的的认识
  • prototype和<code>__proto__</code>的关系
  • instanceof操作符到底是怎么穿梭的
  • [[prototype]]链属性的访问
  • [[prototype]]链上的属性设置与属性屏蔽
  • 关于prototype中的constructor属性
  • 当我们在使用new的时候到底发生了什么
  • 应用:两种继承的设计模式
  • 函数与对象到底是什么关系

1. 函数与对象

我们都知道,JavaScript中,一切都是对象,函数也是对象,数组也是对象,但是数组是对象的子集,而对于函数来说,函数与对象之间有一种“鸡生蛋蛋生鸡”的关系,我们会在最后进行总结。

  1. 所有的对象<b>都是</b>由Object继承而来,而Object对象却是一个函数。
  1. 对象<b>都是</b>由函数来创建的。

对于上述的第一点,前半部分会在后面的解释中讲到,而对于后半部分,在控制台中输入typeof Object,显然输出的是function

上述的第二点,我们可以看一下下面的例子。

var obj = { a: 1, b: 2}
var arr = [2, 'foo', false]

表面上来看,好像不存在函数创建对象,而实际上,以上的过程是这样子的:

var obj = new Object()
obj.a = 1
obj.b = 2

var arr = new Array()
arr[0] = 2
arr[1] = 'foo'
arr[2] = false
//typeof Object === 'function'  
//typeof Array === 'function'

2. 对于prototype的认识

每一个<b>函数</b>都有一个属性叫做prototype,它的属性值是一个对象,在这个对象中默认有一个constructor属性,指向这个函数的本身。如下图:

3. 对于<code>__proto__</code>的的认识

<code>__proto__</code>是隐式原型,通常也写作[[prototype]]每一个<b>对象</b>都有一个这样的隐藏属性,<b>它引用了创建这个对象的函数的prototype。</b>(注:并不是所有浏览器都实现了对于对象的隐式原型的提供!)

需要注意的是,函数也是对象,自然它也有__proto__

可见,<code>__proto__</code>和prototype并不相同(有例外,存在指向相同的情况),那两者有什么样的联系呢,继续往下看。

4. prototype和<code>__proto__</code>的关系

前面我们讲到了两个很重要的点:

  1. 每一个<b>函数</b>都有一个属性叫做prototype,它的属性值是一个对象。
  1. 每一个<b>对象</b>都有一个隐式原型<code>__proto__</code>,它引用了创建这个对象的函数的prototype

所以,下面让我们来看一段代码看看两者之间的关系:

var o1 = new Object()
var o2 = new Object()

上面的Object作为构造函数创建了两个对象o1o2

看一下图解:

结合上面的两句话:

  1. function Object在这里作为一个构造函数,毫无疑问它是一个函数,那么自然有一个prototype属性。
  2. <code>__proto__</code>引用了创建这个对象的函数的prototype。于是,o1o2对象都是由function Object 创建出来的,那么自然的,它就指向(引用)了创建它们的函数(Object)的prototype属性。

那我们再来看如果是一个普通的构造函数而不是内置的呢?一样的道理,这里我们就不再赘述。

function foo() {}
var f1 = new foo()
var f2 = new foo()

<b>注意:这里有一个特例!</b>
对于Object.prototype来说,它的__proto__null,这是一个<b>特例。</b>

同时,我们要注意图里面有一个Foo.prototype,它的__proto__指向了Object.prototype。这个是因为:<b>一切的对象都是由Object继承而来</b>,也就是说Foo.prototype这个对象也是由Object构造的,所以说Foo.prototype.__proto__指向(引用)了Object.prototype,这个也符合我们上面所述的<b>每一个<b>对象</b>都有一个隐式原型<code>__proto__</code>,它引用了创建这个对象的函数的prototype</b>。

到这里,似乎prototype__proto__关系已经很明朗的,但是你有没有发现还有一个坑,我们从头到尾都在围绕function Object()这个东西,那我们会不会考虑🤔这个鬼东西是从哪里来的呢?

难道凭空出现?显然,不存在的!毕竟,存在即合理。

那函数是怎么创建出来的呢?我们继续来看一段代码,这段代码可能你很少见,但是如果你读过红宝书函数的一章,你一定不会感到陌生!

function foo(a, b) {
  return a + b
}
console.log(foo(1, 2)) //3

var boo = new Function('a', 'b', 'return a + b') //Function大写
console.log(boo(1,2)) //3

以上,第二种写法出现了大写的Function。(不推荐这么写。因为这是一种创建动态函数的写法,原因参考:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function

从上面的代码可知,函数是被Function创建的。

所以,function Object是由Function创建的,那么Object.__proto === Function.prototype也就不言而喻了,于是就有下面的一张图。

在这张图中,FooObject这两个函数的__proto__就是指向Function.prototype了。

这里又有一个<b>特例</b>!(相信我,这是最后一个特例了🙄~)

没错,你会发现一个坑?为什么Function.__proto__指向了Function.prototype🤔?这又是什么操作?

我们来理一下思路:函数是由Function创建的,那么Function也是一个函数,那么它有没有可能是自己搞自己的呢😎?

答案是肯定的。

于是,函数是由Function创建的,那么Function由自身创建,所以Function.__proto__就指向了创建它的函数(也就是自己)的prototype

那最后,把Foo.prototypeObject.prototypeFunction.prototype__proto__连起来,就可以得到下面这一张图。(红色标识即为特例)

最后,再次总结一下:

  • 所有的对象<b>都是</b>由Object继承而来,对象<b>都是</b>由函数来创建的。
  • 每一个<b>函数</b>都有一个属性叫做prototype,它的属性值是一个对象。
  • 每一个<b>对象</b>都有一个隐式原型<code>__proto__</code>,它引用了创建这个对象的函数的prototype

5. instanceof操作符到底是怎么穿梭的

既然讲到了__proto__prototype,那么密不可分的就是instanceof操作符了。

对于 A instanceof B来说,它的判断规则是:沿着A__proto__这条线来找,同时沿着B的prototype这条线来找,如果两条线能找到同一个引用,即同一个对象,那么就返回true。如果找到终点还未重合,则返回false

所以,不熟悉的🙊就可以通过上面的那个总图来进行判断到底是返回true还是false

那么,我们来举个🌰:

function fn {}
var f1 = new fn();
console.log(f1 instanceof Object);//true
console.log(f1 instanceof fn);//true

显然,沿着链条穿梭成立!

再来看几个喜闻乐见的:

console.log(Object instanceof Function);//true
console.log(Function instanceof Object);//true
console.log(Function instanceof Funciton);//true

所以,instanceof操作符机制就不言而喻了。

6. [[prototype]]链属性的访问

众所周知,JavaScript中的继承是通过[[prototype]]链来实现的(也叫原型链)。

看下面代码:

function foo (){}
var f1 = new foo()
f1.a = 10

foo.prototype.a=1
foo.prototype.b=2

console.log(f1.a) //10
console.log(f1.b) //2
console.log(f1.c) // undefined

访问一个对象的属性时,先在这个对象自身属性中查找,如果没有,再沿着__proto__这条链向上找,这就是[[prototype]]链(原型链),如果一直找不到,那么最后会返回undefined

那如何区分这个属性是实例对象中的(比如说上面new出来的对象f1)还是通过[[prototype]]链找到的呢?

答案就是hasOwnProperty,同时,在for...in循环中,要注意该遍历会遍历出包括原型的所有属性。

我们可以对上面代码的ab进行检测:

function foo (){}
var f1 = new foo()
f1.a = 10

foo.prototype.a=1
foo.prototype.b=2

console.log(f1.a)//10
console.log(f1.b)//2

console.log(f1.hasOwnProperty('a')) //true
console.log(f1.hasOwnProperty('b')) //false

在这里,本身f1是没有hasOwnProperty方法的,并且,foo.prototype也是没有的。那其实它是从Object.prototype中继承而来的。可见,<b>[[prototype]]链最终的位置就是Object.prototype</b>。以下是Object.prototype的一些属性和方法。

7. [[prototype]]链上的属性设置与属性屏蔽

先来看一下这段代码:

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}

这是一个很简单的属性设置,但是其实里面存在着[[prototype]]链属性设置的机制🙃。

如下:

  • 如果属性c不是直接存于childObject上,[[Prototype]]链就会被遍历,如果[[Prototype]]链上找不到cc这时就会被直接添加到childObject上。
  • 如果这时属性a存在于原型链上层而不存在于childObject中,赋值语句childObject.a = 2却不会修改到parentObject中的a,而是直接把a作为一个新属性添加到了childObject上。

于此同时,也就发生了属性屏蔽😭。

此时会发现,赋值完了以后,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: true),那么无法修改已有属性或者在childObject上创建屏蔽属性,严格模式下执行这个操作还会抛出错误。

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}
  1. 如果在[[Prototype]]链上层存在a并且它被定义成了一个setter函数,那就一定会调用这个setter函数。a不会被添加到childObject,上层的setter也不会被重新定义。
var parentObject = {
    set a(val) { //这是set函数,相当于赋值
      this.aaaaaa = val * 2;
    }
};
var childObject = {
    b: 3
};
childObject.__proto__ = parentObject;
childObject.a = 10;
console.log(childObject); //Object {b: 3, aaaaaa: 20}
console.log(parentObject); //Object {}

另外,属性屏蔽还有一种很容易被忽略的情况😩:

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 {a:2}

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

8. 关于prototype中的constructor属性

上面有介绍说到constructor是函数原型的一个属性,指向函数的本身。

function Foo() {
  this.name = 'dog';
}

Foo.prototype.constructor === Foo; // true

var a = new Foo(); 
a.constructor === Foo; // true

a.constructor === Foo的时候,其实这时候并不能够说明a是由Foo构造而成的。实际上,a.constructor的引用是被委托给了Foo.prototype(本身a自身是没有这个属性的),所以才会出现等价的情况,而并不能说明a是由Foo构造而成的。

而对于constructor来说,这个属性其实就是[[prototype]]上一个简单的默认属性,没有writable:false也不是setter,只是有一个默认行为。

继续看下面的代码:

function Foo() {
  this.name = 'dog';
}
Foo.prototype = {
  h: 'hhh'
};

var a1 = new Foo();

a1.constructor === Foo; // false
a1.constructor === Object; // true

a1 instanceof Foo //true

这里由于Foo.prototype的默认属性被清空了,所以constructor不存在,可是__proto__构成的原型链是不变的,所以a1.constructor的引用被委托到Object.prototype.constructor,所以第一个返回false,第二个返回true

所以,我们应该怎么对待constructor这个属性呢😶?

它并不是什么神秘的属性,Foo.prototypeconstructor属性只是Foo函数在声明时的默认属性。一定程度上可以用.constructor来判断原型指向,但它并不安全,除了有这个默认行为之外,<b>它和我们平常自定义的属性,再也没什么区别了。</b>

9. 当我们在使用new的时候到底发生了什么

JavaScript中,构造函数只是一些使用new操作符时被调用的函数,它们并不会属于某个类,也不会实例化一个类。所以,实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

当使用new来调用函数时,会自动执行以下操作:

  • 创建一个全新的对象
  • 这个新对象会被执行[[prototype]]连接
  • 这个新对象会绑定到函数调用的this
  • 如果函数没有返回其他对象,那么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(…);

得到的结果是:

instance1_nonew
undefined
instance2_nonew
> Object {a: 2}
instance3_nonew
3
instance1_hasnew
> SubType {name: "Cong1", age: 29}
instance2_hasnew
> Object {a: 2}
instance3_hasnew
> SubType3 {name: "Cong3", age: 29}

没有new操作符的语句,就像我们平常调用函数一样,得到的肯定是函数的返回值,所以前3个_nonew变量就会得到图示所示的结果。

而看到下面3个_hasnew变量,行为却有点不同,没有返回值的1_hasnew就直接构造了一个实例对象,而2_hasnew3_hasnew都是有返回值的,两者的表现却不同了。

根据上面所说的原理再来分析一下这个过程:

  1. 首先新建一个对象:
    var instance = new Object()
  2. 给这个对象设置[[prototype]]链:
    instance.__proto__ = SubType.prototype
  3. 绑定this,将SubType中的this指向instance,执行SubType中的语句进行赋值。
  4. 返回值,这里要根据SubType的返回类型来判断😷:
  • 如果是一个引用类型(对象),那么就替换掉instance本身的这个对象。(如:instance2_hasnew
  • 如果是值类型,那么直接丢弃它,返回instance对象本身。(如:instance3_hasnew

10. 应用:两种继承的设计模式

在JavaScript中没有类的概念,使用的是原型继承。而有两种常见的设计模式,一种是面向对象模式,而另外一种是对象关联模式。

在使用的过程中,都用到了Object.create(),它会创建一个新对象并把它关联到我们指定的对象,也就是进行[[prototype]]连接。

  • “原型”面向对象风格
function Foo(who) {
    this.me = who
}
Foo.prototype.identify = function() {
    return "I am " + this.me
}
function Bar(who) {
    Foo.call(this,who)
}
Bar.prototype = Object.create(Foo.prototype)

Bar.prototype.speak = function() {
    console.log("Hello, " + this.identify() + ".")
}

var b1 = new Bar("b1")
var b2 = new Bar("b2")

b1.speak() //Hello, I am b1.
b2.speak() //Hello, I am b2.

关系图如下:

  • 对象关联风格
Foo = {
    init: function(who) {
        this.me = who
    },
    identify: function() {
        return "I am " + this.me
    }
}
Bar = Object.create(Foo)
Bar.speak = function() {
    console.log("Hello, " + this.identify() + ".")
}

var b1 = Object.create(Bar)
b1.init("b1")
var b2 = Object.create(Bar)
b2.init("b2")

b1.speak() //Hello, I am b1.
b2.speak() //Hello, I am b2.

关系图如下:

以上两种继承的设计,明显发现第二种更加的简洁。
在“原型面向对象风格”中,需要时刻的留意prototype的情况,[[prototype]]“游走”于函数的prototype之间。
而对于“对象关联风格”,它只关心一件事,那就是对象之间的关联情况,不将方法写于函数的prototype上。

虽然实现的原理是相同的,但是不同的思维方式,更利于理解,代码风格更为友好🤗。

11. 函数与对象到底是什么关系

其实,这个问题也是困扰了我很久😪。

我们都知道:

  1. 一切对象继承于Object。(当然Object.prototype除外)
  2. Object.prototype.__proto__指向了null
  3. 对象都是由函数创建的。

以上,看似并没有什么用,那现在我们来缕一下思路。

  1. (这里先不考虑Object.prototype)一切对象继承于Object ,所以说,对象的原型链(__proto__)最终的位置应该是Object.prototype。所以一切的老大应该是Object.prototype
  2. Object.prototype.__proto__指向了null。既然__proto__的指向是创建这个对象的函数原型,可是这里Object.prototype.__proto__却指向了null。那么,唯一可能就是Object.prototype是由JavaScript引擎创造出来的。
  3. 所以,<b>最终[[prototype]]链的位置应该是null而不是Object.prototype</b>。
  4. 对象都是由函数创建的。(这里的对象同样是不考虑Object.prototype的)也就是说,所有的对象都是由Function构造出来,那么他们的[[prototype]]都应该经过Function.prototype
  5. 于是,引用类型等构造函数(如:Array()Object()等)以及普通的函数对象,甚至Function,他们的__proto__应该是指向Function.prototype
  6. Function.prototype__proto__指向了哪里?由第一点可知,当然是指向了Object.prototype,所以Function.prototype就是老二。

所以,简而言之:

  • 首先有的应该是Object.prototype,它是由JavaScript引擎创造出来的。
  • 紧接着才有Function.prototype,并把它的__proto__连接到了Object.prototype
  • 接下来,将各种内置引用类型的构造函数的__proto__连接到了Function.prototype
  • 执行Function.__proto__连接到Function.prototype的操作。
  • 执行Object.__proto__连接到Object.prototype的操作。
  • 最后再是对FunctionObject实例的挂载。

注:以上为个人的见解,欢迎指正😉。


😉这是一条可爱的分割线😉。

以上,就是本次博客的全部内容(终于结束了)感谢你耐心的阅读😉
第一次写博客,如有理解错误的地方,师请改正😳。

参考资料:
书籍:《你不知道的JavaScript(上卷)》
博客:http://www.cnblogs.com/wangfupeng1988/p/4001284.html
博客:http://www.yangzicong.com/article/1

推荐阅读更多精彩内容