初識 JavaScript 對象的時候,我以為 JS 是沒有繼承這種說法的,雖說 JS 是一門面向對象語言,可是面向對象的一些特性在 JS 中並不存在(比如多態,不過嚴格來說也沒有繼承)。這就困惑了我很長的時間,當我學習到 JS 原型的時候,我才發現了 JS 的新世界。本篇文章講解了 JavaScr... ...
引言
初識 JavaScript 對象的時候,我以為 JS 是沒有繼承這種說法的,雖說 JS 是一門面向對象語言,可是面向對象的一些特性在 JS 中並不存在(比如多態,不過嚴格來說也沒有繼承)。這就困惑了我很長的時間,當我學習到 JS 原型的時候,我才發現了 JS 的新世界。本篇文章講解了 JavaScript new 操作符與對象的關係、原型和對象關聯(也就是俗稱的繼承)的原理,適合有一定基礎的同學閱讀。
一、JavaScript 的類與對象
許多書籍上都會說到如何在 JS 當中定義“類”,通常來講就是使用如下代碼:
1 function foo () { 2 this.x = 1; 3 this.y = 2; 4 } 5 var obj = new foo(); //{x:1, y:2}
實際上這一個很糟糕的語言機制,我們首先要明確,在 JS 當中根本沒有“類”這種東西。在瞭解它之前,我們要先來瞭解下 JS 的發展歷史。
JavaScript 隨著互聯網和瀏覽器而誕生,在早些年代,互聯網還比較貧乏,上網的成本也比較高,網速非常的慢,通常需要花很長的時間才能傳輸完一個純文本的 HTML 文件。所以那時候 Netscape 就提出,需要有一種解決方案,能使一些操作在客戶端進行而不需要通過伺服器處理,比如用戶在填寫郵箱的時候少寫了一個“@”,在客戶端就可以檢查出錯誤並提示用戶而不需要在伺服器進行解析,這樣就可以極大的降低通信操作帶來了延遲和帶寬消耗。而那時候,正巧 JAVA 問世,火的那叫個一塌糊塗,所以 Netscape 決定和 SUN 合作,在瀏覽器當中植入 JAVA 小程式(後來稱Java applet)。不過後來就這一方案產生了爭議,因為瀏覽器本來只需要很小的操作,而 JAVA 語言本身太“重”了,用來處理什麼表單驗證的問題實在是大材小用,所以決定開發一門新的語言來支持客戶端的輕量級操作,而又要借鑒 JAVA 的語法。於是乎 Netscape 開發出了一門新的輕量級語言,在語法方面偏向於 C 和 JAVA,在數據結構方面偏向於 JAVA,這門語言最初叫做 Mocha,後來經過多年的演變,變成了現在的 JavaScript。
故事說道這裡,好像和本文並沒有什麼關係...別急,馬上就要說道點子上了。這個語言為什麼要取名 JavaScript 呢,其實它和 JAVA 並沒有半毛錢的關係,只是因為在那點年代,面向對象方法問世才不久,所有的程式員都推崇學習面向對象方法,再加上 JAVA 的橫空出世和大力宣傳,只要和 JAVA 沾邊的東西就像是往臉上貼了金一樣,自帶光環。所以便藉助了 JAVA 的名氣來進行宣傳,不過光是嘴皮子宣傳還不行,因為面向對象方法的推崇,大家都習慣於面向對象的語法,也就是 new Class() 的方法編寫代碼。不過 JavaScript 語言本身並沒有類的概念,其是多種語言的大雜燴,為了更加貼合習慣了面向對象語法的程式員,於是 new 操作符誕生了。
好了,說了這麼大一堆故事,就是想告訴同學們,new 操作符在 JavaScript 當中本身就是一個充滿歧義的東西,它並不存在類的概念,只是貼合程式員習慣而已。那麼在 JavaScript 當中 new 操作符和對象究竟有什麼關係呢?思考下麵這一段代碼:
1 function foo () { 2 this.x = 1; 3 this.y = 2; 4 return { 5 z:3 6 } 7 } 8 var obj = new foo(); //{z:3}
咦?發生了什麼奇怪的事情,x 和 y 哪裡去了?實際上 new 操作符並不是傳統面向對象語言那樣,創建一個類的實例,new 操作符實際上只是在引擎內部幫我們在函數的開始創建好了一個對象,然後將函數的上下文綁定到這個對象上面,併在函數的末尾返回這個對象。這裡需要註意的問題是,如果我們手動的返回了一個對象,那麼按照函數執行機制,一旦返回了一個值,那麼該函數也就執行結束,後面的代碼將不會執行,所以說在剛纔的例子中我們得到的對象只是我們手動定義的對象,並不是引擎幫我們創建的對象。 new 操作符實際上類似於以下操作:
1 function foo () { 2 //新創建一個對象,將 this 綁定到該對象上 3 4 //在這裡編寫我們想要的代碼 5 6 //return this; 7 }
不過需要註意的是,new 操作符只接受 Object 類型的值,如果我們手動返回的是基本類型,則還是會返回 this :
1 function foo () { 2 this.x = 1; 3 this.y = 2; 4 return 0; 5 } 6 var obj = new foo(); //{x:1, y:2}
現在我們現在可以將 new 操作符定義成以下方法:
1 function newOpertor (cls, ...args) { 2 var obj = {}; 3 cls.apply(obj, args); 4 return obj; 5 } 6 7 function foo (x, y) { 8 this.x = x; 9 this.y = y; 10 } 11 12 var obj = newOpertor(foo, 1, 2); //{x:1, y:2}
二、對象的原型
JavaScript 中存在類似繼承的機制,但是又不是標準面向對象的繼承,在 JS 中使用的是原型的機制。要記住,在 JS 中只有對象,沒有類,對象的繼承是由原型來實現,籠統的來說可以這樣理解,一個對象是另一個對象的原型,那麼便可以把它比作父類,子類既然也就繼承了父類的屬性和方法。
1 function foo () { 2 this.x = 1; 3 this.y = 2; 4 } 5 6 foo.prototype.z = 3 7 8 var obj = new foo(); 9 console.log(obj.z); //3
[[prototype]] 是函數的一個屬性,這個屬性的值是一個對象,該對象是所有以該函數為構造器創造的對象的原型。可以把它近似的理解為父類對象,那麼相應的,子類自然會繼承父類的屬性和方法。不過為什麼要區分原型繼承和類繼承的概念呢?標準的面向對象方法,類是不具有實際記憶體空間,只是一個事物的抽象,對象才是事物的實體,而通過繼承得到的屬性和方法,同屬於該對象,不同的對象各自都擁有獨立的繼承而來的屬性。不過在 JavaScript 當中,由於沒有類的概念,一直都是對象,所以我們“繼承”的,是一個具有實際記憶體空間的對象,也是實體,也就是說,所有新創建的子對象,他們共用一個父對象(後面我統稱為原型),不會擁有獨立的屬性:
1 function foo () { 2 this.x = 1; 3 this.y = 2; 4 } 5 6 foo.prototype.z = 3 7 8 var obj1 = new foo(); 9 10 console.log(obj1.z); //3 11 12 foo.prototype.z = 2 13 14 console.log(obj1.z); //2
還記得我們之前所說的 new 操作符的原理嗎?new 操作符的本質不是實例化一個類,而是引擎貼合習慣了面向對象編程方法的程式員,所以說 [[prototype]] 屬性本質上也是 new 操作符的一個副產物。這個屬性只在函數上面有意義,該屬性定義了 new 操作符產生的對象的原型。除了 [[prototype]] 可以訪問到對象原型以外,還有一個非標準的方法,在每一個對象中都有一個 __proto__ 屬性,這個屬性直接關聯到了該對象的原型。這種方法沒有寫入 W3C 的標準規範,但是卻得到了瀏覽器的廣泛支持,許多瀏覽器都提供了該方法以供訪問對象的原型。(個人覺得 __proto__ 比 [[prototype]] 更能體現原型鏈的本質)
1 function foo () { 2 this.x = 1; 3 this.y = 2; 4 } 5 6 foo.prototype.z = 3 7 8 var obj1 = new foo(); 9 10 console.log(obj1.__proto__); //{z:3}
除了使用 new 操作符和函數的 [[prototype]] 屬性定義對象的原型之外,我們還可以直接在對象上顯示的通過 __proto__ 來定義,這種定義對象原型的方式更能夠體現出 JavaScript 語言的本質,更能夠使初學者理解原型鏈繼承的機制。
1 var father = {x:1}; 2 3 var child = { 4 y:2, 5 __proto__:father 6 }; 7 8 console.log(child.x); //1
現在我們來完成之前那個自定義 new 操作(如果你還不能理解這個函數,沒有關係,跳過它,這並不影響你接下來的學習):
1 function newOpertor (cls, ...args) { 2 var obj = Object.create(cls.prototype); 3 cls.apply(obj, args); 4 return obj; 5 } 6 7 function foo (x, y) { 8 this.x = x; 9 this.y = y; 10 } 11 12 foo.prototype.z = 3 13 14 var obj1 = newOpertor(foo, 1, 2) 15 16 console.log(obj1.z); //3
三、原型鏈
介紹完原型之後,同學們需要明確以下幾個概念:
- JavaScript 採用原型的機制實現繼承;
- 原型是一個具有實際空間的對象,所有關聯的子對象共用一個原型;
那麼 JavaScript 當中的原型是如何實現相互關聯的呢?JS 引擎又是如何查找這些關聯的屬性呢?如何實現多個對象的關聯形成一條原型鏈呢?
1 var obj1 = { 2 x:1 3 } 4 5 var obj2 = { 6 y:2, 7 __proto__:obj1 8 } 9 10 var obj3 = { 11 z:3, 12 __proto__:obj2 13 } 14 15 console.log(obj3.y); //2 16 console.log(obj3.x); //1
在上面這段代碼,我們可以看出,對象的原型可以實現多層級的關聯的操作,obj1 是 obj2 的原型, obj2 同時又是 obj3 的原型,這種多層級的原型關聯,就是我們常說的原型鏈。在訪問一個處於原型鏈當中的對象的屬性,會沿著原型鏈對象一直向上查找,我們可以把這種原型遍歷操作看成是一個單向的鏈表,每一個處於原型鏈的對象都是鏈表當中的一個節點,JS 引擎會沿著這條鏈表一層一層的向下查找屬性,如果找到了一個與之匹配的屬性名,則返回該屬性的值,如果在原型鏈的末端(也就是 Object.prototype)都沒有找到與之匹配的屬性,則返回 undefined。要註意這種查找方式只會返回第一個與之匹配的屬性,所以會發生屬性屏蔽:
1 var obj1 = { 2 x:1 3 } 4 5 var obj2 = { 6 x:2, 7 __proto__:obj1 8 } 9 10 var obj3 = { 11 x:3, 12 __proto__:obj2 13 } 14 15 console.log(obj3.x); //3
若要訪問原型的屬性,則需要一層的一層的先向上訪問原型對象:
1 console.log(obj3.__proto__.x); //2 2 console.log(obj3.__proto__.__proto__.x); //1
要註意的一點是,原型鏈的遍歷只會發生在 [[getter]] 操作上,也就是取值操作,也可以稱之為右查找(RHS)。相反,若是進行 [[setter]] 操作,也就是賦值操作,也可以稱作左查找(LHS),則不會遍歷原型鏈,這條原則保證了我們在對對象進行操作的時候不會影響到原型鏈:
1 var obj1 = { 2 x:1 3 } 4 5 var obj2 = { 6 __proto__:obj1 7 } 8 9 console.log(obj2.x); //1 10 11 obj2.x = 2; 12 13 console.log(obj2.x); //2 14 console.log(obj1.x); //1(並沒有發生變化)
在遍歷原型鏈中,如果訪問帶有 this 引用的方法,可能會發生令你意想不到的結果:
1 var obj1 = { 2 x:1, 3 foo: function () { 4 console.log(this.x); 5 } 6 } 7 8 var obj2 = { 9 x:2, 10 __proto__:obj1 11 } 12 13 obj2.foo(); //2
在上面的內容中,我們討論過,對象的原型相當於父類,我們可以繼承它所擁有的屬性和方法,所以在我們訪問 foo() 函數的時候時候,實際上調用該方法的對象是 obj2 而不是 obj1。關於更詳細的內容,需要瞭解 this 和上下文綁定,這不在本篇文章的討論範圍之內。
關於原型鏈的問題,大家需要理解的一點是,任何對象的原型鏈終點,都是 Object.prototype,可以把 Object 理解為所有對象的父類,類似於 JAVA 一樣,所以說所有對象都可以調用一些 Object.prototype 上面的方法,比如 Object.prototype.valueOf() 以及 Object.prototype.toString() 等等。所有的 string 類型,其原型為 String.prototype ,String.prototype 是一個對象,所以其原型也就是 Object.prototype。這就是我們為什麼能夠在一個 string 類型的值上調用一些方法,比如 String.prototype.concat() 等等。同理所有數組類型的值其原型是 Array.prototype,數字類型的值其原型是 Number.prototype:
1 console.log({}.__proto__ === Object.prototype); //true 2 3 console.log("hello".__proto__ === String.prototype); //true 4 5 console.log(1..__proto__ === Number.prototype); //true 6 //註意用字面量訪問數字類型方法時,第一個點預設是小數標誌 7 8 console.log([].__proto__ === Array.prototype); //true
理解了原型鏈的遍歷操作,我們現在就可以學習如何添加屬於自己的方法。我們現在知道了所有字元串的原型都是 String.prototype ,那麼我們可以對其進行修改來設置我們自己的內置方法:
1 String.prototype.foo = function () { 2 return this + " foo"; 3 } 4 5 console.log("bar".foo()); //bar foo
所以說,在處理一些瀏覽器相容性問題的時候,我們可以直接修改內置對象來相容一些舊瀏覽器不支持的方法,比如 String.prototype.trim() :
1 if (!String.prototype.trim) { 2 String.prototype.trim = function() { 3 return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, ''); 4 }; 5 }
不過需要註意,切忌隨意修改內置對象的原型方法,一是因為這會帶來額外的記憶體消耗,二是這可能會在系統中造成一些隱患,一般只是用來做瀏覽器相容的 polyfill 。
四、 有關原型的方法
for ... in 語句會遍歷原型鏈上所有可枚舉的屬性(關於屬性的可枚舉性質,可以參考 《JavaScript 常量定義》),有時我們在操作的時候需要忽略掉原型鏈上的屬性,只訪問該對象上的屬性,這時候我們可以使用 Object.prototype.hasOwnProperty() 方法來判斷屬性是否屬於原型屬性:
1 var obj1 = { 2 x:1, 3 } 4 5 var obj2 = { 6 y:2, 7 __proto__:obj1 8 } 9 10 for(var key in obj2){ 11 console.log(obj2[key]); //2, 1 12 } 13 14 for(var key in obj2){ 15 if(obj2.hasOwnProperty(key)){ 16 console.log(obj2[key]); //2 17 } 18 }
我們知道通過 new 操作符創建的對象可以通過 instanceof 關鍵字來查看對象的“類”:
1 function foo () {} 2 3 var obj = new foo(); 4 5 console.log(obj instanceof foo); //true
實際上這個操作也是不嚴謹的,我們現在已經知道了 new 操作符在 JavaScript 當中本是一個具有歧義設計,instanceof 操作符本身也是一個會讓人誤解的操作符,它並沒有實例這種說法,實際上這個操作符只是判斷了對象與函數原型的關聯性,也就是說其返回的是表達式 object.__proto__ === function.prototype 的值。
1 function foo () {} 2 3 var bar = { 4 x:1 5 } 6 7 foo.prototype = bar 8 9 var obj = { 10 __proto__: bar 11 } 12 13 console.log(obj instanceof foo); //true
在這一段代碼中,我們可以看出 obj 和 foo 並沒有任何關係,只是 obj 的原型和 foo.prototype 關聯到了同一個對象上面,所以其結果會返回 true。
不過對基本類型類型使用 instanceof 方法的話,可能會產生意外的結果:
1 console.log("1" instanceof String); //false 2 3 console.log(1 instanceof Number); //false 4 5 console.log(true instanceof Boolean); //false
但是我們同樣可以使用使用字面量調用原型的方法,這可能會讓人感到困惑,不過我們不用擔心它,並不是原型鏈出現什麼毛病,而是在對基本類型進行字面量操作的時候,會涉及到隱式轉換的問題。JS 引擎會先將字面量轉換成內置對象,然後在調用上面的方法,隱式轉換問題不在本文的討論範圍之類,大家可以參考 Kyle Simpson — 《你不知道的 JavaScript (中捲)》。
實際對象的 Object.prototype.isPrototypeOf() 方法更能體現出對象原型鏈的關係,此方法判斷一個對象是否是另一個對象的原型,不同於 instanceof 的是,此方法會遍歷原型鏈上所有的節點,若有匹配項則返回 true:
1 var obj1 = { 2 } 3 4 var obj2 = { 5 __proto__:obj1 6 } 7 8 var obj3 = { 9 __proto__:obj2 10 } 11 12 console.log(obj2.isPrototypeOf(obj3)); //true 13 console.log(obj1.isPrototypeOf(obj3)); //true 14 console.log(Object.prototype.isPrototypeOf(obj3)); //true
在 ES5 當中擁有標準方法 Object.getPrototypeOf() 可以供我們獲得一個對象的原型,在ES6 當中擁有新的方法 Object.setPrototypeOf() 可以設置一個對象的原型,不過在使用之前請先查看瀏覽器相容性。
1 var obj1 = { 2 x:1 3 } 4 5 var obj2 = { 6 y:2 7 } 8 9 Object.setPrototypeOf(obj2, obj1); 10 11 console.log(Object.getPrototypeOf(obj2) === obj1); //true
我們現在知道,通過 new 操作符創建的對象,其原型會關聯到函數的 [[prototype]] 上面,實際上這是一個很糟糕的寫法,一味的貼合面向對象風格的編程模式,使得很多人無法領域 JavaScript 當中的精髓。許多書籍都會寫到 JavaScript 中有許多奇怪的地方,然後教你如何避開這些地雷,實際上這不是一個好的做法,並不是因為 JavaScript 是一門稀奇古怪的語言,而是我們不願意去面對它的特性,正確的理解這些特性,才能讓我們寫出更加高效的程式。Object.create() 方法對於對象之間的關聯和原型鏈的機制更加清晰,比 new 操作符更加能夠理解 JavaScript 的繼承機制。該方法創建一個新對象,並使新對象的原型關聯到參數對象當中:
1 var obj1 = { 2 x:1 3 } 4 5 var obj2 = Object.create(obj1); 6 7 console.log(obj1.isPrototypeOf(obj2)); //true
不過使用的時候還需要註意瀏覽器的相容性,下麵給出 MDN 上面的 polyfill:
1 (function() { 2 if (typeof Object.create != 'function') { 3 Object.create = (function() { 4 function Temp() {} 5 var hasOwn = Object.prototype.hasOwnProperty; 6 return function(O) { 7 if (typeof O != 'object') { 8 throw TypeError('Object prototype may only be an Object or null'); 9 } 10 Temp.prototype = O; 11 var obj = new Temp(); 12 Temp.prototype = null; 13 if (arguments.length > 1) { 14 var Properties = Object(arguments[1]); 15 for (var prop in Properties) { 16 if (hasOwn.call(Properties, prop)) { 17 obj[prop] = Properties[prop]; 18 } 19 } 20 } 21 return obj; 22 }; 23 })(); 24 } 25 })();
關於 Object.create() 方法要註意的一點是,如果參數為 null 那麼會創建一個空鏈接的對象,由於這個對象沒有任何原型鏈,所以說它不具有任何原生的方法,也無法進行原型的判斷操作,這種特殊的對象常被稱作“字典”,它完全不會受原型鏈的干擾,所以說適合用來存儲數據:
1 var obj = Object.create(null); 2 obj.x = 1 3 4 var bar = Object.create(obj); 5 bar.y = 2; 6 7 console.log(Object.getPrototypeOf(obj)); //null 8 9 console.log(Object.prototype.isPrototypeOf(obj)); //false 10 11 console.log(obj instanceof Object); //false 12 13 console.log(bar.x); //1 14 15 obj.isPrototypeOf(bar); //TypeError: obj.isPrototypeOf is not a function 16 17 /** 18 * 註意由於對象沒有關聯到 Object.prototype 上面,所以無法調用原生方法,但這並不影響此對象的關聯操作。 19 */
總結
原型鏈是 JavaScript 當中非常重要的一點,同時也是比較難理解的一點,因為其與傳統的面向對象語言有著非常大的區別,但這是正是 JavaScript 這門語言的精髓所在,關於原型與原型鏈,我們需要知道以下這幾點:
- JavaScript 通過原型來實現繼承操作;
- 幾乎所有對象都有原型鏈,其末端是 Object.prototype;
- 原型鏈上的 [[getter]] 操作會遍歷整條原型鏈,[[setter]] 操作只會針對於當前對象;
- 我們可以通過修改原型鏈上的方法來添加我們想要的操作(最好不要這樣做);
關於 JavaScript 原型鏈,在一開始人們都稱為“繼承”,其實這是一種不嚴謹的說法,因為這不是標準的面向對象方法,不過初期人人常常這麼理解。現在我往往稱之為關聯和委托,關聯指的是一個對象關聯到另一個對象上,而委托則指的是一個對象可以調用另一個對象的方法。
本篇文章均為個人理解,如有不足或紕漏,歡迎在評論區指出。
參考文獻:
Kyle Simpson — 《你不知道的 JavaScript (上捲)》
MDN — Object - JavaScript | MDN
阮一峰 — JavaScript 語言的歷史