详解 JavaScript 为什么要有 Symbol 类型?

作者:函数茶馆

时间:2026-10-23

浏览:625

本文深入解析 JavaScript ES6 引入的 Symbol 类型。通过对比值类型与引用类型,阐述 Symbol 作为唯一标识符在对象属性键中的核心作用。文章详细说明了如何利用 Symbol 避免多库开发时的命名冲突,并探讨其作为非真正私有属性的局限性,包括 JSON 序列化限制及 Reflect.ownKeys 的访问方式。最后结合 Proxy 对象演示模拟私有属性的技巧及其在 Node.js 中的潜在风险。

文章目录

JavaScript Symbol 类型概述

Symbol 是 ES6 引入的一种全新数据类型,它为 JavaScript 带来了显著优势,特别是在处理对象属性时。那么,Symbol 究竟能实现字符串无法完成的任务吗?

在深入探讨 Symbol 之前,我们需要先了解一些 JavaScript 的基础特性,这些特性常被许多开发者忽略。

JavaScript 数据类型背景

背景知识

JavaScript 中的数据类型主要分为两类:值类型和引用类型。

值类型(基本类型)包括:数值型(Number)、字符串型(String)、布尔型(Boolean)、null 以及 undefined。

引用类型(类)包括:函数、对象、数组等。

理解值类型:变量间的赋值意味着开辟新的内存空间,将原变量的值复制一份存入新内存。此后,两个变量的值互不干扰。例如:


var a = 10; //开辟一块内存空间保存变量a的值“10”;
var b = a; //给变量 b 开辟一块新的内存空间,将 a 的值 “10” 赋值一份保存到新的内存里;
//a 和 b 的值以后无论如何变化,都不会影响到对方的值;

部分语言(如 C)区分引用传递和值传递,JavaScript 也遵循类似逻辑,具体取决于传递的数据类型。若传递的是值类型,函数内部重新赋值不会影响调用处的变量;但若修改的是引用类型,调用处的对象也会随之改变。

理解引用类型:变量间的赋值仅是指针(引用地址)的交换,而非复制对象本身。对象在内存中依然只有一个,只是增加了多个指向它的引用。例如:


var a = { x: 1, y: 2 }; //需要开辟内存空间保存对象,变量 a 的值是一个地址,这个地址指向保存对象的空间;
var b = a; // 将a 的指引地址赋值给 b,而并非复制一给对象且新开一块内存空间来保存;
// 这个时候通过 a 来修改对象的属性,则通过 b 来查看属性时对象属性已经发生改变;

除神秘的 NaN 外,值类型之间只要值相同,完全相等(===)即为真,如下所示:


const first = "abc" + "def";
const second = "ab" + "cd" + "ef";
console.log(first === second); // true

然而,即使结构完全相同的引用类型,它们也是不相等的:


const obj1 = { name: "Intrinsic" };
const obj2 = { name: "Intrinsic" };
console.log(obj1 === obj2); // false
// 但是,它们的 .name 属性是基本类型:
console.log(obj1.name === obj2.name); // true

对象在 JavaScript 中至关重要,无处不在。通常用作键值对的集合,但存在一个重大限制:在 Symbol 出现前,对象键只能是字符串。若尝试使用非字符串值作为键,该值会被强制转换为字符串,如下所示:


const obj = {};
obj.foo = 'foo';
obj['bar'] = 'bar';
obj[2] = 2;
obj[{}] = 'someobj';
console.log(obj);
// { '2': 2, foo: 'foo', bar: 'bar',
   '[object Object]': 'someobj' }

Symbol 的定义与特性

Symbol 的定义

Symbol() 函数返回 symbol 类型的值,该类型包含静态属性和静态方法。其静态属性暴露若干内置成员,静态方法暴露全局 symbol 注册表。虽然类似内建对象类,但它不是完整的构造函数,不支持使用 new Symbol() 语法。因此,通过 Symbol 生成的值彼此不相等:


const s1 = Symbol();
const s2 = Symbol();
console.log(s1 === s2); // false

实例化 Symbol 时,可选的第一个参数是字符串,主要用于调试目的,不影响 Symbol 本身的唯一性。


const s1 = Symbol("debug");
const str = "debug";
const s2 = Symbol("xxyy");
console.log(s1 === str); // false
console.log(s1 === s2); // false
console.log(s1); // Symbol(debug)

Symbol 作为对象属性键

Symbol 作为对象属性 Symbol 的另一重要用途是作为对象的键,如下所示:


const obj = {};
const sym = Symbol();
obj[sym] = "foo";
obj.bar = "bar";
console.log(obj); // { bar: 'bar' }
console.log(sym in obj); // true
console.log(obj[sym]); // foo
console.log(Object.keys(obj)); // ['bar']

乍看之下,这似乎允许在对象上创建私有属性。其他编程语言在类中支持私有属性,而 JavaScript 长期缺乏此功能被视为短板。

不幸的是,与该对象交互的代码仍可访问其 Symbol 键属性。即使调用代码无法直接获取 Symbol 本身,这也是可能的。例如,Reflect.ownKeys() 方法能获取对象上所有键的列表,包括字符串和 Symbol:


function tryToAddPrivate(o) {
  o[Symbol("Pseudo Private")] = 42;
}
const obj = { prop: "hello" };
tryToAddPrivate(obj);
console.log(Reflect.ownKeys(obj));
// [ 'prop', Symbol(Pseudo Private) ]
console.log(obj[Reflect.ownKeys(obj)[1]]); // 42

注意:目前 JavaScript 正在推进私有字段(private fields)特性,旨在为类实例添加真正的私有属性。该特性从 Chrome 74 版本开始可用,但并非适用于所有对象。

代码部署后可能存在的 BUG 无法实时获知,事后为解决这些 BUG 需花费大量时间进行日志调试。此处推荐一款好用的 BUG 监控工具 Fundebug。

防止属性名称冲突

防止属性名称冲突

Symbol 虽不能直接为 JavaScript 对象提供真正的私有属性,但它在另一场景下极具价值:当不同库希望向对象添加属性而不引发名称冲突时,Symbol 非常有用。

虽然 Symbol 为 JavaScript 对象提供私有属性仍显困难,但它还有一个好处,即避免不同库向对象添加属性时产生的命名冲突风险。

考虑一种情况:两个不同的库都想向一个对象添加基本数据,例如设置某种标识符。如果简单地使用 id 作为键,存在巨大风险,即多个库可能使用相同的键名导致冲突。


function lib1tag(obj) {
  obj.id = 42;
}
function lib2tag(obj) {
  obj.id = 369;
}

通过使用 Symbol,每个库可以在实例化时生成所需的 Symbol,然后用生成的 Symbol 值作为对象的属性:


const library1property = Symbol("lib1");
function lib1tag(obj) {
  obj[library1property] = 42;
}
const library2property = Symbol("lib2");
function lib2tag(obj) {
  obj[library2property] = 369;
}

因此,Symbol 确实有利于 JavaScript 的发展。

但读者可能会问,为什么每个库在实例化时不直接生成随机字符串或使用命名空间呢?


const library1property = uuid(); // random approach
function lib1tag(obj) {
  obj[library1property] = 42;
}
const library2property = "LIB2-NAMESPACE-id"; // namespaced approach
function lib2tag(obj) {
  obj[library2property] = 369;
}

这种方法并非错误,且与 Symbol 的方法非常相似,除非两个库恰好选择使用相同的属性名,否则不会有冲突风险。然而,聪明的读者会指出,这两种方法并不完全相同。我们使用唯一名称的属性名仍有一个缺点:它们的键非常容易被发现,特别是在运行代码来迭代键或序列化对象时。考虑下面的例子:


const library2property = "LIB2-NAMESPACE-id"; // namespaced
function lib2tag(obj) {
  obj[library2property] = 369;
}
const user = {
  name: "Thomas Hunter II",
  age: 32
};
lib2tag(user);
JSON.stringify(user);
// '{"name":"Thomas Hunter II","age":32,"LIB2-NAMESPACE-id":369}'

如果我们为对象的属性名使用了 Symbol,那么 JSON 输出将不包含它的值。这是为什么呢?虽然 JavaScript 获得了对 Symbol 的支持,但这并不意味着 JSON 规范已经改变!JSON 只允许字符串作为键,JavaScript 不会尝试在最终 JSON 有效负载中表示 Symbol 属性。


const library2property = "f468c902-26ed-4b2e-81d6-5775ae7eec5d"; // namespaced approach
function lib2tag(obj) {
  Object.defineProperty(obj, library2property, {
    enumerable: false,
    value: 369
  });
}
const user = {
  name: "Thomas Hunter II",
  age: 32
};
lib2tag(user);
console.log(user); // {name: "Thomas Hunter II", age: 32, f468c902-26ed-4b2e-81d6-5775ae7eec5d: 369}
console.log(JSON.stringify(user)); // {"name":"Thomas Hunter II","age":32}
console.log(user[library2property]); // 369

通过将 enumerable 属性设置为 false 而“隐藏”的字符串键的行为,与 Symbol 键非常相似。它们通过 Object.keys() 遍历时不可见,但可以通过 Reflect.ownKeys() 显示,如下示例所示:


const obj = {};
obj[Symbol()] = 1;
Object.defineProperty(obj, "foo", {
  enumberable: false,
  value: 2
});
console.log(Object.keys(obj)); // []
console.log(Reflect.ownKeys(obj)); // [ 'foo', Symbol() ]
console.log(JSON.stringify(obj)); // {}

至此,我们几乎重新创建了 Symbol。隐藏的字符串属性和 Symbol 都对序列化器隐藏。这两个属性都可以通过 Reflect.ownKeys() 方法读取,因此它们实际上并非真正的私有。假设我们为属性名的字符串版本使用某种命名空间或随机值,那么我们就消除了多个库意外发生名称冲突的风险。然而,仍有一个微小区别。由于字符串是不可变的,而 Symbol 总是保证唯一,因此仍有可能生成字符串组合会产生冲突。从数学上讲,这意味着 Symbol 确实提供了我们无法从字符串中获得的保证。

Node.js 中的 inspect 与模拟私有属性

在 Node.js 中,检查对象时(例如使用 console.log() ),如果遇到名为 inspect 的对象上的方法,将调用该函数,并将打印内容。可以想象,这种行为并不是每个人都期望的,通常命名为 inspect 的方法经常与用户创建的对象发生冲突。 现在 Symbol 可用来实现这个功能,并且可以在 equire(‘util').inspect.custom 中使用。inspect 方法在 Node.js v10 中被废弃,在 v1 1 中完全被忽略, 现在没有人会偶然改变检查的行为。 模拟私有属性

这里有一种有趣的方法,可用于模拟对象上的私有属性。这种方法将利用另一个 JavaScript 特性:Proxy(代理)。代理本质上封装了一个对象,并允许我们对与该对象的各种操作进行干预。代理提供了许多方法来拦截在对象上执行的操作。我们可以使用代理来说明我们的对象上可用的属性,在这种情况下,我们将制作一个隐藏我们两个已知隐藏属性的代理,一个是字符串 _favColor,另一个是分配给 favBook 的 Symbol:


let proxy;

{
  const favBook = Symbol("fav book");

  const obj = {
    name: "Thomas Hunter II",
    age: 32,
    _favColor: "blue",
    [favBook]: "Metro 2033",
    [Symbol("visible")]: "foo"
  };

  const handler = {
    ownKeys: target => {
      const reportedKeys = [];
      const actualKeys = Reflect.ownKeys(target);

      for (const key of actualKeys) {
        if (key === favBook || key === "_favColor") {
          continue;
        }
        reportedKeys.push(key);
      }

      return reportedKeys;
    }
  };

  proxy = new Proxy(obj, handler);
}

console.log(Object.keys(proxy)); // [ 'name', 'age' ]
console.log(Reflect.ownKeys(proxy)); // [ 'name', 'age', Symbol(visible) ]
console.log(Object.getOwnPropertyNames(proxy)); // [ 'name', 'age' ]
console.log(Object.getOwnPropertySymbols(proxy)); // [Symbol(visible)]
console.log(proxy._favColor); // 'blue'

使用 _favColor 字符串很简单:只需阅读库的源代码即可。另外,通过蛮力找到动态键(例如前面的 uuid 示例)。但是,如果没有对 Symbol 的直接引用,任何人都不能从 Proxy 对象访问 'Metro 2033' 值。

Node.js 警告:Node.js 中有一个功能会破坏代理的隐私。JavaScript 语言本身不存在此功能,并且不适用于其他情况,例如 Web 浏览器。它允许在给定代理时获得对底层对象的访问权。以下是使用此功能打破上述私有属性示例的示例:


const [originalObject] = process.binding("util").getProxyDetails(proxy);
const allKeys = Reflect.ownKeys(originalObject);
console.log(allKeys[3]); // Symbol(fav book)

现在,我们需要修改全局 Reflect 对象,或者修改 util 流程绑定,以防止它们在特定的 Node.js 实例中使用。

以上所述是小编给大家介绍的 JavaScript 为什么要有 Symbol 类型详解整合,希望对大家有所帮助。如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对本站网站的支持!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章