面的文章我们已经介绍了构造函数模式和原型模式,接下来我们将介绍剩下的其他几种模式。
创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混合模式还支持像构造函数传递参数。例:
构造函数模式与原型模式组合
在上面的例子的中,实例属性都是在构造函数中定义的,而由所有实例共享属性 constructor 和方法 sayName() 则是在原型中定义的。而修改了 p1.friends 属性,并不会影响到 p2.friends 属性,因为它们分别引用了不同的数组。这种构造函数与原型混合模式,是目前ECMAScript 中使用最广泛,认同度最高的一种创建自定义类型的方法。
动态原型模式是把所有信息都封装在构造函数中,而通过在构造函数中初始化原型(仅在必要的情况下),它保持了同时使用构造函数和原型的优点。也就是说,可以通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型。例:
动态原型模式
上面的代码示例中对原型所做的修改,能够立即在所有实例中得到反映。不过需要注意的是:使用动态原型模式,不能使用对象字面量重写原型(具体原因,请看上一篇文章)。
通常,在前面几种模式都不适用的情况下,可以使用寄生构造函数模式。这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后再返回新创建的对象,但从表面上看,这个函数又很像是典型的构造函数。例:
寄生构造函数模式
上面的代码演示了寄生构造函数模式,除了使用 new 操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式是一摸一样的。这种模式可以在特殊的情况下用来为对象创建构造函数。假如我们想创建一个具有额外方法的特殊数组,由于不能直接修改 Array 构造函数,因此我们可以使用这种模式。例:
寄生构造函数模式用例
关于寄生构造函数模式需要说明的是,返回的对象与构造函数或者构造函数的原型属性之间没有关系,也就是说,构造函数返回的对象与在构造函数外部创建的对象没有什么不同。因此,不能通过 instanceof 操作符来确定对象类型。由于存在上述问题,在能使用其他模式的情况下,尽量不用使用这种模式。
介绍这个模式之前,我们先来简单介绍一下什么是稳妥对象。所谓稳妥对象,指的是没有公共属性,而且其方法也不能引用this的对象。稳妥对象最适合在一些安全的环境中,或者在防止数据被其他应用程序修改时使用。稳妥构造函数模式与寄生构造函数模式类似,但是又有着不同:首先,新创建对象的实例方法不引用this,其次,不使用 new 操作符调用构造函数。例:
稳妥构造函数模式
上面的代码演示了稳妥构造函数模式,变量 p 中保存的是一个稳妥对象,除了调用 sayName() 方法外,没有别的方式可以访问其数据成功。即使有其他的代码会给这个对象添加方法或数据成员,但也不可能有别的办法访问传入到构造函数中的原始数据。稳妥构造函数模式提供的这种安全性,使得它非常适合在某些安全执行环境。稳妥构造函数模式存在与寄生构造函数模式一样的问题。
们创建的每个函数都有一个 prototype 属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换句话说,不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中,例:
prototype 属性示例
在上面的代码中,我们将 sayName() 方法和所有属性直接添加到了 Person 的 prototype 属性中,构造函数变成了空函数。即使如此,也仍然可以通过调用构造函数来创建新对象,而且新对象还具有相同的属性和方法。但与构造函数模式不同的是,新对象的这些属性和方法是所有实例共享的。也就是说,p1 和 p2 访问的都是同一组属性和同一个 sayName() 函数。
无论什么时候,只要创建了一个函数,就会根据一组特定的规则为该函数创建一个 prototype 属性,这个属性指向函数的原型对象。在默认情况下,所有的原型对象都会自动获得一个 constructor 属性,这个属性是一个指向 prototype属性所在函数的指针。在前面的例子中,Person.prototype.constructor 指向 Person 。而通过这个构造函数,我们还可继续为原型对象添加其他属性和方法。
创建了自定义的构造函数之后,其原型对象默认只会取得 constructor 属性;至于其他方法,都是从 Object 继承而来。当调用构造函数创建一个新实例后,该实例的内部包含一个指针,指向构造函数的原型对象。ECMA 第五版中管这个指针叫 [[Prototype]] ,这个连接存在与实例与构造函数的原型对象之间,而不是存在与实例与构造函数之间。
Person 对象和 Person.prototype 创建实例对象关系
上图展示了 Person 构造函数、Person 的原型属性以及 Person 现有的两个实例之间的关系。Person.prototype 指向了原型对象,而 Person.prototype.constructor 又指会了 Person。原型对象中除了 constructor 属性外,还包括后来添加的其他属性。Person 的每个实例 p1 和 p2 都包含了一个内部属性,该属性仅仅指向了原型对象,也就是说,它们和构造函数没有直接的关系。此外,要格外注意的是,虽然这两个实例都不包含属性和方法,但我们却可以调用 p1.sayName(),这是通过查找对象属性的过程来实现的。
虽然所有实现中都无法访问到 [[Prototype]] ,但是可以通过 isPrototypeOf() 方法来确定对象之间是否存在这种关系。从本质上讲,如果 [[Prototype]] 指向 isPrototypeOf() 方法的对象,那么这个方法返回 true,如下:
isPrototypeOf() 方法示例
另外,ECMAScript 5 中增加了一个新方法 Object.getPrototypeOf() ,这个方法返回 [[Prototype]] 的值,例:
Object.getPrototypeOf() 方法
每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例本身开始。如果在实例中找到了具有给定名字的属性,则返回该属性的值,如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性。如果在原型对象中找到了这个属性,就返回该属性的值。也就是说,在我们调用 p1.sayName() 的时候,会先后执行两次搜索,首先会搜索实例 p1 是否有 sayName 属性,没有,继续搜索 p1 的原型是否有 sayName 属性,有,它就读取那个保存在原型对象中的函数。
虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例去重写原型中的值。如果我们在实例中添加一个与原型中同名的属性,那么该属性将会屏蔽原型中的那个属性。使用 delete 操作符可以删除实例属性,从而恢复对原型中属性的访问,例:
实例对象属性和原型属性操作访问
使用 hasOwnProperty() 方法可以检测一个属性是存在于实例中,还是存在于原型中。
hasOwnProperty() 方法
有两种方式使用 in 操作符:单独使用和在 for-in 循环中使用。单独使用时,in 操作符会在通过对象能够访问给定属性时返回 true,无论该属性存在于实例还是原型中。在使用 for-in 循环时,返回的是所有能够通过对象访问的,可枚举的属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。屏蔽了原型中不可枚举属性(即将[[Enumerable]] 标记为false的属性)的实例属性也会在 for-in 循环中返回。
要取得对象上所有可枚举的实例属性,可以使用 ECMAScript 5 中的 Object.keys() 方法,该方法接收一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。
前面的例子中,每添加一个属性和方法都要写一遍 Person.prototype 。为了减少不必要的输入,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象,如下:
简化后的原型语法
在上面的代码中,将Person.prototype 设置为一个对象字面量形式创建的新对象。最终的结果相同,但有一个例外,constructor 属性不再指向 Person 了。前面介绍过,每创建一个函数,就会同时创建它的 prototype 对象,这个对象也会自动获得 constructor 属性。我们在这里使用的语法,本质上完全重写了默认 prototype 对象,因此 constructor 属性就变成了新对象的 constructor 属性(指向 Object 构造函数),不再指向 Person 函数。
constructor 属性被改变了
如果 constructor 属性真的很重要的话,可以将它的值回写:
回写 constructor 属性
注意:上面的代码会导致 constructor 的 [[Enumerable]] 特性被设置为 true。默认情况下,原生的 constructor 属性是不可枚举的,因此如果你使用兼容 ECMAScript 5 的 JavaScript 引擎,可以使用 Object.defineProperty() 来修改此特性。
重设构造函数
我们已经知道了在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都会立即反映到实例对象上。
修改原型对象后实例对象的表现
尽管可以随时为原型添加属性和方法,并且修改能立即在所有对象实例中反映出来,但是如果重写了整个原型对象,结果就不一样了。调用构造函数时会为实例添加一个指向最初始原型的指针,而把原型修改为另一个对象就等于切断了构造函数与最初始原型之间的的联系。
修改整个原型对象
原型模式的最大问题是由其共享的本性所导致的。原型中所有属性是被很多实例共享,这种共享对于函数非常合适,对于那些包含基本值的属性也还行,通过在实例上添加同名属性,可以屏蔽原型中对应属性,然而,对于包含引用类型值的属性来说,就会出问题:
原型对象问题
CMAScript 是 JavaScript 的核心,但是如果要在 Web 中使用 JavaScript,那么 BOM(browser object model 浏览器对象模型) 才是真正的核心。BOM 提供了很多对象,用于访问浏览器的功能,这些功能与任何网页内容无关。
BOM 的核心对象是 window,它表示一个浏览器的实例。在浏览器中,window 对象有双重角色,它既是通过 JavaScript 访问浏览器窗口的一个接口,又是 ECMAScript 规定的 Global 对象。这意味着在网页中定义的任何一个对象、变量和函数,都以 window 作为其 Global 对象。
由于 window 对象同时扮演着 ECMAScript 中 Global 对象的角色,因此所有在全局作用域中声明的变量、函数都会变成 window 对象的属性和方法。例:
在全局作用域上定义的属性和方法都会变成window对象的属性和方法
上面的例子中,在全局作用域中定义了一个变量 age 和 一个函数 sayAge() ,它们会被自动归在了 window 对象上。于是,使用 window.age 和 window.sayAge() 可以访问这个变量和这个函数。
撇开全局变量会成为 window 对象的属性不谈,定义全局变量与在 window 对象上直接定义属性还是有一点区别的:全局变量不能通过 delete 操作符删除,而直接在 window 对象上定义的属性则可以 。 例:
定义全局变量和定义 window 对象上属性的区别
使用 var 语句添加的 window 属性有一个名为 [[Configurable]] 的特性,这个特性的值被设置成 false,因此这样定义的属性是不能通过 delete 操作符删除的。IE9 之前的版本在使用 delete 删除 window 属性语句时,不管该属性最初是如何创建的,都会抛出错误。另外,尝试访问未声明的变量会抛出错误,但是通过查询 window 对象,可以知道某个可能未声明的变量是否存在。例:
*请认真填写需求信息,我们会在24小时内与您取得联系。