给别人的对象上挂一个属性,这件事听着简单,做起来其实一直有风险。你写一个日志库,想在用户传进来的对象上标记一个 __logged__,怎么保证用户自己没用过这个名字?加下划线、加前缀、加随机数,都是概率游戏,赌的是不撞车。
Symbol 就是来终结这个赌局的。它生成的值天生独一无二,拿它当属性名,谁也覆盖不了谁。更有意思的是,语言自己也在用这套机制,for...of 能遍历什么、instanceof 怎么判断、Object.prototype.toString 输出什么标签,背后全是几个内置 Symbol 在做钩子。这篇把 Symbol 的基本行为和这套「协议钩子」的设计一起讲清楚。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Symbol 作为原始类型的定位,以及它和 BigInt 的先后关系
- 唯一性怎么体现,为什么不能用
new Symbol.for的全局注册表和普通 Symbol 的区别- 内置符号(well-known symbols)是怎么当协议钩子用的
- Symbol 属性在各种遍历方式下的可见性对照
- 什么场景真的该用 Symbol,什么场景是过度设计
# 一、简介
ES6 新加入了一种原始数据类型 Symbol,表示独一无二的值。在 ES6 那个时间点,它是 JS 的第七种数据类型,前六种是 Undefined、Null、布尔值(Boolean)、字符串(String)、数值(Number)、对象(Object)。
这里补一句时效性说明。原文写于 2018 年,那会儿说「第七种」是准确的。后来 ES2020 又加了 BigInt,所以现在的完整清单是七种原始类型(Undefined、Null、Boolean、String、Number、Symbol、BigInt)加上 Object。数数的说法变了,Symbol 本身的行为没变。
对象的属性名现在可以有两种类型,一种是原来就有的字符串,另一种就是新增的 Symbol 类型。凡是属性名属于 Symbol 类型,就都是独一无二的,可以保证不会与其他属性名产生冲突。
# 1.1 定义
Symbol([description])
参数 description 是一个可选参数,是一个字符串,可以用于调试,但不能通过它访问到 Symbol 自身。
var sym1 = Symbol();
var sym2 = Symbol('foo');
var sym3 = Symbol('foo');
description 纯粹是给人看的。你在 DevTools 里看到 Symbol(userId),能一眼知道这是干嘛的,仅此而已,代码逻辑一点都碰不到它。ES2019 之后可以用 sym.description 直接读回这个字符串,比 sym.toString().slice(7, -1) 那种土办法干净不少。
# 1.2 值唯一性
每一个 Symbol() 返回的值都是唯一的。一个 Symbol 值能作为对象属性的标识符,这是该数据类型仅有的目的。
Symbol("yuan") === Symbol("yuan"); // false
原文这里写的是「这是改数据类型仅有的目的」,「改」应为「该」,顺手改了。
两个描述一模一样的 Symbol 依然不相等,这就是唯一性的全部含义。回到开头那个日志库的问题,现在解法很干净:
// logger.js
const LOGGED = Symbol('logged');
export function markLogged(obj) {
obj[LOGGED] = true;
}
export function isLogged(obj) {
return obj[LOGGED] === true;
}
LOGGED 这个值只有你的模块持有,用户就算自己也写了 Symbol('logged'),那也是另一个值,两边井水不犯河水。
# 1.3 不可以使用 new 操作符
var sym = new Symbol(); // TypeError 报错
原因很直白,Symbol 是原始类型,不是对象。new 的语义是造一个对象出来,跟原始类型天然冲突。Number、String、Boolean 之所以能 new(虽然没人这么写),是历史包袱,Symbol 是新加的,标准直接把这条路封了。
# 1.4 结合 Object() 函数
结合 Object() 函数,可以创建一个 Symbol 包装器对象。
var sym = Symbol();
typeof sym; // "symbol"
var symobj = Object(sym);
typeof symobj; // "object"
原文这一行的注释末尾混进了两个全角弯引号字符,已经清理掉。这类字符在 markdown 双引擎渲染下容易出乱码,属于顺手要修的东西。
包装对象日常几乎用不上,知道 typeof 会从 "symbol" 变成 "object" 就行,免得类型判断的时候被绕进去。
# 1.5 全局共享 Symbol
使用 Symbol.for() 方法会根据给定的键 key,从运行时的 symbol 注册表中找到对应的 symbol。如果找到了就返回它,否则新建一个与该键关联的 symbol,并放入全局 symbol 注册表中。
这一条和 1.2 的唯一性看起来矛盾,其实是两套不同的用途,第三节会展开讲。
# 1.6 在对象中查找 Symbol 属性
var obj = {};
var a = Symbol("a");
var b = Symbol.for("b");
obj[a] = "localSymbol";
obj[b] = "globalSymbol";
var objectSymbols = Object.getOwnPropertySymbols(obj);
console.log(objectSymbols) // [Symbol(a), Symbol(b)]
注意 Object.keys(obj) 在这里会返回空数组。Symbol 属性不参与常规遍历,想拿到它们只有 Object.getOwnPropertySymbols 和 Reflect.ownKeys 两条路。这个特性有好有坏,第四节专门对照。
# 二、内置符号:语言自己开的钩子
这一节是 Symbol 里最有意思的部分。除了让你造独一无二的属性名,标准还预置了一批 Symbol,挂在 Symbol 对象上,用来让你改写语言层面的默认行为。它们有个专门的名字,叫 well-known symbols。
# 2.1 length 属性
// Symbol 函数本身的 length 为 0
Symbol.length // 0
这个跟内置符号无关,只是 Symbol 这个函数对象的 length。函数的 length 表示形参个数,description 是可选参数不计入,所以是 0。
# 2.2 Symbol.iterator 与迭代协议
Symbol.iterator 为每一个对象定义了默认的迭代器。这个迭代器可以被 for...of 循环使用。
// 自定义迭代器
var myIterator = {};
myIterator[Symbol.iterator] = function* () {
yield 1;
yield 2;
yield 3;
};
[...myIterator] // [1, 2, 3]
一个普通对象本来是不能展开的,[...{}] 直接报 object is not iterable。挂上 Symbol.iterator 之后它就成了可迭代对象,for...of、展开运算符、解构赋值、Array.from、Promise.all 全都能吃它。
这就是「协议钩子」的意思。语言不关心你的对象是什么类,只看你有没有实现那个约定的 Symbol 键。用 Symbol 而不是字符串 'iterator' 来当这个键,就是为了避免和用户自己定义的属性撞名。这套用生成器来部署迭代接口的写法,我在 ES6系列之 Generator 函数的暂停与自动执行 里有更完整的展开。
Symbol 属性和 for...in 迭代:
var obj = {};
obj[Symbol("a")] = "a";
obj[Symbol.for("b")] = "b";
obj["c"] = "c";
obj.d = "d";
for (var i in obj) {
console.log(i);
}
// "c"
// "d"
两个 Symbol 键被完全跳过了。
# 2.3 正则相关的四个符号
这一组符号用于标识对象是否具有正则表达式的行为。Symbol.match 表示对象是否具有指定的匹配的正则表达式。
"/bar/".startsWith(/bar/);
// Throws TypeError,因为 /bar/ 是一个正则表达式
// 且 Symbol.match 没有修改
startsWith 这类方法会先检查参数上有没有 Symbol.match,有就认为它是正则,直接抛 TypeError。这是标准刻意留的一道保险,防止你把正则当字符串误用。
如果你将 Symbol.match 置为 false,使用 match 属性的表达式检查会认为该对象不是正则表达式对象,startsWith 和 endsWith 方法将不会抛出 TypeError。
var re = /foo/;
re[Symbol.match] = false;
"/foo/".startsWith(re); // true
"/baz/".endsWith(re); // false
这两行的结果值得解释一下。关掉 Symbol.match 之后,re 被当成普通对象,参数会被转成字符串,String(/foo/) 就是 "/foo/"。所以第一行相当于 "/foo/".startsWith("/foo/"),为 true;第二行相当于 "/baz/".endsWith("/foo/"),为 false。
另外三个是 Symbol.replace、Symbol.search、Symbol.split,分别指定了 String.prototype.replace()、String.prototype.search()、String.prototype.split() 在遇到这个对象时调用的方法。改写它们你就能造出一个「行为像正则但不是正则」的东西,日常业务基本用不到,但看到某些库里有奇怪的字符串处理行为时,可以往这里查。
# 2.4 hasInstance 与 toStringTag
Symbol.hasInstance 是一个用来确定构造器对象识别的对象是否为它的实例的方法。它决定的其实就是 instanceof 怎么判。
class Even {
static [Symbol.hasInstance](num) {
return Number(num) % 2 === 0;
}
}
console.log(2 instanceof Even); // true
console.log(3 instanceof Even); // false
Symbol.toStringTag 用于对象的默认描述的字符串值,也就是 Object.prototype.toString() 输出的那个尾巴。