Serenader

Learning by sharing

在 JavaScript 中创建Object

JavaScript 是一门强类型的面向对象语言,实质是 Prototype-based Language ,语言本身没有 类 的概念。在 JavaScript 一切都是以对象的形式存在,包括 Function , Array , Object , String 等。接下来谈谈如何创建 Object 。

创建对象

1. 工厂模式

工厂模式是以一个函数封装一系列属性和方法的形式创建对象。下面是例子:
function createPerson(name,age,job){
	var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){
    	alert(o.name);
    }
    return o;
}

var person1 = createPerson("bill",20,"artist");
console.log(person1.name); //bill
person1.sayName(); //bill
由以上代码不难看出, Person 函数里面定义了一个局部对象,然后将传递给 Person 的参数赋予给这个局部对象的属性或者方法,然后再返回这个局部对象。这样通过 person1 = Person(arguments1,arguments2,arguments3) 就能够创建一个新对象, 并且该对象包含有几个属性和方法。
这种方法创建对象的好处是,可以无限次使用,并且每次调用都会返回一个包含几种特定的属性和方法。但是缺点是由这种方法创建出来的多个对象之间关系性不强,且给每个新建对象赋予独立的属性和方法,新建的对象之间不存在关联。故这种方法既浪费系统资源而且也没有解决对象识别的问题(即怎样知道一个对象的类型)。

2. 构造函数模式

ECMAScript 中的构造函数可以创建特定类型的对象,也可以创建自定义的构造函数,从而定义自定义的属性和方法。上面的例子可以用以下的构造函数的方法重写:
function Person(name,age,job){
	this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function(){
    	alert(this.name);
    }
}
var person1 = new Person("bill",20,"artist");
var person2 = new Person("sam",30,"doctor");
console.log(person1.name); //bill
person2.sayName(); //sam
从以上代码可以看出,最终结果和工厂模式的结果一样。但是里面的运行情况则完全不同。我们首先定义了构造函数 Person()(JavaScript 中我们约定构造函数的函数名的第一个字母为大写,目的是为了与其他函数区分),在其里面定义了 this.name this.age this.job this.sayName 这几个属性和方法,然后通过 new 操作符新建一个 Person 实例并传递相关参数,然后再赋值给 person1。用 new 操作符创建对象实际上经过了这四个过程:
  1. 创建新对象;
  2. 将构造函数的作用于赋给新对象;
  3. 为新对象添加属性和方法;
  4. 返回新对象。
由 new 操作符创建的对象中存在一个属性 constructor(构造函数) ,该属性指向 Person(即构造函数) 。我们可以显式访问它:
alert(person1.construtor);//Person
同时我们也可以用 instanceof 来检测对象是否是某个构造函数的实例:
alert(person1 instanceof Person);//true
以上代码检测了对象 person1 是否是构造函数 Person 的实例,执行结果返回 true 则表明是。通过这个我们就可以利用这种方法来判断对象的类型,这也就是比工厂模式好的地方。
构造函数其实也是函数,我们也可以把构造函数当普通函数来调用。
var person1 = new Person("bill",20,"artist");//以构造函数的方式调用

Person("lily",17,"singer");//以普通函数的方式调用,为 window 添加属性和方法

Person.call(o,"cherry",24,"artist");//在另一个对象的作用域中调用

person1.sayName();//bill
window.sayName();//lily
o.sayName();//cherry
当我们以普通函数的方式调用Person时,this 指针是指向 Global 对象(在浏览器中是 window 对象),所以执行 window.sayName() 才会返回 lily 。
使用构造函数的优点刚刚已经有提到了,就是能够判断对象的类型,但是也不是没有缺点的。其缺点就是,构造函数的方法都要在每个实例上面重新创建一遍。每个实例中的sayName() 方法不是同一个 Function 的实例。以下清晰地看出:
alert(person1.sayName === person2.sayName);//false
为每个实例创建独立的方法没有必要,从性能上讲也会浪费内存。我们可以在构造函数外面创建一个函数来解决这个问题:
function Person(name,age,job){
	this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = sayName;
}

function sayName(){
	alert(this.name);
}
var person1 = new Person("bill",20,"artist");
var person2 = new Person("sam",30,"doctor");
在这个例子中我们将sayName() 函数的定义放在了构造函数外面,然后在构造函数里面的 this.sayName 方法指向了这个函数。所以在创建出来的实例中的 sayName() 方法都指向了这个函数。但是这种方法也存在一个很大的弊端,即创建了一个全局函数。而且该函数只能被某个对象调用。这样就显得名不符实,而且如果构造函数有多个方法的话就要创建多个全局函数,这样构造函数就没有了封装性可言了。

3. 原型模式

在每个构造函数中都有一个 prototype 属性,指向一个对象。这个对象所包含的属性和方法都会被所有的实例共享。也就是说我们可以在 prototype 中定义属性和方法而不必在构造函数中定义。如下:
function Person(){}
Person.prototype.name = "bill";
Person.prototype.age = 20;
Person.prototype.job = "artist";
Person.prototype.sayName = function(){
	alert(this.name);
};

var person1 = new Person();
person1.sayName();//bill
var person2 = new Person();
person2.sayName();//bill
alert(person1.sayName === person2.sayName);//true

理解原型对象

在原型对象中,存在一个 constructor 属性,该属性指向 prototype 属性所在的函数。即 Person.prototype.constructor指向 Person 。当构造函数创建一个新实例时,实例内部存在一个指针,指向构造函数的原型对象。ECMA-262 第5版把这个指针叫 [[prototype]]。但是目前仍没有方法可以访问[[prototype]]。但是可以通过 isPrototypeOf()方法来确定对象之间是否存在这种关系。如:
alert(Person.prototype.isPrototypeOf(person1));//true
用原型模式创建的新实例中虽然实例不包含方法和属性但是却可以调用person1.sayName() 这是通过查找对象属性的过程来实现的。当访问或执行对象的属性或方法时都会执行一次搜索。这个搜索首先会在对象本身内部先进行。如果在对象内部找不到该属性或方法,则在原型对象中搜索。如果搜索到该属性或方法则返回。这就是多个实例共享原型所保存的属性和方法的基本原理。
我们再来看一个例子:
function Person(gender){
    this.gender = gender;
}

Person.prototype.gender = "";
Person.prototype.sayGender = function(){
    alert(this.gender);
};

var person1 = new Person("male");
var genderTeller = person1.sayGender;
var person2 = new Person("female");

person1.sayGender();//male
person2.sayGender();//female
genderTeller();//undefined


genderTeller.call(person1);
alert(genderTeller === person1.sayGender);//true
alert(genderTeller === person2.sayGender);//true
alert(genderTeller === Person.prototype.sayGender);//true
alert(person1.sayGender === person2.sayGender);//true
alert(person2.sayGender === Person.prototype.sayGender);//true
alert(person1.sayGender === Person.prototype.sayGender);//true
如果对原型对象不是很了解的话可能对上面的例子有点疑问。为什么执行 genderTeller() 返回的是 undefined 呢?
其原因是因为,在构造函数里面本来没有定义 sayGender() 这个方法,而是在原型对象中定义的。此时虽然构造函数没有定义这个方法但是实例却能够执行这个方法的原因是执行的时候在实例本身中搜索不到该方法,便从原型对象中搜索。最后在原型对象中找到了该方法,便返回该方法。所以,此时的 person1.sayGender 其实是指向于 Person.prototype.sayGender 。所以执行 genderTeller() 相当于执行 Person.prototype.sayGender ,而此时 this.gender = "" ; (因为 Person.prototype.gender = "") ,所以才会返回 undefined

虽然我们可以通过原型模式创建多个实例,但是却不能重写原型中的值。不过我们可以通过在实例中创建一个与原型中同名的属性,达到屏蔽原型中的属性的目的。如下:
function Person(){}
Person.name = "bill";
Person.age = 20;
Person.job = "artist";
Person.sayName = function(){
	alert(this.name);
}

var person1 = new Person();
person1.name = "sam";
person1.sayName();//sam
person2 = new Person();
person2.sayName();//bill
以上代码中我们为 person1 创建了 name 属性,然后把原型对象中的 name 属性屏蔽了。所以在访问 person1.name 的时候会先在实例中搜索 name 属性,然后返回该属性。而原型中的属性则不会被返回。也就是说,为实例创建与原型对象同名的属性时会屏蔽原型对象中的同名属性,而不是重写它。
要判断一个属性是存在于实例中还是原型中可以使用 hasOwnProperty() 方法。如下:
function Person(){}
Person.name = "bill";
Person.age = 20;
Person.job = "artist";
Person.sayName = function(){
	alert(this.name);
}

var person1 = new Person();

alert(person1.hasOwnProperty("name")); //false;

person1.name = "sam";
person.sayName();//sam
alert(person1.hasOwnProperty("name"));//true

更简单的原型方法

我们不难发现上面的原型模式中,如果要为原型对象添加属性或者方法,都要写一遍 Person.prototype. 。其实我们可以改成另外一种方法,达到更好地封装原型的功能,又能减少不必要的重复。这种方法是用一个包含所有属性和方法的对象字面量来重写整个原型对象。如下:
function Person(){}

Person.prototype = {
	name: "bill",
    age: 20,
    job: "artist",
    sayName: function(){
    	alert(this.name);
    }
};
这种方法的好处很明显。但是也有一个缺点,就是 Person.prototype.constructor 不再指向 Person 了。这是因为每创建一个函数,就会同时创建它的 prototype 对象,这个对象也会自动获得 constructor 属性。而我们用的这种方法本质上是完全重写了默认的 prototype 对象,因此 constructor 属性也就丢失了。为了保存原来的 constructor 属性,我们可以显式地定义它。如:
function Person(){}

Person.prototype = {
	constructor: Person,
    name: "bill",
    ...
}
这样就保存了之前的 constructor 属性了。

原型的动态性

请看例子:
var friend = new Person();
Person.prototype.sayHi = function(){
	alert("hi");
};
friend.sayHi();//hi
以上代码中我们先创建了实例,然后再为原型添加一个方法,最后在实例中访问这个方法,结果没有出错。这是因为每次访问方法都是一次搜索,在实例中搜索不到则到原型中搜索,最终搜索到了该方法。
虽然可以用以上的方法随时为原型添加方法和属性,并且能够立即在实例中反映出来。但是一旦重写了原型的话便会切断实例与原型的所有联系。如下:
function Person(){}
var friend = new Person();

Person.prototype = {
	constructor: Person,
    name: "bill",
    age: 30,
    job: "artist",
    sayName: function(){
    	alert(this.name);
    }
};

friend.sayName();//error
上面的例子可以看出,重写原型之后,之前的实例就与重写之后的原型断了联系了,它们引用的仍然是最初的原型。

原型对象的问题

尽管看起来原型模式挺好的,但是也存在一些缺点。首先,它省略了为构造函数传递参数这一个环节,结果所有的实例默认情况下都将取得相同的属性值。虽然这会在一定程度上带来不方便,但不是原型最大的缺点。其最大的缺点是由于共享所导致的。
原型中的所有属性都是被实例共享的。这种共享对于函数来说非常合适。但是对于包含基本值得属性倒也说得过去,因为我们可以为实例创建属性达到屏蔽原型属性的目的。共享最大的问题是,对于包含引用类型值得属性。下面来看例子:
function Person(){}

Person.prototype = {
	constructor: Person,
	name: "bill",
	age: 20,
	job: "artist",
	friends: ["sam","sarah"],
	sayName: function(){
    	alert(this.name);
	}
};

var person1 = new Person();
var person2 = new Person();

person1.friends.push("vans");

alert(person1.friends);//"sam,sarah,vans"
alert(person2.friends);//"sam,sarah,vans"
alert(person1.firends === person2.friends);//true
由以上例子可以看出,在一个实例中修改一个引用类型值得属性时,也会影响到另外的实例的这个属性值。在实际编程中,一般希望每个实例各自拥有自己的全部属性,而不受其他实例的影响。

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

通常在创建自定义类型的时候一般都是组合使用构造函数和原型模式。这样创建出来的实例,每个实例都拥有各自的一份属性的副本,同时又共享着对方法的引用,最大限度地节约了内存。同时,这种混合模式还允许构造函数传递参数,可谓是集二者之长。看下面的例子:
function Person(name,age,job){
	this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["mike","sarah","tim"];
}
Person.prototype = {
	constructor: Person,
    sayName: function(){
		alert(this.name);
    }
}

var person1 = new Person("bill",20,"artist");
var person2 = new Person("sam",16,"doctor");

person1.friends.push("vans");
alert(person1.friends); //"mike,sarah,tim,vans"
alert(person2.friends);//"mike,sarah,tim"
alert(person1.friends === person2.friends);//false
alert(person1.sayName === person2.sayName);//true
这种方法完美地解决了前面的各种方法的问题。这种方法是认同度最高的、使用最广泛的一种方法。

动态原型模式

我们可以将上面的组合模式再修改一下,把原型的修改封装到构造函数中。如下:
function Person(name,age,job){
	this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["mike","sarah","tim"];
    if (typeof this.sayName != "function"){
    	Person.prototype.sayName = function(){
        	alert(this.name);
        };
    }
}

var person1 = new Person("sam",20,"artist");
person1.sayName();//sam
以上代码将原型模式的重写改进了,将其放在构造函数里面,而且没有使用对象字面量来重写原型,因此不会使实例与原型的联系被切断。这也需要特别注意,在这种方法创建对象的情况下不能使用对象字面量。因为会重写原型对象。所以这种方法可以说非常完美。

以上是几种比较常用的创建对象的方法,但不是全部方法。还有一些比较不常用的比如寄生构造函数模式、稳妥构造函数模式等。由于版面关系就不作介绍了。
本文大量参考了 《JavaScript 高级程序设计》 中的第6章的 ”创建对象“ 这一部分。若有不妥请联系本站。
——完。