【JavaScript 从入门到精通】symbol类型
@[toc]
symbol 类型
根据规范,只有两种原始类型可以用作对象属性键:
- 字符串类型
- symbol 类型
否则,如果使用另一种类型,例如数字,它会被自动转换为字符串。所以 obj[1]
与 obj["1"]
相同,而 obj[true]
与 obj["true"]
相同。
到目前为止,我们一直只使用字符串。
现在我们来看看 symbol 能给我们带来什么。
symbol
“symbol” 值表示唯一的标识符。
可以使用 Symbol()
来创建这种类型的值:
let id = Symbol();
创建时,我们可以给 symbol 一个描述(也称为 symbol 名),这在代码调试时非常有用:
// id 是描述为 "id" 的 symbol
let id = Symbol("id");
symbol 保证是唯一的。即使我们创建了许多具有相同描述的 symbol,它们的值也是不同。描述只是一个标签,不影响任何东西。
例如,这里有两个描述相同的 symbol —— 它们不相等:
let id1 = Symbol("id");
let id2 = Symbol("id");
alert(id1 == id2); // false
如果你熟悉 Ruby 或者其他有 “symbol” 的语言 —— 别被误导。JavaScript 的 symbol 是不同的。
所以,总而言之,symbol 是带有可选描述的“原始唯一值”。让我们看看我们可以在哪里使用它们。
⚠️symbol 不会被自动转换为字符串
JavaScript 中的大多数值都支持字符串的隐式转换。例如,我们可以
alert
任何值,都可以生效。symbol 比较特殊,它不会被自动转换。例如,这个
alert
将会提示出错:let id = Symbol("id"); alert(id); // 类型错误:无法将 symbol 值转换为字符串。
这是一种防止混乱的“语言保护”,因为字符串和 symbol 有本质上的不同,不应该意外地将它们转换成另一个。
如果我们真的想显示一个 symbol,我们需要在它上面调用
.toString()
,如下所示:let id = Symbol("id"); alert(id.toString()); // Symbol(id),现在它有效了
或者获取
symbol.description
属性,只显示描述(description):let id = Symbol("id"); alert(id.description); // id
“隐藏”属性
symbol 允许我们创建对象的“隐藏”属性,代码的任何其他部分都不能意外访问或重写这些属性。
例如,如果我们使用的是属于第三方代码的 user
对象,我们想要给它们添加一些标识符。
我们可以给它们使用 symbol 键:
let user = { // 属于另一个代码
name: "John"
};
let id = Symbol("id");
user[id] = 1;
alert( user[id] ); // 我们可以使用 symbol 作为键来访问数据
使用 Symbol("id")
作为键,比起用字符串 "id"
来有什么好处呢?
因为 user
对象属于其他的代码,那些代码也会使用这个对象,所以我们不应该在它上面直接添加任何字段,这样很不安全。但是你添加的 symbol 属性不会被意外访问到,第三方代码根本不会看到它,所以使用 symbol 基本上不会有问题。
另外,假设另一个脚本希望在 user
中有自己的标识符,以实现自己的目的。这可能是另一个 JavaScript 库,因此脚本之间完全不了解彼此。
然后该脚本可以创建自己的 Symbol("id")
,像这样:
// ...
let id = Symbol("id");
user[id] = "Their id value";
我们的标识符和它们的标识符之间不会有冲突,因为 symbol 总是不同的,即使它们有相同的名字。
……但如果我们处于同样的目的,使用字符串 "id"
而不是用 symbol,那么 就会 出现冲突:
let user = { name: "John" };
// 我们的脚本使用了 "id" 属性。
user.id = "Our id value";
// ……另一个脚本也想将 "id" 用于它的目的……
user.id = "Their id value"
// 砰!无意中被另一个脚本重写了 id!
对象字面量中的 symbol
如果我们要在对象字面量 {...}
中使用 symbol,则需要使用方括号把它括起来。
就像这样:
let id = Symbol("id");
let user = {
name: "John",
[id]: 123 // 而不是 "id":123
};
这是因为我们需要变量 id
的值作为键,而不是字符串 “id”。
symbol 在 for…in 中会被跳过
symbol 属性不参与 for..in
循环。
例如:
let id = Symbol("id");
let user = {
name: "John",
age: 30,
[id]: 123
};
for (let key in user) alert(key); // name, age(没有 symbol)
// 使用 symbol 任务直接访问
alert( "Direct: " + user[id] );
Object.keys(user) 也会忽略它们。这是一般“隐藏符号属性”原则的一部分。如果另一个脚本或库遍历我们的对象,它不会意外地访问到符号属性。
相反,Object.assign 会同时复制字符串和 symbol 属性:
let id = Symbol("id");
let user = {
[id]: 123
};
let clone = Object.assign({}, user);
alert( clone[id] ); // 123
这里并不矛盾,就是这样设计的。这里的想法是当我们克隆或者合并一个 object 时,通常希望 所有 属性被复制(包括像 id
这样的 symbol)。
全局 symbol
正如我们所看到的,通常所有的 symbol 都是不同的,即使它们有相同的名字。但有时我们想要名字相同的 symbol 具有相同的实体。例如,应用程序的不同部分想要访问的 symbol "id"
指的是完全相同的属性。
为了实现这一点,这里有一个 全局 symbol 注册表。我们可以在其中创建 symbol 并在稍后访问它们,它可以确保每次访问相同名字的 symbol 时,返回的都是相同的 symbol。
要从注册表中读取(不存在则创建)symbol,请使用 Symbol.for(key)
。
该调用会检查全局注册表,如果有一个描述为 key
的 symbol,则返回该 symbol,否则将创建一个新 symbol(Symbol(key)
),并通过给定的 key
将其存储在注册表中。
例如:
// 从全局注册表中读取
let id = Symbol.for("id"); // 如果该 symbol 不存在,则创建它
// 再次读取(可能是在代码中的另一个位置)
let idAgain = Symbol.for("id");
// 相同的 symbol
alert( id === idAgain ); // true
注册表内的 symbol 被称为 全局 symbol。如果我们想要一个应用程序范围内的 symbol,可以在代码中随处访问 —— 这就是它们的用途。
ℹ️这听起来像 Ruby
在一些编程语言中,例如 Ruby,每个名字都有一个 symbol。
正如我们所看到的,在 JavaScript 中,全局 symbol 也是这样的。
Symbol.keyFor
对于全局 symbol,不仅有 Symbol.for(key)
按名字返回一个 symbol,还有一个反向调用:Symbol.keyFor(sym)
,它的作用完全反过来:通过全局 symbol 返回一个名字。
例如:
// 通过 name 获取 symbol
let sym = Symbol.for("name");
let sym2 = Symbol.for("id");
// 通过 symbol 获取 name
alert( Symbol.keyFor(sym) ); // name
alert( Symbol.keyFor(sym2) ); // id
Symbol.keyFor
内部使用全局 symbol 注册表来查找 symbol 的键。所以它不适用于非全局 symbol。如果 symbol 不是全局的,它将无法找到它并返回 undefined
。
也就是说,任何 symbol 都具有 description
属性。
例如:
let globalSymbol = Symbol.for("name");
let localSymbol = Symbol("name");
alert( Symbol.keyFor(globalSymbol) ); // name,全局 symbol
alert( Symbol.keyFor(localSymbol) ); // undefined,非全局
alert( localSymbol.description ); // name
系统 symbol
JavaScript 内部有很多“系统” symbol,我们可以使用它们来微调对象的各个方面。
它们都被列在了 众所周知的 symbol 表的规范中:
Symbol.hasInstance
Symbol.isConcatSpreadable
Symbol.iterator
Symbol.toPrimitive
- ……等等。
例如,Symbol.toPrimitive
允许我们将对象描述为原始值转换。我们很快就会看到它的使用。
当我们研究相应的语言特征时,我们对其他的 symbol 也会慢慢熟悉起来。
总结
symbol
是唯一标识符的基本类型
symbol 是使用带有可选描述(name)的 Symbol()
调用创建的。
symbol 总是不同的值,即使它们有相同的名字。如果我们希望同名的 symbol 相等,那么我们应该使用全局注册表:Symbol.for(key)
返回(如果需要的话则创建)一个以 key
作为名字的全局 symbol。使用 Symbol.for
多次调用 key
相同的 symbol 时,返回的就是同一个 symbol。
symbol 有两个主要的使用场景:
-
“隐藏” 对象属性。
如果我们想要向“属于”另一个脚本或者库的对象添加一个属性,我们可以创建一个 symbol 并使用它作为属性的键。symbol 属性不会出现在
for..in
中,因此它不会意外地被与其他属性一起处理。并且,它不会被直接访问,因为另一个脚本没有我们的 symbol。因此,该属性将受到保护,防止被意外使用或重写。因此我们可以使用 symbol 属性“秘密地”将一些东西隐藏到我们需要的对象中,但其他地方看不到它。
-
JavaScript 使用了许多系统 symbol,这些 symbol 可以作为
Symbol.*
访问。我们可以使用它们来改变一些内建行为。例如,在本教程的后面部分,我们将使用Symbol.iterator
来进行 [迭代](迭代(科学概念)_百度百科 (baidu.com)) 操作,使用Symbol.toPrimitive
来设置 对象原始值的转换等等。
从技术上说,symbol 不是 100% 隐藏的。有一个内建方法 Object.getOwnPropertySymbols(obj) 允许我们获取所有的 symbol。还有一个名为 Reflect.ownKeys(obj) 的方法可以返回一个对象的 所有 键,包括 symbol。所以它们并不是真正的隐藏。但是大多数库、内建方法和语法结构都没有使用这些方法。
对象 — 原始值转换
当对象相加 obj1 + obj2
,相减 obj1 - obj2
,或者使用 alert(obj)
打印时会发生什么?
JavaScript 不允许自定义运算符对对象的处理方式。与其他一些编程语言(Ruby,C++)不同,我们无法实现特殊的对象处理方法来处理加法(或其他运算)。
在此类运算的情况下,对象会被自动转换为原始值,然后对这些原始值进行运算,并得到运算结果(也是一个原始值)。
这是一个重要的限制:因为 obj1 + obj2
(或者其他数学运算)的结果不能是另一个对象!
例如,我们无法使用对象来表示向量或矩阵(或成就或其他),把它们相加并期望得到一个“总和”向量作为结果。这样的想法是行不通的。
因此,由于我们从技术上无法实现此类运算,所以在实际项目中不存在对对象的数学运算。如果你发现有,除了极少数例外,通常是写错了。
本文将介绍对象是如何转换为原始值的,以及如何对其进行自定义。
我们有两个目的:
- 让我们在遇到类似的对对象进行数学运算的编程错误时,能够更加理解到底发生了什么。
- 也有例外,这些操作也可以是可行的。例如日期相减或比较(
Date
对象)。我们稍后会遇到它们。
转换规则
在 【类型转换】。现在我们已经掌握了方法(method)和 symbol 的相关知识,可以开始学习对象原始值转换了。
- 没有转换为布尔值。所有的对象在布尔上下文(context)中均为
true
,就这么简单。只有字符串和数字转换。 - 数字转换发生在对象相减或应用数学函数时。例如,
Date
对象(将在 【日期和时间】 一章中介绍)可以相减,date1 - date2
的结果是两个日期之间的差值。 - 至于字符串转换 —— 通常发生在我们像
alert(obj)
这样输出一个对象和类似的上下文中。
我们可以使用特殊的对象方法,自己实现字符串和数字的转换。
现在让我们一起探究技术细节,因为这是深入讨论该主题的唯一方式。
hint
JavaScript 是如何决定应用哪种转换的?
类型转换在各种情况下有三种变体。它们被称为 “hint”,在 规范 所述:
-
"string"
对象到字符串的转换,当我们对期望一个字符串的对象执行操作时,如 “alert”:
// 输出 alert(obj); // 将对象作为属性键 anotherObj[obj] = 123;
-
"number"
对象到数字的转换,例如当我们进行数学运算时:
// 显式转换 let num = Number(obj); // 数学运算(除了二元加法) let n = +obj; // 一元加法 let delta = date1 - date2; // 小于/大于的比较 let greater = user1 > user2;
大多数内建的数学函数也包括这种转换。 -
"default"
在少数情况下发生,当运算符“不确定”期望值的类型时。例如,二元加法
+
可用于字符串(连接),也可以用于数字(相加)。因此,当二元加法得到对象类型的参数时,它将依据"default"
hint 来对其进行转换。此外,如果对象被用于与字符串、数字或 symbol 进行==
比较,这时到底应该进行哪种转换也不是很明确,因此使用"default"
hint。// 二元加法使用默认 hint let total = obj1 + obj2; // obj == number 使用默认 hint if (user == 1) { ... };
像<
和>
这样的小于/大于比较运算符,也可以同时用于字符串和数字。不过,它们使用 “number” hint,而不是 “default”。这是历史原因。
上面这些规则看起来比较复杂,但在实践中其实挺简单的。
除了一种情况(Date
对象,我们稍后会讲到)之外,所有内建对象都以和 "number"
相同的方式实现 "default"
转换。我们也可以这样做。
尽管如此,了解上述的 3 个 hint 还是很重要的,很快你就会明白为什么这样说。
为了进行转换,JavaScript 尝试查找并调用三个对象方法:
- 调用
obj[Symbol.toPrimitive](hint)
—— 带有 symbol 键Symbol.toPrimitive
(系统 symbol)的方法,如果这个方法存在的话, - 否则,如果 hint 是
"string"
—— 尝试调用obj.toString()
或obj.valueOf()
,无论哪个存在。 - 否则,如果 hint 是
"number"
或"default"
—— 尝试调用obj.valueOf()
或obj.toString()
,无论哪个存在。
Symbol.toPrimitive
我们从第一个方法开始。有一个名为 Symbol.toPrimitive
的内建 symbol,它被用来给转换方法命名,像这样:
obj[Symbol.toPrimitive] = function(hint) {
// 这里是将此对象转换为原始值的代码
// 它必须返回一个原始值
// hint = "string"、"number" 或 "default" 中的一个
}
如果 Symbol.toPrimitive
方法存在,则它会被用于所有 hint,无需更多其他方法。
例如,这里 user
对象实现了它:
let user = {
name: "John",
money: 1000,
[Symbol.toPrimitive](hint) {
alert(`hint: ${hint}`);
return hint == "string" ? `{name: "${this.name}"}` : this.money;
}
};
// 转换演示:
alert(user); // hint: string -> {name: "John"}
alert(+user); // hint: number -> 1000
alert(user + 500); // hint: default -> 1500
从代码中我们可以看到,根据转换的不同,user
变成一个自描述字符串或者一个金额。user[Symbol.toPrimitive]
方法处理了所有的转换情况。
toString/valueOf
如果没有 Symbol.toPrimitive
,那么 JavaScript 将尝试寻找 toString
和 valueOf
方法:
- 对于
"string"
hint:调用toString
方法,如果它不存在,则调用valueOf
方法(因此,对于字符串转换,优先调用toString
)。 - 对于其他 hint:调用
valueOf
方法,如果它不存在,则调用toString
方法(因此,对于数学运算,优先调用valueOf
方法)。
toString
和 valueOf
方法很早己有了。它们不是 symbol(那时候还没有 symbol 这个概念),而是“常规的”字符串命名的方法。它们提供了一种可选的“老派”的实现转换的方法。
这些方法必须返回一个原始值。如果 toString
或 valueOf
返回了一个对象,那么返回值会被忽略(和这里没有方法的时候相同)。
默认情况下,普通对象具有 toString
和 valueOf
方法:
toString
方法返回一个字符串"[object Object]"
。valueOf
方法返回对象自身。
下面是一个示例:
let user = {name: "John"};
alert(user); // [object Object]
alert(user.valueOf() === user); // true
所以,如果我们尝试将一个对象当做字符串来使用,例如在 alert
中,那么在默认情况下我们会看到 [object Object]
。
这里提到默认值 valueOf
只是为了完整起见,以避免混淆。正如你看到的,它返回对象本身,因此被忽略。别问我为什么,那是历史原因。所以我们可以假设它根本就不存在。
让我们实现一下这些方法来自定义转换。
例如,这里的 user
执行和前面提到的那个 user
一样的操作,使用 toString
和 valueOf
的组合(而不是 Symbol.toPrimitive
):
let user = {
name: "John",
money: 1000,
// 对于 hint="string"
toString() {
return `{name: "${this.name}"}`;
},
// 对于 hint="number" 或 "default"
valueOf() {
return this.money;
}
};
alert(user); // toString -> {name: "John"}
alert(+user); // valueOf -> 1000
alert(user + 500); // valueOf -> 1500
我们可以看到,执行的动作和前面使用 Symbol.toPrimitive
的那个例子相同。
通常我们希望有一个“全能”的地方来处理所有原始转换。在这种情况下,我们可以只实现 toString
,就像这样:
let user = {
name: "John",
toString() {
return this.name;
}
};
alert(user); // toString -> John
alert(user + 500); // toString -> John500
如果没有 Symbol.toPrimitive
和 valueOf
,toString
将处理所有原始转换。
转换可以返回任何原始类型
关于所有原始转换方法,有一个重要的点需要知道,就是它们不一定会返回 “hint” 的原始值。
没有限制 toString()
是否返回字符串,或 Symbol.toPrimitive
方法是否为 "number"
hint 返回数字。
唯一强制性的事情是:这些方法必须返回一个原始值,而不是对象。
ℹ️历史原因
由于历史原因,如果
toString
或valueOf
返回一个对象,则不会出现 error,但是这种值会被忽略(就像这种方法根本不存在)。这是因为在 JavaScript 语言发展初期,没有很好的 “error” 的概念。相反,
Symbol.toPrimitive
更严格,它 必须 返回一个原始值,否则就会出现 error。
进一步的转换
我们已经知道,许多运算符和函数执行类型转换,例如乘法 *
将操作数转换为数字。
如果我们将对象作为参数传递,则会出现两个运算阶段:
- 对象被转换为原始值(通过前面我们描述的规则)。
- 如果还需要进一步计算,则生成的原始值会被进一步转换。
例如:
let obj = {
// toString 在没有其他方法的情况下处理所有转换
toString() {
return "2";
}
};
alert(obj * 2); // 4,对象被转换为原始值字符串 "2",之后它被乘法转换为数字 2。
- 乘法
obj * 2
首先将对象转换为原始值(字符串 “2”)。 - 之后
"2" * 2
变为2 * 2
(字符串被转换为数字)。
二元加法在同样的情况下会将其连接成字符串,因为它更愿意接受字符串:
let obj = {
toString() {
return "2";
}
};
alert(obj + 2); // 22("2" + 2)被转换为原始值字符串 => 级联
总结
对象到原始值的转换,是由许多期望以原始值作为值的内建函数和运算符自动调用的。
这里有三种类型(hint):
"string"
(对于alert
和其他需要字符串的操作)"number"
(对于数学运算)"default"
(少数运算符,通常对象以和"number"
相同的方式实现"default"
转换)
规范明确描述了哪个运算符使用哪个 hint。
转换算法是:
- 调用
obj[Symbol.toPrimitive](hint)
如果这个方法存在, - 否则,如果 hint 是``string`
- 尝试调用
obj.toString()
或obj.valueOf()
,无论哪个存在。
- 尝试调用
- 否则,如果 hint 是
"number"
或者"default"
- 尝试调用
obj.valueOf()
或obj.toString()
,无论哪个存在。
- 尝试调用
所有这些方法都必须返回一个原始值才能工作(如果已定义)。
在实际使用中,通常只实现 obj.toString()
作为字符串转换的“全能”方法就足够了,该方法应该返回对象的“人类可读”表示,用于日志记录或调试。
- 点赞
- 收藏
- 关注作者
评论(0)