While dealing with Node, we basically have no other choice than write code filled with callbacks. But why do we have callbacks in the first place?
The Node engine is single-threaded and it means we share its only thread with all active users. With this single thread limitation comes another term—Event Loop. The event loop is the state when an application waiting for events that may occurred. What kind of events? It may be a finished database query or an incoming HTTP request.
When an event occurs, Node will add a corresponding event handler—a callback—to the execution queue. Since we stuck with a single thread, the event handlers from the queue will be executed one at the time. If any of callbacks contain an interaction with a database or any other I/O operation, Node will leave the execution of this operation to the system and continue to process other handlers from the queue. When the system finishes the interaction with the I/O, it will notice the Node and a new event handler will be added to the execution queue. This is asynchronous execution with the idea of non-blocking I/O.
The standard Node API contains some methods for performing synchronous interaction with the file system. But using them we are blocking the application from doing something useful at the same time. For example, while synchronously copying files for some user, we block the application from the processing of incoming HTTP requests from other users.
The Apache web server in this situation will create a new thread for each user. And every HTTP-request will be processed without waiting while the file copying for the first user is completed. But at what cost? Every new thread will consume memory and increase the CPU load. Every new thread will have its own connection to the database, so it does not only affect the web server but the whole system.
With Node-based applications, we may get the better performance with spending fewer resources.
It may sound reasonable, but on a practice, we may end writing code like this:
The release of the ECMAScript 2015 specification brings us a native (but not new) approach to handling asynchronous code—Promises. With Promises, we can structure our code to the chain of methods when each method will be executed after the previous one is completed.
The example below shows how to wrap an asynchronous function into a Promise object:
In a callback of an asynchronous function, we can pass a result of the execution further by calling the
resolve function which is given as an argument. This process called as fulfilling a promise.
In the case of an error—like a file doesn’t exist or can’t be opened from the example above—a promise can be rejected.
When a function returns a Promise object, it contains the
then method that accepts two callbacks as arguments:
The first callback (onFulfilled) contains a fulfilled value or values. The second one (onRejected) contains a rejected value, usually an error.
It’s possible to omit the second callback and catch thrown errors like in the example bellow:
And when all asynchronous functions wrapped into promises, we can get rid of nested callbacks and have a code structure like this:
Another great feature of ECMAScript 2015 is a generator. A generator function allows us to pause the function at the time of execution and resume it later.
A generator function can be declared using this expression:
Notice, the asterisk sign after the
function keyword. It is what makes a function generator.
Let’s have a look at a generator in action:
The call of a generator doesn’t execute a whole function body. Instead, it returns an iterator object. This object contains the next method. With this method, we execute the function body until the first
yield statement. After it, the function pauses.
If we need to resume the execution, we call again the
next method. Every time we call the method, it returns the current state of a function with a yield value and the
done property. This property stores a boolean value, by which we can determine if the whole generator function has been executed—all
yield values has been received.
Combining Promises and Generators
We can combine promises and generators and instead of yielding simple values, we will yield promises. Let’s see the full example, in which we read a text file, process the text, and save the result into another file. At the end of the function, we return the result of the text processing.
The example below uses arrow functions. Make sure you understand the concept.
Our script consists of the two promises for handling reading and writing files. Next, we have the generator function in which we write the logic of the script. To provide the step-by-step execution of statements, every promise object should be yielded.
Another important function called
execute takes a generator function and a yielded value as arguments:
This function recursively executes the generator by calling the
next method. Until the generator is fully executed, the function receives promise objects at their completion.
If some promise throws an error, we catch it and throw it into the generator by calling the
generator.throw method. We catch the same error twice. Since we want to make this function universal with the ability to work with any generator that yields promises, it’s a good way to handle the concrete error situation in the generator function itself.
At the end of execution of the generator when its property
done equal to true, we will get the final result of the generator function.