JavaScript 和 TypeScript 的封裝性 —— 私有成員

JavaScript 使用了基於原型模式的 OOP 實現,一直以來,其封裝性都不太友好。爲此,TypeScript 在對 JavaScript 類型進行增強的同時,特別關注了“類”定義。TS 的類定義起來更接近於 Java 和 C# 的語法,還允許使用 privateprotectedpublic 訪問修飾符聲明成員訪問限制,並在編譯期進行檢查。

顯然 ECMAScript 受到啓發,在 ES2015 中引入了新的類定義語法,並開始思考成員訪問限制的問題,提出了基於 Symbol 和閉包私有成員定義方案,當然這個方案使用起來並不太能被接受。又經過長達 4 年思考、設計和討論,最終在 ES2019 中發佈了使用 # 號來定義私有成員的規範。Chrome 74+ 和 Node 12+ 已經實現了該私有成員定義的規範。

JavaScript 和 ECMAScript 有什麼關係?

ECMAScript 由 ECMA-262 標準定義,是一個語言規範(Specification);JavaScript 是該規範的一個實現。拿上面的問題去搜索引擎上搜索一下,可以查閱到更詳盡的答案。

1. ES 規範中的私有成員定義

1.1. 正確示例

先來看一個示例:

 class Test {
     static #greeting = "Hello";
     #name = "James";
 
     test() {
         console.log(`${Test.#greeting} ${this.#name}`);
     }
 }
 
 // 用一個 IIFE 來代替定義並執行 main()
 (() => {
     const t = new Test();
     t.test();               // OUTPUT: Hello James
 })();

這個示例在 Chrome 74+ 和最新版的 Edge 等瀏覽覽器的開發者工具控制檯中運行都沒有問題。

📝 小技巧

試驗代碼時往往需要在開發者工具控制檯中多次粘貼類似的代碼,像 const t = ... 這樣的代碼在第二次運行的時候會報 “Identifier 't' has already been declared”這樣的錯誤。

爲了避免這種錯誤,可以將需要直接運行的代碼封裝在 IIFE 中,即 (() => { ... })()

同理,在不支持頂層 await 的環境中,也可以用 (async () => { ... })() 來封裝需要直接執行的異步代碼。

1.2. 錯誤調用示例

私有成員的訪問限制決定了,這個成員可以在定義它的類的內部訪問,不管它是靜態 (static) 成員還是實例成員。稍稍改一下代碼可以很容易驗證這一點:

 // 前端的類定義不變,只改一下 IIFE 中的測試代碼
 
 (() => {
     // SyntaxError: Private field '#greeting' must be declared in an enclosing class
     console.log(Test.#greeting);
                 
     // SyntaxError: Private field '#name' must be declared in an enclosing class
     console.log(new Test().#name);
 })();

1.3. 私有方法

雖然 MDN 上一直描述的是私有字段 (private fields),但它給的語法中包含了私有方法的定義

來自 MDN: Private class fields 的 Syntax 部分:

 class ClassWithPrivateMethod {  
   #privateMethod() {    
     return 'hello world'
   }
 }

(這部分代碼風格和其他代碼風格不同,它是原樣從 MDN 抄下來的,非“邊城”風格)

很不幸,即使在最新的 Chrome 83 中嘗試上面的代碼,也只能得到語法錯誤。Nodejs 和 Edge 都是基於 Chrome 的,所以會得到相同的結果。而 Firefox 壓根兒不支持私有成員特性。

不過 JS 很靈活,有非常神奇的 this 指向規則。我們可以用定義字段的方式來定義方法:

 class Test {
     #name;
 
     constructor(name) {
         this.#name = name;
     }
 
     #greet = () => {
         console.log(`hello ${this.#name}`);
     }
 
     test() {
         this.#greet();
     }
 }
 
 (() => {
     new Test("James").test();       // OUTPUT: hello James
 })();

2. TypeScript 中的私有成員

都已經 2020 了,講到 JavaScript 而不提 TypeScript 有點說不過去。但是如果你確實一點不會 TypeScript,也暫時不想去了解它,這部分可以跳過。

🖊 作者“邊城”會在近期推出與 TypeScript 有關的視頻教程,即使不免費,也會非常超值。請關注“邊城客棧”訂閱號跟蹤此視頻教程的最新消息。

2.1. 訪問限定修飾符

TypeScript 發明之初就提供了私有成員解決方案,跟  Java 和 C# 類似,通過添加訪問限定修飾符來聲明成員的可訪問級別:

  • public,公共可訪問,不加修飾符默認此級別;

  • protected,子類可訪問;

  • private,僅內部可訪問

還是拿實例來說話:

 class Test {
     private name: string;
     constructor(name: string) {
         this.name = name;
     }
 
     private greet() {
         console.log(`hello ${this.name}`);
     }
 
     test() {
         this.greet();
     }
 }
 
 (() => {
     const test = new Test("James");
     console.log(test.name);
     test.greet();
     test.test();
 })();

這段代碼可以拿到 TypeScript Playground 去運行,打開控制檯來查看結果。不過我更推薦使用 Playgroud v3 beta ,從 Playground 頁面右上角的“Try Playground v3 beta”可進入。

  • Playground: https://www.typescriptlang.org/play/

  • Playground v3: http://www.staging-typescript.org/play

在 JS 區,我們可以看到轉義後的 Test 類定義,已經去掉了訪問限定修飾符:

 class Test {
     constructor(name) {
         this.name = name;
     }
     greet() {
         console.log(`hello ${this.name}`);
     }
     test() {
         this.greet();
     }
 }

這就意味着,下面的測試代碼在 JS 環境中完全可以正確執行,不會受限。在控制檯,或者 Playground v3 的 Logs 部分,可以看到正常的輸出

 [LOG]: James
 [LOG]: hello James
 [LOG]: hello James

不過在編輯器內,我們可以看到 test.nametest.greet() 被標記爲有錯。鼠標移上去可以看到具體的錯誤信息。這些錯誤信息在 Playground v3 的 Errors 部分也可以看到:

 Property 'name' is private and only accessible within class 'Test'.
 Property 'greet' is private and only accessible within class 'Test'.

TypeScript 擴展了更爲嚴格的語法,並藉助 LSP 和編譯器來幫助開發者在開發環境中儘早發現並解決存在或替在的問題。這就是 TS 爲開發者帶來的最大好處,也是 TS 發展如此迅速的原因之一。然而,正如上面的示例所示,TS 編譯出來的 JS 庫並不能限制最終用戶如何使用。所以即使 TS 有了 private#privateField 在仍然在 TS 中具有存在的意義。

2.2. TypeScript 和 #privateField

上面提到,如果使用 TypeScript 寫一個庫,使用 privateprotected 來限定成員訪問,在其用戶同樣使用 TypeScript 的時候不會有問題。但其用戶使用 JavaScript 的時候,卻並不能受到期望的限制。因此 TypeScript 引入 #privateField 是意義的。

不過 TypeScript 並沒有直接把 private 修飾符和 #privateField 關聯起來,它在 v3.8 的發行公告 中解釋了二者的主要區別在於運行時訪問權限。

在 TypeScript 中使用 #privateField,從語法檢查上來說和 private 區別不大,都限制爲僅在內部可訪問,所以在聲明 #privateField 的時候,不允許添加訪問限制修飾符:

  • 如果添加 publicprotected,語義相悖

  • 如果添加 private,沒有必要

上面的示例,如果把 private name 改爲 #name,我們不僅會得到編譯期錯誤,還會得到運行時錯誤:

 [ERR]: Private field '#name' must be declared in an enclosing class

或者

 [ERR]: Unexpected token ')'

得到哪個錯誤取決於 tsconfig.json 中的 target 配置,它決定了 console.log(test.#name) 這句話的轉譯結果。

  • 如果配置爲 ESNEXT,轉譯結果不變,仍然是 test.#name。由於外部不可訪問私有成員,這樣調用會引起語法錯誤;

  • 如果配置爲 ES2020 或以前版本,轉譯結果會直接丟掉對私有字段的訪問:console.log(test.);,直接引發的語法錯誤。

private 和 #privateField 的選擇問題上,我個人建議現階段(現階段 TS 的最高穩定 Target 版本是 TS2020)仍然使用 private。TS 會把 #privateField 轉義成閉包環境下的 privateMap,雖然實現了功能,但看起來彆扭。當然如果你不在意這個問題,或者使用 ESNext 作爲 Target,那不妨早一點嘗試新的語法。

3. 其他私有成員解決方案

3.1. 使用閉包環境下的 Symbol

ES2015 引入了 Symbol 這一特殊的數據類型。說它特殊,因爲它可以做到每次產生的 Symbol 絕不相同,比如

 const a = Symbol("key");
 const b = Symbol("key");
 console.log(a === b);   // false

此外,Symbol 可以作爲對象的 key 使用:

 const o = {};
 const key = Symbol("key");
 o[key] = "something";
 console.log(o[key]);    // OUTPUT: something

如果在閉包環境下使用 Symbol,讓外界拿不到這個 Symbol,就可以實現私有屬性。下面是使用 JS 寫的示例,TS 類似:

 // @file test.mjs
 
 const NAME = Symbol("name");
 
 export class Test {
     constructor(name) {
         this[NAME] = name;
     }
 
     test() {
         console.log(`hello ${this[NAME]}`);
     }
 }
 // @file index.mjs
 
 import { Test } from "./test.mjs";
 
 const t = new Test("James");
 
 // OUTPUT: hello James
 t.test();
 
 // OUTPUT: undefined
 console.log(t[Symbol("name")]);

模塊 —— 不管是 ESM 還是 CommonJS Module —— 都是閉包環境。所以在模塊化框架中使用 Symbol 還是很方便的。

3.2. 用隨機屬性名代替 Symbol

對於沒有 Symbol 的環境,可以使用隨機屬性名代替。不過既然是不支持 Symbol 的環境,顯然也不支持 class, let/const, ESM 等特性,所以示例代碼看起來比較古老:

 var Test = (function () {
     const NAME = ("name__" + Math.random());
 
     function Test(name) {
         this[NAME] = name;
     }
 
     Test.prototype.test = function () {
         console.log("hello " + this[NAME]);
    };
 
     return Test;
 })();
 
 var t = new Test("James");
 t.test();

由於每次運行時創建 Test 構造函數的時候,NAME 的值會隨機生成,所以用戶並不知道它到底是什麼,也就不能通過它來訪問成員,以此達到私有化的目的。

3.3. 擡個槓

不管是 Symbol 還是隨機屬性名實現的私有成員,都有漏洞可鑽,所以防君子不防小人。提示一下,細節就不說了:

  • Object.getOwnPropertySymbols()

  • Object.getOwnPropertyNames()





喜歡此文,點個在看 ⇘

支持作者,賞個咖啡豆 ⇓


本文分享自微信公衆號 - 邊城客棧(fancyidea-full)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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