[译] 探索 JavaScript Symbol

292 阅读8分钟

本文将带你深入了解 JavaScript Symbol - 它是什么、为什么重要以及如何有效使用它。

我还记得第一次在 JavaScript 中遇到 Symbol 的情景,那是 2015 年,和许多开发人员一样,我当时想:“好吧,又多了一个需要学习的基础类型”。

image-20241207073835883

但随着我在前端开发领域中的不断成长,我开始欣赏这个古怪、小巧的基础类型,它能以字符串和数字无法比拟的方式解决一些有趣的问题。

Symbol 和其他基础类型不同,因为它保证了唯一性。

当你使用 Symbol 创建了一个如 Symbol('description') 的变量时,你将得到一个永远不会与其他任何 Symbol 相等的值,即使你使用相同的参数创建 Symbol 也不会相等。这种唯一性使它在特定场景下非常强大。

const symbol1 = Symbol('description');
const symbol2 = Symbol('description');

console.log(symbol1 === symbol2); // false

在和对象结合使用时,Symbol 的真正威力就显现出来了。与字符串或数字不同的是,Symbol 用作属性 key 时不会与现有属性发生冲突。因此,使用 Symbol 给对象添加属性非常有用,它不会影响现有的代码。

const metadata = Symbol('elementMetadata');

function attachMetadata(element, data) {
  element[metadata] = data;
  return element;
}

const div = document.createElement('div');
const divWithMetadata = attachMetadata(div, { lastUpdated: Date.now() });
console.log(divWithMetadata[metadata]); // { lastUpdated: 1684244400000 }

使用 Symbol 作为属性 key 时,它不会出现在 Object.keys()for...in 循环中。

const nameKey = Symbol('name');
const person = {
  [nameKey]: 'Alex',
  city: 'London'
};

// 常规枚举不会显示 Symbol 属性
console.log(Object.keys(person));     // ['city']
console.log(Object.entries(person));  // [['city', 'London']]

for (let key in person) {
  console.log(key);                   // Only logs: 'city'
}

//  但我们仍然可以访问 Symbol 属性
console.log(Object.getOwnPropertySymbols(person));  // [Symbol(name)]
console.log(person[nameKey]);         // 'Alex'

你仍然可以通过 Object.getOwnPropertySymbols() 访问这些属性,但这需要在常规遍历之外进行操作,这样就自然地分离对象的公共接口和内部状态。

全局 Symbol 声明让你可以以另外一种方式使用 Symbol。一般的 Symbol 总是唯一的,但有时你需要在代码的不同地方共享 Symbol,这时 Symbol.for() 就可以发挥作用:

// 使用 Symbol.for() 实现在不同模块中共享 Symbol
const PRIORITY_LEVEL = Symbol.for('priority');
const PROCESS_MESSAGE = Symbol.for('processMessage');

function createMessage(content, priority = 1) {
  const message = {
    content,
    [PRIORITY_LEVEL]: priority,
    [PROCESS_MESSAGE]() {
      return `Processing: ${this.content} (Priority: ${this[PRIORITY_LEVEL]})`;
    }
  };

  return message;
}

function processMessage(message) {
  if (message[PROCESS_MESSAGE]) {
    return message[PROCESS_MESSAGE]();
  }
  throw new Error('Invalid message format');
}

// 用法
const msg = createMessage('Hello World', 2);
console.log(processMessage(msg)); // "Processing: Hello World (Priority: 2)"

// 全局 Symbol 是共享的
console.log(Symbol.for('processMessage') === PROCESS_MESSAGE); // true

// 一般的 Symbol 是独立的
console.log(Symbol('processMessage') === Symbol('processMessage')); // false

对象字面量中的括号 [] 允许我们将 Symbol 作为属性 key。

JavaScript 提供了一些内置的 Symbol,让你可以修改对象在不同情况下的行为方式。这些 Symbol 被称为预定义 Symbol,它们提供了修改核心语言功能的钩子。

一个常见的例子是使用 Symbol.iterator 使对象可迭代。这样,我们就可以对自己的对象使用 for...of 循环,就像对数组一样:

// 使用 Symbol.iterator 使对象可迭代
const tasks = {
  items: ['write code', 'review PR', 'fix bugs'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
};

// 现在我们可以使用 for...of
for (let task of tasks) {
  console.log(task); // 'write code', 'review PR', 'fix bugs'
}

另一个功能强大的 Symbol 是 Symbol.toPrimitive,它可以让我们控制对象如何转换为数字或字符串等原始值。当对象需要进行不同类型的操作时,这就变得非常有用:

const user = {
  name: 'Alex',
  score: 42,
  [Symbol.toPrimitive](hint) {
    // JavaScript 通过 “hint” 参数告诉我们它想要的类型
    // hint 可以是: 'number', 'string', 'default'
    switch (hint) {
      case 'number':
        return this.score;  // 当 JavaScript 需要数值 (like +user)
      case 'string':
        return this.name;  // 当 JavaScript 需要字符串 (like `${user}`)
      default:
        return `${this.name} (${this.score})`; // 其他操作 (like user + '')
    }
  }
};

// JavaScript 使用到这些转换的例子:
console.log(+user);      // + 操作符想要数字, 输出 42
console.log(`${user}`);  // 模板字符串想要字符串类型, 输出 "Alex"
console.log(user + '');  // + 配上字符串使用默认行为, 输出 "Alex (42)"

通过 Symbol.toPrimitive,我们可以控制对象如何转换为不同的类型。JavaScript 通过“hint”参数告诉我们它想要的类型。

使用 Symbol.species 来控制继承行为

在 JavaScript 中使用数组时,我们有时需要限制数组值的类型。我们可以使用特殊的数组来实现这个功能,但使用 map()filter() 等方法时,它们可能会导致意想不到的行为。

一个普通的 JavaScript 数组,可以保存任何类型的值:

// 普通数据 - 接受任何值
const regularArray = [1, "hello", true];
regularArray.push(42);       // ✅ 正常
regularArray.push("world");  // ✅ 正常
regularArray.push({});       // ✅ 正常

具有特殊规则或行为的数组,例如只接受特定类型的值:

// 专用数组 - 只支持数字
const createNumberArray = (...numbers) => {
  const array = [...numbers];

  // push 方法只接受数字
  array.push = function(item) {
    if (typeof item !== 'number') {
      throw new Error('Only numbers allowed');
    }
    return Array.prototype.push.call(this, item);
  };

  return array;
};

const numberArray = createNumberArray(1, 2, 3);
numberArray.push(4);     // ✅ 正常
numberArray.push("5");   // ❌ 报错: Only numbers allowed

可以这样想:普通数组就像一个打开的盒子,可以接受任何东西,而专用数组就像一个投币口,只接受特定的硬币(这里是数字)。

Symbol.species 解决的问题是:当你在一个专用数组上使用 map() 等方法时,你希望返回的数组也是专用的,还是只是普通的?

// 专用数组 - 只支持数字
class NumberArray extends Array {
  push(...items) {
    items.forEach(item => {
      if (typeof item !== 'number') {
        throw new Error('Only numbers allowed');
      }
    });
    return super.push(...items);
  }

  // 其他数组方法也可以受到类似限制
}

// 测试 NumberArray
const nums = new NumberArray(1, 2, 3);
nums.push(4);     // 正常 ✅
nums.push('5');   // 报错 ❌ "Only numbers allowed"

// 当我们使用 map 映射这个数组时,限制条件会继续存在,因为
// 结果也是一个 NumberArray 实例
const doubled = nums.map(x => x * 2);
doubled.push('6'); // 报错! ❌ 依然只能是数字

console.log(doubled instanceof NumberArray); // true

我们可以通过告诉 JavaScript 使用常规数组进行派生操作来解决这个问题。下面是 Symbol.species 如何解决这个问题:

class NumberArray extends Array {
  push(...items) {
    items.forEach(item => {
      if (typeof item !== 'number') {
        throw new Error('Only numbers allowed');
      }
    });
    return super.push(...items);
  }

  // 告诉 JavaScript 在进行 map() 等操作时使用普通数组
  static get [Symbol.species]() {
    return Array;
  }
}

const nums = new NumberArray(1, 2, 3);
nums.push(4);     // 正常 ✅
nums.push('5');   // 报错! ❌ (只能是数字)

const doubled = nums.map(x => x * 2);
doubled.push('6'); // 正常! ✅ (doubled 是个普通数组)

console.log(doubled instanceof NumberArray); // false
console.log(doubled instanceof Array);       // true

Symbol.species 解决了继承时的意外问题。原始数组保持专用,但派生数组(来自 map、filter 等)变为常规数组。

注意:Symbol.species 正在被讨论是否能从 JavaScript 中删除。

Symbol 的局限性和缺陷

使用 Symbol 并不总是那么简单,在尝试和 JSON 一起使用时会出现一个常见的困惑。在 JSON 序列化过程中,Symbol 属性会完全消失:

const API_KEY = Symbol('apiKey');

// 使用 Symbol 作为属性 key
const userData = {
 [API_KEY]: 'abc123xyz',  // 使用 Symbol 隐藏 API KEY
 username: 'alex'         // 正常属性,任何人都可以访问
};

// 稍后,我们可以使用保存的 Symbol 访问 API KEY
console.log(userData[API_KEY]); // 输出: 'abc123xyz'

// 但当我们保存为 JSON 字符串时,它仍然消失了
const savedData = JSON.stringify(userData);
console.log(savedData);  // 只输出了: {"username":"alex"}

把 JSON 想象成一台看不到 Symbol 的复印机,在复制(stringify)时,用 Symbol 作为 key 的任何内容都会变得不可见,一旦不可见,就无法在生成新对象(parse)时将其恢复。

Symbol 的字符串强制转换会产生另一个常见陷阱,虽然你可能希望 Symbol 能像其他基础类型一样工作,但它们对类型转换有严格的规定:

const label = Symbol('myLabel');

// 这将抛出错误
console.log(label + ' is my label'); // TypeError

// 相反,你必须明确地将 Symbol 转换为字符串
console.log(String(label) + ' is my label'); // "Symbol(myLabel) is my label"

Symbol 的内存处理可能会有一些问题,尤其是在使用全局 Symbol 时。常规的 Symbol 在没有引用时可以被垃圾回收,但全局 Symbol 会一直存在:

// 常规 Symbol 可被垃圾回收
let regularSymbol = Symbol('temp');
regularSymbol = null; // Symbol 可以被清理

// 全局 Symbol 持久存在
Symbol.for('permanent'); // 创建全局 Symbol
// 即使我们不保留引用,它也会保留在内存中
console.log(Symbol.for('permanent') === Symbol.for('permanent')); // true

模块之间的 Symbol 共享是一种有趣的模式。使用 Symbol.for() 时,该 Symbol 在整个应用中都可用,而普通 Symbol 则保持唯一:

// 在模块 A
const SHARED_KEY = Symbol.for('app.sharedKey');
const moduleA = {
  [SHARED_KEY]: 'secret value'
};

// 在模块 B - 即使在不同的文件中
const sameKey = Symbol.for('app.sharedKey');
console.log(SHARED_KEY === sameKey);  // true
console.log(moduleA[sameKey]);        // 'secret value'

// 普通 Symbol 不共享
const regularSymbol = Symbol('regular');
const anotherRegular = Symbol('regular');
console.log(regularSymbol === anotherRegular);  // false

Symbol.for() 创建的值在应用的任何地方都可以通过使用相同的名称来创建相同的 Symbol,而普通 Symbol 即使名称相同,也总是唯一的。

何时使用 Symbol

Symbol 在特定情况下很好用。当你需要真正唯一的属性 key 时,比如添加不会干扰现有属性的元数据,就可以使用 Symbol。你也可以通过预定义的 Symbol 来控制对象的行为,而全局的 Symbol.for() 则有助于在整个应用中共享常量。

// 使用 Symbol 表示类私有属性
const userIdSymbol = Symbol('id');
const user = {
  [userIdSymbol]: 123,
  name: 'Alex'
};

// 利用 Symbol 实现特殊行为
const customIterator = {
  [Symbol.iterator]() {
    // 自定义可迭代逻辑
  }
};

// 使用 Symbol.for() 跨模块共享常量
const SHARED_ACTION = Symbol.for('action');

Symbol 初看起来可能很不寻常,但它却能解决 JavaScript 中的实际问题。在我们需要的时候,它能提供唯一性、隐私性,同时能提供修改 JavaScript 内部行为的钩子。