JavaScript 是基於原型的物件導向典範,然而,模擬類別的需求一直都在,只是各自有不同的模擬方式與風格,這就造成了不同風格間要互動合作時的不便。
另一方面,雖然基於原型的 JavaScript 可以很有彈性地模擬不同風格的類別,然而,有些特性的模擬有其困難,像是在繼承時能透過 super 之類的方式呼叫父類方法等。
在基本的類別模擬需求上,為了能提供一致的風格基礎,也為了直接在語法上提供功能,以便能解決過去模擬類別時遇到的一些困難,ES6 提供了類別語法,若它的語法能解決需求就會建議採用,當然,若需要的類別特性無法使用 ES6 類別語法來實現,基於原型的方式仍然適用。
以〈模擬類別的封裝與繼承〉中看到的例子來說:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.toString = function() {
return '[' + this.name + ',' + this.age + ']';
};
var p = new Person('Justin', 35);
console.log(p.name); // Justin
在 ES6 中可以寫為:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
toString() {
return `[${this.name}, ${this.age}`;
}
}
let p = new Person('Justin', 35);
console.log(p.name); // Justin
如果你熟悉其他語言中的類別語法,看到這個 ES6 類別,應該馬上就能理解它的意義,本質上來說,上面的類別語法很大的成份,可以視為前一個基於原因的範例之語法蜜糖。
對 ES6 類別來說,Person
本身是個 Function
實例,toString
則是定義在 Person.prototype
上的一個特性,而 Person.prototype.constructor
參考的就是 Person
,這些都與 ES5 中對應的定義相同,如果透過 Person.prototype
添加特性,那麼 Person
的實例也會找得到該特性;你也可以直接將 toString
參考的函式指定給某個變數,或者是指定為另一物件的特性,透過該物件來呼叫函式,該函式的 this
一樣是依呼叫者而決定;每個透過 new Person(...)
建構出來的實例,本身的原型(__proto__
)也都是參考至 Person.prototype
。
然而不同的是,使用 class
定義的 Person
只能使用 new
來建立實例,直接使用 Person(...)
、Person.call(...)
或 Person.apply(...)
都會發生 TypeError
,而 Person
也不會是定義在全域物件上的一個特性,另外,class
定義的名稱,就像 let
宣告的名稱那樣,不會有 Hoist 的效果,因此在定義 Person
類別之前,就嘗試 new Person(...)
,會發生 ReferenceError
。
另一方面,class
中定義的方法,雖然是等同於定義在 prototype
上,然而特性描述器上的 enumerable
為 false
,因此 for in
、Object.hasOwnProperty
、Object.keys
無法列舉,只能透過 Object.getOwnPropertyNames
,因為這個函式會一併取得可列舉與無法列舉的特性名稱。
在類別中,constructor
定義了建構式,如果類別中沒有撰寫 constructor
,也會自動加入一個無參數的 constructor() {}
,constructor
最後隱含地 return this
,如果在 constructor
明確地 return
某個物件,那麼 new
的結果就會是該物件。
在 ES6 的類別中也可以使用 []
來定義方法,[]
中可以是字串、運算式的結果或者是 Symbol
,例如:
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let i = this.start;
let end = this.end;
return {
next() {
return i < end ?
{value: i++, done: false} :
{value: undefined, done: true}
}
};
}
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]
你也可以在 class
中定義產生器函式:
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 的類別語法下,定義一個特性的 setter、getter 變得比較容易了,例如:
class Person {
constructor(name, age) {
this.__name__ = name;
this.__age__ = age;
}
toString() {
return `[${this.__name__}, ${this.__age__}`;
}
get name() {
return this.__name__;
}
get age() {
return this.__age__;
}
}
var p = new Person('Justin', 35);
console.log(p.name); // Justin
如果要定義 getter 的話,在方法前加上 get
,若是定義 setter 的話,在方法前使用 set
,上頭模擬了物件的私有成員,在 ES6 類別中,並沒有定義私有成員的語法,你還是要以模擬的方式來定義。
在 ES6 的類別中,若方法前加上 static
,那麼該方法會是個靜態方法,也就是以類別名稱為名稱空間的一個函式:
class Foo {
static orz() {
}
}
與自行定義 Foo
函式,然而在函式上定義 orz
特性不同的是,在 class
上定義的靜態方法,子類別可以便於找到而使用,例如若有個 class Foo2 extends Foo {}
,那麼 Foo2.orz()
是可以呼叫的,ES6 中並沒有定義靜態特性的方式,因此仍只能使用 Foo.CONT = 123
這樣的方式來模擬。
類別也可以使用運算式的方式來建立,必要時也可以給予名稱:
> 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' }
>
在 ES6 中新增了 new.target
,如果函式或類別的建構式中有 new.target
,在使用 new
建構實例時,new.target
代表了建構式或類別本身,否則就會是 undefined
,因此,在傳統的建構式定義時,可以如下檢查,以達到強制使用 new
來建構物件的效果:
function Person(name, age) {
if (new.target === Person) {
this.name = name;
this.age = age;
} else {
throw new TypeError(
"Constructor Person cannot be invoked without 'new'");
}
}
在方法中,如果要遞迴呼叫,必須在方法前加上 this
,明確指定是呼叫自身方法,否則會嘗試呼叫範圍內可找到的函式,若找不到就是 ReferenceError
。