[譯] JavaScript 中為什麼會有 Symbol 型別?

熊咆龍吟發表於2019-03-27

[譯] JavaScript 中為什麼會有 Symbol 型別?

作為最新的基本型別,Symbol 為 JavaScript 語言帶來了很多好處,特別是當其用在物件屬性上時。但是,相比較於 String 型別,Symbol 有哪些 String 沒有的功能呢?

在深入探討 Symbol 之前,讓我們先看看一些許多開發人員可能都不知道的 JavaScript 特性。

背景

JavaScript 中有兩種資料型別:基本資料型別和物件(物件也包括函式),基本資料型別包括簡單資料型別,比如 number(從整數到浮點數,從 Infinity 到 NaN 都屬於 Number 型別)、boolean、string、undefinednull(注意儘管 typeof null === 'object'null 仍然是一個基本資料型別)。

基本資料型別的值是不可以改變的,即不能更改變數的原始值。當然可以重新對變數進行賦值。例如,程式碼 let x = 1; x++;,雖然你通過重新賦值改變了變數 x 的值,但是變數的原始值 1 仍沒有被改變。

一些語言,比如 C 語言,有按引用傳遞和按值傳遞的概念。JavaScript 也有類似的概念,它是根據傳遞資料的型別推斷出來的。如果將值傳入一個函式,則在函式中重新對它賦值不會修改它在呼叫位置的值。但是,如果你修改的是基本資料的值,那麼修改後的值在呼叫它的地方被修改。

考慮下面的例子:

function primitiveMutator(val) {
  val = val + 1;
}

let x = 1;
primitiveMutator(x);
console.log(x); // 1

function objectMutator(val) {
  val.prop = val.prop + 1;
}

let obj = { prop: 1 };
objectMutator(obj);
console.log(obj.prop); // 2
複製程式碼

基本資料型別(NaN 除外)總是與另一個具有相同值的基本資料型別完全相等。如下:

const first = "abc" + "def";
const second = "ab" + "cd" + "ef";

console.log(first === second); // true
複製程式碼

然而,構造兩個值相同的非基本資料型別則得到不相等的結果。我們可以看到發生了什麼:

const obj1 = { name: "Intrinsic" };
const obj2 = { name: "Intrinsic" };

console.log(obj1 === obj2); // false

// 但是,當兩者的 .name 屬性為基本資料型別時:
console.log(obj1.name === obj2.name); // true
複製程式碼

物件在 JavaScript 中扮演著重要的角色,幾乎所有地方可以見到它們的身影。物件通常是鍵/值對的集合,然而這種形式的最大限制是:物件的鍵只能是字串,直到 Symbol 出現這一限制才得到解決。如果我們使用非字串的值作為物件的鍵,該值會被強制轉換成字串。在下面的程式中可以看到這種強制轉換:

const obj = {};
obj.foo = 'foo';
obj['bar'] = 'bar';
obj[2] = 2;
obj[{}] = 'someobj';

console.log(obj);
// { '2': 2, foo: 'foo', bar: 'bar',
     '[object Object]': 'someobj' }
複製程式碼

注意:雖然有些離題,但是需要知道的是建立 Map 資料結構的部分原因是為了在鍵不是字串的情況下允許鍵/值方式儲存。

Symbol 是什麼?

現在既然我們已經知道了基本資料型別是什麼,也就終於可以定義 Symbol。Symbol 是不能被重新建立的基本資料型別。在這種情況下,Symbol 類似於物件,因為物件建立多個例項也將導致不完全相等的值。但是,Symbol 也是基本資料型別,因為它不能被改變。下面是 Symbol 用法的一個例子:

const s1 = Symbol();
const s2 = Symbol();

console.log(s1 === s2); // false
複製程式碼

當例項化一個 symbol 值時,有一個可選的首選引數,你可以賦值一個字串。此值用於除錯程式碼,不會真正影響 symbol 本身。

const s1 = Symbol('debug');
const str = 'debug';
const s2 = Symbol('xxyy');

console.log(s1 === str); // false
console.log(s1 === s2); // false
console.log(s1); // Symbol(debug)
複製程式碼

Symbol 作為物件屬性

symbols 還有另一個重要的用法,它們可以被當作物件中的鍵!下面是一個在物件中使用 symbol 作為鍵的例子:

const obj = {};
const sym = Symbol();
obj[sym] = 'foo';
obj.bar = 'bar';

console.log(obj); // { bar: 'bar' }
console.log(sym in obj); // true
console.log(obj[sym]); // foo
console.log(Object.keys(obj)); // ['bar']
複製程式碼

注意,symbols 鍵不會被在 Object.keys() 返回。這也是為了滿足向後相容性。舊版本的 JavaScript 沒有 symbol 資料型別,因此不應該從舊的 Object.keys() 方法中被返回。

乍一看,這就像是可以用 symbols 在物件上建立私有屬性!許多其他程式語言可以在其類中有私有屬性,而 JavaScript 卻遺漏了這種功能,長期以來被視為其語法的一種缺點。

不幸的是,與該物件互動的程式碼仍然可以訪問物件那些鍵為 symbols 的屬性。甚至是在呼叫程式碼自己無法訪問 symbol 的情況下也有可能發生。 例如,Reflect.ownKeys() 方法能夠得到一個物件的所有鍵的列表,包括字串和 symbols:

function tryToAddPrivate(obj) {
  obj[Symbol('Pseudo Private')] = 42;
}

const obj = { prop: 'hello' };
tryToAddPrivate(obj);

console.log(Reflect.ownKeys(obj));

console.log(obj[Reflect.ownKeys(obj)[1]]); // 42
複製程式碼

注意:目前有些工作旨在處理在 JavaScript 中向類新增私有屬性的問題。這個特性就是 Private Fields 雖然這不會對所有物件都有好處,但會對類例項的物件有好處。Private Fields 從 Chrome 74 開始可用。

防止屬性名衝突

Symbol 型別可能會對獲取 JavaScript 中物件的私有屬性不利。它們之所以有用的另一個理由是,當不同的庫希望向物件新增屬性時 symbols 可以避免命名衝突的風險。

如果有兩個不同的庫希望將某種後設資料附加到一個物件上,兩者可能都想在物件上設定某種識別符號。僅僅使用兩個字串型別的 id 作為鍵來標識,多個庫使用相同鍵的風險就會很高。

function lib1tag(obj) {
  obj.id = 42;
}

function lib2tag(obj) {
  obj.id = 369;
}
複製程式碼

應用 symbols,每個庫都可以通過例項化 Symbol 類生成所需的 symbols。然後不管什麼時候,都可以在相應的物件上檢查、賦值 symbols 對應的鍵值。

const library1property = Symbol('lib1');
function lib1tag(obj) {
  obj[library1property] = 42;
}

const library2property = Symbol('lib2');
function lib2tag(obj) {
  obj[library2property] = 369;
}
複製程式碼

基於這個原因 symbols 確實有益於 JavaScript。

然而,你可能會懷疑,為什麼每個庫不能在例項化時簡單地生成一個隨機字串,或者使用一個特殊的名稱空間?

const library1property = uuid(); // 隨機方法
function lib1tag(obj) {
  obj[library1property] = 42;
}

const library2property = 'LIB2-NAMESPACE-id'; // namespaced approach
function lib2tag(obj) {
  obj[library2property] = 369;
}
複製程式碼

你有可能是正確的,上面的兩種方法與使用 symbols 的方法很相似。除非兩個庫使用了相同的屬性名,否則不會有衝突的風險。

在這一點上,機靈的讀者會指出,這兩種方法並不完全相同。具有唯一名稱的屬性名仍然有一個缺點:它們的鍵非常容易找到,特別是當執行程式碼來迭代鍵或以其他方式序列化物件時。請考慮以下示例:

const library2property = 'LIB2-NAMESPACE-id'; // namespaced
function lib2tag(obj) {
  obj[library2property] = 369;
}

const user = {
  name: 'Thomas Hunter II',
  age: 32
};

lib2tag(user);

JSON.stringify(user);
// '{"name":"Thomas Hunter II","age":32,"LIB2-NAMESPACE-id":369}'
複製程式碼

如果我們為物件的屬性名使用了一個 symbol,那麼 JSON 的輸出將不包含 symbol 對應的值。為什麼會這樣?因為僅僅是 JavaScript 支援了 symbols,並不意味著 JSON 規範也改變了!JSON 只允許字串作為鍵,而 JavaScript 不會嘗試在最終的 JSON 負載中呈現 symbol 屬性。

我們可以通過使用 object.defineproperty(),輕鬆糾正庫物件字串汙染 JSON 輸出的問題:

const library2property = uuid(); // namespaced approach
function lib2tag(obj) {
  Object.defineProperty(obj, library2property, {
    enumerable: false,
    value: 369
  });
}

const user = {
  name: 'Thomas Hunter II',
  age: 32
};

lib2tag(user);

// '{"name":"Thomas Hunter II",
"age":32,"f468c902-26ed-4b2e-81d6-5775ae7eec5d":369}
console.log(JSON.stringify(user));
console.log(user[library2property]); // 369
複製程式碼

通過將字串鍵的可列舉描述符設定為 false 來“隱藏”的字串鍵的行為非常類似於 symbol 鍵。它們通過 Object.keys() 遍歷也看不到,但可以通過 Reflect.ownKeys()顯示,如下所示:

const obj = {};
obj[Symbol()] = 1;
Object.defineProperty(obj, 'foo', {
  enumberable: false,
  value: 2
});

console.log(Object.keys(obj)); // []
console.log(Reflect.ownKeys(obj)); // [ 'foo', Symbol() ]
console.log(JSON.stringify(obj)); // {}
複製程式碼

在這一點上,我們幾乎重新建立了 symbols。隱藏的字串屬性和 symbols 都對序列化程式隱身。這兩種屬性都可以使用 Reflect.ownKeys()方法提取,因此實際上並不是私有的。假設我們對字串屬性使用某種名稱空間/隨機值,那麼我們就消除了多個庫意外發生命名衝突的風險。

但是,仍然有一個微小的差異。由於字串是不可變的,Symbol 始終保證是唯一的,因此仍有可能生成相同的字串併產生衝突。從數學角度來說,意味著 symbols 確實提供了我們無法從字串中獲得的好處。

在 Node.js 中,檢查物件時(例如使用 console.log()),如果遇到物件上名為 inspect 的方法,則呼叫該函式,並將輸出表示成物件的日誌。可以想象,這種行為並不是每個人都期望的,通常命名為 inspect 的方法經常與使用者建立的物件發生衝突。現在有 symbol 可用來實現這個功能,並且可以在 require('util').inspection.custom 中使用。inspect 方法在 Node.js v10 中被廢棄,在 v11 中完全被忽略。現在沒有人會因為意外改變 inspect 的行為!

模擬私有屬性

這裡有一個有趣的方法,我們可以使用它來模擬物件上的私有屬性。這種方法將利用另一個 JavaScript 的特性:proxy。proxy 本質上是封裝了一個物件,並允許我們與該物件進行不同的互動。

proxy 提供了許多方法來攔截對物件執行的操作。我們所感興趣的是在嘗試讀取物件的鍵時,proxy 會有哪些動作。我不會去詳細解釋 proxy 是如何工作的,如果你想了解更多資訊,請檢視我們的另一篇文章:JavaScript Object Property Descriptors, Proxies, and Preventing Extension.

我們可以使用 proxy 來謊報物件上可用的屬性。在本例中,我們將建立一個 proxy,它用於隱藏我們的兩個已知隱藏屬性,一個是字串 _favColor,另一個是分配給 favBook 的 symbol:

let proxy;

{
  const favBook = Symbol('fav book');

  const obj = {
    name: 'Thomas Hunter II',
    age: 32,
    _favColor: 'blue',
    [favBook]: 'Metro 2033',
    [Symbol('visible')]: 'foo'
  };

  const handler = {
    ownKeys: (target) => {
      const reportedKeys = [];
      const actualKeys = Reflect.ownKeys(target);

      for (const key of actualKeys) {
        if (key === favBook || key === '_favColor') {
          continue;
        }
        reportedKeys.push(key);
      }

      return reportedKeys;
    }
  };

  proxy = new Proxy(obj, handler);
}

console.log(Object.keys(proxy)); // [ 'name', 'age' ]
console.log(Reflect.ownKeys(proxy)); // [ 'name', 'age', Symbol(visible) ]
console.log(Object.getOwnPropertyNames(proxy)); // [ 'name', 'age' ]
console.log(Object.getOwnPropertySymbols(proxy)); // [Symbol(visible)]
console.log(proxy._favColor); // 'blue'
複製程式碼

使用 _favColor 字串很簡單:只需讀取庫的原始碼即可。此外,動態鍵可以(例如之前講的 uuid 示例)可以通過暴力找到。但是,如果不是直接引用 symbol,任何人都無法從 proxy 物件中訪問到值 metro 2033

Node.js 宣告:Node.js 中的一個特性破壞了 proxy 的隱私性。此功能不存在於 JavaScript 語言本身,也不適用於其他情況,例如 web 瀏覽器。這一特性允許在給定 proxy 時獲得對底層物件的訪問權。以下是一個使用此功能破壞上述私有屬性的示例:

const [originalObject] = process
  .binding('util')
  .getProxyDetails(proxy);

const allKeys = Reflect.ownKeys(originalObject);
console.log(allKeys[3]); // Symbol(fav book)
複製程式碼

我們現在需要修改全域性 Reflect 物件,或是修改 util 程式繫結,以防止它們在特定的 node.js 例項中被使用。但那卻是一個新世界的大門,如果你想了解其中的奧祕,看看我們的其他部落格:Protecting your JavaScript APIs

這篇文章是我和 Thomas Hunter II 一起寫的。我在一家名為 Intricsic 的公司工作(順便說一下,我們正在招聘!),專門編寫用於保護 Node.js 應用程式的軟體。我們目前有一個產品應用 Least Privilege 模型來保護應用程式。我們的產品主動保護 Node.js 應用程式不受攻擊者的攻擊,而且非常容易實現。如果你正在尋找保護 Node.js 應用程式的方法,請在 hello@inherin.com 上聯絡我們。


橫幅照片的作者 Chunlea Ju

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


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

相關文章