◆

JavaScript Symbol 数据类型详解

2023-02-02 01:370

概述

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 类型不行。

💬留言板0

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

PAGE LOAD / LIVEJavaScript Symbol 数据类型详解加载中