簡介
隨著基於web的應用程式不斷普及以及一些插件的消逝(Flash,Siverlight,Java Applets, ...),越來越多的開發者發現他們正在使用JavaScript編寫複雜應用程式.
很多開發者情願借助一些具有繼承功能的第三方框架諸如prototype.js之類的,以便可以按以前熟悉的方式編程,而不用自己去實現一個繼承。我也一樣。然而,很快我就因為對它的原理知之甚少而感到愧疚。
我起初本想寫一篇關於javascript繼承模式的文章,不久之後我發現即便是一個很簡單的物件創建也是有很多地方可以深入研究的。
我知道你可能會說,創建一個javascript的物件?這再容易不過了,像這樣子:
var myObject = {};
普通的物件
JavaScript 是一門很簡單的語言,只有幾種內建的類型。在這一節中我們會詳細研究物件類型,它是怎麼創建的和它繼承的屬性。 JavaScript 裡物件只是屬性的集合。屬性由(唯一的)名稱和任意類型的值組成
物件是動態的,物件被創建以後,你可以增加和刪除它的屬性。下面的程式碼建立了一個「空」的對象,然後加入了幾個屬性:
var shape = {}; shape.color = 'red'; shape.borderThickness = '2.0';
你也可以像下面這樣子更簡單地實現對象的初始化:
var shape = { color : 'red', borderThickness : '2.0' }
上面兩種形式得到的對像是相同的。
我們還有第三種方式,是用Object的建構子:
var shape = new Object();
這也是與上面其他兩種方式等價的,然而我們忽略了一個問題!
我們所創造的這個物件到底長什麼樣子?以及它有什麼屬性?可以透過Google瀏覽器(或IE、Safari、firefox等等)的開發者工具我們可以輕鬆地審查它。通常我們可以透過添加監視或簡單地在控制台輸入它的名字來審查一個物件。
這裡就是那個shape的物件:
正如你所看到的它有兩個屬性,color和borderThickness,這是被顯式地添加的。但是這個神祕的__proto__又是什麼東西呢?
從它是用的下橫線你或許會猜到它是一個私有屬性。然而,眾所周知javascript只有公有屬性,誰也不能阻止你在程式碼裡使用它們(即使有危險)。
__proto__ 這個屬性指向了物件的prototype,打開這個物件之後,你會發現他有不少的函數。
這揭露了JavaScript 真正的繼承機制是圍繞著原型鏈的。如果你試著存取一個不在物件中的屬性(無論該屬性是一個值還是一個函數),JavaScript 執行階段就會檢查這個物件的原型是否有這個屬性。如果有,它就回傳這個屬性,否則會順著原型鏈往上一直找。
你可以試試看。在控制台中試著存取屬性 toString (你可以用點方法或索引方法),這個屬性是一個函數,你可以呼叫它。
雖然結果並不是十分有趣!
你可以看見,JavaScript 的對象十分簡單,就是一個“袋子”,裝著屬性和允許屬性繼承的“隱藏的”原型。
JavaScript 與生俱來的動態性讓創建物件變得十分簡單,然而,大部分複雜的應用都是利用類型(在C#和Java語言中定義為類別)。類型描述了物件的屬性和方法,允許你建立多個類型的實例。
下一節中,我們會看看如何用JavaScript實作類型。
關於術語最後一點 - JavaScript 有函數,但是在物件屬性上的函數稱為方法。
構造函數
上一節中我們簡要地講了原型鏈以及物件如何繼承屬性和方法。一旦物件被創建,你就不能更改它的原型。怎樣才能為它添加屬性和方法呢?
建構函數解決了上述問題,在深入之前,我們先給物件添加一個函數,使例子更貼合實際:
var shape = { color : 'red', borderThickness : '2.0', describe : function() { console.log("I am a " + this.color + " shape, with a border that is " + this.borderThickness + " thick"); } }
这给对象shape加了一个函数(也就是方法)。如果你用控制台审查,你会看见describe这个函数是一个属性,和其他东西一样。你可以通过控制台调用这个方法:
构造函数执行完成会在创建的“shape”对象上添加两个属性,同对象初始化创建的“shape”对象相比,你会发现两者看起来似乎一样。然而有个微妙的区别,初始化创建的“shape”对象有个"隐藏”属性__proto__,Chrome指定为Object,而构造函数创建的“shape”对象的__proto__指向Shape。(注:随后你会明白,Chrome使用的这种指定方式!)
译者注:不知道原文作者的Chrome版本,我的机子上Chrome版本是 53.0.2785.113 m (64-bit)。通过构造器构造的对象的__proto__还是指向一个Object,即Shape函数的prototype属性,理论上也应该是Object。后文翻译还是以原作者的Chrome为准:构造函数实例的__proto__为Shape,初始化对象的__proto__为Object。
在讨论这两者微妙的区别的意图之前,我们来为shape对象添加一个describe方法。
你可以在刚才创建的对象的构造函数上直接添加方法。然而,这种构造模式(译者注:指构造函数创建对象的方式)提供了一种更为有力的替代方式。你可以在构造函数的原型属性上添加方法。如下所示:
function Shape(color, borderThickness) { this.color = color; this.borderThickness = borderThickness; } Shape.prototype.describe = function() { console.log("I am a " + this.color + " shape, with a border that is " + this.borderThickness + " thick"); };
若你创建一个shape对象并审查此对象,你会发现如下所示代码:
现在你的shape对象具有describe方法,但这并非对象本身的属性,而是该对象原型上的一个属性。通过上文,我们明白一个定义在Object原型上的方法是如何通过原型链被实例继承的。同样,所有的Shape实例会继承describe方法:
展开Shape的__proto__属性所指的对象,会显示此对象位于原型链的底部:
因此,通过Shape构造器创建的任意对象都可以访问上述显示的所有方法。
JavaScript中所谓的类,其实是一种设计模式:一个构造函数(consturctor)和一个用于在该类实例间共享属性和方法的原型对象(Objcet.prototype)的结合。
为了达到属性继承,代码复用等目的,通过函数来模拟类来创建对象。
(或许你会疑惑,通过Object就可以创建对象了呀?确实如此,new一个Object对象后,给Object对象增加属性和方法,确实可以生成一个对象。但这种做法实在太麻烦,且不易封装复用。下面介绍的方法一就是这种方法的高级版)
方法一:用一个普通的函数来封装(俗称工厂模式)
function createPerson(name, age){ var o = new Object(); o.name = name; o.age = age; o.sayName = function(){ alert(this.name); }; return o; } var p1 = createPerson("Jack", 32); var p2 = createPerson("Zhang", 30); p1.sayName(); //Jack p2.sayName(); //Zhang
这个例子平淡无奇,createPerson函数内创建了一个Object,并赋予它两个属性(name,age)和一个方法(sayName)。这样部分达到了类的作用。为何说是部分呢?因为它有个明显的缺陷,即无法解决对象识别问题,创建出来的对象(p1,p2)都是Object类型的:
alert(p1 instanceof Object); //true alert(p2 instanceof Object); //true
如果你有一堆对象,有Person有Dog,你无法区分这些对象中哪些是Person哪些是Dog。为了解决上述缺陷,引入了构造函数的概念
方法二:通过构造函数来封装:(俗称构造函数模式)
function Person(name, age){ this.name = name; this.age = age; this.sayName = function(){ alert(this.name); }; } var p1 = new Person("Jack", 32); //用new操作符来调用 var p2 = new Person("Zhang", 30); p1.sayName(); //Jack p2.sayName(); //Zhang alert(p1 instanceof Object); //true,显然创建的对象都既是Object,也是Person alert(p1 instanceof Person); //true alert(p2 instanceof Object); //true alert(p2 instanceof Person); //true alert(p1.constructor == Person); //true alert(p1.constructor == Dog); //false,这样就能区分对象究竟是Person还是Dog了
Person函数与createPerson函数相比,有以下不同:
1.没有var o = new Object();创建对象,自然最后也没有return o;返回对象
2.没有将属性和方法赋给Object对象,而是赋给this对象
因为Person函数内部使用了this对象,因此你必须用new操作符来创建对象:
var p = new Person("Jack", 32); p.sayName();//Jack
如果你忘记用new操作符来创建对象的话:(解决方式见※1)
var p = Person("Jack", 32); p; //undefined this.name; //Jack this.age; //32
灾难的事情发生了,因为没有用new来调用,因此Person函数内的this将指向window对象(即BOM),因此等于给window对象添加了两个全局变量。
像Person这样的函数被称为【构造函数】。你可能疑惑,既然JavaScript并没有类的概念,那怎么会有构造函数呢?其实构造函数与普通函数唯一的区别,就在于它们的调用方式不同。只要通过new操作符来调用,那它就可以作为构造函数。但构造函数毕竟也是函数,并不存在任何特殊的语法。
但通过构造函数的方式来模拟类的话,也有个缺陷:(一种不好的解决方式见※2)
alert(p1.sayName == p2.sayName); //false
sayName只是个普通的方法,如果你创建多少个Person对象,显然你不希望多个对象中都有一个sayName方法的副本。这将造成无谓的浪费。
方法三:通过原型来封装:(俗称原型模式,但其实这里的例子是复合模式,即构造函数模式+原型模式)
每个函数都有个prototype属性,该属性其实是一个指针,指向一个对象,称为原型对象。原型对象中包含着可供所有实例共享的属性和方法。
可以将希望所有对象共享的属性或方法(如上述sayName方法)添加到原型对象中,达到继承复用的目的。
function Person(name, age) { this.name = name; //name和age没有放到原型对象中,而是仍旧留在构造函数内部 this.age = age; //表示不希望每个实例都共享这两个数 } Person.prototype.sayName = function(){ //将希望被所有对象共享的sayName方法放入原型对象中 alert(this.name); } var p1 = new Person("Jack", 32); //用new操作符来调用 var p2 = new Person("Zhang", 30); p1.sayName(); //Jack p2.sayName(); //Zhang <pre name="code" class="javascript">alert(p1.sayName == p2.sayName); //true,sayName方法确实被共享了,而不是每个对象中都有一个独立的副本
示意图:
Person构造函数(再次声明,无论什么函数内部都有原型指针,指向一个原型对象)内部有个原型指针,指向Person的原型对象。图中也可看出Person的原型对象内部有个constructor属性,指向对应的函数。这样函数和原型对象就双向绑定起来了。
已经展示了忘记用new操作符来调用构造函数的可怕后果。靠程序员自觉显然不是个好主意。有3种方式可以避免这种错误:
1.在函数内部第一行加上use strict;启用严格模式,这样var p = Person("Jack", 32);创建将失败。缺点是你必须保证环境支持ES5,否则无法保证严格模式能起作用。
2.改进构造函数:
function Person(name, age) { if(!(this instanceof Person)){ return new Person(name, age); } this.name = name; this.age= age; }
上述代码是自解释代码(self-explanatory),一行注释都不用就能看明白。缺点是需要额外的开销(即额外的函数调用),也无法适用于可变参数函数。
3.利用ES5的Object.create函数改进:
function Person(name, age) { var self = this instanceof Person? this : Object.create(Person.prototype); self.name = name; self.age= age; return self; }
Object.create需要一个原型对象作为参数,并返回一个继承自该原型对象的新对象。同样你必须保证环境支持ES5
已经展示了通过构造函数来模拟类的缺陷,即无法实现共享。你可能会疑惑,通过共通函数不就能实现共享了吗?
function Person(name, age) { this.name = name; this.age= age; this.sayName = sayName; } function sayName() { alert(this.name); } var p1 = new Person("Jack", 32); var p2 = new Person("Zhang", 30); alert(p1.sayName == p2.sayName); //true
在构造函数中定义个函数指针,指向全局函数sayName,这样确实能达到共享的目的。但你真希望看到全局函数吗?如果Person有5,6个方法,那你就需要定义5,6个全局函数,同样是个灾难。
总结
在JavaScript中,对象创建并不是一个简单的主题!希望通读此文后,你会学到些关于构造函数,原型或JavaScript语言的其它内容。