定義類別


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。