JS初学者大都没有认识到其强大的面向对象编程的特性,只是把JS当作一门简单实用的脚本语言来用。也正因如此,JS程序员往往处于程序员鄙视链的最低端,很多人觉得JS是HTML一类的语言,甚至连语言都称不上。事实完全不是如此,你若也有这种想法,说明你对JS的认识太浅薄了。要想正真迈入JS的大门,你必须深入了解JS面向对象编程的特性。下面就让我为大家一一道来。
一、创建对象
既然是面向对象,那肯定先得有对象吧,要有对象,肯定得知道对象是什么吧,那JS中的对象是什么呢?在C++里我们知道,对象就是类或结构体的实例,对象是由其模板实例化得到的。但是JS中连类都没有,它是怎么定义对象的呢?很简单,JS里的对象用个花括号括在一起的一大堆键值对而已。键称为对象的属性名,理论是string类型的,但实际上你加不加引号都无所谓,因为JS对数据类型的概念就是这么任性;值就是对象属性名的属性值,属性值既可以是五大基本数据类型,也可以是另外的对象,这样对象里面又可以有对象,就可以创造出丰富多彩的JS对象了。OK,知道什么是对象之后,让我们着手造个对象出来。
方法一:Object()
方法二:对象字面量
1 var bitch = new Object(); 2 bitch.boobs = ‘huge‘; 3 bitch[‘bf‘] = {name:Jhon,age:22};
这个例子结合使用了方法一和方法二来创建对象,这两个方法都十分基础,但还是有两点需要提一下:一、访问对象属性有两种形式,.和[]。前者相对来说简便一些,因为它不用在属性名上加引号;后者当然也有它的优势,那就是当属性名中有空格这类的特殊字符时,前者就不起作用了,这时便是后者的天下。二、在调用Object()构造函数时,new运算符实际上是可以省略的,这点可以推广到其他很多构造函数上,但有两个是例外:String() 和 Number()。对这两个函数而言,如果不加new,只是作一次数据类型的转换,得到的将是基本数据类型的值;而带new运算符的话,得到的将是String/Number的实例。
这两种方法创建对象简单直观,但也存在问题,那就是无法批量生产对象。由此,工厂模式应运而生。
方法三:工厂模式(注意区别于设计模式中的工厂模式)
function Bitch(boobs,bfName,bfAge){ var bitch = new Object(); bitch.boobs = boobs; bitch.bf = {name:bfName,age:bfAge}; bitch.cry = function(){console.log(‘Crying‘);} return bitch; } bitch=Bitch(‘huge‘,‘Jhon‘,22);
实际上就是用函数封装了特定借口的细节,避免在批量生产对象时出现太多重复代码。工厂模式解决了对象的批量生产问题,但还有个问题没有解决——对象的识别问题。也就是说,这样创建出来的对象是独立的个体,跟其他对象没一毛钱关系,即使是用同一个函数创建出来的对象,也是互不相识的,而这显然不是我们想要的。由此,构造函数模式应运而生。
方法三:构造函数模式
function Bitch(boobs,bfName,bfAge){ this.boobs=boobs; this.bf={name:bfName, age:bfAge}; this.cry = function(){console.log(‘Crying‘);} } bitch=new Bitch(‘huge‘,‘Jhon‘,22);
对比工厂模式可以发现构造函数模式的特点:一,没有显示创建对象,而是直接将对象属性赋给了this指针;二,没有return语句;三,调用时需要用new运算府。其实,最关键的地方在于这个new运算符,如果不加这个new,那构造函数就是个普通函数;而任何普通函数在调用时前面加new运算符的话就会变成构造函数。new运算符的作用下,构造函数的执行过程大致如下:创建一个新对象--->将这个对象赋给this指针--->执行函数中的代码--->返回这个对象(注意,此处特指没有任何返回值的构造函数,如果构造函数显示的返回了值的话,情况有所不同,我会在《JavaScript构造函数》一文中详细解释)。如前所述,构造函数模式的存在是为了解决工厂模式的对象识别问题,那这个问题解决了吗?
bitch instanceof Bitch // true bitch instanceof Object // true bitch.constructor === Bitch // true
由以上代码可见,对象识别是没什么问题了,但接着又发现了一个问题——对象的所有函数属性在每个对象上都要重新创建一遍,而这些函数实际上完全相同,这样做岂不是暴殄天物。因此,构造函数还需要改进。下面是一种改进方法:
function cry(){console.log(‘Crying‘);} function Bitch(boobs,bfName,bfAge){ this.boobs=boobs; this.bf={name:bfName, age:bfAge}; this.cry = cry; } bitch=new Bitch(‘huge‘,‘Jhon‘,22);
初看起来,这种方法很好地解决了上述问题,但其实有一个致命的弊端——你把对象的函数属性都写成全局函数了,那全局环境岂不是被你无情地玷污了?看来这中方法是被PASS了,那又该怎么解决函数在每个对象上重复创建的问题呢?原型模式由此诞生!
方法四:原型模式
function Bitch(){}; Bitch.prototype.boobs = ‘huge‘; Bitch.prototype.bf={name:‘John‘, age:22} Bitch.prototype.cry=function(){console.log(‘Crying‘);}
这就是所谓的原型模式。当然,要看懂这段代码得先弄明白原型究竟是个什么东西。说白了,原型其实不过是个属性。不过不是我们自定义的属性,而是在我们定义函数的时候解析器自动给函数添加的一个属性,是函数与生俱来的属性。这个属性的名字叫做prototype,属性值是个对象,我们就是要对这个名为prototype的对象动手脚。看上面的代码,我们将原本应该在构造函数内部给this指针添加的对象统统添加到了函数的原型对象上,为什么要这样做呢?因为当我们调用构造函数构造对象后,这个构造出来的对象会保存对构造函数原型对象的引用。而且,当我们访问该对象的某个属性的时候,JS解析器会先问这个对象:“嘿,你有这个属性吗?”,如果有,很好,直接返回;如果没有,解析器不会就此善罢甘休,而是根据该对象保存的其构造函数的原型对象的引用,找到那个原型对象并问它:“嘿,你有这个属性吗”,如果有,很好,直接返回;如果没有,解析器还是不会就此善罢甘休,而是再去找这个原型对象的构造函数的原型对象(这里感觉很绕口,其实很简单,只要记住原型对象是构造函数的属性,而不是构造函数构造出的对象的属性,构造函数构造出的对象只是知道到那里去找这个原型对象罢了),然后一直这样作着不懈的努力,直到在某个原型对象上找到这个属性或是碰到原型链的头为止。自然而然地,我们引出了原型链的概念。原型链的出现是因为对象都有个对应的原型对象,而原型对象也是对象,它也有自己对应的原型对象,这样一来不久构成原型链了吗?那原型链的头在哪里呢?原型链的头在Object.prototype。Object.prototype本身是Object对象,但这个对象有一点特殊,因为它没有对应的原型对象。
Ok,扯了一大段,绕得有点晕,刚开始我也不理解,不过见多了自然就明白了。回到上面的代码,我们将属性都添加在原型对象上,当我们构造对象时,实际上得到的是个空对象,但我们可以访问它的相关属性,因为解析器会不余遗力地去找原型对象。但原型对象只有一个(还是上面那句话,原型对象是构造函数的属性),无论我们造多少对象出来,都不会重复地创建属性——也就是说,构造函数模式的问题得到了很好地解决。但是,原型对象又带来一个问题——所有属性都是一样的,那我创建的对象岂不是一点个性都毛有?这确实是个问题,不过这个问题很容易解决——组合使用构造函数模式和原型模式,前者负责个性,后者负责共性,二者取长补短相辅相成。最终代码如下所示(所说是最终代码,实际上还有很多创建对象的模式,不过应用并不广泛,在此不作叙述):
function Bitch(boobs,bfName,bfAge){ this.boobs=boobs; this.bf={name:bfName, age:bfAge}; } Bitch.prototype.cry = function(){console.log(‘Crying‘);};
二、私有属性
如前所述,我们最终以一个较为完美的方式创造出了对象,但还有问题——对象的所有属性都是公开的,一点封装性都没有你也敢妄称面向对象编程?下面就来解决封装性的问题。
首先我们要明确,JS是没有类似public/private之类的访问控制符的,JS甚至连块级作用域都没有,那JS如何实现封装呢?这就要请出我们下一位大爷(上一位大爷是原型)——闭包。闭包何方神圣也?我个人简单地理解,闭包就是个函数(这句话开始恐有争议,不过这是我为了方便理解才如此妄加论断,请不要死扣字眼),不过不是一般的函数,而是函数中的函数,姑且成为内部函数。不过这个内部函数有点牛,因为它可以随意访问它外部函数的变量,不仅如此,它还手握着那些外部函数变量的生杀予夺之权,这句话是什么意思呢?让我们来看一个例子:
function husband(money){ var car = 3, house = 2; function wife(){ return { money:money, car:car, house:house }; } return wife; } var wife = husband(1000), assets = wife(); console.log(assets); // Object {money: 1000, car: 3, house: 2}
理论上说,当语句wife = husband() 执行完毕以后,husband()函数的作用域便被销毁了,它里面那些个变量应该是不复存在了。但代码执行结果告诉我们,这些变量并没有在husband()执行完毕后被立即销毁,因为husband()有个内部函数wife(),也就是闭包,导致husband()对自己内部的变量已经没有生杀予夺之权了,这等大权如今掌握在wife()手里。因此,在wife()执行完毕之前,这些变量是不会被销毁的。这就是前面那句话的含义所在。
目前,我们知道什么什么是闭包,也知道了闭包掌握着外部函数的变量的生死大权,那该如何利用闭包的这种特性来创建私有属性呢?
function Bitch(boo,bfName,bfAge){ var boobs = boo; // 私有属性 this.bf={name:bfName, age:bfAge}; // 公共属性 this.watchBoobs = function(){ return ‘huge‘; }; // 私有属性的访问接口 } Bitch.prototype.cry = function(){console.log(‘Crying‘);};
这段代码的关键在于没有在对象上直接创建应该保持私有的属性(因为直接添加在对象上的属性无法保持私有),而是在函数中简单的声明一个私有变量,然后通过闭包(this.watchBoobs)的方式提供该私有变量对外的访问接口。这样以来,外部无法直接访问boobs,只能通过闭包watchBoobs进行访问,这只能远观而不能亵玩不正是私有属性所要的特点吗?至此,私有属性已经成功实现,也就是说封装性已经搞定了,下面来搞定面向对象编程的第二大特性——继承。
三、继承
在深入理解了原型和原型链的概念之后,我们不难发现原型是JS里实现继承的最佳工具。下面看一段具体实现:
function Sup(a){ this.a = a; } Sup.prototype.foo = function(){console.log(‘Function of Sup.prototype‘);} function Sub(a,b){ this.a = a; this.b = b; } Sub.prototype = new Sup(); Sub.prototype.bar = function(){console.log(‘Function of Sub.prototype‘);}
利用原型实现继承的本质就是重写函数的原型。回想一下,函数的原型的是什么——是个对象(默认是Object对象);那又该怎样重写函数的原型——把一个新对象赋值给它;这个新原型有什么要求——它正是父类的对象。由此,我们才有了这种写法:
Sub.prototype = new Sup();
注意比较JS继承与传统OO语言继承之间的差别,比如C++的继承是这样写的:
class A:public B{}
我觉得区别主要有两点:一,传统OO语言的继承是类的继承,是抽象概念之间的继承,实现继承并不需要父类的实例;而JS的继承则是实例的继承,子类继承的是父类的一个实例。二、传统OO语言的继承分public/private/protected等不同的继承方式,而JS本身连私有变量的概念都没有,就更不可能区分共有继承和私有继承了(那JS可以实现类是功能吗?有该如何实现呢?)。
上述JS实现继承的方式称为“原型模式”,这种方式存在几个缺点:
一、在创建子类实例时,不能向父类的构造函数传递参数(如传统OO语言有这种写法:
A:A(int a,int b){ B:B(b); this.a = a; }
)
二、当父类属性存在引用类型值时,会造成致命问题。几个例子来说:
function Sup(a){ this.a = a; } Sup.prototype.foo = function(){console.log(‘Function of Sup.prototype‘);} function Sub(b){ this.b = b; } Sub.prototype = new Sup([1,2,3]); Sub.prototype.bar = function(){console.log(‘Function of Sub.prototype‘);} var sub1 = new Sub(1), sub2 = new Sub(2); sub1.a.push(4); console.log(sub2.a); // 对sub1的改动影响到了sub2
前面说过,JS的继承实际上是继承了父类的某个实例。当这个父类实例的某个属性是引用类型值,而我们在这个值上调用一些方法改变这个值时,将会影响到所有子类的实例。但是,请注意这里改变这个引用类型值的方式(sub1.a.push(4)),如果我采用下面这种方式:
sub2.a = null; console.log(sub1.a); // sub1没有受到影响
这是因为这种直接为属性赋值实际上是在子类的实例上动态添加了一个属性,而该属性敲好与原型对象的属性重名,则原型对象的属性被覆盖,因此其它子类实例实际上不会受到影响,这一点要区分清楚。
OK,“原型模式”的缺点已经讲完了,那该如何解决呢?很简单,很创建对象时一样,借用构造函数模式,将二者结合起来,取长补短即可。完整实现如下:
function Sup(a){ this.a = a; } Sup.prototype.foo = function(){console.log(‘Function of Sup.prototype‘);} function Sub(a,b){ Sup.call(this,a); this.b = b; } Sub.prototype = new Sup(); Sub.prototype.construtor = Sub; Sub.prototype.bar = function(){console.log(‘Function of Sub.prototype‘);}
看似已经很完美了,但还存在两个值得思考的问题:
一、Sup.call(this,a);
这句既解决了原型模式中创建子类的实例时无法向父类的构造函数传递参数的问题,又解决了因原型对象存在引用类型值的属性导致子类实例可能互相影响的问题(因为在子类上将父类的属性全部复制了一遍,父类属性都被覆盖了)。但是按照这种写法,当我们要扩展父类时,比如父类变为function Sup(a,b)时,子类扩展起来不方便。因此,有如下改进写法(这个想法我一位面试官告诉我的):
Sup.apply(this,arguments);
二、Sub.prototype.construtor = Sub;
这句话有必要写吗?仍然是上面那位一位面试官,他认为这样写有必要。因为如果不这样写,那在用instanceof操作符判断实例类型时会出问题,即 sub1 instanceof Sub 会返回false。但在Chrome上测试,我发现即使没有这句,sub1 instanceof Sub还是会返回true的。《JS高程》一书说,只要是在原型链中出现过的构造函数,都会返回true。那么我认为,那位面试官的说法是错误的,实际上这句话唯一的好处就在于 sub1.constructor 会指向 Sub。
Ok,写了三天终于完成了这篇文章。基本上将JS OOP各方面的基础概念都解释清楚了,对他人是一次很好的技术分享,对自己是一次很好的知识复习。