你懂 JavaScript 嗎?#21 ES6 Class

ES6

本文主要是探討 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 機制解決了什麼問題呢?

看起來 Class 一切都很美好!But?

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"

這程式碼看起來有點難理解,補個模型圖好了 大概就是這個物件指向那個物件這樣

ES6 Class 模型

等等,執行 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 雖然帶給我們簡潔的語法,但背後是有代價的,例如

References


同步發表於2019 鐵人賽


YA

「你所不知道的 JS」系列書的第二集「範疇與閉包 / this 與物件原型」終於讀完了!跟我一起歡呼吧!明天開始要進入第三集「非同步處理與效能」,敬請拭目以待 (๑•̀ㅂ•́)و✧

鐵人賽還有 9 天,加油加油加油!

加油

p.s. 感謝 yvonne11 友情出借愛書 (*´∀`)~♥ 也推薦閱讀她的鐵人賽作品「React 30 天 系列」,讚讚!

範疇與閉包 / this 與物件原型


You-Dont-Know-JS ES6 javascript 2019鐵人賽 你所不知道的JS 你懂JavaScript嗎? 鐵人賽 You-Dont-Know-JS-this-and-Object-Prototypes 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文