Symbol: уникальные ключи
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Обычный ключ свойства — строка, и две строки "id" — это всегда один и тот же ключ. Symbol даёт ключи другого сорта: каждый символ уникален и не совпадает ни с одним другим, даже если выглядит точно так же.
Символ — примитивный тип, как число или строка. Создаётся вызовом Symbol(), и каждый вызов рождает новое значение, не равное никакому другому. Это единственное, что символ умеет, и вся его польза растёт отсюда.
Аргумент в скобках — описание, и оно нужно только человеку: попадает в отладочный вывод и в свойство description, но на сравнение не влияет никак. Это подпись на коробке, а не её содержимое — две коробки с одинаковой подписью остаются разными коробками.
Писать new Symbol() нельзя — будет ошибка. Символ не объект, конструктора у него нет, только функция-создатель.
В отличие от числа или null, символ не превращается в строку автоматически: "ключ: " + id бросит TypeError. Так язык страхует от опечатки, в которой уникальное значение молча схлопывается в текст. Нужен текст — попросите явно.
Ключом свойства в JavaScript может быть строка или символ — третьего варианта нет. Символьный ключ пишут в квадратных скобках: и в литерале объекта, и при чтении. Точечная запись тут не сработает, потому что после точки идёт имя, а символ — это значение из переменной.
Главное следствие — такой ключ невозможно занять дважды по неосторожности. Две библиотеки, обе решившие пометить ваш объект полем id, затрут работу друг друга и вы узнаете об этом из странного бага. Два символа с описанием "id" мирно живут рядом: они разные значения, а значит и разные ключи.
Свойства с символьными ключами не участвуют в обычном обходе объекта. for..in их не назовёт, Object.keys не вернёт, JSON.stringify не сериализует. Чужой код, который перебирает ваш объект, просто не узнает, что там что-то есть.
«Невидимость» не абсолютная: часть операций символьные ключи всё же копирует и показывает. Полная картина умещается в шесть строк.
| Операция | Видит символьные ключи |
|---|---|
| for..in | нет |
| Object.keys / values / entries | нет |
| JSON.stringify | нет |
| { ...obj } и Object.assign | да |
| Object.getOwnPropertySymbols | да |
| Reflect.ownKeys | да |
Спред и Object.assign делают честную копию всех собственных свойств, включая символьные. Это удобно (метка переживает копирование) и это же стоит держать в голове, если вы рассчитывали на обратное.
Несколько символов язык создал заранее и положил прямо в Symbol: Symbol.iterator, Symbol.toPrimitive, Symbol.toStringTag и другие. Это не секретные ключи, а точки подключения: в определённый момент движок сам заглядывает в объект по такому ключу и спрашивает, как с этим объектом поступить.
Самый частый — Symbol.iterator. Его вызывают for..of и спред .... Пока метода нет, объект перебрать нельзя; как только появился — можно, и никакой магии больше не требуется. Звёздочка перед именем ниже делает функцию генератором: такая функция отдаёт значения по одному, и каждое yield — очередное значение перебора. Для нашей цели этого достаточно: генератор здесь просто самый короткий способ написать итератор руками.
По той же схеме Symbol.toPrimitive отвечает на вопрос «во что превратить объект, если его сложили или вставили в шаблон». Аргумент hint говорит, чего от объекта хотят: "number", "string" или "default".
Сценарий первый — метка на чужом объекте. Вы получили данные из библиотеки или от сервера и хотите приписать к ним своё служебное поле так, чтобы оно не сломало сериализацию и не столкнулось с чужим именем. Второй — подключение к языку через системные символы. Третий — набор состояний, которые нельзя случайно сравнить со строкой: const LOADING = Symbol("loading") совпадёт только сам с собой.
Если одному и тому же символу нужно быть общим на весь код — есть глобальный реестр. Symbol.for("ключ") при первом вызове создаёт символ и запоминает, при следующих возвращает тот же самый, хоть из другого файла. Обычный Symbol() в реестр не попадает никогда.
Символ прячет свойство от случайного взгляда, но не от того, кто ищет: Object.getOwnPropertySymbols вернёт всё. Это неконфликтность, а не приватность. Нужна настоящая — берите #поля класса.
И обратная сторона: для обычных полей своего же объекта символы не нужны. Данные, которые уходят в JSON, конфигурация, всё, что удобно читать глазами в отладчике, — там строковый ключ проще и честнее. Символ достают, когда есть настоящий риск столкновения имён или требуется договориться с самим языком.
- Symbol() при каждом вызове создаёт новое уникальное значение; описание в скобках — подпись для отладки, на равенство оно не влияет.
- Ключом свойства может быть строка или символ. Символьный ключ пишут в квадратных скобках, и занять его случайно нельзя.
- Символьные свойства не видят for..in, Object.keys и JSON.stringify, но копируют спред и Object.assign, а показывает Object.getOwnPropertySymbols.
- Системные символы (Symbol.iterator, Symbol.toPrimitive) — точки подключения к языку: по ним движок сам спрашивает объект, как с ним обращаться.
- Symbol.for("ключ") возвращает символ из глобального реестра — один и тот же для всего кода, в отличие от обычного Symbol().
1/3Что выведет console.log(Symbol("x") === Symbol("x"))?
2/3Объявлено const obj = { a: 1, [Symbol("b")]: 2 }. Что вернёт Object.keys(obj)?
3/3Symbol.for("id") вызвали дважды в разных файлах. Что получится?
Скрытая метка на чужом объекте
Объект user пришёл из чужого кода и уходит обратно в JSON. Пометьте его как уже обработанный так, чтобы JSON.stringify и Object.keys ничего не заметили, а вы могли прочитать метку в любой момент.
Ключом берём символ. Свойство существует и читается, но ни сериализация, ни перебор его не видят — а чужая библиотека не сможет случайно занять то же имя.
Строковый ключ "visited" здесь не подошёл бы: он попал бы в JSON и в Object.keys.
Сделать объект перебираемым
У объекта team есть массив members, но сам объект спред не берёт. Добавьте Symbol.iterator, чтобы [...team] отдавал имена участников.
Писать итератор с нуля не нужно: у массива он уже есть, достаточно вернуть его наружу.
Вариант через генератор читается не хуже: *[Symbol.iterator]() { yield* this.members; }. Оба работают и с for..of.