[ES6深度解析]12:Classes

Max力出奇跡發表於2021-08-26

我們將討論一個老問題:在JavaScript中建立物件的建構函式。

存在的問題

假設我們想要建立最典型的物件導向設計的示例:Circle類。假設我們正在為一個簡單的Canvas庫編寫一個Circle。除此之外,我們可能想知道如何做到以下幾點:

  • 在給定的畫布Canvas上畫一個給定的圓Circle。
  • 記錄所做的圓圈的總數。
  • 跟蹤給定圓的半徑,以及如何對其值施加不變數。
  • 計算給定圓的面積。

當前的JS習慣說法是,我們應該首先建立一個函式,作為建構函式;然後將我們可能想要的任何屬性新增到函式本身,然後用一個物件Object替換建構函式的prototype屬性。這個prototype物件將包含建構函式建立的例項物件開始時應該包含的所有屬性。即使是一個簡單的例子,當你把它全部寫出來的時候,這最終會變成一大堆樣板檔案:

function Circle(radius) {
    this.radius = radius;
    Circle.circlesMade++;//circlesMade是與例項無關的屬性
}

Circle.draw = function draw(circle, canvas) { /* Canvas drawing code */ }

Object.defineProperty(Circle, "circlesMade", {
    get: function() {
        return !this._count ? 0 : this._count;
    },

    set: function(val) {
        this._count = val;
    }
});

Circle.prototype = {
    area: function area() {
        return Math.pow(this.radius, 2) * Math.PI;
    }
};

Object.defineProperty(Circle.prototype, "radius", {
    get: function() {
        return this._radius;
    },

    set: function(radius) {
        if (!Number.isInteger(radius))
            throw new Error("Circle radius must be an integer.");
        this._radius = radius;
    }
});

程式碼不僅很麻煩,而且也很不直觀。它需要對函式的工作方式以及已安裝的各種屬性是如何進入建立的例項物件的有相當深入的瞭解。如果這個方法看起來很複雜,不要擔心。這篇文章的重點是展示一種更簡單的程式碼編寫方法。

方法定義的語法

ES6提供了一種向物件新增特殊屬性的新語法。雖然很容易將area方法新增到Circle.prototype中,為radius新增getter/setter對感覺要就複雜多了。隨著JS轉向更物件導向的方法,人們開始對設計更簡潔的方法來新增物件訪問器感興趣。我們需要一種新的方式來新增方法到一個物件,就像方法已經使用obj.prop = method被新增到obj中一樣。不需要使用Object.defineProperty這樣複雜的API。人們希望能夠輕鬆地做以下事情:

  1. 向物件新增普通函式屬性。
  2. 向物件新增生成器函式屬性。
  3. 向物件新增普通訪問器函式屬性。
  4. 新增上面的任何一個,就像在最終的物件上用[]語法做過一樣。我們稱這些為Computed屬性名(Computed property names)

有些事情以前是做不到的。例如,沒有辦法定義一個getter或setter來給obj.prop賦值。因此,必須新增新的語法。你現在可以寫這樣的程式碼:

var obj = {
    // 方法被新增而不帶function關鍵字,使用屬性名作為函式名。
    method(args) { ... },

    // 讓方法稱為一個生成器,在方法名前加一個*號
    *genMethod(args) { ... },

    // 在|get|和|set|的幫助下,訪問器(accessors)現在可以用內聯寫法。

    // 注意,以這種方式註冊的getter不能有引數
    get propName() { ... },

    // 注意,以這種方式註冊的setter只能有一個引數
    set propName(arg) { ... },

    // 為了解決上面的第四個問題,[] 語法可以出現在任何方法名出現的地方。
	// 這裡可以使用Symbols,呼叫函式,連線字串,或者其他任何可以產生一個屬性id的表示式。
	// 這個語法對訪問器(accessors)和生成器(generators)也起作用。
    [functionThatReturnsPropertyName()] (args) { ... }
};

使用這個新的語法,我們現在可以重寫上面的程式碼片段了:

function Circle(radius) {
    this.radius = radius;
    Circle.circlesMade++;
}

Circle.draw = function draw(circle, canvas) { /* Canvas drawing code */ }

Object.defineProperty(Circle, "circlesMade", {
    get: function() {
        return !this._count ? 0 : this._count;
    },

    set: function(val) {
        this._count = val;
    }
});

Circle.prototype = {
    area() {
        return Math.pow(this.radius, 2) * Math.PI;
    },

    get radius() {
        return this._radius;
    },
    set radius(radius) {
        if (!Number.isInteger(radius))
            throw new Error("Circle radius must be an integer.");
        this._radius = radius;
    }
};

這段程式碼與上面的程式碼片段並不完全相同。物件字面量中的方法定義註冊為可配置和可列舉的,而註冊在第一個程式碼段中的訪問器(get,set)將是不可配置和不可列舉的。在實踐中,這一點很少被注意到,為了簡潔起見,我決定省略上面的可列舉性和可配置性。

不過,情況正在好轉,對吧?不幸的是,即使有了這種新的方法定義語法,對於Circle的定義我們也無能為力,因為我們還沒有定義函式。在定義函式的時候,沒有辦法給它賦予屬性。

類定義語法

雖然這更好了,但仍然不能滿足那些想要用JavaScript實現物件導向設計的更清晰解決方案的人。他們認為,其他語言有一個用於處理物件導向設計的構造,這個構造稱為

很好。那麼,讓我們給JS新增

我們想要一個允許我們向有命名建構函式新增方法,並向它的.prototype新增方法的系統,這樣它們就會出現在類的構造例項中。既然我們有了新奇的新方法定義語法,我們肯定應該使用它。然後,我們只需要一種方法來區分哪些是在類的所有例項上通用的,哪些函式是特定於給定例項的。在c++或Java中,關鍵字是static。看起來和其他的一樣好。讓我們使用它。

現在,如果有一種方法可以指定其中一個方法作為建構函式被呼叫,那將是很有用的。在c++或Java中,它將被命名為與類相同的名稱,沒有返回型別。因為JS沒有返回型別,我們需要一個.constructor屬性,為了向後相容,我們把這個方法稱為建構函式constructor

把它們組合在一起,我們就可以重寫Circle類:

class Circle {
    constructor(radius) {
        this.radius = radius;
        Circle.circlesMade++;
    };

    static draw(circle, canvas) {
        // Canvas drawing code
    };

    static get circlesMade() {
        return !this._count ? 0 : this._count;
    };
    static set circlesMade(val) {
        this._count = val;
    };

    area() {
        return Math.pow(this.radius, 2) * Math.PI;
    };

    get radius() {
        return this._radius;
    };
    set radius(radius) {
        if (!Number.isInteger(radius))
            throw new Error("Circle radius must be an integer.");
        this._radius = radius;
    };
}

我們不僅可以把所有與圓相關的東西放在一起,而且所有東西看起來都很乾淨。這肯定比我們開始的時候好。即便如此,一些人可能還會有疑問。我將嘗試預測並解決以下問題:

  • 分號是怎麼回事?
    為了“讓東西看起來更像傳統的類”,我們決定使用更傳統的分隔符。不喜歡它嗎?它是可選的。分隔符不是必須的。

  • 如果我不想要建構函式,但仍然想在建立的物件上放置方法,該怎麼辦?
    這沒問題。建構函式方法是完全可選的。如果你沒有提供一個,預設情況下會補充一個空的建構函式constructor() {}

  • 建構函式可以是生成器嗎?
    不可以。新增一個非普通方法的建構函式將導致TypeError。這包括生成器和訪問器。

  • 我可以用計算屬性名([computed property name])定義建構函式嗎?
    不可以。。這很難檢測,所以我們就不試了。如果你用一個計算屬性名定義了一個方法,這個方法最終返回constructor,你仍然會得到一個命名為constructor的方法,它最終不是類的建構函式。

  • 如果我改變Circle的值呢?這會導致新的Circle行為錯誤嗎?
    不會的!與函式表示式非常相似,類獲得其指定名稱的內部繫結。外部力量無法更改此繫結,因此無論您在外圍作用域Circle中將Circle變數設定為什麼。建構函式中的circlesMade++將按預期執行。

相關文章