Изучение Символов JavaScript

Источник: «Exploring JavaScript Symbols»
Глубокое погружение в JavaScript Символы — что это такое, чем они важны и как их эффективно использовать

Я помню, как впервые столкнулся с Символами в JavaScript. Это был 2015 год, как и многие разработчики, я подумал: Отлично, ещё один примитивный тип, о котором нужно беспокоиться.

Но по мере карьерного роста я стал ценить эти маленькие причудливые примитивы. Они решают некоторые интересные задачи так, что строки и числа просто не могут с ними сравниться.

Symbol отличается от других примитивов JavaScript тем, что они гарантированно уникальны.

Когда создаёте символ с помощью Symbol('description'), вы получаете нечто, что никогда не будет равно ни одному другому символу, даже созданному с тем же description. Эта уникальность и делает их мощными для определённых случаев использования.

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

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

Настоящая сила Символов в JavaScript проявляется при работе с объектами. В отличие от строк или чисел, Символы можно использовать в качестве ключей свойств без риска коллизии с существующими свойствами. Это делает их бесценными для добавления функциональности к объектам без вмешательства в существующий код.

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 }

Когда Символ используется в качестве ключа свойства, он не будет отображаться в 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.for():

// Использование Symbol.for() для совместного использования символов в разных модулях
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');
}

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

// Символы из реестра общие
console.log(Symbol.for('processMessage') === PROCESS_MESSAGE); // true

// Но обычные Символы нет
console.log(Symbol('processMessage') === Symbol('processMessage')); // false

JavaScript предоставляет встроенные Символы, позволяющие изменять поведение объектов в различных ситуациях. Они называются хорошо известными Символами, и дают доступ к основным возможностям языка.

Один из распространённых вариантов использования — сделать объекты итерируемыми с 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.toPrimitive. Позволяет управлять преобразованием объектов в примитивные значения, такие как числа или строки. Это удобно, когда объекты должны работать с разными типами операций:

const user = {
name: 'Alex',
score: 42,
[Symbol.toPrimitive](hint) {
// JavaScript сообщает нам, какой тип ему нужен, с помощью параметра 'hint'.
// hint может быть: 'number', 'string' или 'default'.

switch (hint) {
case 'number':
return this.score; // Когда JavaScript требуется number (например, +user)

case 'string':
return this.name; // Когда JavaScript требуется string (например, `${user}`)

default:
return `${this.name} (${this.score})`; // Для других операций (например, user + '')
}
}
};

// Примеры того, как JavaScript использует эти преобразования:
console.log(+user); // + оператор желает number, получается 42
console.log(`${user}`); // шаблонный литерал желает string, получается "Alex"
console.log(user + ''); // + со строкой используется по умолчанию, получается ЭAlex (42)Э.

Управление наследованием с помощью 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 использовать обычный Array для таких операций, как map()
static get [Symbol.species]() {
return Array;
}
}

const nums = new NumberArray(1, 2, 3);
nums.push(4); // Работает ✅
nums.push('5'); // Ошибка! ❌ (как и ожидалось для nums)

const doubled = nums.map(x => x * 2);
doubled.push('6'); // Работает! ✅ (doubled - обычный массив)

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

Символы ограничения и проблемы

Работа с Символами в JavaScript не всегда проста. Одна из распространённых путаниц возникает при попытке работать с JSON. Свойства символов полностью исчезают при сериализации JSON:

const API_KEY = Symbol('apiKey');

// Используем этот Символ как ключ свойства
const userData = {
[API_KEY]: 'abc123xyz', // Скрываем ключ API с помощью нашего символа
username: 'alex' // Нормальное свойство может увидеть каждый
};

// Позже мы сможем получить доступ к ключу API с помощью сохранённого Символа
console.log(userData[API_KEY]); // выводит: 'abc123xyz'

// Но когда сохраняем в JSON, он исчезает
const savedData = JSON.stringify(userData);
console.log(savedData); // Выводит только: {"username":"alex"}

Строковое приведение Символов приводит к ещё одной распространённой ловушке. Хотя принято ожидать, что Символы работают как другие примитивы, у них строгие правила преобразования типов:

const label = Symbol('myLabel');

// Это вызовет ошибку
console.log(label + ' is my label'); // TypeError

// Вместо этого необходимо явно преобразовать в строку
console.log(String(label) + ' is my label'); // "Symbol(myLabel) is my label"

Работа с памятью при использовании Символов может быть непростой, особенно с глобальным реестром Символов. Обычные Символы могут быть собраны в мусор, когда на них не остаётся ссылок, но Символы реестра остаются:

// Обычный Символ может быть собран в мусор
let regularSymbol = Symbol('temp');
regularSymbol = null; // Символ может быть очищен

// Символ Реестра сохраняется
Symbol.for('permanent'); // Создаёт запись реестра
// Даже если мы не сохраняем ссылку, он остаётся в реестре.
console.log(Symbol.for('permanent') === Symbol.for('permanent')); // true

Совместное использование Символов между модулями демонстрирует интересный паттерн. При использовании Symbol.for() Символ становится доступным во всём приложении, в то время как обычные Символы остаются уникальными:

// В модуле 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'

// Обычные Символы не доступны
const regularSymbol = Symbol('regular');
const anotherRegular = Symbol('regular');
console.log(regularSymbol === anotherRegular); // false

Когда использовать Символы

Символы проявляют себя в определённых ситуациях. Используйте их, когда необходимы действительно уникальные ключи свойств, например, для добавления метаданных, не влияющих на существующие свойства. Они идеально подходят для создания специализированного поведения объектов с помощью хорошо известных Символов, а реестр Symbol.for() помогает совместно использовать константы в рамках всего приложения.

// Используйте Символы для свойств, похожих на приватные
const userIdSymbol = Symbol('id');
const user = {
[userIdSymbol]: 123,
name: 'Alex'
};

// Использование Символов для особого поведения
const customIterator = {
[Symbol.iterator]() {
// Реализация логики итератора
}
};

// Используйте константы в разных модулях с помощью Symbol.for()
const SHARED_ACTION = Symbol.for('action');

Комментарии


Дополнительные материалы

Предыдущая Статья

link rel='modulepreload': Оптимизация загрузки модулей JavaScript

Следующая Статья

Кнопки с несколькими состояниями