在介绍我们的主角之前,我们先来回顾一下经典的类和继承的设计模式。
如果你对这块内容比较熟悉的,可以直接跳过这篇,看(下)篇,不过也可以看一遍,因为这块会讲得比较细,相信你会有新的收获。
先上代码
function Foo() {} Foo.prototype.print = function() { console.log('print !') } var a = new Foo(); a.print(); // print !
JavaScript中每一个对象都有一个内置属性[[Prototype]],它是不可枚举的,在绝大多数浏览器中,可以通过__proto__来访问这个内置属性,这个内置属性有什么用?当我们访问一个对象的属性的时候,会经历哪些步骤?(这也是一道经典面试题)
完整的答案是:
1,会触发[[Get]]操作;(这里补充一下,[[Get]]不是一个内置属性,而是一个内置操作,更像是执行一个内置函数调用[[Get]](),有[[Get]]就有对应的[[Put]],[[Get]]是获取属性值的操作,[[Put]]是设置或创建属性的操作,很多人会把它们和getter和setter搞混,这个后面会提到)
2,先检查该对象是否有该属性的访问描述符getter,如果有,则返回getter返回的值,否则继续;
3,在对象中查找是否有名称相同的属性,如果有则返回这个属性值,否则继续;
4,如果对象中没有,则会继续访问该对象的[[Prototype]]链,也就是我们常说的原型链,一层一层查找,找到则返回,否则继续;
5,[[Prototype]]链的最底层是Object.prototype,找到这一层就不会再继续了,如果还是没有该属性,则返回undefined,查找结束!
这里提一下第二点,比较容易被忽视:
function A() {this.name = 'A'} var a = new A(); ...... a.name = 'a'; console.log(a.name);
这段代码很多人都知道会打印a,而不是A,原因是发生了遮蔽效应。
但是思考一下,上面这段代码一定会打印'a'吗,不一定:
function A() {this.name = 'A'} var a = new A(); Object.defineProperty(a, 'name', { get: function() { return 'b' } }); a.name = 'a'; console.log(a.name); // b
无论给a.name赋值多少,获取出来都是b,因为属性检索会优先考虑getter。
扯得有点远了。。。。
我们再来看这段代码,function A() {},很多人误以为这个A是一个类,因为看到了new关键字,所以有点类似于执行了类的构造函数,这里的new在调用的同时会默认执行以下三步:
1,绑定this;
2,如果“构造函数”里没有返回一个对象,那么则自动返回一个对象;
3,将实例的[[Prototype]]指向该“构造函数”的原型;
前两个就不说了,重点在第三个,由上面的代码我们得到:
a.__proto__ === A.prototype; // true
关于JavaScript中的“构造函数”,还有一个特性:
每一个函数在创建的时候,会自动创建一个原型(prototype)属性,它会自动指向该函数的原型对象,而这个原型对象会自动获得一个constructor(内置且不可枚举)属性,又指向这个函数自身。而该函数生成的实例也会有constructor属性,指向该函数自身。
A.prototype.constructor === A; // true
a.constructor === A; // true
constructor是什么?很多人认为constructor就是它的字面意思,构造器属性,指向构造它的函数,也就是我们上面提到的构造函数,那我们来验证一下:
function A() {} A.prototype = {}; var a = new A(); a.constructor === A; // false a.constructor === Object; // true
如果constructor是构造器属性,那么由构造函数A创建的实例a的构造器属性应该指向A才是,但是现在指向了一个看似和它没关系的Object,那显然这个是一个错误的观点!
constructor并非指向构造函数
那为什么实例的constructor会指向构造函数A呢,为什么重写了A.prototype之后,指向会变呢?我们再来看重写prototype的代码:
A.prototype.constructor === A; // false A.prototype.constructor === Object; // true
我们发现,A.prototype的constructor也指向了Object,那我们知道了,回顾一下之前提到的,访问一个对象的属性的时候会经过的步骤,我们发现,最后一步会去该对象的原型链上去找,a本身是没有这个constructor属性的,但是a.__proto__,也就是A.prototype上有,A.prototype的constructor是指向A自身的,所以a.constructor也会指向A,当我们重写了A.prototype的时候,它原来拥有的constructor属性就没有了,更不存在指向A了,所以a.constructor一定不指向A!
那为什么指向Object,那既然这一层原型链上没有找到,就会继续去上一层,上一层就是Object.prototype,它是有这个constructor属性的,并且指向自身Object,找到了!
因此a.constructor的指向就是Object!
我们再来思考一个问题:
function A() {} var a = new A(); A.prototype = {}; var b = new A(); A.prototype.constructor === A; // false b.constructor === A; // false a.constructor === A; // true a.__proto__ === A.prototype; // false a.__proto__.constructor === A; // true
上面两行就不说了,已经讲过了为什么,但是为什么a.constructor还是指向A呢? a.__proto__已经不是A的原型了,为什么还有一个constructor属性指向A呢?
有点晕了,别着急,一个一个看:
a.constructor指向谁,实际上就是看a.__proto__.constructor指向谁,为了解释这一块细节,先来看一段代码:
function f(x) { x.push(1); x = []; x.push(2); } var a = []; f(a); a;
先思考一下,上面的a是多少?
答案是1,为什么不是2,明明传入f函数的是一个引用类型的值,一个空数组对象,函数体的参数实际上进行了引用复制,a复制给了x,复制的是引用。
关键就是x = [];这里强行修改了x的引用地址,此时的x不再是指向原先x指向的那个值了,关键点来了:
一个引用无法更改另一个引用的指向!
这个很关键,虽然x指向变了,但是原先的x指向依然还在,也就是说原先a的指向也还在,也许此时函数体内最终的x改变了值,但是我们不关心这个,我们关心的是当初复制引用给它的a。
我懂了。
回到我们上面思考的问题,关键点在于A.prototype = {}; 此时A.prototype的指向变了,但是原先A.prototype的指向没变,所以先前创建的a.__proto__的指向也没变,它依然是指向原来指向的地方,只不过现在那个地方已经不再是A.prototype[old]了,准确的说,其实还是A.prototype,只不过当我们再次访问A.prototype的时候,实际上已经被重写了,因此访问的是一个全新的A.prototype[new],所以之前的那个A.prototype[old].constructor还是指向A,因为我们只是重写了A.prototype,而没有重写A,所以A还是一样的引用地址,因此a.__proto__.constructor === A成立!!!
而后面新生成的实例b或者c或者更多,对于他们而言,就不存在什么A.prototype[old] 或[new]了,就一个[new],引用地址就一个,所以都是新的,因此不需要这么纠结!
现在很多检测工具,像eslint这种,对于构造函数名非大写时会报一个error,所以很多人误以为JavaScript中有类这一概念,也有构造函数这一概念,实则是没有的,对于引擎来说,调用构造函数和调用普通函数没什么区别,而特殊是在搭配new关键字之后,它就由一个普通函数调用变成了一个构造函数调用(也就有了上面说的那三步)。
好了,现在清楚了,虽然JavaScript中没有类的概念,但是人们却极力去模仿类的继承,原因是[[Prototype]]这个内置属性的存在可以很轻松地实现类的继承设计模式。
我们来看一下最经典的原型继承的风格代码:
function Foo(name) { this.name = name; } Foo.prototype.sayName = function() { console.log(this.name) } function Bar(name, age) { Foo.call(this, name); this.age = age; } Bar.prototype = Object.create(Foo.prototype); Bar.prototype.sayAge = function() { console.log(this.age); } var a = new Bar('Yan', 24); a.sayName(); // Yan a.sayAge(); // 24
这里的Object.create的polyfill代码就是:
if (!Object.create) { Object.create = function(o) { function F() {} F.prototype = o; return new F() } }
es6也有一个方法,实现了同Object.create的方法,叫Object.setPrototypeof(),上面的代码等同于:
Object.setPrototypeof(Bar.prototype, Foo.prototype);
但是Object.setPrototypeof相比于Object.create有一个优势,就是它会自动绑定constructor的指向,因为重写了子类的prototype之后,constructor指向会变掉,所以需要我们手动“指正”,但是Object.setPrototypeof会自动纠正这一问题,所以建议使用这个方法。
我们再来回顾一下之前说的[[Prototype]]属性,我们通过一个对象的[[Prototype]]属性,能访问到它的构造函数的原型上的属性或方法,并不是因为我们在创建这个对象实例的时候会复制一遍原型上所有的属性和方法,而是用一种委托行为,建立起一个关系,而[[Prototype]]的机制就是指对象中的一个内部链接引用另一个对象,这两个对象之间的关系,就是委托。
本篇最后,我们做一个小结:
JavaScript中没有类的概念,也没有构造函数的概念,虽然有new关键字的构造函数调用,很像传统面向类语言中的类初始化和类继承,但是其实并不是这样,它只是用一种委托的行为来建立两个对象之间的联系。既然没有类的概念,那这种极力去模仿类继承的设计模式会存在一些问题,既然我们有委托这么一个行为,那我们应该针对委托这一特性,来设计一种JavaScript独有的模式,(下)篇中我们会具体细讲这种面向委托的设计思想以及实现方式。
补充一个很有意思的点,无意间发现的:
任何一个对象(包括函数)都有[[Prototype]]内置属性,除了两个特殊的(一个是null,另一个是Object.create(null)),那么像一些最顶层的构造函数,比如Function,String,Number,Boolean,Object这些,他们的[[Prototype]]指向哪里呢?这个我们可以在控制台打印出来看下,但是在打印前可以思考一下,而不是立马去看答案
不过记住一点,如果一个对象有[[Prototype]]内置属性,那么它最后一层一定是Object.prototype,再后面就是null了。
end