JavaScript中创建对象模式的演变(原型)
Object构造函数和对象字面量方法
工厂模式
自定义构造函数模式
原型模式
组合使用自定义构造函数模式和原型模式
动态原型模式、寄生构造函数模式、稳妥构造函数模式
第一部分:Object构造函数和对象字面量方法
我之前在博文《javascript中对象字面量的理解》中讲到过这两种方法,如何大家不熟悉,可以点进去看一看回顾一下。它们的优点是用来创建单个的对象非常方便。但是这种方法有一个明显的缺点:利用同一接口创建很多对象是,会产生大量的重复代码。这句话怎么理解呢?让我们看一下下面的代码:
var person1={ name:"zzw", age:"21", school:"xjtu",
sayName:function(){
console.log(this.name);
};
} var person2={ name:"ht", age:"18", school:"tjut",
sayName:function(){
console.log(this.name);
};
}
可以看出,当我们创建了两个类似的对象时,我们重复写了name age school 以及对象的方法这些代码,随着类似对象的增多,显然,代码会凸显出复杂、重复的感觉。为解决这一问题,工厂模式应运而生。
第二部分:工厂模式
刚刚我们提到:为解决创建多个对象产生大量重复代码的问题,由此产生了工厂模式。那么,究竟什么是工厂模式?它是如何解决这一问题的呢?首先,我们可以想一想何谓工厂? 就我个人理解:在工厂可以生产出一个模具,通过这个模具大量生产产品,最终我们可以加以修饰(比如喷涂以不同颜色,包装不同的外壳)。这样就不用一个一个地做产品,由此可以大大地提高效率。
同样地,对于创建对象也是这样的思路:它会通过一个函数封装创建对象的细节。最后直接将不同的参数传递到这个函数中去,以解决产生大量重复代码的问题。观察以下代码:
function createPerson(name,age,school){ var o=new Object(); o.name=name; o.age=age; o.school=school; o.sayName=function(){ console.log(this.name); }; return o; } var person1=createPerson("zzw","21","xjtu"); var person2=createPerson("ht","18","tjut");
看似这里的代码也不少啊!可是,如果在多创建2个对象呢,10个呢,100个呢?结果可想而知,于是工厂模式成功地解决了Object构造函数或对象字面量创建单个对象而造成大量代码重复的问题!工厂模式有以下特点:
在函数内部显式地创建了对象。
函数结尾一定要返回这个新创建的对象。
但是,我们仔细观察,可以发现工厂模式创建的对象,例如这里创建的person1和person2,我们无法直接识别对象是什么类型。为了解决这个问题,自定义的构造函数模式出现了。
第三部分:自定义构造函数模式
刚刚说到,自定义构造函数模式是为了解决无法直接识别对象的类型才出现的。那么显然自定义构造函数模式至少需要解决两个问题。其一:可以直接识别创建的对象的类型。其二:解决工厂模式解决的创建大量相似对象时产生的代码重复的问题。
那么,我为什么说是自定义构造函数模式呢?这是因为,第一部分中,我们使用的Object构造函数是原生构造函数,显然它是解决不了问题的。只有通过创建自定义的构造函数,从而定义自定义对象类型的属性和方法。代码如下:
function Person(name,age,school){ this.name=name; this.age=age; this.school=school; this.sayName=function(){ console.log(this.name); }; } var person1=new Person("zzw","21","xjtu"); var person2=new Person("ht","18","tjut");
首先我们验证这种自定义的构造模式是否解决了第一个问题。在上述代码之后追加下面的代码:
console.log(person1 instanceof Person);//true console.log(person1 instanceof Object);//true
结构都得到了true,对于Object当然没有问题,因为一切对象都是继承自Object的,而对于Person,我们在创建对象的时候用的是Person构造函数,那么得到person1是Person类型的也就没问题了。
对于第二个问题,答案是显而易见的。很明显,创建大量的对象不会造成代码的重复。于是,自定义构造函数成功解决所有问题。
A 下面我们对比以下自定义构造函数与工厂模式的不同之处:
自定义构造函数没有用 var o = new Object()那样显式地创建对象
与o.name等不同,它直接将属性和方法赋给了this对象,this最终会指向新创建的对象。(this对象的更多细节可以在我的另一篇博文《JavaScript函数之美~》中查看)。
因为没有创建对象,所以最终没有return一个对象(注意:构造函数在不返回值的情况下,会默认返回一个新对象实例)。
B 对于构造函数,我们还应当注意:
构造函数的函数名需要大写,用以区分与普通函数。
构造函数也是函数,只是它的作用之一是创建对象。
构造函数在创建新对象时,必须使用new操作符。
创建的两个对象person1和person2的constructor(构造函数)属性都指向用于创建它们的Person构造函数。
C 如何理解构造函数也是函数?
只要证明构造函数也可以像普通函数一样的调用,那么就可以理解构造函数也是函数了。
function Person(name,age,school){ this.name=name; this.age=age; this.school=school; this.sayName=function(){ console.log(this.name); }; } Person("zzw","21","xjtu"); sayName();//zzw
可以看出,我直接使用了Person("zzw","21","xjtu");来像普通函数一样的调用这个构造函数,因为我们把它当作了普通函数,那么函数中的this就不会指向之前所说的对象(这里亦没有对象),而是指向了window。于是,函数一经调用,内部的变量便会放到全局环境中去,同样,对于其中的函数也会在调用之后到全局环境,只是这个内部的函数是函数表达式并未被调用。只有调用即sayName();才能正确输出。
由此,我们证明了构造函数也是函数。
D 那么这种自定义构造函数就没有任何问题吗?
构造函数的问题是在每次创建一个实例时,构造函数的方法都需要再实例上创建一遍。由于在JavaScript中,我们认为所有的函数(方法)都是对象,所以每当创建一个实例对象,都会同时在对象的内部创建一个新的对象(这部分内容同样可以在我的博文《JavaScript函数之美~》中找到)。即我们之前创建的自定义构造函数模式相当于下列代码:
function Person(name,age,school){ this.name=name; this.age=age; this.school=school; this.sayName=new Function("console.log(this.name)"); }
var person1=new Person("zzw","21","xjtu"); var person2=new Person("ht","18","tjut");
即我们在创建person1和person2的时候,同时创建了两个sayName为对象指针的对象,我们可以通过下面这个语句做出判断:
console.log(person1.sayName==person2.sayName);//false
这就证明了如果创建两个对象同时也在每个对象中又各自创建了一个函数对象,但是创建两个完成同样任务的Function实例的确没有必要(况且内部有this对象,只要创建一个对象,this便会指向它)。这就造成了内部方法的重复造成资源浪费。
E 解决方法。
如果我们将构造函数内部的方法放到构造函数的外部,那么这个方法便会被person1和person2共享了,于是,在每次创建新对象时就不会同时创建这个方法对象了。如下:
function Person(name,age,school){ this.name=name; this.age=age; this.school=school; this.sayName=sayName; } function sayName(){ console.log(this.name); } var person1=new Person("zzw","21","xjtu"); var person2=new Person("ht","18","tjut");
person1.sayName();//zzw
应当注意:this.sayName=sayName;中这里等式右边的sayName是一个指针,所以在创建新对象的时候只是创建了一个指向共同对像那个的指针而已,并不会创建一个方法对象。这样便解决了问题。 而外面的sayName函数在最后一句中是被对象调用的,所以其中的this同样是指向了对象。
F新的问题
如果这个构造函数中需要的方法很多,那么为了保证能够解决E中的问题,我们需要把所有的方法都写在构造函数之外,可是如果这样:
在全局作用域中定义的函数从未在全局环境中调用,而只会被某个对象调用,这样就让全局作用域有点名不副实。
如果把所有构造函数中的方法都放在构造函数之外,这样就没有封装性可言了。
由此,为了解决F中的问题,接下来不得不提到JavaScript语言中的核心原型模式了。
第四部分:原型模式
为什么会出现原型模式呢?这个模式在上面讲了是为了解决自定义构造函数需要将方法放在构造函数之外造成封装性较差的问题。当然它又要解决构造函数能够解决的问题,所以,最终它需要解决以下几个问题。其一:可以直接识别创建的对象的类型。其二:解决工厂模式解决的创建大量相似对象时产生的代码重复的问题。其三:解决构造函数产生的封装性不好的问题。由于这个问题比较复杂,所以我会分为几点循序渐进的做出说明。
A 理解原型对象
首先,我们应当知道:无论什么时候,只要创建了一个新函数(函数即对象),就会根据一组特定的规则创建一个函数(对象)的prototype属性(理解为指针),这个属性会指向函数的原型对象(原型对象也是一个对象),但是因为我们不能通过这个新函数访问prototype属性,所以写为[[prototype]]。同时,对于创建这个对象的构造函数也将获得一个prototype属性(理解为指针),同时指向它所创建的函数(对象)所指向的原型对象,这个构造函数是可以直接访问prototype属性的,所以我们可以通过访问它将定义对象实例的信息直接添加到原型对象中。这时原型对象拥有一个constructor属性(理解为指针)指向创建这个对象的构造函数(注意:这个constructor指针不会指向除了构造函数之外的函数)。
你可能会问?所有的函数都是由构造函数创建的吗?答案是肯定的。函数即对象,我在博文《JavaScript函数之美~》中做了详尽介绍。对与函数声明和函数表达式这样建立函数的方法本质上也是由构造函数创建的。
上面的说法可能过于抽象,我们先写出一个例子(这个例子还不是我们最终想要的原型模式,只是为了让大家先理解原型这个概念),再根据代码作出说明:
function Person(){} Person.prototype.name="zzw"; Person.prototype.age=21; Person.prototype.school="xjtu"; Person.prototype.sayName=function(){ console.log(this.name); }; var person1=new Person(); var person2=new Person(); person1.sayName();//zzw person2.sayName();//zzw console.log(person1.sayName==person2.sayName);//true
在这个例子中,我们首先创建了一个内容为空的构造函数,因为刚刚讲了我们可以通过访问构造函数的prototype属性来为原型对象中添加属性和方法。于是在下面几行代码中,我们便通过访问构造函数的prototype属性向原型对象中添加了属性和方法。接着,创建了两个对象实例person1和person2,并调用了原型对象中sayName()方法,得到了原型对象中的name值。这说明:构造函数创建的每一个对象和实例都拥有或者说是继承了原型对象的属性和方法。(因为无论是创建的对象实例还是创造函数的prototype属性都是指向原型对象的) 换句话说,原型对象中的属性和方法会被构造函数所创建的对象实例所共享,这也是原型对象的一个好处。
下面我会画一张图来继续阐述这个问题:
从这张图中我们可以看出以下几点:
构造函数和由构造函数创建的对象的prototype指针都指向原型对象。即原型对象既是构造函数的原型对象,又是构造函数创建的对象的原型对象。
原型对象有一个constructor指针指向构造函数,却不会指向构造函数创建的实例。
构造函数的实例的[[prototype]]属性被实例访问来添加或修改原型对象的属性和方法的,而构造函数的prototype属性可以被用来访问以修改原型对象的属性和方法。
person1和person2与他们的构造函数之间没有直接的关系,只是他们的prototype属性同时指向了同一个原型对象而已。
Person.prototype指向了原型对象,而Person.prototype.constructor又指回了Person。
虽然这两个实例都不包含属性和方法,但我们却可以调用person1.name,这是通过查找对象属性的过程来实现的。
B.有关于原型对象中的方法以及实例中的属性和原型对象中的属性
为了加深对原型的理解,我在这里先介绍两种方法确定构造函数创建的实例对象与原型对象之间的关系。
第一种方法:isPrototypeOf()方法,通过原型对象调用,确定原型对象是否是某个实例的原型对象。在之前的代码后面追加下面两句代码:
console.log(Person.prototype.isPrototypeOf(person1));//true console.log(Person.prototype.isPrototypeOf(person2));//true
结果不出意外地均为true,也就是说person1实例和person2实例的原型对象都是Person.prototype。
第二种方法:Object.getPrototypeOf()方法,通过此方法得到某个对象实例的原型。在之前的代码后面追加下面三句代码:
console.log(Object.getPrototypeOf(person1)); console.log(Object.getPrototypeOf(person1)==Person.prototype);
console.log(Object.getPrototypeOf(person1).name);//zzw
其中第一句代码在控制台中可以直接获得person1的原型对象,如下图所示:
其中第二句代码得到布尔值:true。第三句代码得到了原型对象中的name属性值。
但是,当实例自己本身有和原型中相同的属性名,而属性值不同,在代码获取某个对象的属性时,该从哪里获取呢?
规则是:在代码读取某个对象而某个属性是,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从实例本身开始,如果在实例中找到了给定名字的属性,则返回该属性的值;如果没有找到,则继续搜索指针指向的原型对象。观察下面的例子。
function Person(){} Person.prototype.name="zzw"; Person.prototype.age=21; Person.prototype.school="xjtu"; Person.prototype.sayName=function(){ console.log(this.name); }; var person1=new Person(); var person2=new Person(); console.log(person1.name);//zzw person1.name="htt"; console.log(person1.name);//htt console.log(person2.name);//zzw
delete person1.name;
console.log(person1.name);//zzw
首先,我们把person1实例的name属性设置为"htt" ,当我们直接获取person1的name属性时,会现在person1本身找该属性(理解为就近原则),找不到,继续向原型对象中寻找。
当给person1对象添加了自身的属性name时,这次得到的时person1自身的属性,即该属性屏蔽了原型中的同名属性。
通过倒数第三句代码再次得到了zzw,这说明我们对person1设定了与原型对象相同的属性名,但却没有重写原型对象中的同名属性。
最后,我们可以通过delete删除实例中的属性,而原型中的属性不会被删除。
第三种方法:hasOwnProperty()方法
该方法可以检测一个属性是存在于实例中还是存在于原型中。只有给定属性存在于对象实例中时,才会返回true,否则返回false。举例如下:
function Person(){} Person.prototype.name="zzw"; Person.prototype.age=21; Person.prototype.school="xjtu"; Person.prototype.sayName=function(){ console.log(this.name); }; var person1=new Person(); var person2=new Person(); console.log(person1.name);//zzw console.log(person1.hasOwnProperty("name"));//false 因为zzw是搜索于原型对象的 person1.name="htt"; console.log(person1.name);//htt console.log(person1.hasOwnProperty("name"));//true 在上上一句,我添加了person1实例的属性,它不是属于原型对象的属性 delete person1.name; console.log(person1.name);//zzw console.log(person1.hasOwnProperty("name"));//false 由于使用delete删除了实例中的name属性,所以为false
C.in操作符的使用以及如何编写函数判断属性存在于对象实例中
in操作符会在通过对象能够访问给定属性时,返回true,无论该属性存在于事例中还是原型中。观察下面的例子:
function Person(){} Person.prototype.name="zzw"; Person.prototype.age=21; Person.prototype.school="xjtu"; Person.prototype.sayName=function(){ console.log(this.name); }; var person1=new Person(); var person2=new Person(); console.log(person1.name);//zzw console.log(person1.hasOwnProperty("name"));//false console.log("name" in person1);//true person1.name="htt"; console.log(person1.name);//htt console.log(person1.hasOwnProperty("name"));//true console.log("name" in person1);//true delete person1.name; console.log(person1.name);//zzw console.log(person1.hasOwnProperty("name"));//false console.log("name" in person1);//true
可以看到,确实,无论属性在实例对象本身还是在实例对象的原型对象都会返回true。
有了in操作符以及hasOwnProperty()方法我们就可以判断一个属性是否存在于原型对象了(而不是存在于对象实例或者是根本就不存在)。编写hasPrototypeProperty()函数并检验:
function Person(){} function hasPrototypeProperty(Object,name){ return !Object.hasOwnProperty(name)&&(name in Object); } Person.prototype.name="zzw"; Person.prototype.age=21; Person.prototype.school="xjtu"; Person.prototype.sayName=function(){ console.log(this.name); }; var person1=new Person(); var person2=new Person(); console.log(person1.name);//zzw console.log(hasPrototypeProperty(person1,"name"));//true person1.name="htt"; console.log(person1.name);//htt console.log(hasPrototypeProperty(person1,"name"));//true delete person1.name; console.log(person1.name);//zzw console.log(hasPrototypeProperty(person1,"name"));//true
其中hasPrototypeProperty()函数的判断方式是:in操作符返回true而hasOwnProperty()方法返回false,那么如果最终得到true则说明属性一定存在于原型对象中。(注意:逻辑非运算符!的优先级要远远高于逻辑与&&运算符的优先级)
D.for-in循环和Object.keys()方法在原型中的使用
在通过for-in循环时,它返回的是所有能够通过对象访问的、可枚举的属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。且对于屏蔽了原型中不可枚举的属性(即将[[Enumerable]]标记为false的属性)也会在for-in中循环中返回。(注:IE早期版本中存在一个bug,即屏蔽不可枚举属性的实例属性不会出现在for-in循环中,这里不做详细介绍)
function Person(){}
Person.prototype.name="zzw"; Person.prototype.age=21; Person.prototype.school="xjtu"; Person.prototype.sayName=function(){ console.log(this.name); }; var person1=new Person(); var person2=new Person(); console.log(person1.name);//zzw person1.name="htt"; console.log(person1.name);//htt delete person1.name; console.log(person1.name);//zzw for(var propName in person1){ console.log(propName);//name age school sayName }
通过for-in循环,我们可以枚举初name age school sayName这几个属性。由于person1中的[[prototype]]属性不可被访问,因此,我们不能利用for-in循环枚举出它。
Object.keys()方法接收一个参数,这个参数可以是原型对象,也可以是由构造函数创建的实例对象,返回一个包含所有可枚举属性的字符串数组。如下:
function Person(){} Person.prototype.name="zzw"; Person.prototype.age=21; Person.prototype.school="xjtu"; Person.prototype.sayName=function(){ console.log(this.name); }; var person1=new Person(); var person2=new Person(); console.log(person1.name);//zzw person1.name="htt"; console.log(person1.name);//htt person1.age="18"; console.log(Object.keys(Person.prototype));//["name", "age", "school", "sayName"] console.log(Object.keys(person1));//["name", "age"] console.log(Object.keys(person2));//[]
我们可以从上面的例子中看到,Object.keys()方法返回的是其自身的属性。如原型对象只返回原型对象中的属性,对象实例也只返回对象实例自己创建的属性,而不返回继承自原型对象的实例。
E 更简单的原型语法
在之前的例子中,我们在构造函数的原型对象中添加属性和方法时,每次都要在前面敲一遍Person.prototype,如果属性多了,这样的方法会显得更为繁琐,那么下面我将介绍给大家一种简单的方法。
我们知道,原型对象说到底它还是个对象,只要是个对象,我们就可以使用对象字面量方法来创建,方法如下:
function Person(){} Person.prototype={ name:"zzw", age:21, school:"xjtu", sayName:function (){ console.log(this.name); } };//原来利用Person.prototype.name="zzw"知识对象中的属性,对于对象并没有任何影响,而这里创建了新的对象
同样,最开始,我们创建一个空的Person构造函数(大家发现了没有,其实每次我们创建的都是空的构造函数),然后用对象字面量的方法来向原型对象中添加属性。这样既减少了不必要的输入,也从视觉上更好地封装了原型。 但是,这时原型对象的constructor就不会指向Person构造函数而是指向Object构造函数了。
为什么会这样?我们知道,当我们创建Person构造函数时,就会同时自动创建这个Person构造函数的原型(prototype)对象,这个原型对象也自动获取了一个constructor属性并指向Person构造函数,这个之前的图示中可以清楚地看出来。之前我们使用的较为麻烦的方法(e.g. Person.prototype.name="zzw")只是简单地向原型对象添加属性,并没有其他本质的改变。然而,上述这种封装性较好的方法即使用对象字面量的方法,实际上是使用Object构造函数创建了一个新的原型对象(对象字面量本质即利用Object构造函数创建新对象),注意:此时Person构造函数的原型对象不再是之前的原型对象(而之前的原型对象的constructor属性仍然指向Person构造函数),而和Object构造函数的原型对象一样均为这个新的原型对象。这个原型对象和创建Person构造函数时自动生成的原型对象风马牛不相及。理所应当的是,对象字面量创建的原型对象的constructor属性此时指向了Object构造函数。
我们可以通过下面几句代码来验证:
function Person(){} Person.prototype={ name:"zzw", age:21, school:"xjtu", sayName:function (){ console.log(this.name); } }; var person1=new Person(); console.log(Person.prototype.constructor==Person);//false console.log(Person.prototype.constructor==Object);//true
通过最后两行代码我们可以看出Person构造函数的原型对象的constructor属性此时不再指向Person构造函数,而是指向了Object构造函数。但是这并被影响我们正常使用,下面几行代码便可以清楚地看出:
function Person(){} Person.prototype={ name:"zzw", age:21, school:"xjtu", sayName:function (){ console.log(this.name); } }; var person1=new Person(); console.log(person1.name);//zzw console.log(person1.age);//21 console.log(person1.school);//xjtu person1.sayName();//zzw
下面我将以个人的理解用图示表示(如果有问题,请指出):
第一步:创建一个空的构造函数。function Person(){}。此时构造函数的prototype属性指向原型对象,而原型对象的constructor属性指向Person构造函数。
第二步:利用对象字面量的方法创建一个Person构造函数的新原型对象。
Person.prototype={ name:"zzw", age:21, school:"xjtu", sayName:function (){ console.log(this.name); } };
此时,由于创建了Person构造函数的一个新原型对象,所以Person构造函数的prototype属性不再指向原来的原型对象,而是指向了Object构造函数创建的原型对象(这是对象字面量方法的本质)。但是原来的原型对象的constructor属性仍指向Person构造函数。
第三步:由Person构造函数创建一个实例对象。
这个对象实例的constructor指针同构造它的构造函数一样指向新的原型对象。
总结:从上面的这个例子可以看出,虽然新创建的实例对象仍可以共享添加在原型对象里面的属性,但是这个新的原型对象却不再指向Person构造函数而指向Object构造函数,如果constructor的值真的非常重要的时候,我们可以像下面的代码这样重新设置会适当的值:
function Person(){} Person.prototype={ constructor:Person, name:"zzw", age:21, school:"xjtu", sayName:function (){ console.log(this.name); } };
这样,constructor指针就指回了Person构造函数。即如下图所示:
值得注意的是:这种方式重设constructor属性会导致它的[[Enumerable]]特性设置位true,而默认情况下,原生的constructor属性是不可枚举的。但是我们可以试用Object.defineProperty()将之修改为不可枚举的(这一部分可以参见我的另一篇博文:《深入理解JavaScript中的属性和特性》)。
F.原生对象的原型
原型的重要性不仅体现在自定义类型方面,就连所有原生的引用类型,都是使用这种模式创建的。所有原生引用类型(Object、Array、String,等等)都在其构造函数的原型上定义了方法。例如在Array.prototype中可以找到sort()方法,而在String.prototype中就可以找到substring()方法。
console.log(typeof Array.prototype.sort);//function console.log(typeof String.prototype.substring);//function
于是,实际上我们是可以通过原生对象的原型来修改它。比如:
String.prototype.output=function (){ alert("This is a string"); } var message="zzw"; message.output();
这是,便在窗口中弹出了“This is a string”。尽管可以这样做,但是我们不推荐在产品化的程序中修改原生对象的原型。这样做有可能导致命名冲突等问题。
G.原型模式存在的问题
实际上,从上面对原型的讲解来看,原型模式还是有很多问题的,它并没有很好地解决我在第四部分初提出的若干问题:“其一:可以直接识别创建的对象的类型。其二:解决工厂模式解决的创建大量相似对象时产生的代码重复的问题。其三:解决构造函数产生的封装性不好的问题。”其中第一个问题解决的不错,通过构造函数便可以直接看出来类型。第二个问题却解决的不好,因为它省略了为构造函数传递初始化参数这一环节,结果所有的实例在默认情况下都将取得相同的默认值,我们只能通过在实例上添加同名属性来屏蔽原型中的属性,这无疑也会造成代码重复的问题。第三个问题,封装性也还说的过去。因此原型模式算是勉强解决了上述问题。
但是这种方法还由于本身产生了额外的问题。看下面的例子:
function Person(){} Person.prototype={ constructor:Person, name:"zzw", age:21, school:"xjtu", friends:["pengnian","zhangqi"], sayName:function (){ console.log(this.name); } }; var person1=new Person(); var person2=new Person(); person1.friends.push("feilong"); console.log(person1.friends);//["pengnian","zhangqi","feilong"] console.log(person2.friends);//["pengnian","zhangqi","feilong"]
这里我在新建的原型对象中增加了一个数组,于是这个数组会被后面创建的实例所共享,但是person1.friends.push("feilong");这句代码我的意思是添加为person1的朋友而不是person2的朋友,但是在结果中我们可以看到person2的朋友也有了feilong,这就不是我们所希望的了。这也是对于包含引用类型的属性的最大问题。
也正是这个问题和刚刚提到的第二个问题(即它省略了为构造函数传递初始化参数这一环节,结果所有的实例在默认情况下都将取得相同的默认值,我们只能通过在实例上添加同名属性来屏蔽原型中的属性,这无疑也会造成代码重复的问题),很少有人会单单使用原型模式。
第五部分:组合使用自定义构造函数模式和原型模式
刚刚我们说到的原型模式存在的两个最大的问题。问题一:由于没有在为构造函数创建对象实例时传递初始化参数,所有的实例在默认情况下获取了相同的默认值。问题二:对于原型对象中包含引用类型的属性,在某一个实例中修改引用类型的值,会牵涉到其他的实例,这不是我们所希望的。而组合使用自定义构造函数模式和原型模式即使构造函数应用于定义实例属性,而原型模式用于定义方法和共享的属性。它能否解决问题呢?下面我们来一探究竟!
function Person(name,age,school){ this.name=name; this.age=age; this.school=school; this.friends=["pengnian","zhangqi"]; } Person.prototype={ constructor:Person, sayName:function(){ console.log(this.name); } } var person1=new Person("zzw",21,"xjtu"); var person2=new Person("ht",18,"tjut"); person1.friends.push("feilong"); console.log(person1.friends);//["pengnian", "zhangqi", "feilong"] console.log(person2.friends);//["pengnian", "zhangqi"] console.log(person1.sayName==person2.sayName);//true
OK!我们来看看组合使用构造函数模式和原型模式解决的问题:
解决了Object构造函数和对象字面量方法在创建大量对象时造成的代码重复问题(因为只要在创建对象时向构造函数传递参数即可)。
解决了工厂模式产生的无法识别对象类型的问题(因为这里通过构造函数即可获知对象类型)。
解决了自定义构造函数模式封装性较差的问题(这里全部都被封装)。
解决了原型模式的两个问题:所有实例共享相同的属性以及包含引用类型的数组在实例中修改时会影响原型对象中的数组。
综上所述,组合使用构造函数模式和原型模式可以说是非常完美了。
第六部分:动态原型模式、寄生构造函数模式、稳妥构造函数模式
实际上,组合使用构造函数模式和原型模式确实已经非常完美了,这里将要讲的几种模式都是在特定的情况下使用的,所以我认为第六部分相对于第五部分并没有进一步的提高。仅仅是多学习几种模式可以解决更多的问题。
A 动态原型模式
这里的动态原型模式相对于第五部分的组合使用自定义构造函数模式和原型模式本质上是没有什么差别的,只是因为对于有其他OO(Object Oriented,面向对象)语言经验的开发人员看到这种模式会觉得奇怪,因此我们可以将所有信息都封装在构造函数中。本质上是通过检测某个应该存在的方法是否存在或有效,来决定是否要初始化原型。如下例所示:
function Person(name,age,school){ this.name=name; this.age=age; this.school=school; if(typeof this.sayName != "function"){ Person.prototype.sayName=function(){ console.log(this.name); }; } } var person=new Person("zzw",21,"xjtu");//使用new调用构造函数并创建一个实例对象 person.sayName(); //zzw console.log(person.school);//xjtu
这里先声明了一个构造函数,然后当使用new操作符调用构造函数创建实例对象时进入了构造函数的函数执行环境,开始检测对象的sayName是否存在或是否是一个函数,如果不是,就使用原型修改的方式向原型中添加sayName函数。且由于原型的动态性,这里所做的修改可以在所有实例中立即得到反映。值得注意的是,在使用动态原型模式时,不能使用对象字面量重写原型,否则,在建立了实例的情况下重写原型会导致切断实例和新原型的联系。
B 寄生构造函数模式
寄生构造函数模式是在前面几种模式都不适用的情况下使用的。看以下例子,再做出说明:
function Person(name,age,school){ var o =new Object(); o.name=name; o.age=age; o.school=school; o.sayName=function(){ console.log(this.name); }; return o; } var person = new Person("zzw",21,"xjtu"); person.sayName();//zzw
寄生构造函数的特点如下:
声明一个构造函数,在构造函数内部创建对象,最后返回该对象,因此这个函数的作用仅仅是封装创建对象的代码。
可以看出,这种方式除了在创建对象的时候使用了构造函数的模式(函数名大写,用new关键字调用)以外与工厂模式一模一样。
构造函数在不返回值的情况下,默认会返回新对象实例,而通过构造函数的末尾添加一个return语句,可以重写调用构造函数时返回的值。
这个模式可以在特殊的情况下来为对象创建构造函数。假设我们想要创建一个具有额外方法的特殊数组,通过改变Array构造函数的原型对象是可以实现的,但是我在第四部分F中提到过,这种方式可能会导致后续的命名冲突等一系列问题,我们是不推荐的。而寄生构造函数就能很好的解决这一问题。如下所示:
function SpecialArray(){ var values=new Array(); values.push.apply(values,arguments); values.toPipedString=function(){ return this.join("|"); }; return values; } var colors=new SpecialArray("red","blue","green"); console.log(colors.toPipedString());//red|blue|green
或者如下所示:
function SpecialArray(string1,string2,string3){ var values=new Array(); values.push.call(values,string1,string2,string3); values.toPipedString=function(){ return this.join("|"); }; return values; } var colors=new SpecialArray("red","blue","green"); console.log(colors.toPipedString());//red|blue|green
这两个例子实际上是一样的,唯一差别在于call()方法和apply()方法的应用不同。(这部分内容详见《JavaScript函数之美~》)
这样就既没有改变Array构造函数的原型对象,又完成了添加Array方法的目的。
关于寄生构造函数模式,需要说明的是:返回的对象与构造函数或构造函数的原型属性之间没有任何关系;也就是说,构造函数返回的对象在与构造函数外部创建的对象没有什么不同。故不能依赖instanceof来确定对象类型。于是,我们建议在可以使用其他模式创建对象的情况下不使用寄生构造函数模式。
C.稳妥构造函数模式
稳妥对象是指这没有公共属性,而且方法也不引用this的对象。稳妥对象适合在安全的环境中使用,或者在防止数据被其他应用程序改动时使用。举例如下:
function Person(name,age,school){ var o=new Object(); o.sayName=function (){ console.log(name); }; return o; } var person=Person("zzw",21,"xjtu"); person.sayName();//zzw
可以看出来,这种模式和寄生构造函数模式非常相似,只是:
1.新创建对象的实例方法不用this。
2.不用new操作符调用构造函数(由函数名的首字母大写可以看出它的确是一个构造函数)。
注意:变量person中保存的是一个稳妥对象,除了调用sayName()方法外没有别的方式可以访问其数据成员。例如在上述代码下添加:
console.log(person.name);//undefined console.log(person.age);//uncefined
因此,稳妥构造函数模式提供的这种安全性,使得它非常适合在某些安全执行环境提供的环境下使用。
第七部分:总结
在这篇博文中,在创建大量相似对象的前提下,我以分析各种方法利弊的思路下向大家循序渐进地介绍了Object构造函数和对象字面量方法、工厂模式、自定义构造函数模式、原型模式、组合使用自定义构造函数模式和原型模式、动态原型模式、寄生构造函数模式、稳妥构造函数模式这几种模式,其中我认为组合使用自定义构造函数模式和原型模式以及动态原型模式都是非常不错的模式。而对于创建对象数量不多的情况下,对象字面量方法、自定义构造函数模式也都是不错的选择。
这一部分内容属于JavaScript中的重难点,希望大家多读几遍,相信一定会有很大的收获!