Стандарт ES6 дал в руки разработчикам мощное и гибкое оружие – JavaScript Maps. Кажется, простым объектам уже пора на покой... Или нет?
Среди других замечательных плюшек, в стандарте ES6 появились мапы (Maps) и сеты (Sets). У этих коллекций большие возможности и удобные методы. При использовании в правильных местах они могут дать выигрыш в производительности. Пора ли отказываться от старых-добрых объектов и массивов, или новые структуры не могут полностью их заменить?
Эта статья посвящена мапам – их специфике, преимуществам и тонкостям использования. Вы также можете узнать больше о том, как JavaScript Sets ускоряют код.
JavaScript Maps vs Objects
Между мапами и простыми объектами есть два принципиальных отличия.
1. Нет ограничений на ключи
Обычные объекты ставят нам четкое условие: ключ должен быть строкой (String) или символом (Symbol).
const symbol = Symbol(); const string2 = String(); const regularObject = { string1: 'value1', string2: 'value2', symbol: 'value3' };
Если вам очень хочется использовать в качестве ключа другой объект или, может быть, функцию – увы, дорогой JavaScript разработчик…
Хотя почему бы и нет, ведь теперь у нас есть мапы! Захотелось вам сделать ключом массив – будьте любезны:
const func = () => null; const object = {}; const array = []; const bool = false; const map = new Map(); map.set(func, 'value1'); map.set(object, 'value2'); map.set(array, 'value3'); map.set(bool, 'value4'); map.set(NaN, 'value5');
Объекты, массивы, функции, примитивные типы, даже NaN
– никаких ограничений больше нет. Это очень-очень гибко, ведь теперь вы можете связать друг с другом любые данные.
Прямая итерация
Нельзя просто так взять и перебрать объект.
Сначала нужно преобразовать его в некоторое подобие массива, используя Object.keys()
, Object.values()
или Object.entries()
.
Есть, конечно, цикл for … in
, но с ним всегда довольно много проблем:
- он перебирает только перечисляемые (enumerable) свойства;
- не работает с полями-символами;
- не сохраняет исходный порядок свойств в коллекции.
JavaScript Maps в отличие от объектов прекрасно перебираются напрямую, причем порядок итерации полей всегда соответствует порядку их вставки в мап.
for (let [key, value] of map) { console.log(key); console.log(value); }; map.forEach((key, value) => { console.log(key); console.log(value); });
Благодаря этому можно легко получить размер коллекции – map.size
. А чтобы узнать, сколько свойств хранится в объекте, приходится вызывать сложную конструкцию Object.keys({}).length
.
JavaScript Maps vs Sets
Мапы очень похожи на сеты по интерфейсу – у них целый набор одинаковых методов и свойств: has
, get
, delete
, size
. Обе структуры можно проитерировать напрямую с сохранением порядка в цикле или перебирающими методами вроде forEach
.
Основное различие заключается в количестве измерений. В Set оно одно, как в простом массиве, а в Map – два:
const set = new Set([1, 2, 3, 4]); const map = new Map([['one', 1], ['two', 2], ['three', 3], ['four', 4]]);
Преобразование типов
Если уж мы превращаем массивы в JavaScript Maps, может потребоваться и обратное преобразование. Используйте для этого синтаксис деструктурирующего присваивания, введенный новым стандартом:
const map = new Map([['one', 1], ['two', 2]]); const arr = [...map];
Сложнее обстоит дело с конвертацией мапы в объект (и обратно). Для этого придется написать специальную функцию:
const mapToObj = map => { const obj = {}; map.forEach((key, value) => { obj[key] = value }); return obj; }; const objToMap = obj => { const map = new Map; Object.keys(obj).forEach(key => { map.set(key, obj[key]) }); return map; };
Впрочем, язык JavaScript заботится о своих адептах – в новом стандарте ES2019 появились два метода, которые смогут решить эту задачу в одну строку – Object.entries()
и Object.fromEntries()
:
Object.fromEntries(map); // Map -> object new Map(Object.entries(obj)); // object -> Map
Убедитесь, что ключи вашей мапы останутся уникальными при преобразовании к строке, прежде чем конвертировать ее в объект, иначе вы потеряете часть данных.
Тесты производительности
А теперь самое интересное. Испытаем JavaScript Maps в деле. Вдруг они окажутся ужасно медленными и неповоротливыми…
Создадим обычный объект и мапу – каждый с миллионом свойств.
let obj = {}, map = new Map(), n = 1000000; for (let i = 0; i < n; i++) { obj[i] = i; map.set(i, i); }
Для бенчмаркинга будем использовать обычный метод console.time
. Точные цифры, разумеется, будут зависеть от системы и среды, в которой запускается код, но нам интереснее общая картина – рост или падение производительности.
Спойлер от автора: наблюдается стабильное ускорение работы программы при использовании мап, особенно при добавлении и удалении свойств.
Поиск значений
let result; console.time('Object'); result = obj.hasOwnProperty('999999'); console.timeEnd('Object'); console.time('Map'); result = map.has(999999); console.timeEnd('Map');
Объект: 0.250 мс
Мапа: 0.095 мс (в 2.6 раз быстрее)
Добавление значений
console.time('Object'); obj[n] = n; console.timeEnd('Object'); console.time('Map'); map.set(n, n); console.timeEnd('Map');
Объект: 0.229мс
Мапа: 0.005мс (в 45.8 раз быстрее)
Удаление значений
console.time('Object'); delete obj[n]; console.timeEnd('Object'); console.time('Map'); map.delete(n); console.timeEnd('Map');
Объект: 0.376мс
Мапа: 0.012мс (в 31 раз быстрее)
Примечание от переводчика
При прогоне тестов в консоли браузера с теми же самыми исходными данными результаты получились не столь ошеломляющие.
Для проверки наличия значения особой разницы не замечено:
При добавлении и удалении значений Map даже проигрывает:
Впрочем, автор отмечает, что внутри цикла for
объекты работают быстрее, чем JavaScript Maps – довольно интригующее наблюдение:
let obj = {}, map = new Map(), n = 1000000; console.time('Map'); for (let i = 0; i < n; i++) { map.set(i, i); } console.timeEnd('Map'); console.time('Object'); for (let i = 0; i < n; i++) { obj[i] = i; } console.timeEnd('Object');
Объект: 32.143мс
Мапа: 163.828мс (в 5 раз медленнее)
Использование JavaScript Maps
Итак, в каких случаях JavaScript разработчик должен использовать мапы, а не обычные объекты?
Предположим, вам нужно написать функцию isAnagram
, которая будет определять, являются ли два слова анаграммами друг друга:
console.log(isAnagram('anagram', 'gramana')); // true console.log(isAnagram('anagram', 'margnna')); // false
Способов решить эту задачку много, однако мапы предлагают одно из самых чистых и быстрых решений:
const isAnagram = (str1, str2) => { if (str1.length !== str2.length) return false; const map = new Map(); for (let char of str1) { const count = map.has(char) ? map.get(char) + 1 : 1; map.set(char, count); } for (let char of str2) { if (!map.has(char)) return false; const count = map.get(char) - 1; if (count === 0) { map.delete(char); continue; } map.set(char, count); } return map.size === 0; }
Если необходимо добавлять и удалять значения динамически, использовать JavaScript Maps предпочтительнее, чем объекты. Кроме того, они очень полезны, если типы данных или количество записей заранее не известны.
С мапами программирование на JavaScript стало еще немного проще, но, конечно, это не панацея от всех бед.
Комментарии