- 原文地址:JavaScript Symbols: But Why?
- 原文作者:Thomas Hunter II
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:xionglong58
- 校對者:EdmondWang, Xuyuey
作為最新的基本型別,Symbol 為 JavaScript 語言帶來了很多好處,特別是當其用在物件屬性上時。但是,相比較於 String 型別,Symbol 有哪些 String 沒有的功能呢?
在深入探討 Symbol 之前,讓我們先看看一些許多開發人員可能都不知道的 JavaScript 特性。
背景
JavaScript 中有兩種資料型別:基本資料型別和物件(物件也包括函式),基本資料型別包括簡單資料型別,比如 number(從整數到浮點數,從 Infinity 到 NaN 都屬於 Number 型別)、boolean、string、undefined
、null
(注意儘管 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 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。