相信很多JS学习者心中都会有几个一直困扰的问题:prototype 到底是个啥???构造函数又是什么鬼???为什么没有看见其他面向对象语言中常见的 Class(类)???
下面, 我就来讲一下 JS 的 prototype 到底是个啥。
首先, 强调一点:
JS 里没有类!
JS 里没有类!!
JS 里没有类!!!
JS不是通常我们所了解的基于类的面向对象语言,它属于原型语言,只有对象, 没有类!
所以, 现在让我们忘掉类, 忘掉那搞得我们怀疑人生的构造函数,来看一门“纯种”原型语言——Io。 当然, 不会太深入, 只需要几分钟时间即可~
1. 原型语言 Io
为什么说 Io 是“纯种”的原型语言呢?因为它没有像 JS 里对象字面量等虽然好用但是可能会让你搞不清深层机制的语法糖,这样虽然语法可读性差一些,但是对于理解原型的机制却非常适合,也没有迷惑人的 new 操作符和构造函数。
语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·约翰·兰达(Peter J. Landin)发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。
Io 中创建新对象的方式很简单,也只有一种方式,就是克隆另一个对象,被克隆的对象就叫做原型。
上面的代码的含义是:
- 以 Object 为原型,克隆(clone)一个新对象 obj;
- 新对象 obj
- 给对象的 name 槽(Slot)(类似于 JS 中的属性)赋值为“undor”
- 赋值后的新对象 obj
- 新对象会记住它的原型,对于 obj 来说,它的原型就是根对象 Object,下面的那些是 Object 的槽,比较多,没有全部截取。
下面是 Io 中的原型继承:
以 Object 为原型克隆一个新对象 Person,在 Io 中,当新对象 的第一个字母为大写时, 会自动为其添加一个 type (类型)槽,值就是它的名字。注意,是类型, 不是类!type 只是一个槽, 或者说只是一个属性, 仅此而已。Person 只是一个对象!
为其添加 legNum(腿的数量) 槽并赋值为 2.
再以 Person 为原型克隆一个新对象 Men。
为 Men 对象添加 sex(性别)槽并赋值为 male(男性)。
然后,以 Men 为原型, 克隆一个新对象 men1,因为 men1 的开头是小写,所以它没有自己的 type 槽, 但在下面的图中我们可以看到,当调用其 type 槽时,调用的是其父对象的 type 槽,也就是说,它被认为是和它的父元对象是同一个类型,再次强调,是类型,不是类!!!
回到上面的图,我们为 men1 添加 name 槽并赋值为“小明”。再以 Men 为原型, 克隆一个新对象 men2,为 men2 添加 name 槽并赋值为“小王”。
查看一下 men1 ,可以看到,name 槽的值为“小明”,且确实没有 type 槽~
调用 men1 的 sex,会显示其父对象(原型) Men 的 sex —— male,
(暂时忽略第二句代码)
调用 men1 的 legNum,会显示其父对象的父对象 Person 的 legNum —— 2,
men2 同理~
下图中,每一个方块为一个对象,方块中的圆角矩形就是槽。箭头指向的每一个对象的原型,从图中我们可以看到这几个对象直接的继承关系,其中 men1 和 men2 自身没有 type 槽,所以会继承父对象 Men 的 type
你可能会问,既然克隆时并没有克隆原型的槽,那么到底克隆了啥?讲真,我也不清楚……不过我们并不打算过深的探究 Io 语言的内部机制,只是为了借助 Io 语言了解一下原型模式的概念, 便于我们理解 JS 中的原型。关于克隆,你也可以这样理解:
克隆时并不是把原型完全复制一份副本,而是创建一个新对象,然后在其内部建立一个隐藏的槽 proto,而 proto 的值即为对其原型的引用,当调用的槽在该对象内部无法找到时,就会其原型中查找,这样对于外界看来,这个新对象就和它的原型完全一样了,调用相同的槽时,返回的结果完全相同。
而当在其原型中也找不到要调用的槽时,就会再向上查找,于是形成了一条原型链,于是就形成了基于原型的对象继承方式。在原型模式中,继承完全是对象与对象之间的事,与类没有半毛钱关系~~~
再回来看 JS:
2. JS中的对象和原型
2.1 JS中的克隆对象操作
在 JS 中,克隆对象的方式是 Object.create()
,这是根对象 Object 的一个方法,这个方法接收一个对象作为参数,返回一个新对象,并将新对象的 prototype 指向刚刚传给Object.create()
的对象。作用基本等同于 Io 中的克隆操作。
var obj1 = {
name: "obj1"
};
var obj2 = Object.create(obj1);
运行上面代码,然后在控制台中查看 obj1 和 obj2,可以看到,obj2 的原型是 obj1,而调用 obj1 中本没有的 name 属性时,则会自动返回其原型 obj1 中的 name 属性。
JavaScript 给对象提供了一个名为
__proto__
的隐藏属性,指向它的原型,在一些浏览器(如Chrome)中__proto__
被公开出来, 我们可以在这些浏览器的控制台中查看对象的原型的详细信息。
但是, Object.create()
是 ES5 中才新增的方法,那么在之前的 JS 版本中是如何实现为对象绑定原型的呢?这正是 JS 中最讨厌的问题,在 ES5 之前,JS中并没有单独的为对象绑定原型的方法,而是通过构造函数执行 new 操作时自动绑定的,结果吧,说它是原型语言,和其他原型语言还不一样,说它是面向对象语言,和其他面向对象语言还不一样。。。。。。
ES5:ECMAScript第五版
2.2 JS中的构造函数
好吧,那我们来说说 new 操作时,发生了什么?来看看《你不知道的 JavaScript(上卷)》第二部分2.2.4节中的说法:
- 创建(或者说构造)一个全新的对象。
- 这个新对象会被执行[[Prototype]]连接。
- 这个新对象会绑定到函数调用的this。
- 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
function Person() {}
Person.prototype.legNum = 2;
Person.prototype.sayType = function () {
console.log("Is Person");
};
var p1 = new Person();
以上述代码为例。每一个 Function 对象都会有一个 prototype 属性,这个属性本身是个对象,这里个例子中这个对象就是 Person.prototype
,大概长图中那个样子。然后在 new 操作中将 p1.__proto__
指向 Person.prototype
。如果再创建 p2、p3 等也是一样,只是不会再创建新的 Person.prototype
,而是会把 p2.__proto__
和 p3.__proto__
都指向第一次创建的 Person.prototype
。
另外,Person.prototype
这个对象的原型(也就是 Person.prototype.__proto__
)默认会指向 Object.prototype
。为什么呢?在 JS中,创建一个新对象的本质是 new Object()
,即使是对象字面量方式 var obj = {}
创建的新对象也是一样,因为对象字面量不过只是一个语法糖而已~所以,新对象的原型自然就指向 Object.prototype
啦~
2.3 构造函数的缺点
构造函数中通过 this.xxx = yyy
,定义的属性和方法(以下统称为属性,因为方法其实也是一个属性,只是值为函数而已,只不过在面向对象中习惯称之为“方法”),会在每个新对象中实例化一遍,这对于各个对象独有的属性等来说没什么问题,但是对于共用的属性来说,就显得浪费内存了。比如上面的代码,所有人都有两条腿,都有共同的 sayType()
方法。
前面说到,同一个构造函数构造出的所有新对象,它们的原型都会指向同一个对象,而且由于原型链的原因,所有对象都可以访问到原型中的属性,这样就能解决刚刚提到的构造函数的问题,把共用的属性放到构造函数的 .prototype
中即可,如代码中的 Person.prototype.legNum = 2
和 Person.prototype.sayType = function (){...}
。
原型链:每一个对象都有自己的原型,原型是一个对象,原型对象也有自己的原型,直到根对象的原型对象
Object.prototype
,它的原型Object.prototype.__proto__
为null
。当访问对象的某个属性,会现在该对象内部查找,若找到则直接返回,找不到就会在其原型中查找,找到则返回,找不到则再向上查找,依次直到Object.prototype.__proto__
若仍未找到,则返回undefined
。这就是原型链。
先写这些,有空再补充~~~
参考资料
《你不知道的 JavaScript(上卷)》第二部分 this 和对象原型
《JavaScript 设计模式与开发实践》第1.4节 原型模式和基于原型继承的JavaScript对象系统
《七周七语言:理解多种编程范型》第三章 Io
《JavaScript 高级程序设计(第3版)》第六章 面向对象的程序设计