为什么需要Symbol数据类型

335 阅读18分钟

为什么需要Symbol?

你是否遇到过这样的场景?在系统中有多个页面都需要获取登录人的信息,但是你不想每个页面都请求,一般都会在进入系统后请求一次登录人信息接口,然后缓存登录人的信息。

const getLoginUserInfo = async () => {
  const res = await fetch(url.getLoginUserInfo)
  window.userInfo = res;
}
​
getLoginUserInfo();

但是这样是否安全哪?假设团队的小伙伴不知道这个逻辑,他在一个子系统中也遇到了多个页面使用管理员信息,那么他的做法也是类似上面

const getAdminUserInfo = async () => {
  const res = await fetch(url.getAdminUserInfo)
  window.userInfo = res;
}
​
getAdminUserInfo();

此时当访问这个子系统后,其他子系统拿到的就不是登录人员信息,而是管理员信息,其他系统都会出现人员信息错误,造成严重的问题。

这个场景抽象成一个问题:假设需要在一个公共对象上添加方法或者属性,如何保证方法或者属性值不被更改?

要实现上述的功能有四种方案:

  1. 使用Object.freeze()冻结对象
  2. 使用Object.seal()密封对象
  3. 使用Object.definePropery()设置对象的属性
  4. 使用Symbol定义对象的属性

那么这些方案有什么区别哪?

Object.freeze()

Object.freeze()是JavaScript中的一个对象方法,可以用于冻结一个对象,使其属性不能被添加、删除或修改。

使用Object.freeze()方法冻结一个对象后,该对象的属性将变得只读,不能再被修改。例如:

const obj = {
  prop1: 'value1',
  prop2: 'value2'
};
​
Object.freeze(obj);obj.prop1 = 'new value'; // 此次赋值无效,因为obj已被冻结
​
console.log(obj.prop1); // 输出'value1'

在上面的例子中,obj对象被冻结后,尝试对obj.prop1重新赋值,但是赋值无效,因为obj已被冻结。最后输出obj.prop1的值,结果为'value1'。

需要注意的是,即使一个对象被冻结,它的属性也可以被通过prototype链继承的prototype上的setter函数更改。因此,如果需要完全保护一个对象的属性,可以使用Object.seal()方法或Object.defineProperty()方法来禁止向原型链上添加或删除属性。

总之,Object.freeze()方法能够有效地保护JavaScript对象,防止它们的属性被意外修改。

Object.seal()

Object.seal()是JavaScript中的一个对象方法,可以用于密封(seal)一个对象,密封后的对象不能被添加新属性,不能删除已有属性,但是可以修改已有属性的值。

使用Object.seal()方法密封一个对象后,该对象的属性将不能够被添加或删除,但是可以修改已有属性的值。例如:

const obj = {
  prop1: 'value1',
  prop2: 'value2'
};
​
Object.seal(obj);obj.prop1 = 'new value'; // 此次赋值有效
obj.prop3 = 'value3'; // 此次添加属性无效,因为obj已被密封
delete obj.prop2; // 此次删除属性无效,因为obj已被密封
​
console.log(obj); // 输出 { prop1: 'new value', prop2: 'value2' }

在上面的例子中,obj对象被密封后,尝试对obj.prop1重新赋值是有效的。但是尝试添加属性(prop3)或者删除属性(prop2),操作无效。最后输出obj对象,结果为{ prop1: 'new value', prop2: 'value2' },可以看出密封了obj对象后,属性(prop1, prop2)的值发生了变化,属性(prop3)是无效的。

需要注意的是,密封一个对象并不会影响其属性的可枚举性(enumerability)。在上面的例子中,虽然obj被密封,但是它的属性prop1和prop2都可以被枚举。如果需要禁止属性的枚举,可以使用Object.defineProperty()方法来定义属性时设置enumerable属性为false。

Object.defineProperty()

Object.defineProperty() 是 JavaScript 对象中的一个方法,它可以定义一个新属性或者修改一个已有属性的特性。这个方法通常被用来实现对象的数据绑定和对象的保护。

该方法可以被调用来给一个已存在的对象添加一个新属性,或修改一个已有属性的特性,比如修改属性的值、可枚举性、可写性等。

要定义一个不可修改的属性,可以使用 Object.defineProperty() 方法,并将可写性 (writable) 和可配置性 (configurable) 设置为 false。

例如:

const obj = {};
​
Object.defineProperty(obj, 'prop', {
  value: 42,
  writable: false,
  enumerable: true,
  configurable: false
});
​
console.log(obj.prop); // 输出 42
​
obj.prop = 65; // 什么也不做,因为该属性是不可写的console.log(obj.prop); // 输出 42delete obj.prop; // 什么也不做,因为该属性是不可删除的console.log(obj.prop); // 输出 42

在上面的例子中,我们定义了一个名为 prop 的属性,并将其可写性和可配置性都设置为 false。这意味着该属性不可修改或删除。

当我们试图修改该属性时,就会被忽略。这也就是为什么上面的代码中 obj.prop 的值没有被修改为 65。

同样,当我们试图删除该属性时,也会被忽略。这也就是为什么上面代码中 obj.prop 仍然存在并输出 42。

总之,如果想要定义一个不可修改的属性,需要将其可写性和可配置性都设置为 false。这样做可以保护对象中重要的属性免受意外修改或删除的影响。

如果要一次定义多个属性,可以使用Object.defineProperties() 方法:

Object.defineProperties(obj, {
  prop1: {
    value: 42,
    writable: true
  },
  prop2: {
    value: "hello",
    writable: false
  }
});
Symbol

Symbol 是 ES6 中新增的一种基本数据类型,用于表示独一无二的值。

Symbol 的创建方式非常简单,只需要调用全局的 Symbol() 函数即可,例如:

const mySymbol = Symbol();

Symbol 通常用于定义对象的属性名,因为每个 Symbol 都是独一无二的,这可以保证对象的属性名是唯一的,例如:

const mySymbol = Symbol();
const obj = {};
​
obj[mySymbol] = 'hello';
​
console.log(obj[mySymbol]); // 输出 "hello"

上面的代码中,我们使用了一个 Symbol 作为对象的属性名,并将其赋值为 "hello"。当我们读取这个属性的值时,需要使用相同的 Symbol 作为键,才能获取到它的值。

当然,Symbol 也可以接受一个可选的字符串作为参数,用于描述这个 Symbol 的作用,例如:

const mySymbol = Symbol('my description');

这可以帮助开发者更好地理解这个 Symbol 的作用,但并不影响它的独一无二性。

除此之外,Symbol 还有一些内置属性,例如 Symbol.iterator、Symbol.toStringTag 等,它们可以用于重写对象的默认行为,从而实现自定义的函数和属性。

总之,Symbol 是一个非常有用的数据类型,可以帮助我们更好地实现对象的属性命名和自定义行为。

总结上述方案的优缺点
方案优点缺点
Object.freeze()可以保护对象不被篡改,阻止属性添加、属性删除、属性值的修改、原型的修改等操作。只能一次性地冻结整个对象,无法针对对象的某个属性进行冻结。冻结对象是浅冻结,对于对象属性的属性值如果是对象,仍然可以对其进行修改。冻结的是对象本身,而不是对象属性的属性值。
Object.seal()防止向对象添加新属性和删除某些属性。它允许修改属性的值,但是不允许修改属性的特性,例如属性值的读写性、可枚举性、可配置性等。无法深度密封对象,在除去某些属性之外,其他属性仍然可以被修改。换句话说,它对于对象属性值是对象的情况仍然无法保证其不被修改。只能密封整个对象,无法针对对象的某个属性进行密封。
Object.defineProperty()用于修改对象的属性特性,包括属性值的读写性、可枚举性、可配置性等。可以使用它实现数据双向绑定等功能。只能修改一个属性,无法同时修改对象多个属性的特性。修改属性特性需要进行较为繁琐的调用,需要多次设置各个特性,如果需要添加多个属性,会显得非常冗长。会使代码变得难以理解和维护,因为通过该方法设置的特性并不会在对象的原型链上继承,所以代码的行为难以预测。
Symbol可以解决对象属性名重复的问题,防止属性值被意外覆盖。使用比较灵活简单。Symbol 定义的属性不会被 for...in 语句和 Object.keys() 等方法枚举,使得对象属性不如普通属性易于遍历和查找,增加了代码的复杂度和维护难度。Symbol 定义的属性无法使用 JSON.stringify() 方法序列化,因为该方法只会序列化对象的可枚举属性,Symbol 属性无法被序列化。Symbol 定义的属性虽然避免了属性名冲突的问题,但也同时带来了属性越来越难以被访问的问题。如果在一个对象中使用过多的 Symbol 属性,会使得代码变得难以理解和维护。

具体使用哪一个,要根据优缺点综合判断。

再回到上面的问题:为什么需要Symbol?

假设没有Symbol,解决假设需要在一个公共对象上添加方法或者属性,如何保证方法或者属性值不被更改? 问题,只能使用前三种方案,但是这三种方案并不能满足需求。第一种和第二种会导致整个对象没有办法再次添加属性值,所以不太合适。第三种方案可以实现,但是有一个问题,不知道这个属性名重复了,需要添加后不能用才能发现,如果属性比较多的话,则需要思考一个新的不重复的属性名需要比较长的时间。所以为了解决这个问题,出现了Symbol这种数据结构,不需要考虑原来的属性值,只要使用Symbol创建的属性就是唯一的。

Symbol的使用

首先了解一下ES7官方文档中Symbol的定义

The Symbol Type

The Symbol type is the set of all non-String values that may be used as the key of an Object property (6.1.7).

Each possible Symbol value is unique and immutable.

Each Symbol value immutably holds an associated value called [[Description]] that is either undefined or a String value

翻译一下:

Symbol类型常作为对象的非字符串类型键的集合。

每个可能是Symbol类型的值都是唯一且不可变的。

每个不可变地Symbol值包含一个名为[[Description]]的关联值,该值要么是未定义的,要么是字符串值。

Symbol的常见用法

下面是Symbol的常见用法示例:

// 声明Symbol的值
const sym1 = Symbol();
const sym2 = Symbol(1);
const sym3 = Symbol('symbol');
const sym4 = Symbol(true);
const sym5 = Symbol(null);
​
// true
console.log(sym1 === sym1);
// true
console.log(sym2 === sym2);
// true
console.log(sym3 === sym3);
// true
console.log(sym4 === sym4);
// true
console.log(sym5 === sym5);
​
// false
console.log(sym1 === Symbol());
// false
console.log(sym2 === Symbol(1));
// false
console.log(sym3 === Symbol('symbol'));
// false
console.log(sym4 === Symbol(true));
// false
console.log(sym5 === Symbol(null));
​
const obj = {
  [Symbol('userInfo')]: {
    name: 'symbol'
  }
}
​
obj[Symbol('userInfo')] = {
  name: 'symbol2'
}
​
// {
//  [Symbol(userInfo)]: { name: 'symbol' },
//  [Symbol(userInfo)]: { name: 'symbol2' }
// }
console.log(obj);
全局 symbol 注册表

全局 symbol 注册表是在 JavaScript 引擎内部维护的一个表格,用于存储全局可见且唯一的 symbol。这个表格中存储了注册到全局的 symbol 以及对应的 symbol 描述字符串 key,是实现全局共享 symbol 的重要机制之一。

在全局共享 symbol 的场合,如果多个文件或作用域中需要使用相同描述字符串的 symbol,可以使用 Symbol.for() 方法来创建或获取已经存在的 symbol。而这些 symbol 会被自动注册到全局 symbol 注册表中,以便在需要时进行访问和共享。

需要注意的是,在使用 Symbol.for() 方法创建 symbol 时,全局 symbol 注册表会根据提供的描述字符串 key 来检查是否已经存在对应的 symbol。如果存在,则返回对应的 symbol。否则,会创建一个新的 symbol,并在全局 symbol 注册表中注册新创建的 symbol。

例如:

// 在全局共享的情况下创建 symbol
let symbol1 = Symbol.for('foo');
​
// 在另一个文件中获取已经存在的 symbol
let symbol2 = Symbol.for('foo');
​
console.log(symbol1 === symbol2); // true

在该例子中,使用 Symbol.for() 方法创建了 symbol1,该 symbol 在全局注册表中被注册。在另一个文件中,使用 Symbol.for() 方法并提供同样描述字符串来获取 symbol2,实际上获得的是已经存在的 symbol1。

Symbol.keyFor() 方法返回一个已经在全局 symbol 注册表中的 symbol 的字符串 key。如果提供的 symbol 不在全局 symbol 注册表中,则返回 undefined。

例如:

let symbol1 = Symbol.for('foo');
console.log(Symbol.keyFor(symbol1)); // 'foo'let symbol2 = Symbol('bar');
console.log(Symbol.keyFor(symbol2)); // undefined

需要注意的是,使用 Symbol.for() 方法创建的 symbol 可以在全局注册表中被检索和访问,因此可以使用 Symbol.keyFor() 方法获取它的 key。但使用 Symbol() 方法创建的 symbol 不会被注册到全局注册表中,因此无法使用 Symbol.keyFor() 方法获取它的 key。

Symbol.for()Symbol.keyFor() 两个方法组合使用,可以实现在不同的文件或作用域内共享 symbol,并且能够方便地获取到已经被注册的 symbol 的 key。但需要注意的是,在进行全局共享时,要避免不同含义的 symbol 字符串 key 相同被创建,以免产生不必要的错误。

总之,全局 symbol 注册表是实现 symbol 全局共享和访问的重要机制,但也需要在操作时避免不同含义的 symbol 字符串 key 相同被创建的情况,以避免错误的发生。

Symbol为什么不能使用new?

Symbol使用时看着像是包装类,包装类可以使用new创建,但是为什么Symbol使用new时会报错?例如:

const num = new Number(1);
const str = new String('string');
const sym = new Symbol(); // Uncaught TypeError: Symbol is not a constructor

因为Symbol没有包装类,Symbol使用时是调用的Symbol工厂函数,返回的结果是一个基础数据类型。

因为Symbol没有包装类,所以不会进行自动封装或者解封。

// 因为String可以自动封装,可以从基础数据类型转化为对象实例,所以可以调用String对象原型上的方法
const str = 'hello';
console.log(str.length); // 5
console.log(str.toUpperCase()); // HELLO

为什么要使用函数调用来创建 Symbol 类型的值呢?

Symbol 构造函数是 JavaScript 内置的构造函数之一,它是由 JavaScript 引擎实现的,其内部实现是无法完全模拟的。但是,我们可以从 ECMAScript 规范中了解到大致的实现过程。

根据规范,Symbol() 构造函数接受一个可选的参数 description,用于为创建的 Symbol 值指定描述信息。Symbol() 构造函数返回一个新的 Symbol 值,每个 Symbol 值都是唯一的。

在具体实现中,可以使用以下过程来模拟 Symbol() 构造函数的内部实现:

  1. 创建一个新的 Symbol 值,通过 SymbolDescriptiveString 构造函数创建一个带有描述信息的 Symbol 类型的值。
  2. 对于相同的描述信息,返回同一实例的 Symbol 类型的值。
  3. 将创建的 Symbol 值的 [[Description]] 内部属性设置为参数 description 的值。

下面是一个简单的示例代码,用于模拟 Symbol() 构造函数的内部实现:

// 定义一个计数器
let count = 0;
​
// 定义 SymbolDescriptiveString 函数
function SymbolDescriptiveString(description) {
  this.count = count++;
  this.description = description;
  this.toString = function() { return `Symbol(${this.description})`; }
}
​
// 定义 MySymbol 函数
function MySymbol(description) {
  if (this instanceof MySymbol)
    throw new TypeError('Symbol is not a constructor');
​
  // 如果相同的描述信息已经存在,返回已有的 MySymbol
  for (let symbol of MySymbol.symbols) {
    if (symbol.description === description) {
      return symbol;
    }
  }
​
  // 创建新的 MySymbol 值
  let sym = new SymbolDescriptiveString(description);
​
  // 添加到 MySymbol 集合中
  MySymbol.symbols.push(sym);
​
  // 返回新创建的 MySymbol 值
  return sym;
}
​
// 定义 symbols 属性,用于存储已有的 MySymbol 值
MySymbol.symbols = [];
​
// 设置 MySymbol.prototype 属性
MySymbol.prototype.toString = function() {
  return `Symbol(${this.description})`;
};
​
// 创建 Symbol 工厂函数
const Symbol = function (str) {
  const sym = new MySymbol(str)
  return sym;
}
​
// 生成新的 Symbol 值
let sym1 = Symbol('foo');
let sym2 = Symbol('bar');
let sym3 = Symbol('foo');
​
// 输出 Symbol 值
console.log(sym1); // Symbol(foo)
console.log(sym2); // Symbol(bar)
console.log(sym3); // Symbol(foo)

需要注意的是,这只是一个简单的模拟示例,用于说明 Symbol() 构造函数的实现原理,而非 Symbol() 实际的内部实现。在实际的 JavaScript 引擎中,Symbol() 构造函数的实现可能会更加高效和优化。

从上述模拟Symbol可以大致了解Symbol的实现,Symbol实际上是一个对象,这个对象只是使用起来像一个基础数据类型。

const sym1 = Symbol();
const sym2 = sym1;
Object.is(sym1, sym2) // true

JS内置对象的Symbol属性

为什么JS内置对象设置Symbol属性

JavaScript 内置对象设置 Symbol 属性的目的是为了在对象的默认行为和内部属性方面提供更大的自定义和扩展性。由于 Symbol 属性是唯一的和不可变的,所以它们可以用作对象的私有属性或标识符,避免重复和命名冲突,同时也可以提供更好的安全性。

例如,Symbol.iterator 属性表示对象是否为可迭代的。当对象需要被遍历时,它会被 for...of 循环使用。而使用 Symbol.iterator 可以自定义对象的迭代行为,使它成为可迭代对象,提供更大的灵活性和可扩展性。

另一个例子是 Symbol.toPrimitive 属性,它用于将一个对象转换为原始类型。当对象在一些表达式中需要被转换为原始类型,如加法运算或字符串拼接时,JavaScript 会调用对象的 Symbol.toPrimitive 方法来执行转换。使用 Symbol.toPrimitive 可以自定义对象的转换行为,使其更符合我们期望的行为,提高了灵活性和自定义能力。

总之,使用 Symbol 属性可以在不改变对象的原有结构的情况下,为对象提供更大的自定义和扩展性,使程序更加健壮,同时也提高了程序的可读性、可维护性和可拓展性。

JS内置对象有哪些Symbol属性

以下是 JS 内置对象常用的 Symbol 属性列表:

  • Symbol.iterator:指向一个方法,该方法返回对象的默认迭代器,用于 for...of 循环。
  • Symbol.toPrimitive:指向一个方法,用于对象转换为原始值时的行为。
  • Symbol.toStringTag:指向一个字符串,用于 Object.prototype.toString 中的输出,在精细调试时非常有用。
  • Symbol.hasInstance:指向一个方法,用于判断一个对象是不是某个构造函数的实例。
  • Symbol.match:指向一个方法,用于正则表达式的匹配操作。
  • Symbol.replace:指向一个方法,用于正则表达式的替换操作。
  • Symbol.search:指向一个方法,用于正则表达式的搜索操作。
  • Symbol.species:指向一个构造函数,用于创建派生对象时使用的构造函数。
  • Symbol.isConcatSpreadable:指向一个布尔值,用于数组的 concat 方法,用于指示该数组是否应该扁平化。
  • Symbol.unscopables:指向一个对象,用于指示那些属性应该被 with 语句排除。

注意,这只是其中一部分常用的 Symbol 属性,不同的 JS 内置对象可能有不同的 Symbol 属性,也可以通过自定义属性来扩展其功能。ES7中的Symbol属性

如何使用JS内置对象的Symbol属性

要使用 JS 内置对象的 Symbol 属性,只需通过对象的静态方法方式进行访问即可。例如,假设你想自定义一个数组对象的迭代器行为,那么可以使用 Array 内置对象的 Symbol.iterator 属性来实现:

const myArray = [1, 2, 3];
myArray[Symbol.iterator] = function() {
  let index = 0;
  return {
    next: function() {
      if (index < myArray.length) {
        return { value: myArray[index++], done: false };
      } else {
        return { done: true };
      }
    }
  }
}
​
for (const value of myArray) {
  console.log(value);
}
// 输出:1 2 3

在上面的例子中,我们通过 Symbol.iterator 属性重写了 myArray 数组的迭代行为,使其成为一个可迭代对象,可以被 for...of 循环遍历。这里 Symbol.iterator 属性返回了一个对象,该对象有一个 next() 方法,每次返回可迭代对象中的一个值,在迭代完成之后返回 { done: true }。

Symbol.toStringTag 属性通常用于自定义对象在输出时的标识。当使用 Object.prototype.toString 方法输出一个对象时,如果对象有 Symbol.toStringTag 属性,则会自动调用该属性的值作为输出的前缀标识。

以下是一个使用 Symbol.toStringTag 的例子:

const myObj = {};
myObj[Symbol.toStringTag] = "MyObject";
​
console.log(Object.prototype.toString.call(myObj)); // 输出 [object MyObject]

在上面的例子中,我们首先创建了一个空对象 myObj,并设置了它的 Symbol.toStringTag 属性,使其被识别为 "MyObject"。接着,我们通过 Object.prototype.toString 方法输出该对象,并得到了 "[object MyObject]" 的输出结果。

这个例子中没有涉及任何实质的功能实现,但在某些情况下,在对象输出时使用 Symbol.toStringTag 属性可以提高可读性和语义化,方便后续的代码维护和调试。

类似的,你可以使用其他内置对象上的 Symbol 属性来自定义其行为,实现更加灵活的功能和自定义规则。

其他

Object.defineProperty()实现数据双向绑定

Object.defineProperty() 是 JavaScript 中用于设置对象属性的方法之一,可以通过其设置对象属性的值,或者拦截某些对象属性在读取或赋值时的操作。

基于 Object.defineProperty(),可以实现数据双向绑定,即当一个数据对象更新时,对应的视图会随之更新,而当视图发生变化时,对应的数据对象也会随之更新。

具体实现如下:

// 定义数据对象
let data = {message: 'Hello, world!'};
​
// 定义对应视图
let input = document.querySelector('input');
input.value = data.message;
​
// 在数据对象中添加访问器属性
Object.defineProperty(data, 'message', {
  get: function () {
    return this._message;
  },
  set: function (value) {
    this._message = value;
    input.value = value;
  }
});
​
// 监听 input 的变化,当 input 变化时,数据对象也会变化
input.addEventListener('input', function () {
  data.message = input.value;
});

在这个例子中,首先定义了一个数据对象 data,并定义了对应的视图,即一个输入框 input,并将 input 的值初始化为数据对象中的属性值。接着,通过 Object.defineProperty() 方法定义了一个名为 message 的访问器属性,并在获取时返回存储的 _message 属性值,在设置时将输入框的值更新为对应的属性值。最后用事件监听器来监听输入框的变化事件,当输入框的值发生变化时,数据对象中的属性也会相应地更新。

这样,当数据对象中的属性值更新时,对应的视图也会自动更新,而当视图改变时,数据对象中的属性值也会自动更新,从而实现了双向数据绑定。

参考资料