在JavaScript中,原型是用来模仿其他「类」语言继承机制的基础。原型并不复杂,原型只是一个对象。
一、原型对象
1.1 什么是原型对象
每当我们创建了一个函数后,这个函数就拥有了一个prototype属性,这个属性指向一个对象——原型对象。原型对象一开始就有一个constructor属性,指向我们创建的函数。而当我们对这个函数进行构造函数调用创建了一个实例对象,这个对象将会有一个特殊的内置属性[[prototype]],这个属性就是对函数原型对象的引用。构造函数、实例对象和原型对象的关系请看下图:
从图上也看出了,实例与构造函数之间不是直接关联的,而是通过原型的constructor间接关联的。
无论是通过构造函数实例化的对象还是通过对象字面量创建的对象,他们都有对应的原型,对于用对象字面量创建的对象来说,其默认的原型为Object.prototype。实际上,几乎所有对象都有原型。
var Person = function(name){ this.name = name;}var person1 = new Person('person1');var obj1 = { name: 'obj1' };console.log(Object.getPrototypeOf(obj1) === Object.prototype);//trueconsole.log(Object.getPrototypeOf(person1) === Person.prototype);//true复制代码
1.2 原型链
既然我们说了所有对象都有原型,那么就意味着原型也有原型,即原型对象的[[prototype]]属性也指向另一个对象。如此一来就形成了一个原型链,而这个链的尽头是Object.prototype。
var Person = function(name){ this.name = name;}Person.prototype = { constructor:Person, age:23}console.log(person1.age);//23--原型中的属性复制代码
1.2.1 属性屏蔽与设置
假设我们查找myObject.foo属性,而原型链上有多个name属性,会发生什么呢?与作用域链类似,会发生「屏蔽」。根据查找的机制,总会返回第一个找到的同名属性而忽略后续的同名属性。
那么给对象设置属性的时候呢?这时候情况就有点复杂了:
- 如果在[[Prototype]]链上层存在名为foo的普通数据访问属性并且没 有被标记为只读(writable:false),那就会直接在myObject中添加一个名为foo的新 属性,它是屏蔽属性。
- 如果在[[Prototype]]链上层存在foo,但是它被标记为只读(writable:false),那么 无法修改已有属性或者在myObject上创建屏蔽属性。如果运行在严格模式下,代码会 抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
- 如果在[[Prototype]]链上层存在foo并且它是一个setter(参见第3章),那就一定会 调用这个setter。foo不会被添加到(或者说屏蔽于)myObject,也不会重新定义foo这 个setter。 4.如果对象以及[[prototype]]链中都没有foo属性,foo属性将会被添加到myObject上。
简单地说分为四种情况:①有且可写 ②有但只读 ③有且是setter ④没有。只有①和④会在myObject上创建属性。当原型链上有同名只读属性时将会阻止同名属性的新增,如果是setter将会按照setter的逻辑操作。
var Person = function(name){ this.name = name;}//修改原型//Person.prototype.name = 'modifined';//重新连接(替换)原型而不是修改原型Person.prototype = { constructor:Person,//恢复constructor属性,但变成了可枚举 name:'constructor'//默认是可写的}Object.defineProperty(Person.prototype,'age',{ value:23, writable:false//设置为只读})var person1 = new Person('person1');console.log(person1.age);//23person1.name = 'tom';person1.age = 20;//严格模式下将会报错console.log(person1.age);//23——修改被忽略console.log(person1.name);//tom复制代码
在上面的代码中我们替换了Person的原型,注意这个操作将会导致实例失去与原原型的连接,转而关联替换后的原型。也就是说,实例再也无法访问原原型中的属性和方法。替换原型用于模仿继承机制,接下来将会讲到实际上并不是继承。
二、类和构造函数
2.1 没有「类」
JavaScript中并没有类似于Java的那种类,关于「类」的一切都是围绕着函数和原型来展开的。虽然ES6中增加了class特性,但是底层上它还是在操作函数和原型,也算不上是真正的类。我们看看Java的继承和JavaScript的继承:
//javapublic class Foo{ String name = "foo"; public String getName(){ System.out.println(this.name); }}public class Bar extends Foo{}Bar bar1 = new Bar();Bar bar2 = new Bar();Foo foo = new Foo();foo.name = 'modifined';bar1.name = "bar1";bar2.getName();//foo--丝毫不受影响复制代码
//jsvar Foo = function Foo(){}Foo.prototype = { name:'foo', getName:function getName(){ console.log(this.name); }}var Bar = function(){}Bar.prototype = Foo.prototype;//"继承"FooBar.prototype.sayHi = function sayHi(){ //拓展Foo console.log('hi!');}var bar1 = new Bar();var bar2 = new Bar();Foo.prototype.name = 'modifined';bar1.getName();//'modifined'bar2.getName();//'modifined'复制代码
从这两段代码可以看到,在Java中继承意味着子类实例对属性进行了私有化,无论是父类对象还是其他子类对象都无法影响到继承的属性。而在JavaScript中,"子类实例"只是关联了共同的对象,只要那个对象更改了就能马上反映到每一个"子类对象"。所以我觉得不应该用「继承」来形容这种机制了,用「委托」更形象,它们本质上是对象之间的关联。
2.2 没有「构造函数」
JavaScript中也没有构造函数。所有的函数都是一样的,都可以通过函数名+()调用,所有的函数都可以new实例化得到一个对象。如果你认为可以new的就是构造函数,那么JavaScript中所有的函数都是构造函数了。
var foo = function foo(){ console.log('foo');}var obj = new foo();//'foo'console.log(obj.constructor === foo);//truefoo.prototype.constructor = Object;console.log(obj.constructor === Object);//true复制代码
我们可以看到,一个普通的foo函数也可以进行new调用实例化一个对象,可能你还想通过constructor属性去证明他是obj的构造函数。但是可以看到我们随后修改了原型中的constructor属性,obj的"构造函数"也发生改变了。这说明什么?constructor根本就没那么「权威」,它只是原型中的一个属性,默认指向了被构造调用的函数,我们可以自行修改它。
2.3 还是想要「类」
可能有时候写「类」语言写多了一下子还没转过来,还是想去模仿类,怎么办?没关系有办法的——方法可以共用,属性要私有嘛,请看代码:
var father = function father(name,age){ this.name = name; this.age = age;}father.prototype = { constructor:father,//不让这个属性丢失 sayName:function sayName(){ console.log(this.name); }, sayAge:function sayAge(){ console.log(this.age); }}var son = function(name,age){ father.call(this,name,age);//构造函数借用,绑定son的this}son.prototype = Object.create(father.prototype);//新建对象,与原father.prototype隔离//不要这样:son.prototype = father.prototype;//否则后续对son.prototype的拓展都是在修改father.prototypevar son1 = new son('tom',12);var son2 = new son('jack',15);father.prototype.name = 'mike';son1.sayName();//tomson1.name = 'kobee';//屏蔽son2.sayName();//jack复制代码
这里主要有两点:一是构造函数借调、二是隔离原父函数的prototype。借调使得我们可以"借用"父函数的代码,当然我们要绑定this才能实现对新对象赋值;接着我们要隔离父函数的原型,以免我们拓展子函数原型时影响到父函数原型。通过Object.create(...)创建一个空的新对象,这个对象的[[prototype]]指向我们传递的对象,这里是father.prototype。我们对son.prototype的修改都是在修改这个新对象,而不是father.prototype。
好了,类模仿完毕了,不过我觉得如果用类的思维去理解这段代码效率会比较低,如果用函数+原型的概念去理解会更好,你觉得呢?
2.4 寻找原型(委托)对象
那么怎么寻找一个对象的原型呢。如果还是用类的思维去判断的话,要判断一个对象是否是一个"类"的实例就会这么做:
var foo = function foo(){};var obj = new foo();obj instanceof foo;//true复制代码
instanceof操作符的左操作数是一个对象,右操作数是一个函数,实际上它在问:在obj的[[prototype]]链中有没有foo.prototype?在这里明显有~所以我们知道了obj的原型(委托)对象为foo.prototype
但是如果想判断两个对象是否关通过[[prototype]]关联呢?instanceof肯定不行,它的右操作数是函数,而我们只有两个对象。如果你已经理解了JavaScript中对象关联(委托)的这个思想,可以这么做:
var foo = function foo(){};var a = new foo();var b = Object.create(o1);function isRelatedTo(o1, o2) { function F(){} F.prototype = o2; return o1 instanceof F;}console.log(isRelatedTo(b,a));//true复制代码
说实话这样做没有错,o2被关联到F函数的原型上,然后用instanceof操作符就可判断o2是否在o1的[[prototype]]链上。这里对象a的确在对象b的原型链上。
但是我们有必要绕一个大圈吗?或者是说没有直接判断的工具吗?有的:
var foo = function foo(){};var a = new foo();console.log(foo.prototype.isPrototypeOf(a));//true复制代码
a.isPrototypeOf(b)在问:a是否出现在b的[[prototype]]链上?这里与上面不同的是,我们用两个对象进行判断,即进行了原型与对象之间的关联判断。而不是"类"与对象之间关联的判断,