跳转到内容

第十章 Symbol

在本章节阮一峰老师就 Symbol 的定义和作用展开描述,其中重点讲解了概述、属性名遍历、for()keyFor() 方法,最后来两个实例。

概述

在 ES5 时,如果使用了他人的数据对象,但又想添加自己的数据,很容易会因为属性名冲突而导致数据覆盖。因为 ES5 对象的属性名都是字符串型。

为了解决这个问题,ES6 推出了 Symbol,他是第7种数据类型。

🖇 拓展

前6种数据类型分别是 undefined、null、布尔值(boolean)、字符串(string)、数值(number)、对象(object)。

通过 Symbol 函数生成,是第一无二的,不会与其他属性名造成冲突。可以接收一个字符串,主要用于控制台打印区分。

js
let s = Symbol()
let b = Symbol('bar')

console.log(typeof s) // symbol
console.log(typeof b) // symbol(bar)

⚠️ 注意

Symbol 函数前不能使用 new 命令,否则会报错 这是因为生成的 Symbol 是一个原始类型的值,不是对象。也就是说,由于 Symbol 值不是对象,所以不能添加属性。基本上,它是 一种类似于字符串的数据类型。

若 Symbol 的参数是一个对象,则会调用对象的 toString 方法转为字符串后再生成 Symbol 值。

js
const obj = {
  toString() {
    return 'abc'
  }
}

const sym = Symbol(obj)
console.log(sym) // Symbol(abc)

Symbol 函数的参数只表示对当前 Symbol 值的描述,因此相同参数的 Symbol 函数的返回值是不相等的。

js
const s1 = Symbol()
const s2 = Symbol()

s1 === s2 // false

const b1 = Symbol('bar')
const b2 = Symbol('bar')

b1 === b2 // false

最后,Symbol 不可以参与运算,可以转为布尔值和字符串,不可转为数值。

js
const sym = Symbol('abc')

console.log('symbol is:' + sym) // Uncaught TypeError: Cannot convert a Symbol value to a string

console.log(`symbol is: ${sym}`) // Uncaught TypeError: Cannot convert a Symbol value to a string

console.log(String(sym)) // abc
console.log(sym.toString()) // abc

console.log(Boolean(sym)) // true
console.log(!sym) // false

Number(sym) // Uncaught TypeError: Cannot convert a Symbol value to a number

作为属性名的Symbol

在ES6中,Symbol(符号)是一种新的基本数据类型,它的主要特点是其值是唯一且不可变的。这使得Symbol特别适合作为对象属性名,用于解决属性名冲突的问题,保证属性不会被意外修改或覆盖。

使用Symbol作为对象属性名的几种写法:

  1. 方括号结构

    js
    var mySymbol = Symbol();
    var a = {};
    
    a[mySymbol] = 'Hello!';
    
    console.log(a[mySymbol]); // 输出: "Hello!"
  2. 方括号定义属性

    js
    var mySymbol = Symbol();
    
    var a = {
      [mySymbol]: 'Hello!'
    };
    
    console.log(a[mySymbol]); // 输出: "Hello!"
  3. 使用Object.defineProperty

    js
    var mySymbol = Symbol();
    var a = {};
    
    Object.defineProperty(a, mySymbol, { value: 'Hello!' });
    
    console.log(a[mySymbol]); // 输出: "Hello!"

注意事项:

  1. 点运算符无法使用Symbol作为属性名

    因为点运算符后面总是字符串,不会将Symbol作为标识符,而是将其转换为字符串。同理,在对象的 使用 Symbol 值定义属性时, mbol 值必须放在方括号中。

    js
    var mySymbol = Symbol();
    var a = {};
    
    a.mySymbol = 'Hello!'; // 错误的写法,属性名实际是字符串 "mySymbol"
    
    console.log(a[mySymbol]); // undefined
    console.log(a['mySymbol']); // 输出: "Hello!"
    
    a = {
      [mySymbol]: function() {}
    }
  2. Symbol作为属性名是公开属性

    使用Symbol作为属性名时,并不会使属性成为私有属性,仍然可以通过其他方式访问到这些属性。

Symbol还可以用于定义一组唯一且不可变的常量,确保不同常量之间不会发生值相等的情况,例如日志级别或颜色常量。

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

function log(level, message) {
  // 省略具体实现
}

log(LOG_LEVELS.DEBUG, 'Debug message');
log(LOG_LEVELS.INFO, 'Info message');

这种方式保证了每个常量都是独一无二的Symbol,可以用于标识不同的日志级别或其他场景下的常量。

总而言之,Symbol 作为 ES6 引入的一种新的基本数据类型,用于创建唯一的标识符,特别适合作为对象属性名或常量的定义。它的唯一性可以有效地防止属性名冲突,并且保证了常量值的独特性,是 JavaScript 语言中更加安全和清晰的编程实践之一。

实例:消除魔术字符串

魔术字符串是指在代码中多次出现、与代码强耦合的具体字符串或值。在编写风格良好的代码时,应尽量消除魔术字符串,用含义清晰的变量或常量代替,以提高代码的可读性、可维护性和安全性。

  1. 使用命名的常量或变量

    最简单的方法是将魔术字符串定义为命名的常量或变量,例如:

    js
    // 使用命名的常量
    const SHAPE_TRIANGLE = 'Triangle';
    
    function getArea(shape, options) {
      let area = 0;
      switch (shape) {
        case SHAPE_TRIANGLE:
         area = 0.5 * options.width * options.height;
         break;
        // 其他形状的处理
      }
      return area;
    }
    
    getArea(SHAPE_TRIANGLE, { width: 100, height: 100 });

    将字符串 'Triangle' 定义为常量 SHAPE_TRIANGLE,用来代替原来的魔术字符串。这样做可以减少代码中字符串的重复出现,提高代码的可维护性。

  2. 使用对象属性

    另一种方法是将魔术字符串作为对象的属性,这种方式也可以避免字符串的直接出现:

    js
    // 使用对象属性
    const shapeType = {
      triangle: 'Triangle'
    };
    
    function getArea(shape, options) {
      let area = 0;
      switch (shape) {
        case shapeType.triangle:
          area = 0.5 * options.width * options.height;
          break;
        // 其他形状的处理
      }
      return area;
    }
    
    getArea(shapeType.triangle, { width: 100, height: 100 });

    shapeType 对象中的 triangle 属性存储了字符串 'Triangle',在 getArea 函数中使用 shapeType.triangle 来替代原始的魔术字符串。

  3. 使用Symbol作为属性值

    如果确保每个属性的值唯一且不变是关键,可以考虑使用Symbol作为属性值:

    js
    // 使用Symbol作为属性值
    const shapeType = {
      triangle: Symbol()
    };
    
    function getArea(shape, options) {
      let area = 0;
      switch (shape) {
        case shapeType.triangle:
          area = 0.5 * options.width * options.height;
          break;
        // 其他形状的处理
      }
      return area;
    }
    
    getArea(shapeType.triangle, { width: 100, height: 100 });

    由于 shapeType.triangle` 的值是一个Symbol,保证了它的唯一性。这种做法适用于需要确保属性值不与其他任何值冲突的情况,同时能有效地消除魔术字符串。

总而言之,消除魔术字符串可以通过使用命名的常量、对象属性或Symbol来实现。这些方法可以提高代码的可读性和可维护性,减少由于字符串拼写错误或修改时遗漏更新导致的bug。

属性名的遍历

前面有介绍到,Symbol 并不是私有属性,而是公有属性,但是不会被 for...offor...inObject.keys()Object.getOwnPropertyNames() 等方法遍历返回。

若需要获取某个对象的 Symbol 属性名,则需要通过 Object.getOwnPropertySymbols 方法获取。该方法返回的是一个数组,每一项是当前对象所有用作属性名的 Symbol 值。

js
let a = Symbol('a')
let b = Symbol('b')
let f = Symbol('f')

let obj = {
  [a]: 'hello',
  [b]: 'world',
}

Object.defineProperty(obj, f, {
  value: 'foo'
})

for (let i in obj) {
  console.log(i) // 无输出
}

Object.getOwnPropertyNaames(obj) // []

let objSym = Object.getOwnPropertySymbols(obj)
console.log(objSym) // [Symbol(a), Symbol(b), Symbol(f)]

另外,Reflect.ownKwys 方法可以返回所有类型的键名,代码如下:

js
let obj = {
  [Symbol('key')]: 1,
  value: 'daodao'
}

Reflect.ownKwys(obj) // ['value', Symbol('key')]

由于用 Symbol 定义的属性不会被常规方法遍历,因此可以利用其这一特性来定义一些非私有但只希望内部使用的方法。

Symbol.for()、Symbol.keyFor()

SymbolSymbol.for() 都会生成新的 Symbol ,二者的区别是 Symbol 每次生成都是返回新的 Symbol 值;而 Symbol.for() 会在全局中提供搜索,如果全局中存在该 key 值的 Symbol 值,则会返回同一个 Symbol 值。

js
Symmbol.for('bar') === Symmbol.for('bar') // true

Symbol('foo') === Symbol('foo') // false

Symbol.keyFor() 则会返回已登记的 Symbol 类型的 key

js
let s1 = Symbol.for('foo')
Symbol.keyFor(s1) // foo

let s2 = Symbol('bar')
Symbol.keyFor(s2) // undefined

🖇 拓展

Symbol.for()Symbol 值登记的名字是全局环境的,可以在不同的 iframe 或 service worker 中取到同一个值。

实例:模块的Singleton模式

Singleton 模式确保一个类只有一个实例,并提供一个全局访问点。在 Node.js 中,模块文件可以视作一个类,如何保证每次加载模块时都返回同一个实例呢?

  • 方法一:使用全局变量 _foo

    js
    // mod.js
    function A() {
      this.foo = 'hello';
      if (!global._foo) {
        global._foo = new A();
        module.exports = global._foo;
      }
    }
    
    // 使用方式
    const a = require('./mod.js');
    console.log(a.foo); // 输出 'hello'

    问题: 全局变量 _foo 可被其他模块修改,可能导致实例失真。

  • 方法二:使用 Symbol 作为键名

    js
    // mod.js
    const FOO_KEY = Symbol.for('foo');
    
    function A() {
      this.foo = 'hello';
      if (!global[FOO_KEY]) {
        global[FOO_KEY] = new A();
        module.exports = global[FOO_KEY];
      }
    }
    
    // 使用方式
    const a = require('./mod.js');
    console.log(a.foo); // 输出 'hello'

    优点: 使用 Symbol.for('foo') 作为键名,外部无法直接引用到该 Symbol,可以避免意外覆盖。

    问题: 全局变量依然可以被改写,例如 global[Symbol.for('foo')] = 123;

  • 方法三:使用局部 Symbol

    js
    // mod.js
    const FOO_KEY = Symbol('foo');
    
    function A() {
      this.foo = 'hello';
      if (!global[FOO_KEY]) {
        global[FOO_KEY] = new A();
        module.exports = global[FOO_KEY];
      }
    }
    
    // 使用方式
    const a = require('./mod.js');
    console.log(a.foo); // 输出 'hello'

    优点: 使用局部 Symbol,其他脚本无法引用到 FOO_KEY,避免外部修改。

    问题: 多次执行模块时,每次得到的 FOO_KEY 都不同,不过一般情况下 Node.js 会缓存模块的执行结果,不会多次执行同一个脚本。

💡 总结

  • 全局变量方法容易被意外覆盖,不安全。
  • Symbol 作为键名可以保证键名不被外部轻易修改,但仍可能被改写。
  • 局部 Symbol可以防止外部修改,但多次执行脚本时每次得到的 Symbol 不同,不过 Node.js 通常会缓存模块执行结果,一般不会影响单例实例的创建。

内置的Symbol值

ES6 还提供了 11个内置的 Symbol 值,指向语言内部使用的方法。

  • Symbol.hasInstance

    指向一个内部方法,对象使用 instanceof 运算符时会调用该方法。如 foo instance Foo 内部实际上是 Foo[Symbol.hasInstance](foo)

    js
    class Event {
      [Symbol.hasInstance] (foo) {
        return foo instanceof Array
      }
    }
    
    [1, 2, 3] instanceof new Event() // true
    {a: 1} instanceof new Event() // false
    
    class OwnNum {
      static [Symbol.hasInstance] (foo) {
        return foo % 2 === 0
      }
    }
    
    1 instanceof new OwnNum() // false
    2 instanceof new OwnNum() // true
  • Symbol.isConcatSpreadable

    该属性是一个布尔值,表示对象或数组在使用 concat 方法时是否可以展开。

    数组默认是 trueundefined ,表示可以展开;若手动修改为 false ,则表示不可展开。

    js
    let arr1 = [1, 2]
    ['a', 'b'].concat(arr1, '开') // ['a', 'b', 1, 2, '开']
    
    let arr2 = [3, 4]
    arr2[Symbol.isConcatSpreadable] = false
    ['c', 'd'].concat(arr2, '不开') // ['c', 'd', [3, 4], '不开']

    对象默认是 false 不可展开,想要展开需要手动设置为 true

    js
    let obj1 = {length: 1, 0: 'a'}
    [1, 2].concat(obj, 'open') // [1, 2, obj, 'open']
    
    let obj2 = {length: 1, 0: 'b'}
    obj2[Symbol.isConcatSpreadable] = true
    [3, 4].concat(obj, 'open') // [3, 4, 'b', 'open']

    对于类而言,Symbol.isConcatSpreadable 属性必须写为类的实例。

    js
    class A extends Array {
      constructor(args) {
        super(args)
        this[Symbol.isConcatSpreadable] = true
      }
    }
    class B extends Array {
      constructor(args) {
        super(args)
        this[Symbol.isConcatSpreadable] = false
      }
    }
    
    let a1 = new A()
    a1[0] = 1
    a1[1] = 2
    let b1 = new B()
    b1[0] = 3
    b1[1] = 4
    ['a', 'b'].concat(a1).concat(b1) // ['a', 'b', 1, 2, [3, 4]]
  • Symbol.species

    Symbol.species 是一个内置的 Symbol 属性,用于定义在执行数组方法时,如何创建新的派生类实例。这个属性允许子类覆盖父类的默认行为,控制返回的实例类型。

    默认情况下,若未定义 Symbol.species,这些方法将返回父类(即调用方法的类)的实例。如果定义了,则返回由 Symbol.species 指定的构造函数的实例。

    js
    class MyArray extends Array {
      // 使用 get 读取器定义 Symbol.species 属性
      static get [Symbol.species]() {
        return Array;
      }
    }
    
    let a = new MyArray(1, 2, 3);
    let mapped = a.map(x => x * x);
    
    console.log(mapped instanceof MyArray); // false
    console.log(mapped instanceof Array);   // true
  • Symbol.match

    对象的 Symbol match 属性指向 个函数 str match myObject )时 ,如果该 属性存在 会调用它返回该方法的返回值。

    js
    String.prototype.match(regexp) //等同于 regexp[Symbol.match](this)
    
    class MyMatcher {
      [Symbol.match] (string) {
        return 'hello world'.indexOf(string)
      }
    }
    
    'e'.match(new MyMatcher()) // 1
    
    class BooleanMatch {
      [Symbol.match] (string) {
        return Boolean(string)
      }
    }
    
    1.match(new BooleanMatch()) // true
  • Symbol.replace

    使用 Symbol.replace 属性可以自定义对象在字符串的 replace 方法中的行为。

    该属性定义的方法会在字符串 replace 方法调用时被调用,接收两个参数:第一个是当前作用的字符串对象,第二个是替换值。

    js
    // 定义一个空对象 x
    const x = {};
    
    // 使用 Symbol.replace 属性定义一个方法
    x[Symbol.replace] = (...s) => console.log(s);
    
    // 调用字符串的 replace 方法,并传入 x 对象作为搜索值
    'Hello'.replace(x, 'World');
    
    // 控制台输出为:[ 'Hello', 'World' ]
  • Symbol.search

    使用 Symbol.search 属性可以自定义对象在字符串的 search 方法中的行为。

    该属性定义的方法会在字符串 search 方法调用时被调用,接收一个字符串参数,表示当前进行搜索的字符串。

    js
    // 定义一个自定义的搜索类 MySearch
    class MySearch {
      constructor(value) {
        this.value = value;
      }
    
      // 使用 Symbol.search 属性定义一个方法
      [Symbol.search](string) {
        return string.indexOf(this.value);
      }
    }
    
    // 调用字符串的 search 方法,并传入 MySearch 类的实例作为正则表达式参数
    console.log('foobar'.search(new MySearch('foo'))); // 输出为 0
  • Symbol.split

    当对象被 String.propertype.split 方法调用时会返回该方法的返回值。

  • Symbol.iterator

  • Symbol.toPrimitive

  • Symbol.toStringTag

  • Symbol.unscopables

总结

Symbol 是ES6引入的第七种数据类型,通过 Symbol 函数生成,具有唯一性,不会与其他属性名冲突,适合用作对象属性名以避免冲突。Symbol 可以接收一个字符串作为描述,但这个描述并不影响 Symbol 的相等性比较。Symbol 值不能参与运算,可以转为布尔值和字符串,但不能转为数值。

Symbol 作为属性名时,不会被常规的属性遍历方法发现,只能通过 Object.getOwnPropertySymbols() 获取。此外,Symbol.for()Symbol.keyFor() 用于全局 Symbol 的创建和查找。Symbol 的这些特性使其在消除魔术字符串、实现Singleton 模式等方面非常有用。

ES6还提供了11个内置的 Symbol 值,用于语言内部的方法,如 Symbol.iteratorSymbol.toStringTag 等,这些内置 Symbol 值影响对象默认行为和内置方法的实现。