「JavaScript的類字段提案」或「TC39委員出了什麼問題?」

翻譯,原始文章:「Class-fields-proposal」 or 「what went wrong in tc39 committee」javascript

一直以來,咱們都指望有一天能在JavaScript中較爲簡單地使用其餘語言常見的封裝語法。好比,咱們想要類屬性/字段的語法,而且它的實現方式並不會破壞現有的程序。如今看起來,這一天已經到來:在TC39委員會的努力之下,類字段提案已經進入stage 3,甚至已經被Chrome實現html

老實說,我很樂意寫一篇文章,描述爲何您必須使用這個新功能以及如何實現它。但惋惜我沒法這麼作。vue

當前提案說明

參考文檔在此不贅述了,具體參考:原始說明FAQ規範變動java

類字段

類字段說明和用法:git

class A {
    x = 1;
    method() {
        console.log(this.x);
    }
}
複製代碼

從外部代碼訪問字段:github

const a = new A();
console.log(a.x);
複製代碼

一眼看去稀鬆日常,有些人可能會說咱們在BabelTypeScript中這樣使用多年了。chrome

但有一件事值得注意:這個語法使用[[Define]]語義而不是咱們習慣的[[Set]]語義。這意味着實際上上面的代碼不等價於如下用法:typescript

class A {
    constructor() {
        this.x = 1;
    }
    method() {
        console.log(this.x);
    }
}
複製代碼

等價於下述用法:npm

class A {
    constructor() {
        Object.defineProperty(this, "x", {
            configurable: true,
            enumerable: true,
            writable: true,
            value: 1
        });
    }
    method() {
        console.log(this.x);
    }
}
複製代碼

儘管在這個例子下,兩種用法實際表現幾乎沒有什麼區別,但實際有一個很重要的區別。咱們假設咱們有一個像這樣的父類:編程

class A {
    x = 1;

    method() {
        console.log(this.x);
    }
}
複製代碼

從該父類派生出一個子類以下:

class B extends A {
    x = 2;
}
複製代碼

而後使用:

const b = new B();
b.method(); // prints 2 to the console
複製代碼

而後爲了某些(不重要的)緣由,咱們以一種彷佛向後兼容的方式改變了A類:

class A {
    _x = 1; // for simplicity let's skip that public interface got new property here
    get x() { return this._x; };
    set x(val) { return this._x = val; };

    method() {
        console.log(this._x);
    }
}
複製代碼

對於[[Set]]語義,它確實是向後兼容的。 可是對於[[Define]]不是。 如今調用b.method()會將打印1而不是2到控制檯。緣由是在Object.defineProperty的做用下,不會調用A類聲明的屬性描述符以及其getter/setter。 所以,在派生類中,咱們以相似變量詞法做用域的方式隱藏了父類x性:

const x = 1;
{
    const x = 2;
}
複製代碼

咱們可使用no-shadowed-variable/no-shadow這樣的liner規則幫助咱們檢測常見的詞法做用域變量隱藏。 可是不幸的是,不太可能有人會建立no-shadowed-class-field這樣的規則幫助咱們規避類字段的隱藏。

儘管如此,我並非[[Define]]語義的的堅決反對者(儘管我更喜歡[[Set]]語義),由於它有它的好的優勢。然而,它的優勢並無超過主要的缺點——咱們多年來一直使用[[Set]]語義,由於它是babel6TypeScript的默認行爲。

我不得不強調一下,babel7改變了默認行爲。

您能夠在這裏這裏瞭解更多原始討論。

私有字段

咱們來看看這個提案中最具爭議的部分。 它是如此有爭議:

  1. 儘管事實上,它已經在Chrome Canary中實現,而且默認狀況下公共字段可用,可是私有字段功能仍需額外開啓;
  2. 儘管事實上,原始的私有字段提案與當前的提案合併,關於分離私有和公有字段的issue一再出現(如:140142144148);
  3. 甚至一些委員會成員(如:Allen Wirfs-BrockKevin Smith)也反對它並提供替代方案,可是該提案仍然順利進入stage 3
  4. 該提案的issue數量最多——當前提案的GitHub倉庫爲131個,原始提案(合併前)的GitHub倉庫爲96個(相比BigInt提案的issue數量爲126個),而且大多數issue持反對觀點
  5. 甚至建立了單獨的issue,以便統計總結對它的反對意見;
  6. 爲了證實這一部分的合理性而建立了單獨的FAQ,然而不夠強力論據又致使了新的爭論(133136
  7. 就我我的而言,幾乎花了我全部的空閒時間(有時甚至是工做時間),花了大精力試圖對其進行調查,充分了解其背後的侷限性和決策,弄明白其造成現狀的緣由,並提出可行的替代方案;
  8. 最後,我決定寫這篇評論文章。

聲明私有字段的語法:

class A {
    #priv;
}
複製代碼

並使用如下表示法訪問:

class A {
    #priv = 1;

    method() {
        console.log(this.#priv);
    }
}
複製代碼

這個語法看起來違反直覺,而且很不直觀(this.#priv != this['#priv']),而且沒有使用JavaScript的保留字privaye/protected(這可能會讓已經使用TypeScript的開發者感到傷腦筋),而且爲更多的訪問級別的設計留下隱患。在這樣的情境下,我深刻的調查並參與了相關討論。

若是這僅僅與語法形式有關,即主觀審美上咱們難以接受,那麼最後咱們或許能夠忍受這樣的語法並習慣它。 可是,還有一個語義問題……

WeakMap語義

讓咱們來看看現有提案背後的語義。 咱們可以在沒有新語法可是保持原有行爲的狀況下重寫前面的示例:

const privatesForA = new WeakMap();
class A {
    constructor() {
        privatesForA.set(this, {});
        privatesForA.get(this).priv = 1;
    }

    method() {
        console.log(privatesForA.get(this).priv);
    }
}
複製代碼

順便說一句,一名委員會成員使用這種語義建立了一個小型實用程序庫,這使咱們如今就可使用私有狀態。 他的目標是代表這種功能被委員會高估了。其格式化代碼只有27行。

很棒,咱們能夠擁有硬私有了,沒法從外部代碼訪問/攔截/跟蹤內部的字段,同時咱們甚至能夠經過如下方式訪問同一類的另外一個實例的私有:

isEquals(obj) {
    return privatesForA.get(this).id === privatesForA.get(obj).id;
}
複製代碼

這一切都很是方便,除了這個語義不只包括封裝,還包括brand-checking(您沒必要谷歌這個術語,由於您不太可能找到任何相關的信息)。 brand-checking鴨子類型相反,從某種意義上說,它根據特定代碼肯定特定對象而非根據該對象的公共接口肯定對象。 實際上,這種檢查有其本身的用途——在大多數狀況下,它們與在同一進程中安全執行不受信任的代碼有關,能夠直接共享對象而無需序列化/反序列化開銷。

可是一些工程師堅持認爲這是正確封裝的要求。

儘管這是一個很是有趣的可能實現,它涉及模式(簡短詳盡描述),Realms提案Mark Samuel Miller的計算機科學研究(他也是委員會成員),可是根據個人經驗,它彷佛並不常見於大多數開發人員的平常工做中。

brand-checking的問題

正如我以前所說,brand-checking與鴨子類型相反。 在實踐中,這意味着使用如下代碼:

const brands = new WeakMap();
class A {
    constructor() {
        brands.set(this, {});
    }

    method() {
        return 1;
    }

    brandCheckedMethod() {
        if (!brands.has(this)) throw 'Brand-check failed';

        console.log(this.method());
    }
}
複製代碼

brandCheckedMethod只能A的實例調用,即便target符合此類的全部結構,此方法也會拋出異常:

const duckTypedObj = {
    method: A.prototype.method.bind(duckTypedObj),
    brandCheckedMethod: A.prototype.brandCheckedMethod.bind(duckTypedObj),
};
duckTypedObj.method(); // no exception here and method returns 1
duckTypedObj.brandCheckedMethod(); // throws an exception
複製代碼

顯然,這個例子是刻意設計的,而且這種鴨子類型的好處是值得懷疑的。除非咱們談論Proxy。 代理有一個很是重要的使用場景——元編程。 爲了使代理執行全部必需的有用工做,代理包裝的對象的方法應該在代理的上下文中調用,而不是在目標的中調用:

const a = new A();
const proxy = new Proxy(a, {
    get(target, p, receiver) {
        const property = Reflect.get(target, p, receiver);
        doSomethingUseful('get', retval, target, p, receiver);
        return (typeof property === 'function')
            ? property.bind(proxy) // actually bind here is redundant, but I want to focus your attention on method's context
            : property;
    }
});
複製代碼

調用proxy.method(); 將致使作一些在代理中聲明並返回1的有用工做,當調用proxy.brandCheckedMethod();而不是作一些有用的工做兩次將致使拋出異常,由於a !== proxy而且brand-check失敗了。

固然,咱們能夠在真實目標而不是代理的上下文中執行方法/函數,而且在某些狀況下它就足夠了(例如實現模式),但它並不是對於全部狀況都是夠用的(例如,實現反應式屬性:MobX 5已經使用代理實現,Vue.jsAurelia正在試驗這種方法以便用於將來版本)。

一般,雖然brand-check須要顯式聲明,但這並非問題:開發人員只需選擇他/她須要哪一種權衡以及緣由。 在明確的brand-check的狀況下,它能夠以容許其與某些可信代理進行交互的方式實現。

不幸的是,目前的提案沒有給予這種靈活性:

class A {
    #priv;

    method() {
        this.#priv; // brand-check ALWAYS happens here
    }
}
複製代碼

若是在沒有用A的構造函數構建的對象的上下文中調用method方法,該方法將始終拋出異常。這就是最可怕的事實:brand-check在這裏隱含並與另外一個特徵——「封裝」混合。

雖然幾乎全部類型的代碼都須要封裝,但品牌檢查的用例數量很是有限。 當開發人員想要隱藏實現細節時,將它們混合成一種語法將致使意外的brand-check,而爲了推廣這個proposal,宣傳#是新的_更是雪上加霜。

您還能夠閱讀有關當前提案破壞代理行爲的討論細節。 在Aurelia開發者Vue.js做者參與其中。此外,個人評論描述了代理的幾個用例之間的差別,這可能頗有趣。 並討論了私有字段與模式之間的關係

備選方案

除非有其餘選擇,不然全部這些討論都沒有多大意義。 不幸的是,它們都沒有達到第一階段,所以這些備選提案沒有機會獲得充分發展。 可是,我想指出其中的一些,以某種方式解決上述問題。

  1. Symbol.private——來自其中一名委員會成員的替代提案。
    1. 解決上面描述的全部問題(它可能有本身的問題,但沒有進一步開發它很難發現)
    2. 在委員會最近的會議上再次被拒絕,由於缺少內置的brand-check模式問題(但這裏這裏提供了可行的解決方案)和缺少方便的語法
    3. 方便的語法能夠創建在這個提議之上,如這裏這裏所示
  2. Classes 1.1 - 來自同一做者的較早提議
  3. private做爲對象使用

結論

看起來我彷佛在責怪委員會——但實際上,我沒有。 我只是認爲,爲了在JS中實現適當的封裝,已經通過多年(甚至幾十年,取決於選擇的起點)的努力,而咱們業界更是的發生了不少變化,可能委員會錯過了一些變化。致使,相關事體的優先級別可能會變得有些模糊。

不光如此,咱們做爲一個社區,迫使TC39更快地發佈功能,可是咱們卻沒有爲早期提案提供足夠的反饋,結果致使爭論不少而可以用來改變某些事情的時間不多。

觀點認爲,在這種狀況下,該提案過程失敗了。

在長期潛水以後,我決定盡我所能,以防止未來發生這種狀況。 不幸的是,我作不了多少——只能寫寫評論文章並在babel中實現早期提案。

總的來講,反饋和溝通是最重要的,因此我懇請你們與委員會分享更多的想法。

翻譯參考

相關文章
相關標籤/搜索