本文共计18100字,且有些不易理解,建议先收藏。转发请注明出处,谢谢!
1. 理解对象
面向对象(Object-Oriented,OO)语言有类的概念,通过类可以创建任意多个具有相同属性和方法的对象。
ECMA-262把对象定义为:“无序属性的集合,其属性可以包括基本值、对象或者函数。”即对象是一组没有特定顺序的值。
1.1 属性类型
ECMA-262第5版在定义只有内部才用的特性(attribute)时,描述了属性(property)的各种特征。为了区分,该规范把表示特性的内部值放在两对方括号中。例如[[Enumerable]]。
ECMAScript中有两种属性:数据属性和访问器属性
1.1.1 数据属性
数据属性包括一个数据值的位置。在这个位置可以读取和写入值,数据属性有如下四个特征:
- [[Configurable]]:表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。
- [[Enumerable]]:表示能否通过for-in循环返回属性(即能否枚举)。
- [[Writable]]:表示能否修改数据的值。
- [[Value]]:包含这个属性的值。读取属性值的时候,从这个位置读取,写入属性值的时候,把心的值保存在这个位置。这个特性的默认值为undefined。
直接在对象上定义的属性,他们的[[Configurable]]、[[Enumerable]]、[[Writable]]特性都被设置为true。而[[Value]]特性被设置为指定的值。例如:
|
|
这里创建了一个name属性,他的指定值为”linlif”,即[[Value]]特性的值被设置为“linlif”,而且对这个值的修改都会反映在[[Value]]特性上。
要修改属性默认的特性,必须使用ECMAScript 5 的Object.defineProperty()
方法。这个方法接受三个参数:属性所在对象,属性的名字和一个描述符对象。其中,描述符(descriptor)对象属性值必须是:configurable
、enmuerable
、writable
和value
。例如:
|
|
可以多次调用Object.defineProperty()
方法修改同一个属性,但在把configurable
设置为false
之后就有了限制。具体限制就是:不能再修改configurable
,对应的属性也不能删除。只能修改writable
属性。
1.1.2 访问其属性
访问器属性不包含数据值。它们只有一对getter和setter函数。在读取访问器属性时,会调用getter函数;在写入访问器属性时会调用setter函数。
访问器属性有如下4属性:
- [[Configurable]]:表示能否通过delete删除属性从而重新定义属性,或者能否把属性修改为数据属性。直接在对象上定义的属性,这个特性默认值为true。
- [[Enumerable]]:表示能否通过for-in循环返回属性。对于直接在对象上定义的特性默认值为true。
- [[Get]]:在读取属性时调用的函数。默认值为undefined。
- [[Set]]:在写入属性时调用的函数。默认值为undefined。、
访问器属性必须通过Object.defineProperty()
来定义。例如:
|
|
上述例子中,创建了一个person对象,并定义了两个属性:_name 和 age。 _name 前面的下划线是一种常用的记号,表示只能通过对象方法访问其属性。而访问器属性”name“则定义了一个getter函数和一个setter函数。getter函数返回_name的值;setter函数修改_name的值。把name修改为”linlif“会导致_name也变成”linlif“。这就是使用访问器属性的常见方式,即设置一个属性的值会导致其他属性发生变化(数据的双向绑定??)。
实现setter和getter函数的浏览器有:IE9+(IE8只是部分实现)、Firefox 4+、Safari 5+、Opera 12+和Chrome。
1.1.3 定义多个属性
由于对象大多有很多属性,所以ECMAScript 5又定义了一个Object.defineProperties()
方法。这个方法可以一次性定义多个属性。这个方法接受两个对象参数:第一个是对象要添加和修改其属性的对象,第二个对象的属性与第一个对象属性中要添加或修改的属性一一对应。有点绕,我们直接看例子:
|
|
支持Object.defineProperties()
方法的浏览器有:IE 9+、Firefox 4+、Safari 5+、Opera 12+和Chrome。
1.1.4 读取属性的特性
使用ECMAScript 5的Object.getOwnPropertyDescriptor()
方法可以取得给定属性的描述符。这个方法接受两个参数:属性所在的对象和要读取其描述符的民粹。返回值是一个对象,如果是访问器属性,这个对象的属性有configurable、enumerable、get、set;如果是数据属性,则这个对象的属性有:configurable、enumerable、writable和value。例如:
|
|
2. 创建对象
创建对象的方式有:Object构造函数和字面量两种方式。但是这些方式都明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。为了解决这个问题,产生了很多创建对象的新方式。
2.1 工厂模式
工厂模式是软件工程中一种广为人知的设计模式,这种模式抽象了创建具体对象的过程。由于ECMAScript无法创建类(ES6以前),所以开发人员就发明了一种函数,用函数来封装以特定接口创建对象的的细节。例如:
|
|
工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即如何知道一个对象的类型)。随着JavaScript的发展,有一个新的模式出现了。
2.2 构造函数模式
我们都知道,ECMAScript存在一些原生的构造函数,例如Object和Array。使用这些构造函数可以创建特定类型的对象。此外,我们可以创建自定义的构造函数。例如,我们将上面的例子改写为构造函数模式:
|
|
在这个例子中,Person()
函数取代了createPerson()
函数。Person()
函数的不同之处有:
- 没有显式地创建对象;
- 直接将属性和方法赋值给this对象;
- 没有return语句。
- 首字母大写。
- 使用了new操作符
使用new操作符后跟构造函数这种方式创建对象时,实际上会经历以下4个步骤:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(因此this就指向了这个新对象);
- 执行构造函数中的代码(为这个新对象添加属性)
- 返回新对象。
使用构造函数模式,会将实例标识为一种特定的类型。而这正是构造函数胜过工厂模式的地方。例如:
|
|
构造函数的问题:每个方法都要在每个实例上重新创建一遍。我们来看下面的例子:
|
|
所以我们可以使用原型模式来解决这些问题👇。
2.3 原型模式
原型函数模式,将所有方法和属性放到Person的prototype属性中,构造函数变成了空函数。使用原型模式改造Person如下:
|
|
这时候person1和person2访问的是同一组属性和同一个sayName()函数。
2.3.1 理解原型对象
无论何时,只要创建一个新的函数,就会根据一组特定的规则为该函数创建一个prototype属性。这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获取一个constructor属性,这个属性包含一个指向构造函数的指针。
我们来捋一捋,从定义构造函数到创建完实例这个过程,看看到底发生了什么。
1、创建自定义构造函数,构造函数的prototype属性就指向其原型对象;
2、原型对象默认取得constructor属性,这个属性包含指向构造函数的指针;
3、原型对象默认只有constructor属性,其他属性要么是用户自定义的,要么是从Object继承来的。
4、调用构造函数创建实例后,每个实例将包含一个指针,指向构造函数的原型对象。ECMAScript-262第5版中,管这个指针叫[[Prototype]]。
5、在脚本中没有标准的访问方式,可以访问这个[[Prototype]],但在Firefox、Safari和Chrome在每个对象中支持一个属性_proto_
,在其他浏览器中,这个属性是完全不可见的。
6、需要注意的是,这个[[Prototype]]属性连接的是实例与原型对象之间,而不是实例与构造函数之间。
下图展示了使用Person构造函数和Person.prototype创建实例后,各个对象之间的关系。
2.3.2 isPrototypeOf()方法
虽然所有实现都无法访问到[[Prototype]],但是可以通过调用isPrototype()方法来确定对象之间是否存在这种关系。如果[[Prototype]]指向调用isPrototype()方法的对象,那么这个方法就返回true。例如:
|
|
上面例子中,都返回了true,说明person1和person2都有一个指向Person.prototype的指针。
2.3.3 Object.getPrototypeOf()方法
这是ECMAScript新增的一个方法。这个方法返回[[Prototype]]的值。例如:
|
|
使用Object.getPrototypeOf()方法可以很方便地取得一个实例的原型,而这在利用原型实现继承的情况下是非常重要的。支持这个方法的浏览器有IE 9+、Firefox 3.5+、Safari 5+、Opera 12+和Chrome。
每当代码读取某个对象的某个属性时,都会执行一次搜索。搜索首先从实例本身开始,如果实例中找到了则返回改属性的值,如果没找到,则继续向上搜索指针指向的原型对象,依此类推,顺着原型链一直找,如果最后都没有找到,则返回null。
实例可以访问保存在原型中的值,但不能通过实例重写原型中的值。例如:
|
|
2.3.4 hasOwnProperty()方法
使用这个方法,可以检测一个属性是存在于实例中,还是存在于原型中。这个方法(继承自Object)只在给定属性存在于对象实例中时,才会返回true。例如:
|
|
2.3.5 原型与in操作符
有两种方式使用in操作符:单独使用和在for-in循环中使用。单独使用时,in操作符会在通过对象能够访问给定属性时返回true,无论该属性存在于实例还是原型中。
|
|
在使用for-in 循环时,返回的是所有能够通过对象访问的,可枚举(enumerated)属性。屏蔽了原型中不可枚举属性([[enumerable]]标记为false的属性)的实例属性也会在for-in 循环中返回,因为根据规定,所有开发人员自定义的属性都是可枚举的——只有在IE8及更早版本中例外。
2.3.6 Object.keys()方法
要取得对象上所有可枚举的实例属性,可以使用ES6的Object.keys()方法。这个方法接受一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。例如:
|
|
2.3.7 Object.getOwnPropertyNames()方法
如果想要得到所有实例属性,无论它是否可枚举,可以使用Object.getOwnPropertyNames()方法,例如:
|
|
支持这个方法的浏览器有IE9+、Firefox 4+、Safari 5+、Opera12+ 和 Chrome。
2.3.8 更简单的原型语法
前面的例子中,每添加一个属性和方法就要敲一遍Person.prototype。为了减少不必要的输入,也为了视觉上更好的封装原型的功能,可以使用对象字面量的写法。例如:
|
|
这种写法,本质上已经完全重写了prototype对象,因此 constructor属性变成了新对象的constructor属性(指向Object构造函数),不再指向Person构造函数。例如:
|
|
如果constructor特别是必须的,可以像下面这指定回适当的值。
|
|
注意:这种手动绑定构造函数,会使constructor属性的[[enumerable]]特性被设置为true,默认情况下,原生的constructor是不可枚举的。
如果不考虑低版本浏览器,可以使用Object.defineProperty()手动将[[enumerable]]特性设置为false。例如:
|
|
2.3.9 原型的动态性
由于原型中查找值的过程是一次搜索,因此我们对原型对象上所做的任何修改都能够立即从实例上反映出来。例如:
|
|
尽管可以随时往原型中添加属性和方法,并且修改能够立即在实例中反映出来。但是如果是重写了整个原型对象,那就不一样了。我们都知道,实例上的[[Prototype]]指针,仅指向原型,而不是指向构造函数。请看下面的例子:
|
|
原型模式的缺点:
- 所有实例在默认情况下都将取得相同的属性值。
- 原型中所有属性被很多实例共享,这对于那些包含基本值的属性倒还好,对于引用类型值的属性来说,问题就比较突出了。
|
|
2.4 组合使用构造函数模式和原型模式
这种组合模式是目前应用最广泛,认可度最高的一种创建自定义类型的方法。这种方法将构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。这种模式还支持传入参数,灵活度较高。
|
|
2.5 动态原型模式
有其他OO语言经验的开发人员在看独立的构造函数和原型时,很可能会感到非常困惑。动态原型模式正是致力于解决这个问题的一个方案。
|
|
注意上面的例子,方法定义那里,只要在sayName()方法不存在的情况下,才会将它添加到原型链中。这段代码只会在初次调用构造函数时才会执行。此后,原型已经完成初始化,不需要再做修改了。不过要注意,这里对原型的更改,会立即反映到所有实例中。因此这种方法可以说非常完美了!
2.6 寄生构造函数模式
这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后再返回新创建的对象。但是从表面上看,这个函数又很像是典型的构造函数。例如:
|
|
除了使用new操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式其实是一模一样的。
这个模式可以用来为对象创建构造函数,可以扩展基础的对象(例如:Array)的功能。请看下面的例子:
|
|
寄生模式的问题:使用这种模式创建的对象与构造函数或者构造函数的原型属性之间没有任何关系。也就是说,构造函数返回的对象与构造函数外部自己创建的对象没有什么不同。因此,不能指望通过instanceof操作符来确定对象类型。
由于存在上面的问题,如果可以使用其他模式,建议不要使用这种模式。
2.7 稳妥构造函数模式
道格拉斯·克罗克德(Douglas Crockford)发明了JavaScript中的稳妥对象(durable object)这个概念。所谓稳妥对象,指的是没有公共属性,而且其方法也不引用this的对象。稳妥对象最适合在一些安全环境中(禁止使用this和new),或者防止数据被其他应用程序改动时使用。
稳妥构造函数与寄生模式类似,但有两点不同:一是创建对象的实例方法不引用this;二是不使用new操作符调用构造函数。例如:
|
|
上面例子中,变量person中保存的是一个稳妥变量,除了调用sayName()方法外,没有别的方式可以访问其数据成员。即使有其他黛眉山会给这个对象添加方法或数据成员,但也不可能有别的办法访问传入到构造函数中的原始数据。
3. 继承
3.1 原型链
ECMAScript中描述了原型链的概念,并将原型链作为实现继承的主要方法。
原型链继承的基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。我们都知道,每个构造函数都有一个原型对象,每个原型对象都包含一个指向构造函数的指针,而每个实例又包含一个指向原型对象的内部指针。
原型链的实现过程:让子类的原型对象指向父类的实例。此时,子类包含一个指向父类原型的指针。如果子类还有子类,则继续执行上述过程。这样下来就构成了实例与原型的链条。这就是原型链的基本概念。
一起看看下面的例子:
|
|
上面代码中SupperType、SubType和instance之间个关系如下图所示。
需要注意的是:instance的constructor指向的是superType,这是因为原来的SubType.prototype中的constructor被重写的缘故(其实不是重写,而是SubType的原型指向了SuperType的原型,而这个原型对象的constructor属性指向的数SuperType)。
其实,上面的原型链少了一环。因为所有引用类型都默认继承了Object,而这个继承也是通过原型链实现的。所以,完整的原型链应该如下图所示:
3.1.1 确定原型与实例的关系
有两种方式:第一种是使用instanceof操作符。只要用这个操作符测试实例与原型链中出现过的构造函数,结果都是true。例如:
|
|
由于原型链的关系,我们可以说instance是Object、SupperType或SubType中的任何一个类型的实例。
第二种方式是使用isPrototypeOf()
方法。同样的,只要原型链中出现过的原型,都可以说是该原型链上的实例的原型。因此这个方法也会返回true。如下所示:
|
|
3.1.2 定义方法需谨慎
子类如果想要重写父类中的某个方法,或添加不存在的方法,都需要在替换了原型之后。来看下面的例子:
|
|
原型链存在的问题:
- 1、原型链上的引用类型会被所有实例共享,也就是说任何一个实例修改了原型链上的引用类型,其他实例的上也会立即反映出来。
- 创建子类实例时,不能向超类构造函数中传递参数。实际上,应该没有办法在不影响所有实例的情况下,给超类的构造函数传递参数。
所以,现实中很少单独使用原型链。
3.2 借助构造函数
为了解决原型中包含引用类型值所带来的问题,开发人员开始使用一种叫做:借助构造函数的技术(也叫伪造对象或经典继承)。
这种技术的思想很简单,就是在子类型构造函数的内部调用超类型的构造函数。例如:
|
|
这种方式,借调超类的构造函数,实际上是在未来创建SubType实例的环境下调用了SuperType的构造函数。这样一来,每个实例都拥有自己的colors属性副本,从而解决了引用类型共享的问题。
当然,上面的例子也可以传递参数,改造一下:
|
|
借助构造函数的问题:方法都在构造函数中定义,所以函数复用不了。而且,在超类的原型中定义的方法,对于子类来说是不可见的,结果所有类型都只能使用构造函数模式。所以,借助构造函数的技术也是很少单独使用的。
3.3 组合继承
组合继承有时候也叫伪经典继承,指的是将原型链和借用构造函数的技术组合到一块,从而发挥二者之长的一种继承模式。
这种继承的思路是:使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。这样,既实现了函数的复用,又能保证每个实例都有它自己的属性。例如:
|
|
这个例子中,instanc1和instance2不仅拥有自己单独的引用类型属性colors,还可以使用相同的方法。
组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为JavaScript中最常用的继承模式。而且,可以使用instanceof和isPrototypeOf()识别基于组合继承创建的对象。
3.4 原型式继承
道格拉斯·克罗克福德在2006年写了一篇文章,题目为Prototypal Inheritance in JavaScript(JavaScript中原型式继承)。
文章中介绍了一种实现继承的方法,这种方法并没有严格意义上的构造函数。他的想法是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。为了达到这个目的,他给出了以下函数。
|
|
本质上讲,object()对传入的对象执行了一次浅复制。来看看下面的例子:
|
|
ECMAScript 5通过新增的Object.create()方法规范化了原型式继承。这个方法接受两个参数:一个用作新对象原型的对象和一个为新对象定义额外属性的对象(可选)。在传入一个参数的情况下,作用与object()方法的行为相同。
第二个参数的用法,与Object.defineProperties()方法的第二个参数格式相同:每个属性都是通过自己的描述符定义的。例如:
|
|
支持Object.create()方法的浏览器有IE 9+、Firefox 4+、Safari 5+、Opera 12+和Chrome。
如果只是想让一个对象与另一个对象保持类似,原型式继承完全可以胜任。不过要注意:引用类型的属性始终会共享相应的值,就像使用原型模式一样。
3.5 寄生式继承
寄生式继承与原型继承思路差不多,也是有克罗克福德推广的。下面代码示范了这种继承模式:
|
|
寄生模式适用于,主要考虑对象而不是自定义类型和构造函数的情况。例子中使用的object()函数不是必须的,任何可以返回新对象的函数都适用于此模式。
3.6 寄生组合式继承
前面说过,组合继承是JavaScript最常用的继承模式。但是它也有不足,组合继承最大的问题就是,无论什么情况下,都会调用两次超类型构造函数:一次是创建子类型原型的时候,另一次是在子类型构造函数内部。一起看看下面的例子:
|
|
由于调用了两次SuperType构造函数,所以存在两组name和colors属性:一组在实例上,一组在SubType原型上。第一次调用,在原型上,第二次调用在实例上。只不过第二次调用会屏蔽原型中两个同名的属性。具体看下图:
寄生组合式继承就是为了解决这个问题的。寄生组合式继承通过借用构造函数来继承属性,通过原型链的混用形式来继承方法。其背后的思路是:不必为了指定子类型的原型而调用超类的构造方法,我们无非要的就是超类型原型的一个副本而已。所以,我们只需要使用寄生式继承来继承超类型的原型,然后将结果指定给指定子类型的原型。例如:
|
|
上述例子实现了最简单的寄生组合式继承。这个函数接受两个参数:子类型构造函数和超类型构造函数。在函数内部,第一步是创建超类型原型的一个副本。第二步是为创建的副本添加constructor属性,从而弥补因为重写原型而失去的默认constructor属性。最后一步,将新创建的对象(即副本)赋值给子类型的原型。这样我们就可以调用inheritPrototype()函数,去替换前面例子中为了子类型原型赋值的语句了。例如:
|
|
这个例子的高效率体现在它只调用了一次SuperType构造函数,并且因此避免了再SubType.prototype上面创建不必要的、多余的属性。与此同时,原型链还能保持不变,因此还能正常使用instanceof和isPrototypeOf()。开发人员普遍认为,寄生式组合继承时引用类型最理想的继承范式。
至此,JavaScript面向对象编程告一段落,此文有些地方不容易理解,需要仔细研读。
看不懂或看不完,可以先收藏啊。老规矩,转载请著名出处,谢谢!