原型模式

  • A+
所属分类:Web前端
摘要

我们创建的每个函数都有一个 prototype(原型) 属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以有特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,那么 prototype 就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换句话说,不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中,如下面的例子所示:


原型模式

原型模式

我们创建的每个函数都有一个 prototype(原型) 属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以有特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,那么 prototype 就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换句话说,不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中,如下面的例子所示:

function Person() {}  Person.prototype.name = '小明' Person.prototype.age = 22 Person.prototype.sex = '男' Person.prototype.sleep = function() { alert(this.name + '睡觉了') }  var person1 = new Person() person1.sleep()			// '小明睡觉了'  var person2 = new Person() person2.sleep()			// '小明睡觉了'  console.log(person1.sleep === person2.sleep)	// true 

在这里,我们将 sleep() 方法和所有属性直接添加到了 Personprototype 属性中,构造函数变成了空函数。即使如此,也仍可以通过构造函数来创建新对象,而且新对象还具有相同的属性和方法。但和构造函数模式不同的是,新对象的这些属性和方法是由所有实例共享的。换句话说, person1 person2 访问的是同一组属性和同一个 sleep() 函数。要理解原型模式的工作原理,必须先理解 ES5 中原型对象的性质。

理解原型对象

​ 无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个 prototype 属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor (构造函数)属性,这个属性包含一个指向 prototype 属性所在函数的指针。就拿前面的例子来说, Person.prototype.constructor 指向 Person 。而通过这个构造函数,我们还可继续为原型对象添加其他属性和方法。
​ 创建了自定义的构造函数之后,其原型对象默认只会取得 constructor 属性;至于其他方法,则都是从 Object 继承而来的。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。ECMA-262第5版中管这个指针叫 [[Prototype]]。虽然在脚本中没有标准的访问方式访问 [[Prototype]] ,但是在 Firefox、 Safari 和 Chrome 在每个对象上都支持一个属性 __proto__;而在其它实现中,这个属性对脚本则是完全不可见的。不过要明确的真正重要的一点就是,在这个连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。
​ 以前面使用 Person 构造函数和 Person.prototype 创建实例的代码为例,下图展示了各个对象之间的关系。

原型模式

​ 上图展示了 Person 构造函数、Person 的原型属性以及 Person 现有的两个实例之间的关系。在此,Person.prototype 指向了原型对象,而 Person.prototype.constructor 又指回了 Person。原型对象中除了包含 constructor 属性之外,还包括后来添加的其他属性。 Person的每个实例—— person1person2 都包含了一个内部属性,该属性仅仅指向了 Person.prototype,换句话说,它们与构造函数没有直接的关系。此外,要格外注意的是,虽然这两个实例都不包含属性和方法,但我们却可以调用 person1.sleep()。这是通过查找对象属性的过程来实现的。
​ 虽然在所有实现中都无法访问到 [[Prototype]],但可以通过 isPrototypeOf() 方法来确定对象之间是否存在这种关系。从本质上讲,如果 [[Prototype]] 指向调用 isPrototype() 方法的对象(Person.prototype),那么这个方法就返回 true

console.log(Person.prototype.isPrototypeOf(person1))	// true console.log(Person.prototype.isPrototypeOf(person2))	// true 

​ 这里,用原型对象的 isPrototype() 方法测试了 person1person2。因为它们内部都有一个指向 Person.prototype 的指针,因此都返回了 true

ES55 增加了一个新方法,叫 Object.getPrototypeOf(),在所有支持的实现中,这个方法返回 [[Prototype]] 的值。例如:

console.log(Object.getPrototype(person1) === Person.prototype)	// true console.log(Object.getPrototype(person1).name)		// 小明 

​ 这里的第一行代码只是确定 Object.getProtitypeOf() 返回的对象实际就是这个对象的原型。这第二行代码取得了原型对象中的 name 属性的值,也就是 ‘小明’ 。使用 Object.getPrototypeOf() 可以方便地取得一个对象得原型,而这在利用原型实现继承得情况下是非常重要的。
​ 每当代码读取某个对象得某个属性时,都会执行一次搜索,目标是具有给定名字得属性。搜索首先从对象实例本身开始。如果在实例中找到了具有给定名字得属性,则返回该属性得值;如果没有找到,则继续搜索指针指向得原型对象,在原型对象中查找具有给定名字得属性。如果在原型对象中找到了这个属性,则返回该属性得值。也就是说,在我们调用 person1.name 的时候,会先后执行两次搜索。

person1.name 

这句代码的内部执行过程如下:

graph TD START([开始])-->A[搜索实例 person1 的属性] A-->B{找到name属性}-->|是|C[返回name属性值]-->END([结束]) B-->|否|E[搜索实例 person1 的原型属性] E-->F{找到name属性}-->|是|C F-->|否|G[返回undefined]-->END

首先,解析器会寻找实例 person1 的属性 name

找到了,它就返回 name 对应的属性值

没找到,它就会执行第二次搜索,搜索实例 person1 的内部属性 [[Prototype]] 指向的原型对象是否存在 name 属性

找到了,返回 name 对应的属性值

没找到,返回 undefined

如果实例 person1 存在 name 属性且它内部 [[Prototype]] 指向的原型对象同样存在 name 属性,它会先寻找实例本身的属性 name,找到了后就不会再次执行第二次对原型对象搜索了。即若实例本身存在我们给定的属性名,就算它指向的原型对象存在该属性名,也只会返回实例本身的属性值。代码如下:

function Person() {} Person.prototype.name = '小明'  var person1 = new Person() person1.name = '小红' console.log(person1.name)		// 小红 var perspn2 = new Person() console.log(person2.name)		// 小明 

​ 虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与实例原型中的一个属性同名,那我们就在实例中创建该属性,该属性将会屏蔽原型中的那个属性。

​ 不过,使用 delete 操作符则可以完全删除实例属性,从而让我们重新访问原型中的属性,如下所示:

function() {} Person.prototype.name = '小明'  var person1 = new Person() var person2 = new Person()  person1.name = '小红' console.log(person1.name)	// 小红	来自实例本身的 name 属性 console.log(person2.name)	// 小明	来自原型对象的 name 属性  delete person1.name			// 移除了实例 person1 的属性 name console.log(person1.name)	// 小明	来自实例本身的 name 属性 

​ 在这个修改的例子中,使用了 delete 操作符 移除了 person1.name,之前它保存的实例 name 属性 屏蔽了同名的原型属性。把它移除后,就恢复了对原型属性 name 的连接。因此,接下来再调用 person1.name 时,返回的就是原型中的 name 的值了。

​ 使用 hasOwnProperty() 方法可以检测一个属性是存在于实例中,还是存在于原型中。这个方法(从Object继承而来的)只在给定属性存在于对象实例中时,才会返回 true

function Person() {}  Person.prototype.name = '小明'  var person1 = new Person() var person2 = new Person()  person2.name = '小红'  console.log(person1.hasOwnProperty('name'))	// false	非实例本身的属性 console.log(person2.hasOwnProperty('name'))	// true		实例本身的属性  delete person2.name			// 移除实例 person2 的属性 name console.log(person2.hasOwnProperty('name'))	// false	非实例本身的属性 

​ 通过使用 hasOwnProperty() 方法,访问的是实例属性还是原型对象的属性就一清二楚了。

原型与 in 操作符

​ 有两种方式使用 in 操作符:单独使用和在 for-in 循环中使用。在单独使用时,in 操作符会在通过对象能够访问给定属性时返回 true,无论该属性存在于实例中还是原型中。

function Person() {}  Person.prototype.name = '小明'  var person1 = new Person() var person2 = new Person()  person2.name = '小红' console.log(person1.hasOwnProperty('name'))		// false	name 属性来自原型对象 console.log(person2.hasOwnProperty('name'))		// true		name 属性来自实例本身 console.log('name' in person1)		// true		person1 实例本身 或者 原型对象有 name 属性 console.log('name' in person2)		// true		person2 实例本身 或者 原型对象有 name 属性  delete person2.name		// 移除了实例 person2 的属性 name  console.log('name' in person2)		// true		person2 实例本事的属性被移除,但是原型对象仍旧有属性 name 

​ 在以上代码执行的整个过程中, name 属性要么在实例本身访问到的,要么在原型对象上访问到的。无论该属性存在于实例中还是原型对象中,使用 ’name‘ in person2 始终都会返回 true
​ 同使使用 hasOwnProperty() 方法和 in 操作符,就可以确定该属性到底时存在于对象中,还是存在于原型中:

function hasPrototypeOrProperty(object, propertyName){ 	// 判断该对象以及它的原型对象是否包含该属性 	if(propertyName in object){ 		// 若存在则判断它是否为实例本身属性 		if(object.hasOwnProperty(propertyName)){ 			// 若为实例属性则: 			console.log(propertyName + '是' + '实例本身的属性') 		// 否则: 		}else { 			console.log(propertyName + '是' + '原型对象的属性') 		} 	// 若该对象不存在该属性 	}else { 		console.log('该对象不存在属性' + propertyName) 	} }  function Person(){} Person.prototype.name = '小明'  var person1 = new Person() var person2 = new Person() person2.name = '小红'  hasPrototypeOrProperty(person1, 'name')	// name是原型对象的属性 hasPrototypeOrProperty(person2, 'name')	// name是实例本身的属性 hasPrototypeOrProperty(person1, 'abcd')	// 该对象不存在属性abcd 

​ 在使用 for-in 循环时,返回的是所有能够通过对象访问的、可枚举的属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。屏蔽了原型中不可枚举的属性(即将[[Enumerable]] 标记为 false 的属性)的实例属性也会在 for-in 循环中返回,因为根据规定,所有开发人员定义的属性都是可被枚举的——只有在 IE8 以及更早的版本中例外。

IE 早期版本的实现中存在一个 bug,即屏蔽不可枚举的属性的实例依旧不会在 for-in 循环中。例如:

var obj = { 	toString: function() {} }  for(var prop in obj){ 	if(prop === 'toString') { 		console.log('找到了toString')	// 在IE中不会显示 	} } 

​ 当以上代码运行时,应该在控制台中输出 ‘hello’ 的字符串,实例中的属性 toString 屏蔽了原型中(不可被枚举)的 toString 属性,在 IE 中,由于其实现认为原型的 toString 属性被打上了 [[Enumerable]] 的标记,因此跳过了该属性。该 bug 会影响默认不可被枚举的所有属性和方法,包括:hasOwnProperty(),hasOwnProperty(),propertyIsEnumerable(),toLocaleString(),toString(),valueOf()。ES55 也将 constructorprototype 属性的 [[Enumerble]] 特性设置为 false,但并不是所有浏览器都照此实现。

​ 要取得对象上所有可枚举的实例属性,可以使用 ES55Object.keys() 方法。这个方法接收一个对象作为参数,返回一个包含所有可枚举的字符串数组。例如:

function Person() {} Person.prototype.name = '小明' Person.prototype.age = 22 Person.prototype.sex = '男'  var person = new Person()  console.log(Object.keys(Person.prototype))		// ["name", "age", "sex"] // 并不是所有的浏览器都能访问到 __proto__ 属性 console.log(Object.keys(person.__proto__))		// ["name", "age", "sex"]  person.name = '小红' person.age = 22 console.log(Object.keys(person))		// ["name", "age"] 

​ 如果想要得到所有实例属性,无论它是否可枚举,都可以使用 Object.getOwnPropertyNames() 方法​,以下方法中返回的数组包含了不可被枚举的属性 constructor

var keys = Object.getOwnPropertyNames(Persone.prototype) console.log(keys)		//  ["constructor", "name", "age", "sex"] 

更简单的原型语法

​ 前面的例子每添加一个属性和方法就要敲一遍 Person.prototype。为了减少不必要的输入,也为了从视觉上更好地封装原型的功能,更常见的做法时用一个包含所有属性和方法的对象字面量来重写整个原型对象,如下面所示:

function Person(){} Person.prototype = { 	name: '小明', 	age: 22, 	sex: '男', 	sleep: function() { alert(this.name + '睡觉了') } } 

​ 在上面的代码中,将 Person.protoyepe 设置成一个新的对象。但是这种方式生成的原型对象中的属性 constructor 属性将不再指向 Person 了。每一次创建一个函数时,就会同使创建它的 prototype 对象,该对象也会自动获得 constructor 属性,这个属性指向了新创建的那个函数,在这里也就时指向了 Person。当我们用字面量的方式,其实就是完全覆盖掉了原来的 prototype 对象,因此 constructor 属性也就变成了新对象的 constructor 属性(指向了 Object 构造函数),不再指向 Person 函数。此时,尽管 instanceof 操作符还能返回正确的结果,但是通过 constructor 已经无法确定对象的类型了,如下所示:

var person = new Person() console.log(person instanceof Object)			// true console.log(person instanceof Person) 			// true console.log(person.constructor === Person) 	// false console.log(person.constructor === Object)			// true 

​ 在此,用 instanceof 操作符测试了 ObjectPerson 仍然返回 true,但是 constructor 属性则等于 Object 而不等于 Person 了。为了解决这种问题,可以在字面量里面定义它的属性 constructor 的指向:

function Person() {} Person.prototype = { 	constructor: Person, 	name: '小明', 	age: 22, 	sex: '男', 	sleep: function() { alert(this.name + '睡觉了') } } 

​ 以上代码特意包含了一个 constructor 属性,并将它的值设置为 Person,从而确保了通过该属性能够访问到正确的值。

​ 注意,以这种方式重设 constructor 属性会导致它的 [[Enumerable]] 特性被设置为 true。默认情况下,原生的 constructor 是不可被枚举的,因此如果兼容 ES5JavaScript 引擎的话,可以这么做:

function Person() {} Person.prototype = { 	name: '小明', 	age: 22, 	sex: '男', 	sleep: function() { alert(this.name + '睡觉了') } } Object.defineProperty(Person.prototype, 'constructor', { 	enumerable: false, 	value: Person }) 

原型的动态性

​ 由于在原型中查找值得过程是一次搜索,因此我们对原型对象所有的任何修改都能够立即从实力上反映出来——即使是先创建了实例后修改原型也照样如此:

var person = new Person()  Person.prototype.sleep = function() { alert('月亮不睡我不睡') }  person.sleep()	// 月亮不睡我不睡 

​ 以上代码先创建了 Person 的一个实例,并将其保存在 person 中。然后,下一条语句在 Person.prototype 中添加了一个方法 sleep()。 即使 **person **是在添加新方法之前创建的,但它仍然可以访问这个新定义的方法。其原因可以归结为实例与原型之间的松散连接关系。当我们调用 person.sleep() 时,首先会在实例中搜索名为 sleep 属性,在没有找到的情况下,会继续搜索原型。因为实例与原型之间的连bi接不过只是一个指针,而非一个副本,因此就可以在原型找到新的 sleep 属性并返回保存在那里的函数。

​ 尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重写整个原型对象,那么情况就不一样了。我们知道,调用构造函数时会为实例添加一个指向最初原型的 [[Prototype]] 指针,而把原型修改为另外一个对象就等于切断了构造函数与最初原型之间的联系。

function() {} var person = new Person() Person.prototype = { 	constructor: Person, 	name: '小明' } console.log(person.name)	// undefined 

​ 在这个例子中,先创建了 Person 的一个实例,然后又重写了其原型对象。然后再调用 person.name 时无法找到该属性,因为 person 指向的原型中不包含以该名字命名的属性。

重写原型对象之前:

原型模式

重写原型对象之后:

原型模式

​ 从上图可以看出,重写原型对象切断了现有原型与任何之前已经存在的对象实例之间的联系,它指向仍旧是最初的原型对象。

原生对象的原型

​ 原型模式的重要性不仅体现再创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。所有原生引用类型(Object、Array、Srting等等) 都在其构造函数的原型上定义了方法。例如,再 **Array.prototype **中可以找到 sort() 方法,而在 String.prototype 中可以找到 subtring() 方法,如下所示:

console.log(typeof Array.prototype.sort)			// function console.log(typeof String.prototype.substring)		// function 

​ 通过原生对象的原型,不仅可以取得所有默认方法的引用,而且也可以定义新方法。可以修改自定义对象的原型一样修改原生对象的原型,因此可以随时添加方法。下面的代码就给基本包装类型 String 添加了一个 startWidth() 的方法。

String.prototype.startWidth = function(text) { 	return this.indexOf(text) === 0 } var msg = 'Hello world!' console.log(msg.startWidth('Hello'))	// true 

​ 这里新定义的 startWidth() 方法会在传入的文本位于一个字符串开始时返回 true。既然方法被添加给了 String.prototype,那么当前环境中的所有字符串都可以调用它。由于 msg 是字符串,而且后台会调用 String 基本包装函数创建这个字符串,因此通过 **msg **就可以调用 startWIdth() 方法。

​ 尽管可以这样做,但并不推荐在产品化的程序中修改原生对象的原型。如果因某个实现中缺少某个方法,就在原生对象的原型中添加这个方法,那么当在另一个支持该方法的实现中运行代码时,就可能导致命名冲突。而且,这样做也可能意外地重写原生方法。

原型对象的问题

​ 原型对象也不是没有缺点。首先,它省略了构造函数船体初始化参数这一环节,结果所有实例在默认情况下都将取得相同的属性值。虽然这会在某种程度上带来一些不方便,但还不是原型的最大的问题。原型模式的最大问题时由其共享的本性所导致的。

​ 原型中所有属性是被很多实例共享的,这种共享对于函数非常合适。对于那些包含基本值得属性倒也说得过去,毕竟(如前面得例子所示),通过在实例上添加一个同名属性,可以隐藏原型中的对应属性。然而,对于包含引用类型值的属性来说,问题就比较突出了。

function Person(){} Person.prototype = { 	constructor: Person, 	name: '小明', 	age: 22, 	friends: ['小红', '小美'], 	sleep: function() { alert(this.name + '睡觉了') } }  var person1 = new Person() var person2 = new Person()  person1.friends.push('小敏')  console.log(person1.friends)	// ["小红", "小美", "小敏"] console.log(person2.friends)	// ["小红", "小美", "小敏"] console.log(person1.friends === person2.friends)	// true 

​ 在此,Person.prototype 对象有一个名为 friends 的属性,该属性包含了一个字符串数组。然后,创建了 Person 的两个实例。接着,修改了 person1.friendsp 引用的数组,想数组中添加了一个字符串。由于 friends 数组存在于 Person.prototype 而非 person1中,所以刚刚提到的修改也会通过 person2.friends (与 person1.friends 指向同一个数组)反映出来。加入我们的初衷就是像这样在所有实例中共享一个数组,那么没什么问题。可是,实例一般都是要有属于自己的全部属性的。而这个问题正是很少看到有人单独使用原型模式的原因所在。