我们创建的每个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面量的意思来理解,那么prototype就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换句话说,不必在构造函数中定义对象实例的信息,而是可以将信息直接添加到原型对象中,如下面的例子所示:
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = "29";
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function () {
alert(this.name);
};
var person1 = new Person();
person1.sayName(); //"Nicholas"
var person2 = new Person();
person2.sayName(); //"Nicholas";
alert(person1.sayName == person2.sayName); //true
在此,我们将sayName()方法和所有属性直接添加了Person的prototype属性中,构造函数变成了空函数。即使如此,也仍然可以通过调用构造函数来创建新对象,而且新对象还会具有相同的属性和方法。但与构造函数模式不同的是,新对象的这些属性和方
法是由所有实例共享的。换句话说,person1和person2访问的都是同一组属性和同一个sayName()函数。要理解原型模式的工作原理,必须理解ECMAScript中原型对象的性质。
理解原型对象
无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会获得一个constructor(构造函数)属性,这个属性包含一个指向prototype属性所在函数的指针。就拿前面的例子来说,Person.prototype.constructor指向Person。而通过这个构造函数,我们还可以继续为原型对象添加其它属性和方法。
创建了自定义的构造函数之后,其原型对象默认只会取得constructor属性;至于其它方法,则都是从Object继承而来的。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。在很多实现中,这个内部属性的名字是_proto_,而且通过脚本可以访问到(在firefox、Safari、Chrome和Flash的ActionScript中,都可以通过脚本访问_proto_);而在其它实现中,这个属性对脚本则是完全不可见的。不过,要明确的真正重要的一点,就是这个连接存在与实例与构造函数的原型对象之间,而不是存在于实例于构造函数之间。
虽然在某些实现中无法访问到内部的_proto_属性,但在所有实现中都可以通过isPrototypeOf()方法来确定对象之间是否存在这种关系。从本质上来讲,如果对象的_proto_指向调用isPrototypeOf()方法的对象(Person.prototype),那么这个方法就返回true,如下所示:
alert(Person.prototype.isPrototypeOf(person1)); //true;
alert(Person.prototype.isPrototypeOf(person2)); //true;
这里,我们用原型对象的isPrototypeOf()方法测试了person1和person2。因为它们内部都有一个指向Person.prototype的指针,因此都返回了true。
每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例本身开始。如果在实例中找到了具有给定名字的属性,则返回该属性的值;如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性。如果在原型对象中找到了这个属性,则返回该属性的值。也就是说,在我们调用person1.sayName()的时候,会先后执行两次搜索。首先,解析器会问:"实例person1有sayName属性吗?"答:"没有。"然后,它继续搜索,再问:"person1的原型有sayName属性吗?"答:"有。"于是,它就读取那个保存在原型对象中的函数。当我们调用person2.sayName()时,将会重现相同的的搜索过程,得到相同的结果。而这正是多个对象共享原型所保存的属性和方法的基本原理。
前面提到过,原型最初值包含constructor属性,而该属性也是共享的,因此可以通过对象实例访问。
虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与实例原型中的一个属性同名,那我们就在实例中创建该属性,该属性将会屏蔽原型中的那个属性。来看下面的例子:
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.ae = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function () {
alert(this.name);
}
var person1 = new Person();
var person2 = new Person();
person1.name = "Greg";
alert(person1.name); //"Greg" ——来自实例
alert(person2.name); //"Nicholas" ——来自原型
在这个例子中,person1的name被一个新值给屏蔽了。但无论访问person1.name还是访问person2.name都能正常地放回值,即分别是"Greg"(来自对象实例)和"Nicholas"(来自原型)。当在alert()中访问person1.name时,需要读取它的值,因此就会在这个实例上搜索一个名为Name的属性。这个属性确实存在,于是就返回它的值而不必再搜索原型了。当以同样的方式访问person2.name时,并没有在实例上发现该属性,因此就会继续搜索原型,结果在那里找到了name属性。
当为对象实例添加了一个属性时,这个属性就会屏蔽原型对象汇总保存的同名属性;换句话说,添加这个属性只会组织我们访问原型中的那个属性,但不会修改那个属性。即使将这个属性值设置为null,也只会在实例中设置这个属性。而不会回复其指向原型的连接。不会,使用delete操作符则可以完全删除实例属性,从而让我们能够重新访问原型中的属性,如下所示:
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 20;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function () {
alert(this.name);
}
var person1 = new Person();
var person2 = new Person();
person1.name = "Greg";
alert(person1.name); //“Greg” ——来自实例
alert(person2.name); //"Nicholas" ——来自原型
delete person1.name;
alert(person1.name); //"Nicholas" ——来自原型
在这个修改后的例子中,我们使用delete操作符删除了person1.name,之前它保存的"Greg"值屏蔽了同名的原型属性。把它删除以后,就恢复了原型中name属性的连接。因此,接下来再调用person1.name时,返回的就是原型中的name属性的值了。
使用hasOwnProperty()方法可以检测一个属性是存在与实例中,还是存在于原型中。这个方法(不要忘了它是从Object继承来的)只在给定属性存在于对象实例中时,才会返回true。来看下面这个例子:
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.jog = "Software Engineer";
Person.prototype.sayName = function () {
alert(this.name);
};
var person1 = new Person();
var person2 = new Person();
alert(person1.hasOwnProperty("name")); //false
person1.name = "Greg";
alert(person1.name); //"Greg" ——来自实例
alert(person1.hasOwnProperty("name")); //true
alert(person2.name); //"Nicholas" ——来自原型
alert(person2.hasOwnProperty("name")); //false
delete person1.name;
alert(person1.name); //"Nicholas" ——来自原型
alert(person1.hasOwnProperty("name")); //flase
通过使用hasOwnProperty()方法,什么时候访问的是实例属性,什么时候访问的是原型属性就一清二楚了。调用person1.hasOwnProperty("name")时,只有当person1重写name属性后才会返回true,因为只有这时候name才是一个实例属性,而非原型属性。
原型与in操作符
有两种方式使用in操作符:单独使用和在for-in循环中使用。在单独使用时,in操作符会在通过对象能够访问给定属性时返回true,无论该属性存在于实例还是原型中。看一看下面的例子:
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = "29";
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function () {
alert(this.name);
};
var person1 = new Person();
var person2 = new Person();
alert(person1.hasOwnProperty("name")); //false
alert("name" in person1); //true
person1.name = "Greg";
alert(person1.name); //"Greg" ——来自实例
alert(person1.hasOwnProperty("name")); //true;
alert("name" in person1); //true
alert(person2.name); //"Nicholas"——来自原型
alert(person2.hasOwnProperty("name")); //false
alert("name" in person1); //true
delete person1.name;
alert(person1.name); //"Nicholas" ——来自原型
alert(person1.hasOwnProperty("name")); //false
alert("name" in person1); //true;
在以上代码执行的整个过程中,name属性要么直接在对象上访问到,要么是通过原型访问到的。因此,调用"name" in Person1始终都返回true,无论该属性存在与实例中还是存在与原型中。同时使用hasOwnProperty()方法和in操作符,就可以确定该属性到底是存在于对象中,还是存在于原型中,如下所示:
function hasPrototypeProperty(object, name) {
return !object.hasOwnProperty(name) && (name in object);
}
由于in操作符只要通过对象能够访问到属性就就返回true,hasOwnProperty()只在属性存在于实例中时才返回true,因此只要in操作符返回true而hasOwnProperty()返回false,就可以确定属性是原型中的属性。下面来看一看上面定义的函数hasPrototypeProperty()用法:
function Person() {
}
Person.prototype.name = "Nicholas";
Person.prototype.age = "29";
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function () {
alert(this.name);
};
var person = new Person();
alert(hasPrototypeProperty(person, "name")); //true
person.name = "Greg";
alert(hasPrototypeProperty(person, "name")); //false
在这里,name属性先是存在于原型中,因此hasPrototypeProperty()返回true。当在实例中重写name属性后,该属性就存在于实例中了,因此hasPrototypeProperty()返回false。
在使用for-in循环时,返回的是所有能够通过对象访问的、可枚举的(enumerated)属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。屏蔽了原型中不可枚举属性(即设置了[[DontEnum]]标记的属性)的实例属性也会在for-in循环中返回,因为规定,所有开发人员定义的属性都是可枚举的——只有IE除外。
IE的JScript实现中存在一个bug,即屏蔽了不可枚举属性的实例属性不会出现在for-in循环中。例如:
var o = {
toString: function () {
return "My Object";
}
}
for (var prop in o) {
if (prop == "toString") {
alert("Found toString"); //在IE中不会显示
}
}
当以上代码运行时,应该会显示一个警告框,表明找到了toString()方法。这里的对象o定义了一个名为toString()的方法,该方法屏蔽了原型中(不可枚举)的toString()方法。在IE中,由于其实现认为原型的toString()方法被打上了[[DontEnum]]标记就应该跳过该属性,结果我们就不会看到警告框。该bug会影响默认不可枚举的所有属性和方法,包括:hasOwnProperty()、propertyIsEnumerable()、toLocaleString()、toString()和valueOf()。有的浏览器也为constructor和prototype属性打上了[[DontEnum]]标记,但这并不是所有浏览器共同的做法。
更简单的原型语法
读者大概注意到了,前面例子中每添加一个属性和方法就要敲一遍Person.prototype。为了减少不必要的输入,也为了从视觉上更好地封装原型的功能,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象,如下面的例子所示:
function Person() {
}
Person.prototype = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName: function () {
alert(this.name);
}
};
在上面的代码中,我们将Person.prototype设置为等于一个以对象字面量形式创建的新对象。最终结果相同,但有一个例外:constructor属性不再指向Person了。前面曾经介绍过,每创建一个函数,就会同时创建它的prototype对象,这个对象也会自动获得constructor属性。而我们在这里使用的语法,本质上完全重写了默认的prototype对象,因此constructor属性也就变成了新对象的constructor属性(指向Object构造函数),不再指向Person函数。此时,尽管instanceof操作符还能返回正确的结果,但通过constructor已经无法确定对象的类型了,如下所示:
function Person() {
}
Person.prototype = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName: function () {
alert(this.name);
}
};
var person = new Person();
alert(person instanceof Object); //true
alert(person instanceof Person); //true
alert(person.constructor == Person); //false
alert(person.constructor == Object); //true
在此,用instanceof操作符测试Object和Person仍然返回true,但constructor属性则等于Object而不等于Person了。如果constructor的值真的很重要,可以像下面这样特意将它设置回适当的值:
function Person() {}
Person.prototype = {
constructor: Person,
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName: function () {
alert(this.name);
}
};
var person = new Person();
以上代码特意包含了一个constructor属性,并将它的值设置为Person,从而确保了通过该属性能够访问到适当的值。
alert(person instanceof Object); //true
alert(person instanceof Person); //true
alert(person.constructor == Person); //true
alert(person.constructor == Object); //false
原型的动态性
由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来——即使是西安创建了实例后修改原型也照样如此。请看下面的例子:
var person = new Person();
Person.prototype.sayHi = function () {
alert("hi");
};
person.sayHi(); //"hi" (没有问题!)
以上代码先创建了Person的一个实例,并将其保存在person中。然后,下一条语句在Person.prototype中添加了一个方法sayHi()。即使person实例是在添加新方法之前创建的,但它仍然可以访问这个新方法。其原因可以归结为实例与原型之间的松散连接关系。当我们调用Person.sayHi()时,首先会在实例中搜索名为sayHi的属性,在没找到的情况下,会继续搜索原型。因为实例与原型之间的连接只不过是一个指针,而非一个副本,因此就可以在原型中找到新的sayHi属性并返回保存在那里的函数。
尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实力中反映出来,但如果是重写整个原型对象,那么情况就不一样了。我们知道,调用构造函数时回味实例添加一个指向最初原型的_proto_指针,而把原型修改为另外一个对象就等于切断了构造函数与最初原型之间的联系。请记住:实例中的指针近指向原型,而不指向构造函数。看下面的例子:
function Person() {}
var person = new Person();
Person.prototype = {
constructor: Person,
name: "Nicholas",
age: 29,
job: "Software engineer",
sayName: function () {
alert(this.name);
}
};
person.sayName(); //error
在这个例子中,我们先创建了Person的一个实例,然后又重写了其原型对象。然后在调用person.sayName()时发生了错误,因为person指向的原型中不包含以改名字命名的属性。
原生对象的原型
圆心模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。所有原生引用类型(Object、Array、String、等等)都在其构造函数的原型上定义了方法。例如,在Array.prototype中可以找到sort()方法,而在String.prototype中可以找到substring()方法,如下所示:
alert(typeof Array.prototype.sort); //"function"
alert(typeof String.prototype.substring); //"function"
通过原生对象的原则,不仅可以取得所有默认方法的引用,而且也可以定义新方法。可以向修改自定义对象的原型一样修改原生对象的原型,因此尅随时添加方法。下面的代码就给基本包装类型String添加了yield名为startsWith()的方法:
String.prototype.startsWith = function (text) {
return this.indexOf(text) == 0;
};
var msg = "Hello world!";
alert(msg.startsWith("Hello")); //true
这里定义的startsWith()方法会在传入的文本位于yield字符串开始时返回true。既然方法被添加给了String.pprototype,那么当前环境中的所有字符串就都可以调用它。由于msg是字符串。而且后台会调用String基本保皇函数创建这个字符串,因此通过msg就可以调用startsWith()方法。
尽管可以这样做,但我们不推荐在产品化的程序中修改原生对象的原型。如果因某个实现中缺少某个方法,就在原生对象的原型中添加这个方法,那么当在另一个支持该方法的实现中运行代码时,就可能会导致命名冲突。而且,这样做也可能意外地重写原生方法。
原型对象的问题
原型模式也不是没有缺点。首先,它省略了为构造函数传递初始化参数这一环节,结果所有实例在默认情况下都将取得相同的属性值。虽然这会在某种程度上带来一些不方便,但还不是原型的最大问题。原型模式的最大问题是由其共享的本性所导致的。
原型中所有属性是被很多实例共享的,这种共享对于函数非常合适。对于那些包含基本值的属性倒也说的过去,毕竟,通过在实例上添加一个同名属性,可以隐藏原型中的对应属性。然而,对于包含引用类型的属性来说,问题就比较突出了。来看下面的例子:
function Person() {}
Person.prototype = {
constructor: Person,
name: "Nicholas",
age: 2,
job: "Software Engineer",
friends: ["Shelby", "Court"],
sayName: function () {
alert(this.name);
}
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push("Van");
alert(person1.friends); //"Shelby,Court,Van"
alert(person2.friends); //"Shelby, Court, Van"
alert(person1.friends == person2.friends); //ture
在此,Person.prototype对象有一个名为friends的属性,该属性包含一个字符串数组。然后,创建了Person的两个实例。接着,修改了person1.friends引用数组,向数组中添加了一个字符串。由于firends数组存在与Person.prototype而非person1中,所以刚刚提到的修改也会通过person2.friends反映出来。假如我们的初衷就是像这样在所有实例中共享一个数组,那么对这个结果我没有话可说。可是,实例一般都是要有属于自己的全部属性的。而这个问题正是我们很少看到有人单独使用原型模式的原因所在。