深入淺出ES6(十三):類 Class

目前面臨的問題

假如我們想要創建一個經典的面向對象設計示例:Circle類。想象一下我們正在爲一個簡單的Canvas庫編寫這個Circle類,在衆多需要考慮的因素中,我們可能更想了解以下功能的實現方式:

  • 在給定的Canvas上繪製一個給定圓。
  • 跟蹤記錄生成圓的總數。
  • 跟蹤記錄給定圓的半徑,以及如何使其值成爲圓的不變條件
  • 計算給定圓的面積。

按照目前常見的JS編碼風格,我們首先應該以函數的形式創建一個構造函數,然後給該函數添加任何我們可能想要的屬性,然後用一個對象替換構造函數的prototype屬性。這個prototype對象將包含構造函數創建的實例的所有初始化屬性。下面是一個簡單的示例,可以直接作爲樣板文件(boilerplate)重複使用:

    function Circle(radius) {
        this.radius = radius;
        Circle.circlesMade++;
    }
    Circle.draw = function draw(circle, canvas) { /* Canvas繪製代碼 */ }
    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("圓的半徑必須爲整數。");
            this._radius = radius;
        }
    });

這段代碼非常繁瑣且不符合人的直覺,要想讀懂必須對函數的運行方式有着非凡的掌握,然後你才能理解各種已裝載的屬性與生成的實例對象進行綁定的方式。如果這種方法看起來很複雜,不要擔心,這篇文章會爲你展示一種更簡單的方法來實現所有這些功能。

方法定義語法

ES6提供一種向對象添加特殊屬性的新語法,可以幫助我們清理這些方法。給Circle.prototype添加area方法非常簡單,但是給radius添加getter/setter方法對就很難。隨着JS引入越來越多的面向對象方法,人們開始對簡化給對象添加訪問器的方法感興趣。我們需要一種功能類似obj.prop = method的新方法來給對象添加“方法”,同時不借助Object.defineProperty的力量。人們想要能夠簡單地實現以下功能:

  • 給對象添加標準的函數屬性。
  • 給對象添加生成器函數屬性。
  • 給對象添加標準的訪問器函數屬性。
  • 給對象添加任意使用[]語法添加的函數屬性,我們稱其爲預計算(computed)屬性名

其中一些功能在以前無法實現,例如:我們不能通過給obj.prop賦值來定義getter或setter。因此,我們亟需新語法來編寫以下代碼:

    var obj = {
        // 現在不再使用function關鍵字給對象添加方法
        // 而是直接使用屬性名作爲函數名稱。
        method(args) { ... },
        // 只需在標準函數的基礎上添加一個“*”,就可以聲明一個生成器函數。
        *genMethod(args) { ... },
        // 藉助|get|和|set|可以在行內定義訪問器。
        // 只是定義內聯函數,即使沒有生成器。
        // 注意通過這種方式裝載的getter不能接受參數
        get propName() { ... },
        // 注意通過這種方式裝載的setter至少接受一個參數
        set propName(arg) { ... },
        // []語法可以用於任意支持預計算屬性名的地方,來滿足上面的第4中情況。
        // 這意味着你可以使用symbol,調用函數,聯結字符串
        // 或其它可以給property.id求值的表達式。
        // 這個語法對訪問器或生成器同樣有效,我在這裏只是舉個例子。
        [functionThatReturnsPropertyName()] (args) { ... }
    };

現在,我們可以用這種新語法重寫上面的代碼片段:

    function Circle(radius) {
        this.radius = radius;
        Circle.circlesMade++;
    }
    Circle.draw = function draw(circle, canvas) { /* Canvas繪製代碼 */ }
    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("圓的半徑必須爲整數。");
            this._radius = radius;
        }
    };

講究地說,這段代碼與上面的代碼段並不完全相同,裝載後的對象字面量中的方法定義是可配置(configurable)和可枚舉(enumerable) 的,然而在第一段代碼段中卻不是這樣。事實上,很少有人會注意到這個問題,我決定爲了簡潔起見暫時省略可枚舉性和可配置性。

不過,這段代碼依然變得更好了,不是麼?不幸的是,即使有了新的方法定義語法,我們仍然不能武裝到牙齒,所以仍然需要通過定義函數來定義Circle類。沒有一種方法能夠讓你在定義函數時就獲取它的屬性。

類定義語法

儘管這比以前更好,但是它仍然不能滿足人們對於簡潔的JavaScript面向對象解決方案的渴望。在其它語言中,有一個句法結構可以用來處理面向對象設計的問題,經過一番討論後他們將其命名爲類(Class)

好吧,讓我們也來添加一些類。

我們需要這樣一個系統:給命名構造函數添加方法的同時給函數的.prototype屬性也添加相應方法,從而用這個類構造出的實例也包含相應的方法。既然我們掌握了一種嶄新的方法定義語言,我們一定要物盡其用。在類的所有實例中,我們只需要一種區分普通函數與特殊函數的方法,在C++或Java中,這種功能對應的關鍵字是static。這種方法看起來不錯,讓我們用起來!

我們還需要一個方法,可以在一堆方法中指定出唯一的構造函數。在C++或Java中,構造函數與類同名,並且沒有返回類型。既然JS沒有返回類型,我們無論如何都需要一個.constructor屬性來支持向後兼容性,你可以稱之爲方法構造函數(method constructor)。

將所有的概念組合到一起後,我們可以重寫Circle類並實現所有功能:

    class Circle {
        constructor(radius) {
            this.radius = radius;
            Circle.circlesMade++;
        };
        static draw(circle, canvas) {
            // Canvas繪製代碼
        };
        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("圓的半徑必須爲整數。");
            this._radius = radius;
        };
    }

哇嗷!我們不僅可以實現Circle所需的功能,還能使代碼如此簡潔,這比剛開始好多了!

雖然如此,有的人有可能會遇到問題或碰到邊緣用例。我會嘗試着預測你們將會遇到的問題並一一解答:

  • 分號是怎麼回事?—— 在一次“打造傳統類”的嘗試中,我們決定編寫一個更傳統的分隔符。如果不喜歡可以不寫,分隔符是可選的。

  • 如果我不想要一個構造函數,但是仍然想在創建的對象中放置方法呢?—— 好吧,constructor方法也是可選的,對象中會默認聲明一個空的構造函數constructor() {}

  • 可以用生成器作爲構造函數麼?—— 堅決不可以!構造器不是普通方法,隨意添加將會觸發類型錯誤(TypeError),這條規則同樣適用於生成器和訪問器。

  • 我可以用預計算屬性名來定義構造函數麼?—— 很不幸的是不可以!那將會變得很難預測,所以我們不去嘗試。如果你用預計算屬性名定義一個方法來命名構造函數,你將得到一個名爲constructor的方法,它就不是類的構造函數了。

  • 如果我改變了Circle的值,會導致new Circle的行爲異常麼?—— 不會!類與函數表達式類似,會得到一個給定名稱的內部綁定,這個綁定不會被外部力量改變,所以無論你在外圍作用域給Circle變量設置什麼值,構造器中的Circle.circlesMade++依然會像預期一樣運行。

  • 好的,但是我可能直接給函數傳一個對象字面量作爲參數,類是不是就不能實例化了?—— 幸運的是,ES6中也支持類表達式!可以是命名或匿名表達式,且行爲與上述一致,唯一的區別是它們不會在你聲明它們的作用域中創建變量。

  • 上面提到的可枚舉性、可配置性又如何解釋呢?—— 人們希望在類中裝載的方法是可配置、不可枚舉的。一來你可以在對象中裝載方法,二來當你枚舉對象屬性時,不會將裝載的方法枚舉出來,得到的只是附加的數據屬性,這樣做是有道理的。

  • 嗨,等等……什麼……?我的實例變量在哪兒?靜態和常量呢?—— 好吧,你問住我了。ES6目前的定義中不存在相關信息。但是有個好消息,在諸多的規範進程中,我強烈支持在類語法中加入可選的staticconst關鍵字,該提案已經正式向規範會議遞交併處於議程中,我認爲我們可以期待在未來會產生更多的相關討論。

  • 好的,即使這樣,這些內容都很贊!我們現在可以使用這些技術麼?—— 不完全可以。目前,你們可以藉助polyfill(尤其是Babel)來熟悉特性的相關語法,等到所有主流瀏覽器原生支持還需要一段時間。我已經在Firefox的Nightly版本中實現了我們所討論的所有特性;同樣,這些特性在Edge和Chrome中也已實現,只是默認不啓用;目前Safari尚未實現相關特性。

  • 在這裏我們沒有提及Java和C+++中的子類(subclassing)和super關鍵字,JS也有麼?– 是的,它有!我們完全可以在另一篇文章中詳細討論,後續歡迎回來與我們一起探索子類,挖掘更多JavaScript類實現的強大之處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章