[譯] JavaScript 中的私有變量

JavaScript 中的私有變量

最近 JavaScript 有了不少改進,新的語法和功能一直在被增長進來。但有些東西並無改變,一切仍然是對象,幾乎全部東西均可以在運行時被改變,而且沒有公共、私有屬性的概念。可是咱們本身能夠用一些技巧來改變這種狀況,在這篇文章中,我介紹各類能夠實現私有變量的方式。javascript

在 2015 年,JavaScript 有了 ,對於那些從 更傳統的 C 語系語言(如 Java 和 C#)過來的程序員們,他們會更熟悉這種操做對象的方式。可是很明顯,這些類不像你習慣的那樣 -- 它的屬性沒有修飾符來控制訪問,而且全部屬性都須要在函數中定義。html

那麼咱們如何才能保護那些不該該在運行時被改變的數據呢?咱們來看看一些選項。前端

在整篇文章中,我將反覆用到一個用於構建形狀的示例類。它的寬度和高度只能在初始化時設置,提供一個屬性來獲取面積。有關這些示例中使用的 get 關鍵字的更多信息,請參閱我以前的文章 Getters 和 Settersjava

命名約定

第一個也是最成熟的方法是使用特定的命名約定來表示屬性應該被視爲私有。一般如下劃線做爲屬性名稱的前綴(例如 _count )。這並無真正阻止變量被訪問或修改,而是依賴於開發者之間的相互理解,認爲這個變量應該被視爲限制訪問。android

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 變量。ios

const map = new WeakMap();

// 建立一個在每一個實例中存儲私有變量的對象
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 }
複製代碼

Symbol

Symbol 的實現方式與 WeakMap 十分相近。在這裏,咱們可使用 Symbol 做爲 key 的方式建立實例上的屬性。這能夠防止該屬性在遍歷或使用 JSON.stringify 時可見。不過這種技術須要爲每一個私有屬性建立一個 Symbol。若是您在類外能夠訪問該 Symbol,那你仍是能夠拿到這個私有屬性。git

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() {
  // 私有變量集
  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,這可能會成爲代碼中的問題所在。github

解決這一問題的方法是將外部的 Shape 設置爲返回實例的原型:typescript

return Object.setPrototypeOf(new Shape(...arguments), this);
複製代碼

不幸的是,這還不夠,只更新這一行如今會將 square.area 視爲未定義。這是因爲 get 關鍵字在幕後工做的緣故。咱們能夠經過在構造函數中手動指定 getter 來解決這個問題。

function Shape() {
  // 私有變量集
  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 設置爲實例原型的原型,這樣咱們就能夠同時使用 instanceofget。在下面的例子中,咱們有一個原型鏈 Object -> 外部的 Shape -> 內部的 Shape 原型 -> 內部的 Shape

function Shape() {
  // 私有變量集
  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
複製代碼

Proxy

Proxy 是 JavaScript 中一項美妙的新功能,它將容許你有效地將對象包裝在名爲 Proxy 的對象中,並攔截與該對象的全部交互。咱們將使用 Proxy 並遵守上面的 命名約定 來建立私有變量,但可讓這些私有變量在類外部訪問受限。

Proxy 能夠攔截許多不一樣類型的交互,但咱們要關注的是 getset,Proxy 容許咱們分別攔截對一個屬性的讀取和寫入操做。建立 Proxy 時,你將提供兩個參數,第一個是您打算包裹的實例,第二個是您定義的但願攔截不一樣方法的 「處理器」 對象。

咱們的處理器將會看起來像是這樣:

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;                  // 錯誤:試圖訪問私有屬性
複製代碼

正如你在這個例子中看到的那樣,咱們保留使用 instanceof 的能力,也就不會出現一些意想不到的結果。

不幸的是,當咱們嘗試執行 JSON.stringify 時會出現問題,由於它試圖對私有屬性進行格式化。爲了解決這個問題,咱們須要重寫 toJSON 函數來僅返回「公共的」屬性。咱們能夠經過更新咱們的 get 處理器來處理 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] !== '_') {           // 只複製公共屬性
        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;                  // 錯誤:試圖訪問私有屬性
複製代碼

Proxy 是現階段我在 JavaScript 中最喜歡的用於建立私有屬性的方法。這種類是以老派 JS 開發人員熟悉的方式構建的,所以能夠經過將它們包裝在相同的 Proxy 處理器來兼容舊的現有代碼。

附: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 會在編譯時給你報出一個錯誤,但不會中止它的編譯。

// 編譯時錯誤:屬性 ‘width’ 是私有的,只能在 ‘Shape’ 類中訪問。
console.log(square.width); // 10
複製代碼

TypeScript 不會自做聰明,不會作任何的事情來嘗試阻止代碼在運行時訪問私有屬性。我只把它列在這裏,也是讓你們意識到它並不能直接解決問題。你能夠 本身觀察一下 由上面的 TypeScript 建立出的 JavaScript 代碼。

將來

我已經向你們介紹瞭如今可使用的方法,但將來呢?事實上,將來看起來頗有趣。目前有一個提案,向 JavaScript 的類中引入 private fields,它使用 符號表示它是私有的。它的使用方式與命名約定技術很是相似,但對變量訪問提供了實際的限制。

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);           // 錯誤:私有屬性只能在類中訪問
複製代碼

若是你對此感興趣,能夠閱讀如下 完整的提案 來獲得更接近事實真相的細節。我以爲有趣的一點是,私有屬性須要預先定義,不能臨時建立或銷燬。對我來講,這在 JavaScript 中感受像是一個很是陌生的概念,因此看看這個提案如何繼續發展將變得很是有趣。目前,這一提案更側重於私有的類屬性,而不是私有函數或對象層面的私有成員,這些可能會晚一些出爐。

NPM 包 -- Privatise

在寫這篇文章時,我還發布了一個 NPM 包來幫助建立私有屬性 -- privatise。我使用了上面介紹的 Proxy 方法,並增長額外的處理器以容許傳入類自己而不是實例。全部代碼均可以在 GitHub 上找到,歡迎你們提出任何 PR 或 Issue。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索