ponyfoo.com

Practical Modern JavaScript

Dive into ES6 and the future of JavaScript — Modular JavaScript Book Series
O’Reilly Media334 PagesISBN 978-1-4919-4353-3

“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 in Object.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 on Object#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.

Unlock with one Tweet!
Grants you full online access to Practical Modern JavaScript!
You can also read the book on the public git repository, but it won’t be as pretty! 😅