要說為何基於原型的 JavaScript 中,始終有人追求基於類別的模擬,主要的原因之一,大概就是在實現繼承時,基於原型的方式,是許多開發者難以掌握,或者實作上複雜、難以閱讀的地方,因而寄望在基於類別的模擬下,在繼承這方面能夠有更直覺、更加簡化、更容易掌握的方式。
ES6 提供了定義(模擬)類別時的標準化方式,而在繼承這方面,可以使用 extends
來實現。例如在〈模擬類別的封裝與繼承〉中看到的繼承模擬:
function Circle(x, y, r) {
this.x = x;
this.y = y;
this.r = r;
}
Circle.PI = 3.14159; // 相當於Java類別的靜態方法
Circle.prototype.area = function() {
return this.r * this.r * Circle.PI;
};
Circle.prototype.toString = function() {
var text = [];
for(var p in this) {
if(typeof this[p] != 'function') {
text.push(p + ':' + this[p]);
}
}
return '[' + text.join() + ']';
};
function Cylinder(x, y, r, h) {
Circle.call(this, x, y, r); // 呼叫父建構式
this.h = h;
}
// 原型繼承
Cylinder.prototype = new Circle();
// 設定原型物件之constructor為目前建構式
Cylinder.prototype.constructor = Cylinder;
// 以下在 new 時會再建構,不需要留在原型物件上
delete Cylinder.prototype.x;
delete Cylinder.prototype.y;
delete Cylinder.prototype.r;
// 共用的物件方法設定在 prototype 上
Cylinder.prototype.volumn = function() {
return this.area() * this.h;
};
在 ES6 中可以寫成:
class Circle {
constructor(x, y, r) {
this.x = x;
this.y = y;
this.r = r;
}
area() {
return this.r * this.r * Circle.PI();
}
toString() {
let text = [];
for(let p in this) {
if(typeof this[p] != 'function') {
text.push(p + ':' + this[p]);
}
}
return '[' + text.join() + ']';
}
static PI() {
return 3.14159;
}
}
class Cylinder extends Circle {
constructor(x, y, r, h) {
super(x, y, r); // 呼叫父建構式
this.h = h;
}
volumn() {
return this.area() * this.h;
}
}
let cylinder = new Cylinder(0, 0, 10, 5);
console.log(cylinder.area());
console.log(cylinder.toString());
console.log(cylinder.volumn());
console.log(Cylinder.PI());
如果熟悉基於類別的繼承,對上面的程式同樣無需做太多的解釋,而這邊也看到了 super
在建構式中,可以用來呼叫父建構式,這是過去基於原型模擬類別繼承時,難以做到的功能,而子類別也可以查找到父類中的 static
方法。
如果定義了子類別建構式,除非子類建構式最後 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 't
his' or returning from derived constructor
at new B (repl:2:16)
at repl:1:1
at ContextifyScript.Script.runInThisContext (vm.js:50:33)
at REPLServer.defaultEval (repl.js:240:29)
at bound (domain.js:301:14)
at REPLServer.runBound [as eval] (domain.js:314:12)
at REPLServer.onLine (repl.js:441:10)
at emitOne (events.js:121:20)
at REPLServer.emit (events.js:211:7)
at REPLServer.Interface._onLine (readline.js:282:10)
>
在子類建構式中試圖使用 this
之前,也一定要先使用 super
呼叫父類建構式,就類別風格來說,可以想成父類建構初始化必須先完成,再執行子類別初始化;如果沒有子類沒有定義建構式,自動加入的建構式中會呼叫父類建構式。
super
也可以用在方法之中,這可用來指定呼叫父類中定義的方法,例如:
> class A {
... toString() {
..... return 'A';
..... }
... }
undefined
> class B extends A {
... toString() {
..... return super.toString() + 'B'
..... }
... }
undefined
> let b = new B();
undefined
> b.toString();
'AB'
>
而在 ES6 中要繼承內建的型態變得簡單多了:
> class MyArray extends Array {}
undefined
> let myArray = new MyArray();
undefined
> myArray[0] = 1;
1
> myArray[1] = 10;
10
> myArray.length;
2
> myArray instanceof Array;
true
>
若父類與子類中有同名的靜態方法,可以使用 super
來指定呼叫父類的靜態方法:
> class A {
... static show() {
..... console.log('A show');
..... }
... }
undefined
> class B extends A {
... static show() {
..... super.show();
..... console.log('B show');
..... }
... }
undefined
> B.show();
A show
B show
undefined
>
如果你來自基於類別的某個物件導向語言,知道這些大概就蠻足夠了,當然,JavaScript 終究是個基於原型的物件導向語言,以上的繼承語法,很大成份是語法蜜糖,也大致上可以對照至基於原型的寫法,你反過來透過原型物件的設定與操作,也可以影響既定的類別定義。
只不過,既然決定使用基於類別來簡化程式的撰寫,非絕對必要的話,不建議混合基於原型的操作,那只會使得程式變得複雜,如果已經使用基於類別的語法,又經常大量地操作原型物件,那麼建議還是放棄基於類別的語法,直接暢快地使用基於原型就好了。
當然,如果對原型夠瞭解,是可以來玩玩一些試驗。
首先是 super
,它是個語法糖,不是個內建變數,在不同的環境或操作中,代表著不同的意義。
在建構式呼叫的話,基本上代表著呼叫父類建構式,而在子類建構式中,要用 super
呼叫父類建構式,在這之後才能存取 this
,這是因為 ES6 中的 super()
主要是為了創造 this
參考的物件(更具體地說,就是最頂層父類建構式 return
的物件),然後再從父至子逐層執行初始流程,這點跟基於原型時實作繼承的方式就有差異了,基於原型時實作繼承時,會先在子建構式中創造出 this
參考的物件,然後再呼叫父初始流程。
如果子類建構式沒有 return
任何物件,那麼隱含地 return this
,這就表示如果子類建構式中沒有 return
與 this
無關的物件時,一定要呼叫 super
,不然就會發生錯誤。
透過 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
來設定特性時,這時代表的是在父類建構式傳回的物件上設定特性,然而在子類中,父類建構式傳回的物件就會是 this
參考的物件,因此這個時候的 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
>
如果用在 static
方法中,那麼 super
代表著父類:
> class A {
... static show() {
..... console.log('A show');
..... }
... }
undefined
> class B extends A {
... static show() {
..... console.log(super.name);
..... }
... }
undefined
> B.show();
A
undefined
>
這就可以來探討一個有趣的問題,當我寫 class A {}
時,它是繼承哪個類別呢?ES6 中,只要是可以 new
的對象,就可以作為 extends
的對象,在其他物件導向程式語言中,你可能會想是是不是相當於 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);
..... }
... }
undefined
> A.show();
undefined
> B.show();
Object
undefined
>
然而,就原型鏈繼承的語義來看,是繼承自 Object
沒錯:
> new A().__proto__.__proto__ === Object.prototype;
true
> new B().__proto__.__proto__ === Object.prototype;
true
>
然而,就 A
的 __proto__
來看,A
只是一個普通函式,就像沒有 ES6 的 class
語法前,利用 function
來定義建構式那樣:
> A.__proto__ === Function.prototype;
true
>
當使用 extends
指定繼承某個可以 new
的對象時,__proto__
會是 extends
的對象:
> B.__proto__ === Object;
true
> class C extends B {}
undefined
> C.__proto__ === B;
true
>
如果想要判斷 class
定義下的繼承關係,可以透過類別的 __proto__
,如果一路向上,最後一定會是 Function.prototype
,也就是最後一定會是個普通函式,例如,就算是 class B extends Object {}
,B.__proto__
會是 Object
,而 Object.__proto__
是 Function.prototype
,因為原生的 Object
本來就是個普通函式。
照這來看,ES6 的類別本身,沒有定義一個頂層的基礎類別,任何沒有 extends
的 class
定義,都會是個基礎類別,行為上就像是個普通函式,畢竟 JavaScript 本來就是個基於原型的語言。
一個特殊的情況是 extends null
:
> class A {}
undefined
> A.__proto__ === Function.prototype;
true
> A.prototype.__proto__ === Object.prototype;
true
> class B extends null {}
undefined
> B.__proto__ === Function.prototype;
true
> B.prototype.__proto__ === undefined;
true
> new B();
TypeError: Super constructor null of B is not a constructor
at new B (repl:1:1)
at repl:1:1
at ContextifyScript.Script.runInThisContext (vm.js:50:33)
at REPLServer.defaultEval (repl.js:240:29)
at bound (domain.js:301:14)
at REPLServer.runBound [as eval] (domain.js:314:12)
at REPLServer.onLine (repl.js:441:10)
at emitOne (events.js:121:20)
at REPLServer.emit (events.js:211:7)
at REPLServer.Interface._onLine (readline.js:282:10)
>
就語義上來說,extends null
是真正沒有繼承任何類別(或者說也不是個類別了),最後也不會有普通函式的行為(雖然 B.__proto__
是參考至 Function.prototype
),而且原型鏈最後中斷了,B.prototype.__proto__
會是 undefined
,這意謂著 B.prototype.toString
也會傳回 undefined
(就像 null
實際上也不能做什麼),這樣的類別也沒辦法拿來建構(也不能當普通函式呼叫),除非明確定義 constructor()
,並在最後 return
一個與與目前類別無關的物件。
> class C extends null {
... constructor() {}
... }
undefined
> new C();
ReferenceError: Must call super constructor in derived class before accessing 't
his' or returning from derived constructor
at new C (repl:2:16)
at repl:1:1
at ContextifyScript.Script.runInThisContext (vm.js:50:33)
at REPLServer.defaultEval (repl.js:240:29)
at bound (domain.js:301:14)
at REPLServer.runBound [as eval] (domain.js:314:12)
at REPLServer.onLine (repl.js:441:10)
at emitOne (events.js:121:20)
at REPLServer.emit (events.js:211:7)
at REPLServer.Interface._onLine (readline.js:282:10)
> class D extends null {
... constructor() {
..... return {}
..... }
... }
undefined
> new D();
{}
>
一個 extends null
的類別可以做什麼呢?我想得到的是定義一個 Null extends null {}
,用來真正代表 null
型態吧!只是對於動態定型的 JavaScript 來說,這樣的意義並不大,只能說是特別為 null
做出的邊角案例(Corner case)考量。
可以玩的原型探討還有很多,而且會讓你迷失方向…別忘了,ES6 基於類別的語法終究只是模擬,試圖從原型鏈等機制上,來理解 ES6 基於類別的語法是不明智的,因為很大的成份是語法糖,而且看來在這過程中原型鏈也被改寫過,如果你打算使用 ES6 基於類別的語法來實現物件導向,以便約束基於原型時的過度彈性,那就用基於類別的想法來看待。
如果用了基於類別的語法,卻又老是在那邊逐磨基於原型時的原理機制,那麼建議就放棄 ES6 的類別語法吧!