詳解 JavaScript 的私有變數
JavaScript最近有很多改進,新的語法和特性一直在新增。但有些事情不會改變,所有的東西仍然是一個物件,幾乎所有的東西都可以在執行時改變,也沒有公有/私有屬性的概念。但是我們可以用一些技巧來改變這些,在這篇文章中,我將研究實現私有屬性的各種方法
JavaScriopt 在 2015 年引入了“JavaScript/Reference/Classes" rel="nofollow,noindex" target="_blank">類 ”這種大家都熟悉的面向物件方法,基於 C 語言的經典語言 Java 和 C# 就提供這種方法。不過很快大家就發現這些類並不是習慣的那樣 —— 他們沒有控制訪問的屬性修飾符,而且所有屬性都必須定義在函式中。
那麼,我們該如何保護那些不應該在執行期間被修改的資料呢?先來看一些辦法。
這篇文章中我會使用一個建立圖形的類作為示例。它的寬度和高度只能高度只能在初始化時設定,同時提供一個用於獲取面積的屬性。這些示例中用到了get關鍵字,你可以在我的文章 Getter 和 Setter 中瞭解到這一知識點
命名規範
第一個方法是使用特定的命名來表示屬性應該被視為私有,這是最成熟的方法。其常見作法是給屬性名字首一個下劃線(比如_count)。但這種方法不能阻止值被訪問或被修改,它依賴於不同開發者之間的共識,公認這個值應該被禁止訪問。
class Shape { constructor(width, height) { this._width = width; this._height = height; } get area() { return this._width * this._height; } } const square = new Shape(10, 10); console.log(square.area);// 100 console.log(square._width);// 10
WeakMap
使用 WeakMap 儲存私有值的方法限制性會稍微強一些。雖然這個方法仍然不能阻止訪問資料,但它把私有值與使用者互動物件隔離開了。在這種技巧中,我們把擁有私有屬性的物件例項作為 WeakMap 的鍵,並使用一個函式(我們稱為internal)來建立或返回一個儲存所有私有屬性值的物件。這種技術的優點是在列舉物件屬性或者使用 JSON.stringify時不會把私有屬性顯示出來,但它依賴 WeakMap,而 WeakMap 物件類的作用域外仍然可以被訪問到,也可以進行操作。
const map = new WeakMap(); // Create an object to store private values in per instance const internal = obj => { if (!map.has(obj)) { map.set(obj, {}); } return map.get(obj); } class Shape { constructor(width, height) { internal(this).width = width; internal(this).height = height; } get area() { return internal(this).width * internal(this).height; } } const square = new Shape(10, 10); console.log(square.area);// 100 console.log(map.get(square));// { height: 100, width: 100 }
Symbols
Symbol 的用法跟 WeakMap 類似。我們把Symbol 當作例項屬性的鍵來使用。這種方式也不會在列舉屬性和 JSON.stringify時呈現出來。這個技巧需要為每個私有屬性建立 Symbol。不過,只要能訪問到這些 Symbol 值,那麼在類之外同樣也能訪問以它們為鍵的屬性。
const widthSymbol = Symbol('width'); const heightSymbol = Symbol('height'); class Shape { constructor(width, height) { this[widthSymbol] = width; this[heightSymbol] = height; } get area() { return this[widthSymbol] * this[heightSymbol]; } } const square = new Shape(10, 10); console.log(square.area);// 100 console.log(square.widthSymbol);// undefined console.log(square[widthSymbol]); // 10
閉包
前面提到的種種技巧都不能避免從類外部訪問私有屬性,這一問題可以使用閉包來解決。可以把閉包和 WeakMap 或者 Symbol 一起使用,當然也可以把閉包用於標準的 JavaScript 物件。閉包的原理是將資料封裝在函式作用域內,這個作用域在函式呼叫時建立,從內部返回函式的結果,在外部訪問不到。
function Shape() { // private vars const this$ = {}; class Shape { constructor(width, height) { this$.width = width; this$.height = height; } get area() { return this$.width * this$.height; } } return new Shape(...arguments); } const square = new Shape(10, 10); console.log(square.area);// 100 console.log(square.width); // undefined
不過這個技巧有一點小問題。假設我們有兩個不同的Shape物件,程式碼調了外面那層 Shape(函式),但返回的例項卻是內部 Shape(類)的例項。多數情況下可能沒什麼問題,但是它會導致 square instanceof Shape返回 false,這就是程式碼中潛藏的問題。
為了解決這個問題,有一種辦法是將外部的 Shape 設定為其返回例項的原型:
return Object.setPrototypeOf(new Shape(...arguments), this);
然而僅更改這一句話還不行,square.area會變成未定義。get關鍵字背後的工作原理是這一問題的根源。我們可以在構造器中手工指定 getter 來解決這個問題。
function Shape() { // private vars const this$ = {}; class Shape { constructor(width, height) { this$.width = width; this$.height = height; Object.defineProperty(this, 'area', { get: function() { return this$.width * this$.height; } }); } } return Object.setPrototypeOf(new Shape(...arguments), this); } const square = new Shape(10, 10); console.log(square.area);// 100 console.log(square.width);// undefined console.log(square instanceof Shape); // true
另外,我們也可以把this設定為例項原型的原型,這樣一來,instanceof和 get就都沒問題了。下面的示例中,我們生成了這樣的原型鏈:Object -> Outer Shape -> Inner Shape Prototype -> Inner Shape。
function Shape() { // private vars const this$ = {}; class Shape { constructor(width, height) { this$.width = width; this$.height = height; } get area() { return this$.width * this$.height; } } const instance = new Shape(...arguments); Object.setPrototypeOf(Object.getPrototypeOf(instance), this); return instance; } const square = new Shape(10, 10); console.log(square.area);// 100 console.log(square.width);// undefined console.log(square instanceof Shape); // true
代理
代理 是 JavaScript 中非常迷人的新特性,它能有效地把物件封裝在稱為代理的物件中,由代理攔截所有與該物件的互動。上面我們提到了使用“命名規範”的方法來建立私有屬性,現在可以用代理來限制從類外部對私有屬性的訪問。
代理可以攔截很多不同型別的互動操作,但我們這裡重點關注 get 和 set ,因為它可以攔截讀取或寫入屬性的動作。建立代理時需要提供兩個引數,第一個是要封裝的例項,第二個是“處理器”物件,這個物件中定義了你想攔截的各種方法。
我們的處理器物件有點兒像這樣:
const handler = { get: function(target, key) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } return target[key]; }, set: function(target, key, value) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } target[key] = value; } };
這裡我們檢查每個屬性是否以下劃線開始,如果是就丟擲一個錯誤來阻止對它的訪問。
class Shape { constructor(width, height) { this._width = width; this._height = height; } get area() { return this._width * this._height; } } const handler = { get: function(target, key) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } return target[key]; }, set: function(target, key, value) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } target[key] = value; } } const square = new Proxy(new Shape(10, 10), handler); console.log(square.area);// 100 console.log(square instanceof Shape); // true square._width = 200; // Error: Attempt to access private property
在這個示例中可以看到,instanceof有效,所以不會出現什麼意想不到的結果。
可惜用JSON.stringify時還是會有問題,因為它會嘗試將私有屬性序列化成字串。為了避免這個問題,我們需要過載 toJSON函式來返回“公有”屬性。我們通過更新處理器物件來專門處理 toJSON:
注意:這會覆蓋掉自定義的toJSON函式。
get: function(target, key) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } else if (key === 'toJSON') { const obj = {}; for (const key in target) { if (key[0] !== '_') { // Only copy over the public properties obj[key] = target[key]; } } return () => obj; } return target[key]; }
現在我們在保留原有功能 同時封裝了私有屬性,唯一的問題在於私有屬性仍然可以枚舉出來。for (const key in square)會把 _width和_height列出來。幸好這個問題也可以用處理器來解決!我們可以攔截對 getOwnPropertyDescriptor的呼叫,控制對私有屬性的輸出:
getOwnPropertyDescriptor(target, key) { const desc = Object.getOwnPropertyDescriptor(target, key); if (key[0] === '_') { desc.enumerable = false; } return desc; }
現在把所有程式碼放在一起:
class Shape { constructor(width, height) { this._width = width; this._height = height; } get area() { return this._width * this._height; } } const handler = { get: function(target, key) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } else if (key === 'toJSON') { const obj = {}; for (const key in target) { if (key[0] !== '_') { obj[key] = target[key]; } } return () => obj; } return target[key]; }, set: function(target, key, value) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } target[key] = value; }, getOwnPropertyDescriptor(target, key) { const desc = Object.getOwnPropertyDescriptor(target, key); if (key[0] === '_') { desc.enumerable = false; } return desc; } } const square = new Proxy(new Shape(10, 10), handler); console.log(square.area);// 100 console.log(square instanceof Shape); // true console.log(JSON.stringify(square));// "{}" for (const key in square) {// No output console.log(key); } square._width = 200;// Error: Attempt to access private property
在 JavaScript 中建立私有屬性的方法中,代理是我目前最喜歡的一種方法。這個方法使用了老派 JS 開發者熟悉的技術,因此它可以應用於現有的舊程式碼,只需要用同樣的代理處理器封裝起來就好。
附註 - TypeScript
如果你還不知道 TypeScript,我告訴你,TypeScript 是基於型別的 JavaScript 超集,它會編譯成普通的 JavaScript。TypeScript 語言允許你指定私有、僅有和受保護的屬性。
class Shape { private width; private height; constructor(width, height) { this.width = width; this.height = height; } get area() { return this.width * this.height; } } const square = new Shape(10, 10) console.log(square.area); // 100
使用 TypeScript 要特別注意,它只是在編譯期識別型別和私有/公有修飾。如果你嘗試訪問square.width會發現毫無壓力。TypeScript 會在編譯時報告一個錯誤,但並不會中止編譯。
// Compile time error: Property 'width' is private and only accessible within class 'Shape'. console.log(square.width); // 10
TypeScript 不會嘗試在執行時智慧地阻止訪問私有屬性。我只是把它列在這裡,讓人們意識到它並不能解決我們所看到的任何問題。你可以自己看看 上面的 TypeScript 會生成什麼樣的 JavaScript。
未來
我已經介紹了目前可以使用的各種方法,但未來會怎樣?哇哦,未來似乎很有意思。目前有一個提議為 JavaScript 引入私有欄位 ,使用 # 符號來表示私有屬性。它的用法和命名規範技術相似,但會提供確確實實的訪問限制。
class Shape { #height; #width; constructor(width, height) { this.#width = width; this.#height = height; } get area() { return this.#width * this.#height; } } const square = new Shape(10, 10); console.log(square.area);// 100 console.log(square instanceof Shape); // true console.log(square.#width);// Error: Private fields can only be referenced from within a class.
如果你對此感興趣,可以閱讀完整的提議 來獲得所有細節。我發現有趣的是私有欄位需要預先定義而且不能針對性的建立或刪除。這是 JavaScript 中讓我感到非常奇怪的概念,所以我想看到這一提議接下來的發展。目前該提議主要關注私有屬性,沒有私有函式,也沒有物件字面量中的私有成員,可能以後會有的。
本文來自雲棲社群合作伙伴“開源中國”
本文作者:h4cd