[譯] JavaScript 中的私有變數

ThinkerNoah發表於2019-03-03

JavaScript 中的私有變數

最近 JavaScript 有了很多改進,新的語法和功能一直在被增加進來。但有些東西並沒有改變,一切仍然是物件,幾乎所有東西都可以在執行時被改變,並且沒有公共、私有屬性的概念。但是我們自己可以用一些技巧來改變這種情況,在這篇文章中,我介紹各種可以實現私有變數的方式。

在 2015 年,JavaScript 有了 ,對於那些從 更傳統的 C 語系語言(如 Java 和 C#)過來的程式設計師們,他們會更熟悉這種操作物件的方式。但是很明顯,這些類不像你習慣的那樣 — 它的屬性沒有修飾符來控制訪問,並且所有屬性都需要在函式中定義。

那麼我們如何才能保護那些不應該在執行時被改變的資料呢?我們來看看一些選項。

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

命名約定

第一個也是最成熟的方法是使用特定的命名約定來表示屬性應該被視為私有。通常以下劃線作為屬性名稱的字首(例如 _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 變數。

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,那你還是可以拿到這個私有屬性。

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,這可能會成為程式碼中的問題所在。

解決這一問題的方法是將外部的 Shape 設定為返回例項的原型:

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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章