JavaScript Symbol 数据类型详解
概述
Symbol 是 ES6 新增的原始数据类型,它的特点是每个 Symbol 值都是唯一的。它和 string、number、boolean、null、undefined、bigint 一起构成 JS 的七种原始类型。
创建方式是调用 Symbol() 函数,参数是可选的描述字符串,只用于调试,不参与相等性判断:
const s1 = Symbol();
const s2 = Symbol('foo');
const s3 = Symbol('foo');
console.log(typeof s1); // 'symbol'
console.log(s2 === s3); // false,描述相同但仍是两个不同的值
console.log(s2.description); // 'foo'
console.log(s2.toString()); // 'Symbol(foo)'
注意 Symbol 不能用 new 调用,new Symbol() 会抛 TypeError。它是原始类型不是构造函数,这一点跟 String、Number 不同。
为什么需要 Symbol
最初的动因是解决对象属性名冲突。在 Symbol 之前,对象的键只能是字符串(数字键也会被转成字符串),当我们给一个别人的对象或者第三方库的对象挂属性时,永远有覆盖已有属性的风险:
// 假设这是第三方传进来的对象
const user = { name: 'wfly', id: 1 };
// 我们想挂一个内部标记,但 'id' 可能已经被用了
user.id = 'my-internal-id'; // 覆盖了原有数据
用 Symbol 当键就不会有这个问题,因为它一定不会和任何已有的键相等:
const MY_ID = Symbol('myId');
const user = { name: 'wfly', id: 1 };
user[MY_ID] = 'my-internal-id';
console.log(user.id); // 1,原有数据没被动
console.log(user[MY_ID]); // 'my-internal-id'
作为属性名时必须用方括号语法。写 user.MY_ID = x 是给对象加了一个字符串键 'MY_ID',不是 Symbol 键。
Symbol 键不会被常规遍历到
这是它另一个重要特性,也是坑的来源:
const KEY = Symbol('hidden');
const obj = { a: 1, [KEY]: 2 };
console.log(Object.keys(obj)); // ['a']
console.log(JSON.stringify(obj)); // '{"a":1}'
for (const k in obj) console.log(k); // 只有 'a'
console.log(Object.getOwnPropertyNames(obj)); // ['a']
Object.keys、for...in、JSON.stringify、Object.getOwnPropertyNames 全都会跳过 Symbol 键。想拿到它们要用专门的 API:
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(hidden)]
console.log(Reflect.ownKeys(obj)); // ['a', Symbol(hidden)]
所以「用 Symbol 存的数据不会被序列化传给后端」既是优点也是坑:适合放内部元信息,但绝不能放业务数据,否则数据静默丢失且很难排查。另外 Object.assign 和对象展开 {...obj} 是会复制 Symbol 键的,这跟上面几个遍历方法不一致,容易记错。
Symbol.for 全局注册表
Symbol() 每次都产生新值,如果想在不同模块间共享同一个 Symbol,用 Symbol.for。它维护一个全局注册表,同一个 key 返回同一个 Symbol:
const a = Symbol.for('app.token');
const b = Symbol.for('app.token');
console.log(a === b); // true
console.log(Symbol.keyFor(a)); // 'app.token'
console.log(Symbol.keyFor(Symbol('x'))); // undefined,非注册表里的返回 undefined
这个注册表是跨 iframe 和 realm 共享的,所以 key 要加命名空间前缀避免撞车。
内置的 Well-Known Symbol
语言本身用一批预定义 Symbol 作为「协议」的挂载点,实现它们就能改变对象在特定语法下的行为。常用的几个:
Symbol.iterator 让对象可以被 for...of 和展开语法消费:
const range = {
from: 1,
to: 3,
[Symbol.iterator]() {
let cur = this.from;
const last = this.to;
return {
next() {
return cur <= last ? { value: cur++, done: false } : { done: true };
},
};
},
};
console.log([...range]); // [1, 2, 3]
for (const n of range) console.log(n); // 1 2 3
Symbol.toPrimitive 接管类型转换:
const money = {
amount: 100,
[Symbol.toPrimitive](hint) {
// hint 为 'number' | 'string' | 'default'
return hint === 'string' ? `¥${this.amount}` : this.amount;
},
};
console.log(+money); // 100
console.log(`${money}`); // '¥100'
console.log(money + 1); // 101,default hint
Symbol.toStringTag 改变 Object.prototype.toString 的结果:
class Collection {
get [Symbol.toStringTag]() {
return 'Collection';
}
}
console.log(Object.prototype.toString.call(new Collection()));
// '[object Collection]'
还有 Symbol.asyncIterator(for await...of)、Symbol.hasInstance(改 instanceof 行为)等。
使用中的注意点
不能隐式转字符串。这是最容易撞的错:
const s = Symbol('x');
console.log(`id: ${s}`); // TypeError: Cannot convert a Symbol value to a string
console.log('id: ' + s); // 同样报错
console.log(`id: ${String(s)}`); // 正确:'id: Symbol(x)'
console.log(`id: ${s.description}`); // 正确:'id: x'
拼接日志时忘记显式转换就会在运行时炸掉,而且报错信息不太直观。
Symbol 可以做数值运算判断但不能参与算术。s + 1 会抛 TypeError,s == s 是 true,Boolean(s) 恒为 true。
它不是真正的私有。Object.getOwnPropertySymbols 能拿到所有 Symbol 键,所以它提供的是「防冲突」和「防误改」,不是安全边界。真正的私有字段用 class 的 #field 语法。
TypeScript 里要用 unique symbol 才能让编译器把某个 Symbol 当作字面量类型用于索引签名,普通的 symbol 类型不行。

还没有留言,来发表第一条吧~