“JavaScript的類欄位提案”或“TC39委員出了什麼問題?”

Cbdy發表於2018-11-16

翻譯,原始文章:“Class-fields-proposal” or “what went wrong in tc39 committee”

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

老實說,我很樂意寫一篇文章,描述為什麼您必須使用這個新功能以及如何實現它。但可惜我無法這麼做。

當前提案說明

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

類欄位

類欄位說明和用法:

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

從外部程式碼訪問欄位:

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

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

但有一件事值得注意:這個語法使用[[Define]]語義而不是我們習慣的[[Set]]語義。這意味著實際上上面的程式碼不等價於以下用法:

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

等價於下述用法:

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中實現早期提案。

總的來說,反饋和溝通是最重要的,所以我懇請大家與委員會分享更多的想法。

翻譯參考

相關文章