詳解 JavaScript 的私有變量

 JavaScript最近有很多改進,新的語法和特性一直在添加。但有些事情不會改變,所有的東西仍然是一個對象,幾乎所有的東西都可以在運行時改變,也沒有公有/私有屬性的概念。但是我們可以用一些技巧來改變這些,在這篇文章中,我將研究實現私有屬性的各種方法

JavaScriopt 在 2015 年引入了“”這種大家都熟悉的面向對象方法,基於 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

原文鏈接

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