JavaScript物件:我們真的需要模擬類嗎?

weixin_34253539發表於2019-02-27

早期的JavaScript程式設計師一般都有過使用JavaScript“模擬物件導向程式設計”的經歷,不過,我在「JavaScript物件:我們真的需要模擬類嗎?」中提到JavaScript本身就是物件導向的,它並不需要模擬,只是它實現物件導向的方式和主流的流派不太一樣,所以才讓很多人產生了誤會。

那麼,接著按照我們理解的思路繼續深入,這些“模擬物件導向”,實際上做的事情就是“模擬基於類的物件導向”。

儘管我認為,“類”並非物件導向的全部,但我們不應該責備社群出現這樣的方案,事實上,因為一些公司政治原因,JavaScript推出之時,管理層就要求它去模仿Java,所以,JavaScript創始人Brendan Eich在“原型執行時”的基礎上引入了new、this等語言特性,使之“看起來更像Java”,而Java正是基於類的物件導向的代表語言之一。

但是JavaScript這樣的半吊子模擬,缺少了繼承等關鍵特性,導致大家試圖對它進行修補,進而產生了種種互不相容的解決方案。

慶幸的是,從ES6開始,JavaScript提供了class關鍵字來定義類,儘管,這樣的方案仍然是基於原型執行時系統的模擬,但是它修正了之前的一些常見的“坑”,統一了社群的方案,這對語言的發展有著非常大的好處。

實際上,我認為“基於類”並非物件導向的唯一形態,如果我們把視線從“類”移開,Brendan當年選擇的原型系統,就是一個非常優秀的抽象物件的形式。

我們從頭講起。

什麼是原型?

原型是順應人類自然思維的產物。中文中有個成語叫做“照貓畫虎”,這裡的貓看起來就是虎的原型,所以由此,我們可以看出,用原型來描述物件的方法可以說是古已有之。

我在「JavaScript物件:我們真的需要模擬類嗎?」講解物件導向的時候提到了:在不同的程式語言中,設計者也利用各種不同的語言特性來抽象描述物件。

最為成功的流派是使用“類”的方式來描述物件,這誕生了諸如 C++、Java等流行的程式語言。這個流派叫做基於類的程式語言。

還有一種則就是基於原型的程式語言,它們利用原型來描述物件。我們的JavaScript就是其中代表。

“基於類”的程式設計提倡使用一個關注分類和類之間關係開發模型。在這類語言中,總是先有類,再從類去例項化一個物件。類與類之間又可能會形成繼承、組合等關係。類又往往與語言的型別系統整合,形成一定的編譯時能力。

與此相對,“基於原型”的程式設計看起來更為提倡程式設計師去關注一系列物件例項的行為,而後才去關心如何將這些物件,劃分到最近的使用方式相似的原型物件,而不是將它們分成類。基於原型的物件導向系統通過“複製”的方式來建立新物件。一些語言的實現中,還允許複製一個空物件。這實際上就是建立一個全新的物件。

基於原型和基於類都能夠滿足基本的複用和抽象需求,但是適用的場景不太相同。

這就像專業人士可能喜歡在看到老虎的時候,喜歡用貓科豹屬豹亞種來描述它,但是對一些不那麼正式的場合,“大貓”可能更為接近直觀的感受一些。(插播一個冷知識:比起老虎來,美洲獅在歷史上相當長時間都被劃分為貓科貓屬,所以性格也跟貓更相似,比較親人)

我們的JavaScript 並非第一個使用原型的語言,在它之前,self、kevo等語言已經開始使用原型來描述物件了,事實上,Brendan更是曾透露過,他最初的構想是一個擁有基於原型的物件導向能力的scheme語言(但是函式式的部分是另外的故事,這篇文章裡,我暫時不做詳細講述)。

在JavaScript之前,原型系統就更多與高動態性語言配合,並且多數基於原型的語言提倡執行時的原型修改,我想,這應該是Brendan選擇原型系統很重要的理由。

原型系統的複製操作,有兩種實現思路,一個是並不真的去複製一個原型物件,而是使得新物件持有一個原型的引用,另一個是切實地複製物件,從此兩個物件再無關聯。歷史上的基於原型語言因此產生了兩個流派,顯然,JavaScript顯然選擇了前一種方式。

JavaScript的原型

如果我們拋開JavaScript用於模擬Java類的複雜語法設施(如new、Function Object、函式的prototype屬性等),原型系統可以說相當簡單,我可以用兩條概括:

  • 如果所有物件都有私有欄位[[prototype]],就是物件的原型;
  • 讀一個屬性,如果物件本身沒有,則會繼續訪問物件的原型,直到原型為空或者找到為止。

這個模型在ES的各個歷史版本中並沒有很大改變,但從 ES6 以來,JavaScript提供了一系列內建函式,以便更為直接地訪問操縱原型。三個方法分別為:

  • Object.create 根據指定的原型建立新物件,原型可以是null;
  • Object.getPrototypeOf 獲得一個物件的原型;
  • Object.setPrototypeOf 設定一個物件的原型。

利用這三個方法,我們可以完全拋開類的思維,利用原型來實現抽象和複用。我用下面的程式碼展示了用原型來抽象貓和虎的例子。

var cat = {    say(){        console.log(\u0026quot;meow~\u0026quot;);    },    jump(){        console.log(\u0026quot;jump\u0026quot;);    }}var tiger = Object.create(cat,  {    say:{        writable:true,        configurable:true,        enumerable:true,        value:function(){            console.log(\u0026quot;roar!\u0026quot;);        }    }})var anotherCat = Object.create(cat);anotherCat.say();var anotherTiger = Object.create(tiger);anotherTiger.say();

這段程式碼建立了一個“貓”物件,又根據貓做了一些修改建立了虎,之後我們完全可以用Object.create來建立另外的貓和虎物件,我們可以通過“原始貓物件”和“原始虎物件”來控制所有貓和虎的行為。

但是,在更早的版本中,程式設計師只能通過Java類風格的介面來操縱原型執行時,可以說非常彆扭。考慮到new和prototype屬性等基礎設施今天仍然有效,而且被很多程式碼使用,學習這些知識也有助於我們理解執行時的原型工作原理,下面我們試著回到過去,追溯一下早年的JavaScript中的原型和類。

早期版本中的類與原型

在早期版本的JavaScript中,“類”的定義是一個私有屬性 [[class]],語言標準為內建型別諸如Number、String、Date等指定了[[class]]屬性,以表示它們的類。語言使用者唯一可以訪問[[class]]屬性的方式是Object.prototype.toString。

以下程式碼展示了所有具有內建class屬性的物件:

    var o = new Object;    var n = new Number;    var s = new String;    var b = new Boolean;    var d = new Date;    var arg = function(){ return arguments }();    var r = new RegExp;    var f = new Function;    var arr = new Array;    var e = new Error;    console.log([o, n, s, b, d, arg, r, f, arr, e].map(v =\u0026gt; Object.prototype.toString.call(v))); 

因此,在ES3和之前,JS中類的概念是相當弱的,它僅僅是執行時的一個字串屬性。

在ES5開始,[[class]] 私有屬性被 Symbol.toStringTag 代替,Object.prototype.toString 的意義從命名上不再跟 class 相關。我們甚至可以自定義 Object.prototype.toString 的行為,以下程式碼展示了使用Symbol.toStringTag來自定義 Object.prototype.toString 的行為:

    var o = { [Symbol.toStringTag]: \u0026quot;MyObject\u0026quot; }    console.log(o + \u0026quot;\u0026quot;);

這裡建立了一個新物件,並且給它唯一的一個屬性 Symbol.toStringTag,我們用字串加法觸發了Object.prototype.toString的呼叫,發現這個屬性最終對Object.prototype.toString 的結果產生了影響。

但是,考慮到JS語法中跟Java相似的部分,我們對類的討論不能用“new運算是針對構造器物件而不是類”來試圖迴避。所以,我們仍然要把new理解成JavaScript物件導向的一部分,下面我就來講一下new操作具體做了哪些事情。

new 運算接受一個構造器和一組呼叫引數,實際上做了幾件事:

  • 以構造器的 prototype 屬性(注意與私有欄位[[prototype]]的區分)為原型,建立新物件;
  • 將 this 和呼叫引數傳給構造器,執行;
  • 如果構造器返回的是物件,則返回,否則返回第一步建立的物件。

new 這樣的行為,試圖讓函式物件的語法跟類變得相似,但是,它客觀上提供了兩種方式,一是在構造器中新增屬性,二是在構造器的 prototype 屬性上新增屬性。

下面程式碼展示了用構造器模擬類的兩種方法:

function c1(){    this.p1 = 1;    this.p2 = function(){        console.log(this.p1);    }} var o1 = new c1;o1.p2();function c2(){}c2.prototype.p1 = 1;c2.prototype.p2 = function(){    console.log(this.p1);}var o2 = new c2;o2.p2();

第一種方法是直接在構造器中修改this,給this新增屬性。

第二種方法是修改構造器的prototype屬性指向的物件,它是從這個構造器構造出來的所有物件的原型。

在沒有Object.create、Object.setPrototypeOf 的早期版本中,new 運算是唯一一個可以指定[[prototype]]的方法(當時的mozilla提供了私有屬性__proto__,但是多數環境並不支援),所以,當時已經有人試圖用它來代替後來的 Object.create,我們甚至可以用它來實現一個Object.create的不完整的pollyfill,見以下程式碼:

Object.create = function(prototype){    var cls = function(){}    cls.prototype = prototype;    return new cls;}

這段程式碼建立了一個空函式作為類,並把傳入的原型掛在了它的prototype,最後建立了一個它的例項,根據new的行為,這將產生一個以傳入的第一個引數為原型的物件。

這個函式無法做到與原生的Object.create一致,一個是不支援第二個引數,另一個是不支援null作為原型,所以放到今天意義已經不大了。

ES6 中的類

好在ES6中加入了新特性class,new跟function搭配的怪異行為終於可以退休了(雖然執行時沒有改變),在任何場景,我都推薦使用ES6的語法來定義類,而令function迴歸原本的函式語義。下面我們就來看一下ES6中的類。

ES6中引入了class關鍵字,並且在標準中刪除了所有[[class]]相關的私有屬性描述,類的概念正式從屬性升級成語言的基礎設施,從此,基於類的程式設計方式成為了JavaScript的官方程式設計正規化。

我們先看下類的基本寫法:

class Rectangle {  constructor(height, width) {    this.height = height;    this.width = width;  }  // Getter  get area() {    return this.calcArea();  }  // Method  calcArea() {    return this.height * this.width;  }}

在現有的類語法中,getter/setter和method是相容性最好的。

我們通過get/set關鍵字來建立getter,通過括號和大括號來建立方法,資料型成員最好寫在構造器裡面。

類的寫法實際上也是由原型執行時來承載的,邏輯上JavaScript認為每個類是有共同原型的一組物件,類中定義的方法和屬性則會被寫在原型物件之上。

此外,最重要的是,類提供了繼承能力。我們來看一下下面的程式碼。

class Animal {   constructor(name) {    this.name = name;  }    speak() {    console.log(this.name + ' makes a noise.');  }}class Dog extends Animal {  constructor(name) {    super(name); // call the super class constructor and pass in the name parameter  }  speak() {    console.log(this.name + ' barks.');  }}let d = new Dog('Mitzie');d.speak(); // Mitzie barks.

以上程式碼創造了Animal類,並且通過entends關鍵字讓Dog繼承了它,展示了最終呼叫子類的speak方法獲取了父類的name。

比起早期的原型模擬方式,使用extends關鍵字自動設定了constructor,並且會自動呼叫父類的建構函式,這是一種更少坑的設計。

所以當我們使用類的思想來設計程式碼時,應該儘量使用class來宣告類,而不是用舊語法,拿函式來模擬物件。一些激進的觀點認為,class關鍵字和箭頭運算子可以完全替代舊的function關鍵字,它更明確地區分了定義函式和定義類兩種意圖,我認為這是有一定道理的。

原文地址:JavaScript物件:我們真的需要模擬類嗎?

推薦閱讀:

明確你的前端學習路線與方法

列一份前端知識架構圖

相關文章