定義類別


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 上,然而特性描述器上的 enumerablefalse,因此 for inObject.hasOwnPropertyObject.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