“I am delighted to support Nicolás’ endeavor because his book looks exactly like what people who are coming to JavaScript with fresh eyes need.”
– Brendan Eich
Ideal for professional software developers with a basic understanding of JavaScript, this practical book shows you how to build small, interconnected ES6 JavaScript modules that emphasize reusability. You’ll learn how to face a project with a modular mindset, and how to organize your applications into simple pieces that work well in isolation and can be combined to create a large, robust application.
This book focuses on two aspects of JavaScript development: modularity and ES6 features. You’ll learn how to tackle application development by following a scale-out approach. As pieces of your codebase grow too big, you can break them up into smaller modules.
The book can be read online for free or purchased through Amazon.
This book is part of the Modular JavaScript series.
Start with the book series launch announcement on Pony Foo
Participate in the crowdfunding campaign on Indiegogo
Amplify the announcement on social media via Thunderclap
Share a message on Twitter or within your social circles
Contribute to the source code repository on GitHub
Read the free HTML version of the book on Pony Foo
Purchase the book from O’Reilly on Amazon
Chapter 5
Leveraging ECMAScript Collections
JavaScript data structures are flexible enough that we’re able to turn any object into a hash-map, where we map string keys to arbitrary values. For example, one might use an object to map npm package names to their metadata, as shown next.
const
registry
=
{}
function
set
(
name
,
meta
)
{
registry
[
name
]
=
meta
}
function
get
(
name
)
{
return
registry
[
name
]
}
set
(
'contra'
,
{
description
:
'Asynchronous flow control'
})
set
(
'dragula'
,
{
description
:
'Drag and drop'
})
set
(
'woofmark'
,
{
description
:
'Markdown and WYSIWYG editor'
})
There are several problems with this approach, outlined here:
-
Security issues where user-provided keys like
__proto__
,toString
, or anything inObject.prototype
break expectations and make interaction with this kind of hash-map data structures more cumbersome -
When iterating using
for..in
we need to rely onObject#hasOwnProperty
to make sure properties aren’t inherited -
Iteration over list items with
Object.keys(registry).forEach
is also verbose -
Keys are limited to strings, making it hard to create hash-maps where you’d like to index values by DOM elements or other nonstring references
The first problem could be fixed using a prefix, and being careful to always get or set values in the hash-map through functions that add those prefixes, to avoid mistakes.
const
registry
=
{}
function
set
(
name
,
meta
)
{
registry
[
'pkg:'
+
name
]
=
meta
}
function
get
(
name
)
{
return
registry
[
'pkg:'
+
name
]
}
An alternative could also be using Object.create(null)
instead of an empty object literal. In this case, the created object won’t inherit from Object.prototype
, meaning it won’t be harmed by __proto__
and friends.
const
registry
=
Object
.
create
(
null
)
function
set
(
name
,
meta
)
{
registry
[
name
]
=
meta
}
function
get
(
name
)
{
return
registry
[
name
]
}
For iteration we could create a list
function that returns key/value tuples.
const
registry
=
Object
.
create
(
null
)
function
list
()
{
return
Object
.
keys
(
registry
).
map
(
key
=>
[
key
,
registry
[
key
]])
}
Or we could implement the iterator protocol on our hash-map. Here we are trading complexity in favor of convenience: the iterator code is more complicated to read than the former case where we had a list
function with familiar Object.keys
and Array#map
methods. In the following example, however, accessing the list is even easier and more convenient than through list
: following the iterator protocol means there’s no need for a custom list
function.
const
registry
=
Object
.
create
(
null
)
registry
[
Symbol
.
iterator
]
=
()
=>
{
const
keys
=
Object
.
keys
(
registry
)
return
{
next
()
{
const
done
=
keys
.
length
===
0
const
key
=
keys
.
shift
()
const
value
=
[
key
,
registry
[
key
]]
return
{
done
,
value
}
}
}
}
console
.
log
([...
registry
])
When it comes to using nonstring keys, though, we hit a hard limit in ES5 code. Luckily for us, though, ES6 collections provide us with an even better solution. ES6 collections don’t have key-naming issues, and they facilitate collection behaviors, like the iterator we’ve implemented on our custom hash-map, out the box. At the same time, ES6 collections allow arbitrary keys, and aren’t limited to string keys like regular JavaScript objects.
Let’s plunge into their practical usage and inner workings.
Using ES6 Maps
ES6 introduces built-in collections, such as Map
, meant to alleviate implementation of patterns such as those we outlined earlier when building our own hash-map from scratch. Map
is a key/value data structure in ES6 that more naturally and efficiently lends itself to creating maps in JavaScript without the need for object literals.
First Look into ES6 Maps
Here’s how what we had earlier would have looked when using ES6 maps. As you can see, the implementation details we’ve had to come up with for our custom ES5 hash-map are already built into Map
, vastly simplifying our use case.
const
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'
})
console
.
log
([...
map
])
Once you have a map, you can query whether it contains an entry by a key
provided via the map.has
method.
map
.
has
(
'contra'
)
// <- true
map
.
has
(
'jquery'
)
// <- false
Earlier, we pointed out that maps don’t cast keys the way traditional objects do. This is typically an advantage, but you need to keep in mind that they won’t be treated the same when querying the map, either. The following example uses the Map
constructor, which takes an iterable of key/value pairs and then illustrates how maps don’t cast their keys to strings.
const
map
=
new
Map
([[
1
,
'the number one'
]])
map
.
has
(
1
)
// <- true
map
.
has
(
'1'
)
// <- false
The map.get
method takes a map entry key
and returns the value
if an entry by the provided key is found.
map
.
get
(
'contra'
)
// <- { description: 'Asynchronous flow control' }
Deleting values from the map is possible through the map.delete
method, providing the key
for the entry you want to remove.
map
.
delete
(
'contra'
)
map
.
get
(
'contra'
)
// <- undefined
You can clear the entries for a Map
entirely, without losing the reference to the map itself. This can be handy in cases where you want to reset state for an object.
const
map
=
new
Map
([[
1
,
2
],
[
3
,
4
],
[
5
,
6
]])
map
.
has
(
1
)
// <- true
map
.
clear
()
map
.
has
(
1
)
// <- false
[...
map
]
// <- []
Maps come with a read-only .size
property that behaves similarly to Array#length
—at any point in time it gives you the current amount of entries in the map.
const
map
=
new
Map
([[
1
,
2
],
[
3
,
4
],
[
5
,
6
]])
map
.
size
// <- 3
map
.
delete
(
3
)
map
.
size
// <- 2
map
.
clear
()
map
.
size
// <- 0
You’re able to use arbitrary objects when choosing map keys: you’re not limited to using primitive values like symbols, numbers, or strings. Instead, you can use functions, objects, dates—and even DOM elements, too. Keys won’t be cast to strings as we observe with plain JavaScript objects, but instead their references are preserved.
const
map
=
new
Map
()
map
.
set
(
new
Date
(),
function
today
()
{})
map
.
set
(()
=>
'key'
,
{
key
:
'door'
})
map
.
set
(
Symbol
(
'items'
),
[
1
,
2
])
As an example, if we chose to use a symbol as the key for a map entry, we’d have to use a reference to that same symbol to get the item back, as demonstrated in the following snippet of code.
const
map
=
new
Map
()
const
key
=
Symbol
(
'items'
)
map
.
set
(
key
,
[
1
,
2
])
map
.
get
(
Symbol
(
'items'
))
// not the same reference as "key"
// <- undefined
map
.
get
(
key
)
// <- [1, 2]
Assuming an array of key/value pair items
you want to include on a map, we could use a for..of
loop to iterate over those items
and add each pair to the map using map.set
, as shown in the following code snippet. Note how we’re using destructuring during the for..of
loop in order to effortlessly pull the key
and value
out of each two-dimensional item in items
.
const
items
=
[
[
new
Date
(),
function
today
()
{}],
[()
=>
'key'
,
{
key
:
'door'
}],
[
Symbol
(
'items'
),
[
1
,
2
]]
]
const
map
=
new
Map
()
for
(
const
[
key
,
value
]
of
items
)
{
map
.
set
(
key
,
value
)
}
Maps are iterable objects as well, because they implement a Symbol.iterator
method. Thus, a copy of the map can be created using a for..of
loop using similar code to what we’ve just used to create a map out of the items
array.
const
copy
=
new
Map
()
for
(
const
[
key
,
value
]
of
map
)
{
copy
.
set
(
key
,
value
)
}
In order to keep things simple, you can initialize maps directly using any object that follows the iterable protocol and produces a collection of [key, value]
items. The following code snippet uses an array to seed a newly created Map
. In this case, iteration occurs entirely in the Map
constructor.
const
items
=
[
[
new
Date
(),
function
today
()
{}],
[()
=>
'key'
,
{
key
:
'door'
}],
[
Symbol
(
'items'
),
[
1
,
2
]]
]
const
map
=
new
Map
(
items
)
Creating a copy of a map is even easier: you feed the map you want to copy into a new map’s constructor, and get a copy back. There isn’t a special new Map(Map)
overload. Instead, we take advantage that map
implements the iterable protocol and also consumes iterables when constructing a new map. The following code snippet demonstrates how simple that is.
const
copy
=
new
Map
(
map
)
Just like maps are easily fed into other maps because they’re iterable objects, they’re also easy to consume. The following piece of code demonstrates how we can use the spread operator to this effect.
const
map
=
new
Map
()
map
.
set
(
1
,
'one'
)
map
.
set
(
2
,
'two'
)
map
.
set
(
3
,
'three'
)
console
.
log
([...
map
])
// <- [[1, 'one'], [2, 'two'], [3, 'three']]
In the following piece of code we’ve combined several new features in ES6: Map
, the for..of
loop, let
variables, and a template literal.
const
map
=
new
Map
()
map
.
set
(
1
,
'one'
)
map
.
set
(
2
,
'two'
)
map
.
set
(
3
,
'three'
)
for
(
const
[
key
,
value
]
of
map
)
{
console
.
log
(
`
${
key
}
:
${
value
}
`
)
// <- '1: one'
// <- '2: two'
// <- '3: three'
}
Even though map items are accessed through a programmatic API, their keys are unique, just like with hash-maps. Setting a key over and over again will only overwrite its value. The following code snippet demonstrates how writing the 'a'
item over and over again results in a map containing only a single item.
const
map
=
new
Map
()
map
.
set
(
'a'
,
1
)
map
.
set
(
'a'
,
2
)
map
.
set
(
'a'
,
3
)
console
.
log
([...
map
])
// <- [['a', 3]]
ES6 maps compare keys using an algorithm called SameValueZero
in the specification, where NaN
equals NaN
but -0
equals +0
. The following piece of code shows how even though NaN
is typically evaluated to be different than itself, Map
considers NaN
to be a constant value that’s always the same.
console
.
log
(
NaN
===
NaN
)
// <- false
console
.
log
(
-
0
===
+
0
)
// <- true
const
map
=
new
Map
()
map
.
set
(
NaN
,
'one'
)
map
.
set
(
NaN
,
'two'
)
map
.
set
(
-
0
,
'three'
)
map
.
set
(
+
0
,
'four'
)
console
.
log
([...
map
])
// <- [[NaN, 'two'], [0, 'four']]
When you iterate over a Map
, 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 anyway: map[Symbol.iterator]
points to map.entries
. The .entries()
method returns an iterator for the key/value pairs in the map.
console
.
log
(
map
[
Symbol
.
iterator
]
===
map
.
entries
)
// <- true
There are two other Map
iterators you can leverage: .keys()
and .values()
. The first enumerates keys in a map while the second enumerates values, as opposed to .entries()
, which enumerates key/value pairs. The following snippet illustrates the differences between all three methods.
const
map
=
new
Map
([[
1
,
2
],
[
3
,
4
],
[
5
,
6
]])
console
.
log
([...
map
.
keys
()])
// <- [1, 3, 5]
console
.
log
([...
map
.
values
()])
// <- [2, 4, 6]
console
.
log
([...
map
.
entries
()])
// <- [[1, 2], [3, 4], [5, 6]]
Map entries are always iterated in insertion order. This contrasts with Object.keys
, which is specified to follow an arbitrary order. Although in practice, insertion order is typically preserved by JavaScript engines regardless of the specification.
Maps have a .forEach
method that’s equivalent in behavior to that in ES5 Array
objects. The signature is (value, key, map)
, where value
and key
correspond to the current item in the iteration, while map
is the map being iterated. Once again, keys do not get cast into strings in the case of Map
, as demonstrated here.
const
map
=
new
Map
([
[
NaN
,
1
],
[
Symbol
(),
2
],
[
'key'
,
'value'
],
[{
name
:
'Kent'
},
'is a person'
]
])
map
.
forEach
((
value
,
key
)
=>
console
.
log
(
key
,
value
))
// <- NaN 1
// <- Symbol() 2
// <- 'key' 'value'
// <- { name: 'Kent' } 'is a person'
Earlier, we brought up the ability of providing arbitrary object references as the key to a Map
entry. Let’s go into a concrete use case for that API.
Hash-Maps and the DOM
In ES5, whenever we wanted to associate a DOM element with an API object connecting that element with some library, we had to implement a verbose and slow pattern such as the one in the following code listing. That code returns an API object with a few methods associated to a given DOM element, allowing us to put DOM elements on a map from which we can later retrieve the API object for a DOM element.
const
map
=
[]
function
customThing
(
el
)
{
const
mapped
=
findByElement
(
el
)
if
(
mapped
)
{
return
mapped
}
const
api
=
{
// custom thing api methods
}
const
entry
=
storeInMap
(
el
,
api
)
api
.
destroy
=
destroy
.
bind
(
null
,
entry
)
return
api
}
function
storeInMap
(
el
,
api
)
{
const
entry
=
{
el
,
api
}
map
.
push
(
entry
)
return
entry
}
function
findByElement
(
query
)
{
for
(
const
{
el
,
api
}
of
map
)
{
if
(
el
===
query
)
{
return
api
}
}
}
function
destroy
(
entry
)
{
const
index
=
map
.
indexOf
(
entry
)
map
.
splice
(
index
,
1
)
}
One of the most valuable aspects of Map
is the ability to index by any object, such as DOM elements. That, combined with the fact that Map
also has collection manipulation abilities greatly simplifies things. This is crucial for DOM manipulation in jQuery and other DOM-heavy libraries, which often need to map DOM elements to their internal state.
The following example shows how Map
would reduce the burden of maintenance in user code.
const
map
=
new
Map
()
function
customThing
(
el
)
{
const
mapped
=
findByElement
(
el
)
if
(
mapped
)
{
return
mapped
}
const
api
=
{
// custom thing api methods
destroy
:
destroy
.
bind
(
null
,
el
)
}
storeInMap
(
el
,
api
)
return
api
}
function
storeInMap
(
el
,
api
)
{
map
.
set
(
el
,
api
)
}
function
findByElement
(
el
)
{
return
map
.
get
(
el
)
}
function
destroy
(
el
)
{
map
.
delete
(
el
)
}
The fact that mapping functions have become one-liners thanks to native Map
methods means we could inline those functions instead, as readability is no longer an issue. The following piece of code is a vastly simplified alternative to the ES5 piece of code we started with. Here we’re not concerned with implementation details anymore, but have instead boiled the DOM-to-API mapping to its bare essentials.
const
map
=
new
Map
()
function
customThing
(
el
)
{
const
mapped
=
map
.
get
(
el
)
if
(
mapped
)
{
return
mapped
}
const
api
=
{
// custom thing api methods
destroy
:
()
=>
map
.
delete
(
el
)
}
map
.
set
(
el
,
api
)
return
api
}
Maps aren’t the only kind of built-in collection in ES6; there’s also WeakMap
, Set
, and WeakSet
. Let’s proceed by digging into WeakMap
.