创建对象的几种模式
虽然Object构造函数或者字面量,都可以用来创建对象,但这些方式有明显的缺点:使用同一个接口创建很多对象,会产生大量的代码,
于是,工厂模式诞生了
1 工厂模式
工厂模式是广为人知的设计模式,抽象了创建具体对象的过程。在ES6的Class创建类之前,是无法创建类的,开发人员就发明了一种函数,用函数来封装以特定接口创建对象的细节,如下
工厂模式简单说,就是定一个函数,在函数内部创建一个对象定义属性并返回。
可以无数次调用这个函数,根据参数返回包含三个属性和一个方法的对象。书中说:虽然工厂模式解决了创建多个相似对象的问题,但却没有解决对象识别问题(即怎样知道一个对象的类型)。这个问题想说的是:通过函数创建的实例,无论卢本伟也好还是马飞飞也好,全部都是Object的实例。没什么意义啊,为了解决这种问题。
---------------------------于是构造函数模式出来了
2 构造函数模式
构造函数可以用来创建特定类型的对象。像Object和Array这样的原生构造函数,在运行时会自动出现在执行环境中。我们可以自定义构造函数,从而自定义对象类型的属性和方法。将前面工厂模式重写如下:
在这个栗子中,Person()函数取代了createPerson()函数,其中代码有些相同部分外,有以下不用之处
- 没有显示的创建对象
- 直接将属性和方法赋值给了this对象
- 没有return 语句
按照惯例,构造函数始终使用一个大写字母开头,而非构造函数以小写字母开头,主要是区分其他函数,因为构造函数本身也是函数,只不过用来创建对象而已。
那么。我们在创建Person的实例的时候,用到了new 操作符。用这种方式调用构造函数会经历一下4个步骤
1 创建一个新对象
2 将构造函数的作用域赋值给新对象(因此this就指向了这个对象)
3 执行构造函数中的代码(这个时候新对象就添加了属性)
4 返回新对象
前面的栗子最后,person1和person2分别保存着Person的一个不用的实例。这两个对象都有一个constructor属性,该属性指向Person.
对象的constructor属性最初是用来标识对象类型的,但是检测对象类型还是instanceof操作符要更可靠一些。
我们这个栗子中创建的对象既是Object的实例,也是Person的实例。
创建自定义构造函数以为可以将它的实例标记为一种特定的类型,这也正是构造函数模式胜于工厂模式的地方。
构造函数模式也不是没有缺点。使用构造函数的主要问题是:每个方法都要在每个实例上创建一遍。上面的例子中,person1和 person2中都有一个名为sayName()的方法,但是两个方法不是同一个Function的实例,在ES中函数也是对象。每定义一个函数,也就是实例化一个对象,逻辑上
this.sayName = function(){} 等价于 this.sayName = new Function();
完成同样任务的方法,在创建实例的时候,new Function重复调用,在堆中重复开辟空间,是不是有点浪费?
ok! 于是我们就想到了把函数定义在构造函数外部来解决这个问题
在这个例子中,我们将sayName()函数定义到了构造函数外部。而在构造函数的内部我们引用全局的sayName函数。这样一来,person1和person2的对象就共享了全局作用域中的同一个sayName函数。但是新问题又来了,在全局中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实,而且定义过多的函数可能导致全局变量污染。
----------------------于是原型模式创建对象出现了
3 原型模式
我们创建的每个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象上定义的所有属性和方法可以被所有的实例共享。按照字面意思来理解,那么prototype就是通过调用构造函数而创建的那个对象实例的原型对象。直接上图:
在这里例子中,我们直接将sayName()方法和所有属性直接添加到Person的prototype中。构造函数为空函数,也仍然可以通过调用构造函数来创建对象,而且新对象还会据有相同的属性和方法,但与构造函数模式不同的是,新对象的这些属性和方法是由所有实例共享的。person1和person2的访问都是同一组属性的同一个sayName()函数,因此打印为true。
如上图所示,展示了Person构造函数,Person的原型属性以及Person实例之间的关系。Person.protorype指向了原型对象,而
Person.prototype.constructor又指回了Person。person1和person2包含一个内部属性__proto__,该属性指向了Person.prototype;虽然实例不包含属性和方法,但是我们却可以调用person1.sayName().这是通过查找对象属性的过程来实现的。
虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性。而该属性与实例原型中的一个属性同名,就会在实例中创建该属性,并屏蔽原型中的那个属性。
在这个例子中,person1的name被一个新值屏蔽了,访问person1.name的时候,需要读取它的值,因此就会在这个实例上搜索一个名为name的属性。如果存在就不必在搜索原型了。同样访问person2.name,并没有在实例上发现该属性,就会继续搜索原型,结果在那里找到了name属性
我们注意到了,前面例子中每添加一个属性和方法就要敲一边Person.prototype。为减少不必要的输出,更常用的做法是用包含所有属性和方法的对象字面量来重写整个原型对象。
上面的代码中,我们讲Person.prototype设置为一个对象字面量形式创建的对象。最终的结果相同。但有一个例外:constructor的属性不再指向了Person,而是指向了Object构造函数,尽管instanceof操作符还能返回正确的结果,但是通过constructor已经无法确定对象类型了。
如果你的constructor的值真的很重要,可以这样设置:
以上代码特意包含了一个constructor属性,并将它的值设置为Person,从而确保了通过该属性能访问到适当的值。
注意:这种方式重设constructor属性会导致它的**[[Enumerable]]** 特性备设置为true,默认情况下,原生的constructor属性是不可枚举的。因此如果你使用兼容ES5的javascript引擎,可以试试用Object.defineProperty()
3.1 原型的动态性
由于原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能立即从实例上反映出来,即使是先创建实例后修改原型也如此。
以上代码中,先创建了一个Person实例,并将其保存在person中,下一条语句在Person.prototype添加了一个sayHi()方法。即使person实例是在添加新方法之前创建的,但它仍可以访问这个新方法,但我们调用person.sayHi()时,首先会在实例中搜索名为sayHi的属性,在没找到的情况下,会继续搜索原型。因为实例和原型之间的连接只不过是一个指针,并非一个副本,因此可以在原型中找到新的sayHi属性并返回保存在那里的函数
尽管可以随时为原型添加属性和方法,并且修改能立即在所有对象实例中反映出来,但是如果重写整个原型对象,情况就不一样了。我们知道,嗲用构造函数的时候,会为实例添加一个指向原型的__proto__指针,而把原型修改为另一个对象,就等于切断了构造函数与最初原型之间的联系。看下面
以上代码中,我们先创建了Person的一个实例,然后又重写了其原型对象。然后在调用friend.sayHi发生了错误,因为friend指向的原型中不包含该属性。
下图是重写原型对象之前
下图是重写原型对象之后
从上面可以看出,重写原型对象切断了现有原型与任何之前已经存在的对象实例之间的关系;它们引用的仍然是最初的原型
3.2 原生对象的原型
原型模式的重要性不仅提到现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。(Object、Array、String等等)都在其构造函数的原型上定义了方法。例如,在Array.prototype中可以找到sort()方法,而在String.prototype中可以找到substring()方法,如下所示
alert(typeof Array.prototype.sort); //*function*
alert(typeof String.prototype.substring); //*function*
通过原生对象的原型,不仅可以取得所有默认方法的引用,而且也可以定义新方法。可以像修改自定义对象的原型一样修改原生对象的原型,因此可以随时添加方法。下面的代码就给基本包装类型String添加一个名为startWith()的方法
String.prototype.startsWith = function(text) {
return this.indexOf(text) == 0;
}
var msg = "Hello World";
alert(msg.startsWith("Hello")); // true
这里新定义的startWith()方法会在传入的文本位于一个字符串开始时返回true。既然方法被添加给了String.prototype,那么当前环境中的所有字符串就都可以调用它。
3.3 原型对象的问题
原型模式也不是没有缺点。首先,它省略了为构造函数传递初始化参数这一环节,结果是所有势力在默认情况下都会取得相同的属性值。这还不是最大的问题。原型模式最大的问题它共享的本性导致的。
为什么这么说呢?原型中所有属性被很多实例共享,这种共享对函数非常合适,对于那么包含基本值的属性也说得过去,(通过在实例上添加一个同名属性,可以隐藏原型中对应的属性),然而,对于包含引用类型值得属性来说,问题就比较突出,来看下面的例子
function Person() {}
Person.prototype = {
constructor: Person,
name: "卢本伟",
age: 29,
job: "主播",
friends: ["马飞飞", "UU妹"],
sayHi: function () {
console.log("伞兵一号卢本伟,申请出站!");
},
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push("帝师");
console.log(person1.friends); // [ '马飞飞', 'UU妹', '帝师' ]
console.log(person2.friends); // [ '马飞飞', 'UU妹', '帝师' ]
console.log(person1.friends === person2.friends); // true
在此,Person.prototype对象有一个名为friends的属性,该属性包含一个字符串数组。然后,创建了Person的两个实例,接着修改了person1.friends引用的数组。由于friends数组存在于Person.prototype而不是person1中,所以我们修改person1.friends也会通过person2.friends反映出来。可以,实例一般都是要有属于自己的全部属性的。而这个问题正是我们很少看到有人单独使用原型模式的原因所在
---------------------- 于是组合模式出现了
4 组合使用构造函数模式和原型模式
创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用来定义实例属性,而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省内存。此外,这种混成模式还支持向构造函数传递参数;集两种模式之长。看下面重写前面的代码
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.friends = ["马飞飞", "UU妹"];
}
Person.prototype = {
constructor: Person,
sayHi: function () {
console.log(this.name);
},
};
var person1 = new Person("卢本伟", 29, "LOL主播");
var person2 = new Person("帝师", 26, "户外主播");
console.log(person1.friends); // [ '马飞飞', 'UU妹' ]
console.log(person2.friends); // [ '马飞飞', 'UU妹' ]
console.log(person1.friends === person2.friends); // false
console.log(person1.sayHi === person2.sayHi); // true
在这个例子中,实例属性都是定义在构造函数中的,而由所有实例共享的属性constructor和方法sayHi()则是在原型中定义。而修改person1.friends,并不会影响到person2.friends,因为他们分别引用不同的数组。
有的人就是看不惯这个形式,独立的构造函数和独立的原型,就想把所有信息封装在构造函数中,而通过在构造函数中初始化原型(在必要的情况下),又保持同时使用构造函数和原型的优点
-------------------------- 于是动态原型模式出现了
5 动态原型模式
来看代码
function Person(name, age, job) {
// 属性
this.name = name;
this.age = age;
this.job = job;
}
// 方法
if (typeof this.sayName != "function") {
Person.prototype.sayName = function () {
console.log(this.name);
};
}
var friend = new Person("卢本伟", 29, "LOL主播");
friend.sayName();
上面的方法,只有在sayName()不存在的情况下,才会将它添加到袁兴中。这段代码只有在初次调用构造函数时才会执行。此后,原型已经初始化完成了,不需要在做什么修改了。这里对原型所做的修改,能够立即在所有实例中得到反映。其中 if 语句检查的可以使初始化之后应该存在的任何属性或方法,不必用一大堆if语句检查每个属性和方法,只用检查其中一个即可。对于这个模式创建的对象,还可以使用instanceof操作符确定它的类型。
注意事项:使用动态原型模式时,不能使用对象字面量重写原型。前面已经解释过了,如果在已经创建了实例的情况下重写原型,那么就会切断现有实例与新原型之间的联系
6 寄生构造函数模式
通常在前面几种模式都不使用的情况下,可以使用寄生构造函数模式。这种模式的基本思想是创建一个函数,该函数的作用是仅仅封装创建对象的代码,然后再返回新创建的对象;但是从表面上看,这种函数又很像是典型的构造函数,看下面代码
function Person(name, age, job) {
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function () {
console.log(this.name);
};
return o;
}
var friend = new Person("卢本伟", 29, "LOL主播");
friend.sayName(); // 卢本伟
这个例子中,Person函数创建了一个新对象,并以相应的属性和方法初始化该对象,然后返回这个对象。除了使用new操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式其实是一模一样的。构造函数在不返回值的情况下,默认会返回新对象实例。而通过在构造函数的末尾添加一个return语句,可以重写调用构造函数时返回的值。
这个模式可以在特殊的情况下用来为对象创建构造函数,假设我们想创建一个具有额外方法的特殊数组,由于不能直接修改Array构造函数,因此可以使用这个模式
function SpecailArray() {
// 创建数组
var values = new Array();
// 添加值
values.push.apply(values, arguments);
// 添加方法
values.toPipeString = function () {
return this.join("|");
};
// 返回数组
return values;
}
var colors = new SpecailArray("red", "blue", "green");
console.log(colors.toPipeString()); // red|blue|green
在这个例子中,我们创建了一个名叫SpecialArray 的构造函数。在这个函数内部,首先创建了一个数组,然后push()方法(用构造函数接收到的所有参数)初始化了数组的值。随后又给数组实例添加了个toPipedstring()方法,该方法返回以竖线分割的数组值。最后,将数组以函数值的形式返回。接着,我们调用了specialArray构造函数,向其中传入了用于初始化数组的值,此后又调用了toPipedstring()方法。
关于寄生构造函数模式,有一点需要说明:首先,返回的对象与构造函数或者与构造函数的原型属性之间没有关系;也就是说,构造函数返回的对象与在构造函数外部创建的对象没有什么不同。为此,不能依赖instanceof操作符来确定对象类型。由于存在上述问题,我们建议在可以使用其他模式的情况下,不要使用这种模式。
7 稳妥构造函数模式
道格拉斯·克罗克福德(Douglas Crockford)发明了JavaScript中的稳妥对象( durable objects)这个概念。
所谓稳妥对象,指的是没有公共属性,而且其方法也不引用this的对象。稳妥对象最适合在一些安全的环境中(这些环境中会禁止使用this和new),或者在防止数据被其他应用程序(如Mashup程序)改动时使用。
稳妥构造函数遵循与寄生构造函数类似的模式,但有两点不同:一是新创建对象的实例方法不引用this;二是不使用new操作符调用构造函数。按照稳妥构造函数的要求,可以将前面的Person构造函数重写如下。
function Person(name, age, job) {
// 创建要返回的对象
var o = new Object();
// 可以在这里定义私有变量和函数
// 添加方法
o.sayName = function () {
console.log(name);
};
// 返回对象
return o;
}
注意,在以这种模式创建的对象中,除了使用sayName()方法之外,没有其他办法访问name值,可以向下面使用稳妥的Person构造函数
var friend = new Person("卢本伟", 29, "LOL主播");
friend.sayName();// 卢本伟
这样,变量person中保存的是一个稳妥对象,而除了调用sayName()方法外,没有别的可以访问其数据成员。及时有其他代码会给这个对象添加方法或数据成员,但也不可能有别的办法访问传入到构造函数中的原始数据。
稳妥构造函数模式提供的这种安全性,使得它非常适合在某些安全执行环境。