JavaScript不是一门真正的面向对象语言,因为它连最基本的类的概念都没有,因此它的对象和基于类的语言中的对象也会有所不同。ECMA-262把对象定义为:“无序属性的集合,其属性可以包含基本值、对象或者函数。” 严格来讲,这就相当于说对象是一组没有特定顺序的值。对象的每个属性或方法都有一个名字,而每个名字都映射到一个值。我们可以把ECMAScript的对象想象成散列表:无非就是一组名值对,其中的值可以是数据或函数。每个对象都是基于一个引用类型创建的,这个引用类型可以是原生类型,也可以是开发人员自己定义的。
一、理解对象
创建自定义对象的最简单方式就是创建一个Object的实例,然后为其添加属性和方法。代码如下:
1 var person = new Object(); 2 person.name = "Tom"; 3 person.age = 29; 4 person.job = "CEO"; 5 6 person.sayName = function(){ 7 alert(this.name); 8 }
简单点,可以用对象字面量创建对象。代码如下:
1 var person = { 2 person.name = "Tom"; 3 person.age = 29; 4 person.job = "CEO"; 5 6 person.sayName = function(){ 7 alert(this.name); 8 } 9 }
1、属性类型
ECMA-262第5版在定义只有内部才用的特性(attribute)时,描述了属性(property)的各种特征。定义这些特性是为了实现JavaScript引擎用的,因此在JavaScript中不能直接访问它们。为了表示特性是内部值,该规范把它们放在了俩对儿方括号里,例如[[Enumerable]]。
ECMAScript中有两种属性:数据属性和访问器属性。
1.1 数据属性:数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有4个描述其行为的特性。
[[Configurable]] : 表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。
[[Enumerable]] : 表示能否通过for...in循环返回属性。
[[Writable]] : 表示能否修改属性的值。
[[Value]] : 包含这个属性的数据值。
要修改属性默认的特性,必须使用ECMAScript 5的Object.defineProperty()方法。这个方法接收三个参数:属性所在的对象,属性的名字和一个描述付对象,其中描述符(descriptor)对象的属性必须是:configurable, enumerable, writable 和value。设置其中的一个或多个值,可以修改对应的特性值。调用Object.defineProperty()方法时,如果不显式指定,configurable
、enumerable
和writable
特性的默认值都是false
。
1.2 访问器属性:访问器属性不包含数据值;访问器属性包含一对getter和setter函数(这两个函数都不是必需的)。在读取访问器属性时,会调用getter函数,这个函数负责返回有效的值;在写入访问器属性时,会调用setter函数并传入新值,这个函数负责决定如何处理数据。访问器有如下4个特性:
[[Configurable]] : 表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。
[[Enumerable]] : 表示能否通过for...in循环返回属性。
[[Get]]:在读取属性时调用的函数。默认值为undefined。
[[Set]]:在写入属性时调用的函数。默认值为undefined。
数据属性和访问器属性详情请参见 :《JS高程》——数据属性和访问器属性.
二、创建对象
虽然Object构造函数或对象字面量都可以用来创建单个对象,但这些方法有个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。下面来一步一步完善:
1、工厂模式
这种模式抽象了创建对象的过程,考虑到JavaScript中无法创建类,开发人员就发明了一种函数,用函数来封装以特定接口创建对象的细节,如下:
1 function createPerson(name, age, job){ 2 var o = new Object(); 3 o.name = name; 4 o.age = age; 5 o.job = job; 6 o.sayName = function(){ 7 alert(this.name); 8 }; 9 return o; 10 } 11 var person1 = createPerson('pretty', 29, "FE"); 12 var person2 = createPerson('Grey', 27, "DT");
工厂模式虽然解决了多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型)。于是,新的模式出现了...
2、构造函数模式
ECMAScript中的构造函数可用来创建特定类型的对象。像Object和Array这样的原生构造函数,在运行时会自动出现在执行环境中。此外,也可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。例如:
1 function Person(name, age, job){ 2 // 默认 var this = new Object(); 3 this.name= name; 4 this.age = age; 5 this.job = job; 6 this.sayName = function(){ 7 alert(this.name); 8 } 9 10 // 默认 return this; 11 } 12 var person1 = new Person('pretty', 29, "FE"); 13 var person2 = new Person('Grey', 27, 'Dt');
在调用部分,var person1=new Person("nicole",24); 经历了以下4个步骤(即new操作符都做了啥):
(1)创建一个新对象 // var this = new Object();
(2)将构造函数的作用域赋给新对象(this指向新对象) // this._proto_ = Base.prototype;
(3)执行构造函数中的代码(为新对象添加属性) // Base.call(this);
(4)返回新对象 // return this;
以上两个实例person1&person2分别保存着Person的不同实例,这两个对象都有一个constructor属性(不可枚举,enumerable=false),该属性指向Person
1 console.log(person1.constructor==Person) //true 2 console.log(person2.constructor==Person); //true 3 console.log(person2.constructor==person1.constructor); //true
提到检测对象类型,instanceof 操作符要更可靠一些,我们在这个例子中创建的所有对象既是Object的实例,同时也是Person的实例
1 alert(person1 instanceof Person) //true 2 alert(person1 instanceof Object) //true
person1和person2之所以同时是Object的实例,是因为所有对象均继承自Object
2.1 将构造函数当作函数
构造函数与其他函数的唯一区别,就在于调用它们的方式不同。不过,构造函数也是函数,不存在定义构造函数的特殊语法。任何函数,只要通过new操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过new操作符来调用,那它跟普通的函数也不会有什么俩样。
1 //当作构造函数使用 2 var person = new Person('pretty', 29, 'Fe'); 3 person.sayName(); // 'pretty' 4 5 //作为普通函数调用 6 Person('Greg', 27, "Dt"); 7 window.sayName(); //'Greg' 8 9 //在另一个对象的做用域中调用 10 var o = new Object(); 11 person.call(o, "Kristen" ,25, "Te"); 12 o.sayName(); //'Kristen
当作构造函数时好理解,关键看看作为普通函数调用时发生了什么:属性和方法都被添加到window对象。因为在全局作用域调用一个函数时,this对象总是指向Global对象(在浏览器中就是window对象)。因此,在调用完函数之后,可以通过window对象来调用sayName()方法,并且还返回了Greg。最后,也可以使用call()(或者apply())在某个特殊对象的的作用域中调用Person()。这里是在对象o的作用域中调用的,因此,调用后o就拥有了所有的属性和sayName()方法。
2.2 构造函数的问题
使用构造函数的主要问题就是每个方法都要在每个实例上重新创建一遍。在前面的例子中,person1和person2都有一个名为sayName()的方法。但那两个方法不是同一个function的实例。不要忘了--ECMAScript 中的函数就是对象,因为每定义一个函数,也就是实例化了一个对象。从逻辑上讲,此时的构造函数也可以这样定义:
1 function Person(name, age, job){ 2 this.name = name; 3 this.age = age; 4 this.job = job; 5 this.sayName = new Function('alert(this.name)'); //与声明函数在逻辑上是等价的 6 }
3、原型模式
我们创建的每个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途就是包含可以由特定类型的所有实例共享的属性和方法。
1 function Person(){} 2 Person.prototype.name = "pretty"; 3 Person.prototype.age = 18; 4 Person.prototype.job = "fe"; 5 Person.prototype.sayName = function(){ 6 alert(this.name); 7 }; 8 var person1 = new Person(); 9 person1.sayName(); //'pretty' 10 var person2 = new Person(); 11 person2.sayName() //''pretty" 12 13 alert(person1.sayName == person2.sayName); //true
3.1 理解原型对象
无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象,在默认情况下,所有原型对象都会自动获得一个constructor(构造函数)属性,这个属性包含一个指向prototype属性所在函数的指针。Person.prototype.constructor指向Person. 而通过这个属性我们可以继续为原型对象添加属性和方法
创建了自定义的构造函数之后,其原型对象默认只会取得constructor属性;至于其他方法,则都是从Object继承而来.当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性)指向构造函数的原型对象,ECMA-262第五版中称这个指针为[[Prototype]],没有标准的方式来访问[[Prototype]],但是在Firefox、Safari、Chrome浏览器中每个对象都支持属性__proto__,而在其它实现中该属性是完全不可见的。不过,要明确的真正重要的一点就是,这个连接存在于实例与构造函数原型对象之间,而不是存在于实例与构造函数之间。关系如图:
虽然所有的实现都无法访问[[Prototype]],但是可以通过原型对象的isPrototypeOf()方法来确定实例与原型对象之间是否存在这种关系,如果实例的[[Prototype]]指向了调用isPrototypeOf()方法的原型对象(Person.prototype),则该返回true.
1 Person.prototype.isPrototypeOf(person1); //true 2 Person.prototype.isPrototypeOf(person2); //true
在ECMAScript5中有个方法叫做Object.getPrototypeOf(),在所有支持的实现中,该方法返回[[Prototype]]的值:
alert(Object.getPrototypeOf(person) == Person.prototype); //true
支持该方法的浏览器有IE9+、Firefox 3.5+、Safari 5+、Opera 12+、Chrome。
每当读取某个对象的属性时,都会进行一次搜索,首先从对象实例本身开始,找到了就返回属性值,如果没有找到,则继续搜索指针指向的原型对象,这就是多个对象实例共享原型对象所保存的属性和方法的基本原理。
虽然可以通过对象实例访问保存在原型对象中的值,但是不能通过对象实例重写原型对象中的值。如果在某个实例中添加了一个属性,且该属性与原型对象中的某个属性同名,那么是在实例中创建该属性,该属性将会屏蔽掉原型对象中的那个同名属性。
同时也可以使用delete操作符来删除某个实例属性,从而能够重新访问原型对象中的同名属性。
方法hasOwnProperty()(从Object对象继承而来的)可以用来检测一个属性是存在于实例本身还是存在于原型对象中,只有给定属性存在于实例中时,才返回true。
3.2 原型与in操作符
使用in操作符的两种方式,如下:
-
- 单独使用:通过对象能够访问指定属性时返回true,无论该属性存在于实例对象中还是存在于原型对象中,使用方式为:"属性名" in 对象。结合hasOwnProperty方法使用就能确定一个属性是否存在且存在什么对象中。
/*判断实例属性是否在原型中*/ function hasPrototypePrototype(object, name){ return !object.hasOwnProperty(name) && (name in object); }
- 在for-in循环中使用:返回的是所有能够通过对象访问、可枚举(enumerated)的属性,既包括存在于实例中的属性,也包括存在于原型对象中的属性;屏蔽了原型对象中的不可不枚举属性([[Enumerable]]标记的属性)的实例属性也会在该循环中返回,因为根据规定,所有开发人员定义的属性都是可枚举的(IE8--例外)。此外,要取得对象上所有可枚举的的实例属性,可以使用ECMAScript5中的Object.keys()方法,返回一个包含所有可枚举属性的字符串数组:Object.keys(Person.prototype)。如果是要取得所有实例属性,而无论该属性是否可枚举,则可以使用Object.getOwnPropertyNames(对象)。支持这两个方法的浏览器包括:IE9+、Firefox4+、Safari5+、Opera12+、Chrome。
- 单独使用:通过对象能够访问指定属性时返回true,无论该属性存在于实例对象中还是存在于原型对象中,使用方式为:"属性名" in 对象。结合hasOwnProperty方法使用就能确定一个属性是否存在且存在什么对象中。
要想取得对象上所有可枚举的实例属性,可以使用ECMAScript5的Object.key()方法。这个方法接受一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。例如:
1 function Person() {} 2 3 Person.prototype.name = "Tom"; 4 Person.prototype.age = 22; 5 Person.prototype.job = "CEO"; 6 Person.prototype.sayName = function() { 7 alert(this.name); 8 } 9 10 var keys = Object.keys(Person.prototype); 11 alert(keys); //"name,age,job,sayName" 12 13 var p1 =new Person(); 14 p1.name = "Rob"; 15 p1.age = 32; 16 17 var p1keys = Object.keys(p1); 18 alert(pekeys); //"name,aeg";
3.3 原型的动态性
由于在原型中查找值的过程是一次搜索,因此我们对原型所做的任何修改都能够立即从实例上反映出来——即使是先创建了实例后修改原型也照样如此。比如:
1 var friend = new Person(); 2 3 Person.prototype.sayHi = function(){ 4 alert("Hi"); 5 } 6 7 friend.sayHi(); //"Hi" (没有问题)
原因是实例与原型之间的松散连接关系。当我们调用person.sayHi()时,首先会在实例中搜索名为sayHi的属性,在没有找到的情况下,会继续搜索原型。因为实例与原型之间的连接只不过是一个指针,而非一个副本,因此就可以在原型中找到新的sayHi属性并返回保存在那里的函数。
尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重写整个原型对象,那么情况就变的糟糕了。我们知道,调用构造函数时会为实例添加一个指向最初原型的[[Prototype]]指针,而把原型修改为另一个对象就等于切断了构造函数与最初原型之间的联系。切记:实例中的指针仅指向最初的原型,而不指向构造函数。
1 function Person(){} 2 3 var friend = new Person(); 4 5 Person.prototype = { 6 constructor: Person, 7 name: "Tom", 8 age: 22, 9 sayName: function(){ 10 alert(this.name); 11 } 12 }; 13 14 friend.sayName(); /* error 此时的friend实例指向还是最初的原型Person.prototype(里面只有一个constructor属性),因此,先写原型后实例化 */
3.4 原型对象的问题
由于原型对象中的所有属性和方法都是被所有实例共享的,这种共享对于方法来说很适合,对于基本数据类型的属性来说也是适合的,因为通过在实例上添加同名的属性可以隐藏掉原型对象中的对应属性,但是对于引用类型的属性来说,要格外注意,如下:
1 function Person() {} 2 3 Person.prototype = { 4 constructor: Person, 5 name: "Tom", 6 friend: ["Tom", "Jake"] 7 }; 8 9 var p1 = new Person(); 10 var p2 = new Person(); 11 12 p1.books.push("Mary"); 13 14 alert(person1.friend); //"Tom,Jake,Mary" 15 alert(person2.friend); //"Tom,Jake,Mary" 16 alert(person1.friend === person2.friend); //true
可见,这样的操作不会屏蔽同名属性,而会修改同名属性。因为,friend属性是个引用类型,实例调用friend时调用的是它的指针,通过指针找到相应内存地址,然后修改其属性值。注意:原型中引用类型可能会被改写,但构造函数中的任何类型都不会被改写!
4、组合使用构造函数模式和原型模式
组合使用构造函数模式和原型模式的方式是创建自定义对象最常见的方式,构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。所以,每个实例都会有自己的一份实例属性的副本,但同时又共享对方法的引用,好处就是最大限度的节省了内存,如下:
1 function Person(name, age, job) { 2 this.name = name; 3 this.age = age; 4 this.job = job;
5 this.friend = ["Tom", "Jake"];
6 } 7 Person.prototype = { 8 constructor: Person, 9 getName: function() { 10 return this.name; 11 } 12 }; 13 14 var person1 = new Person("Tom",22,"A"); 15 var person2 = new Person("Jake",23,"B"); 16 17 person1.friend.push("Van"); 18 alert(person1.friends); // "Tom,Jake,Van" 19 alert(person2.friends); // "Tomo,Jake" 20 alert(person1.friends === person2.friends); //false 21 alert(person1.sayName === person2.sayName); //true
5、动态原型模式
动态原型模式把所有信息都封装在构造函数中,而通过在构造函数中初始化原型对象(仅在必要的情况下),且保持了同时使用构造函数和原型的优点。也就是说,可以通过检查某个应该存在的方法是否有效来决定是否需要初始化原型对象。如下:
1 function Person(name, age, job) { 2 //属性 3 this.name = name; 4 this.age = age; 5 this.job = job; 6 //方法 7 if (typeof this.sayName != "function") { 8 Person.prototype.sayName = function() { 9 return this.name; 10 }; 11 } 12 } 13 14 var friend = new Person("Tom",22,"A"); 15 friend.sayName();
注意加粗部分,只在sayName()方法不存在的情况下,才会将其添加到原型对象中,所以只会在第一次调用构造函数创建实例时才会添加该方法到原型对象中。这段代码只会在初次调用构造函数时才会执行。此后,原型已经完成初始化,不需要再做什么修改了。不过要记住,这里对原型所做的修改,能够立即在所有实例中得到反映。因此,这种方法可以说是完美。
不过在使用这种方式时,需要注意的是不能使用对象字面量来重写原型,因为如果在已经创建了实例的情况下重写原型,那么就会切断现有实例与新原型之间的关系。
6、寄生构造函数模式
通常,在前述的几种模式都不适用的情况下,可以使用寄生(parasitic)构造函数模式。这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装对象的代码,然后再返回新创建对象:
1 function Person(name, age, job) { 2 var o = new Object(); 3 o.name = name; 4 o.age = age;
o.job = job; 5 o.getName = function() { 6 return this.name; 7 }; 8 9 return o; 10 } 11 12 var person = new Person("Tom", 21, "A"); 13 friend.sayName(); // "Tom"
咋一看,这与工厂模式一模一样,但是注意函数名定义时首字母是大写的,说明把该函数当做构造函数来使用,而不是普通函数,而且使用的是new操作符来创建对象,并不是普通的函数调用。在该例子中,Person函数中创建了一个新对象,并以相应的属性和方法来初始化该对象,然后返回了该对象。构造函数在不返回值的情况下,默认会返回新对象实例,而通过在构造函数的末尾添加返回语句,就可以重写调用构造函数时返回的值。
该模式可以在特殊的情况下用来为对象创建构造函数。比如,要创建一个具有额外方法的特殊数组,由于不能直接修改Array的构造函数,所以可以使用该模式,如下:
1 function SpecialArray() { 2 // 创建数组对象 3 var arr = new Array(); 4 5 // 添加元素 6 arr.push.apply(arr, arguments); 7 8 // 添加方法 9 arr.toPipedString = function() { 10 return this.join("|"); 11 }; 12 13 // 返回数组对象 14 return arr; 15 } 16 17 var colors = new SpecialArray("red", "blue", "green"); 18 colors.toPipedString(); // "red|blue|green"
对于寄生构造函数模式,返回的对象与构造函数或者与构造函数的原型属性之间没有任何关系;也就是说,构造函数返回的对象与在构造函数外部创建的对象没有什么不同,所以不能依赖instanceof操作符来确定对象类型。
7、稳妥构造函数模式
1、稳妥对象:所谓稳妥对象指的是没有公共属性,且其方法也不引用this的对象。稳妥对象最适合在一些安全的环境中(这些环境禁止使用this和new),或者在防止数据被其它应用程序改动时使用。
2、稳妥构造函数模式与寄生构造函数模式类似,但是有两点不同:
-
- 新创建对象的实例方法不引用this
- 不使用new操作符调用构造函数
1 function Person(name, age, job) { 2 // 创建要返回的对象 3 var o = new Object(); 4 5 // 可以在这里定义私有变量和函数 6 7 o.sayName = function() { 8 return name; 9 }; 10 //返回对象 11 return o; 12 } 13 14 var person = Person("Tom", 21, "CEO"); 15 person.getName(); // "Tom"
姊妹篇:JS之继承的常用方法