当今 JavaScript 大行其道,各种应用对其依赖日深。web 程序员已逐渐习惯使用各种优秀的 JavaScript 框架快速开发 Web 应用,从而忽略了对原生 JavaScript 的学习和深入理解。所以,经常出现的情况是,很多做了多年 JS 开发的程序员对闭包、函数式编程、原型总是说不清道不明,即使使用了框架,其代码组织也非常糟糕。这都是对原生 JavaScript 语言特性理解不够的表现。要掌握好 JavaScript,首先一点是必须摒弃一些其他高级语言如 Java、C# 等类式面向对象思维的干扰,全面地从函数式语言的角度理解 JavaScript 原型式面向对象的特点。把握好这一点之后,才有可能进一步使用好这门语言。本文适合群体:使用过 JS 框架但对 JS 语言本质缺乏理解的程序员,具有 Java、C++ 等语言开发经验,准备学习并使用 JavaScript 的程序员,以及一直对 JavaScript 是否面向对象模棱两可,但希望知道真相的 JS 爱好者。
重新认识面向对象
为了说明 JavaScript 是一门彻底的面向对象的语言,首先有必要从面向对象的概念着手 , 探讨一下面向对象中的几个概念:
一切事物皆对象
对象具有封装和继承特性
对象与对象之间使用消息通信,各自存在信息隐藏
以这三点做为依据,C++ 是半面向对象半面向过程语言,因为,虽然他实现了类的封装、继承和多态,但存在非对象性质的全局函数和变量。Java、C# 是完全的面向对象语言,它们通过类的形式组织函数和变量,使之不能脱离对象存在。但这里函数本身是一个过程,只是依附在某个类上。
然而,面向对象仅仅是一个概念或者编程思想而已,它不应该依赖于某个语言存在。比如 Java 采用面向对象思想构造其语言,它实现了类、继承、派生、多态、接口等机制。但是这些机制,只是实现面向对象编程的一种手段,而非必须。换言之,一门语言可以根据其自身特性选择合适的方式来实现面向对象。所以,由于大多数程序员首先学习或者使用的是类似 Java、C++ 等高级编译型语言(Java 虽然是半编译半解释,但一般做为编译型来讲解),因而先入为主地接受了“类”这个面向对象实现方式,从而在学习脚本语言的时候,习惯性地用类式面向对象语言中的概念来判断该语言是否是面向对象语言,或者是否具备面向对象特性。这也是阻碍程序员深入学习并掌握 JavaScript 的重要原因之一。
实际上,JavaScript 语言是通过一种叫做 原型(prototype)的方式来实现面向对象编程的。下面就来讨论 基于类的(class-based)面向对象和 基于原型的 (prototype-based) 面向对象这两种方式在构造客观世界的方式上的差别。
基于类的面向对象和基于原型的面向对象方式比较
在基于类的面向对象方式中,对象(object)依靠 类(class)来产生。而在基于原型的面向对象方式中,对象(object)则是依靠 构造器(constructor)利用 原型(prototype)构造出来的。举个客观世界的例子来说明二种方式认知的差异。例如工厂造一辆车,一方面,工人必须参照一张工程图纸,设计规定这辆车应该如何制造。这里的工程图纸就好比是语言中的 类 (class),而车就是按照这个 类(class)制造出来的;另一方面,工人和机器 ( 相当于 constructor) 利用各种零部件如发动机,轮胎,方向盘 ( 相当于 prototype 的各个属性 ) 将汽车构造出来。
事实上关于这两种方式谁更为彻底地表达了面向对象的思想,目前尚有争论。但笔者认为原型式面向对象是一种更为彻底的面向对象方式,理由如下:
首先,客观世界中的对象的产生都是其它实物对象构造的结果,而抽象的“图纸”是不能产生“汽车”的,也就是说,类是一个抽象概念而并非实体,而对象的产生是一个实体的产生;
其次,按照一切事物皆对象这个最基本的面向对象的法则来看,类 (class) 本身并不是一个对象,然而原型方式中的构造器 (constructor) 和原型 (prototype) 本身也是其他对象通过原型方式构造出来的对象。
再次,在類別式物件導向語言中,物件的狀態(state) 由物件實例(instance) 所持有,物件的行為方法(method) 則由宣告該物件的類別所持有,並且只有物件的結構和方法能夠被繼承;而在原型式物件導向語言中,物件的行為、狀態都屬於物件本身,並且能夠一起被繼承(參考資源),這也更貼近客觀實際。
最後,類別物件導向語言例如 Java,為了彌補無法使用過程導向語言中全域函數和變數的不便,允許在類別中聲明靜態 (static) 屬性和靜態方法。而實際上,客觀世界不存在所謂靜態概念,因為一切事物皆物件!而在原型式物件導向語言中,除內建物件 (build-in object) 外,不允許全域物件、方法或屬性的存在,也沒有靜態概念。所有語言元素 (primitive) 必須依賴物件存在。但由於函數式語言的特點,語言元素所依賴的物件是隨著執行時間 (runtime) 上下文 (context) 變化而變化的,具體體現在 this 指標的變化。正是這種特徵更貼近 「萬物皆有所屬,宇宙乃萬物生存之根本」的自然觀點。在 程序清單 1中 window 便類似與宇宙的概念。
清單 1. 物件的上下文依賴
最基本的物件導向
ECMAScript 是一門徹底的物件導向的程式語言(參考資源),JavaScript 是其中的一個變種 (variant)。它提供了 6 種基本資料類型,即 Boolean、Number、String、Null、Undefined、Object。為了實現面向對象,ECMAScript設計出了一種非常成功的數據結構- JSON(JavaScript Object Notation), 這一經典結構已經可以脫離語言而成為一種廣泛應用的數據交互格式(參考資源)。
應該說,具有基本資料型別和 JSON 建構語法的 ECMAScript 已經基本上可以實現物件導向的程式設計了。開發者可以隨意地用字面式聲明(literal notation)方式來建構一個對象,並對其不存在的屬性直接賦值,或用delete將屬性刪除( 註:JS 中的delete 關鍵字用於刪除物件屬性,經常被誤作為C 中的delete,而後者是用於釋放不再使用的物件),如 程式清單2 。
清單 2. 字面式 (literal notation) 物件宣告
字面式聲明(literal notation)方式之外,ECMAScript 允許透過 構造器(constructor)建立物件。每個建構器其實是一個 函數(function) 物件, 此函數物件含有一個「prototype」屬性用於實作 基於原型的繼承(prototype-based inheritance)和 共享屬性(shared properties。 物件可以由「new 關鍵字 建構器呼叫」的方式來創建,如 程式清單 3: 清單 3. 使用構造器 (constructor) 建立物件
徹底理解原型鏈 (prototype chain) 在ECMAScript 中,每個由構造器創建的物件擁有一個指向構造器prototype 屬性值的
隱式引用(implicit reference),這篇引用稱為 原型(prototype)。進一步,每個原型可以擁有指向自己原型的 隱式引用(即該原型的原型),如此下去,這就是所謂的原型鏈(prototype chain) (參考資源)。在特定的語言實作中,每個物件都有一個 __proto__ 屬性來實作原型的 隱式引用。 程序清單 4說明了這一點。 清單 4. 物件的 __proto__ 屬性與隱式參考
// 原型本身是一個Object 對象,所以他的隱式引用指向了
// Object 構造器的prototype 屬性, 故而打印true
console.log( Person.prototype.__proto__ === Object.prototype );
// 建構器 Person 本身是函數對象,所以此處印出 true
console.log( Person.__proto__ === Function.prototype );
有了 原型鏈,便可以定義一種所謂的 屬性隱藏機制,並透過這個機制實現繼承。 ECMAScript 規定,當要給某個物件的屬性賦值時,解釋器會尋找該物件原型鏈中第一個含有該屬性的物件(註:原型本身就是一個對象,那麼原型鏈即為一組物件的鏈。反之,如果要取得某個物件屬性的值,解釋器自然是傳回該物件原型鏈中首先具有該屬性的物件屬性值。 圖 1說名了這中隱藏機制:
在圖1 中,object1->prototype1->prototype2 構成了物件object1 的原型鏈,根據上述屬性隱藏機制,可以清楚地看到prototype1 物件中的property4 屬性和prototype2 物件中的property3 屬性皆被隱藏。了解原型鏈,那麼將非常容易理解 JS 中基於原型的繼承實作原理,程式清單 5 是利用原型鏈實現繼承的簡單範例。
清單 5. 利用原型鏈 Horse->Mammal->Animal 實作繼承
Horse.prototype = new Mammal();
// 重新指定eat 方法, 此方法將覆寫從Animal 原型繼承過來的eat 方法
Horse.prototype.eat = function() {
alert( "Horse is eating grass!" )
}
// 驗證並瞭解原型鏈
var horse = new Horse( 100, 300 );
理解清單 5 中物件原型繼承邏輯實作的關鍵在於 Horse.prototype = new Mammal() 和 Mammal.prototype = new Animal() 這兩句程式碼。首先,等式右邊的結果是建構出一個臨時對象,然後將這個對象賦值給等式左邊對象的 prototype 屬性。也就是說將右邊新建的物件作為左邊物件的原型。讀者可以將這兩個等式替換到對應的程式清單 5 程式碼最後兩行的等式中自行領悟。
從程式碼清單 5 可以看出,基於原型的繼承方式,雖然實現了程式碼復用,但其行文鬆散且不夠流暢,可閱讀性差,不利於實現擴展和對原始程式碼進行有效地組織管理。不得不承認,類別繼承方式在語言實作上更具健壯性,且在建構可重複使用程式碼和組織架構程式方面具有明顯的優勢。這使得程式設計師希望尋找到一種能夠在 JavaScript 中以類別繼承風格進行編碼的方法途徑。從抽象的角度來講,既然類別繼承和原型繼承都是為實現物件導向而設計的,並且他們各自實現的載體語言在計算能力上是等價的( 因為圖靈機的計算能力與Lambda 演算的運算能力是等價的),那麼能不能找到一種變換,使得原型式繼承語言透過該變換實現具有類別繼承編碼的風格呢?
目前一些主流的 JS 框架都提供了這個轉換機制,也也就是類別式宣告方法,例如 Dojo.declare()、Ext.entend() 等等。使用者使用這些框架,可以輕易且友善地組織自己的 JS 程式碼。其實,在眾多框架出現之前,JavaScript 大師 Douglas Crockford 最早利用三個函數對Function 物件進行擴展,實現了這種變換,關於它的實現細節可以(參考資源 )。另外還有由 Dean Edwards實現的著名的 Base.js(參考資源)。值得一提的是,jQuery 之父 John Resig 在搏眾家之長之後,用不到 30 行程式碼便實現了自己的 Simple Inheritance。使用其提供的 extend 方法聲明類別非常簡單。 程式清單 6是使用了 Simple Inheritance函式庫實現類別的聲明的例子。其中最後一句列印輸出語句是對 Simple Inheritance實作類別繼承的最佳說明。
清單 6. 使用 Simple Inheritance 實作類別繼承
プロトタイプ、関数コンストラクター、クロージャー、およびコンテキストベースの this をすでによく理解している場合は、単純な継承がどのように機能するかを理解することはそれほど難しくありません。基本的に、ステートメント var Person = Class.extend(...) では、左側の Person は、実際には extend メソッドを呼び出す Class によって返されるコンストラクター、つまり関数オブジェクトへの参照を取得します。このアイデアに従って、単純な継承がどのようにこれを実行し、プロトタイプ継承からクラス継承への変換を実現するかを引き続き紹介します。図 2 は、Simple Inheritance のソース コードとそれに付随するコメントです。理解を容易にするために、コードを中国語で一行ずつ説明しています。
図 2. 単純な継承ソース コード分析
コードの 2 番目の部分を脇に置いて、最初と 3 番目の部分を全体として一貫して調べます。 extend 関数の基本的な目的は、新しいプロトタイプ プロパティを使用して新しいコンストラクターを構築することです。私たちは John Resig の見事な筆跡と、JS 言語の本質に対する彼の繊細な把握に感嘆せずにはいられません。 John Resig がこのような絶妙な実装方法をどのように思いついたのかについては、興味のある読者はこの記事 (参考資料) を読むことができます。この記事では、Simple Inheritance の初期設計の思考プロセスが詳しく説明されています。
JavaScript プライベート メンバーの実装 ここまでの説明で、オブジェクト指向 JavaScript にまだ懐疑的な人は、JavaScript がオブジェクト指向における情報の隠蔽、つまりプライベートとパブリックを実装していないのではないかという疑いがあるはずです。プライベート メンバーとパブリック メンバーを明示的に宣言する他のクラスベースのオブジェクト指向メソッドとは異なり、JavaScript の情報隠蔽はクロージャーによって実現されます。
リスト 7: を参照してください。 リスト 7. クロージャーを使用して情報の非表示を実装する