JavaScript高级程序设计之面向对象程序设计
JavaScript不是纯粹的面向对象编程语言,因此用面向对象编程,对与一些初学者是比较难的,甚至对一些中级的前端工程师来说也是一种考验。既然用JavaScript面向对象编程,如此困难,为何我们还要用面向对象呢?下边我们去揭开面向对象的层层面纱,去寻找其中的原因。
面向对象简称OO编程,是目前比较盛行的编程方法。在面向对象中,推崇一切皆对象的理念,这些与现实世界的事物更加的贴合。与面向对象相对的编程方式,是面向过程。面向过程编程方式,注重的是功能的实现过程,而不注重功能本身。在编程界中,有的推崇面向对象,有的推崇面向动作,也有的人,认为两种方式是互补的,不过这种人比较少。
理解对象
在JS中,创建对象的最简单方式,就是直接声明一个对象字面量,然后在给对象添加对应的属性和方法。例:const persion = {}; persion.name = "xyq";。这是一个给对象赋属性的例子,那什么是属性呢?属性是用于描述对象特征的值。例如创建一个人类,人类有性别、年龄等属性,来描述人类的特征。JS中属性分两种,数据属性和访问器属性。
数据属性是包含数据的值,我们可以对这个值,进行读取和写入的操作。数据属性拥有四个描述其行为的特性:[[Configurable]]:是否可配置,表示属性能否用delete删除,重新定义,能否修改属性的特性或能否把属性修改为访问器属性。默认值为true。[[Enumerable]]:是否可枚举,表示是否能通过for in 、for of循环返回属性。默认值true。[[Writable]]:是否可写,标示是否可以修改属性值,默认为true。[[value]]:用来存储属性的值,默认值为undefined。
可以通过Object的defineProperty和defineProperties方法来修改属性的描述符,例:
1 let obj = { 2 age:18 3 } 4 //defineProperty方法 5 //第一个参数为要设置的对象 第二个参数要设置的属性 第三个参数一个对象 6 //具体要设置的属性描述符 7 Object.defineProperty(obj,"age",{ 8 writable:false, 9 configurable:false, 10 }) 11 12 obj.age = 35 //报错 13 14 //defineProperties方法 15 //第一个参数为要设置的对象 第二个参数为属性对象 16 Object.defineProperties(obj,{ 17 "age":{writable:true} 18 }) 19 20 obj.age = 35 //不在报错 21 22 //获取指定属性的描述符 23 //第一个参数为要获取属性描述符的对象 第二个参数为要获取属性描述符的属性 24 Object.getOwnPropertyDescriptor(obj,"age");
访问器属性不包含数据值,它们包含一对getter和setter函数,当然它们并不是必须的。读取属性的时候,会调用getter函数,设置属性的时候会调用setter函数。访问器属性也有四个特性:[[Configrable]]:能否通过delete删除、能否修改属性特性、能否把属性改为数据属性。默认true。[[Enmerable]]:能否通过for in 或 for of循环,默认true。[[Get]]:读取属性调用的函数,默认值为undefined。[[Set]]:在写入属性时,调用的函数,默认值为undefined。
必须通过Object的defineProperty或defineProperties方法来修改。例:
1 //访问器属性 2 Object.defineProperty(obj,"name",{ 3 get:function(){ 4 return this._name 5 }, 6 set:function(value){ 7 if(value == "c") value = "b"; 8 9 this._name = value; 10 } 11 }) 12 13 obj.name = "c"; 14 15 console.log(obj.name);
创建对象
工厂创建模式
1 // 工厂创建 2 3 function factory(name,age){ 4 const f = {}; 5 6 f.name = name,f.age = age; 7 8 return f; 9 }
构造函数创建模式
与工厂创建模式的不同:1、没有显式地创建对象。2、直接将属性和方法赋给了this对象。3、没有return语句。
构造函数需要通过new操作符去创建对象,这样创建出来的对象都是构造函数的实例,都会有一个constructor属性,指向构造函数。使用构造函数创建对象,有一个很明显的优点,就是可以把它的实例标为特定的类型。
构造函数也是函数,它与普通函数除了调用方式不同之外,并无其它不同,也就是说,任何函数,通过new调用,都可以称为构造函数。
缺点:每次创建实例,方法都会被重新创建一遍,而这会引起不必要的性能消耗。
1 //构造函数创建 2 function Persion(name,age){ 3 this.name = name; 4 this.age = age; 5 6 this.getName = function(){ 7 return this.name; 8 } 9 } 10 11 const persion1 = new Persion("ww",15); 12 13 persion1.getName();
原型模式
构造函数创建对象,存在方法不能共享的问题。原型模式的出现,解决了此问题。原型模式让构造函数有一个原型属性,这个原型属性指向构造函数的原型对象,因此可以在其身上添加属性和方法,而且添加的属性和方法,是所有实例共享的。
原型是js面向对象中,十分重要的一环,因此理解原型是十分必要的。原型属性有个constructor属性,这个属性存储的是指向构造函数的一个标示,我们可以通过这个属性来访问构造函数。创建的构造函数,初始的原型只有constructor一个属性,其它方法都是继承自Object。当我们创建构造函数的时候,实例内部会有一个指向原型对象的指针,ES5中管这个指针叫做[prototype],不过并没有标准的方式访问这个指针,但是在主流的浏览器中,支持用__proto__来访问该指针。(注:实例的[prototype]指针是指向构造函数的原型对象的,这点一定不要弄混)。虽然并没有一个标准的方式来访问[prototype],但是可以用isPrototypeOf方法来确定对象之间是否存在次关系。例:Persion.prototype.isPrototypeOf(persion) 返回true。还可以用新增的getPrototypeOf方法获取[prototype]的值。例:Persion.prototype.getPrototypeOf(persion) == Persion.prototype 返回true。
每一次对原型属性的访问,都会进行一次查找操作。查找会首先查找对象自身,然后在查找原型,如果差找不到,会继续查找原型的原型,知道查找到属性为止。所以实例只能访问原型的属性,无法修改原型的属性。如果在实例上添加一个属性的话,原型中的属性并不会改变,它只是纯粹的给实例加了一个属性,但是你访问的时候,根据属性的查找原理,似乎的确是修改成功了,但是,当你实例化另一个实例时,通过这个实例,你去访问属性,你会发现,修改并没有成功,值并没有发生变化。之前添加的实例属性,只不过是把访问原型属性的路给堵住了,造成了修改成功的假象。而这也就是说,只要我们把路疏通,仍然可以访问原型中的属性。如何疏通呢?可以用delete操作符,把实例属性删除掉,路就被疏通了。
有一个hasOwnProperty方法,此方法会判断属性是否是对象本身的,如果是返回true,如果不是返回false。方法接收一个属性名作为参数,通过实例来调用。
原型模式很好用,但并不是没有缺点。原型模式最大的一个缺点,就是如果有个属性它的值是引用类型的话 ,修改这个值里的某个属性,在其它拥有此原型属性的对象上,修改也会生效。为了弥补这个缺点,就出现了构造函数和原型合用的模式。这个模式,在构造函数中定义属性,在原型中定义方法。用构造函数的优点来弥足原型的缺点。
动态原型模式,是另一种创建原型的模式,它把原型写在构造函数中,只在构造函数第一次实例化的时候,创建原型,之后的使用中,不在创建。该模式,会在构造函数中添加一个判断,判断原型方法是否存在,不存在的话,则创建这个方法。存在的话则不进行任何操作。
寄生构造函数模式采用的是借鸡生蛋的原理。创建一个普通的函数,把创建对象的代码封装到函数里。使用上,既可以当做普通工厂函数使用,也可以用new操作符,当做构造函数使用。
稳妥构造函数模式其实就是寄生构造函数的升级版,让其变得更加的安全。该模式,不在提供直接访问属性的途径,需要通过调用方法来获取属性存储的值,而且调用函数时只使用函数调用方式,不再使用new。
继承
JS对继承的实现和其它语言不同,它通过原型来实现继承。
原型链
原型链是个比较重要的概念,js的继承就是通过原型链来实现的。原型链其实并不是很难,其原理就是一个对象的原型指向了另一个对象,而另一个对象的原型又指向了第三个对象,这时三个对象之间通过原型这个链条彼此互相链接,而这就是原型链。在JS中,所有的对象都默认继承Object类型,它们的默认原型都是Object的实例,也就是说其内部有个指针指向Object的原型。
原型链的确是个好东西,但实践中并不是很常用,因为它还存在一些问题,为了解决这些问题,出现了一种继承方法,名字叫做借用构造函数。这种方式的思想比较简单,就是在子类的构造函数中,借用call和apply来调用父类的构造函数,绑定子类的环境对象。这样子,子类会生成父类属性的副本,不会存在引用值共享的问题,而且可以传递参数,不过因为使用的是构造函数,因此构造函数模式的问题,也会在其身上体现出来。
为了解决借用构造函数的问题,又出现了另一种继承方式。组合继承其实就是构造函数和原型组合模式,只不过这里用来实现继承。这种方式把构造函数和原型合二为一,各取其长。通过构造函数来实现属性的继承,通过原型来实现方法的继承。看上去是个比较完美的继承方式。
借于寄生构造函数的理念,出现了原型式继承的方式。这种方式通过借用第三方的函数,来封装具体的继承实现。而在具体实现中,还会创建一个函数,用来通过其原型来实现继承,然后在把函数实例化后返回出去。这种方式的确很好,以至于JS在es5中推出了Object.create方法。这个方法就是对原型式继承方式的一个规范,通过这个方法可以很轻松的实现原型式继承。方法接收两个参数,第一个父类对象,第二参数为可选,是用来给新对象定义额外属性和方法的对象。
寄生式继承更加的接近了寄生构造函数,它可以说是原型式继承的一种提升。它和原型继承的区别,就在与函数的内部,不仅仅是实现继承,它还会对实例对象进行一些扩充,然后再把实例对象返回出去。
前边说的组合继承,虽然很常用,但存在问题。它最大的问题就是,无论什么情况下,都会调用两次超类型构造函数,一次是在构造函数内部继承属性的时候,另一次是继承原型方法的时候。为了解决这个问题,出现了一种更完善的继承方式,寄生组合式继承保留了组合式继承的思想,并在其上加入了寄生式继承的思想,两者混合,实现了该继承方式。基本原理为属性的继承依然在构造函数中用call,然后原型的继承通过封装的第三方函数来实现。该函数接收两个参数,父类和子类构造函数。函数中会调用Object()创建一个父类原型的副本,然后把副本的constructor属性指向子类构造函数,最后把副本赋给子类的原型。