Symbol 的作用

xiaoweiy發表於2019-04-05

Symbols 的出現是為了什麼呢?

  • 翻譯自 medium

  • Symbols 是 JavaScript 最新推出的一種基本型別,它被當做物件屬性時特別有用,但是有什麼是它能做而 String 不能做的呢?

  • 在我們開始探索 Symbols 功能之前,我們先來看一下被很多開發者忽略 JavaScript 的特性。

背景:

  • JavaScript 有兩種值型別,一種是 基本型別 (primitives),一種是 物件型別 (objects,包含 function 型別),基本型別包括數字 number (包含 integer,float,Infinity,NaN),布林值 boolean,字串 string,undefined,null,儘管 typeof null === 'object',null 仍然是一個基本型別。

  • 基本型別的值是不可變的,當然了,存放基本型別值得變數是可以被重新分配的,例如當你寫 let x = 1; x++,變數 x 就被重新分配值了,但是你並沒有改變原來的1.

  • 一些語言,例如 c 語言有引用傳遞和值傳遞的概念,JavaScript 也有類似的概念,儘管它傳遞的資料型別需要推斷。當你給一個 function 傳值的時候,重新分配值並不會修改該方法呼叫時的引數值。然而,假如你修改一個非基本型別的值,修改值也會影響原來的值。

  • 考慮下下面的例子:

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
// Though, their .name properties ARE primitives:
console.log(obj1.name === obj2.name); // true
複製程式碼
  • 物件扮演了一個 JavaScript 語言的基本角色,它們被到處使用,它們常被用在鍵值對的儲存。然而這樣使用有一個很大的限制:在 symbols 誕生之前,物件的鍵只能是字串。假如我們試著使用一個非字串當做物件的鍵,就會被轉換為字串,如下所示:
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 資料結構被建立的目的就是為了應對儲存鍵值對中,鍵不是字串的情況。

symbols 是什麼?

  • 現在我們知道了什麼是基本型別,終於準備好如何定義什麼是 symbols 了。symbols 是一種無法被重建的基本型別。這時 symbols 有點類似與物件建立的例項互相不相等的情況,但同時 symbols 又是一種無法被改變的基本型別資料。這裡有一個例子:
const s1 = Symbol();
const s2 = Symbol();
console.log(s1 === s2); // false
複製程式碼
  • 當你初始化一個帶有一個接收可選字串引數的 symbols 時,我們可以來 debug 看下,除此之外看看它會否影響自身。
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)
複製程式碼

symbols 作為物件的屬性

  • symbols 有另一個很重要的用途,就是用作物件的 key。這兒有一個 symbols 作為物件 key 使用的例子:
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']
複製程式碼
  • 我們注意到使用 Object.keys() 並沒有返回 symbols,這是為了向後相容性的考慮。老程式碼不相容 symbols,因此古老的 Object.keys() 不應該返回 symbols。

  • 看第一眼,我們可能會覺得 symbols 這個特性很適合作為物件的私有屬性,許多其他語言都要類似的類的隱藏屬性,這一直被認為是 JavaScript 的一大短板。不幸的是,還是有可能通過 symbols 來取到物件的值,甚至都不用試著獲取物件屬性就可以得到物件 key,例如,通過 Reflect.ownKeys() 方法就可以獲取所有的 key,包括 字串和 symbols,如下所示:

function tryToAddPrivate(o) {
  o[Symbol('Pseudo Private')] = 42;
}
const obj = { prop: 'hello' };
tryToAddPrivate(obj);
console.log(Reflect.ownKeys(obj));
        // [ 'prop', Symbol(Pseudo Private) ]
console.log(obj[Reflect.ownKeys(obj)[1]]); // 42
複製程式碼

注意:現在已經有一個旨在解決 JavaScript 私有屬性的提案,叫做 Private Fields,儘管這並不會使所有的物件受益,它仍然對物件的例項有用,Private Fields 在 Chrome 74版本可用。

阻止物件屬性名衝突

  • symbols 可能對物件的私有屬性沒有直接好處,但是它有另外一個用途,它在不知道物件原有屬性名的情況下,擴充套件物件屬性很有用。
  • 考慮一下當兩個不同的庫要讀取物件的一些原始屬性時,或許它們都想要類似的識別符號。如果只是簡單的使用字串 id 作為 key,這將會有很大的風險,因為它們的 key 完全有可能相同。
function lib1tag(obj) {
  obj.id = 42;
}
function lib2tag(obj) {
  obj.id = 369;
}
複製程式碼
  • 通過使用 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(); // random approach
function lib1tag(obj) {
  obj[library1property] = 42;
}
const library2property = 'LIB2-NAMESPACE-id'; // namespaced approach
function lib2tag(obj) {
  obj[library2property] = 369;
}
複製程式碼
  • 你是對的,這種方法確實類似於 symbols 的這一作用,除非兩個庫使用相同的屬性名,那就會有被覆寫的風險。

  • 機敏的讀者已經發現這兩種方案的效果並不完全相同。我們獨有的屬性名仍然有一個缺點:它們的 key 很容易被找到,尤其是當程式碼進行遞迴或者系列化物件,考慮如下的例子:

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}'
複製程式碼
  • 假如我們使用 symbols 作為屬性名,json 的輸出將不會包含 symbols,這是為什麼呢?因為 JavaScript 支援 symbols,並不意味著 json 規範也會跟著修改。json 只允許字串作為 key,JavaScript 並沒有試圖讓 json 輸出 symbols。

  • 我們可以簡單的通過 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
複製程式碼
  • 類似於 symbols,物件通過設定 enumerable 識別符號來隱藏字串 key,它們都會被 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() 來獲取,因此他們並不算私有屬性。假設我們使用名稱空間、隨機字串等字串作為物件的屬性名,我們就可以避免多個庫重名的風險。

  • 但是仍然有一點細微的不同,字串是不可變的,而 symbols 可以保證永遠唯一,因此仍然有可能會有人生成重名的字串。從數學意義上 symbols 提供了一個字串沒有的優點。

  • 在 Node.js 裡面,當檢測一個物件(例如使用 console.log()),假如物件上的一個方法叫做 inspect,當記錄物件時,該方法會被呼叫並輸出。你可以想象,這種行為並不是每個人都會這樣做,被使用者建立的 inspect 方法經常會導致命名衝突,現在 require('util').inspect.custom 提供的 symbol 可以被用在函式上。inspect 方法在 Node.js v10 被放棄,在 v11 版直接被忽略。現在沒人可以忽然就改變 inspect 方法的行為了。

模擬私有屬性

  • 這裡有一個在物件上模擬私有屬性的有趣的嘗試。使用了另一個 JavaScript 的新特性:proxy。proxy 會包住一個物件,然後我們就可以跟這個物件進行各種各樣的互動。

  • proxy 提供了很多種攔截物件行為的方式。這裡我們感興趣的是讀取物件屬性的行為。我並不會完整的解釋 proxy 是如何工作的,所以如果你想要了解的更多,可以檢視我們的另一篇文章:JavaScript Object Property Descriptors, Proxies, and Preventing Extension

  • 我們可以使用代理來展示物件上可用的屬性。這裡我們先建立一個 proxy 來隱藏兩個屬性,一個是字串 _favColor,另一個是 symbol 叫 favBook。

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 屬性很簡單,只需要閱讀原始碼即可,另外,動態的 key 可以通過暴力破解方式獲得(例如前面的 uuid 例子)。但是對 symbol 屬性,如果你沒有直接的引用,是無法訪問到 Metro 2033 這個值的。

  • Node.js 備註:有一個特性可以破解私有屬性,這個特性不是 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

相關文章