《JavaScript高级程序设计》第六章,面向对象的程序设计。
面向对象的语言的标志为类的概念,ECMAScript无类的概念,因此它的对象和基于类的语言中的对象也有所不同。
ECMAScript-262中将对象定义为:无序属性的集合,其属性值可以包含基本值、对象或函数。对象的每个属性或方法都有一个名字,每个名字都映射到一个值,因此我们可以把ECMAScript的对象理解为散列表:一组key-value。
每个对象都是基于一个引用类型创建的,该引用类型既可以是原生的,也可以是自定义的。
理解对象
我们知道属性是有属性名和属性值的,但属性仅限于这些吗?其实属性在创建的时候都带有一些特征值(characteristic),JavaScript通过这些特征值来定义它们的行为。对此,我的理解是:特征值是属性的属性。
特征是为了实现JavaScript引擎用的,因此在JavaScript中不能直接访问它们,为了表现它们是内部值,ECMA-262规定将它们放在两对方括号中:[[]]
。
属性类型
ECMAScript中有两种属性:数据属性和访问器属性。
数据属性
数据属性包含一个数据值的位置,在该位置可以读取和写入值。数据属性有4个特性:
- [[Configurable]]
表示能否通过delete删除属性、能否修改属性的特性、能否把数据属性修改为访问器属性,默认值为true。 - [[Enumerable]]
表示能否用for-in来循环属性,默认值为true。 - [[Writable]]
表示能否修改属性的数据值,默认为true。 - [[Value]]
包含该属性的值,默认为undefined。读属性值时从这个位置读,写属性值时把新值写入这个位置。
创建对象时
1 | var person = { |
这里创建的对象有个name属性,对其赋值为”Nicholas”。反映在属性特征上就是[[Value]]被设置为”Nicholas”,其他三个为默认值。
如何修改特性?
使用ECMAScript5的Object.defineProperty()
方法。接收三个参数:属性所在的对象、属性名、描述符对象。其中描述符(descriptor)对象的属性必须是:configurable、enumerable、writable和value之中的任意一个或多个。
1 | var person = {}; |
这里修改了person对象的name属性的[[Writable]]和[[Value]]特性。
1 | var person = {}; |
一旦把[[Configurable]]设置为false后,就不能再修改除[[Writable]]之外的特性了。其他的则可以多次设置。
在调用Object.defineProperty()
方法时,如果不指定,configurable、enumerable、writable特性的默认值都是false。
访问器属性
访问器属性不包含数据值,访问器属性有4个特性:
- [[Configurable]]
… - [[Enumerable]]
… - [[Get]]
在读取属性时调用的函数,默认值为undefined。 - [[Set]]
在写入属性时调用的函数,默认值为undefined。
访问器属性不能直接定义,必须使用Object.defineProperty()
来定义。
1 | var book = { |
这里创建了book对象,定义了两个默认属性:_year、edition。带下划线的记法通常用于表示只能通过对象方法访问的属性。后又定义了一个访问器属性:year。year属性有getter函数和setter函数,它们各自被设置了相应的处理逻辑。在setter中我们可以看到,year属性的变化会导致_year属性和edition属性的改变,这是使用访问器属性的常用方式。
* getter和setter的要求
getter和setter并非要同时指定。只指定getter意味着属性只能get(读),不能set(写),反之亦然。非严格模式下尝试对只有getter的属性写会被忽略,严格模式下则会报错,反之亦然。
* 旧版本的创建访问器属性方法
在此之前,创建访问器属性一般使用非标准的两个方法:__defineGetter__()
、__defineSetter__()
,用法如下:
1 | book.__defineGetter__("year", function() { |
在不支持Object.defineProperty()
方法的浏览器中不能修改[[Configurable]]和[[Enumerable]]。
定义多个属性
使用方法Object.defineProperties()
可以一次定义多个属性,接收两个对象作为参数:第一个对象为要修改或添加属性的对象,第二个对象中的属性要和第一个对象要修改或添加的属性一一对应。
1 | var book = {}; |
读取属性的特性
使用ECMAScript5的Object.getOwnPropertyDescriptor()
方法可以获取给定属性的描述符,接收两个参数:属性所在的对象、想获取特性的属性名,返回一个包含特性的对象。对于数据属性和访问器属性,返回的描述符中包含的4个特性不一样,上面写过不赘述。
1 | var book = {}; |
在JavaScript中可以针对任何对象使用Object.getOwnPropertyDescriptor()
方法,包括DOM和BOM对象。
创建对象
到目前为止,我们都是用Object构造函数和字面量来创建对象的,但这些方式有明显的缺点:一个接口创建很多对象,会产生大量重复代码。因此我们要发掘创建对象的新方法。
工厂模式
工厂模式是一种常用的设计模式,这种模式抽象了具体对象的创建过程。考虑到ECMAScript中无法创建类,开发人员就发明了一种函数来封装创建对象的细节。
1 | function createPerson(name, age, job) { |
这种模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题,即如何知道一个对象的类型。因而我们引入了构造函数模式。
构造函数模式
前几章中,用构造函数可以创建特定类型的对象,如原生构造函数Object()
、Array()
等,我们也可以创建自定义的构造函数。
1 | function Person(name, age, job) { |
注意到不同之处:没有显式地创建对象、直接将属性和方法赋给this对象、没有return语句、函数名大写开头。
要创建一个实例必须使用new操作符,这种方式调用构造函数会经历4个步骤:
- 创建一个新对象
- 将构造函数的作用域赋给新对象(因此this指向新对象)
- 执行构造函数中的代码(为新对象添属性)
- 返回新对象
实例都有constructor属性,该属性指向构造函数,最初用来标注对象类型。但也可以用instanceof操作符检测对象类型。
1 | alert(person.constructor == Person); // true |
这种模式的进步之处:可将它的实例表示为一种特定的类型。但它仍然有缺点:每个方法都要在每个实例中重新创建一遍。上面例子中的person和person2都有sayName()方法,但俩方法不是同一个Function的实例。这种方式创建函数会导致不同的作用域链和标识符解析。对此,我们可以想到以下解决办法:
1 | function Person(name, age, job) { |
这样person和person2就共享了全局作用域的同一个sayName()
方法。但这个解决办法又引入了新的问题:自定义的对象的方法在全局作用域,没有封装性可言了。因而我们引入了原型模式。
* 将构造函数当作函数
任何函数,只要通过new操作符来调用,它就可以作为构造函数;任何函数,如果不通过new操作符来调用,它就和普通函数一样。
1 | // 当作构造函数 |
原型模式
第五章说过每个函数都有一个prototype属性,这个属性是一个指针,指向一个叫做原型对象的对象,这个对象包含了这个类型的所有实例共享的属性和方法。使用原型模式就可以把共享的属性和方法抽象出来,放在原型对象之中。
1 | function Person() {} |
以上例举例来说,关系如下图:
各个箭头的意思是:
- 创建一个新函数时,会为函数创建一个prototype属性,指向该函数的原型对象。
- 原型对象默认有一个constructor(构造函数)属性,指向prototype属性所在的函数。
- 调用构造函数创建实例时,实例会获得一个[[Prototype]]内部属性,指向构造函数的原型对象;这个属性无法被脚本访问,但浏览器在每个对象上都支持属性__proto__;也可以通过
isPrototypeOf()
方法来确定实例和原型对象的关系;还可以通过Object.getPrototypeOf()
方法来获取实例所指向的原型对象。
1 | Person.prototype.isPrototypeOf(person1); // true |
来自实例/原型的属性
- 读取属性
当代码要读取某个对象的属性时,都会执行一次关于属性名的搜索。搜索过程从对象实例中开始,若没找到,则会在指针指向的原型对象中搜索。 - 添加和删除属性
虽然可以通过实例访问原型中的值,但不能通过实例重写原型中的值。若在实例中添加和原型中同名的属性,则该属性会被创建于实例中,而不会影响原型中的属性。若想删除实例属性,可使用delete操作符。 - 判断属性来源
可以使用继承于Object的hasOwnProperty()
方法判断一个属性是存在于实例中还是存在于原型中,存在于实例中返回true,存在于原型中返回false。
1 | function Person() {} |
* Object.getOwnPropertyDescriptor()方法
只能用于实例属性,若想获得原型属性的描述符,须直接在原型对象上调用该方法。
在属性上使用in
使用in操作符(单独使用或for-in语句)可以判断对象能否访问某一属性,无论该属性位于实例中还是原型中。
1 | function Person() {} |
一些方法举例如下。tips:根据规定,所有开发人员定义的属性都是可枚举的。
1 | function Person() {} |
一一对应:
- 对二者有区分的方法
- hasOwnProperty()
- Object.keys()
- 对二者无区分的方法
- in
- for-in
简化语法
可以将上面的例子进行语法上的化简。
1 | function Person() {} |
但是这会导致constructor属性不再指向Person。原因在于函数在创建的时候会同时自动创建prototype对象,它也自动带有constructor属性;而这里的代码相当于完全重写了Person.prototype,现在的新对象的constructor不再指向Person()
,而是指向Object()
。这时尽管instanceof操作符还能返回正确的结果,但通过constructor已经无法确定对象的类型了。
1 | var friend = new Person(); |
解决办法:手动设置constructor。
1 | fucntion Person() {} |
但是这又引入了新的问题:手动设置的constructor属性会导致[[Enumerable]]被设置为true,而原生的constructor属性是不可枚举的。解决办法:再手动设置constructor的[[Enumerable]]特性。
1 | fucntion Person() {} |
原型的动态性
由于实例与原型之间的连接是指针而非副本,因此,对原型对象的任何修改都能立即从实例上反映出来——即使是先创建实例后修改原型也是如此。
1 | var friend = new Person(); |
但对于原型对象被完全重写的情况,就不是这样的效果了。
1 | function Person() {} |
在先创建的friend实例中,[[Prototype]]指针指向的是当时的Person.prototype,后面将原型对象整个重写了,也就是将Person()
的prototype属性指向了新的对象。因此现在添加了方法的是新的prototype,而friend访问的是旧的prototype。
原生对象的原型
原型模式不仅体现在创建自定义类型方面,就连所有原生的引用类型,也都是采用这种模式创建的。且所有原生引用类型都在其构造函数的原型上定义了方法,如Array.prototype的sort()
、String.prototype的subString()
等。
1 | alert(typeof Array.prototype.sort); // "function" |
和其他自定义对象一样,我们也可以通过原型来给原生对象定义新方法,不过不建议,因为这可能会造成意外重写原生方法或一些冲突等。
原型对象的问题
原型对象的问题在于共享。对于包含基本值的属性,共享是没有什么问题的,因为实例无法修改原型的该属性;而对于包含引用值的属性来说,就存在比较大的问题了。
1 | function Person() {} |
组合使用构造函数模式和原型模式
可以将二者结合,用构造函数模式定义实例属性,用原型模式定义方法和共享的属性。这种模式是目前在ECMAScript中使用最广泛、认同度最高的一种创建自定义类型的方法。
1 | // 独有的 |
动态原型模式
有OO语言经验的开发人员可能会对构造函数和原型分开感到困惑,因此引入了动态原型模式。它把所有信息都封装在了构造函数中,在必要的情况下在构造函数中初始化原型。
1 | function Person(name, age, job) { |
这里只在sayName()
方法不存在的情况下才将它添加到原型中,也就是说该段代码只有在初次调用构造函数时才会执行。这种模式下不能使用对象字面量重写原型,因为如果在已创建实例的情况下重写原型,会切断现有实例与新原型之间的联系。
寄生构造函数模式
在上面所有模式都不适用的情况下,可以使用寄生(parasitic)构造函数模式。它的思想在于:创建一个仅用于封装创建对象代码的函数,然后再返回新创建的对象;但从表面来看,它又很像典型的构造函数。
1 | function Person(name, age, job) { |
它和工厂方法的区别仅在于多了个new操作符。这种模式适用于一些特殊情况,如想创建一个具有额外方法的特殊数组:
1 | function SpecialArray() { |
使用寄生构造函数模式情况下,返回的对象与构造函数或构造函数的原型属性之间没有关系,即构造函数返回的对象与在构造函数外部创建的对象没有什么不同,因此不能依赖instanceof操作符来确定对象类型。这种模式不被建议使用。
稳妥构造函数模式
Douglas Crockford提出的稳妥对象(durable objects)指的是没有公共属性,而且其方法也不引用this的对象。它适用于一些禁用this和new的安全环境中,或者在防止数据被其他应用程序改动时使用。该模式与寄生构造函数模式类似,区别在于:不用this和new。
1 | function Person(name, age, job) { |
这种模式创建的对象中,除了使用sayName()
方法外,没有其他方法访问name的值。这样,即使有其他代码会给这个对象添加方法或数据成员,也不可能有别的方法访问传入到构造函数中的原始数据,即所谓安全性。这里创建的对象和构造函数之间也没什么关系,因此也不能依赖instanceof。
继承
原型链
原型链是ECMAScript中实现继承的主要方法,其思想在于利用原型让一个引用类型继承另一个引用类型的属性和方法,其做法为:让原型对象等于另一个类型的实例。
1 | function SuperType() { |
我所理解的构造步骤:
这部分的原型链图示:
更完整的原型链图示:
调用instance.getSuperValue()
时会经历三个搜索步骤:搜索实例;搜索SubType.prototype;搜索SuperType.prototype。在找不到属性或方法的情况下,搜索过程会一环一环地前行到原型链末端才会停下来。
要注意instance.constructor现在指向的是SuperType而不是SubType,因为:constructor指向的应是原型对象的constructor所指向的,而SubType的原型对象指向了SuperType的原型对象,SuperType的原型对象的constructor指向的是SuperType。
确定原型和实例的关系
可以使用instanceof和isPrototypeOf()
,前者只要原型链中出现过构造函数就返回true,后者只要原型链中出现过原型就返回true。
1 | alert(instance instanceof Object); // true |
修改/添加方法的注意事项
- 添加或重写的方法必须要放在替换原型的语句之后
1 | function SuperType() { |
SubType实例调用getSuperValue()
时,调用的是它重写的新方法;SuperType实例调用getSuperValue()
时,调用的是原来的它自己的方法。
- 不要使用字面量创建原型方法,因为这样会重写原型链,导致原型链断开
1 | ... |
原型链的问题
问题之一:超类中的引用类型属性,可以通过放在构造函数而非原型中的办法,来让不同的实例可以有自己的引用类型而非共享引用类型;但当属性通过原型链传递之后,由于子类的原型对象是超类的实例,因此子类的原型对象中自然有引用类型,而此时的引用类型会在子类的实例中共享,这是不好的。
1 | function SuperType() { |
问题之二:在创建子类的实例时,不能向超类的构造函数中传递参数,准确说是没法在不影响所有对象实例的情况下给超类的构造函数传递参数。因此实践中很少单独使用原型链。
借用构造函数
为了解决以上问题,引入了借用构造函数(constructor stealing)的技术,也叫伪造对象、经典继承。它的思想在于:在子类构造函数内部调用超类构造函数。这样一来,既可以把引用类型通过构造函数继承从而不共享,也可以向超类构造函数传递参数。
1 | // 不传递参数时 |
在上面的例子中,通过使用call()
或apply()
方法,我们在(未来将要)新创建的SubType实例的环境下调用了SuperType构造函数,这样就会在新的SubType实例上执行SuperType()
函数中定义的所有对象初始化代码,每个SubType实例也可以拥有自己的colors属性了。也可通过此解决参数传递的问题。如果想要防止超类属性重写子类的属性,可以在调用超类的构造函数之后再添加子类中定义的属性。
但这个方法还是存在问题:方法都在构造函数中定义,没法共享复用,也不能通过原型链传递给子类使用。因此这个技术也很少使用。
组合继承
组合继承(combination inheritance)又叫伪经典继承,是上面两种继承的结合版本。它的思想在于:使用原型链实现对原型属性和方法的继承,使用借用构造函数来实现对实例属性的继承。
1 | function SuperType(name) { |
这样一来,就可以让SubType实例既分别拥有自己的属性(包括引用类型的属性),又可以使用相同的方法了。而且instanceof和isPrototypeOf()
也能够用于识别基于组合继承创建的对象。因此这种继承模式是JavaScript中最常用的。
原型式继承
Douglas Crockford提出的一种继承方式,它的思路在于:借助原型,可以基于已有的对象来创建新对象,同时还不必因此创建自定义类型。
1 | function object(o) { |
这里先创建了一个临时的构造函数,再将传入的对象作为构造函数的原型,最后返回该临时类型的实例。本质上它是对传入的对象进行了一次浅复制。
1 | var person = { |
这里person被传递给object()
,因此它被作为原型,所以新对象的原型中就有基本类型值name和引用类型值friends。这意味着person.friends不仅属于person,还属于anotherperson和yetAnotherPerson,相当于创建了person对象的两个副本。
ECMAScript5用Object.create()
方法规范化了原型式继承,它接收一个或两个参数:第一个参数是作为新对象原型的对象,第二个参数是可选的为新对象定义额外属性的对象。
1 | var person = { |
在没有必要大举创建构造函数,而只想让一个对象与另一个对象保持相似的情况下,可以使用这种继承模式。
寄生式继承
同样是由Douglas Crockford提出的,与寄生构造函数和工厂模式的关联类似。它的思路在于:创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再返回对象。
1 | function createAnother(original) { |
这里的代码基于person返回了一个新对象anotherPerson,新对象不仅有person的所有属性和方法,还有自己的方法。示例中的object()
函数不是必须的,任何能返回新对象的函数都适用于此模式。它适用于主要考虑对象而不是自定义类型和构造函数的情况,但它不能做到函数复用,降低了效率,这点与构造函数模式类似。
寄生组合式继承
之前说组合式继承是JavaScript最常用的继承模式,但其实它也有缺点:会调用两次超类的构造函数,一次在创建子类原型时,一次在子类构造函数内部。这样子类会包含超类对象的所有实例属性,再在必要的时候进行修改,这造成了一定的冗余工作量。再把之前的代码贴过来看看:
1 | function SuperType(name) { |
第一次调用时,SubType.prototype会得到name和colors两个属性;第二次调用时,新创建的实例也会得到name和colors两个属性,也就是说有两组属性,之前能够解决问题的原因不过是实例的属性屏蔽了原型中的同名属性罢了。
因此我们要解决的问题是,怎样可以避免第一次的调用。我们的解决办法是,引入寄生组合式继承,它的思路是:不必为了指定子类的原型而调用超类的构造函数,我们需要的只是超类原型的一个副本。本质是使用寄生式继承来继承超类的原型,再将结果指定给子类的原型。
1 | function inheritPrototype(subType, superType) { |
函数内部:创建超类原型的副本;为副本添加constructor属性来弥补因重写原型而失去的默认constructor属性;将副本赋值给子类的原型。这样就可以将上例的第一次调用行SubType.prototype = new SuperType();
换成inheritPrototype(subType, superType);
,减少了对超类构造函数的调用。除此之外,原型链还能保持不变,因此也还能够正常地使用instanceof和isPrototypeOf()
。开发人员普遍认为这种模式是最理想的继承范式。
* 应用
YUI的YAHOO.lang.extend()
方法采用了寄生组合继承,这也是这种模式首次出现在一个应用非常广泛的JavaScript库中。
敲黑板
创建对象的演变
最初的方法是原始的Object的创建。
- 缺点:对象都是Object类型,没有区分。
引入工厂模式,就是普通的函数,只不过内部创建原始的Object再把它返回,相当于对前一种的简单包装。
- 优点:起码有点封装的意味了,想要表示不同的类型可以写多几个不同的名字的包装函数。
- 缺点:没法判断对象的类型。
引入构造函数模式,不再显式创建和返回原始的Object了,而是直接this指向新对象再赋值,调用时候用new,把它当作构造函数使用。
- 优点:可以使用实例的constructor和instanceof来判断对象的类型。
- 缺点:同类的实例根本是各自独立、完全没有共享内容的,而且如果为了共享而单独把函数拎出来也破坏了封装性,解决不了问题。
引入原型模式,将构造函数内设置为空,把所有内容都放在原型对象里共享。
- 优点:可以有地方存放共享的内容了,而每个实例也可以在原型的基础上自己覆盖和屏蔽它。
- 缺点:引用类型的共享,一个实例去改它不是像数值类型的覆盖屏蔽它,而是会把共享的内容改掉,这会影响其他实例。
引入构造函数模式和原型模式结合的模式,在原型里写共享的,在构造函数里写实例的。
- 优点:集二者之长,是最常用的模式了。
- 缺点:构造函数和原型分开可能会让oo人感觉困惑。
引入动态原型模式,把原型写进构造函数里了,用条件判断来对原型进行一次初始化。
提出了寄生构造函数模式,就是工厂模式加个new。适用于想在现有类型的基础上做一点添加成为新类型的情况。
提出了稳妥构造函数模式,就是没有this的工厂模式。适用于强调安全性的环境。
继承的演变
最初是原型链继承,把超类的一个实例作为子类的原型。
- 优点:方便,通过原型链来完成继承。
- 缺点:子类原型会因为继承的原因拥有引用类型,带来共享引用的问题;而且创建子类实例时不能向超类传递参数。
引入借用构造函数,在子类的构造函数中调用超类构造函数。
- 优点:解决了引用类型和无法传递参数的问题。
- 缺点:方法都在构造函数里,没有共享和复用了。
引入组合继承,用原型链实现原型属性的继承,用借用构造函数实现实例属性的继承。
- 优点:集二者之长,是最常用的继承模式了。
- 缺点:每次都会调用两次超类构造方法,原型和实例都继承所有属性,原型里的是冗余的。
引入寄生组合式继承,不再创建一个超类实例作为子类原型,而是用超类原型的副本作为子类原型。是组合继承和寄生式继承的结合。
- 优点:解决了组合继承里的冗余问题,是最理想的继承模式。
提出了原型式继承,就是借助原型来基于已有对象创建新对象,不再显式写一个新的类型,而是使用一个临时类型,相当于浅复制。适用于不想新写一个构造函数,只想要一个相似的类型的对象的情况。
提出了寄生式继承,工厂模式的继承版,把工厂模式的传入属性变成了传入对象,创建Object变成了创建新类型对象。适用于不想新写一个构造函数,只想要一个稍微新增一点改动的类型的对象的情况。