More Related Content
Similar to JavaScript 技術手冊第 5 章 (20)
More from Justin Lin (20)
JavaScript 技術手冊第 5 章
- 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. 第 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. 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
}
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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 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. 第 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. 5-44 JavaScript 技術手冊
ES6 提供了模擬類別的標準方式,而在繼承這方面,可以使用 extends 來模
擬基於類別的繼承。
如果沒有使用 constructor 定義建構式,會自動建立預設建構式,並自動呼
叫 super(),如果定義了子類別建構式,除非子類別建構式最後 return 了一個
與 this 無關的物件,否則要明確地使用 super()來呼叫父類建構式,不然 new
時會引發錯誤。
在子類建構式中試圖使用 this 之前,一定要先使用 super()呼叫父類建構
式,也就是父類別定義的建構初始化流程必須先完成,再執行子類別建構式後續的
初始化流程。
避免使用原型鏈機制來實現標準 API 的繼承,因為特殊行為不會被繼承,例如
若繼承 Array,子型態實例的 length 特性,並不會隨著元素數量自動維護。
類別語法的繼承,能夠繼承標準 API,而且內部實作特性以及特殊行為也會被
繼承,例如,可以繼承 Array,子型態實例的 length 行為,能隨著元素數量自
動調整。
- 45. 第 5 章 建構式、原型與類別 5-45
課後練習
實作題
1. ES5 提供 Object.seal()函式,可以對物件加以彌封,請自行實作出相同
功能的 seal()函式。
2. ES5 提供 Object.freeze()函式,可以對物件加以凍結,請自行實作出相
同功能的 freeze()函式。
3. 在 5.1.3 的 immutable.js 範例中,使用建構式定義了 ImmutableList,請
使用類別實作出相同功能,並令其實例可以搭配 for..of 來迭代元素。