【读书笔记】之Javascript prototype(JS原型)深入理解
最近比较疑惑这个问题,看过博客、论坛、知乎等关于JS的prototype各种讨论,也仔细阅读了手上几本书关于prototype的介绍,包括《Javascript语言精髓与编程实践》、《Javascript面向对象编程指南》、《Javascript权威指南》、《Javascript高级程序设计》、《悟透Javascript》等等。
个人觉得还是《Javascript语言精髓与编程实践》介绍的最为到位,《面向对象指南》介绍得有些含糊:
◆ 当我们对原型对象执行完全替换时,可能会触发原型链中某种异常(exception)。
◆ prototype.constructor属性是不可靠的。
首先,是否是“异常”有待探讨(个人觉得这才是正确的原型继承),其次如果你真正理解了prototype并正确的使用它,constructor属性是不可靠的吗?
下面,来深入介绍一下JS的prototype:
一、 什么是原型?
(见《语言精髓》中“原型继承的基本性质”)原型其实也是一个对象实例。原型的含义是指,如果构造器有一个原型对象A,则由该构造器创建的实例( Instance)都必然复制自A。
这段话,句句经典,如果真的看懂了,会解决你很多的疑惑(什么才有原型?原型继承为什么会触发“某种异常”?…)
二、 原型“复制”:
这里的“复制”就存在了多种可能性,由此引申出了动态绑定和静态绑定等等问题。但我们先不考虑“复制”如何被实现,而只需先认识到:由于实例复制自对象A,所以实例必然继承了A的所有属性、方法和其它性质。“原型也是对象实例”是一个最关键的性质,这是它与“类继承体系”在本质上不同。
构造复制?写时复制?还是读遍历?那么JS的prototype继承属于哪种“复制”?
写时复制:在系统中指明实例等同于它们的原型,这样在读取的时候,只需要顺着指示去读原型即可。当需要写实例对象的属性时, 我们就复制一个原型的映象出来,并使以后的操作指向该映象就行了。优点是只在第一次写的时候会用一些代码来分配内存,并带来一些代码和内存上的开销。但此后这种开销就不再有了,因为访问映象与访问原型的效率是一致的。不过对于经常写操作的系统来说,这种法子并不比上一种法子经济。
读遍历: 把写复制的粒度从原型变成了成员。这种方法的特点是:仅当写某个实例的成员时,将成员的信息复制到实例的映象中。这样一来,在初始构该对象时的局面仍与写时复制一样。但当需要写对象属性(例如obj.value=10 )时,会产生一个名为value 的属性值,放在实例对象的成员列表中。
这张表是否与原型一致并不重要,只需要:
● 规则1:保证在读取时被首先访问到即可。
● 规则2:如果在对象中没有指定属性,则尝试遍历对象的整个原型链,直到原型为空(null) 或找到该属性。
很显然,JS采用了第三种方案“读遍历”。
三、 Prototype 继承:
// 构造器声明 function MyObject() {} function MyObjectEx() {} //原型链 MyObjectEx.prototype = new MyObject(); //创建对象实例 obj1 = new MyObjectEx(); obj2 = new MyObjectEx();
上面代码是我们常用到的继承方法,而且文章开头讲到的“异常(exception)”也出于此:
输出obj1.constructor的结果是什么?MyObject!
的确,你没看错通过MyObjectEx实例化构造的对象的constructor竟然指向MyObject,正应证了我们上面介绍的原型“复制”。
先来看看MyObject的prototype是什么
MyObject.prototype是一个constructor属性指向MyObject本身的对象。所以就有我们常听说的“构造器的原型的constructor指向构造器本身”这样一个说法。 那么为什么MyObject.prototype.constructor要等于MyObject呢?
原型就相当于一个模板,通过构造器的加工,来产生所需的实例。所以实例就和模板长的很像,在模板中定义了constructor为构造器本身,那么,从模板“复制”出来的实例的constructor就也是构造器本身了,这不正是constructor本意么。
MyObject().prototype.constructor=MyObject; //原型constructor为MyObject Obj=new MyObject(); //构造产生实例 Obj.constructor; 输出: MyObject(); //实例的constructor也就为MyObject
这样“输出obj1.constructor的结果是什么?MyObject!”这个“异常”就很正常了,
MyObjectEx.prototype = new MyObject ();
MyObjectEx.prototype是MyObject的实例,自然MyObjectEx.prototype中的constructor也是从MyObject.prototype“复制”过来的,理所当然的就是MyObject。
所以通过MyObjectEx的“模板”产生的实例obj1的constructor就是MyObject,这是正确的原型“复制”,但这并不是我们想要的,因为obj是从MyObjectEx构造来的constructor应该是MyObjectEx,不然大家伙都不知道孩子的亲爹到底是谁了,MyObject是它爷爷好不!
那么如何解决这个异常呢?
很多人说,这简单,不是说“构造器的原型的constructor指向构造器本身”么,那我就重置回去就好了。原型继承的代码就变成下面这种了:
● 法一:原型继承要求的“复制行为”其实已经正确的发生了,问题是出在我们给MyObjectEx 的原型时,应该“修正” 该原型的构造器值。为此,一般的建议(例如《JavaScript 权威指南》)是这样处理:
MyObjectEx.prototype = new MyObject(); MyObjectEx.prototype.constructor = MyObjectEx; //告诉大家MyObjectEx才是孩儿他爹 //创建对象实例 obj1 = new MyObjectEx(); obj1.constructor输出: MyObjectEx //孩子终于知道他爹是谁了
但是这么一来,通过obj1 就没法访问到MyObject的原型了。这就是我们所说的原型链“断裂”(oh no! 族谱没上文了!祖宗找不到了!这子孙真是不孝。)
可能你会问,既然原型链“断裂”了,那为什么obj1里还能输出从原型中“复制”来的属性呢?这就是靠系统自己维护的另一条原型链(内部原型链)了FF和chrome中可以通过“__proto__”访问到,IE中不能通过这个访问到。(记住,是内部原型链,别和我说既然有这个内部原型链,那就没断裂啊,,,政府专用食堂,你能随便进去吃饭么?所以在外部是不能这么访问的)
于是乎你承认了,政府的专用食堂平民还进不去,再于是,通过obj1就没法修改MyObject的原型了(祖宗啊,子孙真的没法把你写入族谱了)。 当然,通常情况下,我们也用不着通过obj1修改MyObject,只需要在MyObjectEx的prototype中增删改就可以了,那么通过MyObjectEx构造的实例依然有我们改过的东西。这种情况下,法一就够你用的了。
● 法二:保持原型的构造器属性,而在子类构造器函数内初始化实例的构造器属性——这看起来有点令人发晕,但代码不过如下:
//方法二:正确维护constructor ,以便回溯原型链 function MyObject() {} function MyObjectEx() { this.constructor = arguments.callee; // or, this.constructor = MyObject Ex; } //原型链 MyObjectEx.prototype = new MyObject(); Obj1=new MyObject(); //构造产生实例
原型的constructor 则指向MyObject()。虽然你会发现它的效率较低—— 因为每次构造实例时都要重写constructor 属性—— 但是它是唯一有效的方法。
通过法二,我们看到obj1.constructor属性正常了,而且我们也可以通过obj1访问到MyObject的原型了(终于可以认祖归宗了)。
● 法三:
再来看看《Javascript面向对象编程指南》中讲到的一种对法一改造的方法:通过往实例中添加uber属性来维持原型链的完整性。
function extend(Child, Parent){ var F=function(){}; F.prototype=Parent.prototype; Child.prototype=new F(); //这个地方为什么不用Child.prototype=new Parent();呢? Child.prototype.constructor=Child; Child.uber=Parent.prototype; } //那么创建实例就需要变成这样了, extend(MyObjectEx, MyObject); extend(obj1, MyObjectEx);
自然,可以看出,法三通过uber属性指向Parent.prototype实现了原型链的完整。Obj1就可以通过uber访问修改Parent的prototype了。
关于Child.prototype=new F();这个地方为什么不用Child.prototype=new Parent();而是去构建一个prototype等于Parent.prototype的构造器,应该是为了只继承Parent.prototype的属性,而不受Parent构造器的影响。比如如下:
function Parent(){ this.name= "二毛" } Parent.prototype.name="大毛"; function Child(){} Child.prototype=new Parent(); obj1=new Child(); ->obj1.name 输出”二毛”;
(皇阿玛呀,“大毛”这名字是太后老佛爷给起的,你又给改成“二毛”了,您还是给儿臣改回来吧,,,),结果皇阿玛不理你!好吧,只好自己改回去了,于是不得不又重复添加一条语句:
obj1.name="大毛";
当然,这种方法也需要综合考虑,如果obj1不想受到父级构造器的影响,那就用法三,如果需要原封的继承自父级,那么就用法二。
自此,JS的prototype就讲解完成了。
四、 Prototype 的一些相关问题:
下面讲解一些大家经常碰到的问题的疑惑,
一、__proto__到底是什么?
前面已经讲到过prototype继承有两条原型链:“构造器原型链”、“内部原型链”。
从表象来看,维护原型链似乎全是构造器的事情,因为只有构造器(函数对象)有一个名为prototype 的成员。但事实上并非完全如此。从更前面的讨论来看,我们知道一个实例(注意是实例)至少应该拥有指向原型的__proto__(指向其构造器的原型) 属性,这是JavaScript中的对象系统的基础。不过这个属性是不可见的,我们称之为“内部原型链”,以便和构造器的prototype 所组成的“构造器原型链”(亦即是我们通常所说的“原型链”)区别开来。
构造器通过了显式的prototype 属性构建了一个原型链,而对象实例也通过内部属性__proto__构建了一个原型链。由于obj1. __proto__是一个不可访问的内部属性所以没有办法从obj(指代所有MyObjectEx 的实例)开始访问整个原型链。解决这个问题的法子是通过obj.constructor。
接下来会有人问:既然用户代码只需要正确维护constructor 属性就可以回溯原型链,那么实例的内部属性__proto__有什么价值呢?换而言之,内部原型链有什么价值呢?
这个问题与原型继承的实质有关,也与面向对象的实质有关。面向对象的继承性约定:子类与父类具有相似性。在原型继承中,这种相似性是在构造时决定的,也就是由new()运算内部的那个“复制”操作决定的。
如果我们改变了“继承”的定义,说:子类可以与父类不具备相似性。那么我们就违背了对象系统的基本特性。因此,你会发现子类实例有一个特性:不能用delete 删除从父类继承来的成员。也就是说,子类必须具有父类的特性。
即使你可以重写成员,改变它的实现,但在界面(Interfaces)上必然与父类一致。为了达成这种一致性,且保证它不被修改。JavaScript 使对象实例在内部执有这样一个proto 属性,且不允许用户访问。这样,用户可以出于任何目的来修改constructor 属性,而不用担心实例与父类的一致性。
简单地说, 内部原型链是JavaScript 的原型继承机制所需的。而通过constructor 与prototype 所维护的构造器原型链,则是用户代码需要回溯时才需要的。如果用户无需回溯,那么不维护这个“原型链”,也没有关系——例如上一小节中用于修正constructor 属性的“方法一”。
二、再来看第二个问题(摘自知乎):
function A() {this.name = "A"} function B() {this.name = "B"} //这个时候要使 B 继承 A,用 B.prototype = new A(); B.prototype.constructor = B;我想知道,这里为啥是 B.prototype = new A(); 而不是 B.prototype = A or B.prototype = A.prototype?
这个问题,知乎上有两个回答,但对于“而不是 B.prototype = A”目测都没有回答正确。先不要看下面的,你能回答出来吗?
看出什么毛病来了没有?
obj.name竟然是A,为嘛不是“大毛”呢?而且为嘛obj.email是undefined呢? “B.prototype = A”,当你设置B.prototype = A时,A是什么?一个函数!B的prototype是一个函数,如果没有在B中重设name,当访问obj.name时,返回的就是A的函数名称“A” 而不是“大毛”。
同样,访问obj.email时,根本不是访问的A.prototype.email,而是A这个函数的email属性,话说函数有email属性么?函数对外只有个函数名而已,自然就是undefined了。
关于“为什么不是B.prototype = A.prototype”,看看下图就知道了:
通过A和B构造的实例name值竟然都是“二毛”,为嘛A的实例不是叫“大毛”呢? 那是因为当设置B.prototype = A.prototype,那么着两个对象的引用是完全一致了,这样的话,修改B的原型就会直接把A的原型给污染掉!
所以:必须用B.prototype = new A();这个方法,B.prototype = new A();创建了一个新的对象{},并且继承了A的原型,这是一个新对象,不是和A同一引用,所以不会污染A。
三、为什么typeof Function.prototype的结果是function?(来自知乎)
先来看看Stackoverflow上被采纳的回答:
15.3.4 Properties of the Function Prototype Object
The Function prototype object is itself a Function object (its [[Class]] is "Function") that, when invoked, accepts any arguments and returns undefined.
在ECMAScript 5中定义的:
15.3.4 Function 原型对象的属性
Function 原型对象本身就是一个Function对象(它的[[Class]] 是“Function”),这意味着,当被调用时,接受传递的任何参数并且返回undefined
Function.prototype is the prototype from which all function objects inherit - it might contain properties like call and apply which are common to all Function instances; the implementations you checked were consistent in that it is implemented as a function object itself (as some pointed out, the ECMA specification requires this)
Funcion是所有函数对象的构造器
Function.prototype,所有函数对象都继承与它,它可能包含一些属性,如所有Function实例中所常见的call和apply。它被实现为一个函数对象本身(正如一些人士指出的,ECMA规范要求这样),这和你检查的结果是一致的。
其实,从最开始的原型“复制”的概念,也可以简单的理解出来,通过prototype为函数象的Function构造器,才能最简单,最直观的构造出函数实例。参考问题二的图片:
关于JS继承方式,后续会再写到。