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) → 正常 !sym → 正常
数字 Number(sym)TypeError sym*2TypeError
字符串 String(sym) → 正常 ''+symTypeError
  sym.toString() → 正常 `${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) → 正常 !sym → 正常
数字 Number(sym)TypeError sym*2TypeError
字符串 String(sym) → 正常 ''+symTypeError
  sym.toString() → 正常 `${sym}`TypeError
对象 Object(sym) → OK Object.keys(sym) → OK

7.10.4 知名符号

全局对象 Symbol 有几个属性,它们充当所谓的_知名符号_的常量。这些符号允许您通过将它们用作属性键来配置 ES6 如何处理对象。这是所有知名符号的列表。

7.10.5 全局符号注册表

如果您希望符号在所有领域中都相同,则需要通过以下两种方法使用全局符号注册表:

下一篇:8. 模板字面量