Successfully reported this slideshow.
We use your LinkedIn profile and activity data to personalize ads and to show you more relevant ads. You can change your ad preferences anytime.

JavaScript 技術手冊第 5 章

177 views

Published on

提供第五章試讀!

 認識建構式之作用
 理解原型鏈機制
 區別原型與類別典範
 善用類別語法

Published in: Technology
  • Be the first to comment

JavaScript 技術手冊第 5 章

  1. 1. 5建構式、原型與類別  認識建構式之作用  理解原型鏈機制  區別原型與類別典範  善用類別語法
  2. 2. 5-2 JavaScript 技術手冊 5.1 建構式 在物件導向典範中,物件會將相關的狀態、功能集合在一起,之後要設定 物件狀態、思考物件可用操作時,都會比較方便一些;JavaScript 支援物件導 向,在物件的建立、操作、調整等各方面提供多樣、極具彈性的語法與功能, 令物件在使用上更為便利,具有極高的可用性。 然而,在建立物件與設定狀態、功能時,流程上可能重複,封裝這些流程 以便重複使用,也是支援物件導向典範的語言著墨的重點之一,JavaScript 在 這方面提供建構式(Constructor),這一節將進行探討。 5.1.1 封裝物件建構流程 你也許會想設計一個銀行商務相關的簡單程式,首先必須建立帳戶相關資 料,因為帳戶會有名稱、帳號、餘額,自然地,會想要使用物件將這三個相關 特性組織在一起: let acct1 = { name: 'Justin Lin', number: '123-4567', balance: 1000, toString() { return `(${this.name}, ${this.number}, ${this.balance})`; } }; let acct2 = { name: 'Monica Huang', number: '987-654321', balance: 2000, toString() { return `(${this.name}, ${this.number}, ${this.balance})`; } }; let acct3 = { name: 'Irene Lin', number: '135-79864', balance: 500, toString() { return `(${this.name}, ${this.number}, ${this.balance})`; } };
  3. 3. 第 5 章 建構式、原型與類別 5-3 console.log(acct1.toString()); // 顯示 (Justin Lin, 123-4567, 1000) console.log(acct2.toString()); // 顯示 (Monica Huang, 987-654321, 2000) console.log(acct3.toString()); // 顯示 (Irene Lin, 135-79864, 500) 定義建構式 這些物件在建立時,必須設定相同的特性與方法,因而在程式碼撰寫上有 重複之處,重複在程式設計上是不好的訊號,應該適時重構(Refactor),以 免日後程式難以維護,或許你會想到,不如定義函式來封裝吧! function account(name, number, balance) { return { name, number, balance, toString() { return `(${this.name}, ${this.number}, ${this.balance})`; } }; } let acct1 = account('Justin Lin', '123-4567', 1000); let acct2 = account('Monica Huang', '987-654321', 2000); let acct3 = account('Irene Lin', '135-79864', 500); console.log(acct1.toString()); // 顯示 (Justin Lin, 123-4567, 1000) console.log(acct2.toString()); // 顯示 (Monica Huang, 987-654321, 2000) console.log(acct3.toString()); // 顯示 (Irene Lin, 135-79864, 500) 這是個不錯的想法,只不過 account()函式傳回的物件,其實是 Object 的 實例,有沒有辦法令傳回的物件,可以是 Account 之類的實例呢?可以的,只 要把剛剛的函式作個調整: constructor account.js function Account(name, number, balance) { this.name = name; this.number = number; this.balance = balance; this.toString = () => `Account(${this.name}, ${this.number}, ${this.balance})`; } let acct1 = new Account('Justin Lin', '123-4567', 1000); let acct2 = new Account('Monica Huang', '987-654321', 2000); let acct3 = new Account('Irene Lin', '135-79864', 500);
  4. 4. 5-4 JavaScript 技術手冊 console.log(acct1.toString()); // 顯示 (Justin Lin, 123-4567, 1000) console.log(acct2.toString()); // 顯示 (Monica Huang, 987-654321, 2000) console.log(acct3.toString()); // 顯示 (Irene Lin, 135-79864, 500) console.log(acct1 instanceof Account); // 顯示 true console.log(acct1.constructor); // 顯示 [Function: Account] 範例中定義的 Account 本質上就是個函式,在需要建構物件時,new 關鍵 字的意義是建立一個物件作為 Account 的實例,接著執行 Account 函式定義的 流程,執行時函式中的 this 就是 new 建立的實例,執行完函式內容之後,該實 例會作為結果傳回。 像 Account 這類與 new 結合使用的函式,在 JavaScript 稱為建構式 (Constructor),目的是封裝物件建構的流程;慣例上,建構式的名稱首字母 會大寫;如果開發者曾經學過基於類別的物件導向語言(例如 Java),會覺得 建構式與類別很像,不過建構式不是類別,JavaScript 在 ES6 之前沒有類別語 法,ES6 雖然開始提供類別語法,然而本質上仍只是「模擬」類別,ES6 以後, JavaScript 並沒有變成基於類別的語言! instanceof 運算子可用來判別,物件是否為某建構式的實例,如果使用之前 account()函式建立物件,使用 instanceof Account 測試會是 false,若是使 用 new Account(…)的方式建立的實例,instanceof Account 測試會是 true, 表示它是 Account 的實例。 每個物件都是某建構式的實例,基本上可以從物件的 constructor 特性得 知實例的建構式,不過要小心的是,有些情況下,物件的 constructor 不一定 指向其建構式(例如 5.2.1 談到的原型物件),constructor 也可以被修改; instanceof 並不是從 constructor 來判斷,而是基於 5.2 會討論的原型鏈。 建構式與 return 建構式基本上無需撰寫 return,如果建構式使用 return 指定了傳回值, 該傳回值就會被當作建構的結果,建構式中撰寫 return,在 JavaScript 中並不 多見,應用之一是用來控制實例的數量之類,例如: let loggers = {}; function Logger(name) { if(loggers[name]) { return loggers[name];
  5. 5. 第 5 章 建構式、原型與類別 5-5 } loggers[name] = this; //... 其他程式碼 } let logger1 = new Logger('cc.openhome.Main'); let logger2 = new Logger('cc.openhome.Main'); let logger3 = new Logger('cc.openhome.Test'); console.log(logger1 == logger2); // 顯示 true console.log(logger1 == logger3); // 顯示 false 在上例中,若是 loggers 上已經有對應於 name 的特性,就會將特性值傳回 (原本 this 參考的物件,執行完建構式後會被回收),否則使用 name 在 loggers 上新增特性,因此 logger1 與 logger2 會參考同一物件,然而 logger3 因為名 稱不同,會取得另一個物件。 在建構式中出現 return 的另一情況,就類似在 JavaScript 標準 API 中, 有些函式既可以當作建構式,也可以作為一般函式呼叫,例如 Number: > Number('0xFF') 255 > new Number(0xFF) [Number: 255] > 若要自行實作這類功能,就要在函式中進行檢查,確認是否明確撰寫 return,只不過在 ES6 之前,並沒有可靠而標準的檢查方式;在 ES6 中新增 了 new.target,如果函式中撰寫了 new.target,在使用 new 建構實例時, new.target 代表了建構式(或類別)本身,否則就會是 undefined,因此可以 如下檢查來達到需求: function Num(param) { if (new.target === Account) { … 建立 Num 實例的流程 } else { … 剖析字串並 return 數值的流程 } } 稍後會談到原型物件,new.target 的 prototype,會成為實例的原型,而 且有函式可以指定 new.target,這在第 9 章會看到。
  6. 6. 5-6 JavaScript 技術手冊 模擬 static 建構式就是函式,而函式是物件,物件可以擁有特性,對於一些與建構式 相關的常數,可以作為建構式的特性。例如若有個 Circle 建構式,需要 PI 之 類的常數,可以如下定義: Circle.PI = 3.14159; 類似地,有些函式與建構式的個別實例沒有特別的關係,也可以定義為建 構式的特性,像是角度轉徑度: Circle.toRadians = angle => angle / 180 * Math.PI; JavaScript 在 Math 上定義了不少數學相關的函式,例如要取得圓周率的話,可以直 接 使 用 Math.PI ; 除 此 之 外 , 之 前 有 看 過 一 些 API , 像 是 Number.MIN_SAFE_INTEGER、Number.isSafeInteger()等,也是以這種形式存 在 ; 對 於 來 自 於 Java 程 式 語 言 的 開 發 者 , 建 構 式 很 像 是 類 別 , 而 Number.MIN_SAFE_INTEGER、Number.isSafeInteger(),就模擬了 Java 語言中的 static 成員與方法。 5.1.2 私有性模擬 方才的 account.js,物件本身有 name、number、balance 特性,你可能會 想將這些特性隱藏起來,避免被其他開發者直接修改,JavaScript 目前並沒有 提供 private 之類的語法,然而,可以透過 Closure 來模擬。例如: constructor account2.js function Account(name, number, balance) { this.getName = () => name; this.getNumber = () => number; this.getBalance = () => balance; this.toString = () => `(${name}, ${number}, ${balance})`; } let acct1 = new Account('Justin Lin', '123-4567', 1000); let acct2 = new Account('Monica Huang', '987-654321', 2000); let acct3 = new Account('Irene Lin', '135-79864', 500); console.log(acct1.toString()); // 顯示 (Justin Lin, 123-4567, 1000) console.log(acct2.toString()); // 顯示 (Monica Huang, 987-654321, 2000) console.log(acct3.toString()); // 顯示 (Irene Lin, 135-79864, 500) console.log('name' in acct1); // 顯示 false
  7. 7. 第 5 章 建構式、原型與類別 5-7 建構式只在 this 上增添了 getName()、getNumber()、getBalance()方法, 然而,this 本身並沒有 name、number、balance 特性,getName、getNumber、 getBalance 方法參考的函式,捕捉了參數 name、number、balance,因此透過相 關方法的呼叫,就可以取得參數值,然而沒辦法修改參數值,因為那是建構式中的區域變數。 既然談到了私有性的模擬,或許你會想到 4.1.3 也做過類似的範例,當時 setter-getter.js 範例中,直接對物件定義了設值方法與取值方法,有沒有辦法 在定義建構式時做類似的事情呢?可以的!透過 Object.defineProperty(): constructor account3.js function Account(name, number, balance) { Object.defineProperty(this, 'name', { get: () => name }); Object.defineProperty(this, 'number', { get: () => number }); Object.defineProperty(this, 'balance', { get: () => balance }); this.toString = () => `(${this.name}, ${this.number}, ${this.balance})`; } let acct1 = new Account('Justin Lin', '123-4567', 1000); let acct2 = new Account('Monica Huang', '987-654321', 2000); let acct3 = new Account('Irene Lin', '135-79864', 500); console.log(acct1.toString()); // 顯示 (Justin Lin, 123-4567, 1000) console.log(acct2.toString()); // 顯示 (Monica Huang, 987-654321, 2000) console.log(acct3.toString()); // 顯示 (Irene Lin, 135-79864, 500) Object.defineProperty()的第一個參數接受物件,第二個參數接受想設 定的特性名稱,第三個參數是屬性描述,它採用選項物件的方式來指定屬性, 在這邊指定了 get 屬性,表示要在物件上建立取值方法,如果要建立設值方 法,可以指定 set 屬性,然而這邊沒有設定 set,因此就只能對 name、number、 balance 取值。 如果物件必須設定多個特性,逐一使用 Object.defineProperty()顯得有點麻煩, 這時可以使用 Object.defineProperies()函式,例如: constructor account4.js function Account(name, number, balance) {  定義物件的 name 特性  定義取值方法
  8. 8. 5-8 JavaScript 技術手冊 Object.defineProperties(this, { name: { get: () => name }, number: { get: () => number }, balance: { get: () => balance } }); this.withdraw = function(money) { if(money > balance) { console.log('餘額不足'); } balance -= money; }; this.toString = () => `(${this.name}, ${this.number}, ${this.balance})`; } let acct1 = new Account('Justin Lin', '123-4567', 1000); acct1.withdraw(500); console.log(acct1.balance); // 顯示 500 acct1.withdraw(1000); //顯示餘額不足 在使用了 Closure 模擬私有性之後,就可以提供 withdraw()之類的方法, 在這種情況下,只有符合方法的流程條件下,才能修改私有的資料,例如範例 中的餘額,藉此模擬了對私有值的保護。 5.1.3 特性描述器 JavaScript 的物件在設定上非常自由,然而,這份自由度在多人合作的專 案中若沒有共識,維護上反而會是種傷害。例如,有些特性不想被變更,有些 特性不想被列舉等,為了支援這類需求,ES5 開始支援物件特性的屬性設定, 之前使用 Object.defineProperty()、Object.defineProperties()函式,時 指定的選項物件,就是用來描述每個特性的屬性。 從 ES5 開 始 , 每 個 特 性 都 會 有 value、 writable、 enumerable 與 configurable 四個屬性設定:
  9. 9. 第 5 章 建構式、原型與類別 5-9  value:特性值。  writable:特性值可否修改。  enumerable:特性名稱可否列舉。  configurable : 可 否 用 delete 刪 除 特 性 , 或 是 使 用 Object.defineProperty()、Object.defineProperties()修改特性的屬 性設定。 在查詢或設定屬性時,這四個屬性會聚合在物件上,稱為特性描述器 (Property descriptor),可以使用 Object.getOwnPropertyDescriptor()來 取得特性描述器的資訊,例如: > let obj = { ... x: 10 ... } undefined > Object.getOwnPropertyDescriptor(obj, 'x') { value: 10, writable: true, enumerable: true, configurable: true } > 在 JavaScript 中 直 接 對 物 件 新 增 特 性 , writable 、 enumerable 、 configurable 預設都是 true,也就是說,特性值預設可以修改、列舉、刪除, 也可以使用 Object.defineProperty()、Object.defineProperties()修改特 性的屬性設定。 Object.getOwnPropertyDescriptor()只是用來取得特性描述器,傳回的 物件只是描述,對該物件修改並不會影響特性本身,想要修改特性本身的屬性, 必須透過 Object.defineProperty()或 Object.defineProperties()。 > let obj = {}; undefined > > Object.defineProperty(obj, 'name', { ... value : 'caterpillar', ... writable : false, ... enumerable : false, ... configurable : false ... }); {} > Object.getOwnPropertyDescriptor(obj, 'name') { value: 'caterpillar', writable: false, enumerable: false,
  10. 10. 5-10 JavaScript 技術手冊 configurable: false } > 使用 Object.defineProperty()、Object.defineProperties()定義特性 時,若某個屬性未曾設定過,那麼 writable、enumerable 或 configurable 預 設都會是 false,因此底下的範例效果等同於上例: > let obj = {}; undefined > > Object.defineProperty(obj, 'name', { ... value : 'caterpillar' ... }); {} > Object.getOwnPropertyDescriptor(obj, 'name') { value: 'caterpillar', writable: false, enumerable: false, configurable: false } > 因此之前範例 account3.js、account4.js 的 name、number、balance,都 是不可列舉、修改、刪除的特性。 如果特性的 writable 屬性為 false 時,嚴格模式下重新設定特性的值會 引發 TypeError,如果 configurable 屬性為 false 時,嚴格模式下刪除特性, 或者是使用 Object.defineProperty()、Object.defineProperties()重新定義 屬性,都會引發 TypeError。 回想一下,2.3.2 討論嚴格模式時,曾經說過陣列的 length 特性可以修改, 但是不能刪除,而 3.2.4 討論 for..in 時,也說過陣列的 length 無法列舉,這 表示 length 特性 writable 會是 true,enumerable、configurable 會是 false, 這邊就取得特性描述器來驗證一下: > Object.getOwnPropertyDescriptor([], 'length') { value: 0, writable: true, enumerable: false, configurable: false } > 在 JavaScript 中 直 接 對 物 件 新 增 特 性 , writable 、 enumerable 、 configurable 預設都是 true,也就是說,當特性本身其實是個方法時,也會
  11. 11. 第 5 章 建構式、原型與類別 5-11 被 for..in 列舉,然而,通常使用 for..in 列舉特性時,並不希望把方法也列 舉出來。 例如 account3.js、account4.js 若使用 for..in 列舉 Account 實例,就會 發現方法也被列舉出來(然而 name 等特性卻沒有,因為 enumerable 預設為 false),如果不希望有這種結果,可以如下設置: constructor account5.js function Account(name, number, balance) { Object.defineProperties(this, { name: { get: () => name, enumerable: true }, number: { get: () => number, enumerable: true }, balance: { get: () => balance, enumerable: true }, withdraw: { value: function(money) { if(money > balance) { console.log('餘額不足'); } balance -= money; } }, toString: { value: () => `(${this.name}, ${this.number}, ${this.balance})` } }); } let acct = new Account('Justin Lin', '123-4567', 1000); for(let p in acct) { console.log(`${p}: ${acct[p]}`); } 在上例中,將 name、number、balance 設為可列舉,而 withdraw()、 toString()方法預設為不可列舉,因此執行結果會是: name: Justin Lin number: 123-4567 balance: 1000  可列舉  不列舉方法
  12. 12. 5-12 JavaScript 技術手冊 另外,如果使用 Object.defineProperty()、Object.defineProperties() 定義 get、set,表示要自行控制特性的存取,也就是說,不能再去定義 value 或 writable 特性。 既然談到了 writable,就來用它做個不可變動的陣列吧! constructor immutable.js function ImmutableList(...elems) { elems.forEach((elem, idx) => { Object.defineProperty(this, idx, { value: elem, enumerable: true }); }); Object.defineProperty(this, 'length', { value: elems.length }); Object.preventExtensions(this); } let lt = new ImmutableList(1, 2, 3); // 顯示 0 到 2 for(let i in lt) { console.log(i); } ImmutableList 建構式接受不定長度引數,elems 實際上會是個陣列,因 此可以使用 forEach()方法逐一設定索引與元素值,forEach()的回呼函式第 二個參數可以接受目前元素的索引位置,這可以用來作為特性名稱,遵照陣列 的慣例,索引被設成了可列舉,而 length 設成了不可列舉。 為 了 避 免 後 續 有 人 在 ImmutableList 上 新 增 特 性 , 範 例 中 還 使 用 Object.preventExtensions()來阻止物件被擴充,這個函式稍後再來討論。 在這邊要留意的是,範例中使用了箭號函式,4.1.2 中討論過,箭號函式中 的 this 是依當時的語彙環境來綁定,也就是說,範例中箭號函式中的 this 綁 定的就是 ImmutableList 實例本身,如果使用 function 的話,必須寫成這樣: function ImmutableList(...elems) { let lt = this; elems.forEach(function(elem, idx) { Object.defineProperty(lt, idx, { value: elem,  逐一設定索引與元素值  length 不可列舉  物件不可擴充
  13. 13. 第 5 章 建構式、原型與類別 5-13 enumerable: true }); }); …略 } 在這個程式片段中,function 中若撰寫 this,嚴格模式下會是 undefined, 因為 forEach()方法在呼叫回呼函式時,預設並不會指定 this 實際參考的物 件;雖然少見,forEach()是可以使用第二個引數,指定 this 實際參考的物件, 例如: function ImmutableList(...elems) { elems.forEach(function(elem, idx) { Object.defineProperty(this, idx, { value: elem, enumerable: true }); }, this); …略 } 當然,在這類情況下,若是支援 ES6,使用箭號函式會比較方便而簡潔。 5.1.4 擴充、彌封、凍結 ES5 提 供 了 Object.preventExtensions() 與 Object.isExtensible() , 可 以 限 定 或 測 試 物 件 的 擴 充 性 。 Object.preventExtensions()可指定物件,將物件標示為無法擴充並傳回物件本 身 , 可 透 過 Object.isExtensible() 測 試 物 件 是 否 可 擴 充 , 呼 叫 Object.preventExtensions()之後,對物件進行任何直接擴充,在嚴格模式下 會引發 TypeError。 被標示為無法擴充的物件,只是無法再增添特性,不過若 configurable 屬性為 true,就可以用 delete 刪除特性,如果 writable 為 true,就可以對 特性加以修改;物件被標示為無法擴充,就沒有方式可以重設為可擴充。 基於 Object.preventExtensions()、Object.defineProperty()等 API, ES5 還定義了 Object.seal()函式,可以對物件加以彌封,被彌封的物件不能
  14. 14. 5-14 JavaScript 技術手冊 擴充或刪除物件上的特性,也不能修改特性描述器,然而可以修改現有的特性 值,可以使用 Object.isSeal()來測試物件是否被彌封。 被彌封的物件,仍然可以修改現有的特性值,若想連特性值都不能修改, 只想作為一個唯讀物件,那麼可以使用 Object.freeze()來凍結物件,可以使 用 Object.isFrozen()來測試物件是否被凍結。 5.2 原型物件 在前一節中,使用了建構式來初始物件相關的特性,然而,有些特性並不 需要個別實例各自擁有,例如 toString()方法的流程中,使用 this 參考實際 的物件,沒必要每次都產生函式物件,給個別物件的 toString 特性參考,這類 可在實例之間可以共享的特性,可以在建構式的原型物件上定義。 5.2.1 建構式與 prototype 每個函式實例都會有個 prototype 特性,基本上是 Object 的實例,本身 沒有任何特性,不過 prototype 物件的 constructor 特性會參考函式本身: > function Foo() {} undefined > Foo.prototype Foo {} > Foo.prototype instanceof Foo false > Foo.prototype.constructor [Function: Foo] > 若函式作為建構式使用,使用 new 建構的物件,會有個__proto__特性參考 至建構式的 prototype 特性,例如承接上例: > let foo = new Foo() undefined > foo.__proto__ === Foo.prototype true > 只不過,__proto__名稱的底線,似乎暗示著這是非標準特性?在 ES6 之 前,__proto__確實是非標準特性,不過瀏覽器幾乎都支援這個特性,因此 ES6
  15. 15. 第 5 章 建構式、原型與類別 5-15 以後,規範 ECMAScript 的實作必須支援__proto__;雖然如此,不少文件還 是建議避免使用__proto__,改用 ES5 的 Object.getPrototypeOf()函式來取 得實例的原型物件: > Object.getPrototypeOf(foo) === Foo.prototype true > 存取物件的特性時,JavaScript 會先在實例本身尋找,如果有就使用,沒有 的話,就會看看實例的原型物件上有沒有該特性,因此,對於不需要個別實例擁有, 而是可以各個實例間共用的特性,可以定義在建構式的 prototype。例如,可以 將物件的方法定義在 prototype: prototype account.js function Account(name, number, balance) { Object.defineProperties(this, { name: { get: () => name, enumerable: true }, number: { get: () => number, enumerable: true }, balance: { get: () => balance, set: value => balance = value, enumerable: true } }); } Account.prototype.withdraw = function(money) { if(money > this.balance) { console.log('餘額不足'); } this.balance -= money; }; Account.prototype.toString = function() { return `(${this.name}, ${this.number}, ${this.balance})`; }; let acct = new Account('Justin Lin', '123-4567', 1000); for(let p in acct) { console.log(`${p}: ${acct[p]}`); }
  16. 16. 5-16 JavaScript 技術手冊 在 ES5 前確實都以這種方式,定義實例間共用的方法,不少談 JavaScript 的書籍或文件也會使用此方式,然而這會在列舉物件特性時,連同方法一併列 舉出來,執行結果如下: name: Justin Lin number: 123-4567 balance: 1000 withdraw: function(money) { if(money > balance) { console.log('餘額不足'); } balance -= money; } toString: function() { return `(${this.name}, ${this.number}, ${this.balance})`; } 在支援 ES10 的環境裡,函式實例的 toString()方法會傳回函式的原始碼, 在 物件 上新增 的特性 ,預 設會 是可列 舉,因 此才 會在 執行結 果中顯 示了 withdraw 與 toString 參考的函式物件之原始碼。 通常列舉物件特性時,希望只列舉物件本身的特性,如果要在原型上新增特 性,建議將特性設為不可列舉,在 ES5 以後,因為有 Object.defineProperty()、 Object.defineProperties(),可以做到這點: prototype account2.js function Account(name, number, balance) { Object.defineProperties(this, { name: { get: () => name, enumerable: true }, number: { get: () => number, enumerable: true }, balance: { get: () => balance, set: value => balance = value, enumerable: true } }); } Object.defineProperty(Account.prototype, 'withdraw', { value: function(money) {
  17. 17. 第 5 章 建構式、原型與類別 5-17 if(money > this.balance) { console.log('餘額不足'); } this.balance -= money; }, writable: true, configurable: true }); Object.defineProperty(Account.prototype, 'toString', { value: function() { return `(${this.name}, ${this.number}, ${this.balance})`; }, writable: true, configurable: true }); let acct = new Account('Justin Lin', '123-4567', 1000); for(let p in acct) { console.log(`${p}: ${acct[p]}`); } 在上面的範例中,使用了 ES5 的 Object.defineProperty(),呼叫函式時 沒有設定 emnuerable 屬性,這時會是預設值 false,而 writable、configurable 設為 true,除了符合內建標準 API 的慣例,也保留了後續繼承時重新定義方法、 修補 API 彈性等優點,5.2.4 就會談到繼承,而修補 API 會在第 9 章時進行討 論。 一開始有談到,函式的 prototype 物件上,constructor 特性會參考函式 本身,在透過建構式的實例取得 constructor 特性時,就可以得知實例是由哪 個建構式產生,然而,constructor 並不是每個實例本身擁有的特性,而是定 義在原型上。想要知道實例本身是否擁有某個特性,可以透過 hasOwnProperty() 方法。例如: > let obj = {x: 10} undefined > obj.hasOwnProperty('x'); true > obj.constructor [Function: Object] > obj.hasOwnProperty('constructor'); false > 如果要在原型上定義符號特性呢?同樣地,雖然可以直接這麼撰寫:
  18. 18. 5-18 JavaScript 技術手冊 prototype immutable.js function ImmutableList(...elems) { elems.forEach(function(elem, idx) { Object.defineProperty(this, idx, { value: elem, enumerable: true }); }, this); Object.defineProperty(this, 'length', { value: elems.length }); Object.preventExtensions(this); } ImmutableList.prototype[Symbol.iterator] = function*() { for(let i = 0; i < this.length; i++) { yield this[i]; } }; let lt = new ImmutableList(1, 2, 3); for(let elem of lt) { console.log(elem); } 然而,上例中的 Symbol.iterator 特性會是可列舉的,建議修改為以下: prototype immutable2.js function ImmutableList(...elems) { …同前…略 } Object.defineProperty(ImmutableList.prototype, Symbol.iterator, { value: function*() { for(let i = 0; i < this.length; i++) { yield this[i]; } }, writable: true, configurable: true }); let lt = new ImmutableList(1, 2, 3); for(let elem of lt) { console.log(elem); }
  19. 19. 第 5 章 建構式、原型與類別 5-19 ECMAScript 規範 Object 預設的 toString()方法,必須傳回'[object name]'格式的字串,name 是建構式名稱,不少第三方程式庫會以此作為判 別 實 例 型 態 ; 從 ES6 開 始 , 可 以 在 建 構 式 的 prototype 定 義 Symbol.toStringTag 特 性 來 決 定 name 的 值 , 有 關 Symbol.toStringTag,在第 9 章會再詳細討論。 5.2.2 __proto__與 Object.create() 方才談到,ES6 標準化__proto__,而這個特性是可以修改的,這是個很 強大也很危險的功能,例如,透過修改__proto__,可以將類陣列變得更像是 陣列,連 instanceof 都可以騙過: let arrayLike = { '0' : 10, '1' : 20, length : 2 }; arrayLike.__proto__ = Array.prototype; arrayLike.forEach(elem => console.log(elem)); // 顯示 10、20 console.log(arrayLike instanceof Array); // 顯示 true 因 為 arrayLike 的 原 型 被 設 為 Array.prototype , 在 使 用 arrayLike.forEach()時,物件本身沒有,然而原型上找到了 forEach,因此 就可以使用;instanceof 會查看左運算元的原型,如果等同於右運算元的 prototype,就會傳回 true,因此,instanceof 用來確認物件是否為某建構 式的實例,某些程度上並不可靠! 如果臨時需要將一個類陣列變得更像陣列,以便「借用」陣列的相關 API, 這一招就很有用!不過記得,雖然原型與 Array.prototype 相同了,然而終 究不是陣列,因為 length 特性並不會自動隨著索引增減而變更;ES6 有個 Array.from(),可以指定類陣列物件,傳回一個陣列,如果不想修改__proto__ 將類陣列改得更像陣列時可以善用。 絕大多數情況下,建議不要修改物件的原型,除非你知道自己在做什麼。在有 限的流程範圍內,臨時調整類陣列的原型為 Array.prototype,以便於呼 叫 Array 的 API,這類情況勉強可以接受。
  20. 20. 5-20 JavaScript 技術手冊 想要確認某個建構式的 prototype,是否為某實例的原型,可以使用物件 的 isPrototypeOf()方法。例如: > Object.getPrototypeOf([]) === Array.prototype true > Array.prototype.isPrototypeOf([]) true > 如 果 不 想 要 修 改 __proto__ 來 指 定 原 型 , ES6 提 供 了 Object.setPrototypeOf()函式,例如: let arrayLike = { '0' : 10, '1' : 20, length : 2 }; Object.setPrototypeOf(arrayLike, Array.prototype); arrayLike.forEach(elem => console.log(elem)); // 顯示 10、20 console.log(arrayLike instanceof Array); // 顯示 true ES6 之 前 , __proto__ 並 未 標 準 化 , 要 判 斷 物 件 的 原 型 得 使 用 isPrototypeOf()方法;類似地,ES5 也提供了 Object.create()函式, 可以指定原型物件及特性描述器,Object.create()函式會建立新物件,物件 的原型將被設為呼叫 Object.create()時指定的原型物件。例如: prototype arraylike.js let arrayLike = Object.create(Array.prototype, { '0': { value: 10, enumerable: true, writable: true, configurable: true }, '1': { value : 20, enumerable: true, writable: true, configurable: true }, length: { value: 2, writable: true } });
  21. 21. 第 5 章 建構式、原型與類別 5-21 arrayLike.forEach(elem => console.log(elem)); // 顯示 10、20 console.log(arrayLike instanceof Array); // 顯示 true 同樣地,雖然 arrayLike 更像是陣列了,然而,終究不是陣列,因為 length 特性並不會自動隨著索引增減而變更。 你甚至可以透過 Object.create(null)的方式,建立一個不具原型的物 件,這樣的物件也就不會繼承 Object 任何方法,可以當成純綷的字典來使 用,或者調整為你想要的樣子。 5.2.3 原型鏈 在 5.2.1 時談過,存取物件的特性時,會先在實例本身尋找,如果有就使用, 沒有的話,就會看看實例的原型物件上有沒有該特性,如果原型物件上也沒有呢? 那就看看原型物件的原型物件,也就是看看原型物件的建構式是哪個,進一步查看 該建構式的 prototype 上有沒有該特性,這種查詢特性的方式,會一直持續到 Object.prototype 為止,這一連串的原型就稱為原型鏈(Prototype chain)。 Object.prototype 的原型是 null, Object.prototype.__proto__ 或 Object.getPrototypeOf(Object.prototype)會是 null。 以 5.2.1 的 immutable2.js 範例來說,如果呼叫 lt 參考的 ImmutableList 實例之 toString()方法,因為實例本身並沒有該方法,接著查詢 lt 的原型 物件,也就是 ImmutableList.prototype,看看有沒有該方法,結果還是沒 有,ImmutableList.prototype 是 Object 的實例,因此就進一步看看 Object.prototype 有沒有定義 toString()方法,這時找到了,因此最後呼 叫的,就是 Object.prototype 上定義的 toString()。 從比較簡化的說法來看,就像是在說 ImmutableList 沒有定義方法的 話,就到 Object 上看看有沒有定義,這似乎是物件導向裡繼承的概念?是的, JavaScript 支援物件導向,而繼承就是透過原型鏈的機制來實現,而 JavaScript 也就被稱為基於原型(Prototype-based)的物件導向語言。 不少支援物件導向的語言,是所謂基於類別(Class-based)的物件導向 語言(例如 Java),面對 JavaScript 基於原型的機制,通常會很不習慣,ES6 以後提供了模擬類別的語法,然而,這並不改變 JavaScript 基於原型的本質。
  22. 22. 5-22 JavaScript 技術手冊 不過,查找原型鏈確實是蠻麻煩的,幸而,可以透過__proto__來簡化一 下,同樣使用 immutable2.js 為例,若 lt 參考 ImmutableList 實例, lt.toString()呼叫方法時,lt 本身沒有,就看看 lt.__proto__上有沒有, 結果還是沒有,就看看 lt.__proto__.__proto__上有沒有,在查找的過程 中,若可以結合除錯器,檢視繼承關係就會蠻方便的了。 圖 5.1 結合__proto__與除錯器查找原型鏈 運算子 instanceof 可以用來查詢,某物件是否為某個建構式的實例,背 後 也 是 透 過 原 型 鏈 查 找 , 不 過 , 因 為 實 例 的 __proto__ 可 以 修 改 , 也 有 Object.create()函式可以指定物件原型,嚴格來 說 , obj instanceof Constructor 這 種 語 法 , 預 設 是 用 來 確 認 可 否 在 obj 的 原 型 鏈 上 , 找 到 Constructor.prototype。 如 5.2.1 中談過的,不少文件還是建議避免使用__proto__,本書有時為了 說 明 方 便 才 使 用 __proto__ , 正 式 的 程 式 碼 中 , 應 該 使 用 標 準 API , 如 Object.getPrototypeOf()、Object.setPrototypeOf()等。 ES6 提供了 Symbol.hasInstance,可用來控制 instanceof 的行為,這 將留待第 9 章時再來討論。
  23. 23. 第 5 章 建構式、原型與類別 5-23 5.2.4 基於原型的繼承 既然瞭解了原型鏈的機制,那來自行實作基於原型的繼承吧!物件導向中 繼承到底是為了什麼呢?以 JavaScript 來說,建構式與原型用來定義物件的基 本藍圖,然而有時會發現多個建構式與原型的定義出現了重複。例如,假設你 在正開發一款 RPG(Role-playing game)遊戲,一開始設定的角色有劍士與 魔法師。首先你定義了劍士: function SwordsMan(name, level, blood) { this.name = name; // 角色名稱 this.level = level; // 角色等級 this.blood = blood; // 角色血量 } Object.defineProperties(SwordsMan.prototype, { fight: { value: () => console.log('揮劍攻擊'), writable: true, configurable: true }, toString: { value: function() { return `(${this.name}, ${this.level}, ${this.blood})`; }, writable: true, configurable: true } }); 劍士擁有名稱、等級與血量等特性,可以揮劍攻擊,為了方便顯示劍士的 特性,定義了 toString()方法,接著類似地,你為魔法師定義建構式與原型: function Magician(name, level, blood) { this.name = name; // 角色名稱 this.level = level; // 角色等級 this.blood = blood; // 角色血量 } Object.defineProperties(Magician.prototype, { fight: { value: () => console.log('魔法攻擊'), writable: true, configurable: true }, cure: { value: () => console.log('魔法治療'), writable: true, configurable: true
  24. 24. 5-24 JavaScript 技術手冊 }, toString: { value: function() { return `(${this.name}, ${this.level}, ${this.blood})`; }, writable: true, configurable: true } }); 有注意什麼嗎?因為只要是遊戲中的角色,都會具有角色名稱、等級與血 量,也定義了相同的 toString()方法,Magician 中粗體字部份與 SwordsMan 中相對應的程式碼重複了。 重複在程式設計上,就是不好的訊號。舉個例子來說,如果要將 name、 level、blood 更改為其他名稱,那就要修改 SwordsMan 與 Magician 兩個建構 式以及相對應的原型,如果有更多角色,而且都具有類似的程式碼,那要修改 的程式碼就更多,造成維護上的不便。 如果要改進,可以把相同的程式碼提昇(Pull up),定義在建構式 Role 及 Role.prototype: prototype inheritance.js function Role(name, level, blood) { this.name = name; // 角色名稱 this.level = level; // 角色等級 this.blood = blood; // 角色血量 } Object.defineProperties(Role.prototype, { toString: { value: function() { return `(${this.name}, ${this.level}, ${this.blood})`; }, writable: true, configurable: true } }); function SwordsMan(name, level, blood) { Role.call(this, name, level, blood); } SwordsMan.prototype = Object.create(Role.prototype, { constructor: { value: SwordsMan, 定義 Role 建構式 定義 toString()方法 呼叫 Role 定義的初始流程 繼承 Role 設定 constructor 特性
  25. 25. 第 5 章 建構式、原型與類別 5-25 writable: true, configurable: true } }); Object.defineProperties(SwordsMan.prototype, { fight: { value: () => console.log('揮劍攻擊'), writable: true, configurable: true } }); function Magician(name, level, blood) { Role.call(this, name, level, blood); } Magician.prototype = Object.create(Role.prototype, { constructor: { value: Magician, writable: true, configurable: true } }); Object.defineProperties(Magician.prototype, { fight: { value: () => console.log('魔法攻擊'), writable: true, configurable: true }, cure: { value: () => console.log('魔法治療'), writable: true, configurable: true } }); let swordsMan = new SwordsMan('Justin', 1, 200); let magician = new Magician('Monica', 1, 100); swordsMan.fight(); // 顯示揮劍攻擊 magician.fight(); // 顯示魔法攻擊 console.log(swordsMan.toString()); // 顯示 (Justin, 1, 200) console.log(magician.toString()); // 顯示 (Monica, 1, 100) 有關角色名稱、等級、血量的特性建立,被定義在 Role 建構式,toString() 則被定義在 Role.prototype,這麼一來,SwordsMan 中就只要呼叫 Role 來 設定 this 上的特性,在範例中使用了 call()方法來指定 this,接著透過 定義 fight()方法 使用繼承的 toString()
  26. 26. 5-26 JavaScript 技術手冊 Object.create()指定 Role.prototype 為原型,建立一個物件來取代原有的 SwordsMan.prototype , 如 此 一 來 , 在 SwordsMan 實 例 及 SwordsMan.prototype 上找不到的方法,就會到 Roles.prototype 上找。 每個實例都會有個 constructor 特性,參考至建構式,constructor 不需 要每個實例本身擁有,因此定義在 SwordsMan.prototype,接下來,就只要 定義 SwordsMan.prototype 擁有的 fight()方法就可以了,不需要再定義 toString(),這會從 Role.prototype 繼承下來;Magician 的相關定義,與 SwordsMan 類似;從執行結果中可以看出,在需要 toString()時,會使用 Role.prototype 定義的 toString()。 Object.create()是 ES5 開始提供的函式,在 ES5 之前,在實現繼承指定 原型時,會是採以下的方式: ...略 SwordsMan.prototype = new Role(); // 不需要 name、level、blood 等特性 delete SwordsMan.prototype.name; delete SwordsMan.prototype.level; delete SwordsMan.prototype.blood; ...略 必 須 new Role() 的 原 因 在 於 , 建 立 的 實 例 之 原 型 物 件 就 是 Role.prototype,然而,因為 new 實際上會執行 Role 中定義的流程,因此建 立的實例會有 name、level、blood 等特性(雖然就這邊的例子而言,特性值 會是 undefined),為了避免在 for..in 等情況下列舉了這些特性,就使用 delete 將之刪除,在不少書籍或文件中,還是會看到這類做法,當然,在可以 使用 Object.create()函式的情況下,使用 Object.create()會是比較方便的 做法。 避免使用原型鏈機制來實現標準 API 的繼承,因為特殊行為不會被繼承,例如 若繼承 Array,子型態實例的 length 特性,並不會隨著元素數量自動維護。
  27. 27. 第 5 章 建構式、原型與類別 5-27 5.2.5 重新定義方法 如果想寫個 drawFight()函式,若傳入 SwordsMan、Magician 實例時,想 要能夠分別顯示 SwordsMan(Justin, 1, 200)揮劍攻擊、Magician(Monica, 1, 100)魔法攻擊的話,要怎麼做呢? 你也許會想到,判斷傳入的物件到底是 SwordsMan 或 Magician 的實例, 然後分別顯示劍士或魔法師的字樣,確實地,可以透過 instanceof 進行這類 的判斷。例如: function drawFight(role) { if(role instanceof SwordsMan) { console.log(`SwordsMan${role.toString()}`); } else if(role instanceof Magician) { console.log(`Magician${role.toString()}`); } } instanceof 可用來進行型態檢查,不過每當想要 instanceof 時,要再多 想一下,有沒有其他的設計方式。 以這邊的例子來說,若是未來有更多角色的話,勢必要增加更多型態檢查 的判斷式,在多數的情況下,檢查型態而給予不同的流程行為,對於程式的維護性 有著不良的影響,應該避免。 確實在某些特定的情況下,還是免不了要判斷物件的種類,並給予不同的流 程,不過多數情況下,應優先選擇思考物件的行為。 那麼該怎麼做呢?目前 toString()的行為是定義在 Role.prototype 而繼 承下來,那麼可否分別重新定義 SwordsMan.prototype 與 Magician.prototype 的 toString()行為,讓它們各自能增加劍士或魔法師的字樣如何? 是 可 以 這 麼 做 , 不 過 , 並 不 用 單 純 地 在 SwordsMan.prototype 或 Magician.prototype 中定義以下的 toString(): ...略 Object.defineProperties(SwordsMan.prototype, { ...略, toString: { value: function() { return `SwordsMan(${this.name}, ${this.level}, ${this.blood})`; },
  28. 28. 5-28 JavaScript 技術手冊 writable: true, configurable: true } }); ...略 Object.defineProperties(Magician.prototype, { ...略, toString: { value: function() { return `Magician(${this.name}, ${this.level}, ${this.blood})`; }, writable: true, configurable: true } }); 因為粗體字部份,就是 Role.prototype 的 toString()傳回的字串,只要 各自在前面附加上劍士或魔法師就可以了,例如: prototype inheritance2.js ...略 Object.defineProperties(SwordsMan.prototype, { ...略 , toString: { value: function() { let desc = Role.prototype.toString.call(this); return `SwordsMan${desc}`; }, writable: true, configurable: true } }); ...略 Magician.prototype = Object.create(Role.prototype, { constructor: { value: Magician, writable: true, configurable: true } }); Object.defineProperties(Magician.prototype, { ...略 , toString: {
  29. 29. 第 5 章 建構式、原型與類別 5-29 value: function() { let desc = Role.prototype.toString.call(this); return `Magician${desc}`; }, writable: true, configurable: true } }); function drawFight(role) { console.log(role.toString()); } let swordsMan = new SwordsMan('Justin', 1, 200); let magician = new Magician('Monica', 1, 100); drawFight(swordsMan); // 顯示 SwordsMan(Justin, 1, 200) drawFight(magician); // 顯示 Magician(Monica, 1, 100) 藉由粗體字的部份,呼叫了 Role.prototype 上的 toString(),呼叫 call() 時使用 this 指定了實例,傳回的字串再與各自角色描述結合,如此就可以重用 Role.prototype 上的 toString()定義。 5.3 類別語法 在物件導向的支援上,JavaScript 的原型鏈是極具彈性的機制,運用得當 的話,可以達到不少基於類別的物件導向語言無法做到之事;然而彈性的另一 面就是不易掌握,若開發者已習慣基於類別的物件導向語言,往往難以適應 JavaScript 基於原型鏈的機制。 因此在過去,不少開發者尋求各種方式,在 JavaScript 中模擬出類別,雖 說可以解決部份問題,然而在中大型專案中,往往發生不同模擬方式共處的情 況,因而造成維護上的困擾;從 ES6 開始,提供了標準的類別語法,用來模擬 基於類別的物件導向,這一節就要來進行討論。 如果對於如何自行模擬類別有興趣,可以參考〈模擬類別的封裝與繼承 1 〉。 1 模擬類別的封裝與繼承:openhome.cc/Gossip/ECMAScript/Class.html
  30. 30. 5-30 JavaScript 技術手冊 5.3.1 定義類別 話先說在前頭,雖然 ES6 開始提供類別語法,不過嚴格來說,仍是在模擬 基於類別的物件導向,本質上 JavaScript 仍是基於原型的物件導向,這也就是 5.2 花了不少篇幅先談原型的原因,ES6 的類別語法,主要是提供標準化的類別 模擬方式,透過語法蜜糖令程式碼變得簡潔一些。 例如,以 5.2.1 的 account2.js 來說,若使用類別語法來定義會簡潔許多: class account.js class Account { constructor(name, number, balance) { this.name = name; this.number = number; this.balance = balance; } withdraw(money) { if(money > this.balance) { console.log('餘額不足'); } this.balance -= money; } deposit(money) { if(money < 0) { console.log('存款金額不得為負'); } else { this.balance += money; } } toString() { return `(${this.name}, ${this.number}, ${this.balance})`; } } let acct = new Account('Justin Lin', '123-4567', 1000); for(let p in acct) { console.log(`${p}: ${acct[p]}`); } ES6 使用 class 關鍵字來定義類別,而 constructor 用來定義實例的初 始流程,如果類別中沒有撰寫 constructor,也會自動加入一個無參數的 定義 Acount 類別 定義建構式 定義方法
  31. 31. 第 5 章 建構式、原型與類別 5-31 constructor() {};constructor 最後隱含地傳回物件本身,也就是 this,如 果在 constructor 明確地 return 某個物件,那麼 new 的結果就會是該物件。 在定義方法時,方式與 4.1.3 談到的物件實字定義語法相同;這邊的範 例相對於 5.2.1 的 account2.js 來說,著實簡潔許多!不過,眼尖的你或許會發 現,嗯?name、number、balance 似乎都是公開可見的?是的!這邊為了突顯 類別語法,並沒有使用 Object.defineProperties()來定義屬性,在撰寫本文 的這個時間點,ECMAScript 規範還未正式提供私有性設定的相關語法,若有 這種需求的話,必須自行使用 Object.defineProperties()來定義。 在撰寫本文的這個時間點,TC39 有兩個處於階段三的提案〈Private instance methods and accessors 2 〉與〈Class Public Instance Fields & Private Instance Fields 3 〉,提供了私有特性與方法的相關語法。 既然使用了類別語法,通常就是希望以基於類別的物件導向來思考,不過, 範例中的 Account 本身,確實仍是 Function 的實例,withdraw()方法則是定 義 在 Account.prototype 的 特 性 , 預 設 為 不 可 列 舉 , Account.prototype.constructor 參考的就是 Account,這些與 ES5 自定建構 式、方法時的相關設定相同,使用類別語法來做,著實省了不少功夫。 既然本質上還是基於原型,這表示還是可以對 Account.prototype 直接添 加特性,之後 Account 的實例也能找得到該特性;也可以直接將 withdraw 參 考的函式指定給其他變數,或者是指定為另一物件的特性,透過該物件來呼叫 函式,該函式的 this 一樣是依呼叫者而決定;每個透過 new Account(...) 建 構出來的實例,本身的原型也都是參考至 Account.prototype。 然而不同的是,使用 class 定義的 Account 只能使用 new 來建立實例,直 接 以 函 式 的 呼 叫 方 式 , 像 是 Account(...) 、 Account.call(...) 或 Account.apply(...)都會發生 TypeError。 2 Private instance methods and accessors:bit.ly/2XwSXpd 3 Class Public Instance Fields & Private Instance Fields:bit.ly/2t1XzT3
  32. 32. 5-32 JavaScript 技術手冊 類別也可以使用運算式的方式來建立,可以是匿名類別,必要時也可以給 予名稱: > let clz = class { ... constructor(name) { this.name = name; } ... } undefined > new clz('xyz') clz { name: 'xyz' } > var clz2 = class Xyz { ... constructor(name) { this.name = name; } ... } undefined > new clz2('xyz') Xyz { name: 'xyz' } > 5.3.2 定義方法 方才的 account.js 並沒有隱藏 Account 實例的 name、number、balance 等 特性,就動態定型語言的生態圈來說,多半覺得隱藏沒什麼必要,有方法就透 過方法,避免直接修改特性才是開發者應該有的認知,這樣的作法也可以讓程 式碼維持簡潔。 當然,團隊開發時總是有人不遵守慣例,為了團隊合作,必要時總是得採 適當措施,只不過這必須得多費些功夫,,例如,定義設值、取值方法來控管: class account2.js class Account { constructor(name, number, balance) { Object.defineProperties(this, { __name__: { value: name, writable: true }, __number__: { value: number, writable: true }, __balance__: { value: balance, writable: true }, 定義實例__xxx__特性
  33. 33. 第 5 章 建構式、原型與類別 5-33 }); } get name() { return this.__name__; } get number() { return this.__number__; } get balance() { return this.__balance__; } withdraw(money) { if(money > this.__balance__) { console.log('餘額不足'); } this.__balance__ -= money; } deposit(money) { if(money < 0) { console.log('存款金額不得為負'); } else { this.__balance__ += money; } } toString() { return `(${this.__name__}, ${this.__number__}, ${this.__balance__})`; } } Object.defineProperties(Account.prototype, { name: {enumerable: true}, number: {enumerable: true}, balance: {enumerable: true} }); let acct = new Account('Justin Lin', '123-4567', 1000); for(let p in acct) { console.log(`${p}: ${acct[p]}`); } 在特性的命名慣例上,底線開頭的名稱,通常暗示著它是個內部特性,可 能是私有或非標準,因此不要直接存取,範例中只設定了 writable 為 true, 其他屬性都是 false,這表示 enumerable 也是 false,也就是這些私有特性不 定義設值方法 設值方法為可列舉
  34. 34. 5-34 JavaScript 技術手冊 可列舉;然而,為了取得特性值,類別定義了取值方法(若是設值方法則 使用 set)。 類別上定義的設值、取值方法預設是不可列舉(畢竟本質上是方法),在這邊 為了配合範例的 for..in 迴圈,將設值方法設定為可列舉了,記得!雖然 ES6 可以使用類別語法,然而本質上還是基於原型,方法是定義在原型上,因此改 變方法的可列舉性時,範例中使用的是 Account.prototype。 ES6 類別語法也可以使用[]來定義方法,[]中可以是字串、運算式的結果 或者是符號,定義方法時也可以結合產生器語法。例如: class range.js class Range { constructor(start, end) { this.start = start; this.end = end; } *[Symbol.iterator]() { for(let i = this.start; i < this.end; i++) { yield i; } } toString() { return `Range(${this.start}...${this.end - 1})`; } } let range = new Range(1, 4); for(let i of range) { console.log(i); // 顯示 1 2 3 } console.log(range.toString()); // 顯示 Range(1...3) 在 ES6 的類別中,若方法前加上 static,那麼該方法會是個靜態方法,也 就是以類別為名稱空間的一個函式: class Circle { static toRadians(angle) { return angle / 180 * Math.PI; } }
  35. 35. 第 5 章 建構式、原型與類別 5-35 就目前來說,ECMAScript 規範並沒有定義如何在類別上直接定義靜態特 性,然而,可以在 static 後置 get、set,若想模擬靜態特性的話可以使用, 例如,如下定義之後,可以使用 Circle.PI 來取得 3.14159: class Circle { ... static get PI() { return 3.14159; } } 在類別的 static 方法中若出現 this,代表的是類別本身。例如: > class Foo { ... static get self() { ..... return this; ..... } ... } undefined > Foo.self [Function: Foo] > 在撰寫本文的這個時間點,TC39 有個處於階段三的提案〈Static class fields and private static methods 4 〉,提供了靜態特性與私有靜態方法的相關語法。 5.3.3 實作繼承 要說為何基於原型的 JavaScript 中,始終有開發者追求基於類別的模擬, 原因之一大概就是,使用基於原型的方式實現繼承時,許多開發者難以掌握, 或者實作上有複雜、難以閱讀之處(可以回顧一下 5.2.4、5.2.5),因而寄望 在類別的模擬下,繼承這方面能夠有更直覺、簡化、易於掌握的方式。 ES6 提供了模擬類別的標準方式,而在繼承這方面,可以使用 extends 來模 擬基於類別的繼承。以 5.2.5 的 inheritance2.js 為例,若改以類別與 extends 來模擬的話會是如下: 4 Static class fields and private static methods:bit.ly/2LFW8np
  36. 36. 5-36 JavaScript 技術手冊 class inheritance.js class Role { constructor(name, level, blood) { this.name = name; // 角色名稱 this.level = level; // 角色等級 this.blood = blood; // 角色血量 } toString() { return `(${this.name}, ${this.level}, ${this.blood})`; } } class SwordsMan extends Role { constructor(name, level, blood) { super(name, level, blood); } fight() { console.log('揮劍攻擊'); } toString() { return `SwordsMan${super.toString()}`; } } class Magician extends Role { constructor(name, level, blood) { super(name, level, blood); } fight() { console.log('魔法攻擊'); } cure() { console.log('魔法治療'); } toString() { return `Magician${super.toString()}`; } } let swordsMan = new SwordsMan('Justin', 1, 200); let magician = new Magician('Monica', 1, 100); swordsMan.fight(); magician.fight(); 繼承 Role 類別 呼叫父類別建構式 呼叫父類別方法
  37. 37. 第 5 章 建構式、原型與類別 5-37 console.log(swordsMan.toString()); console.log(magician.toString()); 想繼承某個類別時,只要在 extends 右邊指定類別名稱就可以了,既有 的 JavaScript 建構式,像是 Object 等,也可以在 extends 右方指定;若要呼 叫父類別建構式,可以使用 super(),若要呼叫父類別中定義的方法,則是 在 super 來指定方法名稱。 如 果 要 呼 叫 父 類 別 中 以 符 號 定 義 的 方 法 , 則 使 用 [] , 例 如 super[Symbol.iterator](arg1, arg2, ...)。 類別語法的繼承,能夠繼承標準 API,而且內部實作特性以及特殊行為也會被 繼承,例如,可以繼承 Array,子型態實例的 length 行為,能隨著元素數量自 動調整。 如果沒有使用 constructor 定義建構式,會自動建立預設建構式,並自動 呼叫 super(),如果定義了子類別建構式,除非子類別建構式最後 return 了一 個與 this 無關的物件,否則要明確地使用 super()來呼叫父類建構式,不然 new 時會引發錯誤: > class A {} undefined > class B extends A { ... constructor() {} ... } undefined > new B(); ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor at new B (repl:2:16) …略 在子類建構式中試圖使用 this 之前,也一定要先使用 super()呼叫父類建 構式,也就是父類別定義的建構初始化流程必須先完成,再執行子類別建構式後續 的初始化流程。 若父類別與子類別中有同名的靜態方法,也可以使用 super 來指定呼叫父 類的靜態方法: > class A { ... static show() { ..... console.log('A show'); ..... }
  38. 38. 5-38 JavaScript 技術手冊 ... } undefined > class B extends A { ... static show() { ..... super.show(); ..... console.log('B show'); ..... } ... } undefined > B.show(); A show B show undefined > 5.3.4 super 與 extends 如果是來自基於類別的語言開發者,知道先前討論的繼承語法,大概就足 夠了,當然,JavaScript 終究是個基於原型的語言,以上的繼承語法,很大成 份是語法蜜糖,也大致上可以對照至基於原型的寫法,透過原型物件的設定與 操作,也可以影響既定的類別定義。 只不過,既然決定使用基於類別來簡化程式的撰寫,非絕對必要的話,不 建議又混合基於原型的操作,那只會使得程式變得複雜,若已經使用基於類別 的語法,又經常地操作原型物件,這時需要的不會是類別,建議還是直接暢快 地使用基於原型方式就好了。 當然,如果對原型夠瞭解,是可以來玩玩一些試驗,接下來的內容純綷是 探討,若不感興趣,可以直接跳過,不會影響後續章節的內容理解。 super 其實是個語法糖,在不同的環境或操作中,代表著不同的意義。在 建構式以函式方式呼叫,代表著呼叫父類別建構式,在 super()呼叫父類別建 構式之後,才能存取 this,這是因為建構式裏的 super()是為了創造 this,以 及它參考的物件,更具體地說,就是最頂層父類別建構式 return 的物件,物件 產生之後,才由父類別至子類別,逐層執行建構式中定義的初始流程。 如果子類別建構式沒有 return 任何物件,就是傳回 this,這就表示如果 子類建構式中沒有 return 與 this 無關的物件時,一定要呼叫 super,不然就 會因為不存在 this 而引發錯誤。
  39. 39. 第 5 章 建構式、原型與類別 5-39 至 於 透 過 super 取 得 某 個 特 性 的 話 , 可 以 將 super 視 為 父 類 別 的 prototype: > class A {} undefined > A.prototype.foo = 10; 10 > class B extends A { ... show() { ..... console.log(super.foo); ..... } ... } undefined > new B().show(); 10 undefined > 除了透過 super 呼叫父類別方法之外,其實還可以透過 super 設定特性, 不過試圖透過 super 來設定特性時,會是在實例本身上設定,也就是這個時候 的 super 就等同於 this: > class A {} undefined > A.prototype.foo = 10; 10 > class B extends A { ... show() { ..... console.log(super.foo); ..... super.foo = 100; // 相當於 this.foo = 100; ..... console.log(super.foo); // 還是取 A.prototype.foo ..... console.log(this.foo); ..... } ... } undefined > new B().show(); 10 10 100 undefined > 就程式碼閱讀上來說,super.foo = 100 可以解釋成,在父類別建構式傳 回的物件上設定特性吧! 如果用在 static 方法中,那麼 super 代表著父類別: > class A { ... static show() {
  40. 40. 5-40 JavaScript 技術手冊 ..... console.log('A show'); ..... } ... } undefined > class B extends A { ... static show() { ..... console.log(super.name); ..... } ... } undefined > B.show(); A undefined > 這就可以來探討一個有趣的問題,如果只定義 class A {}時,A 繼承哪個 類別呢?若開發者有基於類別的語言經驗,可能會想是否相當於 class A extends Object {}?若就底層技術來說,class A {}時沒有繼承任何類別: > class A { ... static show() { ..... console.log(super.name); // 結果是空字串 ..... } ... } undefined > class B extends Object { ... static show() { ..... console.log(super.name); // 結果是 'Object' ..... } ... } undefined > A.show(); undefined > B.show(); Object undefined > 這是因為 ES6 以後提供的類別語法,終究就只是模擬類別,本質上,每個 類別就是個函式,就像 ES6 之前利用 function 來定義建構式那樣: > A.__proto__ === Function.prototype; true > 使用 extends 指定繼承某類別時,子類別本質上也是個函式 ,而它的 __proto__會是 extends 的對象:
  41. 41. 第 5 章 建構式、原型與類別 5-41 > B.__proto__ === Object; true > class C extends B {} undefined > C.__proto__ === B; true > 如此一來,若父類別定義了 static 方法,透過子類別也可以呼叫,而且以 範 例 中 的 原 型 鏈 來 看 , 最 後 一 定 有 個 類 別 的 __proto__ 指 向 Function.prototype,也就是說,每個類別都是 Function 的實例,在 ES6 前, 每個建構式都是 Function 實例,在 ES6 以後,並沒有為類別創建一個類型。 或者應該說「類別」這名詞只是個晃子,底層都是 Function 實例;extends 實 際 上 也 不 是 繼 承 類 別 , 當 class C extends P {} 時 , 其 實 是 將 C.prototype.__proto__設為 P.prototype。 從原型來看,class A {}時,A.prototype.__proto__是 Object.prototype, 而 class B extends Object {} 時 , B.prototype.__proto__ 也 是 Object.prototype,extends 實際上還是在處理原型。 > class A {} undefined > A.prototype.__proto__ === Object.prototype true > class B extends Object {} undefined > B.prototype.__proto__ === Object.prototype true > 你 甚 至 可 以 透 過 class Base extends null 的 方 式 , 令 Base.prototype.__proto__為 null,只是作用不大,或許可用來建立 一個不繼承任何方法的物件吧!例如: class Base extends null { constructor() { return Object.create(null); } } 就結論來說,ES6 提供類別語法的目的,是為了打算基於類別的典範來設 計時,可以在程式碼的撰寫與閱讀上清楚易懂;然而,類別語法終究只是模擬,
  42. 42. 5-42 JavaScript 技術手冊 JavaScript 本質上還是基於原型,在類別語法不如人意,覺得其行為詭異,或 無法滿足需求時,回歸基於原型的思考方式,往往就能理解其行為何以如此, 也能進一步採取適當的措施,令程式碼在可以滿足需求的同時,同時兼顧日後 的可維護性。 5.4 重點複習 與 new 結合使用的函式,在 JavaScript 稱為建構式,目的是封裝物件建構的 流程;慣例上,建構式的名稱首字母會大寫。 建構式不是類別,JavaScript 在 ES6 之前沒有類別語法,ES6 雖然開始提供 類別語法,然而本質上仍只是「模擬」類別,ES6 以後,JavaScript 並沒有變成 基於類別的語言! 每個物件都是某建構式的實例,基本上可以從物件的 constructor 特性得知 實例的建構式,不過要小心的是,有些情況下,物件的 constructor 不一定指向 其建構式(例如原型物件)。 建構式基本上無需撰寫 return,如果建構式使用 return 指定了傳回值,該 傳回值就會被當作建構的結果。 如果函式中撰寫了 new.target,在使用 new 建構實例時,new.target 代 表了建構式(或類別)本身,否則就會是 undefined。 從 ES5 開 始,每個特性都會有 value、 writable、 enumerable 與 configurable 四個屬性設定;在查詢或設定屬性時,這四個屬性會聚合在物件 上,稱為特性描述器,可以使用 Object.getOwnPropertyDescriptor()來 取得特性描述器的資訊。 Object.getOwnPropertyDescriptor()只是用來取得特性描述器,傳回 的物件只是描述,對該物件修改並不會影響特性本身,想要修改特性本身的屬性, 必須透過 Object.defineProperty()或 Object.defineProperties()。 ES5 提 供 了 Object.preventExtensions() 與 Object.isExtensible(),可以限定或測試物件的擴充性。
  43. 43. 第 5 章 建構式、原型與類別 5-43 每個函式實例都會有個 prototype 特性,基本上是 Object 的實例,本身沒 有任何特性,不過 prototype 物件的 constructor 特性會參考函式本身。 ES6 以後,規範 ECMAScript 的實作必須支援__proto__。如果不想要修 改__proto__來指定原型,ES6 提供了 Object.setPrototypeOf()函式。 存取物件的特性時,JavaScript 會先在實例本身尋找,如果有就使用,沒有的 話,就會看看實例的原型物件上有沒有該特性,因此,對於不需要個別實擁有,而 是可以各個實例間共用的特性,可以定義在建構式的 prototype。 如果要在原型上新增特性,建議將特性設為不可列舉。 instanceof 用來確認物件是否為某建構式的實例,某些程度上並不可靠! Object.create() 函 式 會 建 立 新 物 件 , 物 件 的 原 型 將 被 設 為 呼 叫 Object.create()時指定的原型物件。 存取物件的特性時,會先在實例本身尋找,如果有就使用,沒有的話,就會看 看實例的原型物件上有沒有該特性,如果原型物件上也沒有,就看看原型物件的原 型物件,也就是看看原型物件的建構式是哪個,進一步查看該建構式的 prototype 上有沒有該特性,這種查詢特性的方式,會一直持續到 Object.prototype 為 止,這一連串的原型就稱為原型鏈。 obj instanceof Constructor 這種語法,嚴格來說,預設是用來確認可 否在 obj 的原型鏈上,找到 Constructor.prototype。 在多數的情況下,檢查型態而給予不同的流程行為,對於程式的維護性有著不 良的影響,應該避免。 ES6 的類別語法,主要是提供標準化的類別模擬方式,透過語法蜜糖令程式碼 變得簡潔一些。 使用 class 定義的 Account 只能使用 new 來建立實例,直接以函式的呼 叫方式,像是 Account(...)、Account.call(...)或 Account.apply(...) 都會發生 TypeError。 類別上定義的設值、取值方法預設是不可列舉(畢竟本質上是方法)。
  44. 44. 5-44 JavaScript 技術手冊 ES6 提供了模擬類別的標準方式,而在繼承這方面,可以使用 extends 來模 擬基於類別的繼承。 如果沒有使用 constructor 定義建構式,會自動建立預設建構式,並自動呼 叫 super(),如果定義了子類別建構式,除非子類別建構式最後 return 了一個 與 this 無關的物件,否則要明確地使用 super()來呼叫父類建構式,不然 new 時會引發錯誤。 在子類建構式中試圖使用 this 之前,一定要先使用 super()呼叫父類建構 式,也就是父類別定義的建構初始化流程必須先完成,再執行子類別建構式後續的 初始化流程。 避免使用原型鏈機制來實現標準 API 的繼承,因為特殊行為不會被繼承,例如 若繼承 Array,子型態實例的 length 特性,並不會隨著元素數量自動維護。 類別語法的繼承,能夠繼承標準 API,而且內部實作特性以及特殊行為也會被 繼承,例如,可以繼承 Array,子型態實例的 length 行為,能隨著元素數量自 動調整。
  45. 45. 第 5 章 建構式、原型與類別 5-45 課後練習 實作題 1. ES5 提供 Object.seal()函式,可以對物件加以彌封,請自行實作出相同 功能的 seal()函式。 2. ES5 提供 Object.freeze()函式,可以對物件加以凍結,請自行實作出相 同功能的 freeze()函式。 3. 在 5.1.3 的 immutable.js 範例中,使用建構式定義了 ImmutableList,請 使用類別實作出相同功能,並令其實例可以搭配 for..of 來迭代元素。

×