面向对象的程序设计
对象是一组没有特定顺序的值
6.1.1 属性类型
ECMAScript中有两种属性:数据属性和访问器属性。
1. 数据属性
Configurable 表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为true。
Enumerable 表示能否通过for-in循环返回属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为true。
Writable 表示能否修改属性的值。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为true。
Value 包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。这个特性的默认值为undefined。
对于像前面例子中那样直接在对象上定义的属性,它们的[[Configurable]]、[[Enumerable]]和[[Writable]]特性都被设置为true,而[[Value]]特性被设置为指定的值。例如:
var person = { name: "Nicholas" };
这里创建了一个名为name的属性,为它指定的值是"Nicholas"。也就是说,[[Value]]特性将被设置为"Nicholas",而对这个值的任何修改都将反映在这个位置。
要修改属性默认的特性
Object.defineProperty()方法不能重复定义
2. 访问器属性
Configurable
Enumerable
Get 在读取属性时调用的函数。默认值为undefined。
Set 在写入属性时调用的函数。默认值为undefined。
访问器属性不能直接定义,必须使用Object.defineProperty()来定义
var book = { _year: 2004, edition: 1 }; Object.defineProperty(book,"year",{ get:function(){ return this._year; }, set:function(newValue){ if(newValue > 2004){ this._year = newValue; this.edition += newValue - 2004; }
} }); book.year = 2010; console.log(book.edition)//7
以上代码创建了一个book对象,并给它定义两个默认的属性:_year和edition。_year前面的下划线是一种常用的记号,用于表示只能通过对象方法访问的属性。而访问器属性year则包含一个getter函数和一个setter函数。
使用访问器属性的常见方式,即设置一个属性的值会导致其他属性发生变化。
6.1.2 定义多个属性
var book = {}; Object.defineProperties(book,{ _year: { value: 2004 }, edition: { value: 1 }, year: { get: function(){ return this._year; }, set: function(newValue){ if(newValue >2004){ this._year = newValue; this.edition += newValue - 2004; } } }
}) console.log(book)
6.2 创建对象
虽然Object构造函数或对象字面量都可以用来创建单个对象,但这些方式有个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。
6.2.1 工厂模式
在ECMAScript中无法创建类,开发人员就发明了一种函数,用函数来封装以特定接口创建对象的细节
function createPerson(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 = createPerson('lxf',"23",'fw'); var person2 = createPerson("lxx","21"); console.log(person1);//{name: "lxf", age: "23", job: "fw", sayName: ƒ} console.log(person2);//{name: "lxx", age: "21", job: undefined, sayName: ƒ}
工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型)。
6.2.2 构造函数模式
ECMAScript中的构造函数可用来创建特定类型的对象。
function Person(name, age, job){ this.name = name; this.age = age; this.job = job; this.sayName = new Function("console.log(this.name);");//新实例化了一个对象 // this.sayName = function(){console.log(this.name)} } var person1 = new Person('lxf',"23",'fw'); var person2 = new Person('lxx',"23"); console.log(person1); console.log(person2 instanceof Person);//我们在这个例子中创建的所有对象既是Object的实例,同时也是Person的实例 console.log(person2 instanceof Object); console.log(person1.sayName == person2.sayName);//以这种方式创建函数,会导致不同的作用域链和标识符解析,但创建Function新实例的机制仍然是相同的。因此,不同实例上的同名函数是不相等的
然而,创建两个完成同样任务的Function实例的确没有必要;况且有this对象在,根本不用在执行代码前就把函数绑定到特定对象上面。因此,大可像下面这样,通过把函数定义转移到构造函数外部来解决这个问题
function Person(name, age, job){ this.name = name; this.age = age; this.job = job; // this.sayName = new Function("console.log(this.name);");//新实例化了一个对象 // this.sayName = function(){console.log(this.name)} this.sayName = sayName; } function sayName(){ console.log(this.name); }
与工厂模式的区别
没有显式地创建对象;
直接将属性和方法赋给了this对象;
没有return语句。
函数名Person使用的是大写字母P,为了区别于ECMAScript中的其他函数;因为构造函数本身也是函数,只不过可以用来创建对象而已。
要创建Person的新实例,必须使用new操作符。以这种方式调用构造函数实际上会经历以下4个步骤:
(1) 创建一个新对象;
(2) 将构造函数的作用域赋给新对象(因此this就指向了这个新对象);
(3) 执行构造函数中的代码(为这个新对象添加属性);
(4) 返回新对象。
1. 将构造函数当作函数
以这种方式定义的构造函数是定义在Global对象(在浏览器中是window对象)中的。
2. 构造函数的问题
使用构造函数的主要问题,就是每个方法都要在每个实例上重新创建一遍。
可是新问题又来了:在全局作用域中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实。而更让人无法接受的是:如果对象需要定义很多方法,那么就要定义很多个全局函数,于是我们这个自定义的引用类型就丝毫没有封装性可言了。好在,这些问题可以通过使用原型模式来解决。
6.2.3 原型模式
ECMA-262第5版中管这个指针叫[[Prototype]]。虽然在脚本中没有标准的方式访问[[Prototype]],但Firefox、Safari和Chrome在每个对象上都支持一个属性__proto__;而在其他实现中,这个属性对脚本则是完全不可见的。不过,要明确的真正重要的一点就是,这个连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。
我们创建的每个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如按照字面意思来理解,那么 prototype就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换话说,不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中
Person.prototype 指向了原型对象,而 Person.prototype.constructor 又指回了 Person。原型对象中除了包含 constructor 属性之外,还包括后来添加的其他属性。Person 的每个实例——person1 和 person2 都包含一个内部属性,该属性仅仅指向了 Person.prototype;换句话说,它们与构造函数没有直接的关系。此外,要格外注意的是,虽然这两个实例都不包含属性和方法.
function Person(){ } Person.prototype.name = "lxf"; Person.prototype.age = "23"; Person.prototype.sayName = function(){ console.log("ok"); } var person1 = new Person(); var person2 = new Person(); person1.name = "Greg"; console.log(person1.name,person2.name); console.log(Object.getPrototypeOf(person1));//返回实例的prototype,即原形对象 console.log(Object.getPrototypeOf(person2)); console.log(Person.prototype.isPrototypeOf(person2));//确认实例的prototype, console.log(person2.hasOwnProperty("name"));//检查属性是否在实例上 console.log("name" in person1);//true,对象person1能访问name属性
console.log(Object.keys(Person.prototype));//取得对象上所有可枚举的实例属性
function hasPrototypeProperty(object, name){ return !object.hasOwnProperty(name) && (name in object); } console.log(hasPrototypeProperty(person2,"name"));//true
使用delete操作符则可以完全删除实例属性,从而让我们能够重新访问原型中的属性。
delete person1.name;
function Person(){ } var person1 = new Person(); Person.prototype = { constructor : Person, name : "sad", sayName:function(){ console.log(this.name) } } console.log(Person.prototype.constructor == Person); console.log(person1)//error
从图中可以看出,重写原型对象切断了现有原型与任何之前已经存在的对象实例之间的联系;它们引用的仍然是最初的原型。
function Person(){ } Person.prototype = { constructor : Person, name : "sad", friends:["shelby","count"], sayName:function(){ console.log(this.name) } } var person1 = new Person(); var person2 = new Person(); person1.friends.push("van"); console.log(person1.friends); console.log(person2.friends); console.log(person1.friends == person2.friends);
6.2.4 组合使用构造函数模式和原型模式
创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实 例属性,而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本, 但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混成模式还支持向构造函数传递参 数;可谓是集两种模式之长。下面的代码重写了前面的例子。
function Person(name,age,job){ this.name = name; this.age = age; this.job = job; this.friends = ["xiaohuang","xiaogming"]; } Person.prototype = { constructor:Person, sayName:function(){ console.log(this.name); } } var person1 = new Person("lxf","23","fw"); var person2 = new Person("xm","99","my"); person2.friends.push("aa"); console.log(person1.friends == person2.friends);//false console.log(person1.sayName == person2.sayName);//true console.log(person1);
6.2.5 动态原型模式
function Person(name,age,job){ this.name = name; this.age = age; this.job = job; this.friends = ["xiaohuang","xiaogming"]; if(typeof this.sayName != "function"){ Person.prototype.sayName = function(){ console.log(this.name); },
Person.prototype.sayme = function(){
console.log(this.name);
}
} } var person1 = new Person("asd","23","dd");
var person2 = new Person("asd1","223","ff");
console.log(person1.name == person2.name);//false
console.log(person2.sayName == person1.sayName);true
注意构造函数代码中加粗的部分。这里只在 sayName()方法不存在的情况下,才会将它添加到原 型中。这段代码只会在初次调用构造函数时才会执行。此后,原型已经完成初始化,不需要再做什么修 改了。不过要记住,这里对原型所做的修改,能够立即在所有实例中得到反映。因此,这种方法确实可 以说非常完美。其中,if 语句检查的可以是初始化之后应该存在的任何属性或方法——不必用一大堆 if 语句检查每个属性和每个方法;只要检查其中一个即可。对于采用这种模式创建的对象,还可以使 用 instanceof 操作符确定它的类型。
使用动态原型模式时,不能使用对象字面量重写原型。前面已经解释过了,如果 在已经创建了实例的情况下重写原型,那么就会切断现有实例与新原型之间的联系。
6.2.6 寄生构造函数模式
function Person(name,age,job){ var o = new Object(); o.name = name; o.age = age; o.job = job; o.friends = ["xiaohuang","xiaogming"]; o.sayName = function(){ console.log(this.name); } return o; } var friend = new Person("ad","23","fw"); var friend1 = new Person("33","321","fw1"); console.log(friend1);//{name: "33", age: "321", job: "fw1", friends: Array(2), sayName: ƒ} console.log(friend);//{name: "ad", age: "23", job: "fw", friends: Array(2), sayName: ƒ} console.log(friend.name == friend1.name);//false console.log(friend1.sayName == friend1.sayName);//true
关于寄生构造函数模式,有一点需要说明:首先,返回的对象与构造函数或者与构造函数的原型属 性之间没有关系;也就是说,构造函数返回的对象与在构造函数外部创建的对象没有什么不同。为此, 不能依赖 instanceof 操作符来确定对象类型。由于存在上述问题,我们建议在可以使用其他模式的情 况下,不要使用这种模式。
6.2.7 稳妥构造函数模式
所谓稳妥对象,指的是没有公共属性,而且其方法也不引用 this 的对象。
function Person(name,age,job){ var o = new Object();//创建要返回的对象 //可以在这里定义私有变量和函数 o.sayName = function(){ console.log(name); } return o;//返回对象 } var person1 = Person("lxfa","23","front-end"); var person2 = Person("lxf","23","front-end"); console.log(person1.name == person2.name);//true console.log(person1.sayName == person2.sayName);//false
在以这种模式创建的对象中,除了使用 sayName()方法之外,没有其他办法访问 name 的值。 可以像下面使用稳妥的 Person 构造函数。
变量 friend 中保存的是一个稳妥对象,而除了调用 sayName()方法外,没有别的方式可 以访问其数据成员。即使有其他代码会给这个对象添加方法或数据成员,但也不可能有别的办法访问传 入到构造函数中的原始数据。
与寄生构造函数模式类似,使用稳妥构造函数模式创建的对象与构造函数之间也 没有什么关系,因此 instanceof 操作符对这种对象也没有意义。
6.3 继承
许多 OO 语言都支持两种继承方式:接口继承和 实现继承。接口继承只继承方法签名,而实现继承则继承实际的方法。如前所述,由于函数没有签名, 在 ECMAScript 中无法实现接口继承。ECMAScript 只支持实现继承,而且其实现继承主要是依靠原型链 来实现的。
6.3.1 原型链
ECMAScript 中描述了原型链的概念,并将原型链作为实现继承的主要方法。其基本思想是利用原 型让一个引用类型继承另一个引用类型的属性和方法。简单回顾一下构造函数、原型和实例的关系:每 个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型 对象的内部指针。那么,假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?显然,此时的 原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数 的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实 例与原型的链条。这就是所谓原型链的基本概念。
function SuperType(){ this.property = true; } SuperType.prototype.getSuperValue = function(){ return this.property; } function SubType(){ this.subproperty = false; } ////继承了 SuperType SubType.prototype = new SuperType(); SubType.prototype.getSubValue = function(){ return this.subproperty; } var instance = new SubType(); console.log(instance.getSuperValue());//true console.log(instance);
以上代码定义了两个类型:SuperType 和 SubType。每个类型分别有一个属性和一个方法。它们 的主要区别是 SubType 继承了 SuperType,而继承是通过创建 SuperType 的实例,并将该实例赋给 SubType.prototype 实现的。实现的本质是重写原型对象,代之以一个新类型的实例。换句话说,原 来存在于 SuperType 的实例中的所有属性和方法,现在也存在于 SubType.prototype 中了。在确立了 继承关系之后,我们给 SubType.prototype 添加了一个方法,这样就在继承了 SuperType 的属性和方 法的基础上又添加了一个新方法。这个例子中的实例以及构造函数和原型之间的关系如图 6-4所示。
完整的原型链:
一句话,SubType 继承了 SuperType,而 SuperType 继承了 Object。当调用 instance.toString() 时,实际上调用的是保存在 Object.prototype 中的那个方法。
① 实际上,不是 SubType 的原型的 constructor 属性被重写了,而是 SubType 的原型指向了另一个对象—— SuperType 的原型,而这个原型对象的 constructor 属性指向的是 SuperType。
3. 谨慎地定义方法
子类型有时候需要重写超类型中的某个方法,或者需要添加超类型中不存在的某个方法。但不管怎 样,给原型添加方法的代码一定要放在替换原型的语句之后。