Successfully reported this slideshow.
Your SlideShare is downloading. ×

JavaScript 技術手冊第 5 章

Ad
Ad
Ad
Ad
Ad
Ad
Ad
Ad
Ad
Ad
5建構式、原型與類別
 認識建構式之作用
 理解原型鏈機制
 區別原型與類別典範
 善用類別語法
5-2 JavaScript 技術手冊
5.1 建構式
在物件導向典範中,物件會將相關的狀態、功能集合在一起,之後要設定
物件狀態、思考物件可用操作時,都會比較方便一些;JavaScript 支援物件導
向,在物件的建立、操作、調整等各方面提供...
第 5 章 建構式、原型與類別 5-3
console.log(acct1.toString()); // 顯示 (Justin Lin, 123-4567, 1000)
console.log(acct2.toString()); // 顯示...
Advertisement
Advertisement
Advertisement
Advertisement
Advertisement
Advertisement
Advertisement
Advertisement
Advertisement
Upcoming SlideShare
Javascript之昨是今非
Javascript之昨是今非
Loading in …3
×

Check these out next

1 of 45 Ad

JavaScript 技術手冊第 5 章

Download to read offline

提供第五章試讀!

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

提供第五章試讀!

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

Advertisement
Advertisement

More Related Content

Similar to JavaScript 技術手冊第 5 章 (20)

Advertisement

Recently uploaded (20)

Advertisement

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 來迭代元素。

×