创建对象的模式多种多样,但是各种模式又有怎样的利弊呢?有没有一种最为完美的模式呢?下面我将就以下几个方面来分析创建对象的几种模式:
- Object构造函数和对象字面量方法
- 工厂模式
- 自定义构造函数模式
- 原型模式
- 组合使用自定义构造函数模式和原型模式
- 动态原型模式、寄生构造函数模式、稳妥构造函数模式
第一部分:Object构造函数和对象字面量方法
我之前在博文《JavaScript中对象字面量的理解 http://www.linuxidc.com/Linux/2016-11/136666.htm》中讲到过这两种方法,如何大家不熟悉,可以点进去看一看回顾一下。它们的
优点是用来创建单个的对象非常方便。但是这种方法有一个明显的
缺点:利用同一接口创建很多对象是,会产生大量的重复代码。这句话怎么理解呢?让我们看一下下面的代码:
| 1234 | var person1={ <strong>name</strong>:"zzw", <strong>age</strong>:"21", <strong>school</strong>:"xjtu",<br> <strong> sayName</strong>:<strong>function(){</strong><br><strong> console.log(this.name);</strong><br><strong> };</strong> |
| 12345 | }var person2={ <strong>name</strong>:"ht", <strong>age</strong>:"18", <strong>school</strong>:"tjut",<br> <strong> sayName:function(){</strong><br><strong> console.log(this.name);</strong><br><strong> };</strong><br><br> } |
可以看出,当我们创建了两个类似的对象时,我们重复写了name age school 以及对象的方法这些代码,随着类似对象的增多,显然,代码会凸显出复杂、重复的感觉。为解决这一问题,工厂模式应运而生。
第二部分:工厂模式
刚刚我们提到:为解决创建多个对象产生大量重复代码的问题,由此产生了工厂模式。那么,究竟什么是工厂模式?它是如何解决这一问题的呢?首先,我们可以想一想何谓工厂? 就我个人理解:
在工厂可以生产出一个模具,通过这个模具大量生产产品,最终我们可以加以修饰(比如喷涂以不同颜色,包装不同的外壳)。这样就不用一个一个地做产品,由此可以大大地提高效率。 同样地,对于创建对象也是这样的思路:
它会通过一个函数封装创建对象的细节。最后直接将不同的参数传递到这个函数中去,以解决产生大量重复代码的问题。观察以下代码:
| 12345678910111213 | 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构造函数是原生构造函数???显然它是解决不了问题的。只有通过创建自定义的构造函数,从而定义自定义对象类型的属性和方法。代码如下:
| 12345678910 | 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"); |
首先我们验证这种自定义的构造模式是否解决了第一个问题。在上述代码之后追加下面的代码:
| 12 | console.log(person1 <strong>instanceof</strong> Person);console.log(person1 <strong>instanceof</strong> Object); |
结构都得到了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
如何理解构造函数也是函数? 只要证明构造函数也可以像普通函数一样的调用,那么就可以理解构造函数也是函数了。
| 12345678910 | function Person(name,age,school){ this.name=name; this.age=age; this.school=school; this.sayName=function(){ console.log(this.name); };}<strong>Person("zzw","21","xjtu");</strong>sayName(); |
可以看出,我直接使用了Person("zzw","21","xjtu");来像普通函数一样的调用这个构造函数,因为我们把它当作了普通函数,那么函数中的this就不会指向之前所说的对象(这里亦没有对象),而是指向了window。于是
,函数一经调用,内部的变量便会放到全局环境中去,同样,对于其中的函数也会在调用之后到全局环境,只是这个内部的函数是函数表达式并未被调用。只有调用即sayName();才能正确输出。 由此,我们证明了构造函数也是函数。
D 那么这种自定义构造函数就没有任何问题吗? 构造函数的问题是在每次创建一个实例时,构造函数的方法都需要再实例上创建一遍。由于在JavaScript中,我们认为所有的函数(方法)都是对象,所以每当创建一个实例对象,都会同时在对象的内部创建一个新的对象(这部分内容同样可以在我的博文《JavaScript函数之美~》中找到)。即我们之前创建的自定义构造函数模式相当于下列代码:
| 123456 | function Person(name,age,school){ this.name=name; this.age=age; this.school=school; this.sayName=new Function("console.log(this.name)");} |
?
| 12 | var person1=new Person("zzw","21","xjtu");var person2=new Person("ht","18","tjut"); |
即我们在创建person1和person2的时候,同时创建了两个sayName为对象指针的对象,我们可以通过下面这个语句做出判断:
| 1 | console.log(person1.sayName==person2.sayName); |
这就证明了如果创建两个对象同时也在每个对象中又各自创建了一个函数对象,但是创建两个完成同样任务的Function实例的确没有必要(况且内部有this对象,只要创建一个对象,this便会指向它)。这就造成了内部方法的重复造成资源浪费。 E 解决方法。 如果我们将构造函数内部的方法放到构造函数的外部,那么这个方法便会被person1和person2共享了,于是,在每次创建新对象时就不会同时创建这个方法对象了。如下:
| 1234567891011 | function Person(name,age,school){ this.name=name; this.age=age; this.school=school; this.sayName=sayName;}<strong>function sayName(){ console.log(this.name);}</strong>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函数之美~》中做了详尽介绍。对与函数声明和函数表达式这样建立函数的方法本质上也是由构造函数创建的。 上面的说法可能过于抽象,我们先写出一个例子(这个例子还不是我们最终想要的原型模式,只是为了让大家先理解原型这个概念),再根据代码作出说明:
| 123456789101112 | <strong> function Person(){}</strong> 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(); person2.sayName(); console.log(person1.sayName==person2.sayName); |
在这个例子中,我们首先创建了一个内容为空的构造函数,因为刚刚讲了我们可以
通过访问构造函数的prototype属性来为原型对象中添加属性和方法。于是在下面几行代码中,我们便通过访问构造函数的prototype属性向原型对象中添加了属性和方法。接着,创建了两个对象实例person1和person2,并调用了原型对象中sayName()方法,得到了原型对象中的name值。这说明:
构造函数创建的每一个对象和实例都拥有或者说是继承了原型对象的属性和方法。(因为无论是创建的对象实例还是创造函数的prototype属性都是指向原型对象的) 换句话说,
原型对象中的属性和方法会被构造函数所创建的对象实例所共享,这也是原型对象的一个好处。 下面我会画一张图来继续阐述这个问题:从这张图中我们可以看出以下几点:
- 构造函数和由构造函数创建的对象的prototype指针都指向原型对象。即原型对象既是构造函数的原型对象,又是构造函数创建的对象的原型对象。
- 原型对象有一个constructor指针指向构造函数,却不会指向构造函数创建的实例。
- 构造函数的实例的[[prototype]]属性被实例访问来添加或修改原型对象的属性和方法的,而构造函数的prototype属性可以被用来访问以修改原型对象的属性和方法。
- person1和person2与他们的构造函数之间没有直接的关系,只是他们的prototype属性同时指向了同一个原型对象而已。
- Person.prototype指向了原型对象,而Person.prototype.constructor又指回了Person。
- 虽然这两个实例都不包含属性和方法,但我们却可以调用person1.name,这是通过查找对象属性的过程来实现的。
B.有关于原型对象中的方法以及实例中的属性和原型对象中的属性
为了加深对原型的理解,我在这里先介绍两种方法确定构造函数创建的实例对象与原型对象之间的关系。 第一种方法:isPrototypeOf()方法,通过原型对象调用,确定原型对象是否是某个实例的原型对象。在之前的代码后面追加下面两句代码:
| 12 | console.log(Person.prototype.isPrototypeOf(person1));console.log(Person.prototype.isPrototypeOf(person2)); |
结果不出意外地均为true,也就是说person1实例和person2实例的原型对象都是Person.prototype。
第二种方法:Object.getPrototypeOf()方法,通过此方法得到某个对象实例的原型。在之前的代码后面追加下面三句代码:
| 12 | console.log(Object.getPrototypeOf(person1));console.log(Object.getPrototypeOf(person1)==Person.prototype);<br> console.log(Object.getPrototypeOf(person1).name); |
其中第一句代码在控制台中可以直接获得person1的原型对象,如下图所示: 其中第二句代码得到布尔值:true。第三句代码得到了原型对象中的name属性值。
但是,当实例自己本身有和原型中相同的属性名,而属性值不同,在代码获取某个对象的属性时,该从哪里获取呢? 规则是:在代码读取某个对象而某个属性是,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从实例本身开始,如果在实例中找到了给定名字的属性,则返回该属??的值;如果没有找到,则继续搜索指针指向的原型对象。观察下面的例子。
| 12345678910111213 | 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);<strong>person1.name="htt";</strong><strong>console.log(person1.name);console.log(person2.name); |
- 首先,我们把person1实例的name属性设置为"htt" ,当我们直接获取person1的name属性时,会现在person1本身找该属性(理解为就近原则),找不到,继续向原型对象中寻找。
- 当给person1对象添加了自身的属性name时,这次得到的时person1自身的属性,即该属性屏蔽了原型中的同名属性。
- 通过倒数第三句代码再次得到了zzw,这说明我们对person1设定了与原型对象相同的属性名,但却没有重写原型对象中的同名属性。
- 最后,我们可以通过delete删除实例中的属性,而原型中的属性不会被删除。
第三种方法:hasOwnProperty()方法 该方法可以检测一个属性是存在于实例中还是存在于原型中。
只有给定属性存在于对象实例中时,才会返回true,否则返回false。举例如下:
| 123456789101112131415161718 | 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); <strong> console.log(person1.hasOwnProperty("name")); person1.name="htt"; console.log(person1.name); <strong> console.log(person1.hasOwnProperty("name")); delete person1.name; console.log(person1.name);<strong> console.log(person1.hasOwnProperty("name"));</strong> |
C.in操作符的使用以及如何编写函数判断属性存在于对象实例中
in操作符会在通过对象能够访问给定属性时,返回true,无论该属性存在于事例中还是原型中。观察下面的例子:
| 123456789101112131415161718192021 | 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); console.log(person1.hasOwnProperty("name")); <strong>console.log("name" in person1); person1.name="htt"; console.log(person1.name); console.log(person1.hasOwnProperty("name")); <strong>console.log("name" in person1); delete person1.name; console.log(person1.name); console.log(person1.hasOwnProperty("name")); <strong> console.log("name" in person1);</strong> |
可以看到,确实,无论属性在实例对象本身还是在实例对象的原型对象都会返回true。 有了in操作符以及hasOwnProperty()方法我们就可以判断一个属性是否存在于原型对象了(而不是存在于对象实例或者是根本就不存在)。编写hasPrototypeProperty()函数并检验:
| 123456789101112131415161718192021 | function Person(){} <strong> function hasPrototypeProperty(Object,name){ return !Object.hasOwnProperty(name)&&(name in Object); }</strong> 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); <strong>console.log(hasPrototypeProperty(person1,"name")); person1.name="htt"; console.log(person1.name);<strong> console.log(hasPrototypeProperty(person1,"name")); delete person1.name; console.log(person1.name); <strong> console.log(hasPrototypeProperty(person1,"name"));</strong> |
其中hasPrototypeProperty()函数的判断方式是:in操作符返回true而hasOwnProperty()方法返回false,那么如果最终得到true则说明属性一定存在于原型对象中。(
注意:逻辑非运算符!的优先级要远远高于逻辑与&&运算符的优先级)
D.for-in循环和Object.keys()方法在原型中的使用
在通过for-in循环时,它返回的是所有能够通过对象访问的、可枚举的属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。且对于屏蔽了原型中不可枚举的属性(即将[[Enumerable]]标记为false的属性)也会在for-in中循环中返回。(注:IE早期版本中存在一个bug,即屏蔽不可枚举属性的实例属性不会出现在for-in循环中,这里不做详细介绍)
| 12345678910111213141516 | function Person(){}<br> 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);person1.name="htt";console.log(person1.name); delete person1.name; console.log(person1.name);for(var propName in person1){ console.log(propName);} |
通过for-in循环,我们可以枚举初name age school sayName这几个属性。由于person1中的[[prototype]]属性不可被访问,因此,我们不能利用for-in循环枚举出它。 Object.keys()方法接收一个参数,
这个参数可以是原型对象,也可以是由构造函数创建的实例对象,返回一个包含所有可枚举属性的字符串数组。如下:
| 1234567891011121314151617 | 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); person1.name="htt"; console.log(person1.name); person1.age="18"; <strong> console.log(Object.keys(Person.prototype)); console.log(Object.keys(person1)); console.log(Object.keys(person2));</strong> |
我们可以从上面的例子中看到,Object.keys()方法返回的是其自身的属性。如原型对象只返回原型对象中的属性,对象实例也只返回对象实例自己创建的属性,而不返回继承自原型对象的实例。
E 更简单的原型语法
在之前的例子中,我们在构造函数的原型对象中添加属性和方法时,每次都要在前面敲一遍Person.prototype,如果属性多了,这样的方法会显得更为繁琐,那么下面我将介绍给大家一种简单的方法。 我们知道,
原型对象说到底它还是个对象,只要是个对象,我们就可以使用对象字面量方法来创建,方法如下:
| 123456789 | function Person(){}Person.prototype={ name:"zzw", age:21, school:"xjtu", sayName:function (){ console.log(this.name); }}; |
同样,最开始,我们创建一个空的Person构造函数(大家发现了没有,其实每次我们创建的都是空的构造函数),然后用对象字面量的方法来向原型对象中添加属性。
这样既减少了不必要的输入,也从视觉上更好地封装了原型。 但是,这时原型对象的constructor就不会指向Person构造函数而是指向Object构造函数了。
为什么会这样?我们知道,当我们创建Person构造函数时,就会
同时自动创建这个Person构造函数的原型(prototype)对象,这个原型对象也自动获取了一个constructor属性并指向Person构造函数,这个之前的图示中可以清楚地看出来。之前我们使用的较为麻烦的方法(e.g. Person.prototype.name="zzw")只是简单地向原型对象添加属性,并没有其他本质的改变。然而,上述这种封装性较好的方法即使用对象字面量的方法,实际上是使用Object构造函数创建了一个新的原型对象(对象字面量本质即利用Object构造函数创建新对象),注意:此时Person构造函数的原型对象不再是之前的原型对象(而之前的原型对象的constructor属性仍然指向Person构造函数),而和Object构造函数的原型对象一样均为这个新的原型对象。这个原型对象和创建Person构造函数时自动生成的原型对象风马牛不相及。理所应当的是,对象字面量创建的原型对象的constructor属性此时指向了Object构造函数。 我们可以通过下面几句代码来验证:
| 123456789101112 | 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);console.log(Person.prototype.constructor==Object); |
通过最后两行代码我们可以看出Person构造函数的原型对象的constructor属性此时不再指向Person构造函数,而是指向了Object构造函数。但是这并被影响我们正常使用,下面几行代码便可以清楚地看出:
| 1234567891011121314 | function Person(){}Person.prototype={ name:"zzw", age:21, school:"xjtu", sayName:function (){ console.log(this.name); }};var person1=new Person();console.log(person1.name);console.log(person1.age);console.log(person1.school);person1.sayName(); |
下面我将以个人的理解用图示表示(如果有问题,请指出): 第一步:创建一个空的构造函数。function Person(){}。此时构造函数的prototype属性指向原型对象,而原型对象的constructor属性指向Person构造函数。 第二步:利用对象字面量的方法创建一个Person构造函数的新原型对象。
| 12345678 | Person.prototype={ name:"zzw", age:21, school:"xjtu", sayName:function (){ console.log(this.name);
|