在js的编程中我们有时候要创建一批模板相同的变量。比如:
var person1 = {
name : 'p1',
age : 21
};
var person2 = {
name : 'p2',
age : 22
};
var person3 = {
name : 'p3',
age : 23
};
// ...
如果每一个对象都像这样通过对象字面量(Object literals)直接定义,就要写太多的重复代码了。这些对象明明是结构相同的,为什么不提取出相同的部分进行代码重用呢?
因此,人们在实践的过程中,就总结出了以下几种创建对象的模式。
1. 工厂模式
工厂模式使用一个函数来封装创建对象、属性赋值、组装对象,这个函数叫做工厂函数。使用的时候只需要将对象的信息作为参数传递给工厂函数,工厂函数就会帮我们“加工”出需要的对象了。
function createPerson(name, age) {
var o = {};
o.name = name;
o.age = age;
o.sayName = function() {
alert(this.name);
};
return o;
}
var person1 = createPerson("p1", 21);
var person2 = createPerson("p2", 22);
var person3 = createPerson("p3", 23);
工厂模式的缺陷
- 低效率,每次调用工厂函数都重复地定义了相同的函数(上面例子的sayName)。这些函数的作用完全相同,却各自占用了内存空间和cpu资源。
- 对象识别困难。实例对象与它的工厂函数没有关联。给出一个对象,很难知道它是通过哪个模板构造出来的(即对象的“类型”)。
2. 构造函数模式
构造函数模式的特点是:模板的所有属性都通过构造函数来定义。
js在最初设计的时候,为了让其他面向对象语言的程序员更好上手,给js加入了很多Java的特性,构造函数和new关键字就是其中之一。
function Person(name, age) {
this.name = name;
this.age = age;
this.sayName = function() {
alert(this.name);
};
}
var person1 = new Person("p1", 21);
var person2 = new Person("p2", 22);
var person3 = new Person("p3", 23);
像Java一样使用js对象
构造函数就像一个“类”,它规定了对象的模板。而且,constructor指针和instanceof关键字的引入,让对象与构造函数关联起来,可以进行对象识别,就像在Java中识别一个对象是否为一个类的实例一样:
console.log(person1.constructor === Person);
console.log(person1 instanceof Object);
console.log(person1 instanceof Person);
// 全部打印true
像使用Java一样使用js,被很多专家批评是“不伦不类”的,而且会给人一种“js有类”的误导。js本身的原型系统是非常强大而灵活的,掌握好以后,比Java的类系统更加好用。很多权威专家劝js程序员应该慢慢摆脱使用构造函数和new关键字。
构造函数模式的缺陷
低效率。原因与工厂模式相同。在上面这个例子中,我们每次执行一次Person构造函数,都会定义一个sayName函数(函数在js中是对象)。所有的sayName函数作用都是一样的,重复的定义显然是浪费cpu和内存资源。我们可以这样改进:
function Person(name, age) {
this.name = name;
this.age = age;
this.sayName = sayName;
}
function sayName() {
alert(this.name);
}
但是这样做,又会有以下两个问题:
- 在全局作用域定义的sayName仅仅只是为了给Person使用,这让全局作用域有点名不副实(全局作用域被滥用)。
- 如果Person需要定义很多方法,那么就需要定义很多个全局函数,这会污染命名空间,而且封装性很差,代码混乱。
基于以上原因,我们很少使用纯的构造函数模式,文章的后面会展示如何将构造函数模式与原型模式一起使用而避免这些问题。
3. 原型模式
原型模式的特点是:模板的所有属性都定义在原型对象上,让所有实例对象共享原型链上的属性。每次创建对象只是创建一个[[prototype]]指向这个原型的空对象。
function Person() {}
Person.prototype = {
name: "Nicholas",
age: 29,
friends: ["Shelby", "Court"],
sayName: function() {
alert(this.name);
},
constructor: Person
};
var person1 = new Person();
var person2 = new Person();
以上代码虽然使用到了构造函数和new关键字,但是我们并没有在构造函数内添加属性。使用它们仅仅是为了创建一个[[prototype]]指向这个模板的空对象。事实上,我们也可以不使用构造函数来实现原型模式:
var personTemplate = {
name: "Nicholas",
age: 29,
friends: ["Shelby", "Court"],
sayName: function() {
alert(this.name);
}
};
var person1 = Object.create(personTemplate);
var person2 = Object.create(personTemplate);
原型函数模式的问题
使用原型模式不会出现构造函数模式中重复定义函数的问题。但是会出现其他的问题:
- 如果不通过构造函数来实现原型模式(比如通过Object.create来指定新对象的原型),会出现与工厂模式相同的对象识别问题。
- 在实例化的时候我们无法指定新对象的属性值。比如name和age的值始终都是默认的"Nicholas"和29。程序员需要在创建实例以后自己将需要的name和age赋值给新对象。
- 原型链的改变会影响所有已经构造出的实例。这是一种灵活性,也是一种危险。如果我们不小心通过实例对象改变了原型链上的属性,会影响所有的实例对象。比如:
person1.friedns.push('new friend');
person1.friedns和person2.friedns都会增加'new friend'
。
4. 组合使用构造函数模式和原型模式
构造函数模式和原型模式定义“模板”的思想是完全不同的:
- 构造函数模式的思路是每次实例化对象的时候都给新对象定义属性。
- 原型模式的思路是所有实例对象共享同一条原型链。
这两者都有自己的缺陷,但是又不会出现对方的缺陷,因此很容易就想到,将这两个模式一起使用,优势互补。
组合模式就是:通过构造函数来定义那些需要属于自己的属性,通过原型对象来定义那些需要共享的属性(尤其是函数)。
function Person(name, age, friends) {
this.name = name;
this.age = age;
this.friends = friends;
}
Person.prototype = {
constructor: Person,
sayName: function() {
alert(this.name);
}
};
var person1 = new Person("p1", 21, ['f1', 'f2']);
var person2 = new Person("p2", 22, ['f3', 'f4']);
这种创建对象的模式,是在js中被使用最多、广泛认可的定义“对象模板”的方法。
使用原型链的时候,我们要作出合理的选择:
- 哪些属性是每个实例对象都拥有的,需要在每次实例化的时候添加到实例对象上。
- 哪些属性是所有实例共享的,只需要定义在原型对象上。这可以减少资源的浪费。
组合模式的缺陷
组合模式已经相当好用了。程序员对组合模式的主要抱怨是:构造函数定义与原型定义的割裂。能不能将两者放在同一个代码块中呢?这时候就需要下面的动态原型模式了。
5. 动态原型模式
动态原型模式可以看作是一种组合模式,它将原型的配置放在了构造函数中,使得“模板定义”的代码集中在了一个代码块中。
function Person(name, age) {
this.name = name;
this.age = age;
if (typeof this.sayName != "function") {
Person.prototype.sayName = function() {
alert(this.name);
};
}
}
可以看出,“类”的定义更加“一体化”了。
6. 寄生模式
寄生模式利用已有的对象创建方式,封装得到新的对象创建方式。新特性“寄生”在旧的对象上。
寄生模式封装了以下步骤:
- 使用已有的对象创建方法,创建新的实例对象
- 将这个对象增强(给它增加属性)
- 返回这个对象
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"
以上例子中,使不使用new关键字,结果都一样。
寄生模式和工厂模式在本文的例子几乎一样,除了工厂函数前不能使用new关键字以外,两者的区别主要在于设计思想上:
- 工厂模式是用来大量制造复杂对象的。要制造出这些复杂对象,你可能需要在工厂函数中给对象添加属性、设置对象的原型链、拼接几个组件(对象)。
- 寄生模式用来制造已有对象的增强对象。在寄生模式的函数中,一般都是给对象添加属性。
寄生模式的缺陷
寄生模式的缺陷与工厂模式相同:低效率和对象识别。
不管是否使用new关键字,寄生模式的函数都像一个工厂函数,与实例对象没有关系,出现对象识别问题。在上例中colors instanceof SpecialArray
的值为false;colors.constructor
为Array,而不是SpecialArray。
7. 稳妥构造函数模式
在一些特殊环境下,对象的使用者并不是对象的定义者。定义者在定义对象的时候要防止他的对象被滥用,因此定义者就需要定义“稳妥对象”来给使用者使用。
稳妥对象(Durable Object)是这样一种对象:
- 它的“信息”并不直接保存在对象属性中,以防被对象的使用者随意访问。
- 对象属性上定义了一些工作方法。这些方法可以访问到这些“信息”,以便完成工作。
- 在工作方法中绝不使用this指针,而是通过闭包来访问所需要的对象“信息”。
用来创建稳妥对象的函数就叫稳妥构造函数。
function Car(make, model, year) { // 传递给Car的参数是私有变量
var o = new Object();
var condition = 'used'; // 私有变量
o.sayCar = function() { // 公有函数
console.log('I have a ' + condition + ' ' + year + ' ' + make + ' ' + model + '.');
};
return o;
}
var johnCar = Car('Ford', 'F150', '2011');
johnCar.sayCar();
// I have a used 2011 Ford F150.
johnCar对象是安全的,因为使用者只能够调用它的sayCar方法,而无法直接访问它的make, model, year, condition信息。
这些“信息”可以理解为私有变量。
不一定要像上面这个例子一样,通过工厂函数来实现稳妥构造函数模式,完全可以改成使用构造函数来实现,这样还能解决对象识别的问题。可见这些对象创建模式并不是互斥的,只要掌握了它们的核心思想,就可以各取所长:
function Car(make, model, year) { // 传递给Car的参数是私有变量
var condition = 'used'; // 私有变量
this.sayCar = function() { // 公有函数
console.log('I have a ' + condition + ' ' + year + ' ' + make + ' ' + model + '.');
};
}
var johnCar = new Car('Ford', 'F150', '2011');
johnCar.sayCar();
// I have a used 2011 Ford F150.
console.log(johnCar instanceof Car);
// true
重复定义sayCar函数是不能避免的,这是因为我们需要使用闭包,以便它能访问make, model, year, condition这些变量。闭包的使用详见彻底理解js闭包。
参考资料
《JavaScript高级程序设计》6.2