【JavaScript高级程序设计】读书笔记之二 —— 理解对象的原型、继承

这一章主要介绍JS高程的第六章:面向对象的程序设计。

面向对象(Object-Oriented)的语言的一个标志是都有类的概念,通过类可以创建多个具有相同属性和方法的对象。而ECMAScript中是没有类的概念的,所以ES的对象跟其他基于类的对象时有所不同的。

在ECMA-262中,把对象定义为:无序属性的集合,其属性可以包含基本值、对象或函数

ECMAScript 支持面向对象编程,但不是使用类或接口。对象可以在代码的执行过程中创建和增强,因此具有动态性,而非严格定义的实体。 —— P174

一、理解对象

ECMAScript中有两种属性(property):

  • 数据属性 Data Properties
  • 访问器属性 Accessor Properties

ECMA-262 第5版在定义特性(attribute)时,描述了属性的各种特征。这些特性是为了实现JavaScript引擎用的,是只有内部才用的,在JS中不能直接访问它们。为了表示内部值,规范中把它们放在[[ ]]中,如[[Enumerable]]。—— P139

ECMA-262 fifth edition describes characteristics of properties through the use of internal-only
attributes. These attributes are defi ned by the specifi cation for implementation in JavaScript engines,
and as such, these attributes are not directly accessible in JavaScript.

1.属性类型

(1)数据属性

数据属性包含一个数据值的位置,在这个位置可以读取和写入值。有4个 描述其行为的特性

  • [[Configurable]]
    表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。默认值为true。
  • [[Enumerable]]
    表示能否通过for-in循环返回属性,默认值为true。
  • [[Writable]]
    表示能否修改属性的值,默认值为true。
  • [[Value]]
    包含这个属性的值,读取属性值的时候,从这个位置读。写入属性值的时候,把新值保存到这个位置。默认值为undefined。

要修改属性的特性,必须使用ECMAScript 5的Object.defineProperty()方法,其参数依次为属性所在的对象,属性的名字,描述符对象

如下:

var person = {};
Object.defineProperty(person, 'name', {
	writable: false,
	value: 'Nico'
});
console.log(person.name);	// 'Nico'
person.name = 'Greg';
console.log(person.name);	// 'Nico'

当writable特性设置为false时, 则这个属性的值是只读的,不可修改。如果尝试修改它的值,在非严格模式下,赋值操作会被忽略;在严格模式下,会抛出错误(在Chrome 56下报 TypeError: Cannot assign to read only property 'name' of object '#<Object>')。

对于configurable,一旦把一个属性定义为不可配置的,那么就再也不能把它改变为可配置的了。

虽然在大多数的情况下,没有必要利用Object.defineProperty()方法提供的这些高级功能,不过,这对于理解JavaScript对象却非常有用。

** 注意:建议不要在IE8中使用Object.defineProperty()方法,因为IE8中只能在DOM对象上使用这个方法,而且只能创建访问器属性,由于实现的不彻底,不建议使用。 **

(2)访问器属性

访问器属性不包含数据值,它们包含一对 gettersetter 函数,在读取访问器属性时,会调用 getter 函数,这个函数负责返回有效的值;在写入访问器属性时,会调用 setter 函数并传入新值,这个函数负责决定如何处理数据。

访问器属性也有4个特性:

  • [[Configurable]]
    表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为数据属性。默认值为true。
  • [[Enumerable]]
    表示能否通过for-in循环返回属性,默认值为true。
  • [[Get]]
    读取属性时调用的函数,默认值为undefined。
  • [[Set]]
    写入属性时调用的函数,默认值为undefined。

同样,访问器属性也不能直接定义,必须使用Object.defineProperty()方法。

var book = {
	_year: 2004,	// 下划线是一种常用的记号,用来表示只能通过对象方法访问的属性
	edition: 1
};

// 访问器属性 year 包含getter函数和setter函数
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

其实,在使用这个方法之前,要创建访问器属性,一般都使用两个非标准方法:__defineGetter__()__defineSetter__()。最初是由Firefox引入的,后来Safari 3,、Chrome 1、Opera 9.5也有相同的实现。在对象的原型__proto__中也能看到这两个方法。

重写访问器属性 year 的方法,如下:

var book = {
	_year: 2004,
	edition: 1
};

book.__defineGetter__('year', function(){
	return this._year;
})

book.__defineSetter__('year', function(newValue){
	if(newValue > 2004) {
		this._year = newValue;
		this.edition += newValue - 2004;
	}
});

book.year = 2005;
console.log(book._edition);		// 2

2.定义多个属性

Object.defineProperty()这个方法的另一个类似方法是Object.defineProperties(),不同的是这个方法可以通过描述符一次定义多个属性。它接受两个参数:属性所在的对象,其属性与第一个对象的属性一一对应的描述符对象,例如:

Object.defineProperties(book, {
	_year: {
		writable: true,
		value: 2004
	},
	edition: {
		writable: true,
		value: 1
	},
	year: {
		get: function(){
			return this._year;
		},
		set: function(newValue){
			if(newValue > 2004) {
				this._year = newValue;
				this.edition += newValue - 2004;
			}
		}
	}
});

3.读取属性的特性

Object.getOwnPropertyDescriptor()方法可以取得给定属性的描述符。这个方法接受两个参数:属性所在的对象,要读取其描述符的属性名称,返回值是一个对象,根据是数据属性还是访问器属性返回对应的对象。
根据上面直接定义多个属性代码之后,

var descriptor = Object.getOwnPropertyDescriptor(book, '_year');
console.log(descriptor)

打印结果为:

configurable:false
enumerable:false
value:2004
writable:true

支持Object.defineProperty()Object.defineProperties()Object.getOwnPropertyDescriptor()的浏览器有IE9+(IE8只有部分实现)、Firefox 4+、Safari 5+、Opera 12+、Chrome.

二、创建对象

单例模式(Singleton Pattern)

单例模式是指只有一个实例的对象。
JavaScript推荐使用对象字面量的方式创建单例对象:

var singleton = {
	name: 'value',
	method: function(){
		// 代码块
	}
};

模块模式(Module Pattern)

var obj = function(){

	// 私有变量和私有函数
	var privateVariable = 10;

	function privateFunction(){
		return false;
	}

	// 公有方法:返回对象字面量,是这个单例的公共接口。
	return{
		publicProperty: true,
		publicMethod: function(){
			++privateVariable;
			return privateFunction();
		};
	}
}

以上两种方式参考上一次分享中的创建这种特权方法的方式之模块模式(Module Pattern)。

工厂模式(Factory Pattern)

这种模式抽象了创建具体对象的过程。由于ES中没有类,所以就发明了一种函数,封装整个创建对象的细节,只给出一个接口。

function createPerson(name, age) {
	var obj = new Object();
	obj.name = name;
	obj.age = age;
	obj.sayName = function(){
		console.log(this.name);
	};
	return obj;
}

但是这种模式有一个缺点: 无法进行对象的识别,即我们不知道这个对象的类型。

构造函数模式(Constructor Pattern)

构造函数可以创建一个原生的/自定义的特定类型的实例(对象)。

构造函数应该使用大写字母开头的函数名,这种做法是借鉴于其他的OO语言,主要是为了区别于ES中的其他函数,实际上构造函数也不过是一个普通的函数,只不过其作用是用来创建对象,唯一区别是调用方式不同——要使用这种构造函数创建对象的实例,必须使用 new 操作符。

如下,利用构造函数模式重写前面的例子:

function Person(name, age) {	// 是定义在Global对象中的(在浏览器中是Window对象)
	this.name = name;
	this.age = age;
	this.sayName = function(){
		console.log(this.name);
	};
}

var person = new Person('Shih', 27);

对比这两种模式,可以看到构造函数模式

  • 没有显式地创建对象
  • 直接将属性和方法赋给this对象
  • 不需要return语句

使用构造函数模式创建对象的总体流程是:

  1. 创建一个对象
  2. 将构造函数的作用域赋给这个新对象(即将this指向了新对象)
  3. 执行构造函数中的代码(为新对象添加属性和方法)
  4. 返回这个新对象

比较重要的概念:

  • 在实例 person 中,有一个 constructor 属性,这个属性指向 Person
person.constructor === Person	// true
  • 对象的 constructor 属性最开始是用来标识对象的类型的,但是用 instanceof 来检测对象的具体类型更加靠谱一些。
person instanceof Object	// true
person instanceof Person	// true

所以这种模式创建出的实例可以标识为一种特定的类型,这也是构造函数模式优于工厂模式之处。

当构造函数作为普通函数

前面讲到,构造函数与普通函数没什么根本的区别,唯一的区别是调用的方式不同。当我们不使用 new 操作符来调用,那么它跟普通函数没什么两样,如下

// 作普通函数使用
Person('Shih', 27);
window.sayName();   // 'Shih'

// 在另一个对象的作用域中调用
var obj = new Object();
Person.call(obj, 'Kris', 25);
obj.sayName();  // 'Kris'
构造函数模式的缺点

以这种方式创建的每个对象实例都会创建一个sayName函数,即包含一个sayName实例。其实,我们可以利用函数指针,每个实例不需要指向
每个实例不需要都去创建一个sayName的实例,而是利用函数指针指向同一处公共的函数代码。

function Person(name, age) {
	this.name = name;
	this.age = age;
	this.sayName = sayName;
}

function sayName() {    
    console.log(this.name);
}

但是,缺点是这个方法是全局的,没有体现封装性,所以引入原型模式。

原型模式(Prototype Pattern)

每一个函数都有一个原型属性(prototype),它是一个指向一个对象的指针,这个对象包含着这个特定类型函数的所有实例共享的方法和属性——使用原型的好处是可以让所有的该类型对象的实例共享它所包含的所有方法和属性。

如下,利用原型模式再次重写前面的例子:

function Person() {
}

Person.prototype.name = 'Shih';
Person.prototype.age = 27;
Person.prototype.sayName = function(){
	console.log(this.name);
};

var person = new Person();

对于对象中的方法来说,为所有实例所共享,不需要每个实例都去创建这个方法;对于属性来说,也是为所有实例所共享,但是当一个实例改变了这个属性,其他的实例也跟着受影响,所以需要考虑使用场景,大多数情况下,都不建议把属性放在原型中,而是放在构造函数内部定义,定义在this对象上,也就是定义在每一个单独的实例上。

理解原型对象

原型对象

**
创建了一个这个类型的对象实例之后,这个实例只有从构造函数中定义的属性(和方法),以及原型prototype(图中第一级 __proto__)。
那么创建了自定义的构造函数之后,除了我们自己在原型上添加的方法(和属性),其原型对象只会取得 constructor 属性,其他的都是从 Object 继承而来(图中最后一级的 __proto__)。
**

无论什么时候,只要创建一个新函数,就会根据一组特定的规则为该函数创建一个 prototype 属性,这个属性指向函数的原型对象。

注:ECMA-262 Version 5 把这个指向构造函数的原型对象的指针叫做 [[Prototype]],在脚本中是没有标准的形式访问 [[PRototype]] 的,这应该是一个不可见的属性,但是在有些浏览器中:Chrome、Firefox、Safari中的对象上支持一个属性 __proto__ ,如上图,所以这不是一个标准的属性。

书上之处真正的重点:

这个连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。

访问对象的属性的搜索顺序
几个关于原型的方法
isPrototypeOf( )

我们无法访问 [[Prototype]],但可以通过这种方法来判断对象之间是否存在这种关系:

Person.prototype.isPrototypeOf(person)		// true

有点类似利用 instanceof 来判断某个对象是不是某个特定类型的实例:

person instanceof Person	// true
Object.getPrototypeOf( )

在所有的实现中,这个方法取出一个对象实例的 [[Prototype]] 的值

Object.getPrototypeOf(person) == Person.prototype		// true
Object.getPrototypeOf(person).name		// 'Shih'

IE9以上才支持这个方法。

hasOwnProperty( )

这个方法是用来检测在某个对象实例中是否存在某个属性。

person.hasOwnProperty('name')		// false
in 操作符
  • 单独使用 in 操作符:无论这个属性时存在于实例中还是原型中,都会返回true
'name' in person		// true,无论 'name' 存在于原型还是实例中
person.hasOwnProperty('name')		// false

所以可以通过同时使用 inhasOwnProperty() 来确定某个属性到底是存在于对象实例中还是原型中。

function hasPrototypeProperty(object, attr) {
	return !object.hasOwnProperty(attr) && (attr in object);
}
  • for-in 循环中使用
    这种方式是返回能够通过对象访问到的、可枚举的属性(即 [[Enumerable]] 标记为 true),既包括实例中的属性,也包括原型中的属性。
    在IE8及更早版本中有一个Bug:
    应该说,就算是原型中有一个不可枚举的属性,但是开发人员重新定义了一个同名属性,屏蔽了原型中的这个属性,那么这个定义的实例属性肯定是可以枚举的。但是IE8及更早的版本例外。
Object.keys()

这个方法取对象上所有可枚举的实例属性
参数为接收一个对象,返回一个包含所有可枚举属性的字符串数组,而且这个顺序也是它们在 for-in 中出现的顺序。

Object.getOwnPropertyNames()

返回所有的实例属性,无论是否可枚举。

但是上面的这两个方法需要IE9+以上支持。

简化原型语法

三、继承

OO语言主要支持两种继承方式:
1.接口继承:只继承方法签名
2.实现继承:继承实际的方法

在ECMAScript中,由于函数没有签名,所以无法实现接口继承。只支持实现继承,而且实现继承的主要方法是原型链。

注:函数签名就是函数的声明信息,包括参数、返回值、调用约定之类。函数在重载时,利用函数签名的不同(即参数个数与类型的不同)来区别调用者到底调用的是哪个方法。

原型链

用原型链实现继承的基本思想是:利用原型让一个引用类型继承另一个引用类型的属性和方法。
实际中,很少单独是用原型链。

构造函数、原型、实例的关系

每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例中都包含一个指向这个原型对象的内部指针。-- P162

如下图,

原型链

在实例A中存在一个指针指向构造函数的原型对象,如果我们改变这个指向,也就是不再指向它的构造函数的原型的对象,而是把这个指针指向另一个类型的实例B。在实例A中存在一个指针指向构造函数的原型对象,如果我们改变这个指向(改变构造函数的原型对象的指向),也就是不再指向它的构造函数的原型的对象,而是把这个指针指向另一个类型的实例B(重写原型对象)。由于实例B中包含它本身的属性和方法以及它的构造函数的原型对象,那么就相当于实例A继承了另一个类型的实例B的属性和方法,层层递进,就构成了实例与原型的链条。这就是所谓的原型链。

所以,关于原型链的理解是与继承、原型对象相关联的

所以继承的本质是重写原型对象 -- P163

具体的实现方式就是:

function SuperType () {
	this.property = true;
}

SuperType.prototype.getSuperValue = function () {
	return this.property;
}

function SubType () {
	this.subproperty = false;
}
// 继承:将一个构造函数的原型对象指向另一个类型的实例,那么它的实例就相当于继承了另一个类型的属性和方法
// SubType 继承了 SuperType
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function () {
	return this.subproperty;
}

var instance = new SubType();
console.log(instance.getSuperValue());	// true

上述代码的执行结果是:

  • instance指向SubType的原型,SubType的原型又指向了SuperType的原型。
  • 因为重写了原型对象,所以 instance.constructor 现在指向SuperType。
  • 同时,这也相当于扩展了原型搜索机制,现在,调用instance.getSuperValue() 的步骤是:
    1. 搜索实例
    2. 搜索SubType.prototype
    3. 搜索SuperType.prototype
    4. 搜索Object.prototype (默认的原型,所有的函数的默认原型都是Object的实例)

这里,有几个简单的注意点,(1)给原型添加方法的代码要放在替换原型对象的语句之后;(2)给原型添加方法时,不能使用对象字面量的方式创建,因为这样相当于重新指向另一个对象,导致重写了原型链。

原型链存在的问题

第一个还是老问题,看代码

function SuperType() {
	this.colors = ['red', 'blue', 'green'];
}
function SubType() {
}
// 继承
SubType.prototype = new SuperType();

var instance1 = new SubType();
instance1.colors.push('black');
console.log(instance1.colors);	// ['red', 'blue', 'green', 'black']

var instance2 = new SubType();
console.log(instance2.colors);	// ['red', 'blue', 'green', 'black']

SuperType实例的属性存在于SubType的原型中,所以会存在共享。实际上,包含引用类型值的原型属性会被所有实例所共享(在新建一个实例的时候,colors的值没有变,保存的还是指向那个引用类型值的地址,但这个地址指向的那个引用类型值被改变了,相当于C中的指针概念)。

第二个问题是,在创建子类型的实例时,不能够向超类型的构造函数中传递参数。

因此,在实际中,很少单独是用原型链。

借用构造函数

英文是 Constructor Stealing,也叫伪造对象或经典继承。
基本思想很简单,即:在子类型的构造函数的内部调用超类型的构造函数。

函数不过是在特定环境中执行代码的对象,可以通过apply() 和call() 方法在新创建的对象上执行构造函数。

function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}
function SubType() {
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push('black');
console.log(instance1.colors);    // ['red', 'blue', 'green', 'black']

var instance2 = new SubType();
console.log(instance2.colors);    // ['red', 'blue', 'green']

优势:可以传递参数

function SuperType(name) {
    this.name = name;
}
function SubType(name, age) {
	// 继承
    SuperType.call(this, name);
    this.age = age;
}

var instance = new SubType('Shih', 25);
console.log(instance.name, instance.age);	// ‘Shih' 25

值得注意的是为了防止SuperType中会重写子类型的属性,所以在调用超类型的构造函数后,再去添加子类型中定义的属性。

function SuperType(name) {
    this.name = name;
}
function SubType(name, age) {
    this.name = 'Nico';
	// 继承
	SuperType.call(this, name);
    this.age = age;
}

var instance = new SubType('Shih', 25);
console.log(instance.name, instance.age);	// ‘Shih' 25

问题:超类型的原型方法不可见

可以看到,实例只继承了子类型的SubType的属性和方法,而没有继承超类型定义的的属性和方法,所以现实中,借用构造函数的技术也是很少单独用到的。

最常用的继承模式:组合继承

英文是 Combination inheritance,也叫伪经典继承。
即将原型链和借用构造函数技术进行组合的继承模式:使用原型链实现对原型属性和方法的继承,通过构造函数实现对实例属性的继承。

function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'green', 'blue'];
}
SuperType.prototype.getName = function() {
    return this.name;
}

function SubType(name, age) {
    // 继承属性
    SuperType.call(this, name);
    this.age = age;
}

// 继承方法 
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;    // 添加子类型构造函数的指针

// 添加子类型的原型方法
SubType.prototype.getAge = function() {
    return this.age;
}

var instance1 = new SubType('Shih', 25);
instance1.colors.push('black');
console.log(instance1.colors);  // ['red', 'green', 'blue', 'black']
console.log(instance1.getName());    // 'Shih'
console.log(instance1.getAge()); // 25

var instance2 = new SubType('Nico', 24);
console.log(instance2.colors);  // ['red', 'green', 'blue']
console.log(instance2.getName());    // 'Nico'
console.log(instance2.getAge()); // 24

console.log(instance1 instanceof SubType);  // true
console.log(instance1 instanceof SuperType);    // true

如果没有添加子类型的构造函数指针:

组合继承避免了原型链和借用构造函数的缺陷,融合了两者的优点,成为JS中最常用的继承模式,而且instanceof 和 isPrototypeOf() 也能够识别基于组合继承创建的对象。

其他内容:
原型式继承(道格拉斯·克罗克福德——Prototypal Inheritance in Javascript)
寄生式继承(parasitic Inheritance)
寄生组合式继承

posted @ 2017-05-08 19:00  少东主  阅读(271)  评论(0编辑  收藏  举报