Детальный обзор Well-known Symbols с примерами их использования

Доброго времени суток, друзья!

Символ (Symbol) — это примитивный тип данных, представленный в ECMAScript2015 (ES6), позволяющий создавать уникальные идентификаторы: const uniqueKey = Symbol(‘SymbolName’).

Вы можете использовать символы Детальный обзор Well-known Symbols в качестве ключей для свойств объектов. Символы, которые JavaScript обрабатывает особым образом, называются хорошо известными символами (Well-known Symbols). Эти символы используются встроенными алгоритмами JavaScript. Например, Symbol.iterator используется для перебора элементов массивов, строк. Его также можно использовать для определения собственных функций-итераторов.

Данные символы играют важную роль, поскольку позволяют осуществлять тонкую настройку поведения объектов. Возможно будет интересно 10 полезных приёмов для JavaScript-программистов.

Будучи уникальными, использование символов в качестве ключей объектов (вместо строк) позволяет легко добавлять объектам новый функционал. При этом, не нужно беспокоиться о возникновении коллизий между ключами (поскольку каждый символ уникален), что может стать проблемой при использовании строк.

В данной статье речь пойдет о хорошо известных Детальный обзор Well-known Symbols с примерами их использования.

В целях небольшого упрощения синтаксис хорошо известных символов Symbol.<name> представлен в формате @@<name>. Например, Symbol.iterator представлен как @@iterator, Symbol.toPrimitive — как @@toPrimitive и т.д.

Если мы говорим о том, что объект имеет метод @@iterator, значит, объект содержит свойство под названием Symbol.iterator, представленное функцией: { [Symbol.iterator]: function() { } }.

1. Краткое введение в символы

Символ — это примитивный тип (такой как число, строка или логическое значение), уникальный и неизменяемый (иммутабельный).

Для создания символа необходимо вызвать функцию Symbol() с опциональным аргументом — названием или, точнее, описанием символа:

const mySymbol = Symbol()
const namedSymbol = Symbol('myName')
typeof mySymbol // symbol
typeof namedSymbol // symbol

mySymbol и namedSymbol — это символы-примитивы. namedSymbol имеет название ‘myName’, которое, обычно, используется в целях отладки кода.

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

const first = Symbol()
const second = Symbol()
first === second // false

const firstNamed = Symbol('Lorem')
const secondNamed = Symbol('Lorem')
firstNamed === secondNamed // false

Символы могут быть ключами объектов. Для этого в объектном литерале или определении класса необходимо использовать синтаксис вычисляемых свойств ([symbol]):

const strSymbol = Symbol('String')

const myObj = {
  num: 1,
  [strSymbol]: 'Hello World'
}

myObj[strSymbol] // Hello World
Object.getOwnPropertyNames(myObj) // ['num']
Object.getOwnPropertySymbols(myObj) // [Symbol(String)]

Свойства-символы не могут быть получены с помощью Object.keys() или Object.getOwnPropertyNames(). Для доступа к ним нужно использовать специальную функцию Object.getOwnPropertySymbols().

Использование хорошо известных символов в качестве ключей позволяет изменять поведение объектов.

Хорошо известные символы доступны как неперечисляемые, неизменяемые и ненастраиваемые свойства объекта Symbol. Для их получения следует использовать точечную нотацию: Symbol.iterator, Symbol.hasInstance и т.д.

Вот как можно получить список хорошо известных символов:

Object.getOwnPropertyNames(Symbol)
// ["hasInstance", "isConcatSpreadable", "iterator", "toPrimitive",
//  "toStringTag", "unscopables", "match", "replace", "search",
//  "split", "species", ...]

typeof Symbol.iterator // symbol

Object.getOwnPropertyNames(Symbol) возвращает список собственных свойств объекта Symbol, включая хорошо известные символы. Разумеется, типом Symbol.iterator является symbol.

2. @@iterator, позволяющий делать объекты перебираемыми (итерируемыми)

Symbol.iterator — это, пожалуй, наиболее известный символ. Он позволяет определять, как объект должен перебираться с помощью инструкции for-of или spread-оператора (и должен ли он перебираться вообще).

Многие встроенные типы, такие как строки, массивы, карты (maps), наборы или коллекции (sets) являются итерируемыми по умолчанию, поскольку у них есть метод @@iterator:

const myStr = 'Hi'
typeof myStr[Symbol.iterator] // function
for (const char of myStr) {
  console.log(char) // по символу на каждой итерации: сначала 'H', затем 'i'
}
[...myStr] // ['H', 'i']

Переменная myStr содержит примитивную строку, у которой имеется свойство Symbol.iterator. Данное свойство содержит функцию, используемую для перебора символов строки.

Объект, в котором определяется метод Symbol.iterator, должен соответствовать протоколу перебора (итератора). Точнее, данный метод должен возвращать объект, соответствующий указанному протоколу. У такого объекта должен быть метод next(), возвращающий { value: <iterator_value>, done: <boolean_finished_iterator> }.

В следующем примере мы создаем итерируемый объект myMethods, позволяющий перебирать его методы:

function methodsIterator() {
  let index = 0
  const methods = Object.keys(this)
    .filter(key => typeof this[key] === 'function')

    return {
      next: () => ({
        done: index === methods.length,
        value: methods[index++]
      })
    }
}

const myMethods = {
  toString: () => '[object myMethods]',
  sum: (a, b) => a + b,
  numbers: [1, 3, 5],
  [Symbol.iterator]: methodsIterator
}

for (const method of myMethods) {
  console.log(method) // toString, sum
}

methodsIterator() — это функция, которая возвращает итератор { next: function() { } }. В объекте myMethods определяется вычисляемое свойство [Symbol.iterator] со значением methodsIterator. Это делает объект перебираемым с помощью цикла for-of. Методы объекта также можно получить с помощью […myMethods]. Такой объект можно преобразовать в массив с помощью Array.from(myMethods).

Создание итерируемого объекта можно упростить с помощью функции-генератора. Данная функция возвращает объект Generator, соответствующий протоколу перебора.

Создадим класс Fibonacci с методом @@iterator, генерирующим последовательность чисел Фибоначчи:

class Fibonacci {
  constructor(n) {
    this.n = n
  }

  *[Symbol.iterator]() {
    let a = 0, b = 1, index = 0
    while (index < this.n) {
      index++
      let current = a
      a = b
      b = current + a
      yield current
    }
  }
}

const sequence = new Fibonacci(6)
const numbers = [...sequence]
console.log(numbers) // [0, 1, 1, 2, 3, 5]

*[Symbol.iterator]() { } определяет метод класса — функцию-генератор. Экземпляр Fibonacci соответствует протоколу перебора. spread-оператор вызывает метод @@iterator для создания массива чисел.

Если примитивный тип или объект содержит @@iterator, он может быть использован в следующих сценариях:

  • Перебор элементов с помощью for-of
  • Создание массива элементов с помощью spread-оператора
  • Создание массива с помощью Array.from(iterableObject)
  • В выражении yield* для передачи другому генератору
  • В конструкторах Map(), WeakMap(), Set() и WeakSet()
  • В статических методах Promise.all(), Promise.race() и т.д.

Подробнее про создание перебираемого объекта можно почитать здесь.

3. @@hasInstance для настройки instanceof

По умолчанию оператор obj instanceof Constructor проверяет, имеется ли в цепочке прототипов obj объект Constructor.prototype. Рассмотрим пример:

function Constructor() {
  // ...
}
const obj = new Constructor()
const objProto = Object.getPrototypeOf(obj)

objProto === Constructor.prototype // true
obj instanceof Constructor // true
obj instanceof Object // true

obj instanceof Constructor возвращает true, поскольку прототипом obj является Constructor.prototype (как результат вызова конструктора). instanceof при необходимости обращается к цепочке прототипов, поэтому obj instanceof Object также возвращает true.

Иногда в приложении требуется более строгая проверка экземпляров.

К счастью, у нас имеется возможность определить метод @@hasInstance для изменения поведения instanceof. obj instanceof Type является эквивалентом Type[Symbol.hasInstance](obj).

Давайте проверим, являются ли переменные итерируемыми:

class Iterable {
  static [Symbol.hasInstance](obj) {
    return typeof obj[Symbol.iterator] === 'function'
  }
}

const arr = [1, 3, 5]
const str = 'Hi'
const num = 21
arr instanceof Iterable // true
str instanceof Iterable // true
num instanceof Iterable // false

Класс Iterable содержит статический метод @@hasInstance. Данный метод проверяет, является ли obj перебираемым, т.е. содержит ли он свойство Symbol.iterator. arr и str являются итерируемыми, а num нет.

4. @@toPrimitive для преобразования объекта в примитив

Используйте Symbol.toPrimitive для определения свойства, значением которого является функция преобразования объекта в примитив. @@toPrimitive принимает один параметр — hint, которым может быть number, string или default. hint указывает на тип возвращаемого значения.

Усовершенствуем преобразование массива:

function arrayToPrimitive(hint) {
  if (hint === 'number') {
    return this.reduce((x, y) => x + y)
  } else if (hint === 'string') {
    return `[${this.join(', ')}]`
  } else {
    // hint имеет значение по умолчанию
    return this.toString()
  }
}

const array = [1, 3, 5]
array[Symbol.toPrimitive] = arrayToPrimitive

// преобразуем массив в число. hint является числом
+ array // 9
// преобразуем массив в строку. hint является строкой
`array is ${array}` // array is [1, 3, 5]
// преобразование по умолчанию. hint имеет значение default
'array elements: ' + array // array elements: 1,3,5

arrayToPrimitive(hint) — это функция, преобразующая массив в примитив на основе значения hint. Присвоение array[Symbol.toPrimitive] значения arrayToPrimitive заставляет массив использовать новый метод преобразования. Выполнение + array вызывает @@toPrimitive со значением hint, равным number. Возвращается сумма элементов массива. array is ${array} вызывает @@toPrimitive с hint = string. Массив преобразуется в строку ‘[1, 3, 5]’. Наконец ‘array elements: ‘ + array использует hint = default для преобразования. Массив преобразуется в ‘1,3,5’.

Метод @@toPrimitive используется для представления объекта в виде примитивного типа:

  • При использовании оператора нестрогого (абстрактного) равенства: object == primitive
  • При использовании оператора сложения/конкатенации: object + primitive
  • При использовании оператора вычитания: object — primitive
  • В различных ситуациях преобразования объекта в примитив: String(object), Number(object) и т.д.

 

5. @@toStringTag для создания стандартного описания объекта

Используйте Symbol.toStringTag для определения свойства, значением которого является строка, описывающая тип объекта. Метод @@toStringTag используется Object.prototype.toString().

Спецификация определяет значения, возвращаемые Object.prototype.toString() по умолчанию, для многих типов:

const toString = Object.prototype.toString
toString.call(undefined) // [object Undefined]
toString.call(null)      // [object Null]
toString.call([1, 4])    // [object Array]
toString.call('Hello')   // [object String]
toString.call(15)        // [object Number]
toString.call(true)      // [object Boolean]
// Function, Arguments, Error, Date, RegExp и т.д.
toString.call({})        // [object Object]

Эти типы не имеют свойства Symbol.toStringTag, поскольку алгоритм Object.prototype.toString() оценивает их особым образом.

Рассматриваемое свойство определяется в таких типах, как символы, функции-генераторы, карты, промисы и др. Рассмотрим пример:

const toString = Object.prototype.toString
const noop = function() { }

Symbol.iterator[Symbol.toStringTag]   // Symbol
(function* () {})[Symbol.toStringTag] // GeneratorFunction
new Map()[Symbol.toStringTag]         // Map
new Promise(noop)[Symbol.toStringTag] // Promise

toString.call(Symbol.iterator)   // [object Symbol]
toString.call(function* () {})   // [object GeneratorFunction]
toString.call(new Map())         // [object Map]
toString.call(new Promise(noop)) // [object Promise]

В случае, когда объект не относится к группе со стандартным типом и не содержит свойства @@toStringTag, возвращается Object. Разумеется, мы можем это изменить:

const toString = Object.prototype.toString

class SimpleClass { }
toString.call(new SimpleClass) // [object Object]

class MyTypeClass {
  constructor() {
    this[Symbol.toStringTag] = 'MyType'
  }
}

toString.call(new MyTypeClass) // [object MyType]

Экземпляр класса SimpleClass не имеет свойства @@toStringTag, поэтому Object.prototype.toString() возвращает [object Object]. В конструкторе класса MyTypeClass экземпляру присваивается свойство @@toStringTag со значением MyType, поэтому Object.prototype.toString() возвращает [object MyType].

Обратите внимание, что @@toStringTag был введен в целях обеспечения обратной совместимости. Его использование нежелательно. Для определения типа объекта лучше использоваить instanceof (совместно с @@hasInstance) или typeof.

6. @@species для создания производного объекта

Детальный обзор Well-known Symbols

Используйте Symbol.species для определения свойства, значением которого является функция-конструктор, используемая для создания производных объектов.

Значением @@species многих конструкторов являются сами конструкторы:

Array[Symbol.species] === Array // true
Map[Symbol.species] === Map // true
RegExp[Symbol.species] === RegExp // true

Во-первых, обратите внимание, что производным называется объект, возвращаемый после совершения определенной операции с исходным объектом. Например, вызов map() возвращает производный объект — результат преобразования элементов массива.

Обычно, производные объекты ссылаются на тот же конструктор, что и исходные объекты. Но порой возникает необходимость в определении другого конструктора (возможно, одного из стандартных классов): вот где может помочь @@species.

Предположим, что мы расширяем конструктор Array с помощью дочернего класса MyArray для добавления некоторых полезных методов. При этом мы хотим, чтобы конструктором производных объектов экземпляра MyArray был Array. Для этого необходимо определить вычисляемое свойство @@species со значением Array:

class MyArray extends Array {
  isEmpty() {
    return this.length === 0
  }
  static get [Symbol.species]() {
    return Array
  }
}
const array = new MyArray(2, 3, 5)
array.isEmpty() // false
const odds = array.filter(item => item % 2 === 1)
odds instanceof Array // true
odds instanceof MyArray // false

В MyArray определено статическое вычисляемое свойство Symbol.species. Оно указывает, что конструктором производных объектов должен быть конструктор Array. Позже при фильтрации элементов массива array.filter() возвращает Array.

Вычисляемое свойство @@species используется методами массивов и типизированных массивов, такими как map(), concat(), slice(), splice(), возвращающими производные объекты. Использование данного свойства может быть полезным для расширения карт, регулярных выражений или промисов с сохранением оригинального конструктора Детальный обзор Well-known Symbols.

7. Создание регулярного выражения в форме объекта: @@match, @@replace, @@search и @@split

Прототип строки содержит 4 метода, принимающих регулярные выражения в качестве аргумента:

  • String.prototype.match(regExp)
  • String.prototype.replace(regExp, newSubstr)
  • String.prototype.search(regExp)
  • String.prototype.split(regExp, limit)

ES6 позволяет этим методам принимать другие типы при условии определения соответствующих вычисляемых свойств: @@match, @@replace, @@search и @@split.

Любопытно, что прототип RegExp содержит указанные методы, также определенные с помощью символов:

typeof RegExp.prototype[Symbol.match]   // function
typeof RegExp.prototype[Symbol.replace] // function
typeof RegExp.prototype[Symbol.search]  // function
typeof RegExp.prototype[Symbol.split]   // function

В следующем примере мы определяем класс, который может использоваться вместо регулярного выражения:

class Expression {
  constructor(pattern) {
    this.pattern = pattern
  }
  [Symbol.match](str) {
    return str.includes(this.pattern)
  }
  [Symbol.replace](str, replace) {
    return str.split(this.pattern).join(replace)
  }
  [Symbol.search](str) {
    return str.indexOf(this.pattern)
  }
  [Symbol.split](str) {
    return str.split(this.pattern)
  }
}

const sunExp = new Expression('солнечный')

'солнечный день'.match(sunExp) // true
'дождливый день'.match(sunExp) // false
'солнечный day'.replace(sunExp, 'дождливый') // 'дождливый день'
'обещают солнечный день'.search(sunExp) // 8
'оченьсолнечныйдень'.split(sunExp) // ['очень', 'день']

В классе Expression определяются методы @@match, @@replace, @@search и @@split. Затем экземпляр этого класса — sunExp используется в соответствующих методах вместо регулярного выражения.

8. @@isConcatSpreadable для преобразования объекта в массив

Symbol.isConcatSpreadable представляет собой логическое значение, указывающее на возможность преобразования объекта в массив с помощью метода Array.prototype.concat().

По умолчанию, метод concat() извлекает элементы массива (раскладывает массив на элементы, из которых он состоит) при объединении массивов:

const letters = ['a', 'b']
const otherLetters = ['c', 'd']
otherLetters.concat('e', letters) // ['c', 'd', 'e', 'a', 'b']

Для объединения двух массивов letters передается в качестве аргумента методу concat(). Элементы массивы letters становятся частью результата объединения: [‘c’, ‘d’, ‘e’, ‘a’, ‘b’].

Для того, чтобы предотвратить разложение массива на элементы и сделать массив частью результата объединения как есть, свойству @@isConcatSpreadable следует присвоить значение false:

const letters = ['a', 'b']
letters[Symbol.isConcatSpreadable] = false
const otherLetters = ['c', 'd']
otherLetters.concat('e', letters) // ['c', 'd', 'e', ['a', 'b']]

В противоположность массиву, метод concat() не раскладывает на элементы массивоподобные объекты. Это поведение также можно изменить с помощью @@isConcatSpreadable:

const letters = { 0: 'a', 1: 'b', length: 2 }
const otherLetters = ['c', 'd']
otherLetters.concat('e', letters)
// ['c', 'd', 'e', {0: 'a', 1: 'b', length: 2}]
letters[Symbol.isConcatSpreadable] = true
otherLetters.concat('e', letters) // ['c', 'd', 'e', 'a', 'b']

 

9. @@unscopables для доступа к свойствам посредством with

Symbol.unscopables — это вычисляемое свойство, собственные имена свойств которого исключаются из объекта, добавляемого в начало цепочки областей видимости с помощью инструкции with. Свойство @@unscopables имеет следующий формат: { propertyName: <boolean_exclude_binding> }.

ES6 определяет @@unscopables только для массивов. Это сделано в целях сокрытия новых методов, которые могут перезаписать одноименные переменные в старом коде:

Array.prototype[Symbol.unscopables]
// { copyWithin: true, entries: true, fill: true,
//   find: true, findIndex: true, keys: true }
let numbers = [1, 3, 5]
with (numbers) {
  concat(7) // [1, 3, 5, 7]
  entries // ReferenceError: entries is not defined
}

Мы можем получить доступ к методу concat() в теле with, поскольку данный метод не содержится в свойстве @@unscopables. Метод entries() указан в этом свойстве и имеет значение true, что делает его недоступным внутри with.

@@unscopables был введен исключительно для обеспечения обратной совместимости со старым кодом, где используется инструкция with (которая признана устаревшей и запрещена в строгом режиме).

Надеюсь, вы нашли для себя что-нибудь интересное. Благодарю за внимание.

источник