第六章:面向对象的程序设计
本章内容:
- 理解对象属性
- 理解并创建对象
- 理解继承
ECMA-262把对象定义为:"无序属性的集合,其属性可以包含基本值,对象或者函数"。严格来说,这就相当于说对象是一组没有特定顺序的值。对象的每个属性都有一个名字,而每个名字都映射到了一个值。
6.1 理解对象
创建对象的实例有两种方式
// new的方式
var obj = new Object();
// 表达式方式
var obj = {};
在对象上增加属性与:
// 可以这样
var person = {};
person.name = "TOM";
person.getName = function() {
return this.name;
}
// 也可以这样
var person = {
name: "TOM",
getName: function() {
return this.name;
}
}
访问对象的属性和方法
假如我们有一个简单的对象如下:
var person = {
name: 'TOM',
age: '20',
getName: function() {
return this.name
}
}
当我们想要访问他的name属性时,可以用如下两种方式访问。
person.name
// 或者
person['name']
如果我们想要访问的属性名是一个变量时,常常会使用第二种方式。例如我们要同时访问person的name与age,可以这样写:
['name', 'age'].forEach(function(item) {
console.log(person[item]);
})
这种方式一定要重视,记住它以后在我们处理复杂数据的时候会有很大的帮助。
6.1.1 属性类型
ECMA-262第五版在定义只有内部采用的特征(attribute)时,描述了属性(property)的各种特征。
ECMAScript中有两种属性:数据属性和访问器属性。
数据属性
数据属性包含一个数据值的位置。在这个位置上可以读取和写入值。数据属性有4个描述其行为的特征。
- [[Configurable]]:表示能否用delete删除属性从而重新定义属性,能否修改属性的特征(包括value),或者能否把属性修改为访问器属性。默认值为true
- [[Enumerable]]: 表示能否通过for-in循环返回属性。默认值为true;
- [[Writable]]: 表示能否修改属性的值。 默认值为true;
- [[value]]: 包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值得时候,把新值保存在这个位置。这个特性默认值为undefined
我们可以通过Object.defineProperty
方法来修改这些属性类型。
下面我们用一些简单的例子来演示一下这些属性类型的具体表现。
configurable
// 用普通的方式给person对象添加一个name属性,值为TOM
var person = {
name: 'TOM'
}
// 使用delete删除该属性
delete person.name; // 返回true 表示删除成功
// 通过Object.defineProperty重新添加name属性
// 并设置name的属性类型的configurable为false,表示不能再用delete删除
Object.defineProperty(person, 'name', {
configurable: false,
value: 'Jake' // 设置name属性的值
})
// 再次delete,已经不能删除了
delete person.name // false
console.log(person.name) // 值为Jake
// 试图改变value
person.name = "alex";
console.log(person.name) // Jake 改变失败
enumerable
var person = {
name: 'TOM',
age: 20
}
// 使用for-in枚举person的属性
var params = [];
for(var key in person) {
params.push(key);
}
// 查看枚举结果
console.log(params); // ['name', 'age']
// 重新设置name属性的类型,让其不可被枚举
Object.defineProperty(person, 'name', {
enumerable: false
})
var params_ = [];
for(var key in person) {
params_.push(key)
}
// 再次查看枚举结果
console.log(params_); // ['age']
writable
var person = {
name: 'TOM'
}
// 修改name的值
person.name = 'Jake';
// 查看修改结果
console.log(person.name); // Jake 修改成功
// 设置name的值不能被修改
Object.defineProperty(person, 'name', {
writable: false
})
// 再次试图修改name的值
person.name = 'alex';
console.log(person.name); // Jake 修改失败
value
var person = {}
// 添加一个name属性
Object.defineProperty(person, 'name', {
value: 'TOM'
})
console.log(person.name) // TOM
访问器属性
访问器属性不包含数据值;他们是一对儿getter和setter函数(不过,这两个函数都不是必须的)。读取访问器属性时,会调用getter函数,这个函数负责返回有效的值;在读取写入访问器的属性时,会调用setter函数并传入新值,这个函数负责如何处理数据。访问器属性有如下4个特征:
- [[Configurable]]: 表示能否用delete删除属性从而重新定义属性,能否修改属性的特征(包括value),或者能否把属性修改为数据属性。默认值为true
- [[Enumerable]]: 表示能否通过for-in循环返回属性。默认值为true;
- [[Get]]: 在读取属性的时候调用的函数,默认为undefined;
- [[Set]]: 在写入属性的时候调用的函数,默认为undefined;
get/set
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 = 2005;
console.log(book.edition); //2
_year前面的下划线是一种常用标记,用于表示只能通过对象方法访问的属性。
这是使用访问器属性的常用方式,即设置一个属性的值会导致其他属性发生变化。
setter和getter可以拦截你的赋值和获取操作.实际上你用的那个等号就可以理解成一个语法糖.
请尽量同时设置get、set。如果仅仅只设置了get,那么我们将无法设置该属性值。如果仅仅只设置了set,我们也无法读取该属性的值。
Object.defineProperty
只能设置一个属性的属性特性。当我们想要同时设置多个属性的特性时,需要使用我们之前提到过的Object.defineProperties
var person = {}
Object.defineProperties(person, {
name: {
value: 'Jake',
configurable: true
},
age: {
get: function() {
return this.value || 22
},
set: function(value) {
this.value = value
}
}
})
person.name // Jake
person.age // 22
读取属性的特性值
我们可以使用Object.getOwnPropertyDescriptor
方法读取某一个属性的特性值。
var person = {}
Object.defineProperty(person, 'name', {
value: 'alex',
writable: false,
configurable: false
})
var descripter = Object.getOwnPropertyDescriptor(person, 'name');
console.log(descripter);
/* 返回如下
descripter = {
configurable: false,
enumerable: false,
value: 'alex',
writable: false
}
*/
6.2 创建对象
虽然使用构造函数或对象字面量都可以创建单个对象,但这个方式有个明显缺点:使用同一个接口创建很多对象,会产生大量的重复代码。
假如我们在实际开发中,不仅仅需要一个名字叫做TOM的person对象,同时还需要另外一个名为jim的person对象,虽然他们有很多相似之处,但是我们不得不重复写两次。
var perTom = {
name: 'tom',
age: 18,
}
var perJim = {
name: 'jim',
age: 20
}
6.2.1 工厂模式
我们可以使用工厂模式的方式解决这个问题。顾名思义,工厂模式就是我们提供一个模子,然后通过这个模子复制出我们需要的对象。我们需要多少个,就复制多少个。
function createPerson(name,age){
var o = new Object();
o.name = name;
o.age = age;
o.sayName = function(){
console.log(o.name)
}
return o
}
var tom = createPerson('tom',18); //实例化tom
var tom = createPerson('jim',20); //实例化jim
相信上面的代码并不难理解,也不用把工厂模式看得太过高大上。很显然,工厂模式帮助我们解决了重复代码上的麻烦,让我们可以写很少的代码,就能够创建很多个person对象。
工厂模式的问题:
问题就是这样处理,我们没有办法识别对象实例的类型。使用instanceof可以识别对象的类型,如下例子:
var obj = {};
var foo = function() {}
console.log(obj instanceof Object); // true
console.log(foo instanceof Function); // true
因此在工厂模式的基础上,我们需要使用构造函数的方式来解决这个麻烦。
6.2.2 构造函数
在JavaScript中,new关键字可以让一个函数变得与众不同。通过下面的例子,我们来一探new关键字的神奇之处。
function demo() {
console.log(this);
}
demo(); // window
new demo(); // demo
为了能够直观的感受他们不同,建议大家动手实践观察一下。很显然,使用new之后,函数内部发生了一些变化,让this指向改变。那么new关键字到底做了什么事情呢,大概理解来表达一下吧
// 先一本正经的创建一个构造函数,其实该函数与普通函数并无区别
var Person = function(name, age) {
this.name = name;
this.age = age;
this.sayName = function() {
return this.name;
}
}
// 将构造函数以参数形式传入
function New(func) {
// 声明一个中间对象,该对象为最终返回的实例
var res = {};
if (func.prototype !== null) {
// 将实例的原型指向构造函数的原型
res.__proto__ = func.prototype;
}
// ret为构造函数执行的结果,这里通过apply,将构造函数内部的this指向修改为指向res,即为实例对象
var ret = func.apply(res, Array.prototype.slice.call(arguments, 1));
// 当我们在构造函数中明确指定了返回对象时,那么new的执行结果就是该返回对象
if ((typeof ret === "object" || typeof ret === "function") && ret !== null) {
return ret;
}
// 如果没有明确指定返回对象,则默认返回res,这个res就是实例对象
return res;
}
// 通过new声明创建实例,这里的p1,实际接收的正是new中返回的res
var p1 = New(Person, 'tom', 20);
console.log(p1.getName());
// 当然,这里也可以判断出实例的类型了
console.log(p1 instanceof Person); // true
所以,为了能够判断实例与对象的关系,我们就使用构造函数来搞定。
function Person(name,age){
this.name = name;
this.age = age;
this.sayName = function(){
console.log(this.name);
}
}
var p1 = new Person('Ness', 20);
console.log(p1.sayName()); // Ness
console.log(p1 instanceof Person); // true
关于构造函数,如果你暂时不能够理解new的具体实现,就先记住下面这几个结论吧。
- 与普通函数相比,构造函数并没有任何特别的地方,首字母大写只是我们约定的小规定,用于区分普通函数;
- new关键字让构造函数具有了与普通函数不同的许多特点,而new的过程中,执行了如下过程:
- 声明一个中间对象;
- 将该中间对象的原型指向构造函数的原型;
- 将构造函数的this,指向该中间对象;
- 返回该中间对象,即返回实例对象。
构建函数的问题:
每个方法都要在每个实例上重新创建一遍, 虽然构造函数解决了判断实例类型的问题,但是,说到底,还是一个对象的复制过程。跟工厂模式颇有相似之处。也就是说,当我们声明了100个person对象,那么就有100个getName方法被重新生成。
这里的每一个sayName方法实现的功能其实是一模一样的,但是由于分别属于不同的实例,就不得不一直不停的为sayName分配空间。这就是工厂模式存在的第二个麻烦。
显然这是不合理的。我们期望的是,既然都是实现同一个功能,那么能不能就让每一个实例对象都访问同一个方法?
var p1 = new Person('Ness', 20);
var p2 = new Person('Ness1', 21);
console.log(p1.sayName == p2.sayName); //false
6.2.3 原型模式
我们创建的每个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面上来理解,那么prototype就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处就是可以让所有其对象实例共享它的属性和方法。换句话说,不必再构造函数中定义对象的实例信息,而是可以将这些信息直接添加到原型对象中。
function Person(){};
Person.prototype.name = 'nicholas';
Person.prototype.age = 18;
Person.prototype.sayName = function(){
console.log(this.name)
}
var person1 = new Person();
person1.sayName();
var person2 = new Person();
person2.sayName();
console.log(person1.sayName == person2.sayName); //true
理解原型对象
无论什么时候,只要创建一个函数,就会根据一组特定的规则为该函数创建一个prototype
属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获取constructor
(构造函数)属性,这个属性是一个指向prototype属性所在的函数指针。就拿前面的一个例子Person.prototype.constructor指向Person。而通过这个构造函数。
创建了自定义的构造函数后,其原型对象默认只会取得constructor属性;至于其他方法都是从Object继承下来的。
当调用构造函数创建一个新实例后,该实例的内部包含了一个指针(内部属性),指向了构造函数的原型对象。ECMA-262第五版叫这个为[[Prototype]],虽然脚本中没有标准的方式访问[[prototype]],但浏览器上有个支持的属性____proto____。要明确的一点:这个链接存在实例与构造函数的原型对象之间,而不是实例与构造函数之间。
在浏览器中[[prototype]]实现了一个属性是proto
通过图示我们可以看出,构造函数的prototype与所有实例对象的__proto__
都指向原型对象。而原型对象的constructor指向构造函数。
当我们访问实例对象中的属性或者方法时,会优先访问实例对象自身的属性和方法。如果没有找到会通过[[prototype]]找其原型对象
原型与in操作符
我们还可以通过in来判断,一个对象是否拥有某一个属性/方法,无论是该属性/方法存在与实例对象还是原型对象。
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.getName = function() {
return this.name;
}
var p1 = new Person('tim', 10);
console.log('name' in p1); // true
in的这种特性最常用的场景之一,就是判断当前页面是否在移动端打开。
isMobile = 'ontouchstart' in document;
// 很多人喜欢用浏览器UA的方式来判断,但并不是很好的方式
更简单的原型语法
根据前面例子的写法,如果我们要在原型上添加更多的方法,可以这样写:
function Person() {}
Person.prototype.getName = function() {}
Person.prototype.getAge = function() {}
Person.prototype.sayHello = function() {}
... ...
除此之外,我还可以使用更为简单的写法。
function Person() {}
Person.prototype = {
getName: function() {},
getAge: function() {},
sayHello: function() {}
}
var friend = new Person();
console.log(friend instanceof Object); //true
console.log(friend instanceof Person); //true
console.log(friend.constructor == Person); //false
console.log(friend.constructor == Object); //true
这种字面量的写法看上去简单很多,但是有一个需要特别注意的地方。Person.prototype = {}
实际上是重新创建了一个{}
对象并赋值给Person.prototype,这里的{}
并不是最初的那个原型对象。因此它里面并不包含constructor
属性。为了保证正确性,我们必须在新创建的{}
对象中显示的设置constructor
的指向。即上面的constructor: Person
。
Person.prototype = {
constructor: Person,
getName: function() {},
getAge: function() {},
sayHello: function() {}
}
原型的动态性
由于原型中查找值是搜索的过程,因此我们在原型对象所做的任何修改都能立刻从实例上反映出来。
var friend = new Person();
Person.prototype.sayHi = function(){
console.log('hi');
}
friend.sayHi(); //'hi' 没有问题
但如果重写整个原型对象,那么情况就不一样了。我们知道,调用构造函数时会为新的实例添加一个指向最初原型的[[Prototype]]指针,而把原型修改为另外一个对象就等于切断了构造函数与最初原型之间的联系。
请记住:实例中的指针仅指向原型,而不是指向构造函数。看看下面的这个例子:
function Person(){};
var friend = new Person();
Person.prototype = {
constructor: Person,
name: 'Nicholas',
age: 29,
sayName: function(){
alert(this.name);
}
};
friend.sayName(); // error friend.sayName is not a function
从图上可看出,重写原型对象切断了现有原型与任何之前已经存在的对象实例之间的联系;它们引用的仍然是最初的原型。
原型对象的问题
对于那些包含基本值得属性可以说得过去,通过在实例上添加同名方法,可以隐藏原型中的对应属性。但是:对于包含引用类型的值,问题就出来了
function Person(){}
Person.prototype = {
constructor: Person,
name: 'Nicholas',
age: 29,
friends: ['zz','xx'],
sayName: function(){alert(this.name)}
}
var person1 = new Person();
var person2 = new Person();
console.log(person1.friends.friends); //['zz','xx']
person2.friends.push('cc');
console.log(person1.friends.friends); //['zz','xx','cc']
console.log(person1.friends == person2.friends)
由于引用类型friends存在Person.prototype中而非person1中,所以刚刚person2的修改也会影响person1.
6.2.4 组合使用构造函数模式和原型模式
每个实例都有自己一份实例属性的副本,但同时共享者公共属性的引用
function Person(name,age){
this.name = name;
this.age = age;
this.friends = ["zz","xx"];
}
Person.prototype = {
constructor: Person,
sayName: function(){
alert(this.name)
}
}
var person1 = new Person('Nicholas',29)
var person2 = new Person('Greg',27)
console.log(person1.friends == person2.friends); //false
console.log(person1.sayName == person2.sayName); //true
6.3 继承
6.3.1 原型链
原型对象其实也是普通的对象。几乎所有的对象都可能是原型对象,也可能是实例对象,而且还可以同时是原型对象与实例对象。这样的一个对象,正是构成原型链的一个节点。因此理解了原型,那么原型链并不是一个多么复杂的概念。
基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。
简单回顾一下构造函数、原型和实例的关系:每一个构造函数都有一个原型对象,原型对象都包含有一个指向构造函数的指针,而每个实例有指向原型对象内部的指针。那么,假如我们让远行对象等于另一个类型的实例,那么此时的原型对象包含另一个指向另一个原型的指针,相应的,另一个原型也包含着指向另一个构造函数的指针。
实现原型链的一种基本模式,大概如下:
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();
alert(instance.getSuperValue());
以上代码定义了两种类型:SuperType和SubType。每个类型分别有一个属性和一个方法。他们的主要区别是SubType继承了SuperType,而继承是通过创建SuperType的实例,并将该实例赋值给了SubType.prototype实现的。实现的本质是重写了原型对象,带之以一个新对象的实例。换句话说,原来存在SuperTYpe的实例中的所有属性和方法,也存于SubType.prototype中了。图解如下:
这里SubType的原型即是SuperType的实例也是SubType的原型。
别忘记默认的原型
所有的引用类型都继承了Object,而这个继承也是用原型链实现的。大家记住,所有的默认原型都是Object的实例,因此默认原型都会包含一个内部指针,指向Object.prototype。
一句话,SubType继承了SuperType,而SuperType继承了Object,当调用instance.toString时,实际上是调用Object.prototype中的那个方法。
原型链的问题
最主要的问题来自包含引用类型值得原型。包含引用类型值得原型属性会被所有实例共享;而这也正是因为为什么在构建函数中,而不是原型对象中定义引用类型的原因。
function SuperType(){
this.color = ['red','blue'];
}
function SubType(){
}
// 继承superType
SubType.prototype = new SuperType();
var instance1 = new SubType();
instance1.color.push('pink'); // ['red','blue','pink']
var instance2 = new SubType();
instance2.color.push('orange'); // ['red','blue','pink', 'orange'];
6.3.2 借用构造函数
为了解决原型中包含引用类型值,有了一种叫借用构造函数的技术。即:在子类型的构造函数中调用超类型的构造函数
function SuperType(name){
this.name = name;
}
function SubType(){
// 继承SuperType, 同时还传递了参数
SuperType.call(this,'Nicholas');
this.age = 18;
}
var instance = new SubType();
alert(instance.age); // 18
6.3.3 组合继承
背后的思路就是使用原型链实现对原型属性和方法的继承,然后通过构造函数实现对实例属性的继承。
function SuperType(name){
this.name = name;
this.colors = ['blue'];
}
SuperType.prototype.sayName = function(){
alert(this.name);
}
function SubType(name, age){
// 继承属性
SuperType.call(this,name);
this.age = age;
}
// 继承方法
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
alert(this.age);
}
var instance1 = new SubType('zz',18);
instance1.sayName(); //zz
instance1.sayAge(); //18
instance1.colors.push('red');
console.log(instance1.colors); // ['blue','red']
var instance2 = new SubType('tt',22);
console.log(instance2.colors); // ['blue']
6.3.4 原型式继承
借助原型可以基于已有的对象创建新对象
function object(o){
function F(){};
F.prototype = o;
return new F();
}
从本质上将,object()对传入其中的对象做了一次浅复制。看下面的例子
var Person = {
name: 'Nicholas'
friends: ['zz','cc']
}
var anotherPerson = object(Person);
anotherPerson.name.push('dd');
console.log(anotherPerson.friends); //['zz','cc','dd']
ECMAScript5 新增了Object.create()方法规范了原型式继承。这个方法接受两个参数,一个作为新对象原型的对象和(可选)一个为新对象定义额外属性的对象。
var anotherPerson = Object.create(person, {
name:{
value: 'Greg'
}
})
但这种方式的缺点跟原型链的方式相同:还是引用类型的属性会被共享出来。
6.3.5 寄生式继承
寄生式继承的思路创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后在返回对象。
function createAnother(o){
var clone = object(o);
o.sayHi = function(){
console.log('hi');
}
return clone;
}
6.3.5 寄生式组合式继承
组合式继承最大的问题就是调用了两次超类的构造函数,
function SuperType(name){
this.name = name;
this.colors = ['red','blue','green'];
}
SuperType.prototype.sayName = function(){
alert(this.name);
}
function SubType(name,age){
SuperType.call(this,name);
this.age = age;
}
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.saAge = function(){
alert(this.age)
}
第一次调用构造函数的时候SubType.prototype = new SuperType();
,SupType.的原型上会得到name和colors两个属性。他们都是SuperType实例上的属性,不过这里被SubType的原型所拥有。
当调用SubType的构造函数时SuperType.call(this,name);
,又在新的对象创建了name和colors的两个属性。于是这两个属性就屏蔽了原型上的属性。解释如图:
所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的思路是:不必为指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类的原型的一个副本。本质上,就是使用寄生式继承来继承超类的原型,然后再将结果指定子类型的原型
function object(o){
function F(){};
F.prototype = o;
return new F();
}
function inheritPrototype(subType,superType){
var prototype = object(superType.prototype);
prototype.constructor = subType;
subType.prototype = prototype;
}
function SuperType(name){
this.name = name;
this.colors = ['red','blue','green'];
}
SuperType.prototype.sayName = function(){
alert(this.name);
}
function SubType(name,age){
SuperType.call(this,name);
this.age = age;
}
inheritPrototype(SubType,SuperType);
SubType.prototype.sayAge = function(){
alert(this.age)
}
var instance1 = new SubType('zz', 18);
instance1.sayName();
小结:
ECMASscript支持面向对象(OO)模型,但不适用类或者接口。对象可以在代码执行过程中创建和增强,因此具有动态性和非严格定义的实体。在没有类的情况下,可以采用下列的模式创建对象:
- 工厂模式,使用简单的函数创建对象,在对象上添加属性和方法,然后返回对象。这个模式被构造函数模式取代。
- 构造函数模式,可以创建自定义引用类型,可以像创建内置对象实例一样用new操作符。不过构造函数模式也有缺陷,即它的每个属性不能得到复用,包括函数。
- 原型模式,使用构造函数的prototype的属性来指定那些应该共享的属性和方法。组合使用原型模式和构造函数模式,使用构造函数模式定义实例属性,使用原型模式来共享属性和方法。
Javascript主要通过原型链来实现继承。原型链的构建是通过一个类型的实例来赋值到另一个类型的构造函数的原型实现的。原型链的问题是对象实例共享所有的属性和方法。解决这个问题是用构造函数,即在子类型构造函数的内部调用超类型的构造函数。这样就可以做到每个实例都具有自己的属性。使用最多的继承模式是组合模式,这种模式使用原型链继承共享的属性和方法,而通过构造函数继承实例属性。
此外还有可选择的继承模式。
- 原型式继承,可以在不必预先定义构造函数的情况下实现继承,其本质是执行给定对象的浅复制,而复制得到的副本可以得到进一步改造。
- 寄生式继承,原型式继承相似,也是基于某个对象的某些信息创建一个对象,然后增强对象,最后返回对象。为了解决组合继承模式由于多次调用超类型构造函数而导致的低效率问题,可以将这种模式与组合继承一起使用。
- 寄生组合继承,集寄生式继承和组合继承的优点,实现基于类型继承最有效方式。