Like I did in previous articles on the series, I would love to point out that you should probably set up Babel and follow along the examples with either a REPL or the
babel-node
CLI and a file. That’ll make it so much easier for you to internalize the concepts discussed in the series. If you aren’t the “install things on my computer” kind of human, you might prefer to hop on CodePen and then click on the gear icon for JavaScript – they have a Babel preprocessor which makes trying out ES6 a breeze. Another alternative that’s also quite useful is to use Babel’s online REPL – it’ll show you compiled ES5 code to the right of your ES6 code for quick comparison.
Before getting into it, let me shamelessly ask for your support if you’re enjoying my ES6 in Depth series. Your contributions will go towards helping me keep up with the schedule, server bills, keeping me fed, and maintaining Pony Foo as a veritable source of JavaScript goodies.
Thanks for reading that, and let’s go into collections now! For a bit of context, you may want to check out the article on iterators – which are closely related to ES6 collections – and the one on spread and rest parameters.
Now, let’s start with Map
. I moved the rest of the ES6 collections to tomorrow’s publication in order to keep the series sane, as otherwise this would’ve been too long for a single article!
Before ES6, There Were Hash-Maps
A very common abuse case of JavaScript objects is hash-maps, where we map string keys to arbitrary values. For example, one might use an object to map npm
package names to their metadata, like so:
var registry = {}
function add (name, meta) {
registry[name] = meta
}
function get (name) {
return registry[name]
}
add('contra', { description: 'Asynchronous flow control' })
add('dragula', { description: 'Drag and drop' })
add('woofmark', { description: 'Markdown and WYSIWYG editor' })
There’s several issues with this approach, to wit:
- Security issues where user-provided keys like
__proto__
,toString
, or anything inObject.prototype
break expectations and make interaction with these kinds of hash-map data structures more cumbersome - Iteration over list items is verbose with
Object.keys(registry).forEach
or implementing the iterable protocol on theregistry
- Keys are limited to strings, making it hard to create hash-maps where you’d like to index values by DOM elements or other non-string references
The first problem could be fixed using a prefix, and being careful to always get or set values in the hash-map through methods. It would be even better to use ES6 proxies, but we won’t be covering those until tomorrow!
var registry = {}
function add (name, meta) {
registry['map:' + name] = meta
}
function get (name) {
return registry['map:' + name]
}
add('contra', { description: 'Asynchronous flow control' })
add('dragula', { description: 'Drag and drop' })
add('woofmark', { description: 'Markdown and WYSIWYG editor' })
Luckily for us, though, ES6 maps provide us with an even better solution to the key-naming security issue. At the same time they facilitate collection behaviors out the box that may also come in handy. Let’s plunge into their practical usage and inner workings.
ES6 Maps
Map is a key/value data structure in ES6. It provides a better data structure to be used for hash-maps. Here’s how what we had earlier looks like with ES6 maps.
var map = new Map()
map.set('contra', { description: 'Asynchronous flow control' })
map.set('dragula', { description: 'Drag and drop' })
map.set('woofmark', { description: 'Markdown and WYSIWYG editor' })
One of the important differences is also that you’re able to use anything for the keys. You’re not just limited to primitive values like symbols, numbers, or strings, but you can even use functions, objects and dates – too. Keys won’t be casted to strings like with regular objects, either.
var map = new Map()
map.set(new Date(), function today () {})
map.set(() => 'key', { pony: 'foo' })
map.set(Symbol('items'), [1, 2])
You can also provide Map
objects with any object that follows the iterable protocol and produces a collection such as [['key', 'value'], ['key', 'value']]
.
var map = new Map([
[new Date(), function today () {}],
[() => 'key', { pony: 'foo' }],
[Symbol('items'), [1, 2]]
])
The above would be effectively the same as the following. Note how we’re using destructuring in the parameters of items.forEach
to effortlessly pull the key
and value
out of the two-dimensional item
.
var items = [
[new Date(), function today () {}],
[() => 'key', { pony: 'foo' }],
[Symbol('items'), [1, 2]]
]
var map = new Map()
items.forEach(([key, value]) => map.set(key, value))
Of course, it’s kind of silly to go through the trouble of adding items one by one when you can just feed an iterable to your Map
. Speaking of iterables – Map
adheres to the iterable protocol. It’s very easy to pull a key-value pair collection much like the ones you can feed to the Map
constructor.
Naturally, we can use the spread operator to this effect.
var map = new Map()
map.set('p', 'o')
map.set('n', 'y')
map.set('f', 'o')
map.set('o', '!')
console.log([...map])
// <- [['p', 'o'], ['n', 'y'], ['f', 'o'], ['o', '!']]
You could also use a for..of
loop, and we could combine that with destructuring to make it seriously terse. Also, remember template literals?
var map = new Map()
map.set('p', 'o')
map.set('n', 'y')
map.set('f', 'o')
map.set('o', '!')
for (let [key, value] of map) {
console.log(`${key}: ${value}`)
// <- 'p: o'
// <- 'n: y'
// <- 'f: o'
// <- 'o: !'
}
Even though maps have a programmatic API to add items, keys are unique, just like with hash-maps. Setting a key over and over again will only overwrite its value.
var map = new Map()
map.set('a', 'a')
map.set('a', 'b')
map.set('a', 'c')
console.log([...map])
// <- [['a', 'c']]
In ES6 Map
, NaN
becomes a “corner-case” that gets treated as a value that’s equal to itself even though the following expression actually evaluates to true
– NaN !== NaN
.
console.log(NaN === NaN)
// <- false
var map = new Map()
map.set(NaN, 'foo')
map.set(NaN, 'bar')
console.log([...map])
// <- [[NaN, 'bar']]
Hash-Maps and the DOM
In ES5, whenever we had a DOM element we wanted to associate with an API object for some library, we had to follow a verbose and slow pattern like the one below. The following piece of code just returns an API object with a bunch of methods for a given DOM element, allowing us to put and remove DOM elements from the cache, and also allowing us to retrieve the API object for a DOM element – if one already exists.
var cache = []
function put (el, api) {
cache.push({ el: el, api: api })
}
function find (el) {
for (i = 0; i < cache.length; i++) {
if (cache[i].el === el) {
return cache[i].api
}
}
}
function destroy (el) {
for (i = 0; i < cache.length; i++) {
if (cache[i].el === el) {
cache.splice(i, 1)
return
}
}
}
function thing (el) {
var api = find(el)
if (api) {
return api
}
api = {
method: method,
method2: method2,
method3: method3,
destroy: destroy.bind(null, el)
}
put(el, api)
return api
}
One of the coolest aspects of Map
, as I’ve previously mentioned, is the ability to index by DOM elements. The fact that Map
also has collection manipulation abilities also greatly simplifies things.
var cache = new Map()
function put (el, api) {
cache.set(el, api)
}
function find (el) {
return cache.get(el)
}
function destroy (el) {
cache.delete(el)
}
function thing (el) {
var api = find(el)
if (api) {
return api
}
api = {
method: method,
method2: method2,
method3: method3,
destroy: destroy.bind(null, el)
}
put(el, api)
return api
}
The fact that these methods have now become one liners means we can just inline them, as readability is no longer an issue. We just went from ~30 LOC to half that amount. Needless to say, at some point in the future this will also perform much faster than the haystack alternative.
var cache = new Map()
function thing (el) {
var api = cache.get(el)
if (api) {
return api
}
api = {
method: method,
method2: method2,
method3: method3,
destroy: () => cache.delete(el)
}
cache.set(el, api)
return api
}
The simplicity of Map
is amazing. If you ask me, we desperately needed this feature in JavaScript. Being to index a collection by arbitrary objects is super important.
What else can we do with
Map
?
Collection Methods in Map
Maps make it very easy to probe the collection and figure out whether a key
is defined in the Map
. As we noted earlier, NaN
equals NaN
as far as Map
is concerned. However, Symbol
values are always different, so you’ll have to use them by value!
var map = new Map([[NaN, 1], [Symbol(), 2], ['foo', 'bar']])
console.log(map.has(NaN))
// <- true
console.log(map.has(Symbol()))
// <- false
console.log(map.has('foo'))
// <- true
console.log(map.has('bar'))
// <- false
As long as you keep a Symbol
reference around, you’ll be okay. Keep your references close, and your Symbol
s closer?
var sym = Symbol()
var map = new Map([[NaN, 1], [sym, 2], ['foo', 'bar']])
console.log(map.has(sym))
// <- true
Also, remember the no key-casting thing? Beware! We are so used to objects casting keys to strings that this may bite you if you’re not careful.
var map = new Map([[1, 'a']])
console.log(map.has(1))
// <- true
console.log(map.has('1'))
// <- false
You can also clear a Map
entirely of entries without losing a reference to it. This can be very handy sometimes.
var map = new Map([[1, 2], [3, 4], [5, 6]])
map.clear()
console.log(map.has(1))
// <- false
console.log([...map])
// <- []
When you use Map
as an iterable, you are actually looping over its .entries()
. That means that you don’t need to explicitly iterate over .entries()
. It’ll be done on your behalf anyways. You do remember Symbol.iterator
, right?
console.log(map[Symbol.iterator] === map.entries)
// <- true
Just like .entries()
, Map
has two other iterators you can leverage. These are .keys()
and .values()
. I’m sure you guessed what sequences of values they yield, but here’s a code snippet anyways.
var map = new Map([[1, 2], [3, 4], [5, 6]])
console.log([...map.keys()])
// <- [1, 3, 5]
console.log([...map.values()])
// <- [2, 4, 6]
Maps also come with a read-only .size
property that behaves sort of like Array.prototype.length
– at any point in time it gives you the current amount of entries in the map.
var map = new Map([[1, 2], [3, 4], [5, 6]])
console.log(map.size)
// <- 3
map.delete(3)
console.log(map.size)
// <- 2
map.clear()
console.log(map.size)
// <- 0
One more aspect of Map
that’s worth mentioning is that their entries are always iterated in insertion order. This is in contrast with Object.keys
loops which follow an arbitrary order.
The
for..in
statement iterates over the enumerable properties of an object, in arbitrary order.
Maps also have a .forEach
method that’s identical in behavior to that in ES5 Array
objects. Once again, keys do not get casted into strings here.
var map = new Map([[NaN, 1], [Symbol(), 2], ['foo', 'bar']])
map.forEach((value, key) => console.log(key, value))
// <- NaN 1
// <- Symbol() 2
// <- 'foo' 'bar'
Get up early tomorrow morning, we’ll be having
WeakMap
,Set
, andWeakSet
for breakfast :)
Comments