阅读 2545

[译] JavaScript 中为什么会有 Symbol 类型?

作为最新的基本类型,Symbol 为 JavaScript 语言带来了很多好处,特别是当其用在对象属性上时。但是,相比较于 String 类型,Symbol 有哪些 String 没有的功能呢?

在深入探讨 Symbol 之前,让我们先看看一些许多开发人员可能都不知道的 JavaScript 特性。

背景

JavaScript 中有两种数据类型:基本数据类型和对象(对象也包括函数),基本数据类型包括简单数据类型,比如 number(从整数到浮点数,从 Infinity 到 NaN 都属于 Number 类型)、boolean、string、undefinednull(注意尽管 typeof null === 'object'null 仍然是一个基本数据类型)。

基本数据类型的值是不可以改变的,即不能更改变量的原始值。当然可以重新对变量进行赋值。例如,代码 let x = 1; x++;,虽然你通过重新赋值改变了变量 x 的值,但是变量的原始值 1 仍没有被改变。

一些语言,比如 C 语言,有按引用传递和按值传递的概念。JavaScript 也有类似的概念,它是根据传递数据的类型推断出来的。如果将值传入一个函数,则在函数中重新对它赋值不会修改它在调用位置的值。但是,如果你修改的是基本数据的值,那么修改后的值在调用它的地方被修改。

考虑下面的例子:

function primitiveMutator(val) {
  val = val + 1;
}

let x = 1;
primitiveMutator(x);
console.log(x); // 1

function objectMutator(val) {
  val.prop = val.prop + 1;
}

let obj = { prop: 1 };
objectMutator(obj);
console.log(obj.prop); // 2
复制代码

基本数据类型(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' }
复制代码

注意:虽然有些离题,但是需要知道的是创建 Map 数据结构的部分原因是为了在键不是字符串的情况下允许键/值方式存储。

Symbol 是什么?

现在既然我们已经知道了基本数据类型是什么,也就终于可以定义 Symbol。Symbol 是不能被重新创建的基本数据类型。在这种情况下,Symbol 类似于对象,因为对象创建多个实例也将导致不完全相等的值。但是,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 作为对象属性

symbols 还有另一个重要的用法,它们可以被当作对象中的键!下面是一个在对象中使用 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']
复制代码

注意,symbols 键不会被在 Object.keys() 返回。这也是为了满足向后兼容性。旧版本的 JavaScript 没有 symbol 数据类型,因此不应该从旧的 Object.keys() 方法中被返回。

乍一看,这就像是可以用 symbols 在对象上创建私有属性!许多其他编程语言可以在其类中有私有属性,而 JavaScript 却遗漏了这种功能,长期以来被视为其语法的一种缺点。

不幸的是,与该对象交互的代码仍然可以访问对象那些键为 symbols 的属性。甚至是在调用代码自己无法访问 symbol 的情况下也有可能发生。 例如,Reflect.ownKeys() 方法能够得到一个对象的所有键的列表,包括字符串和 symbols:

function tryToAddPrivate(obj) {
  obj[Symbol('Pseudo Private')] = 42;
}

const obj = { prop: 'hello' };
tryToAddPrivate(obj);

console.log(Reflect.ownKeys(obj));

console.log(obj[Reflect.ownKeys(obj)[1]]); // 42
复制代码

注意:目前有些工作旨在处理在 JavaScript 中向类添加私有属性的问题。这个特性就是 Private Fields 虽然这不会对所有对象都有好处,但会对类实例的对象有好处。Private Fields 从 Chrome 74 开始可用。

防止属性名冲突

Symbol 类型可能会对获取 JavaScript 中对象的私有属性不利。它们之所以有用的另一个理由是,当不同的库希望向对象添加属性时 symbols 可以避免命名冲突的风险。

如果有两个不同的库希望将某种元数据附加到一个对象上,两者可能都想在对象上设置某种标识符。仅仅使用两个字符串类型的 id 作为键来标识,多个库使用相同键的风险就会很高。

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

function lib2tag(obj) {
  obj.id = 369;
}
复制代码

应用 symbols,每个库都可以通过实例化 Symbol 类生成所需的 symbols。然后不管什么时候,都可以在相应的对象上检查、赋值 symbols 对应的键值。

const library1property = Symbol('lib1');
function lib1tag(obj) {
  obj[library1property] = 42;
}

const library2property = Symbol('lib2');
function lib2tag(obj) {
  obj[library2property] = 369;
}
复制代码

基于这个原因 symbols 确实有益于 JavaScript。

然而,你可能会怀疑,为什么每个库不能在实例化时简单地生成一个随机字符串,或者使用一个特殊的命名空间?

const library1property = uuid(); // 随机方法
function lib1tag(obj) {
  obj[library1property] = 42;
}

const library2property = 'LIB2-NAMESPACE-id'; // namespaced approach
function lib2tag(obj) {
  obj[library2property] = 369;
}
复制代码

你有可能是正确的,上面的两种方法与使用 symbols 的方法很相似。除非两个库使用了相同的属性名,否则不会有冲突的风险。

在这一点上,机灵的读者会指出,这两种方法并不完全相同。具有唯一名称的属性名仍然有一个缺点:它们的键非常容易找到,特别是当运行代码来迭代键或以其他方式序列化对象时。请考虑以下示例:

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 的输出将不包含 symbol 对应的值。为什么会这样?因为仅仅是 JavaScript 支持了 symbols,并不意味着 JSON 规范也改变了!JSON 只允许字符串作为键,而 JavaScript 不会尝试在最终的 JSON 负载中呈现 symbol 属性。

我们可以通过使用 object.defineproperty(),轻松纠正库对象字符串污染 JSON 输出的问题:

const library2property = uuid(); // namespaced approach
function lib2tag(obj) {
  Object.defineProperty(obj, library2property, {
    enumerable: false,
    value: 369
  });
}

const user = {
  name: 'Thomas Hunter II',
  age: 32
};

lib2tag(user);
// '{"name":"Thomas Hunter II","age":32,"f468c902-26ed-4b2e-81d6-5775ae7eec5d":369}
console.log(JSON.stringify(user));
console.log(user[library2property]); // 369
复制代码

通过将字符串键的可枚举描述符设置为 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)); // {}
复制代码

在这一点上,我们几乎重新创建了 symbols。隐藏的字符串属性和 symbols 都对序列化程序隐身。这两种属性都可以使用 Reflect.ownKeys()方法提取,因此实际上并不是私有的。假设我们对字符串属性使用某种命名空间/随机值,那么我们就消除了多个库意外发生命名冲突的风险。

但是,仍然有一个微小的差异。由于字符串是不可变的,Symbol 始终保证是唯一的,因此仍有可能生成相同的字符串并产生冲突。从数学角度来说,意味着 symbols 确实提供了我们无法从字符串中获得的好处。

在 Node.js 中,检查对象时(例如使用 console.log()),如果遇到对象上名为 inspect 的方法,则调用该函数,并将输出表示成对象的日志。可以想象,这种行为并不是每个人都期望的,通常命名为 inspect 的方法经常与用户创建的对象发生冲突。现在有 symbol 可用来实现这个功能,并且可以在 require('util').inspection.custom 中使用。inspect 方法在 Node.js v10 中被废弃,在 v11 中完全被忽略。现在没有人会因为意外改变 inspect 的行为!

模拟私有属性

这里有一个有趣的方法,我们可以使用它来模拟对象上的私有属性。这种方法将利用另一个 JavaScript 的特性:proxy。proxy 本质上是封装了一个对象,并允许我们与该对象进行不同的交互。

proxy 提供了许多方法来拦截对对象执行的操作。我们所感兴趣的是在尝试读取对象的键时,proxy 会有哪些动作。我不会去详细解释 proxy 是如何工作的,如果你想了解更多信息,请查看我们的另一篇文章:JavaScript Object Property Descriptors, Proxies, and Preventing Extension.

我们可以使用 proxy 来谎报对象上可用的属性。在本例中,我们将创建一个 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 中的一个特性破坏了 proxy 的隐私性。此功能不存在于 JavaScript 语言本身,也不适用于其他情况,例如 web 浏览器。这一特性允许在给定 proxy 时获得对底层对象的访问权。以下是一个使用此功能破坏上述私有属性的示例:

const [originalObject] = process
  .binding('util')
  .getProxyDetails(proxy);

const allKeys = Reflect.ownKeys(originalObject);
console.log(allKeys[3]); // Symbol(fav book)
复制代码

我们现在需要修改全局 Reflect 对象,或是修改 util 进程绑定,以防止它们在特定的 node.js 实例中被使用。但那却是一个新世界的大门,如果你想了解其中的奥秘,看看我们的其他博客:Protecting your JavaScript APIs

这篇文章是我和 Thomas Hunter II 一起写的。我在一家名为 Intricsic 的公司工作(顺便说一下,我们正在招聘!),专门编写用于保护 Node.js 应用程序的软件。我们目前有一个产品应用 Least Privilege 模型来保护应用程序。我们的产品主动保护 Node.js 应用程序不受攻击者的攻击,而且非常容易实现。如果你正在寻找保护 Node.js 应用程序的方法,请在 hello@inherin.com 上联系我们。


横幅照片的作者 Chunlea Ju

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

关注下面的标签,发现更多相似文章
评论