Iterators and generators were introduced in 2015 as a part of the ES6 addition. We might not use them in our daily coding but behind the scenes, these features for instance allowed the implementation of destructuring, spread/rest operators, for..of loops, and async/await which are actually parts of the language we interact with every day.
Why are iterators useful?
Imagine you have an array of names and you need to print out all of the elements. The most intuitive way from a beginner's perspective is to use a classic for loop
const names = ["Jake", "Cody", "Hannah"]
for(let i = 0; i < names.length; i++) {
console.log(names[i])
}
// CONSOLE
// "Jake"
// "Cody"
// "Hannah"
To access each element, we needed to create "an algorithm" on our own. Iterators are shifting this model and allowing us to think about data as flows of information instead of a collection.
At this point go and review closure as it's essential to understand what we are gonna do next.
Article No Longer Available
const nextElement = (function(arr){
let count = 0
return function() {
return arr[count++]
}
})(["Jake", "Cody", "Hannah"])
nextElement() // "Jake"
nextElement() // "Cody"
nextElement() // "Hannah"
nextElement() // "undefined"
Now we have the ability to iterate over an array by simply calling a function that remembers the current index and returns the correct element. This is called the iterator pattern.
Luckily for us, JavaScript has this feature built-in.
const names = ["Jake", "Cody", "Hannah"]
const it = names[Symbol.iterator]()
it.next() {value: "Jake", done: false}
//more calls ...
it.next() {value: undefined, done: true}
We can even define our own iterator for data types that are not iterable - objects. For..in loop does not use iterators.
const person = {
name: "Jake",
age: 16,
hobby: "Annoy my twin brother",
[Symbol.iterator]: function() {
const keys = Object.keys(this)
let count = 0
return {
next: () => (
count < keys.length ?
{ done: false, value: this[keys[count++]] } :
{ done: true, value: undefined}
)
}
}
}
console.log([...person]) // ['Jake', 16, 'Annoy my twin brother']
In the code above we are implementing the iterator protocol on the person object, which allows us to use the spread operator on it. We designed it so when we call next(), we get the next value in the object. Notice the use of the arrow function which does not change the reference of the this keyword.
Now, we have an idea of what iterators allow us to do. So, we can formally define them: Iterators are objects that define the iterator protocol by having a next() method that returns an object with two properties (value and done).
Generators
Our implementation of the iterator works, but it is fairly complicated. To simplify that behaviour, we can use generator functions, that return an iterator object with our well-known next() method.
To declare a generator function we use the * in front of the function name. The values, that the iterator will go over are defined by the yield operator. The generator function returns for us the iterator object with the next() method.
function *generator() {
yield "Jake"
yield "Cody"
yield "Hannah"
}
const it = generator()
it.next() // {value: "Jake", done: false}
console.log([...generator()]) // ["Jake", "Cody", "Hannah"]
Now we can adjust our person object using a generator function
const person = {
name: "Jake",
age: 16,
hobby: "Annoy my twin brother",
*[Symbol.iterator]() {
for (let key of Object.keys(this)) {
yield this[key]
}
}
}
Implementing async/await functionality from scratch
For this bit, you need to be comfortable using promises.
Article No Longer Available
Before we start implementing our version of async/await, it is important to understand how to pass arguments into the next() method.
function *generator() {
const num = yield 5
yield num * 7
yield 2
}
const it = generator()
it.next() // {value: 5, done: false}
it.next(3) // {value: 21, done: false}
it.next() // {value: 2, done}
In the snippet above we can see, that the yield operator immediately returns the value 5, and when we call the next(3) the number three is placed right where the last yield appeared.
With that knowledge, we finally possess all the power to create our async/await as it's just syntactic sugar for generators used with promises.
function* asyncGenerator() {
const data = yield fetch('someAPI/posts/1') // "AWAIT"
console.log(data)
}
// BEHIND THE SCENES STUFF
const it = asyncGenerator()
const futureData = it.next().value
futureData
.then((res) => res.json())
.then((post) => it.next(post))
// CONSOLE
// {id: 1, post: "Hello there"}
When we call it.next() for the first time we receive the Promise object as the value property, we attach the two callbacks to it which are triggered when the promise is resolved. The second callback is crucial as we finally have the post and we call next() on our iterator. The post is then passed back to the asyncGenerator and assigned to the data variable and finally, it gets printed.
The snippet above should give you an idea about what is happening behind the scenes when we use async/await.
Discussion (1)
Thanks for the deep explanation