NIKO'S BLOG

面向对象二之创建对象

摘要

书中介绍了创建对象的7种模式,着重介绍前面4种,后面3种貌似不常用。前面4种模式的出场顺序都是遵循着为了解决前一种模式出现的问题而出现的。

正文

工厂模式

写法

当大量创建同类功能的对象时,最基本的创建对象的写法往往意味着大量重复的代码。为了解决这个问题,工厂模式应运而生。看代码:

1
2
3
4
5
6
7
8
9
10
11
12
function createPerson(name,age,job){
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function(){
console.log(o.name);
};
return o;
}
var person1 = createPerson('nikolaus',23,'Frond-End');
var person2 = createPerson('liuyu',20,'Designer');

工厂模式的问题

上面的代码确实解决了大量重复代码的问题,而且可以在每次创建一个对象实例的时候传入参数。但是这种方式无法识别出这些实例对象是出于同一类的。只能知道它们都是Object对象的实例:

1
2
console.log(person1 instanceof Object); //true
console.log(person2 instanceof Object); //true

构造函数模式

写法

为了解决工厂模式中出现的问题,我们可以采用构造函数模式:

1
2
3
4
5
6
7
8
9
10
function Person(name,age,job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function(){
console.log(this.name);
};
}
var person1 = new Person('nikolaus',23,'Frond-End');
var person2 = new Person('liuyu',20,'Designer');

区别

构造函数模式与工厂模式的区别在于:

  • 没有显示的创建对象这一步,即var o = new Object();
  • 构造函数模式直接将属性和方法赋给this对象。
  • 没有return语句。
  • 构造函数的函数名首字母是大写的(其实这只是一个规范,写成小写也并没有语法错误)。

在用构造函数创建一个对象实例时,实际上在javascript引擎中经历了以下几步:

  • 创建一个新的空的对象(这个对象是不是用new Object()来创建的我就不知道了)。
  • 把构造函数的作用域赋给新对象(因此,this指向新对象)。
  • 执行构造函数内部的代码(也就是为新对象添加属性和方法)。
  • 返回全新的对象。

构造函数也是函数

实际上我们可以用下面的代码来模拟这个过程,虽然我不知道它底层是不是这样实现的:

1
2
var person1 = new Object();
Person.say(person1,'nikolaus',23,'Frond-End');

关于call()apply(),可以参见我的另一篇文章:

构造函数本质上还是一个函数。除了上面的那种用call来延长其作用域的用法,我们也可以直接调用,就像下面这样:

1
2
Person('nikolaus',23,'Frond-End');
console.log(window.name); //nikolaus

由于是在全局作用域中执行的函数。this就指向了window,这个过程就相当于为window添加了若干属性和方法。

构造函数模式的问题

“一切皆对象”。

javascript中的函数也是对象,所有的函数都是Function的实例,所以上面例子的代码可以这样写:

1
2
3
4
5
6
7
8
function Person(name,age,job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function(){"console.log(this.name);"}
}
var person1 = new Person('nikolaus',23,'Frond-End');
var person2 = new Person('liuyu',20,'Designer');

这样,问题就很明显了:每次创建一个Person的实例对象的时候,都会在实例对象内部重新创建一个Function的实例,它们的功能是一样的,但是它们并不来自一个引用,通过下面的代码可以看出这一点:

1
console.log(person1.sayName == person2.sayName); //false

其实,解决这个问题也不难,我们可以把方法的声明拿到构造函数外面,让方法保存的只是一个指针,这样,多个实例对象的方法就是共用的了:

1
2
3
4
5
6
7
8
9
function Person(name,age,job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName(){
console.log(this.name);
}

没错,是解决了。但是一个全局的函数只是作为一个对象的方法而存在的,这对全局作用域是一种侮辱。而且如果对象要定义很多的方法,就要向全局作用域添加很多变量,严重污染了全局作用域,这个类也就毫无封装性可言了。

原型模式

写法

原型模式可以很好的解决构造函数模式存在的问题,先看代码:

1
2
3
4
5
6
7
8
9
function Person(){};
Person.prototype.name = 'nikolaus';
Person.prototype.age = 23;
Person.prototype.job = 'Front-End';
Person.prototype.sayName = function(){
console.log(this.name);
}
var person1 = new Person();
var person2 = new Person();

我们创建的每个函数都有一个prototyp属性,这个属性是一个指针,指向一个对象,这个对象保存了特定类型的所有实例的共有的属性和方法。要理解原型模式,必须要先理解原型对象。

理解原型对象

各种概念

这一块有很多概念,如果不理解,很容易混淆,所以我单独把这些概念拎出来:

  • prototype属性
  • 原型对象prototype
  • constructor属性
  • 构造函数(Person)
  • 实例对象(person1)
  • 内部属性[[prototype]]
  • __proto__属性(左右各两个下划线)

所有的函数在被创建的时候,引擎都会为它创建一个原型对象prototype,前面说过每个函数都有一个prototype属性,这个prototype属性就是指向原型对象prototype的一个指针。当原型对象prototype被创建时,会自动的为它添加一个constructor属性,其他的方法都是继承自Object。constructor属性也是一个指针,指回构造函数实例对象又有一个内部属性[[prototype]],这个属性也是一个指针,指向构造函数原型对象prototype。但是这个内部属性[[prototype]]并没有标准的方式来访问它,Chrome、Safari、Firefox在每个对象上支持一个__proto__属性,在其他实现中,这个属性对脚本不可见。

好了,到此为止,是不是晕了,反正我当时是晕了,花了好久才捋清它们之间的关系:

  • 只有函数有prototype属性,对象没有,对象有的是内部属性[[prototype]]
  • 原型对象prototype实例对象都有constructor属性(当然,实例对象继承自原型对象,下面讲),而且都指向构造函数
  • Person.prototype = 原型对象prototype
  • Person.prototype.constructor = Person = person1.constructor
  • person1.__proto__ = Person.prototype

isPrototypeOf()

我们已经知道在一些实现中,我们无法访问到[[prototype]],但是我们可以用isPrototypeOf()方法来检测

1
console.log(Person.prototype.isPrototypeOf(person1)); //true

Object.getPrototypeOf()

ECMAScript为我们提供了Object.getPrototypeOf()方法来返回[[prototype]]的值:

1
console.log(Person.prototype == Object.getPrototyeOf(person1)); //true

原型链

在访问一个实例对象的属性或方法的时候时候实际上是一系列的搜索过程。还是以上面的实例代码为例。当访问person1.name的时候,引擎会先到person1的实例上去找,如果找到了,就会直接返回找到的结果,咱就不继续找了。可是由于name属性是定义在原型上的,并不是实例属性,所以在实例上找不到,找不到就往它上一层的原型中找,找到了,就返回,找不到,继续往上,一直找到Object的原型中去。这中查找是按照一种链式来查找的,这个链就是原型链。

实例不能修改原型属性,只能屏蔽原型属性(只对于基本简单数据类型)

我们已经知道,通过实例我们可以访问保存在原型中的属性,但并不能修改原型属性。在实例上定义原型中的同名属性,会阻止我们访问原型中的这个属性,因为沿着原型链的查找会在实例中停止。要恢复对这个原型属性的访问,只能通过delete操作符删除这个实例属性来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person(){};
Person.prototype.name = 'nikolaus';
Person.prototype.age = 23;
Person.prototype.job = 'Front-End';
Person.prototype.sayName = function(){
console.log(this.name);
}
var person1 = new Person();
var person2 = new Person();
person1.name = 'niko';
console.log(person1.name); //niko
console.log(person2.name); //nikolaus
delete person1.name;
console.log(person1.name); //nikolaus

hasOwnProperty()

hasOwnProperty()方法可以用来检测属性是存在于原型中还是存在于实例中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person(){};
Person.prototype.name = 'nikolaus';
Person.prototype.age = 23;
Person.prototype.job = 'Front-End';
Person.prototype.sayName = function(){
console.log(this.name);
}
var person1 = new Person();
var person2 = new Person();
person1.name = 'niko';
console.log(person1.hasOwnProperty('name')); //true
console.log(person2.hasOwnProperty('name')); //false
delete person1.name;
console.log(person1.hasOwnProperty('name')); //false

原型与in操作符

有两种使用in操作符的方式:单独使用和在for-in循环中使用。

单独使用

单独使用in操作符时,对象在能够访问到给定属性时返回true,无论这个属性存在于实例中还是存在于原型中。

1
2
3
4
5
var person1 = new Person();
var person2 = new Person();
person1.name = 'niko';
console.log("name" in person1); //true
console.log("name" in person2); //true

结合使用in操作符和hasOwnProperty()方法,可以确定一个属性是否存在于实例中

1
2
3
function hasPrototypeProperty(obj,propertyName){
return !obj.hasOwnProperty(propertyName) && (propertyName in obj);
}

返回true就说明属性只存在于实例中

在for-in循环中使用

for-in循环会返回所有对象可以访问、可以枚举(即[[ennumerable]]为true)的属性,无论这个属性是存在于实例中还是存在于原型中。如果实例中重写了继承自Object的方法,这个方法会变为可枚举属性。因为按照规定,所有用户自定义的属性都是可枚举的

1
2
3
4
5
6
7
8
9
10
var o = {
toString : function(){
return 'hello world';
}
}
for(var prop in o){
if(prop == 'toString'){
console.log('Found toString'); //Found toString
}
}

Object.keys()

要想获取对象上所有可枚举的实例属性,需要用到Object.keys(obj)方法,这个方法返回一个由所有可枚举属性组成的字符串数组。

Object.getOwnPropertyNames()

要获取对象上所有的实例属性,而无论属性是否可枚举,可以用Object.getOwnPropertyNames(obj)方法,返回值也是字符串数组。

更简单的原型模式写法

上面例子中在使用原型模式为对象添加属性和方法的时候,需要写多次Person.prototype,很麻烦。使用对象字面量的写法可以简化这个过程:

1
2
3
4
5
6
7
8
9
function Person(){};
Person.prototype = {
name : 'nikolaus',
age : 23,
job : 'Front-End',
sayName : function(){
console.log(this.name);
}
};

但是,有一个问题。我们前面说过,所有函数创建的时候,引擎都为它创建了一个原型对象prototype,原型对象prototype的constructor属性又指向构造函数Person.prototype.constructor = Person。但是,这里用字面量的写法,就相当于重写了构造函数的原型对象。可以这样理解:有两个原型对象,一个是开始引擎创建的默认的原型对象,就叫他旧的原型对象吧;一个是用户自己用字面量形式创建的对象,我们称他为新的原型对象。现在用户把新的原型对象赋值给了构造函数的prototype属性,就切断了构造函数与旧的原型对象之间的关系。所以,现在的原型对象的constructor属性就不再指向构造函数了,我们需要手动的把它指回构造函数,像下面这样:

1
2
3
Person.prototype = {
constructor : Person
};

但是,新的问题又出现了,还记得上面我们说过所有用户自定义的属性都是可枚举的吗?constructor属性本来是继承自Object的,是不可枚举的,现在被用户重写后,这个属性变为可枚举的了,我们可以用Object.defindeProperty()来定义这个属性:

1
2
3
4
Object.defineProperty(Person.prototype,'constructor',{
enumerable : false,
value : Person
});

在这里记住一点:字面量写法不是在原有的原型对象上增删属性,而是把一个新的对象赋值给了构造函数的prototype属性,这实际上是重写了原型对象,切断了构造函数与原有的原型对象之间的连接

原型的动态性

由于实例对象是继承自原型对象的,所以,原型的改变会立即反映在所有继承自它的实例对象上,即使这个实例对象是在原型对象发生改变之前被创建的:

1
2
3
4
var person1 = new Person();
console.log(person1.name); //nikolaus
Person.prototype.name = 'liuyu';
console.log(person1.name); //liuyu

但是,不能使用字面量写法,原因参考上面说过的:

1
2
3
4
5
6
var person1 = new Person();
console.log(person1.name); //nikolaus
Person.prototype = {
name : 'liuyu';
};
console.log(person1.name); //nikolaus

原生对象的原型

我们所熟知的原生的引用类型(如Array、Date等)也是用原型来定义的,所以我们可以在通过原型来访问这些引用类型的方法:

1
2
console.log(typeof Array.prototype.sort); //function
console.log(typeof String.prototype.subString); //function

我们还可以在这些原生的引用类型的原型上定义方法,当然,这并不推荐:

1
2
3
4
5
6
Array.prototype.startsWith = function(text){
return this.indexOf(text) == 0;
};
var msg = "hello world";
var t = "hello";
console.log(msg.startsWith(t)); //true

原型模式的问题

原型模式也是有缺点的。首先,它省去了构造函数模式中的传参这一步,使得所有同类型的实例对象最初都只能去到默认一样的值。当然,这还不是原型模式的主要问题,原型模式的主要问题是由其共享的特性导致的。

原型模式可以使得多个实例对象共享属性和方法。一般地,我们确实希望方法可以被多个实例对象共享,但是希望每个对象拥有各自不同的实例属性。对于包含简单数据类型值的属性,也还能说的过去。因为,毕竟我们还可以通过在实例对象上定义同名属性来屏蔽原型上的属性。但是,对于包含引用类型值的属性,就有问题了:

1
2
3
4
5
6
7
8
9
10
11
function Person(){};
Person.prototype = {
constructor : Person, //这里就不管它的[[enumerable]]了,知道就好
name : 'nikolaus',
friends : ['Jan','anton']
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push('Tobias');
console.log(person1.friends); //'Jan,anton,Tobias'
console.log(person2.friends); //'Jan,anton,Tobias'

可见,并不能通过实例来屏蔽原型中包含引用类型值的属性,而是会修改,这会反应在所有的实例中,并不是我们想看到的。

组合使用构造函数和原型模式

最常见的创建自定义类型的方式,就是组合使用构造函数和原型模式。应该属于实例对象私有的属性用构造函数定义,共有属性和方法在原型对象上定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Person(name,age,job){
this.name = name;
this.age = age;
this.job = job;
this.friends = ['Jan','anton'];
}
Person.prototype = {
constructor : Person,
sayName : function(){
console.log(this.name);
}
}
var person1 = new Person('nikolaus',23,'Frond-End');
var person2 = new Person('liuyu',20,'Designer');
person1.friends.push('Tobias');
console.log(person1.friends); //'Jan,anton,Tobias'
console.log(person2.friends); //'Jan,anton'
console.log(person1.friends == person2.friends); //false
console.log(person1.sayName == person2.sayName); //true

动态原型模式

前面组合使用构造函数和原型模式已经很好了,但是如果能把定义原型的代码也封装进构造函数,强迫症患者应该就感到更舒服了吧…动态原型模式就是干这个事的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);
};
//...more function here
}
}

书中关于这段的描述,我之前一直不是很理解:

可以通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型。

其中,if 语句检查的可以是初始化之后应该存在的任何属性或方法——不必用一大堆if语句检查每个属性和每个方法;只要检查其中一个即可。

在网上查了一番,这个答案解决了我的困惑,这里不再赘述。

另外需要注意的一点,if块里不能用字面量的形式来定义,原因参考我前面关于原型的动态性的描述。如果非要用字面量来表示,可以像下面这样把新的对象return,参照js面向对象中的动态原型模式怎么理解? - 艾密尔的回答 - 知乎,感谢!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
}
}
return new Person(age,name,job);
}
}

寄生构造函数模式

关于这个模式,还是先上代码:

1
2
3
4
5
6
7
8
9
10
11
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 person1 = new Person('nikolaus',23,'Front-End');

其实不难发现,这个模式除了用new操作符来创建对象实例和把函数写成构造函数的形式(函数名首字母大写)之外,和工厂模式没有什么区别。这个模式主要用来扩展现有的类型(比如加个方法),但又不想在现有类型上直接修改,因为这可能会造成不可预知的后果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function SpecialArray(){
var values = new Array();
values.push.apply(values,arguments);
values.toPipedString = function(){
return this.join('|');
};
return values;
}
var color = new SpecialArray('red','green','blue');
console.log(color.toPipedString()); //'red|green|blue'

以上实例就是基于这样一种需求:需要添加一个数组方法toPipedString(),用竖线|来分隔给定数组。原生的Array没有提供这样一种方法,在原生上直接定义又不太好,所以就用到了这种模式。

构造函数在不返回值的情况下,默认会返回新对象实例。而通过在构造函数的末尾添加一个return语句,可以重写调用构造函数时返回的值。

返回的对象与构造函数或者与构造函数的原型属性之间没有关系。不能依赖instanceof操作符来确定对象类型。

稳妥构造函数模式

道格拉斯·克罗克福德(Douglas Crockford)发明了JavaScript中的稳妥对象(durable objects)这个概念。所谓稳妥对象,指的是没有公共属性,而且其方法也不引用this的对象。

稳妥构造函数遵循与寄生构造函数类似的模式,但有两点不同:一是新创建对象的实例方法不引用this;二是不使用new操作符调用构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person(name, age, job){
//创建要返回的对象
var o = new Object();
//可以在这里定义私有变量和函数
//添加方法
o.sayName = function(){
console.log(name);
};
//返回对象
return o;
}

注意,在以这种模式创建的对象中,除了使用sayName()方法之外,没有其他办法访问name的值。可以像下面使用稳妥的Person构造函数。

1
2
var friend = Person('nikolaus',23,'Front-End');
friend.sayName(); //"nikolaus"

这里为什么说“除了使用sayName()方法之外,没有其他办法访问name的值”呢?因为稳妥模式不允许使用this,上面代码中第9行访问name值是因为有上下文执行环境,访问的是传入函数的参数的值。如果在这个函数外部再想访问创建对象时传入函数的原始数据,由于稳妥模式不允许使用this,函数外部又没有上下文执行环境了,所以访问不到:

1
2
3
4
friend.sayAge() = function(){
console.log(age);
}
friend.sayAge(); //undefined

如果你这样写,确实可以在外部访问到创建对象时传入函数的原始数据:

1
2
3
4
friend.sayJob() = function(){
console.log(this.job);
}
friend.sayJob(); //Front-End

但是,别忘了稳妥构造函数模式的定义中是不允许使用this的!用了this就已经不是稳妥构造函数模式了。

好了,废话有点多,这种模式应该用的也不多吧,主要是安全。

稳妥构造函数模式提供的这种安全性,使得它非常适合在某些安全执行环境——例如,ADsafe(www.adsafe.org)和Caja(http:/code.google.com/p/google-caja/)提供的环境——下使用。

笔者注

博客内文章若不另外注明,均为原创。转载请注明出处,谢谢。

博客内容主要是对新知识的归纳、总结。记录博客的目的也是为了方便自己日后的巩固复习。欢迎大家提出文章中理解错误的地方,不胜感激。若文章对你有帮助,将是我的荣幸。