7. 符號
目錄
請支持這本書:購買它(PDF、EPUB、MOBI)捐款
(廣告,請不要封鎖。)

7. 符號



7.1 概述

符號是 ECMAScript 6 中的一種新的原始類型。它們是透過工廠函式建立的

const mySymbol = Symbol('mySymbol');

每次呼叫工廠函式時,都會建立一個新的唯一符號。選用的參數是一個描述字串,它會在列印符號時顯示(它沒有其他用途)

> mySymbol
Symbol(mySymbol)

7.1.1 用例 1:唯一的屬性鍵

符號主要用作唯一的屬性鍵,符號絕不會與任何其他屬性鍵 (符號或字串) 衝突。例如,您可以使用儲存在 Symbol.iterator 中的符號作為方法的鍵,讓物件可迭代 (可透過 for-of 迴圈和其他語言機制使用),有關可迭代的詳細資訊,請參閱 迭代章節)

const iterableObject = {
    [Symbol.iterator]() { // (A)
        ···
    }
}
for (const x of iterableObject) {
    console.log(x);
}
// Output:
// hello
// world

在 A 行中,符號用作方法的鍵。這個唯一的標記讓物件可迭代,並讓我們可以使用 for-of 迴圈。

7.1.2 用例 2:表示概念的常數

在 ECMAScript 5 中,您可能使用字串來表示概念,例如顏色。在 ES6 中,您可以使用符號,並確定它們始終是唯一的

const COLOR_RED    = Symbol('Red');
const COLOR_ORANGE = Symbol('Orange');
const COLOR_YELLOW = Symbol('Yellow');
const COLOR_GREEN  = Symbol('Green');
const COLOR_BLUE   = Symbol('Blue');
const COLOR_VIOLET = Symbol('Violet');

function getComplement(color) {
    switch (color) {
        case COLOR_RED:
            return COLOR_GREEN;
        case COLOR_ORANGE:
            return COLOR_BLUE;
        case COLOR_YELLOW:
            return COLOR_VIOLET;
        case COLOR_GREEN:
            return COLOR_RED;
        case COLOR_BLUE:
            return COLOR_ORANGE;
        case COLOR_VIOLET:
            return COLOR_YELLOW;
        default:
            throw new Exception('Unknown color: '+color);
    }
}

每次呼叫 Symbol('Red') 時,都會建立一個新的符號。因此,COLOR_RED 絕不會被誤認為另一個值。如果它是字串 'Red',情況就會不同。

7.1.3 陷阱:您無法將符號強制轉換為字串

將符號強制轉換 (隱式轉換) 為字串會擲回例外

const sym = Symbol('desc');

const str1 = '' + sym; // TypeError
const str2 = `${sym}`; // TypeError

唯一的解決方案是明確轉換

const str2 = String(sym); // 'Symbol(desc)'
const str3 = sym.toString(); // 'Symbol(desc)'

禁止強制轉換可防止一些錯誤,但也讓使用符號變得更複雜。

下列操作知道符號作為屬性鍵

下列操作忽略符號作為屬性鍵

7.2 新的基本型別

ECMAScript 6 引進新的基本型別:符號。它們是作為唯一 ID 的標記。您可以透過工廠函式 Symbol() 建立符號 (如果呼叫為函式,它與傳回字串的 String 類似)

const symbol1 = Symbol();

Symbol() 有個選用的字串值參數,讓您可以為新建立的符號提供說明。在符號轉換為字串時,會使用該說明 (透過 toString()String())

> const symbol2 = Symbol('symbol2');
> String(symbol2)
'Symbol(symbol2)'

Symbol() 傳回的每個符號都是唯一的,每個符號都有自己的身分

> Symbol() === Symbol()
false

如果您將 typeof 算子套用至符號,您可以看到符號是基本型別,它會傳回新的符號特定結果

> typeof Symbol()
'symbol'

7.2.1 符號作為屬性鍵

符號可用作屬性鍵

const MY_KEY = Symbol();
const obj = {};

obj[MY_KEY] = 123;
console.log(obj[MY_KEY]); // 123

類別與物件文字有一個稱為計算屬性鍵的功能:你可以透過表達式指定屬性的鍵,方法是將其放入方括弧中。在以下物件文字中,我們使用計算屬性鍵,讓 MY_KEY 的值成為屬性的鍵。

const MY_KEY = Symbol();
const obj = {
    [MY_KEY]: 123
};

方法定義也可以有計算鍵

const FOO = Symbol();
const obj = {
    [FOO]() {
        return 'bar';
    }
};
console.log(obj[FOO]()); // bar

7.2.2 列舉自有屬性鍵

由於現在有一種新的值可以成為屬性的鍵,因此 ECMAScript 6 使用以下術語

讓我們先建立一個物件,來檢視列舉自有屬性鍵的 API。

const obj = {
    [Symbol('my_key')]: 1,
    enum: 2,
    nonEnum: 3
};
Object.defineProperty(obj,
    'nonEnum', { enumerable: false });

Object.getOwnPropertyNames() 忽略符號值屬性鍵

> Object.getOwnPropertyNames(obj)
['enum', 'nonEnum']

Object.getOwnPropertySymbols() 忽略字串值屬性鍵

> Object.getOwnPropertySymbols(obj)
[Symbol(my_key)]

Reflect.ownKeys() 考慮所有類型的鍵

> Reflect.ownKeys(obj)
[Symbol(my_key), 'enum', 'nonEnum']

Object.keys() 只考慮可列舉的字串屬性鍵

> Object.keys(obj)
['enum']

Object.keys 這個名稱與新的術語衝突(只列出字串鍵)。現在 Object.namesObject.getEnumerableOwnPropertyNames 會是更好的選擇。

7.3 使用符號表示概念

在 ECMAScript 5 中,人們通常透過字串表示概念(例如列舉常數)。例如

var COLOR_RED    = 'Red';
var COLOR_ORANGE = 'Orange';
var COLOR_YELLOW = 'Yellow';
var COLOR_GREEN  = 'Green';
var COLOR_BLUE   = 'Blue';
var COLOR_VIOLET = 'Violet';

然而,字串並不像我們希望的那麼獨特。讓我們看看以下函式,了解原因。

function getComplement(color) {
    switch (color) {
        case COLOR_RED:
            return COLOR_GREEN;
        case COLOR_ORANGE:
            return COLOR_BLUE;
        case COLOR_YELLOW:
            return COLOR_VIOLET;
        case COLOR_GREEN:
            return COLOR_RED;
        case COLOR_BLUE:
            return COLOR_ORANGE;
        case COLOR_VIOLET:
            return COLOR_YELLOW;
        default:
            throw new Exception('Unknown color: '+color);
    }
}

值得注意的是,你可以使用任意表達式作為 switch 案例,你不會受到任何限制。例如

function isThree(x) {
    switch (x) {
        case 1 + 1 + 1:
            return true;
        default:
            return false;
    }
}

我們使用 switch 提供的彈性,並透過我們的常數(COLOR_RED 等)來參照顏色,而不是硬式編碼它們('Red' 等)。

有趣的是,即使我們這樣做,仍然可能發生混淆。例如,有人可能會定義一個情緒常數

var MOOD_BLUE = 'Blue';

現在 COLOR_BLUE 的值不再是唯一的了,而且 MOOD_BLUE 可能會被誤認為是它。如果您將它用作 getComplement() 的參數,它會傳回 'Orange',而它應該會擲出例外。

讓我們使用符號來修正這個範例。現在我們也可以使用 ES6 功能 const,它讓我們宣告實際的常數(您無法變更繫結到常數的值,但值本身可能是可變的)。

const COLOR_RED    = Symbol('Red');
const COLOR_ORANGE = Symbol('Orange');
const COLOR_YELLOW = Symbol('Yellow');
const COLOR_GREEN  = Symbol('Green');
const COLOR_BLUE   = Symbol('Blue');
const COLOR_VIOLET = Symbol('Violet');

Symbol 傳回的每個值都是唯一的,這就是為什麼現在沒有其他值會被誤認為是 BLUE。有趣的是,如果我們使用符號而不是字串,getComplement() 的程式碼完全不會變更,這顯示了它們有多麼相似。

7.4 符號作為屬性的金鑰

能夠建立金鑰絕不會與其他金鑰衝突的屬性,在兩種情況下很有用

7.4.1 符號作為非公開屬性的金鑰

每當 JavaScript 中有繼承層級時(例如透過類別、混入或純粹的原型方法建立),您會有兩種屬性

為了易用性,公開屬性通常會有字串金鑰。但是對於有字串金鑰的私人屬性,意外的名稱衝突可能會變成一個問題。因此,符號是一個好選擇。例如,在以下程式碼中,符號會用於私人屬性 _counter_action

const _counter = Symbol('counter');
const _action = Symbol('action');
class Countdown {
    constructor(counter, action) {
        this[_counter] = counter;
        this[_action] = action;
    }
    dec() {
        let counter = this[_counter];
        if (counter < 1) return;
        counter--;
        this[_counter] = counter;
        if (counter === 0) {
            this[_action]();
        }
    }
}

請注意,符號只能保護您免於名稱衝突,無法保護您免於未經授權的存取,因為您可以透過 Reflect.ownKeys() 找出物件的所有自有屬性金鑰,包括符號。如果您也想要在那裡受到保護,您可以使用「類別的私人資料」一節中列出的其中一種方法。

7.4.2 符號作為元層級屬性的金鑰

具有唯一識別碼的符號使其成為不同層級上存在於「一般」屬性金鑰的公用屬性金鑰的理想選擇,因為元層級金鑰和一般金鑰不得衝突。元層級屬性的範例之一是物件可實作的方法,用以自訂其在函式庫中的處理方式。使用符號金鑰可避免函式庫誤將一般方法當成自訂方法。

ES6 可迭代性即為其中一種自訂方式。如果物件的方法金鑰為 (儲存在) Symbol.iterator 中的符號,則該物件為可迭代的。在以下程式碼中,obj 為可迭代的。

const obj = {
    data: [ 'hello', 'world' ],
    [Symbol.iterator]() {
        ···
    }
};

obj 的可迭代性讓您可以使用 for-of 迴圈和類似的 JavaScript 功能

for (const x of obj) {
    console.log(x);
}

// Output:
// hello
// world

7.4.3 JavaScript 標準函式庫中名稱衝突的範例

如果您認為名稱衝突無關緊要,以下列舉三個名稱衝突導致 JavaScript 標準函式庫演進過程中出現問題的範例

相反地,透過屬性金鑰 Symbol.iterator 為物件新增可迭代性不會造成問題,因為該金鑰不會與任何項目衝突。

7.5 將符號轉換為其他基本型別

下表顯示如果您明確或隱式地將符號轉換為其他基本型別會發生什麼情況

轉換為 明確轉換 強制轉換(隱式轉換)
布林值 Boolean(sym) → OK !sym → OK
數字 Number(sym)TypeError sym*2TypeError
字串 String(sym) → OK ''+symTypeError
  sym.toString() → OK `${sym}`TypeError

7.5.1 陷阱:強制轉換為字串

禁止強制轉換為字串很容易讓你絆倒

const sym = Symbol();

console.log('A symbol: '+sym); // TypeError
console.log(`A symbol: ${sym}`); // TypeError

要修復這些問題,你需要明確轉換為字串

console.log('A symbol: '+String(sym)); // OK
console.log(`A symbol: ${String(sym)}`); // OK

7.5.2 了解強制轉換規則

符號通常禁止強制轉換(隱式轉換)。本節說明原因。

7.5.2.1 允許真值檢查

強制轉換為布林值總是允許的,主要是為了啟用 if 陳述式和其他位置的真值檢查

if (value) { ··· }

param = param || 0;
7.5.2.2 意外將符號轉換為屬性金鑰

符號是特殊的屬性金鑰,這就是為什麼你想要避免意外將它們轉換為字串的原因,而字串是不同類型的屬性金鑰。如果你使用加法運算子來計算屬性的名稱,可能會發生這種情況

myObject['__' + value]

這就是為什麼如果 value 是符號,就會擲出 TypeError

7.5.2.3 意外將符號轉換為陣列索引

你也不想意外地將符號轉換為陣列索引。以下是可能發生這種情況的程式碼,如果 value 是符號

myArray[1 + value]

這就是為什麼加法運算子在此情況下會擲出錯誤。

7.5.3 規格中的明確和隱式轉換

7.5.3.1 轉換為布林值

要明確將符號轉換為布林值,你可以呼叫 Boolean(),它會為符號傳回 true

> const sym = Symbol('hello');
> Boolean(sym)
true

Boolean() 透過內部運算 ToBoolean() 計算其結果,它會為符號和其他真值傳回 true

強制轉換也使用 ToBoolean()

> !sym
false
7.5.3.2 轉換為數字

若要明確將符號轉換為數字,請呼叫 Number()

> const sym = Symbol('hello');
> Number(sym)
TypeError: can't convert symbol to number

Number() 透過內部運算 ToNumber() 計算其結果,而此運算會對符號擲回 TypeError

強制轉換也會使用 ToNumber()

> +sym
TypeError: can't convert symbol to number
7.5.3.3 轉換為字串

若要明確將符號轉換為字串,請呼叫 String()

> const sym = Symbol('hello');
> String(sym)
'Symbol(hello)'

如果 String() 的參數是符號,則它會自行處理轉換為字串,並傳回以建立符號時提供的描述為封裝的字串 Symbol()。如果未提供描述,則會使用空字串

> String(Symbol())
'Symbol()'

toString() 方法傳回的字串與 String() 相同,但這兩個運算都不會呼叫對方,它們都呼叫相同的內部運算 SymbolDescriptiveString()

> Symbol('hello').toString()
'Symbol(hello)'

強制轉換透過內部運算 ToString() 處理,而此運算會對符號擲回 TypeError。一種會強制轉換其參數為字串的方法是 Number.parseInt()

> Number.parseInt(Symbol())
TypeError: can't convert symbol to string
7.5.3.4 不允許:透過二元加法運算子 (+) 轉換

加法運算子 的運作方式如下

強制轉換為字串或數字會擲回例外,這表示您無法 (直接) 對符號使用加法運算子

> '' + Symbol()
TypeError: can't convert symbol to string
> 1 + Symbol()
TypeError: can't convert symbol to number

7.6 符號的包裝物件

雖然所有其他基本型別值都有字面值,但您需要透過函式呼叫 Symbol 來建立符號。因此,有意外呼叫 Symbol 作為建構函式的風險。這會產生 Symbol 的執行個體,而它們並非十分有用。因此,當您嘗試這麼做時,會擲回例外

> new Symbol()
TypeError: Symbol is not a constructor

仍然有辦法建立包裝物件,也就是 Symbol 的執行個體:Object,作為函式呼叫,會將所有值轉換為物件,包括符號。

> const sym = Symbol();
> typeof sym
'symbol'

> const wrapper = Object(sym);
> typeof wrapper
'object'
> wrapper instanceof Symbol
true

7.6.1 透過 [ ] 和包裝鍵存取屬性

方括號運算子 [ ] 通常會強制轉換其運算元為字串。現在有兩個例外:符號包裝物件會解開包裝,而符號則會原樣使用。讓我們使用下列物件來檢視此現象。

const sym = Symbol('yes');
const obj = {
    [sym]: 'a',
    str: 'b',
};

方括號運算子會解開包裝符號的包裝

> const wrappedSymbol = Object(sym);
> typeof wrappedSymbol
'object'
> obj[wrappedSymbol]
'a'

與任何其他與符號無關的值一樣,包裝字串會透過方括號運算子轉換為字串

> const wrappedString = new String('str');
> typeof wrappedString
'object'
> obj[wrappedString]
'b'
7.6.1.1 規格中的屬性存取

用於取得和設定屬性的運算子使用內部運算 ToPropertyKey(),其運作方式如下

7.7 符號跨越領域

程式碼領域(簡稱:領域)是程式碼片段存在的內容。它包含全域變數、載入的模組等。儘管程式碼存在於「單一」領域「內部」,它仍可能存取其他領域中的程式碼。例如,瀏覽器中的每個框架都有自己的領域。而且執行可以從一個框架跳到另一個框架,如下列 HTML 所示。

<head>
    <script>
        function test(arr) {
            var iframe = frames[0];
            // This code and the iframe’s code exist in
            // different realms. Therefore, global variables
            // such as Array are different:
            console.log(Array === iframe.Array); // false
            console.log(arr instanceof Array); // false
            console.log(arr instanceof iframe.Array); // true

            // But: symbols are the same
            console.log(Symbol.iterator ===
                        iframe.Symbol.iterator); // true
        }
    </script>
</head>
<body>
    <iframe srcdoc="<script>window.parent.test([])</script>">
</iframe>
</body>

問題在於每個領域都有自己的全域變數,其中每個變數 Array 指向不同的物件,儘管它們本質上都是同一個物件。類似地,函式庫和使用者程式碼會在每個領域中載入一次,而且每個領域都有相同物件的不同版本。

物件會依識別碼進行比較,但布林值、數字和字串會依值進行比較。因此,不論數字 123 來自哪個領域,它都與其他所有 123 無法區分。這類似於數字文字 123 始終產生相同的值。

符號有單獨的識別碼,因此無法像其他基元值一樣順利跨越領域。這對應當跨越領域運作的符號(例如 Symbol.iterator)來說是個問題:如果物件在一個領域中可迭代,則它應在所有領域中都可迭代。所有內建符號都由 JavaScript 引擎管理,這會確保例如 Symbol.iterator 在每個領域中都是相同的值。如果函式庫想要提供跨領域符號,則它必須依賴額外的支援,而這種支援以全域符號註冊的形式提供:此註冊對所有領域都是全域的,而且會將字串對應到符號。對於每個符號,函式庫需要想出一個儘可能唯一的字串。它不會使用 Symbol() 來建立符號,而是詢問註冊表將字串對應到的符號。如果註冊表已經有該字串的項目,則會傳回關聯的符號。否則,會先建立項目和符號。

您透過 Symbol.for() 向註冊表索取符號,並透過 Symbol.keyFor() 擷取與符號關聯的字串(其金鑰

> const sym = Symbol.for('Hello everybody!');
> Symbol.keyFor(sym)
'Hello everybody!'

由 JavaScript 引擎提供的跨領域符號(例如 Symbol.iterator)不在註冊表中

> Symbol.keyFor(Symbol.iterator)
undefined

7.8 常見問題:符號

7.8.1 我可以使用符號定義私有屬性嗎?

符號的原始計畫是支援私有屬性(會有公開和私有符號)。但此功能已取消,因為使用「get」和「set」(兩個元物件協定操作)來管理私有資料與代理程式互動不佳

這兩個目標互相衝突。類別章節說明 管理私有資料的選項。符號是其中一種選項,但您無法獲得與私有符號相同程度的安全性,因為可以透過 Object.getOwnPropertySymbols()Reflect.ownKeys() 來判斷用作物件屬性金鑰的符號。

7.8.2 符號是基本型別還是物件?

在某些方面,符號類似於基本型別值,在其他方面,它們類似於物件

那麼符號是什麼 - 基本型別值還是物件?最後,它們變成了基本型別值,原因有兩個。

首先,符號更類似於字串,而不是物件:它們是語言的基本值,不可變,並且可用作屬性金鑰。符號具有唯一識別碼並不一定與它們類似於字串相矛盾:UUID 演算法會產生近乎唯一的字串。

其次,符號最常被用作屬性金鑰,因此針對此用例最佳化 JavaScript 規格和實作很有意義。然後符號不需要物件的許多功能

符號不具備這些功能,讓規格和實作變得更簡單。V8 團隊也表示,在處理屬性鍵時,將原始類型設為特殊情況比某些物件更容易。

7.8.3 我們真的需要符號嗎?字串不夠用嗎?

與字串不同,符號是唯一的,可避免名稱衝突。這對於顏色等代幣來說很方便,但對於支援元層級方法(例如其鍵為 Symbol.iterator 的方法)來說至關重要。Python 使用特殊名稱 __iter__ 來避免衝突。你可以保留雙底線名稱給程式語言機制使用,但程式庫該怎麼辦?有了符號,我們就有了適用於所有人的擴充機制。如你稍後在公開符號部分所見,JavaScript 本身已經廣泛使用此機制。

在無衝突屬性鍵方面,有一個假設的符號替代方案:使用命名慣例。例如,帶有 URL 的字串(例如 'http://example.com/iterator')。但這會引入第二類屬性鍵(相對於通常是有效識別碼且不包含冒號、斜線、點等符號的「一般」屬性名稱),這基本上就是符號的本質。那麼我們不如直接引入一種新的值。

7.8.4 JavaScript 的符號與 Ruby 的符號相同嗎?

不,它們不同。

Ruby 的符號基本上是建立值的文字。兩次提到同一個符號會產生同一個值兩次

:foo == :foo

JavaScript 函式 Symbol() 是符號的工廠,它傳回的每個值都是唯一的

Symbol('foo') !== Symbol('foo')

7.9 已知符號的拼寫:為什麼是 Symbol.iterator 而不是 Symbol.ITERATOR(等)?

已知符號儲存在名稱以小寫字元開頭且使用駝峰式大小寫的屬性中。在某種程度上,這些屬性是常數,而常數通常會使用全大寫名稱(例如 Math.PI)。但它們的拼寫原因不同:已知符號用於取代一般屬性鍵,這就是為什麼它們的「名稱」遵循屬性鍵規則,而不是常數規則。

7.10 符號 API

本節概述 ECMAScript 6 的符號 API。

7.10.1 函數 Symbol

Symbol(description?) : symbol
建立一個新的符號。選用的參數 description 允許您為符號提供描述。存取描述的唯一方法是將符號轉換為字串(透過 toString()String())。此類轉換的結果為 'Symbol('+description+')'

> const sym = Symbol('hello');
> String(sym)
'Symbol(hello)'

Symbol 無法用作建構函數 – 如果您透過 new 呼叫它,將擲回例外。

7.10.2 符號的方法

符號唯一有用的方法是 toString()(透過 Symbol.prototype.toString())。

7.10.3 將符號轉換為其他值

轉換為 明確轉換 強制轉換(隱式轉換)
布林值 Boolean(sym) → OK !sym → OK
數字 Number(sym)TypeError sym*2TypeError
字串 String(sym) → OK ''+symTypeError
  sym.toString() → OK `${sym}`TypeError
物件 Object(sym) → OK Object.keys(sym) → OK

7.10.4 已知符號

全域物件 Symbol 有幾個屬性,用作所謂已知符號的常數。這些符號讓您可以使用它們作為屬性金鑰,來設定 ES6 如何處理物件。以下是 所有已知符號 的清單

7.10.5 全域符號註冊表

如果您希望符號在所有領域都相同,則需要透過以下兩種方法使用全域符號註冊表

下一篇:8. 範本字串