跳到主要內容
黯羽輕揚每天積累一點點

class_ES6 筆記 10

免費2016-07-30#JS#es6 class#js class#javascript class#js类#javascript类

JS 要變 Java 了!?清爽的類型定義語法,keep it simple

一、JS 要變 Java 了!?

ES6 啟用了 classconstructorstaticextendssuper 等關鍵字來支持類定義,感覺是馬上要變 Java 了,終於不用管 prototype 了嗎?

不是這樣的。啟用這一套關鍵字僅僅是為了減少「語法噪音」,減少定義類結構時的工作量,JS 基於原型的(prototype-based)對象系統無法輕易改成基於類的(class-based),這是 JS 語言設計者的選擇,不可能把現有的對象系統連根拔起,再把 Java 那一套塞進來縫合好。

P.S.「語法噪音」是從老趙那搬過來的詞,用 Python 和 Java 實現同樣的功能,diff 結果就是「語法噪音」,比如 JS 中的 functionprototype 等等輸入起來很難受的東西

而且,基於原型的對象系統相對靈活,比如 JS 可以在運行時動態修改類繼承樹,因此我們可以只預先定義好空的類繼承樹,需要時才去增添類成員,例如:

// 空的繼承樹
var SuperType = function() {};
var SubType = function() {};
SubType.prototype = new SuperType();
// 動態增強
void function() {
    //...
    SuperType.prototype.cl = console.log.bind(console);
    new SubType().cl('invoked by subType'); // invoked by subType
}();

這種感覺很奇妙,Java 和 JS 一起畫畫,Java 用精細的刻刀在畫布上一絲不苟地完成了清明上河圖,裝裱好掛在牆上,四下驚艷。JS 拿起水筆畫了兩條彎彎扭扭的橫線,說「我畫完了,這就是清明上河圖」,觀眾感到被愚弄了,但也勉強把 JS 的作品裝裱好,掛在 Java 的大作旁邊。Java 束手而立靜待分曉,這時 JS 才開始忙活,拿起筆認真地在相框玻璃面上完成了一模一樣的作品,觀眾感到不可思議。突然人群裡傳出一個聲音,「左邊第三個房子應該是尖頂的,你們都畫錯了」,Java 臉上掛不住了,急匆匆地攤開另一張宣紙,想要重新畫一幅改掉那個礙眼的錯誤。JS 指著左上角說「是這裡嗎?我已經改好了」

JS 是命令式語言、動態語言和函數式語言的結合體,就整個體系而言,對象系統需要基於原型實現帶來的這種靈活性,不用羨慕 Java 精雕細刻的基於類的對象系統,完全不合身。ES 規範設計者不會也沒有理由去照搬 Java,至此,JavaScript 與 Java 仍然毫不相干,像 20 年前一樣

P.S. JS 和筆者都是 1995 年誕生的,至於 Netscape 與 JavaScript 和 Java Applet 的絲絲縷縷,請自行查找

二、class 帶來的新特性

ES6 之前的「class」可能是這樣的:

function Circle(radius) {
    if (typeof radius !== 'number') {
        throw new TypeError('radius: a number expected');
    }
    // 實例屬性
    this.radius = radius;
    // 靜態屬性
    Circle.count++;
}
Circle.count = 0;
// 原型屬性
Circle.prototype.getArea = function() {
    return Math.PI * this.radius * this.radius;
};

new Circle 得到的每個實例都具有兩個屬性,實例屬性 radius 和原型屬性 getArea

偶爾能看到用 ES5 特性實現的更嚴謹的「class」:

// 更嚴謹的,定義 getter/setter
function CircleEx(radius) {
    this.radius = radius;
    CircleEx.count++;
}
// 定義靜態屬性
Object.defineProperty(CircleEx, 'count', {
    get: function() {
        // this 指向 CircleEx
        // console.log(this);
        // CircleEx.count++ 先 get 返回 0
        // 再 set 給 CircleEx 添上_count 屬性並賦值為 1
        return !this._count ? 0 : this._count;
    },
    set: function(val) {
        this._count = val;
    }
});
CircleEx.prototype.getArea = function() {
    return Math.PI * this.radius * this.radius;
};
// 定義原型屬性
// 實際上是為實例屬性定義 getter/setter
Object.defineProperty(CircleEx.prototype, 'radius', {
    get: function() {
        //!!! this 指向 CircleEx 實例
        // 而不是其原型對象
        // console.log(this);
        // 構造函數中 this.radius = radius;
        // 查找 radius 屬性,觸發 get,返回 this._radius
        // 賦值為 radius
        return this._radius;
    },
    set: function(val) {
        if (typeof val !== 'number') {
            throw new TypeError('radius: a number expected');
        }
        this._radius = val;
    }
});

new CircleEx 得到的每個實例都具有兩個屬性,實例屬性 _radius 和原型屬性 getArea,至於 radius,則是定義在原型對象上的訪問器(getter/setter),不可枚舉

之前說「偶爾」能看到這樣的「class」定義,就是因為太麻煩了,大家都懶得用。ES6 class 部分的目標就是改變現狀,提供更方便的類成員定義方式

簡化了對象的定義方式

可以直接在對象字面量中定義 getter/setter,簡化了函數類型屬性定義方式,包括一般函數、生成器和動態函數名,例如:

// getter/setter
var obj = {
    // getter
    //!!! getter 無參,無法傳參
    get attr() {
        console.log('getter');
        return this._attr || 0;
    },
    // setter
    //!!! setter 至少接受一個參數
    set attr(val) {
        console.log('setter');
        this._attr = val;
    },
    // 預計算屬性(用 [] 語法添加的函數屬性,動態函數名)
    [(function() {return 'print';})()](arg) {
        console.log(arg);
    },

    // 一般方法
    fn(arg) {
        console.log(`arg = ${arg}`);
    },
    // 生成器
    *gen(i) {
        while(true) {
            yield i++;
        }
    }
}

冗長的 function 關鍵字從類型定義中徹底消失了,甚至比 [箭頭函數](/articles/箭頭函數-es6 筆記 6/) 都簡潔。又感受到了初學 JS 時的欣喜——給變量添上一對圓括號就是函數調用,怎麼這麼簡單粗暴

用 ES6 增強版對象字面量重寫之前的 CircleEx 類,將會是這樣:

// 重寫 CircleEx
CircleEx.prototype = {
    getArea() {
        return Math.PI * this.radius * this.radius;
    },
    get radius() {
        return this._radius;
    },
    set radius(val) {
        if (typeof val !== 'number') {
            throw new TypeError('radius: a number expected');
        }
        this._radius = val;
    }
};

注意,與之前不同的是,此時訪問器 radius可枚舉的,並且作為一個原型屬性出現。但同樣的,訪問器自身不會被暴露出來,針對 radius 的所有操作都是對訪問器返回值的操作,而不是訪問器本身,因此:

typeof new CircleEx(1).radius === 'number'  // true

啟用了 class 定義

P.S. 之所以說「啟用」而不是「引入」,是因為 ES6 class 相關的所有關鍵字本來就是保留字,只是現在被賦予了明確的含義

class 定義中,constructor 表示構造函數,static 關鍵字用來區分一般函數和特殊函數(含義同 Java、C++)

constructor 可選,默認提供空構造函數(constructor() {}),constructor必須是字面量形式,不能是動態函數名,否則將得到名為 constructor 的一般方法,而不是構造函數

class 語法重寫之前的 Circle 類,如下:

// 重寫 CircleEx
class MyCircle {
    // 構造函數
    constructor(radius) {
        this.radius = radius;
        MyCircle.count++;
    }

    // 實例屬性的 getter/setter
    get radius() {
        return this._radius;
    }
    set radius(val) {
        if (typeof val !== 'number') {
            throw new TypeError('radius: a number expected');
        }
        this._radius = val;
    }

    // 原型屬性
    getArea() {
        return Math.PI * this.radius * this.radius;
    }

    // 靜態屬性
    static get count() {
        return this._count || 0;
    }
    static set count(val) {
        this._count = val;
    }
}

無論實際效果如何,但至少看起來清晰多了

類型保護

內置了類型保護,用 class 語法定義的類型,必須通過 new 操作符來調用,例如:

// 當作一般函數調用,會報錯
MyCircle();
// Uncaught TypeError: Class constructors cannot be invoked without 'new'

這種內置保護能夠避免一些問題,但確實也限制了靈活性,比如某些類庫提供的 API 中含有既能作為一般函數調用,也能作為構造函數使用的函數,用 class 語法就無法實現

此外,類定義中引用的類名不會被外部力量改變,例如:

class T {
    static get val() {
        return 'val';
    }

    get() {
        return T.val;
    }
}
// test
var t = new T();
console.log(t.get());   // val
// 外力破壞
T = null;
console.log(t.get());   // val
//! 報錯 Uncaught TypeError: T is not a constructor
// new T();

在 ES6 之前是沒有這種保護的,用 function 重寫,遭到外力破壞後必定出錯

支持類表達式(匿名類)

// 匿名類
var circle = new class {
    constructor(radius) {
        this.radius = radius;
    }
}(3);
console.log(circle.radius); // 3

匿名類好像沒什麼用,因為一般來說,類是對象模板,通過類能實現對象的量產,Java 用匿名類來創建不需要量產的臨時對象,而 JS 有 N 種方法可以創建對象,用匿名類可能是最傻的方式

類中定義的方法可配置不可枚舉

比如 MyClass.prototype(通過 class 語法定義的)所有屬性都不可枚舉,類中定義的方法也不可枚舉

這樣做似乎是在刻意掩蓋對象系統基於原型的事實,比如 for...in 無法枚舉之前的 MyCircle.prototype,但可以通過 Object.getOwnPropertyNames() 發現一些痕跡,例如:

console.log(Object.getOwnPropertyNames(MyCircle.prototype));
// log print:
// ["constructor", "radius", "getArea"]

這些屬性都真實存在於原型對象上,但默認不可枚舉掩蓋了這個事實,可能是不希望我們在使用 class 語法的同時,手動操作 prototype 對象,破壞既有規則

可能總覺得這種掩蓋 prototype 的做法欠妥,但又說不上哪裡不對,好,考慮下原型繼承的經典問題:原型引用屬性會在實例間共享,例如:

function Type() {}
Type.prototype.issue = [1, 2];
// test
var t1 = new Type();
var t2 = new Type();
t1.issue.push(3);
console.log(t2.issue);  // [1, 2, 3]

class 語法存在這個問題嗎?首先我們得用 class 語法在 Type 的原型上定義一個數組類型屬性,沮喪地發現根本做不到,除非直接訪問 prototype,所以不用擔心這種「掩蓋」會引發隱秘的問題,ES6 設計者比我們考慮得多得多

三、總結

清爽的類型定義語法,keep it simple

ES 正在逐漸剔除語法噪音:

箭頭函數 + `class` 剔掉了 `function`

默認參數 + 不定參數剔掉了 `arguments`

`class` + `extends` + `super` 剔掉了 `prototype`

從函數式語言的角度來看,這些變化是極好的,數學家追求的正是極致簡潔的美

參考資料

  • 《ES6 in Depth》:InfoQ 中文站提供的免費電子書

  • 《JavaScript 語言精髓與編程實踐》

評論

暫無評論,快來發表你的看法吧

提交評論