你懂 JavaScript 嗎?#21 ES6 Class
28 Oct 2018本文主要是探討 ES6 Class 的美好與陷阱。
ES6 Class
關於 ES6 Class,我們先再次檢視先前提過的 Widget 與 Button 範例。
class Widget {
constructor(width, height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
}
render($where) {
this.$elem &&
this.$elem
.css({
width: `${this.width}px`,
height: `${this.height}px`,
})
.appendTo($where);
}
}
class Button extends Widget {
constructor(width, height, label = 'Default') {
super(width, height);
this.label = label;
this.$elem = $('<button>').text(this.label);
}
render($where) {
super.render($where);
this.$elem.click(this.onClick.bind(this));
}
onClick(e) {
console.log(`Button ${this.label} cliked!`);
}
}
…
…
我知道大家都沒有在看程式碼 XD 一定就是很快地滑了過去
…
…
ES6 的 Class 機制解決了什麼問題呢?
- 使用簡潔的 extends 達到繼承的目的,而非雜亂的
Object.create(..)
、.__proto__
、Object.setPrototypeOf(..)
,這樣能讓開發者更順利的擴充功能。 - 使用簡潔的 super 達到相對多型的目的,因此能順利的往父層或更上一層參考同名方法,解決了
.constructor
可能會被更動的問題。 - class 語法只能指定方法,不能設定屬性,這避免開發者誤將屬性(狀態或資料)放在類別中造成的共用問題,
這應該是內建的防呆機制。
…
…
看起來 Class 一切都很美好!But?
最重要的就是這個 But!
來看一些陷阱吧。
…
…
Class 依舊是利用 [[Prototype]]
委派機制來實作的,它只是個語法糖而已,也就是說,Class 並非如其他物件導向語言般在宣告時期靜態的複製定義,而只是物件間的連結,因此若不小心變更了父類別的方法或屬性,子類別與其實體都會受到影響。
如下,Dice 類別用於擲骰子得到點數,在標註為 (1) 之處,d1 骰出來的是小數,因此後續我們做了一些修正,希望後來新建立的物件 d2 能改成骰出正確的 1 - 10 的數字,結果改是改成功了,d2 真的可以正常運作(標註 (2)),但 d1 卻也被改到了(標註 (3))
class Dice {
constructor() {
this.num = Math.random();
}
rand() {
console.log(`Random: ${this.num}`);
}
}
var d1 = new Dice();
d1.rand(); // Random: 0.9602558780625552 (1)
Dice.prototype.rand = function () {
const result = Math.round(this.num * 10 + 1);
console.log(`Random: ${result}`);
};
var d2 = new Dice();
d2.rand(); // Random: 2 (2)
d1.rand(); // Random: 10 (3)
我們還是得回歸到最初的委派機制,而無法期待 JavaScript 的 Class 能如其他物件導向語言般運作了。
…
…
再來,JavaScript 的 Class 語法無法宣告屬性,只能宣告方法,因此若想宣告屬性以追蹤共用狀態,就只能回歸到 .prototype
了,而這就失去了語法糖的意義了-洩露了底層的實作細節。
class Dice {
constructor() {
this.num = Math.random();
Dice.prototype.count++;
console.log(`Count: ${Dice.prototype.count}`);
}
rand() {
const result = Math.round(this.num * 10 + 1);
console.log(`Random: ${result}`);
}
}
Dice.prototype.count = 0;
var d1 = new Dice(); // Count: 1
var d2 = new Dice(); // Count: 2
d1.count === d2.count; // true,的確是更新同一個值!
…
…
最後要來看的是 super-為了優化效能,super 是在宣告時期靜態綁定的,在某些狀況下,綁定會失敗,因此必須手動綁定。
class P {
foo() {
console.log('P.foo');
}
}
class C extends P {
foo() {
super.foo();
}
}
var c1 = new C();
c1.foo(); // "P.foo"
var D = {
foo: function () {
console.log('D.foo');
},
};
var E = {
foo: C.prototype.foo,
};
Object.setPrototypeOf(E, D); // 將 E 連結到 D 以進行委派
E.foo(); // "P.foo"
這程式碼看起來有點難理解,補個模型圖好了 大概就是這個物件指向那個物件這樣。
…
…
等等,執行 E.foo()
後,怎麼會是印出「P.foo
」而不是「D.foo
」!?
…
…
仔細看原始碼與模型圖,E.foo()
是連到 C.prototype.foo
,而 C 繼承 P,因此就會呼叫 P.foo()
最後印出「P.foo
」。原先預期 super 能因 E 委派給 D 而動態調整,但由於 super 是靜態綁定的,並不會更新,因此只能手動修改。
改進如下。
class P {
foo() {
console.log('P.foo');
}
}
class C extends P {
foo() {
super.foo();
}
}
var c1 = new C();
c1.foo(); // "P.foo"
var D = {
foo: function () {
console.log('D.foo');
},
};
var E = Object.create(D); // 將 E 連結到 D 以進行委派
E.foo = C.prototype.foo.toMethod(E, 'foo');
E.foo(); // "D.foo"
[[HomeObject]]
是類別的方法的內部屬性,用來儲存該方法所屬的物件,這個屬性是在宣告時就賦值了,並不會自動更新,因此,我們要手動修改這個部份。使用 Function.prototype.toMethod()
來重新綁定 foo 的 [[HomeObject]]
為 E,而 E 的 [[Prototype]]
是 D。
有興趣的可以參考這裡。
…
…
這本書的作者想要告訴我們…JavaScript 與其他類別導向語言最大的不同之處在於,它是「動態」的,任何物件的定義都可以在稍後執行時做修改,強迫 JavaScript 背離本性,假裝自己是個靜態語言,根本只是逃避理解 JavaScript 的基礎原理而已(是在罵讀者不用功嗎 XDDD)。
…
…
老話一句,「加油,好嗎?」
回顧
看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…
ES6 Class 雖然帶給我們簡潔的語法,但背後是有代價的,例如
- 若在執行時期不小心變更了父類別的方法或屬性,子類別與其實體都會受到影響。
- Class 語法無法宣告屬性,只能宣告方法,因此若想宣告屬性以追蹤共用狀態,就只能回歸到
.prototype
了,而這就失去了語法糖的意義-洩露了底層的實作細節。 - 為了優化效能,super 是在宣告時期靜態綁定的,在某些狀況下,綁定會失敗,因此必須手動綁定。
References
同步發表於2019 鐵人賽。
「你所不知道的 JS」系列書的第二集「範疇與閉包 / this 與物件原型」終於讀完了!跟我一起歡呼吧!明天開始要進入第三集「非同步處理與效能」,敬請拭目以待 (๑•̀ㅂ•́)و✧
…
…
鐵人賽還有 9 天,加油加油加油!
…
…
p.s. 感謝 yvonne11 友情出借愛書 (*´∀`)~♥ 也推薦閱讀她的鐵人賽作品「React 30 天 系列」,讚讚!