Jason Pan

潘忠显 / 2021-04-23


“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显

How JavaScript works: iterators + tips on gaining advanced control over generators

Alexander Zlatkov

Alexander ZlatkovFollow

Mar 11 · 7 min read

img

This is post # 23 of the series, dedicated to exploring JavaScript and its building components. In the process of identifying and describing the core elements, we also share some rules of thumb we use when building SessionStack, a JavaScript application that needs to be robust and highly-performant to help companies optimize the digital experience of their users.

Overview

Processing each item in a collection is a very common operation, no matter the programming language. JavaScript is no exception and provides a number of ways to iterate over collections. The spectrum ranges from simple for loops to the more complex map() and filter().

Iterators and Generators bring the concept of iteration, build into the core of JavaScript and provide a mechanism for customizing the behavior of for…of loops.

Iterators

In JavaScript, an iterator is an object which defines a sequence and potentially a return value upon its termination.

An iterator can be any object that implements the Iterator interface. This means that it needs to have a next() method that returns an object with two properties:

Once created, an iterator object can be iterated by invoking the next() method. After the last value in the sequence has been reached, additional calls to next() should continue returning {done: true}.

Using Iterators

Sometimes it might require too many resources in order to allocate an array with values and loop through each of them. Iterators, on the other hand, are consumed only as necessary. This gives the potential of iterators to even express sequences of unlimited size.

Here is an example that shows the creation of a simple iterator that generates the Fibonacci Sequence:

function makeFibonacciSequenceIterator(endIndex = Infinity) {
  let currentIndex = 0;
  let previousNumber = 0;
  let currentNumber = 1;

  return {
    next: () => {
      if (currentIndex >= endIndex) { 
          return { value: currentNumber, done: true }; 
      }

      let result = { value: currentNumber, done: false };
      let nextNumber = currentNumber + previousNumber;
      previousNumber = currentNumber;
      currentNumber = nextNumber;
      currentIndex++;

      return result;
    }
  };
}

The makeFibonacciSequenceIterator simply starts generating the Fibonacci numbers and stops when it reaches the endIndex. The iterator returns the current Fibonacci number on each iteration and continues returning the last generated number upon completion.

This is how the Fibonacci numbers can be generated through the iterator above:

let fibonacciSequenceIterator = makeFibonacciSequenceIterator(5); // Generates the first 5 numbers.
let result = fibonacciSequenceIterator.next();
while (!result.done) {
    console.log(result.value); // 1 1 2 3 5 8
    result = fibonacciSequenceIterator.next();
}

Defining Iterables

The way the iterator is created above could create potential problems since there is no way to validate whether it is a valid iterator or not. Yes, the returned value contains a next() function but this could be just a coincidence. Many objects could have a next() function defined while not being actually iterable.

This is why JavaScript has one more requirement in order to properly define an iterable object.

The Fibonacci example above won’t be recognized by JavaScript as an iterable object and this can be tested by trying to iterate through the sequence with a for…of loop:

let fibonacciSequenceIterator = makeFibonacciSequenceIterator(5);

for (let x of fibonacciSequenceIterator) {
    console.log(x);
}

The code above will produce the following exception:

Uncaught TypeError: fibonacciSequenceIterator is not iterable

Some built-in types, such as Array or Map, have a default iteration behavior, while other types (such as Object) do not.

In order to be iterable, an object must implement the @@iteratorby having a property with a Symbol.iterator key. The property-definition should be a function that returns the items to be iterated.

Let’s see how the above Fibonacci example looks like if we create an iterable object:

function makeFibonacciSequenceIterator(endIndex = Infinity) {
  let currentIndex = 0;
  let previousNumber = 0;
  let currentNumber = 1;

  let iterator = {};
  iterator[Symbol.iterator] = () => {
    return {
      next: () => {
        if (currentIndex >= endIndex) { 
            return { value: currentNumber, done: true }; 
        }
        
        const result = { value: currentNumber, done: false };
        const nextNumber = currentNumber + previousNumber;
        previousNumber = currentNumber;
        currentNumber = nextNumber;
        currentIndex++;

        return result;
      }
    }
  };

  return iterator;
}

Now that we have an iterable object, we can use the for…of operator to iterate through it:

let fibonacciSequenceIterator = makeFibonacciSequenceIterator(5);

for (let x of fibonacciSequenceIterator) {
    console.log(x); //1 1 2 3 5 8
}

Generators

Custom iterators are very useful and can bring great efficiency in certain use-cases. Their creation and maintenance, however, requires careful programming due to the need to explicitly maintain their internal state.

Generator functions provide a powerful alternative by allowing you to define an iterative procedure by writing a single function whose execution is not continuous. Generator functions are written using the function* syntax.

When called, generator functions do not initially execute their code. Instead, they return a special type of iterator, called a Generator. When a value is consumed by calling the generator’s next method, the Generator function executes until it encounters the yield keyword.

A generator can be thought of as a function that can produce a series of values instead of a single value, as it is being continuously invoked.

The syntax of generators includes an operator called yield which allows pausing the function until the next value is requested.

Let’s look at how the Fibonacci example can be rewritten using a generator:

function* makeFibonacciSequenceGenerator(endIndex = Infinity) {
    let previousNumber = 0;
    let currentNumber = 1;
    
    for (let currentIndex = 0; currentIndex < endIndex; currentIndex++) {
        yield currentNumber;
        let nextNumber = currentNumber + previousNumber;
        previousNumber = currentNumber;
        currentNumber = nextNumber;
    }
}

let fibonacciSequenceGenerator = makeFibonacciSequenceGenerator(5);

for (let x of fibonacciSequenceGenerator) {
    console.log(x);
}

It can easily be seen that this implementation is much easier to implement and maintain.

Advanced Control Over Generators

Iterators define the next() function explicitly in order to implement the required interface by JavaScript. With generators, the next() function is added implicitly but it still exists. And this is how generators produce valid iterables.

The implicitly defined next() function of the generator accepts an argument that can be used to modify the internal state of the generator. A value passed to next() will be received by the yield statement.

Let’s further modify the Fibonacci example so that you can control how many numbers can be skipped on each step of the sequence:

function* makeFibonacciSequenceGenerator(endIndex = Infinity) {
    let previousNumber = 0;
    let currentNumber = 1;
    let skipCount = 0;
    
    for (let currentIndex = 0; currentIndex < endIndex; currentIndex++) {
        if (skipCount === 0) {
            skipCount = yield currentNumber; // skipCount is the parameter passed through the invocation of `fibonacciSequenceGenerator.next(value)` below.
            skipCount = skipCount === undefined ? 0 : skipCount; // makes sure that there is an input
        } else if (skipCount > 0){
            skipCount--;
        }
        
        let nextNumber = currentNumber + previousNumber;
        previousNumber = currentNumber;
        currentNumber = nextNumber;
    }
}

let fibonacciSequenceGenerator = makeFibonacciSequenceGenerator(50);

console.log(fibonacciSequenceGenerator.next().value);  // prints 1
console.log(fibonacciSequenceGenerator.next(3).value); // prints 5 since 1, 2, and 3 are skipped.
console.log(fibonacciSequenceGenerator.next().value);  // prints 8
console.log(fibonacciSequenceGenerator.next(1).value); // prints 21 since 13 is skipped.

It’s important to note that a value passed to the first invocation of next() is always ignored.

Another important feature is the ability to force a generator to throw an exception by calling its throw() method and passing the exception value it should throw. This exception will be thrown from the current suspended context of the generator as if the yield that is currently suspended were instead a throw value statement.

If the exception is not caught within the generator, it will propagate up through the external call of throw(), and subsequent calls to next() will result in the done property being true. Let’s look at the following example:

function* makeFibonacciSequenceGenerator(endIndex = Infinity) {
    let previousNumber = 0;
    let currentNumber = 1;
    let skipCount = 0;
    
    try {
      for (let currentIndex = 0; currentIndex < endIndex; currentIndex++) {
          if (skipCount === 0) {
              skipCount = yield currentNumber;
              skipCount = skipCount === undefined ? 0 : skipCount;
          } else if (skipCount > 0){
              skipCount--;
          }
 
          let nextNumber = currentNumber + previousNumber;
          previousNumber = currentNumber;
          currentNumber = nextNumber;
      }
    } catch(err) {
    	console.log(err.message); // will print ‘External throw’ on the fourth iteration.
    }
}
 
let fibonacciSequenceGenerator = makeFibonacciSequenceGenerator(50);

console.log(fibonacciSequenceGenerator.next(1).value);
console.log(fibonacciSequenceGenerator.next(3).value);
console.log(fibonacciSequenceGenerator.next().value);
fibonacciSequenceGenerator.throw(new Error('External throw'));
console.log(fibonacciSequenceGenerator.next(1).value); // undefined will be printed since the generator is done.

A generator can also be terminated by invoking the return(value) method which returns the given value:

let fibonacciSequenceGenerator = makeFibonacciSequenceGenerator(50);

console.log(fibonacciSequenceGenerator.next().value); // 1
console.log(fibonacciSequenceGenerator.next(3).value); // 5
console.log(fibonacciSequenceGenerator.next().value);   // 8
console.log(fibonacciSequenceGenerator.return(374).value); // 374
console.log(fibonacciSequenceGenerator.next(1).value); // undefined

Async Generators

A generator can be defined and used in an async context. An async generator can asynchronously generate a sequence of values

The syntax is quite straightforward. The async keyword needs to be prepended to the function* definition of the generator.

When iterating over the generated sequence, the await keyword needs to be used in the for…of construct.

Let’s modify the Fibonacci example so that it generates the sequence with predefined timeouts:

async function* makeFibonacciSequenceGenerator(endIndex = Infinity) {
    let previousNumber = 0;
    let currentNumber = 1;
    
    for (let currentIndex = 0; currentIndex < endIndex; currentIndex++) {
        await new Promise(resolve => setTimeout(resolve, 1000)); // a simple timeout as an example.
        yield currentNumber;
        let nextNumber = currentNumber + previousNumber;
        previousNumber = currentNumber;
        currentNumber = nextNumber;
    }
}

(async () => {
  const fibonacciSequenceGenerator = makeFibonacciSequenceGenerator(6);
  for await (let x of fibonacciSequenceGenerator) {
    console.log(x); // 1, then 1, then 2, then 3, then 5, then 8 (with delay in between).
  }
})();

As the generator is asynchronous, we can use await inside it, rely on promises, perform network requests, and so on. The next() method of the generator returns a Promsie.

If for some reason you don’t want to use generators but want to define an iterable, you have to use Symbol.asyncIterator rather than Symbol.iterator as it was done before.

Even though generators are much simpler to create and maintain compared to iterators, they could be more difficult to debug compared to normal functions. This is especially true in asynchronous contexts. There might be many reasons for this. An example of such could be a quite limited stack trace when invoking the throw() method externally. Debugging in such cases might be quite impossible from the available technical information and you might have to ask your users for more context.

To optimize the troubleshooting efforts, you can use a tool like SessionStack, where you can replay JavaScript errors as if they happened in your browser. You can visually replay the exact user steps that led to the error, see the device, resolution, network, and all of the data that might be needed to connect the dots.

There is a free trial if you’d like to give SessionStack a try.

img

SessionStack replaying a session

If you missed the previous chapters of the series, you can find them here:

Resources: