在 JavaScript 中實現私有成員的語法特性

發表於2015-10-21

前言

在物件導向的程式設計正規化中,封裝都是必不可少的一個概念,而在諸如 Java,C++等傳統的物件導向的語言中, 私有成員是實現封裝的一個重要途徑。但在 JavaScript 中,確沒有在語法特性上對私有成員提供支援, 這也使得開發人員使出了各種奇技淫巧去實現 JS 中的私有成員,以下將介紹下目前實現 JS 私有成員特性的幾個方案以及它們之間的優缺點對比。

現有的一些實現方案

約定命名方案

約定以下劃線’_’開頭的成員名作為私有成員,僅允許類成員方法訪問呼叫,外部不得訪問私有成員。簡單的程式碼如下:

優點
  • 毫無疑問,約定命名是最簡單的私有成員實現方案,沒有程式碼層面上的工作。
  • 除錯方便,能夠在控制檯上直接看到物件上的私有成員,方便排查問題。
  • 相容性好,ie6+都支援
不足
  • 無法阻止外部對私有成員的訪問和變更,如果真有不知道或者不遵守約定的開發人員變更私有屬性,也是無能為力。
  • 必須強制或說服大家遵守這個約定,當然這個在有程式碼規範的團隊中不是什麼太大的問題。

es6 symbol 方案

在 es6中,引入了一個 Symbol 的特性,該特性正是為了實現私有成員而引入的。

主要的思路是,為每一個私有成員的名稱產生一個隨機且唯一的字串key,這個 key 對外不可見,對內的可見性則是通過 js 的閉包變數實現,示例程式碼如下:

優點
  • 彌補了命名約定方案的缺陷,外部無法通過正常途徑獲得私有成員的訪問權。
  • 除錯便捷程度上可以接受,一般是通過給 symbol 的建構函式傳入一個字串引數,則控制檯上對應的私有屬性名稱會展示為:Symbol(key)
  • 相容性不錯,不支援 Symbol的瀏覽器可以很容易的 shim 出來。
不足
  • 寫法上稍顯彆扭,必須為每一個私有成員都建立一個閉包變數讓內部方法可以訪問。
  • 外部還是可以通過 Object.getOwnPropertySymbols的方式獲取例項的 symbol 屬性名稱,通過該名稱獲得私有成員的訪問權。這種場景出現得比較少,且知道這種途徑的開發人員水平相信都是有足夠的能力知道自己的行為會有什麼影響,因此這個不足點也算不上真正意義的不足。

es6 WeakMap 方案

在 es6 中引入了 Map, WeakMap 容器,最大的特點是容器的鍵名可以是任意的資料型別,雖說初衷不是為了實現私有成員引入,但意外的可以被用來實現私有成員特性。

主要的思路是,在類的級別上建立一個 WeakMap 容器,用於儲存各個例項的私有成員,這個容器對外不可見,對內通過閉包方式可見;內部方法通過將例項作為鍵名獲取容器上對應例項的私有成員,示例程式碼如下:

優點
  • 彌補了命名約定方案的缺陷,外部無法通過正常途徑獲得私有成員的訪問權。
  • 對 WeakMap 做一些封裝,抽出一個私有特性的實現模組,可以在寫法上相對 Symbol 方案更加簡潔乾淨,其中一種封裝的實現可以檢視參考文章3。
  • 最後一個是個人認為最大的優勢:基於 WeakMap 方案,可以方便的實現保護成員特性(這個話題會在其他文章說到:))
不足
  • 不好除錯,因為是私有成員都在閉包容器內,無法在控制檯列印例項檢視對應的私有成員
  • 待確認的效能問題,根據 es6的相關郵件列表,weakmap 內部似乎是通過順序一一對比的方式去定位 key 的,時間複雜度為 O(n),和 hash 演算法的 O(1)相比會慢不少
  • 最大的缺陷則是相容性帶來的記憶體膨脹問題,在不支援 WeakMap 的瀏覽器中是無法實現 WeakMap 的弱引用特性,因此例項無法被垃圾回收。 比如示例程式碼中 privateProp 是一個很大的資料項,無弱引用的情況下,例項無法回收,從而造成記憶體洩露。

現有實現方案小結

從上面的對比來看,Symbol方案最大優勢在於很容易模擬實現;而WeakMap的優勢則是能夠實現保護成員, 現階段無法忍受的不足是無法模擬實現弱引用特性而導致的記憶體問題。於是我的思路又轉向了將兩者優勢結合起來的方向。

Symbol + 類WeakMap 的整合方案

在 WeakMap 的方案中最大的問題是無法 shim 弱引用,較次要的問題是不大方便除錯。

shim 出來的 WeakMap 主要是無法追溯例項的生命週期,而例項上的私有成員的生命週期又是依賴例項, 因此將例項級別的私有成員部分放在例項上不就好了? 例項沒了,自然其屬性也隨之摧毀。而私有儲存區域的隱藏則可以使用 Symol 來做。

該方案的提供一個 createPrivate 函式,該函式會返回一個私有的 token 函式,對外不可見,對內通過閉包函式獲得, 傳入當前例項會返回當前例項的私有儲存區域。使用方式如下:

程式碼中主要就是實現 createPrivate 函式,大概的實現如下:

上述實現做了兩層儲存,privateStore 這層是例項上統一的私有成員儲存區域,而 classToken 對應的則是繼承層次之間不同類的私有成員定義,基類有基類的私有成員區域,子類和基類的私有成員區域是不同的。

當然,只做一層的儲存也可以實現,兩層儲存僅僅是為了除錯方便,可以直接在控制檯通過Symbol(‘privateStore’)這個屬性來檢視例項各個層次的私有部分。

奇葩的 es5 property getter 攔截方案

該方案純粹是閒得無聊玩了玩,主要是利用了 es5 提供的 getter,根據 argument.callee.caller 去判斷呼叫場景,如果是外部的則拋異常或返回 undefined, 如果是內部呼叫則返回真正的私有成員,實現起來比較複雜,且不支援 strict 模式,不推薦使用。 有興趣的同學可以看看實現

總結

以上幾個方案對比下來,我個人是傾向 Symbol+WeakMap 的整合方案,結合了兩者的優點,又彌補了 WeakMap 的不足和 Symbol 書寫的冗餘。 當然了,我相信隨著 JS 的發展,私有成員和保護成員也遲早會在語法層面上進行支援,正如 es6 對 class 關鍵字和 super 語法糖的支援一樣, 只是現階段需要開發者使用一些技巧去填補語言特性上的空白。

參考文章

  1. Private instance members with weakmaps in JavaScript
  2. Private Properties: Mozilla Developer Network
  3. Implementing Private and Protected Members in JavaScript

相關文章