15. 類別
目錄
請支持這本書:購買(PDF、EPUB、MOBI)捐款
(廣告,請不要封鎖。)

15. 類別



15.1 概觀

類別和子類別

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return `(${this.x}, ${this.y})`;
    }
}

class ColorPoint extends Point {
    constructor(x, y, color) {
        super(x, y);
        this.color = color;
    }
    toString() {
        return super.toString() + ' in ' + this.color;
    }
}

使用類別

> const cp = new ColorPoint(25, 8, 'green');

> cp.toString();
'(25, 8) in green'

> cp instanceof ColorPoint
true
> cp instanceof Point
true

在底層,ES6 類別並不是什麼新奇的事物:它們主要提供更方便的語法來建立舊式的建構函式。如果您使用 typeof,您會看到這一點

> typeof Point
'function'

15.2 要點

15.2.1 基底類別

在 ECMAScript 6 中,類別定義如下

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return `(${this.x}, ${this.y})`;
    }
}

您使用這個類別就像 ES5 建構函式一樣

> var p = new Point(25, 8);
> p.toString()
'(25, 8)'

事實上,類別定義的結果是一個函式

> typeof Point
'function'

但是,您只能透過 new 呼叫類別,不能透過函式呼叫(稍後會說明這個背後的原理)

> Point()
TypeError: Classes can’t be function-called
15.2.1.1 類別定義的成員之間沒有分隔符號

類別定義的成員之間沒有分隔符號。例如,物件文字的成員以逗號分隔,而逗號在類別定義的頂層是不合法的。分號是允許的,但會被忽略

class MyClass {
    foo() {}
    ; // OK, ignored
    , // SyntaxError
    bar() {}
}

分號是允許的,以準備可能包含以分號終止成員的未來語法。逗號是被禁止的,以強調類別定義與物件文字不同。

15.2.1.2 類別宣告不會提升

函式宣告會提升:進入作用域時,在其中宣告的函式會立即可用,與宣告發生在哪裡無關。這表示你可以呼叫稍後宣告的函式

foo(); // works, because `foo` is hoisted

function foo() {}

相反地,類別宣告不會提升。因此,類別只在執行到達其定義並評估後才會存在。事先存取它會導致ReferenceError

new Foo(); // ReferenceError

class Foo {}

此限制的原因是類別可以有一個extends子句,其值是任意表達式。該表達式必須在適當的「位置」評估,其評估無法提升。

沒有提升比你想像的限制更少。例如,出現在類別宣告之前的函式仍然可以參照該類別,但你必須等到類別宣告經過評估後才能呼叫函式。

function functionThatUsesBar() {
    new Bar();
}

functionThatUsesBar(); // ReferenceError
class Bar {}
functionThatUsesBar(); // OK
15.2.1.3 類別表達式

與函式類似,有兩種類別定義,兩種定義類別的方法:類別宣告類別表達式

與函式表達式類似,類別表達式可以是匿名的

const MyClass = class {
    ···
};
const inst = new MyClass();

也與函式表達式類似,類別表達式可以有僅在其內部可見的名稱

const MyClass = class Me {
    getClassName() {
        return Me.name;
    }
};
const inst = new MyClass();

console.log(inst.getClassName()); // Me
console.log(Me.name); // ReferenceError: Me is not defined

最後兩行顯示Me不會成為類別外部的變數,但可以在類別內部使用。

15.2.2 類別定義主體內

類別主體只能包含方法,但不能包含資料屬性。原型擁有資料屬性通常被視為反模式,所以這只是強制執行最佳實務。

15.2.2.1 constructor、靜態方法、原型方法

讓我們探討類別定義中常見的三種類別方法。

class Foo {
    constructor(prop) {
        this.prop = prop;
    }
    static staticMethod() {
        return 'classy';
    }
    prototypeMethod() {
        return 'prototypical';
    }
}
const foo = new Foo(123);

此類別宣告的物件圖如下。理解它的提示:[[Prototype]]是物件之間的繼承關係,而prototype是一個常規屬性,其值是一個物件。屬性prototype僅相對於使用其值作為其建立的實例的原型的新運算子是特殊的。

首先,偽方法constructor此方法很特別,因為它定義表示類別的函式

> Foo === Foo.prototype.constructor
true
> typeof Foo
'function'

它有時稱為類別建構函式。它具有常規建構函式函式沒有的功能(主要是透過super()建構呼叫其超建構函式的功能,稍後會說明)。

其次,靜態方法。靜態屬性(或類別屬性)是 Foo 本身的屬性。如果您在方法定義前加上 static,您將建立一個類別方法

> typeof Foo.staticMethod
'function'
> Foo.staticMethod()
'classy'

第三,原型方法。 Foo原型屬性Foo.prototype 的屬性。它們通常是方法,並由 Foo 的實例繼承。

> typeof Foo.prototype.prototypeMethod
'function'
> foo.prototypeMethod()
'prototypical'
15.2.2.2 靜態資料屬性

為了及時完成 ES6 類別,它們被刻意設計成「極簡」。這就是為什麼您目前只能建立靜態方法、getter 和 setter,但不能建立靜態資料屬性。有一個建議將它們加入語言中。在該建議被接受之前,有兩個解決方法可以使用。

首先,您可以手動加入一個靜態屬性

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
}
Point.ZERO = new Point(0, 0);

您可以使用 Object.defineProperty() 來建立一個唯讀屬性,但我喜歡指定值的簡潔性。

其次,您可以建立一個靜態 getter

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    static get ZERO() {
        return new Point(0, 0);
    }
}

在兩種情況下,您都會取得一個屬性 Point.ZERO,您可以讀取它。在第一個情況中,每次都會傳回同一個實例。在第二個情況中,每次都會傳回一個新實例。

15.2.2.3 getter 和 setter

getter 和 setter 的語法就像 ECMAScript 5 物件字面值 中的一樣

class MyClass {
    get prop() {
        return 'getter';
    }
    set prop(value) {
        console.log('setter: '+value);
    }
}

您使用 MyClass 如下。

> const inst = new MyClass();
> inst.prop = 123;
setter: 123
> inst.prop
'getter'
15.2.2.4 運算方法名稱

如果您將方法名稱放在方括號中,您可以透過一個運算式來定義它。例如,下列定義 Foo 的方式都是等效的。

class Foo {
    myMethod() {}
}

class Foo {
    ['my'+'Method']() {}
}

const m = 'myMethod';
class Foo {
    [m]() {}
}

ECMAScript 6 中有幾個特殊方法的鍵是符號。運算方法名稱讓您可以定義此類方法。例如,如果一個物件有一個方法的鍵是 Symbol.iterator,它就是可迭代的。這表示它的內容可以透過 for-of 迴圈和其他語言機制來迭代。

class IterableClass {
    [Symbol.iterator]() {
        ···
    }
}
15.2.2.5 產生器方法

如果您使用星號 (*) 作為方法定義的前置詞,它就會變成一個產生器方法。產生器在定義其金鑰為 Symbol.iterator 的方法時特別有用。以下程式碼示範其運作方式。

class IterableArguments {
    constructor(...args) {
        this.args = args;
    }
    * [Symbol.iterator]() {
        for (const arg of this.args) {
            yield arg;
        }
    }
}

for (const x of new IterableArguments('hello', 'world')) {
    console.log(x);
}

// Output:
// hello
// world

15.2.3 子類化

extends 子句讓您可以建立現有建構函式的子類別 (可能已透過類別定義或尚未定義)

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return `(${this.x}, ${this.y})`;
    }
}

class ColorPoint extends Point {
    constructor(x, y, color) {
        super(x, y); // (A)
        this.color = color;
    }
    toString() {
        return super.toString() + ' in ' + this.color; // (B)
    }
}

同樣地,這個類別的用法符合您的預期

> const cp = new ColorPoint(25, 8, 'green');
> cp.toString()
'(25, 8) in green'

> cp instanceof ColorPoint
true
> cp instanceof Point
true

有兩種類別

有兩種使用 super 的方式

15.2.3.1 子類別的原型是超類別

在 ECMAScript 6 中,子類別的原型是超類別

> Object.getPrototypeOf(ColorPoint) === Point
true

這表示靜態屬性會被繼承

class Foo {
    static classMethod() {
        return 'hello';
    }
}

class Bar extends Foo {
}
Bar.classMethod(); // 'hello'

您甚至可以超呼叫靜態方法

class Foo {
    static classMethod() {
        return 'hello';
    }
}

class Bar extends Foo {
    static classMethod() {
        return super.classMethod() + ', too';
    }
}
Bar.classMethod(); // 'hello, too'
15.2.3.2 超建構函式呼叫

在衍生類別中,您必須先呼叫 super(),才能使用 this

class Foo {}

class Bar extends Foo {
    constructor(num) {
        const tmp = num * 2; // OK
        this.num = num; // ReferenceError
        super();
        this.num = num; // OK
    }
}

隱含地離開衍生建構函式而不呼叫 super() 也會導致錯誤

class Foo {}

class Bar extends Foo {
    constructor() {
    }
}

const bar = new Bar(); // ReferenceError
15.2.3.3 覆寫建構函式的結果

就像在 ES5 中,您可以透過明確傳回物件來覆寫建構函式的結果

class Foo {
    constructor() {
        return Object.create(null);
    }
}
console.log(new Foo() instanceof Foo); // false

如果你這樣做,this 是否已初始化並不重要。換句話說:如果你以這種方式覆寫結果,則不必在衍生建構函式中呼叫 super()

15.2.3.4 類別的預設建構函式

如果你未為基底類別指定 建構函式,則會使用下列定義

constructor() {}

對於衍生類別,會使用下列預設建構函式

constructor(...args) {
    super(...args);
}
15.2.3.5 子類化內建建構函式

在 ECMAScript 6 中,你終於可以子類化所有內建建構函式(ES5 有 解決方法,但這些方法有很大的限制)。

例如,你現在可以建立自己的例外類別(在大部分引擎中,這些類別會繼承堆疊追蹤的功能)

class MyError extends Error {
}
throw new MyError('Something happened!');

你也可以建立 Array 的子類別,其執行個體會正確處理 length

class Stack extends Array {
    get top() {
        return this[this.length - 1];
    }
}

var stack = new Stack();
stack.push('world');
stack.push('hello');
console.log(stack.top); // hello
console.log(stack.length); // 2

請注意,子類化 Array 通常不是最佳解決方案。通常最好建立你自己的類別(由你控制其介面),並委派給私有屬性中的 Array。

15.3 類別的私有資料

本節說明管理 ES6 類別私有資料的四種方法

  1. 將私有資料保留在類別 建構函式 的環境中
  2. 透過命名慣例(例如,加上底線前綴)標示私有屬性
  3. 將私有資料保留在 WeakMaps 中
  4. 使用符號作為私有屬性的金鑰

方法 #1 和 #2 在 ES5 中已經很常見,用於建構函式。方法 #3 和 #4 是 ES6 中的新功能。讓我們透過每種方法實作相同的範例四次。

15.3.1 透過建構函式環境的私有資料

我們的執行範例是類別 Countdown,它會在計數器(其初始值為 counter)達到零時呼叫回呼 action。兩個參數 actioncounter 應儲存在私有資料中。

在第一個實作中,我們將 actioncounter 儲存在類別建構函式的環境中。環境是內部資料結構,JavaScript 引擎會在每次進入新範圍(例如,透過函式呼叫或建構函式呼叫)時儲存參數和區域變數。以下是程式碼

class Countdown {
    constructor(counter, action) {
        Object.assign(this, {
            dec() {
                if (counter < 1) return;
                counter--;
                if (counter === 0) {
                    action();
                }
            }
        });
    }
}

使用 Countdown 的方式如下

> const c = new Countdown(2, () => console.log('DONE'));
> c.dec();
> c.dec();
DONE

優點

缺點

關於此技術的更多資訊:在「Speaking JavaScript」中的「建構函式環境中的私有資料(Crockford 隱私模式)」一節。

15.3.2 透過命名慣例的私有資料

下列程式碼將私有資料保留在名稱以底線開頭的屬性中

class Countdown {
    constructor(counter, action) {
        this._counter = counter;
        this._action = action;
    }
    dec() {
        if (this._counter < 1) return;
        this._counter--;
        if (this._counter === 0) {
            this._action();
        }
    }
}

優點

缺點

15.3.3 透過 WeakMaps 的私有資料

有一個涉及 WeakMaps 的巧妙技術,結合了第一種方法(安全性)和第二種方法(能夠使用原型方法)的優點。下列程式碼示範了此技術:我們使用 WeakMaps _counter_action 來儲存私有資料。

const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
    constructor(counter, action) {
        _counter.set(this, counter);
        _action.set(this, action);
    }
    dec() {
        let counter = _counter.get(this);
        if (counter < 1) return;
        counter--;
        _counter.set(this, counter);
        if (counter === 0) {
            _action.get(this)();
        }
    }
}

兩個 WeakMaps _counter_action 各自將物件對應到它們的私有資料。由於 WeakMaps 的運作方式,這不會阻止物件被垃圾回收。只要您讓 WeakMaps 對外界隱藏,私有資料就是安全的。

如果您想更安全,您可以將 WeakMap.prototype.getWeakMap.prototype.set 儲存在變數中,並呼叫它們(而不是方法,動態地)

const set = WeakMap.prototype.set;
···
set.call(_counter, this, counter);
    // _counter.set(this, counter);

如果惡意程式碼將這些方法替換為窺探我們私有資料的方法,您的程式碼就不會受到影響。但是,您僅受到在您的程式碼之後執行的程式碼的保護。如果它在您的程式碼之前執行,您無能為力。

優點

缺點

15.3.4 透過符號的私有資料

另一個私有資料儲存位置是金鑰為符號的屬性

const _counter = Symbol('counter');
const _action = Symbol('action');

class Countdown {
    constructor(counter, action) {
        this[_counter] = counter;
        this[_action] = action;
    }
    dec() {
        if (this[_counter] < 1) return;
        this[_counter]--;
        if (this[_counter] === 0) {
            this[_action]();
        }
    }
}

每個符號都是唯一的,這就是為什麼符號值屬性金鑰永遠不會與任何其他屬性金鑰衝突。此外,符號在某種程度上對外界隱藏,但並非完全如此

const c = new Countdown(2, () => console.log('DONE'));

console.log(Object.keys(c));
    // []
console.log(Reflect.ownKeys(c));
    // [ Symbol(counter), Symbol(action) ]

優點

缺點

15.3.5 進一步閱讀

15.4 簡單的混入

在 JavaScript 中使用子類別有兩個原因

類別對於實作繼承的用途有限,因為它們僅支援單一繼承(一個類別最多只能有一個超類別)。因此,不可能從多個來源繼承工具方法,它們都必須來自超類別。

那麼我們要如何解決這個問題?讓我們透過一個範例來探討解決方案。考慮一個企業的管理系統,其中 EmployeePerson 的子類別。

class Person { ··· }
class Employee extends Person { ··· }

此外,還有用於儲存和資料驗證的工具類別

class Storage {
    save(database) { ··· }
}
class Validation {
    validate(schema) { ··· }
}

如果我們可以像這樣包含工具類別,那就太好了

// Invented ES6 syntax:
class Employee extends Storage, Validation, Person { ··· }

也就是說,我們希望 EmployeeStorage 的子類別,而 Storage 應該是 Validation 的子類別,而 Validation 應該是 Person 的子類別。EmployeePerson 將只會用於類別的其中一個鏈。但是 StorageValidation 將會被多次使用。我們希望它們成為類別的範本,我們填入其超類別。此類範本稱為抽象子類別混入

在 ES6 中實作混入的一種方法是將其視為一個函式,其輸入值為超類別,其輸出值為延伸該超類別的子類別

const Storage = Sup => class extends Sup {
    save(database) { ··· }
};
const Validation = Sup => class extends Sup {
    validate(schema) { ··· }
};

在此,我們受益於 extends 子句的運算元不是固定的識別碼,而是一個任意表達式。使用這些混入,Employee 會像這樣建立

class Employee extends Storage(Validation(Person)) { ··· }

致謝。我所知道的這個技術的首次出現是 Sebastian Markbåge 的 Gist

15.5 類別的詳細資料

到目前為止,我們已經看過類別的基本要素。如果您有興趣了解底層是如何運作的,您只需要繼續閱讀。讓我們從類別的語法開始。以下是 ECMAScript 6 規格 A.4 節 中所示語法的略微修改版本。

ClassDeclaration:
    "class" BindingIdentifier ClassTail
ClassExpression:
    "class" BindingIdentifier? ClassTail

ClassTail:
    ClassHeritage? "{" ClassBody? "}"
ClassHeritage:
    "extends" AssignmentExpression
ClassBody:
    ClassElement+
ClassElement:
    MethodDefinition
    "static" MethodDefinition
    ";"

MethodDefinition:
    PropName "(" FormalParams ")" "{" FuncBody "}"
    "*" PropName "(" FormalParams ")" "{" GeneratorBody "}"
    "get" PropName "(" ")" "{" FuncBody "}"
    "set" PropName "(" PropSetParams ")" "{" FuncBody "}"

PropertyName:
    LiteralPropertyName
    ComputedPropertyName
LiteralPropertyName:
    IdentifierName  /* foo */
    StringLiteral   /* "foo" */
    NumericLiteral  /* 123.45, 0xFF */
ComputedPropertyName:
    "[" Expression "]"

兩個觀察

15.5.1 各種檢查

15.5.2 屬性的屬性

類別宣告會建立(可變)let 繫結。下表說明與特定類別 Foo 相關的屬性屬性

  可寫 可列舉 可設定
靜態屬性 Foo.* true false true
Foo.prototype false false false
Foo.prototype.constructor false false true
原型屬性 Foo.prototype.* true false true

備註

15.5.3 類別具有內部名稱

類別具有詞彙內部名稱,就像命名函式運算式一樣。

15.5.3.1 命名函式運算式的內部名稱

您可能知道命名函式運算式具有詞彙內部名稱

const fac = function me(n) {
    if (n > 0) {
        // Use inner name `me` to
        // refer to function
        return n * me(n-1);
    } else {
        return 1;
    }
};
console.log(fac(3)); // 6

命名函式運算式的名稱 me 會變成一個詞彙繫結變數,不受目前持有函式的變數影響。

15.5.3.2 類別的內部名稱

有趣的是,ES6 類別也具有詞彙內部名稱,您可以在方法(建構函式方法和一般方法)中使用。

class C {
    constructor() {
        // Use inner name C to refer to class
        console.log(`constructor: ${C.prop}`);
    }
    logProp() {
        // Use inner name C to refer to class
        console.log(`logProp: ${C.prop}`);
    }
}
C.prop = 'Hi!';

const D = C;
C = null;

// C is not a class, anymore:
new C().logProp();
    // TypeError: C is not a function

// But inside the class, the identifier C
// still works
new D().logProp();
    // constructor: Hi!
    // logProp: Hi!

(在 ES6 規範中,內部名稱是由 ClassDefinitionEvaluation 的動態語意 設定的。)

致謝:感謝 Michael Ficarra 指出類別具有內部名稱。

15.6 子類別化的詳細資訊

在 ECMAScript 6 中,子類別的建立方式如下。

class Person {
    constructor(name) {
        this.name = name;
    }
    toString() {
        return `Person named ${this.name}`;
    }
    static logNames(persons) {
        for (const person of persons) {
            console.log(person.name);
        }
    }
}

class Employee extends Person {
    constructor(name, title) {
        super(name);
        this.title = title;
    }
    toString() {
        return `${super.toString()} (${this.title})`;
    }
}

const jane = new Employee('Jane', 'CTO');
console.log(jane.toString()); // Person named Jane (CTO)

下一節將探討由前一個範例建立的物件結構。其後一節將探討 `jane` 如何配置和初始化。

15.6.1 原型鏈

前一個範例建立了以下物件。

原型鏈 是透過 [[Prototype]] 關係(一種繼承關係)連結的物件。在圖表中,您可以看到兩個原型鏈

15.6.1.1 左欄:類別(函式)

衍生類別的原型是它所延伸的類別。此設定的理由是您希望子類別繼承其超類別的所有屬性

> Employee.logNames === Person.logNames
true

基底類別的原型是 Function.prototype,它也是函式的原型

> const getProto = Object.getPrototypeOf.bind(Object);

> getProto(Person) === Function.prototype
true
> getProto(function () {}) === Function.prototype
true

這表示基底類別及其所有衍生類別(其原型)都是函式。傳統的 ES5 函式基本上是基底類別。

15.6.1.2 右欄:實例的原型鏈

類別的主要目的是設定此原型鏈。原型鏈以 Object.prototype 結束(其原型為 null)。這使得 Object 成為每個基底類別的隱含超類別(就實例和 instanceof 算子而言)。

此設定的理由是您希望子類別的實例原型繼承超類別實例原型的所有屬性。

順帶一提,透過物件文字建立的物件也具有原型 Object.prototype

> Object.getPrototypeOf({}) === Object.prototype
true

15.6.2 配置和初始化實例

類別建構函式之間的資料流程不同於 ES5 中子類別建立的正規方式。在底層,它大致如下。

// Base class: this is where the instance is allocated
function Person(name) {
    // Performed before entering this constructor:
    this = Object.create(new.target.prototype);

    this.name = name;
}
···

function Employee(name, title) {
    // Performed before entering this constructor:
    this = uninitialized;

    this = Reflect.construct(Person, [name], new.target); // (A)
        // super(name);

    this.title = title;
}
Object.setPrototypeOf(Employee, Person);
···

const jane = Reflect.construct( // (B)
             Employee, ['Jane', 'CTO'],
             Employee);
    // const jane = new Employee('Jane', 'CTO')

實例物件在 ES6 和 ES5 中是在不同的位置建立的

前述程式碼使用了兩個新的 ES6 功能

這種子類別化的方式有其優點,它讓一般程式碼可以對內建建構函式(例如 ErrorArray)進行子類別化。後續章節會說明為何需要不同的方法。

提醒一下,以下是您在 ES5 中進行子類別化的方式

function Person(name) {
    this.name = name;
}
···

function Employee(name, title) {
    Person.call(this, name);
    this.title = title;
}
Employee.prototype = Object.create(Person.prototype);
Employee.prototype.constructor = Employee;
···
15.6.2.1 安全檢查
15.6.2.2 extends 子句

讓我們探討 extends 子句如何影響類別的設定方式 (規格第 14.5.14 節)。

extends 子句的值必須是「可建構的」(可透過 new 呼叫)。不過,null 是允許的。

class C {
}
class C extends B {
}
class C extends Object {
}

請注意以下與第一個案例的細微差異:如果沒有 extends 子句,類別就是基礎類別,並配置執行個體。如果類別延伸 Object,它就是衍生類別,而 Object 配置執行個體。產生的執行個體 (包括其原型鏈) 是相同的,但您到達的方式不同。

class C extends null {
}

此類別讓您避免在原型鏈中使用 Object.prototype

15.6.3 為什麼您無法在 ES5 中對內建建構函式進行子類別化?

在 ECMAScript 5 中,大多數內建建構函式無法進行子類別化 (有幾個解決方法)。

為了了解原因,讓我們使用正規的 ES5 模式對 Array 進行子類別化。正如我們很快就會發現的,這行不通。

function MyArray(len) {
    Array.call(this, len); // (A)
}
MyArray.prototype = Object.create(Array.prototype);

不幸的是,如果我們實例化 MyArray,我們會發現它無法正常運作:執行個體屬性 length 對於我們新增陣列元素並未產生反應

> var myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
0

有兩個障礙阻止 myArr 成為適當的陣列。

第一個障礙:初始化。您傳遞給建構函式 Arraythis (在 A 行) 會被完全忽略。這表示您無法使用 Array 來設定為 MyArray 建立的執行個體。

> var a = [];
> var b = Array.call(a, 3);
> a !== b  // a is ignored, b is a new object
true
> b.length // set up correctly
3
> a.length // unchanged
0

第二個障礙:配置。Array 建立的執行個體物件是異國的 (ECMAScript 規格用來表示具有一般物件沒有的功能的物件的術語):它們的屬性 length 追蹤並影響陣列元素的管理。一般來說,異國物件可以從頭建立,但您無法將現有的正常物件轉換為異國物件。不幸的是,當在 A 行呼叫時,這是 Array 必須執行的動作:它必須將為 MyArray 建立的正常物件轉換為異國陣列物件。

15.6.3.1 解決方案:ES6 子類別化

在 ECMAScript 6 中,對 Array 進行子類別化如下所示

class MyArray extends Array {
    constructor(len) {
        super(len);
    }
}

這可行

> const myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
1

讓我們探討 ES6 子類別化方法如何移除先前提到的障礙

15.6.4 在方法中參照超屬性

以下 ES6 程式碼在 B 行進行超方法呼叫。

class Person {
    constructor(name) {
        this.name = name;
    }
    toString() { // (A)
        return `Person named ${this.name}`;
    }
}

class Employee extends Person {
    constructor(name, title) {
        super(name);
        this.title = title;
    }
    toString() {
        return `${super.toString()} (${this.title})`; // (B)
    }
}

const jane = new Employee('Jane', 'CTO');
console.log(jane.toString()); // Person named Jane (CTO)

為了了解超呼叫如何運作,讓我們看看 jane 的物件圖表

在 B 行,Employee.prototype.toString 進行超呼叫(B 行)至它已覆寫的方法(從 A 行開始)。讓我們將儲存方法的物件稱為該方法的起始物件。例如,Employee.prototypeEmployee.prototype.toString() 的起始物件。

B 行的超呼叫包含三個步驟

  1. 從目前方法的起始物件原型開始搜尋。
  2. 尋找名稱為 toString 的方法。該方法可能在搜尋開始的物件中找到,或在原型鏈中之後找到。
  3. 使用目前的 this 呼叫該方法。這樣做的原因是:超呼叫方法必須能夠存取相同的實例屬性(在我們的範例中,是 jane 的自有屬性)。

請注意,即使您只取得(super.prop)或設定(super.prop = 123)超屬性(相對於進行方法呼叫),this 仍可能(在內部)在步驟 3 中扮演角色,因為可能會呼叫 getter 或 setter。

讓我們用三種不同的方式(但等效的方式)表達這些步驟

// Variation 1: supermethod calls in ES5
var result = Person.prototype.toString.call(this) // steps 1,2,3

// Variation 2: ES5, refactored
var superObject = Person.prototype; // step 1
var superMethod = superObject.toString; // step 2
var result = superMethod.call(this) // step 3

// Variation 3: ES6
var homeObject = Employee.prototype;
var superObject = Object.getPrototypeOf(homeObject); // step 1
var superMethod = superObject.toString; // step 2
var result = superMethod.call(this) // step 3

變化 3 是 ECMAScript 6 處理超呼叫的方式。此方法由 兩個內部繫結 支援,而函式的環境具有這些繫結(環境提供儲存空間,即範圍中變數的所謂繫結

15.6.4.1 您可以在哪裡使用 super

參照超屬性在涉及原型鏈時很方便,這就是為什麼您可以在物件文字和類別定義中的方法定義(包括產生器方法定義、getter 和 setter)中使用它的原因。類別可以是衍生的或非衍生的,方法可以是靜態的或非靜態的。

在函式宣告、函式表達式和產生器函式中,不允許使用 super 參照屬性。

15.6.4.2 陷阱:使用 super 的方法無法移動

您無法移動使用 super 的方法:此類方法具有內部插槽 [[HomeObject]],將其繫結到建立它的物件。如果您透過指定移動它,它將繼續參照原始物件的 superproperties。在未來的 ECMAScript 版本中,可能也有辦法傳輸此類方法。

15.7 species 模式

ECMAScript 6 中已經讓內建建構函式的另一個機制變得可擴充:有時方法會建立其類別的新執行個體。如果您建立子類別,方法應該傳回其類別的執行個體,還是子類別的執行個體?幾個內建的 ES6 方法讓您可以透過所謂的species 模式來設定它們如何建立執行個體。

舉例來說,考慮 Array 的子類別 SortedArray。如果我們在該類別的執行個體上呼叫 map(),我們希望它傳回 Array 的執行個體,以避免不必要的排序。預設情況下,map() 傳回接收器 (this) 的執行個體,但 species 模式讓您可以變更這個設定。

15.7.1 範例的輔助方法

在以下三個區段中,我將在範例中使用兩個輔助函式

function isObject(value) {
    return (value !== null
       && (typeof value === 'object'
           || typeof value === 'function'));
}

/**
 * Spec-internal operation that determines whether `x`
 * can be used as a constructor.
 */
function isConstructor(x) {
    ···
}

15.7.2 標準 species 模式

標準 species 模式是由 Promise.prototype.then()、Typed Arrays 的 filter() 方法和其他運算使用的。其運作方式如下

在 JavaScript 中實作,模式看起來會像這樣

function SpeciesConstructor(O, defaultConstructor) {
    const C = O.constructor;
    if (C === undefined) {
        return defaultConstructor;
    }
    if (! isObject(C)) {
        throw new TypeError();
    }
    const S = C[Symbol.species];
    if (S === undefined || S === null) {
        return defaultConstructor;
    }
    if (! isConstructor(S)) {
        throw new TypeError();
    }
    return S;
}

15.7.3 陣列的 species 模式

一般陣列實作 species 模式的方式略有不同

function ArraySpeciesCreate(self, length) {
    let C = undefined;
    // If the receiver `self` is an Array,
    // we use the species pattern
    if (Array.isArray(self)) {
        C = self.constructor;
        if (isObject(C)) {
            C = C[Symbol.species];
        }
    }
    // Either `self` is not an Array or the species
    // pattern didn’t work out:
    // create and return an Array
    if (C === undefined || C === null) {
        return new Array(length);
    }
    if (! IsConstructor(C)) {
        throw new TypeError();
    }
    return new C(length);
}

Array.prototype.map() 透過 ArraySpeciesCreate(this, this.length) 建立它傳回的陣列。

15.7.4 靜態方法中的種類模式

Promise 使用種類模式的變體,用於靜態方法,例如 Promise.all()

let C = this; // default
if (! isObject(C)) {
    throw new TypeError();
}
// The default can be overridden via the property `C[Symbol.species]`
const S = C[Symbol.species];
if (S !== undefined && S !== null) {
    C = S;
}
if (!IsConstructor(C)) {
    throw new TypeError();
}
const instance = new C(···);

15.7.5 覆寫子類別中的預設種類

這是屬性 [Symbol.species] 的預設 getter

static get [Symbol.species]() {
    return this;
}

這個預設 getter 由內建類別 ArrayArrayBufferMapPromiseRegExpSet%TypedArray% 實作。它會自動繼承至這些內建類別的子類別。

你可以透過兩種方式覆寫預設種類:使用你選擇的建構函式或 null

15.7.5.1 將種類設定為你選擇的建構函式

你可以透過靜態 getter (A 行) 覆寫預設種類

class MyArray1 extends Array {
    static get [Symbol.species]() { // (A)
        return Array;
    }
}

因此,map() 會傳回 Array 的執行個體

const result1 = new MyArray1().map(x => x);
console.log(result1 instanceof Array); // true

如果你沒有覆寫預設種類,map() 會傳回子類別的執行個體

class MyArray2 extends Array { }

const result2 = new MyArray2().map(x => x);
console.log(result2 instanceof MyArray2); // true
15.7.5.1.1 透過資料屬性指定種類

如果你不想使用靜態 getter,你需要使用 Object.defineProperty()。你無法使用指定,因為已經有一個只有 getter 的相同金鑰屬性。這表示它是唯讀的,無法指定給它。

例如,這裡我們將 MyArray1 的種類設定為 Array

Object.defineProperty(
    MyArray1, Symbol.species, {
        value: Array
    });
15.7.5.2 將種類設定為 null

如果你將種類設定為 null,則會使用預設建構函式(使用哪一個建構函式取決於使用的種類模式變體,請參閱前幾節以取得更多資訊)。

class MyArray3 extends Array {
    static get [Symbol.species]() {
        return null;
    }
}

const result3 = new MyArray3().map(x => x);
console.log(result3 instanceof Array); // true

15.8 類別的優缺點

類別在 JavaScript 社群中引起爭議:一方面,來自基於類別語言的人很開心他們不必再處理 JavaScript 的非傳統繼承機制。另一方面,許多 JavaScript 程式設計師認為 JavaScript 複雜的部分不是原型繼承,而是建構函式。

ES6 類別提供了一些明確的好處

讓我們看看一些關於 ES6 類別的常見抱怨。你會看到我同意它們中的大多數,但我認為類別的優點遠大於它們的缺點。我很高興它們在 ES6 中,我建議使用它們。

15.8.1 抱怨:ES6 類別模糊了 JavaScript 繼承的真實本質

是的,ES6 類別確實模糊了 JavaScript 繼承的真實本質。類別的外觀(其語法)與其行為(其語意)之間存在不幸的脫節:它看起來像一個物件,但它是一個函式。我比較希望類別是建構函式物件,而不是建構函式。我在 Proto.js 專案 中透過一個小型函式庫(證明了這種方法有多麼合適)探討了這種方法。

然而,向下相容性很重要,這就是類別成為建構函式的另一個原因。這樣一來,ES6 程式碼和 ES5 就能有更好的互操作性。

語法和語意之間的脫節會在 ES6 及後續版本中造成一些摩擦。但你可以簡單地按字面意思理解 ES6 類別,過著舒適的生活。我不認為這個錯覺會讓你吃虧。新手可以更快入門,然後在之後閱讀幕後發生的事情(在他們對這門語言更熟悉之後)。

15.8.2 抱怨:類別僅提供單一繼承

類別只提供單一繼承,這嚴重限制了您在物件導向設計方面的表達自由。然而,一直以來,它們的計畫便是成為多重繼承機制的基礎,例如特質。

然後,類別便成為可實例化的實體,以及您組裝特質的位置。在發生這種情況之前,如果您想要多重繼承,您將需要使用函式庫。

15.8.3 抱怨:類別會將您鎖定,因為強制使用 new

如果您想實例化類別,您被迫在 ES6 中使用 new。這表示您無法在不變更呼叫站點的情況下,從類別切換到工廠函式。這確實是一個限制,但有兩個減輕因素

因此,類別在語法上確實會 有些 限制您,但是,一旦 JavaScript 具有特質,它們便不會在 概念上 限制您(關於物件導向設計)。

15.9 常見問答:類別

15.9.1 為什麼類別無法使用函式呼叫?

目前禁止使用函式呼叫類別。這樣做是為了讓未來保持選擇的彈性,最終新增一種透過類別處理函式呼叫的方法。

15.9.2 如何實例化一個類別,給定一個引數陣列?

類別的 Function.prototype.apply() 類比是什麼?也就是說,如果我有一個類別 TheClass 和一個引數陣列 args,我該如何實例化 TheClass

一種方法是透過展開運算子(...

function instantiate(TheClass, args) {
    return new TheClass(...args);
}

另一個選項是使用 Reflect.construct()

function instantiate(TheClass, args) {
    return Reflect.construct(TheClass, args);
}

15.10 類別的下一步是什麼?

類別的設計座右銘是「極簡」。討論過幾個進階功能,但最後為了得到 TC39 一致通過的設計而捨棄。

ECMAScript 的後續版本現在可以延伸這個極簡設計 – 類別將提供特徵(或混入)、值物件(如果內容相同,不同的物件就是相等的)和 const 類別(產生不可變的實例)等功能的基礎。

15.11 延伸閱讀

下列文件是本章節的重要來源

下一篇:16. 模組