潘忠显 / 2021-04-23
“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显。
How JavaScript works: iterators + tips on gaining advanced control over generators
Mar 11 · 7 min read
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:
value
: the next value in the sequence.done
: this value is true if the last value in the sequence has been consumed. If the value property is also present, it’s the iterator’s return value.
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 @@iterator
by 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.
SessionStack replaying a session
If you missed the previous chapters of the series, you can find them here:
- An overview of the engine, the runtime, and the call stack
- Inside Google’s V8 engine + 5 tips on how to write optimized code
- Memory management + how to handle 4 common memory leaks
- The event loop and the rise of Async programming + 5 ways to better coding with async/await
- Deep dive into WebSockets and HTTP/2 with SSE + how to pick the right path
- A comparison with WebAssembly + why in certain cases it’s better to use it over JavaScript
- The building blocks of Web Workers + 5 cases when you should use them
- Service Workers, their life-cycle, and use cases
- The mechanics of Web Push Notifications
- Tracking changes in the DOM using MutationObserver
- The rendering engine and tips to optimize its performance
- Inside the Networking Layer + How to Optimize Its Performance and Security
- Under the hood of CSS and JS animations + how to optimize their performance
- Parsing, Abstract Syntax Trees (ASTs) + 5 tips on how to minimize parse time
- The internals of classes and inheritance + transpiling in Babel and TypeScript
- Storage engines + how to choose the proper storage API
- The internals of Shadow DOM + how to build self-contained components
- WebRTC and the mechanics of peer to peer connectivity
- Under the hood of custom elements + Best practices on building reusable components
- How JavaScript works: exceptions + best practices for synchronous and asynchronous code
- How JavaScript works: 5 types of XSS attacks + tips on preventing them
- How JavaScript works: CSRF attacks + 7 mitigation strategies
Resources: