一般JavaScript支援物件導向的方式是基於原型,ES6之後有了模擬類別的語法,但時至今日,依舊有開發者對其戒慎恐懼,甚至提倡最好別使用,其實只要多觀察標準API的作法,深入掌握原型,類別語法也是一個不錯的工具選擇。

原型與特性描述器

每個函式實例都會有個prototype特性,若函式作為建構式使用,以new建構的物件,都會有個原型參考至prototype,對JavaScript開發者而言,這是基本常識,如果是ES5,可以使用Object.getPrototypeOf來取得實例的原型。由於存取物件的特性時,會先在實例本身尋找,若找不到的話,就會看看實例的原型物件上是否有該特性,因此,對於各個實例間共用的特性,可以定義在建構式的prototype。

constructor特性就是其中之一,它參考建構式本身,可作為判別實例類型的依據。物件可用的方法也是,如陣列實例的filter等方法,就是Array.prototyp.filter參考的函式,開發者也能自行在prototype定義特性,文件或書籍常見的方式就像:

Account.prototype.withdraw = function(money) {...};

而且,這種方式是可以運作的。但是,若觀察標準API,定義在prototype的特性,都是不可列舉的。畢竟在列舉物件特性時,通常只想列舉物件本身的特性,而不是原型上的特性。若要遵循這個慣例,應該善用特性描述器來定義,例如:

Object.defineProperty(Account.prototype, 'withdraw', {
value: function(money) {...},
writable: true, configurable: true
});

這邊使用了ES5的Object.defineProperty,呼叫函式時沒有設定emnuerable屬性,此時會是預設值false,而writable、configurable設為true,除了符合內建標準API的慣例,也保留了後續繼承時重新定義方法、修補API彈性等優點。

深入理解、掌握原型這件事,並不是只要知道原型查找機制就可以了,建議多觀察標準API的作法,別再忽略特性描述器。例如,你知道constructor的特性描述器中,三個屬性各是什麼嗎?

原型與原型鏈

除了使用Object.getPrototypeOf來取得實例的原型,實際上,我們也能使用__proto__,只不過名稱的底線,似乎暗示著這是非標準特性?在ES6以前,__proto__確實是非標準特性,不過,瀏覽器幾乎都支援這個特性,因此,ES6之後,規範ECMAScript的實作必須支援__proto__,所以,__proto__等同於標準了,而且這個特性是可以修改的!

但這是個很強大,也很危險的功能。若arrayLike是個類陣列,透過arrayLike.__proto__ = Array.prototype,可將類陣列變得更像陣列(但終究不是陣列,length特性不會自動隨著索引增減而變更),此時連instanceof都可以騙過,這是因為嚴格來說,obj instanceof Constructor這種語法,是用來確認可否在obj的原型鏈上,找到Constructor.prototype。

原型鏈查找其實是很麻煩的一件事,然而,有了__proto__,再配合除錯器的話,事情就會變得簡單許多。例如,若lt參考自訂的ImmutableList實例,lt.toString()呼叫方法時,lt本身沒有,就看看lt__proto__上有沒有,若還是沒有,就可以查看lt.__proto__.__proto__上有沒有...

雖然__proto__可以修改,然而非必要就不要修改,而且,在ES6之前,它不是標準特性。在ES5中,倒是有個Object.create函式會建立新物件,物件的原型會被設為呼叫Object.create時指定的物件,例如,在ES5時,就可以如下建立一個類陣列:

let arrayLike = Object.create(Array.prototype, {
...特性描述
});

Object.create也可以運用在實作繼承,卻經常被忽略,許多文件或書籍實作原型繼承時,經常會使用B.prototype = new A()的形式,然而,因為new實際上會執行A建構式定義的流程,被建構的實例自身會有些特性(即使特性值可能是undefined),為了避免在for..in等情況下列舉這些特性,務必使用delete將之刪除,若使用Object.create,就不用做這類處理:

B.prototype = Object.create(A.prototype, {
constructor: { // 記得設定constructor,以符合標準API做法
value: B, writable: true, configurable: true
}
});

原型與類別

原型鏈是極具彈性的機制,運用得當,可達到不少基於類別的物件導向語言無法做到之事;然而,彈性的另一面就是不易掌握,而且就算掌握了,寫來也囉嗦而容易出錯,因此,從ES6開始,提供了標準的類別語法,用來模擬基於類別的物件導向。

其實,若能掌握原型鏈機制,經常觀察標準API的作法,善用Object.defineProperty與特性描述器,使用Object.create來實作原型鏈,在運用類別語法時,就會覺得方便而沒有疑惑。例如,在類別中定義的方法都是不可列舉的,繼承時,也會自動設定constructor等相關特性等,這些大致上都可以對照至基於原型的寫法。

不過,ES6提供的類別語法,終究就只是模擬類別,本質上每個類別仍是個函式實例。例如class A {},A.__proto__就是Function.prototype,A是個函式;如果class B extends A,B.__proto__就是A,因此B.__proto__.__proto__就是Function.prototype,也就是說B也是函式。

在ES6之後,並沒有為類別創建一個類型。以範例中的原型鏈來看,每個類別都是Function的實例;然而,就方才描述來看,class A {}不就不等於class A extends Object {}?前者的A.__proto__是Function.prototype,後者卻是Object?沒錯!extends語法嚴格來說,在底層「本來就不是繼承類別」,當class C extends P {}時,其實是將C.prototype.__photo__設為P.prototype!

在class A {}的情況下,A.prototype.__proto__是Object.prototype,而class A extends Object {}時也是,從原型來看沒有矛盾,或者應該說「類別」這名詞只是個晃子,底層都是Function實例,「extends實際上還是在處理原型」,一切都還是遵循著原型機制。

就這點來看,class A extends null {}也是可行的了,A.prototype.__proto__就會是null,如此的結果並不讓人感到意外,畢竟使用Object.create時,也可以指定null,令傳回的物件不具備原型。

掌握原型的意義是?

掌握原型是必要的,然而開發者真的掌握原型了嗎?倡議著不要使用類別語法的同時,寫出來的東西若與標準API慣例相去甚遠,程式碼也混亂不堪的話,不如還是使用類別語法吧!至少類別本身就遵循著標準與慣例,而且,在程式碼的撰寫與閱讀上更清楚易懂。

當然,類別語法只是模擬,JavaScript本質上還是基於原型,在類別語法不如人意,覺得其行為詭異,或無法滿足需求時,回歸基於原型的思考方式,往往就能理解其行為何以如此,也能進一步採取適當的措施,令程式碼在可以滿足需求的同時,同時兼顧日後的可維護性。

作者簡介


Advertisement

更多 iThome相關內容