一、Symbol概述

Symbol是ES6引入的一种新的原始数据类型(Primitive Data Types),表示独一无二的值。它是JavaScript的第7种原始数据类型(前6种是:String、Number、Boolean、Null、Undefined、BigInt)。

题外话:JavaScript的数据类型分为两大类:原始数据类型(Primitive Data Types);引用数据类型(Reference Data Types)。原始数据类型共有7种,如上述所言;引用数据类型,包括:Object、Array、Function、Date、RegExp、Map/Set等。


原始数据类型是不可变的,存储在栈内存中;而引用数据类型是可变的,存储在堆内存中,变量保存的只是引用(地址)。

基本示例
// 创建Symbol
const sym1 = Symbol();
const sym2 = Symbol('description'); // 可以添加描述
const sym3 = Symbol('description');

console.log(sym2 === sym3); // false - 即使描述相同,也是不同的Symbol

二、Symbol的核心特性

唯一性

每个Symbol值都是唯一的,这使其成为对象属性的理想选择,可以避免属性名冲突。

想象一下:

你有一个班级,每个学生都有一个学号。学号是唯一的,即使有两个学生同名,学号也能区分他们。

Symbol就像这个学号,它是JavaScript中的一种唯一标识符。

const obj = {
  [Symbol('id')]: 1,
  [Symbol('id')]: 2
};

console.log(Object.getOwnPropertySymbols(obj)); // 两个不同的Symbol
隐藏性

默认情况下,Symbol属性不会出现在常规的对象遍历中,即当使用 Symbol 作为对象属性名时,这个属性不会被常规方法(如for...in、Object.keys())遍历到。所以,它适合用来定义一些对象内部私有属性。

想象一下:

普通属性就像是你家客厅里的家具,所有人来做客都能看到它们(用for...in或Object.keys能遍历到)。

而Symbol属性就像是你房间保险箱里的东西,虽然存在,但客人在普通参观时看不到它们(常规遍历方法看不到)。

const obj = {
  [Symbol('secret')]: 'I am hidden',
  normal: 'I am normal'
};

console.log(Object.keys(obj)); // 只输出 ['normal']

可以使用 Object.getOwnPropertySymbols()方法可以获取Symbol属性。

三、创建和使用Symbol

创建Symbol

创建一个Symbol,可以给它一个描述,但这个描述只是用来调试的,不会影响唯一性:

const sym1 = Symbol('id');
const sym2 = Symbol('id');
console.log(sym1 === sym2); // false,就像两个同名的学生,但学号不同
作为对象属性
let user = {
name: '唐国礼',
  [Symbol('id')]: 123,   // 使用Symbol作为属性名
  [Symbol('id')]: 456    // 这是另一个属性,因为每个Symbol都是唯一的
};

// 访问Symbol属性
console.log(user[Symbol('id')]); // 这样访问不到,因为每次创建的Symbol都不同

注意:上面这样访问不到,因为每次 Symbol('id') 都是不一样的。正确的做法是保存Symbol引用:

const idSymbol = Symbol('id');
let user = {
  name: '月神',
  [idSymbol]: 123
};

console.log(user[idSymbol]); // 123
全局Symbol注册表

如果你想要一个Symbol在全局可复用,可以使用Symbol.for():

// 从全局注册表中读取,如果没有则创建
const sym1 = Symbol.for('app.id');
const sym2 = Symbol.for('app.id');

console.log(sym1 === sym2); // true,因为是从全局注册表获取的同一个Symbol
四、内置Symbol

JavaScript提供了一些内置的Symbol,它们可以用来改变对象的默认行为。比如,Symbol.iterator可以让对象变成可迭代的,从而可以用for...of循环。

名称

说明

Symbol.iterator

使对象可迭代,支持 for...of 循环

Symbol.toStringTag

自定义对象的 toString 行为

Symbol.hasInstance

自定义 instanceof 操作符的行为

Symbol.species

指定创建衍生对象的构造函数

Symbol.toPrimitive

对象转换为原始值时调用

Symbol.match

被 String.prototype.match 调用

Symbol.replace

被 String.prototype.replace 调用

Symbol.search

被 String.prototype.search 调用

示例:Symbol.iterator
// 创建一个自定义的可迭代对象
const myIterable = {
  // 使用Symbol.iterator这个特殊的Symbol作为属性名,定义迭代器方法
  // Symbol.iterator是JavaScript内置的Symbol,用于使对象可迭代
  // [Symbol.iterator]这里的中括号,可以将这个Symbol值作为对象的属性名(计算属性名语法)
  [Symbol.iterator]: function* () {
    // function* 定义了一个生成器函数,它会返回一个生成器对象
    // 生成器对象自动实现了迭代器协议
    
    // 使用yield关键字依次产生值
    yield 1;  // 第一次调用next()方法时返回 {value: 1, done: false}
    yield 2;  // 第二次调用next()方法时返回 {value: 2, done: false}
    yield 3;  // 第三次调用next()方法时返回 {value: 3, done: false}
    // 函数结束后,再次调用next()会返回 {value: undefined, done: true}
  }
};

// 使用for...of循环遍历可迭代对象
// for...of会自动调用对象的Symbol.iterator方法获取迭代器
for (let value of myIterable) {
  // 每次迭代获取yield产生的值
  console.log(value); // 依次输出: 1, 2, 3
}

补充知识:计算属性名语法

这是ES6引入的一个重要特性,允许我们在对象字面量中使用表达式作为属性名。当JavaScript引擎遇到 [expression] 这样的语法时,它会:

  • 先计算方括号内的表达式的值。

  • 然后将计算结果作为属性名。

对比示例

// 正确写法:使用计算属性名,将Symbol值作为属性名
const obj1 = {
  [Symbol.iterator]: function*() { yield 1; }
};

// 错误写法:这里的"Symbol.iterator"只是一个普通字符串
const obj2 = {
  Symbol.iterator: function*() { yield 1; } // 语法错误!
};

// 错误写法的正确表示(但这不是我们想要的)
const obj3 = {
  "Symbol.iterator": function*() { yield 1; } // 这只是字符串属性名
};
示例:Symbol.toStringTag
class Collection {
  constructor() {
    this.items = [];
  }
  
  get [Symbol.toStringTag]() {
    return 'Collection';
  }
}

const coll = new Collection();
console.log(Object.prototype.toString.call(coll)); // '[object Collection]'
示例:Symbol.hasInstance
class MyArray {
  static [Symbol.hasInstance](instance) {
    return Array.isArray(instance);
  }
}

console.log([] instanceof MyArray); // true
console.log({} instanceof MyArray); // false

五、应用场景

  1. 避免属性名冲突:当多个库向同一个对象添加属性时,使用Symbol可以避免覆盖已有的属性。

  1. 模拟私有属性:由于Symbol属性不会被常规遍历到,所以可以在一定程度上隐藏属性,但注意这并不是真正的私有(因为通过Object.getOwnPropertySymbols还是可以拿到)。

  2. 定义常量:保证常量值的唯一性。

六、注意事项

  • Symbol不能使用new关键字,因为它不是对象,是原始类型。

  • Symbol不能与其他类型的值进行运算,但可以显式转换为字符串或布尔值。

  • Symbol就像是一个唯一的标签,你可以用它来标记对象,确保这个标记不会和其他标记冲突。它主要用于对象属性名,以避免冲突和实现一些特殊的内置行为。

为什么需要Symbol?

在JavaScript中,对象属性名通常是字符串,但有时我们可能会不小心给对象添加了同名的属性,导致冲突。比如,你和一个同学都叫“小明”,老师点名时就会分不清。Symbol就是为了解决这个问题而生的,它确保每个属性名都是独一无二的,即使它们看起来一样(描述相同)。

类型转换示例
const sym = Symbol('test');

console.log(String(sym)); // "Symbol(test)"
console.log(sym.toString()); // "Symbol(test)"
console.log(Boolean(sym)); // true

// 不能转换为数字
console.log(Number(sym)); // TypeError

// 不能与字符串拼接
console.log('Symbol: ' + sym); // TypeError
属性遍历方法示例
const obj = {
  regular: 'regular property',
  [Symbol('sym1')]: 'symbol property 1',
  [Symbol('sym2')]: 'symbol property 2'
};

// 只获取常规属性
console.log(Object.keys(obj)); // ['regular']
console.log(Object.getOwnPropertyNames(obj)); // ['regular']

// 只获取Symbol属性
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(sym1), Symbol(sym2)]

// 获取所有属性(包括Symbol)
console.log(Reflect.ownKeys(obj)); // ['regular', Symbol(sym1), Symbol(sym2)]

总结

Symbol的核心价值
  1. 唯一性:从根本上解决了属性名冲突问题。

  2. 隐蔽性:提供了一定程度的属性隐藏。

  3. 元编程能力:通过内置Symbol可以干预对象的默认行为。

  4. 清晰的意图表达:明确表明某些属性有特殊用途。

最佳实践建议
  • 使用Symbol作为需要特殊处理或避免冲突的属性键。

  • 对于需要跨模块共享的Symbol,使用Symbol.for()。

  • 利用内置Symbol实现自定义的对象行为。

  • 注意Symbol属性的遍历特性,合理选择对象操作方法。

Symbol为JavaScript带来了更强大的元编程能力和更优雅的设计模式,是构建大型复杂应用和框架时的重要工具。

补充Symbol的实际应用场景示例

创建私有属性

虽然JavaScript没有真正的私有属性,但Symbol可以模拟私有属性。

const _password = Symbol('password');

class User {
  constructor(name, password) {
    this.name = name;
    this[_password] = password;
  }
  
  checkPassword(pwd) {
    return this[_password] === pwd;
  }
}

const user = new User('Bob', 'secret123');
console.log(user[_password]); // 可以访问,但需要知道Symbol
console.log(Object.keys(user)); // ['name'] - 不会出现在常规遍历中
避免属性冲突

在库或框架开发中,防止属性名冲突。

// 库A
const LIB_A_KEY = Symbol('lib_a_key');

// 库B
const LIB_B_KEY = Symbol('lib_b_key');

const sharedObject = {};
sharedObject[LIB_A_KEY] = 'Library A data';
sharedObject[LIB_B_KEY] = 'Library B data';
// 两者互不干扰
定义常量

创建一组唯一的值作为常量。

const LOG_LEVELS = {
  DEBUG: Symbol('debug'),
  INFO: Symbol('info'),
  WARN: Symbol('warn'),
  ERROR: Symbol('error')
};

function log(message, level = LOG_LEVELS.INFO) {
  if (level === LOG_LEVELS.DEBUG) {
    console.debug(message);
  } else if (level === LOG_LEVELS.ERROR) {
    console.error(message);
  }
  // ...
}
元编程

修改对象的内置行为。

const temperature = {
  value: 25,
  [Symbol.toPrimitive](hint) {
    if (hint === 'string') {
      return `${this.value}°C`;
    }
    if (hint === 'number') {
      return this.value;
    }
    return this.value;
  }
};

console.log(String(temperature)); // "25°C"
console.log(Number(temperature)); // 25
console.log(temperature + 5); // 30

知识扩展:

Symbol.toPrimitive和Reflect API服务于不同的目的,前者用于自定义对象的原始值转换行为,后者用于操作对象的属性和方法