天天看點

使用 ES6 編寫更好的 JavaScript Part II:深入探究 [類]

<b>本文講的是使用 ES6 編寫更好的 JavaScript Part II:深入探究 [類],</b>

<b></b>

在本文的開始,我們要說明一件事:

從本質上說,ES6 的 classes 主要是給建立老式構造函數提供了一種更加友善的文法,并不是什麼新魔法 —— Axel Rauschmayer,Exploring ES6 作者

從功能上來講,<code>class</code> 聲明就是一個文法糖,它隻是比我們之前一直使用的基于原型的行為委托功能更強大一點。本文将從新文法與原型的關系入手,仔細研究 ES2015 的 <code>class</code> 關鍵字。文中将提及以下内容:

定義與執行個體化類;

使用 <code>extends</code> 建立子類;

子類中 <code>super</code> 語句的調用;

以及重要的标記方法(symbol method)的例子。

在此過程中,我們将特别注意 <code>class</code> 聲明文法從本質上是如何映射到基于原型代碼的。

讓我們從頭開始說起。

JavaScript 的『類』與 Java、Python 或者其他你可能用過的面向對象語言中的類不同。其實後者可能稱作面向『類』的語言更為準确一些。

在傳統的面向類的語言中,我們建立的類是對象的模闆。需要一個新對象時,我們執行個體化這個類,這一步操作告訴語言引擎将這個類的方法和屬性複制到一個新實體上,這個實體稱作執行個體。執行個體是我們自己的對象,且在執行個體化之後與父類毫無内在聯系。

而 JavaScript 沒有這樣的複制機制。在 JavaScript 中『執行個體化』一個類建立了一個新對象,但這個新對象卻不獨立于它的父類。

正相反,它建立了一個與原型相連接配接的對象。即使是在執行個體化之後,對于原型的修改也會傳遞到執行個體化的新對象去。

原型本身就是一個無比強大的設計模式。有許多使用了原型的技術模仿了傳統類的機制,<code>class</code> 便為這些技術提供了簡潔的文法。

總而言之:

JavaScript 不存在 Java 和其他面向對象語言中的類概念;

JavaScript 的 <code>class</code> 很大程度上隻是原型繼承的文法糖,與傳統的類繼承有很大的不同。

搞清楚這些之後,讓我們先看一下 <code>class</code>。

我們使用 <code>class</code> 關鍵字建立類,關鍵字之後是變量辨別符,最後是一個稱作類主體的代碼塊。這種寫法稱作類的聲明。沒有使用 <code>extends</code> 關鍵字的類聲明被稱作基類:

需要注意到以下事情:

類隻能包含方法定義,不能有資料屬性;

與建立對象不同,我們不能在類主體中使用逗号分隔方法定義;

我們可以在執行個體化對象上直接引用類的屬性(如 LINE A)。

類有一個獨有的特性,就是 contructor 構造方法。在構造方法中我們可以初始化對象的屬性。

構造方法的定義并不是必須的。如果不寫構造方法,引擎會為我們插入一個空的構造方法:

将一個類指派給一個變量的形式叫類表達式,這種寫法可以替代上面的文法形式:

使用 <code>extends</code> 建立的類被稱作子類,或派生類。這一用法簡單明了,我們直接在上面的例子中建構:

派生類擁有我們上文讨論的一切有關基類的特性,另外還有如下幾點新特點:

如果你的派生類需要引用它的父類,可以使用 <code>super</code> 關鍵字。

一個派生類不能有一個空的構造函數。即使這個構造函數就是調用了一下 <code>super()</code>,你也得把它顯式的寫出來。但派生類卻可以沒有構造函數。

在派生類的構造函數中,必須先調用 <code>super</code>,才能使用 <code>this</code> 關鍵字(譯者注:僅在構造函數中是這樣,在其他方法中可以直接使用 <code>this</code>)。

在 JavaScript 中僅有兩個 <code>super</code> 關鍵字的使用場景:

在子類構造函數中調用。如果初始化派生類是需要使用父類的構造函數,我們可以在子類的構造函數中調用<code>super(parentConstructorParams)</code>,傳遞任意需要的參數。

引用父類的方法。在正常方法定義中,派生類可以使用點運算符來引用父類的方法:<code>super.methodName</code>。

我們的 <code>FatFreeFood</code> 示範了這兩種情況:

在構造函數中,我們簡單的調用了 <code>super</code>,并将脂肪的量傳入為 <code>0</code>。

在我們的 <code>print</code> 方法中,我們先調用了 <code>super.print</code>,之後才添加了其他的邏輯。

不管你信不信,我反正是信了以上說的已涵蓋了有關 <code>class</code> 的基礎文法,這就是你開始實驗需要掌握的全部内容。

現在我們開始關注 <code>class</code> 是怎麼映射到 JavaScript 内部的原型機制的。我們會關注以下幾點:

使用構造調用建立對象;

原型連接配接的本質;

屬性和方法委托;

使用原型模拟類。

構造函數不是什麼新鮮玩意兒。使用 <code>new</code> 關鍵字調用任意函數會使其傳回一個對象 —— 這一步稱作建立了一個構造調用,這種函數通常被稱作構造器:

當我們使用 <code>new</code> 關鍵字調用函數時,JS 内部執行了下面四個步驟:

建立一個新對象(這裡稱它為 O);

給 O 賦予一個連接配接到其他對象的連結,稱為原型;

将函數的 <code>this</code> 引用指向 O;

函數隐式傳回 O。

在第三步和第四步之間,引擎會執行你函數中的具體邏輯。

知道了這一點,我們就可以重寫 <code>Food</code> 方法,使之不用 <code>new</code> 關鍵字也能工作:

四步中的三步都是簡單明了的。建立一個對象、指派屬性、然後寫一個 <code>return</code> 聲明,這些操作對大多數開發者來說沒有了解上的問題——然而這就是難倒衆人的黑魔法原型。

在通常情況下,JavaScript 中的包括函數在内的所有對象都會連結到另一個對象上,這就是原型。

如果我們通路一個對象本身沒有的屬性,JavaScript 就會在對象的原型上檢查該屬性。換句話說,如果你對一個對象請求它沒有的屬性,它會對你說:『這個我不知道,問我的原型吧』。

在另一個對象上查找不存在屬性的過程稱作委托。

盡管我們的 <code>toString</code> 的輸出完全沒啥用,但請注意:這段代碼沒有引起任何的 <code>ReferenceError</code>!這是因為盡管 <code>joe</code> 和<code>sara</code> 沒有 <code>toString</code> 的屬性,但他們的原型有啊。

當我們尋找 <code>sara.toString()</code> 方法時,<code>sara</code> 說:『我沒有 <code>toString</code> 屬性,找我的原型吧』。正如上文所說,JavaScript 會親切的詢問 <code>Object.prototype</code> 是否含有 <code>toString</code> 屬性。由于原型上有這一屬性,JS 就會把 <code>Object.prototype</code> 上的<code>toString</code> 傳回給我們程式并執行。

<code>sara</code> 本身沒有屬性沒關系——我們會把查找操作委托到原型上。

換言之,我們就可以通路到對象上并不存在的屬性,隻要其的原型上有這些屬性。我們可以利用這一點将屬性和方法指派到對象的原型上,然後我們就可以調用這些屬性,好像它們真的存在在那個對象上一樣。

更給力的是,如果幾個對象共享相同的原型——正如上面的 <code>joe</code> 和 <code>sara</code> 的例子一樣——當我們給原型指派屬性之後,它們就都可以通路了,無需将這些屬性單獨拷貝到每一個對象上。

這就是為何大家把它稱作原型繼承——如果我的對象沒有,但對象的原型有,那我的對象也能繼承這個屬性。

事實上,這裡并沒有發生什麼『繼承』。在面向類的語言裡,繼承指從父類複制屬性到子類的行為。在 JavaScript 裡,沒發生這種複制的操作,事實上這就是原型繼承與類繼承相比的一個主要優勢。

在我們探究原型究竟是怎麼來的之前,我們先做一個簡要回顧:

<code>joe</code> 和 <code>sara</code> 沒有『繼承』一個 <code>toString</code> 的屬性;

<code>joe</code> 和 <code>sara</code> 實際上根本沒有從 <code>Object.prototype</code> 上『繼承』;

<code>joe</code> 和 <code>sara</code> 是連結到了 <code>Object.prototype</code> 上;

<code>joe</code> 和 <code>sara</code> 連結到了同一個 <code>Object.prototype</code> 上。

如果想找到一個對象的(我們稱它作O)原型,我們可以使用 <code>Object.getPrototypeof(O)</code>。

然後我們再強調一遍:對象沒有『繼承自』他們的原型。他們隻是委托到原型上。

以上。

接下來讓我們深入一下。

我們已了解到基本上每個對象(下文以 O 指代)都有原型(下文以 P 指代),然後當我們查找 O 上沒有的屬性,JavaScript 引擎就會在 P 上尋找這個屬性。

至此我們有兩個問題:

以上情況函數怎麼玩?

這些原型是從哪裡來的?

換句話說,<code>Object</code> 和 <code>Object.prototype</code> 在任意執行中的 JavaScript 程式中永遠存在。

這個 <code>Object</code> 乍一看好像和其他函數沒什麼差別,但特别之處在于它是一個構造器——在調用它時傳回一個新對象:

這個 <code>Object.prototype</code> 對象是個……對象。正如其他對象一樣,它有屬性。

<a href="https://camo.githubusercontent.com/1d873f9d4423d590ca8852b69b2327da1a211866/68747470733a2f2f692e696d67736166652e6f72672f656262643565332e706e67" target="_blank"></a>

關于 <code>Object</code> 和 <code>Object.prototype</code> 你需要知道以下幾點:

<code>Object</code> 函數有一個叫做 <code>.prototype</code> 的屬性,指向一個對象(<code>Object.prototype</code>);

<code>Object.prototype</code> 對象有一個叫做 <code>.constructor</code> 的屬性,指向一個函數(<code>Object</code>)。

實際上,這個總體方案對于 JavaScript 中的所有函數都是适用的。當我們建立一個函數——下文稱作 <code>someFunction</code>——這個函數就會有一個屬性 <code>.prototype</code>,指向一個叫做 <code>someFunction.prototype</code> 的對象。

與之相反,<code>someFunction.prototype</code> 對象會有一個叫做 <code>.contructor</code> 的屬性,它的引用指回函數 <code>someFunction</code>。

需要記住以下幾個要點:

所有的函數都有一個屬性,叫做 <code>.prototype</code>,它指向這個函數的關聯對象。

所有函數的原型都有一個屬性,叫做 <code>.constructor</code>,它指向這個函數本身。

一個函數原型的 <code>.constructor</code> 并非必須指向建立這個函數原型的函數……有點繞,我們等下會深入探讨一下。

設定函數的原型有一些規則,在開始之前,我們先概括設定對象原型的三個規則:

『預設』規則;

使用 <code>new</code> 隐式設定原型;

使用 <code>Object.create</code> 顯式設定原型。

考慮下這段代碼:

十分簡單,我們做的事兒就是建立一個叫 <code>foo</code> 的對象,然後給他一個叫 <code>status</code> 的屬性。

然後 JavaScript 在幕後多做了點工作。當我們在字面上建立一個對象時,JavaScript 将對象的原型指向 <code>Object.prototype</code> 并設定其原型的 <code>.constructor</code> 指向 <code>Object</code>:

讓我們再看下之前調整過的 <code>Food</code> 例子。

現在我們知道函數 <code>Food</code> 将會與一個叫做 <code>Food.prototype</code> 的對象關聯。

當我們使用 <code>new</code> 關鍵字建立一個對象,JavaScript 将會:

設定這個對象的原型指向我們使用 <code>new</code> 調用的函數的 <code>.prototype</code> 屬性;

設定這個對象的 <code>.constructor</code> 指向我們使用 <code>new</code> 調用到的構造函數。

這就可以讓我們搞出下面這樣的黑魔法:

最後我們可以使用 <code>Object.create</code> 方法手工設定對象的原型引用。

還記得使用 <code>new</code> 調用函數的時候,JavaScript 在幕後幹了哪四件事兒嗎?<code>Object.create</code> 就幹了這三件事兒:

建立一個新對象;

設定它的原型引用;

傳回這個新對象。

直接使用原型來模拟面向類的行為需要一些技巧。

在 Line A,我們需要設定 <code>FatFreeFood.prototype</code> 使之等于一個新對象,這個新對象的原型引用是 <code>Food.prototype</code>。如果沒這麼搞,我們的子類就不能通路『超類』的方法。

讓開發者從使用原型對類行為笨拙的模仿中脫離苦海是 <code>class</code> 關鍵字的産生動機之一。它确實也提供了避免原型文法常見陷阱的解決方案。

現在我們已經探究了太多關于 JavaScript 的原型機制,你應該更容易了解 class 關鍵字讓一切變得多麼簡單了吧!

現在我們已了解到 JavaScript 原型系統的必要性,我們将深入探究一下類支援的三種方法,以及一種特殊情況,以結束本文的讨論。

構造器;

靜态方法;

原型方法;

一種原型方法的特殊情況:『标記方法』。

一個類的 <code>constructor</code> 方法用于關注我們的初始化邏輯,<code>constructor</code> 方法有以下幾個特殊點:

隻有在構造方法裡,我們才可以調用父類的構造器;

它在背後處理了所有設定原型鍊的工作;

它被用作類的定義。

第二點就是在 JavaScript 中使用 <code>class</code> 的一個主要好處,我們來引用一下《探索 ES6》書裡的 15.2.3.1 的标題:

子類的原型就是超類

正如我們所見,手工設定非常繁瑣且容易出錯。如果我們使用 <code>class</code> 關鍵字,JavaScript 在内部會負責搞定這些設定,這一點也是使用 <code>class</code> 的優勢。

第三點有點意思。在 JavaScript 中類僅僅是個函數——它等同于與類中的 <code>constructor</code> 方法。

與一般把函數作為構造器的方式不同,我們不能不用 <code>new</code> 關鍵字而直接調用類構造器:

<code>const burrito = Food('Heaven', 100, 100, 25); // 類型錯誤</code>

這就引發了另一個問題:當我們不用 <code>new</code> 調用函數構造器的時候發生了什麼?

簡短的回答是:對于任何沒有顯式傳回的函數來說都是傳回 <code>undefined</code>。我們隻需要相信用我們構造函數的使用者都會使用構造調用。這就是社群為何約定構造方法的首字母大寫:提醒使用者要用 <code>new</code> 來調用。

長一點的回答是:傳回 <code>undefined</code>,除非你手工檢測是否使用被 <code>new</code> 調用,然後進行自己的處理。

<code>new.target</code> 是一個定義在所有使用 <code>new</code> 調用的函數上的屬性,包括類構造器。 當我們使用 <code>new</code> 關鍵字調用函數時,函數體内的 <code>new.target</code> 的值就是這個函數本身。如果函數沒有被 <code>new</code> 調用,這個值就是 <code>undefined</code>。

在 ES5 裡用起來也還行:

靜态方法是構造方法自己的方法,不能被類的執行個體化對象調用。我們使用 <code>static</code> 關鍵字定義靜态方法。

靜态方法與老式構造函數中直接屬性指派相似:

任何不是構造方法和靜态方法的方法都是原型方法。之是以叫原型方法,是因為我們之前通過給構造函數的原型上附加方法的方式來實作這一功能。

應該說明,在方法定義時完全可以使用生成器。

最後我們說說标志方法。這是一些名為 <code>Symbol</code> 值的方法,當我們在自定義對象中使用内置構造器時,JavaScript 引擎可以識别并使用這些方法。

Symbol 是一個唯一且不變的資料類型,可以作為一個對象的屬性标示符。

對我們來講更有意思的是,這給我們提供了一種方式來告訴 JavaScript 引擎使用特定方法來達到特定的目的。

這對于 JavaScript 來說有點怪異,我們還是看個例子吧:

當你使用 <code>for...of</code> 周遊一個對象時,JavaScript 将會嘗試執行對象的疊代器方法,這一方法就是該對象 <code>Symbol.iterator</code>屬性上關聯的方法。如果我們提供了自己的方法定義,JavaScript 就會使用我們自定義的。如果沒有自己制定的話,如果有預設的實作就用預設的,沒有的話就不執行。

<code>Symbo.species</code> 更奇異了。在自定義的類中,預設的 <code>Symbol.species</code> 函數就是類的構造函數。當我們的子類有内置的集合(例如 <code>Array</code> 和 <code>Set</code>)時,我們通常希望在使用父類的執行個體時也能使用子類。

通過方法傳回父類的執行個體而不是派生類的執行個體,使我們更能確定我們子類在大多數代碼裡的可用性。而 <code>Symbol.species</code> 可以實作這一功能。

如果不怎麼需要這個功能就别費力去搞了。Symbol 的這種用法——或者說有關 Symbol 的全部用法——都還比較罕見。這些例子隻是為了示範:

我們可以在自定義類中使用 JavaScript 内置的特定構造器;

用兩個普通的例子展示了怎麼實作這一點。

ES2015 的 <code>class</code> 關鍵字沒有帶給我們 Java 裡或是 SmallTalk 裡那種『真正的類』。甯可說它隻是提供了一種更加友善的文法來建立通過原型關聯的對象,本質上沒有什麼新東西。

你對 <code>class</code> 的感受是什麼呢?喜歡、讨厭,還是毫無感覺?每個人都有自己的觀點——在下面說出你的觀點吧!

<b>原文釋出時間為:2016年06月12日</b>

<b>本文來自雲栖社群合作夥伴掘金,了解相關資訊可以關注掘金網站。</b>