创建对象

本文是对 JavaScript 高级程序设计 一书中相关章节的整理,加上自己的理解


1. Object 构造函数和对象字面量

1.1 Object 构造函数

创建一个自定义对象最简单的方式就是创建一个 Object 实例然后再为它添加属性和方法,如下:

var person = new Object();
person.name = "Paco";
person.age = 26;
person.saySomething = function() {
alert("Hi, I'm " + this.name);
};

1.2 对象字面量

使用对象字面量的方式可以更简单地创建一个单例对象:

var person = {
name: "Paco",
age: 26,
saySomething: function() {
alert("Hi, I'm " + this.name);
}
}

采用这两种方式构造一个单例对象是很方便的,但是要创建多个共有一些属性或方法的对象时,每次创建一个对象,就要重写一遍共有的属性或方法,这样产生了很多重复的代码。后来就演变出工厂模式。

2. 工厂模式

用一个函数来封装以 new Object() 接口创建对象的细节:

function createPerson(name, age) {
var o = new Object();
o.name = name;
o.age = age;
o.saySomething = function() {
alert("Hi, I'm " + this.name);
};
return o;
}
var person1 = createPerson("Paco", 26);
var person2 = createPerson("xiaoming", 10);
person1 instanceof Object; // true
person2 instanceof createPerson; // false

可以无数次调用这个函数,不用重复那些细节代码,每次它都会返回一个包含两个属性一个方法的对象。

工厂模式的问题

但是,工厂模式虽然解决了创建多个相似对象的问题,却没有解决对象识别的问题。在检测新创建的对象是谁的实例时,只能知道是 Object 的实例,而不能知道是来自什么类型(人、动物或什么)的对象,是从什么模板创建的。

3. 构造函数模式

构造函数可以创建特定类型的对象。可以将前面的例子用构造函数改写:

function Person(name, age) {
this.name = name;
this.age = age;
this.saySomething = function() {
alert("Hi, I'm " + this.name);
};
}
var person1 = new Person("Paco", 26);
var person2 = new Person("xiaoming", 10);

这个例子中,Person()函数取代了 createPerson() 函数。person1 和 person2 分别保存着 Person 的一个不同的实例。这两个对象都有一个 constructor 属性,该属性指向对象的构造函数,即 Person。

alert(person1.constructor === Person); //true
alert(person2.constructor === Person); //true

但是说到检测对象的类型,还是 instanceof 操作符要更靠谱一些。我们例子中创建的所有对象即是 Object 的实例,又是 Person 的实例,这一点通过 instanceof 操作符可以得到验证。

alert(person1 instanceof Object); //true
alert(person1 instanceof Person); //true
alert(person2 instanceof Object); //true
alert(person2 instanceof Person); //true

这一点就要比工厂模式强,还可以知道实例是从哪个 Object 下面那一个对象模板得到。

构造函数虽然相比工厂模式可以知道对象的类型,但是跟工厂模式一样,不同实例中的同名方法函数(saySomething())是不相等的,因为函数也是对象,每个方法都要在每个实例上重新创建一遍,它们来自不同的 Function。
然而,完成同样任务的函数每次都要重新创建的确没有必要,况且有 this 在,根本不用在执行代码前就把函数绑定到特定对象上。大可把函数定义转义到构造函数外部,留个函数的指针在构造函数内部。

构造函数模式的问题

function Person(name, age) {
this.name = name;
this.age = age;
this.saySomething = saySomething;
}
function saySomething() {
alert("Hi, I'm " + this.name);
}
var person1 = new Person("Paco", 26);
var person2 = new Person("xiaoming", 10);

上面代码中 person1 和 person2 共享了 saySomething 方法,而且两个对象中的此方法是同一个函数,没有重复创建。但是,这里的函数是定义在全局作用域中的,如果对象有很多方法,就要在全局作用域中定义很多全局函数,这些函数没有丝毫封装性可言。

4.原型模式

我们知道函数也是对象,我们创建的每个构造函数都有一个 prototype 属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由通过构造函数创建的所有实例对象共享的属性和方法。prototype 就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。

function Person() {};
Person.name = "Paco";
Person.age = 26;
Person.saySomething = function () {
alert("Hi, I'm " + this.name);
}
var person1 = new Person();
var person2 = new Person();
person1.saySomething(); // "Hi, I'm Paco"
Person.prototype.isPrototypeOf(person1); // true
Object.getPrototypeOf(person1) === Person.prototype; // true
Person.prototype.constructor === person1.constructor; // true(都是 Person)
person1._proto_ === Person.prototype; // true

person1 和 person2 两个实例都不包含实例,但是我们仍然可以访问实例属性和方法,这是因为它们都包含了一个 内部属性,这个内部属性指向了Person.prototype,可以通过查找原型对象中的属性和方法来实现。我们可以通过以下几种方式来检测实例与原型对象之间到底有没有联系:

  1. 如上面代码所示,我们可以通过 isPrototypeOf 方法来确认一个对象是不是另一个实例的原型对象,还可以通过 Object.getPrototypeOf() 方法得到一个实例对象的原型对象。
  2. 原型对象中有一个 constructor 属性,指向构造函数,而每个实例中也有这样一个属性(其实也是共享),同样指向构造函数。
  3. Firefox、Safari 和 Chrome 在每个实例对象上都支持一个属性 _proto_,这个属性指向了实例的原型对象。

4.1 遍历问题

有了原型,在遍历对象属性时就出现了是否遍历原型对象的问题:

  1. for-in 循环,遍历实例对象和原型对象(未被屏蔽的)中所有属性,也可以结合 hasOwnProperty() 只遍历实例对象的所有属性
  2. Object.getOwnPropertyNames(), 返回一个数组,里面包含了指定对象的所有属性
  3. Object.keys() ,返回一个数组,里面只包含了指定对象中的可枚举属性

4.2 更简单的原型语法

上面代码中,我们是一个属性一个属性往原型对象中添加的,产生了很多重复性的操作。为了减少不必要的输入,也为了从视觉上更好地封装原型,更好的方法是用一个包含了所有属性和方法的对象字面量 重写 整个原型对象。

function Person() {};
Person.prototype = {
name: "Paco",
age: 26,
saySomething: function () {
alert("Hi, I'm " + this.name)
}
};
var person1 = new Person();
person1.constructor === Person; // false
person1.constructor === Object; // true
person1 instanceof Person; // true
person1 instanceof Objece; // true

因为这里是完全用一个对象字面量将原型对象覆盖了,所以自然 constructor 属性也就不是原来的值了。因为实例共享了原型对象中的 constructor 属性,所以实例中的 constructor 自然也就变了。如果想要检测实例对象的类型。可以用 instanceof。或者在重写原型对象的时候,在对象字面量中添加 constructor 属性,并且指向构造函数。如下:

function Person() {};
Person.prototype = {
constructor: Person,
name: "Paco",
age: 26,
saySomething: function () {
alert("Hi, I'm " + this.name)
}
};
var person1 = new Person();
person1.constructor === Person; // true
person1.constructor === Object; // true

4.3 原型的动态性

由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能立即从实例上反映出来,即使是先创建了实例后修改原型也一样。

function Person() {};
var person1 = new Person();
Person.prototype.name = "Paco";
person1.name === "Paco"; // true

但是如果采用对象字面量重写原型对象,则结果就会大不一样:

function Person() {};
var person1 = new Person();
Person.prototype = {
name: "Paco"
};
person1.name === "Paco"; // false

调用构造函数时会为实例添加一个指向 最初原型 的 [[Prototype]] 指针,而把原型修改为另一个对象就等于切断了构造函数与 最初原型 之间的联系,所以,改写之前的实例中的指针仍然指向的是最初的原型对象,而不是改写后的。

原型模式的问题

原型模式最大的问题在于原型对象中所有的属性被很多实例共享,这种共享对于函数是十分合适的,对于包含基本值的属性也不是问题,毕竟可以通过同名属性覆盖。但是,对于包含引用类型值的属性来说,就十分有问题了。

function Person() {};
Person.prototype = {
name: "Paco",
age: 26,
friends: ["laoluo", "xucen"],
saySomething: function () {
alert("Hi, I'm " + this.name)
}
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push("comma");
alert(person2.friends); // "laoluo,xucen,comma"
person1.friends === person2.friends; // true
person1.friends = ["refresh"];
alert(person1.friends); // "refresh"
alert(person2.friends); // "laoluo,xucen,comma"
person1.friends === person2.friends; // false

我们可以看出,实例化了两个人后,person1 有了个新朋友,添加到 friends 属性中,但是因为引用类型是共享传参,对引用类型做修改时,会直接在原地址上修改。 (但是如果是重新赋值的话,就不会影响到原地址中的值,我们在 person1 中对 friends 属性重新赋值,并没有影响到 person2 中的 friends 属性,这时,两个实例中的 friends 属性已经不指向同一个地址了。)

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

构造函数模式用于定义实例属性,而原型模式用于定义方法和共享属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混成模式还支持向构造函数传递参数。

function Person(name, age) {
this.name = name;
this.age = age;
this.friends = ["laoluo", "xucen"]
}
Person.prototype = {
constructor: Person,
saySomething: function () {
alert("Hi, I'm " + this.name);
}
};
var person1 = new Person("Paco", 26);
var person2 = new Person("xiaoming", 10);
person1.friends.push("comma");
alert(person1.friends); //"laoluo", "xucen"
alert(person2.friends); // "laoluo,xucen,comma"

在上面的例子中,实例属性都是在构造函数中定义的,而由所有实例共享的属性 constructor 和方法 saySomething() 则是在原型中定义的。而修改了 person1.friends,并不会影响到 person2.friends。
这种构造函数与原型混成的模式,是目前使用最广泛,认同度最高的一种创建自定义类型的方法。

6. 动态原型模式

组合使用构造函数和原型模式虽然基本上解决了所有可能遇到的问题,但是如果能将构造函数的定义和原型对象的设置封装到一起就更好了。这时就出现了动态原型模式,它把所有的信息都封装到了构造函数中,而通过在构造函数中初始化原型(仅在必要的情况下),又保持了同时使用构造函数和原型的优点:

function Person(name, age) {
this.name = name;
this.age = age;
this.friends = ["laoluo", "xucen"];
if (typeof this.saySomething != "function") {
Person.prototype.saySomething = function () {
alert("Hi, I'm " + this.name);
};
Person.company = "Smartisan";
};
};

上面的例子中,可以通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型。其中,if 语句检查的可以是初始化之后应该存在的任何属性或方法(只需检查其中一个即可)。

注意,这里同样不能使用对象字面量重写原型。这会使得在之前创建的实例和新的原型之间没有任何关系。

7. 寄生构造函数模式

可以理解为把构造函数寄生到了工厂模式上
代码形式如下:

function CreatePerson(name, age) {
var o = new Object();
o.name = name;
o.age = age;
o.saySomething = function() {
alert("Hi, I'm " + this.name);
};
return o;
}
var person1 = new CreatePerson("Paco", 26);
var person2 = new CreatePerson("xiaoming", 10);

创建对象实例的时候,采用 new 操作符调用函数,由于函数体内实际上是工厂模式的函数体,返回的对象重写了构造函数返回的对象。
这种模式通常用来创建具有特殊属性的数组,是因为数组的构造函数不能直接修改:

function SpecialArray() {
var sArray = new Array();
sArray.push.apply(sArray, arguments);
sArray.toPipedString = function () {
return this.join("|");
};
return sArray
}
var colors = new sArray("red", "green", "blue");
alert(colors.toPipedString()); // "red|green|blue"

在这个例子中,我们创建了一个名叫 SpecialArray 的构造函数。在这个函数内部,首先创建了一个数组,并且将构造函数接收的参数都 push 到这个数组中,然后我们又为这个数组添加了一个新方法,返回以竖线分割的数组值。