第6章、面向对象的程序设计
对象定义:无序属性的集合,其属性可以包含基本值、对象或函数。
6.1 理解对象
6.1.1 属性类型
ECMA-262定义了内部使用的特性,用来描述属性的各种特征。这些特性不能再JavaScript中直接访问。
ECMAScript中有两种属性:数据属性和访问器属性。
1、数据属性
数据属性包含一个数据值的位置,在这个位置可以读取和写入值。数据属性有4个用于描述的特性。
configurable:表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。直接在对象上定义的属性,默认值为true。
enumerable:表示能否通过for-in循环返回属性。直接在对象上定义的属性,默认值为true。
writable:表示能否修改属性的值,直接在对象上定义的属性,默认值为true。
value:包含这个属性的数据值,读取属性值的时候,从这个位置读,写入属性值的时候,把新值保存在这个位置,默认值为undefined。
要修改属性默认的特性,必须使用ECMAScript5的Object.defineProperty()方法。接收3个参数,属性所在的对象、属性的名字和一个描述符对象。描述符对象的属性必须是configurable、enumerable、writable和value。
var a = {
name: 'abc'
}
Object.defineProperty(a, 'name', {
writable: false,
value: 'xyz'
});
console.log(a.name); // xyz
a.name = 'qqq';
console.log(a.name); // xyz
把configurable属性设置为false之后,表示不能从对象中删除属性,且不能修改除writable之外的特性。
在调用Object.defineProperty()方法时,如果不指定,configurable、enumerable、writable的默认值都为false
2、访问器属性
访问器属性不包含数据值,它们包含一对getter和setter函数,读取访问器属性时,会调用getter函数,返回有效的值;写入访问器属性时,会调用setter函数并传入新值,这个函数决定如何处理数据。访问器属性有4个用于描述的特性。
configurable:与数据属性相同。
enumerable:与数据属性相同。
get:读取属性时调用的函数,默认值为undefined。
set:写入属性时调用的函数,默认值为undefined。
访问器属性不能直接定义,必须使用Object.defineProperty()方法来定义。
var a = {
name: 'abc',
age: 10
}
Object.defineProperty(a, '_name', {
get: function(){
return this.name;
},
set: function(newValue){
if(this.age>5){
this.name += newValue;
}
}
});
a._name = 'xyz';
console.log(a.name); // abcxyz
只定义get意味着属性不能写,只定义set意味着属性不能读,非严格模式下返回undefined,严格模式报错。
6.1.2 定义多个属性。
ECMAScript5定义个了一个Object.defineProperties()方法,用于通过描述符一次定义多个属性。此方法与Object.defineProperty()方法相同,属性的各项特性默认值为false。
var a = {};
Object.defineProperties(a, {
name: {
value: 'abc'
},
age: {
value: 10
},
year: {
get: function () {
return this.age;
},
set: function (newValue) {
if(this.age > 5){
this.name += newValue;
}
}
}
});
6.1.3 读取属性的特性
使用ECMAScript5的Object.getOwnPropertyDescriptor()方法,可以取得给定属性的描述符。接收两个参数,属性所在的对象和要读取其描述符的属性名称。返回值是一个对象,由属性的特性和其值组成。
var der = Object.getOwnPropertyDescriptor(a,'name');
console.log(der.configurable); // false
console.log(der.writable); // false
console.log(der.enumerable); // false
console.log(der.value); // abc
6.2 创建对象
工厂模式
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;
}
构造函数模式
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('abc', 10, 'xyz');
构造函数与其他函数的唯一区别就在于调用他们的方式不同。使用new操作符调用为当做构造函数调用,作为普通函数调用时会将属性和方法添加到当前作用域上。
构造函数的缺点在于每个方法都要在每个实例上重新创建一遍。同一个构造函数创建的不同实例中的同名方法并不是同一个。通过把函数定义转到构造函数外部来解决这个问题。
function Person(name,age,job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName() {
console.log(this.name);
}
原型模式
每个函数都有一个prototype属性,这个属性是一个指针,指向一个对象,这个对象包含可以由特定类型的所有实例共享的属性和方法。即prototype就是通过调用构造函数而创建的那个实例的原型对象。
function Person(name,age,job) {}
Person.prototype.name = 'abc';
Person.prototype.age = 10;
Person.prototype.job = 'xyz';
Person.prototype.sayname = function () {
console.log(this.name);
};
var person1 = new Person();
不同实例之间的同名方法是同一个函数。所有原型对象都有一个constructor属性,该属性包含一个指向prototype属性所在函数的指针。
当调用构造函数创建新实例时,该实例内部将包含一个指向构造函数的原型对象的指针,Firefox、Safari、Chrome可以通过非标准的__proto__访问。这个链接是存在于实例与构造函数的原型对象之间,不是存在于实例于构造函数之间。可以通过isPrototypeOf()方法来确定对象之间是否存在这个链接。当传入的参数对象内部有指向调用该函数的原型对象的指针时返回true。
var p1 = new Person();
console.log(Person.prototype.isPrototypeOf(p1)); // true
ECMAScript5新增Object.getPrototypOf()方法,这个方法会返回传入参数对象指向的原型对象。
console.log(Object.getPrototypeOf(p1) == Person.prototype); // true
读取某个对象的属性时,现在其本身上查找,若没查找到则继续搜索指针指向的原型对象。在实例上定义的属性和方法会屏蔽原型对象上的同名属性和方法。屏蔽后只是阻止我们访问,不会修改原型上的同名属性和方法。使用hasOwnProperty()方法可以检测一个属性是存在于实例中,还是存在于原型中。只在给定属性存在于对象实例中时,才会返回true。
console.log(p1.hasOwnProperty('name')); // false
有两种方式使用in操作符,第一种是单独使用,in操作符会在通过该对象能够访问属性时返回true,无论属性时在对象本身还是在原型上。
console.log('name' in p1); // true
第二种是使用for-in循环,会返回所有能够通过对象访问到的、可枚举的属性(即Enumerable特性为true的属性),包括原型中的属性。若对象上某个属性屏蔽了原型中的不可枚举属性,那么该属性也会被返回。所有开发人员定义的属性都是可枚举的。
使用Object.keys()方法可以取得对象上所有可枚举的实例属性,不会包含原型的属性。这个方法接收一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。
var p1 = new Person();
p1.name = 'cba';
p1.age = 20;
console.log(Object.keys(p1)); // ["name", "age"]
无论属性是否可枚举,使用Object.getOwnPropertyNames()方法可以得到所有实例属性,不会包含原型的属性。
// 包含了不可枚举的constructor属性
console.log(Object.getOwnPropertyNames(Person.prototype)); // ["constructor", "name", "age", "job", "sayname"]
为减少输入,更简洁的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象。
function Person() {}
Person.prototype = {
name: 'abc',
age: 29,
sayName: function () {
console.log(this.name);
}
}
但是这样做会使得constructor属性不再指向构造函数Person,因为这样做完全重写了默认的prototype对象。因此constructor属性变成了新对象的constructor属性,即指向Object构造函数。可以通过Object.defineProperty()方法将constructor属性设置为Person(直接设置constructor属性为Person时会导致它的enumerable属性为true)。
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person
});
实例中的prototype指针仅指向原型,而不指向构造函数。这种方法会切断构造函数与最初原型之间的联系,之前已经存在的对象实例无法读取到现有原型中的方法。
所有原生引用类型(Object、Array、String等等)都在其构造函数的原型上定义了方法,例如Array.prototype.sort()、String.prototype.substring()。通过原生对象的原型,也可以修改这些默认的方法。
原型模式最大的问题是由其共享的本性所导致的。当原型中的属性包含引用类型值时,所有实例会共享这个引用类型值。
function Person() {}
Person.prototype = {
friends: ['a', 'b', 'c']
}
var p1 = new Person();
var p2 = new Person();
// 对p1的修改反应在了p2上
p1.friends.push('d');
console.log(p2.friends); // ["a", "b", "c", "d"]
组合使用构造函数模式和原型模式
将构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。能够集两者之长解决它们各自的问题。
// 还可以向构造函数传递参数
function Person(name, age) {
this.name = name;
this.age = age;
this.friends = ['a', 'b'];
}
Person.prototype = {
sayName: function () {
console.log(this,name)
}
};
动态原型模式
通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型。
function Person(name, age) {
this.name = name;
this.age = age;
this.friends = ['a', 'b'];
// 这段代码只会在初次调用构造函数时才会执行,只要检查一个属性或方法就可以判断是否是初次调用,注意同样不能使用对象字面量重写原型
if (typeof this.sayName != "function") {
Person.prototype = {
sayName: function () {
console.log(this, name)
}
};
}
}
寄生构造函数模式
构造函数在没有定义返回值的时候,会默认返回新对象的实例,而通过给构造函数添加返回值,可以重写调用构造函数时的返回值。
function Person(name, age) {
var obj = new Object();
obj.name = name;
obj.age = age;
return obj;
}
这种模式可以用于定义一个具有额外方法的特殊对象,例如一个具有额外方法的数组。
function SpecialArr() {
var arr = new Array();
// 通过参数传递数组的初始值
arr.push.apply(arr.arguments);
// 为创建的实例数组添加一个特殊的方法
arr.toJoin = function () {
return this.join("|");
}
return arr;
}
var arrs = new SpecialArr('a','b','c');
console.log(arrs.toJoin()); // a|b|c
返回的对象与构造函数或者构造函数的原型属性之间没有关系,因此不能依赖instanceof操作符来确定对象类型。
稳妥构造函数模式
稳妥构造函数模式遵循与寄生构造函数类似的模式,但有两点不同,一是新创建的对象的实例方法不引用this,二是不使用new操作符调用构造函数。
function Person(name, age) {
var obj = new Object();
obj.sayName = function () {
console.log(name);
};
return obj;
}
var p1 = Person();
这种模式中,只有sayName()方法可以访问到传入到构造函数中的原始数据,同样instanceof操作符对这种对象也没有效果。
6.3 继承
6.3.1 原型链
原型链是ECMAScript实现继承的主要方法,其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。
每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,每个实例都包含一个指向原型对象的内部指针,当原型对象等于另一个类型的实例时,此时的原型对象也将包含一个指向另一个原型的指针。另一个原型又是另一个类型的实例时,上述关系依然成立,如此层层递进,就构成了实例与原型的链条,这就是原型链。实现的本质是重写原型对象,用一个新类型的实例代替它。
function A() {
this.aName = true;
}
A.prototype.getAName = function () {
return this.aName;
};
function B() {
this.bName = false;
}
// 让B的原型对象等于A类型的实例,从而继承A类型的方法
B.prototype = new A();
B.prototype.getBName = function () {
return this.bName;
};
var obj = new B();
console.log(obj.getAName()); // true
// obj的constructor属性现在指向构造函数A,因为B的原型被重写了。
console.log(obj.constructor); // ƒ A() {this.aName = true;}
所有引用类型都默认继承了Object,这个继承也是通过原型链实现的,所有函数的默认原型都是Object的实例,这就是所有自定义类型都会继承toString()、valueOf()等方法的原因。
两种方法可以确定原型和实例之间的关系,一种是使用instanceof操作符,另一种是使用isPrototypeOf()方法。
console.log(obj instanceof B); // true
console.log(obj instanceof A); // true
console.log(obj instanceof Object); // true
console.log(B.prototype.isPrototypeOf(obj)); // true
console.log(A.prototype.isPrototypeOf(obj)); // true
console.log(Object.prototype.isPrototypeOf(obj)); // true
当子类型需要覆盖超类型中的某个方法或者需要添加超类型中不存在的某个方法时,添加方法的代码一定要放在替换原型的语句之后,而且不能使用字面量创建原型方法,因为这样做会重写原型,导致之前的原型重写无效。
原型链的主要问题来自包含引用类型值的原型,包含引用类型值的原型属性会被所有实例共享,定义在构造函数中的实例属性,通过原型来实现继承时,原型会变成另一个类型的实例,实例中的属性也就变成了原型属性。
function A() {
this.num = ['1','2','3'];
}
function B() {}
B.prototype = new A();
var obj1 = new B();
var obj2 = new B();
// 修改obj1实例会导致obj2实例也发生变化
obj1.num.push('4');
console.log(obj2.num); // ["1", "2", "3", "4"]
原型链的第二个问题在于创建子类型实例时,无法向超类型的构造函数中传递参数。
6.3.2 借用构造函数
借用构造函数的基本思想是在子类型构造函数的内部调用超类型构造函数,从而避免引用类型值通用的情况,并且可以向超类型中传递参数。
function A(x) {
this.num = ['1','2','3',x];
}
function B() {
// 继承A的同时向A传递参数
A.call(this,'4');
}
B.prototype = new A();
var obj1 = new B();
var obj2 = new B();
obj1.num.push('5');
console.log(obj1.num); // ["1", "2", "3", "4", "5"]
console.log(obj2.num); // ["1", "2", "3", "4"]
借用构造函数的问题在于所有的方法都在构造函数中定义,无法复用函数,而且超类型原型中定义的方法,子类型也不可见。
6.3.3 组合继承
组合继承即使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。
function A(name, age) {
this.name = name;
this.num = ['1', '2', '3', age];
}
A.prototype.sayName = function () {
console.log(this.name);
};
// 借用构造函数继承属性,原型链继承方法
function B(name, age) {
A.call(this, name, age);
this.age = age;
}
B.prototype = new A();
B.prototype.sayAge = function () {
console.log(this.age);
};
var obj1 = new B('name1', 11);
var obj2 = new B('name2', 12);
obj1.num.push('5');
console.log(obj1.num); // ["1", "2", "3", 11, "5"]
obj1.sayName(); // name1
obj1.sayAge(); // 11
console.log(obj2.num); // ["1", "2", "3", 12]
obj2.sayName(); // name2
obj2.sayAge(); // 12
6.3.4 原型式继承
借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。
// 传入一个对象作为参数,将其设置为构造函数的原型。最后返回这个临时构造函数的实例
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
var person = {
name: 'abc',
friends: ['a', 'b', 'c']
};
var obj1 = object(person);
var obj2 = object(person);
// 包含引用类型值的属性会被共享
obj1.friends.push('d');
console.log(obj2.friends); // ["a", "b", "c", "d"]
ECMAScript5通过Object.create()方法规范了原型式继承。这个方法接收两个参数,一个是用作新对象原型的对象,另一个可选的是为新对象定义额外属性的对象。只传一个参数时与object()方法相同,第二个参数与Object.defineProperties()方法的第二个参数相同,每个属性通过自己的特性定义,这样定义的任何属性都会覆盖原型对象上的同名属性。
var person = {
name: 'abc'
};
var obj = Object.create(person, {
name: {
value: 'cba'
}
});
console.log(obj.name); // cba
6.3.5 寄生式继承
寄生式继承的思路是创建一个仅用于封装继承过程的函数,该函数内部以某种方式增强对象,最后再返回对象。
function createObject(o) {
var clone = Object.create(o);
clone.sayName = function () {
console.log(this.name);
};
return clone;
}
var person = {
name: 'abc'
};
var obj = createObject(person);
obj.sayName(); // abc
寄生式继承同样无法做到函数的复用。
6.3.6 寄生组合式继承
组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数。一次是在创建子类型原型的时候,这次会将超类型中的实例属性添加到子类型的原型中。第二次是在子类型的构造函数内部,这次会将超类型的实例属性添加到子类型的实例上。这样,被添加到子类型实例上的属性就屏蔽了被添加到子类型的原型上的同名属性。
寄生组合式继承的原理是借用构造函数来继承属性,通过原型链的混成形式来继承方法。这样就不必为了指定子类型的原型而调用超类型的构造函数。本质是使用寄生式继承来继承超类型的原型,再将结果指定给子类型的原型。
function inheriPrototype(A, B) {
// 创建对象,为A原型的一个副本
var prototype = Object.create(A.prototype);
// 为创建的副本添加constructor属性
prototype.constructor = B;
// 将新创建的对象赋值给子类型的原型
B.prototype = prototype;
}
function A(name) {
this.name = name;
this.friends = ['a', 'b', 'c'];
}
A.prototype.sayName = function () {
console.log(this.name);
};
function B(name, age) {
A.call(this, name);
this.age = age;
}
// 值调用一次A构造函数,避免了在B.prototype上创建不必要多余属性的情况
inheriPrototype(A, B);
B.prototype.sayAge = function () {
console.log(this.age);
};