先来看两种简单的对象创建方式:
1.Object构造函数方法
1 var person = new Object(); 2 person.name = "Nicholas"; 3 person.age = 29; 4 person.job = "Software Engineer"; 5 6 person.sayName = function(){ 7 console.log(this.name); 8 }
2.对象字面量方法
1 var person = { 2 name: "Nicholas", 3 age: 29, 4 job: "Software Engineer", 5 6 sayName: function(){ 7 console.log(this.name); 8 } 9 };
如果在html环境下,为了便于输出,可以将 console.log() 替换成 alert() ,以下代码同理。
和传统的面向对象语言相比较,上述的对象创建方式存在明显的问题。第一个问题,对象的创建代码并不能实现很好的复用,第二个问题,使用同一个接口创建很多对象,会产生大量的重复代码。
以下提出了几种创建对象的模式,针对每种模式,本文中会针对上述两个问题以及其他问题进行优缺点分析。
一、工厂模式
工厂模式用函数封装了以特定接口创建对象的细节
1 function createPerson(name, age, job){ 2 var o = new Object(); 3 o.name = name; 4 o.age = age; 5 o.job = job; 6 o.sayName = function(){ 7 console.log(this.name); 8 }; 9 return o; 10 } 11 12 var person1 = createPerson("Nicholas", 29, "Software Engineer"); 13 var person2 = createPerson("Greg", 27, "Doctor"); 14 15 person1.sayName(); //"Nicholas" 16 person2.sayName(); //"Greg"
17 console.log(person1.sayName == person2.sayName); //false
工厂模式虽然解决了创建多个相似对象的问题,但是没有解决对象识别的问题,即通过这种方式创建的对象通过instanceof确定的对象类型只能是Object。从17行可以看出,每创建一个对象, sayName() 方法都会被重复定义一次,造成了冗余。
二、构造函数模式
使用构造函数模式重写前面的例子,如下:
1 function Person(name, age, job){ 2 this.name = name; 3 this.age = age; 4 this.job = job; 5 this.sayName = function(){ 6 console.log(this.name); 7 }; 8 } 9 10 var person1 = new Person("Nicholas", 29, "Software Engineer"); 11 var person2 = new Person("Greg", 27, "Doctor"); 12 13 person1.sayName(); //"Nicholas" 14 person2.sayName(); //"Greg" 15 16 console.log(person1 instanceof Object); //true 17 console.log(person1 instanceof Person); //true 18 console.log(person2 instanceof Object); //true 19 console.log(person2 instanceof Person); //true 20 21 console.log(person1.sayName == person2.sayName); //false
从代码的16-19行可以看出,创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型,而这正是构造函数模式胜过工厂模式的地方。但是和工厂模式一样, sayName() 方法被重复定义的问题并没有得到解决,为什么在传统的面向对象语言比如Java,C++中这么写不会产生这个问题呢?因为ECAMScript中的函数是对象,所以每定义一个函数,也就是实例化了一个对象,本质上就造成了对象的重复创建,所以说会造成冗余,而在Java,C++中,函数仅仅是函数而已。
好了,既然这样,那我们就尝试把函数定义转移到构造函数的外部:
1 function Person(name, age, job){ 2 this.name = name; 3 this.age = age; 4 this.job = job; 5 this.sayName = sayName; 6 } 7 8 function sayName(){ 9 console.log(this.name); 10 } 11 12 var person1 = new Person("Nicholas", 29, "Software Engineer"); 13 var person2 = new Person("Greg", 27, "Doctor"); 14 15 person1.sayName(); //"Nicholas" 16 person2.sayName(); //"Greg" 17 18 console.log(person1.sayName == person2.sayName); //true
看起来问题好像解决了,两个实例共享了全局的 sayName() ,但是这么写,却带来新的问题,如果某个类型需要相当多的自定义函数,而这些函数全都被定义为了全局函数,那么这个类型还有什么封装性可言。
三、原型模式
我们所创建的每个函数都有一个prototype(原型)属性,因为函数本质上也是对象,对象拥有属性无可厚非,这个prototype属性是一个指针,指向了另一个对象,而这个对象的用途就是用来容纳某个类的被所有实例所共享的那部分属性和方法,例子如下:
1 function Person(){ 2 } 3 4 Person.prototype.name = "Nicholas"; 5 Person.prototype.age = 29; 6 Person.prototype.job = "Software Engineer"; 7 Person.prototype.sayName = function(){ 8 console.log(this.name); 9 }; 10 11 var person1 = new Person(); 12 person1.sayName(); //"Nicholas" 13 14 var person2 = new Person(); 15 person2.sayName(); //"Nicholas" 16 17 console.log(person1.sayName == person2.sayName); //true 18 }
注意到17行, sayName() 只被定义了一次,被所有的实例共享了。
首先来解释一下什么是原型对象,函数的prototype属性指向的就是原型对象,在默认情况下,所有原型对象都会自动获得一个constructor属性,以上面的代码为例, Person.prototype.constructor == Person //true ,当调用构造函数创建了一个新的实例时,实例的内部将包含一个指针,写做 [[Prototype]] ,当然这个指针是被内部实现不可见的,正是这个指针联系了实例和构造函数的原型对象。下图解释了这些关系:
所以,当代码读取某个对象的属性时,实际上会进行两次搜索,首先,搜索实例本身,如果存在该属性,则返回,如果不存在,则继续搜索实例的原型对象。需要特别强调的一点就是,可以访问原型对象中保存的属性值,但是不可以重写属性值,因为如果在实例中重写了这个属性值,则会屏蔽原型中对应的属性。看下面的例子:
1 function Person(){ 2 } 3 4 Person.prototype.name = "Nicholas"; 5 Person.prototype.age = 29; 6 Person.prototype.job = "Software Engineer"; 7 Person.prototype.sayName = function(){ 8 console.log(this.name); 9 }; 10 11 var person1 = new Person(); 12 var person2 = new Person(); 13 14 person1.name = "Greg"; 15 console.log(person1.name); //"Greg" --来自实例 16 console.log(person2.name); //"Nicholas" --来自原型
在这个例子中,person1的name被一个新值给屏蔽了,而原型值没有受到影响,因为person2可以正常返回name值。
读者应该注意到,在上例中,每添加一个属性和方法,都要敲一遍 Person.prototype ,为了简便,可以使用对象字面量的形式对上例进行重写:
1 function Person(){ 2 } 3 4 Person.prototype = { 5 name : "Nicholas", 6 age : 29, 7 job: "Software Engineer", 8 sayName : function () { 9 console.log(this.name); 10 } 11 }; 12 13 var friend = new Person(); 14 15 console.log(friend instanceof Object); //true 16 console.log(friend instanceof Person); //true 17 console.log(friend.constructor == Person); //false 18 console.log(friend.constructor == Object); //true
这里使用的语法,本质上完全重写了默认的prototype对象,因此第17行才会得出false的结果,因此,需要在第4行下面加上一行代码: constructor: Person; ,用来弥补完全重写导致的损失。
还要注意的一点就是,原型的修改会动态反映到实例上,也就是说,即使已经声明了实例,在声明之后对原型进行修改,这些修改也会在实例上立刻得到反映。因为实例与原型之间的联系只不过是一个指针,而非一个副本。
话是这么说,这里也要注意另一个问题,即对象字面量形式的重写会彻底切断实例和原型之间的联系,如下:
1 function Person(){ 2 } 3 4 var friend = new Person(); 5 6 Person.prototype = { 7 constructor: Person, 8 name : "Nicholas", 9 age : 29, 10 job : "Software Engineer", 11 sayName : function () { 12 console.log(this.name); 13 } 14 }; 15 16 friend.sayName(); //error
说了这么多,我们对原型模式做一下总结,原型模式很好地解决了共享属性和函数的问题,使用对象字面量形式定义时代码简洁优雅,但原型模式存在的一个很大缺点就是:类型中存在引用类型的属性时,会造成一处修改,处处被改的问题,下面的代码说明了这个问题:
1 function Person(){ 2 } 3 4 Person.prototype = { 5 constructor: Person, 6 name : "Nicholas", 7 age : 29, 8 job : "Software Engineer", 9 friends : ["Shelby", "Court"], 10 sayName : function () { 11 console.log(this.name); 12 } 13 }; 14 15 var person1 = new Person(); 16 var person2 = new Person(); 17 18 person1.friends.push("Van"); 19 20 console.log(person1.friends); //"Shelby,Court,Van" 21 console.log(person2.friends); //"Shelby,Court,Van" 22 console.log(person1.friends === person2.friends); //true
这是由于person1和person2的friends指向了同一块内存区域。因为这个缺陷,原型模式很少被单独使用。
四、组合使用构造函数模式和原型模式
在这种模式中,构造函数模式用于定义实例属性,原型模式用于定义方法和共享的属性。可以说这种方式结合得非常巧妙,对两种模式扬长避短,因此,这种模式也是目前在ECMAScripts中使用最广泛、认同度最高的一种创建自定义类型的方法。
1 function Person(name, age, job){ 2 this.name = name; 3 this.age = age; 4 this.job = job; 5 this.friends = ["Shelby", "Court"]; 6 } 7 8 Person.prototype = { 9 constructor: Person, 10 sayName : function () { 11 console.log(this.name); 12 } 13 }; 14 15 var person1 = new Person("Nicholas", 29, "Software Engineer"); 16 var person2 = new Person("Greg", 27, "Doctor"); 17 18 person1.friends.push("Van"); 19 20 console.log(person1.friends); //"Shelby,Court,Van" 21 console.log(person2.friends); //"Shelby,Court" 22 console.log(person1.friends === person2.friends); //false 23 console.log(person1.sayName === person2.sayName); //true
五、动态原型模式
组合模式其实已经做得很好了,为什么还要引出一个动态原型模式,其实动态原型模式和组合模式在本质上是一样的,只不过在代码风格上做了一些调整,更符合面向对象所体现的封装特性。
1 function Person(name, age, job){ 2 3 //properties 4 this.name = name; 5 this.age = age; 6 this.job = job; 7 8 //methods 9 if (typeof this.sayName != "function"){ 10 11 Person.prototype.sayName = function(){ 12 console.log(this.name); 13 }; 14 15 } 16 } 17 18 var friend = new Person("Nicholas", 29, "Software Engineer"); 19 friend.sayName();
第9-15行代码只有在初次调用构造函数时才会执行,此后,原型已完成初始化。
六、寄生(parasitic)构造函数模式
按笔者理解,寄生构造函数模式很类似于装饰者模式的思想,在无法直接修改某个类型(或者仅仅是为了避免重写每个方法的麻烦),而又希望加强这个类型的某些功能时,就可以使用这种模式
1 function SpecialArray(){ 2 3 //create the array 4 var values = new Array(); 5 6 //add the values 7 values.push.apply(values, arguments); 8 9 //assign the method 10 values.toPipedString = function(){ 11 return this.join("|"); 12 }; 13 14 //return it 15 return values; 16 } 17 18 var colors = new SpecialArray("red", "blue", "green"); 19 console.log(colors.toPipedString()); //"red|blue|green" 20 21 console.log(colors instanceof SpecialArray);
这个例子中,创建的实例基于Array的基础上,添加了 toPipedString() 方法。
这种模式返回的对象与构造函数或者构造函数的原型属性之间没有关系(辨别的方法是看构造函数有没有进行return),不能依赖 instanceof 来确定对象类型,由于这种缺陷,不到万不得已,也不建议使用这种模式。
综上,在JavaScript中创建对象,默认情况下我们可以使用组合模式,在组合模式中,每个实例都会有自己的一份实例属性的副本,但同时有共享着方法的引用,最大程度节省了内存。在JavaScript这种弱类型的语言中,很好地还原了传统OO语言的类的概念,可以说是一种完美的妥协。