Flow Control in Modern JS: Callbacks to Promises to Async/Await
Translator: OFED
JavaScript is often thought of as asynchronous. What does that mean? What is the impact on development? How has it changed in recent years?
Take a look at the following code:
result1 = doSomething1();
result2 = doSomething2(result1);Copy the code
Most programming languages execute each line of code synchronously. The first line completes and returns a result. No matter how long the first line of code is executed, the second line of code will not be executed until execution is complete.
Single-threaded handler
JavaScript is single-threaded. When the browser TAB executes the script, everything else stops. This is necessary because changes to the page DOM cannot be executed concurrently; It is dangerous for one thread to redirect a URL at the same time that another thread is adding child nodes.
It is not noticeable to the user because the processor executes quickly in chunks. For example, JavaScript detects a button click, runs the calculation, and updates the DOM. Once that’s done, the browser is free to process the next item in the queue.
(Side note: other languages such as PHP are also single-threaded, but managed by a multi-threaded server such as Apache. Two simultaneous requests from the same PHP page can start two threads running, which are separate PHP instances.
Asynchrony through callbacks
Single threading presents a problem. What happens when JavaScript executes a “slow” handler, such as an Ajax request in a browser or a database operation on a server? These operations can take seconds – even minutes. The browser is locked while waiting for a response. On the server, node.js applications will not be able to handle other user requests.
The solution is asynchronous processing. When the result is ready, a process is told to call another function instead of waiting for completion. This is called a callback and is passed as an argument to any asynchronous function. Such as:
doSomethingAsync(callback1); console.log('finished'); Function CallBack1 (error) {if (! error) console.log('doSomethingAsync complete'); }Copy the code
DoSomethingAsync () accepts a callback function as an argument (only a reference to that function is passed, so the overhead is minimal). It doesn’t matter how long doSomethingAsync() executes; All we know is that callback1() will execute at some point in the future. The console will display:
finished
doSomethingAsync completeCopy the code
The callback hell
Typically, callbacks are called only by an asynchronous function. Therefore, a concise, anonymous inline function can be used:
doSomethingAsync(error => { if (! error) console.log('doSomethingAsync complete'); });Copy the code
A series of two or more asynchronous calls can be completed consecutively by nested callback functions. Such as:
async1((err, res) => { if (! err) async2(res, (err, res) => { if (! err) async3(res, (err, res) => { console.log('async1, async2, async3 complete.'); }); }); });Copy the code
Unfortunately, this introduces callback hell — a concept so notorious that it even has a dedicated web page about it! The code is hard to read and gets worse when error-handling logic is added.
Callback hell is relatively rare in client-side coding. If you make Ajax requests, update the DOM, and wait for the animation to complete, you might need to nest two or three layers, but it’s usually manageable.
Operating systems or server processes are different. A Node.js API can receive file uploads, update multiple database tables, write logs, and make the next API call before sending a response.
Promises
ES2015(ES6) introduces Promises. Callbacks are still useful, but Promises offers a clearer syntax for chained asynchronous commands, so they can be run in series (more on that in the next section).
To build on Promise encapsulation, asynchronous callback functions must return a Promise object. The Promise object performs one of two functions (passed as arguments) :
resolve
: The callback is successfully executedreject
: Failed to execute the callback
In the following example, the Database API provides a connect() method that receives a callback function. The external asyncDBconnect() function immediately returns a new Promise, and resolve() or reject() is executed once the connection is created successfully or fails:
const db = require('database'); Function asyncDBconnect(param) {return new Promise(resolve, reject) => {db.connect(param, (err, err)) connection) => { if (err) reject(err); else resolve(connection); }); }); }Copy the code
Node.js 8.0 + provides util.promisify() to convert callback-based functions to promise-based functions. There are two conditions of use:
- Pass in a unique asynchronous function
- The function passed in wants to be error-first (e.g. :(err, value) =>…). The error parameter comes before the value parameter
For example:
// Node.js: Const util = require('util'), fs = require('fs'), readFileAsync = uti.promisify (fs.readfile); readFileAsync('file.txt');Copy the code
All libraries offer their own promisify methods, and a few lines are optional:
// promisify accepts only one function argument // incoming function accepts (err, Data) parameter function promisify(fn) {return function() {return new Promise((resolve, reject) => fn(... Array.from(arguments), (err, data) => err ? reject(err) : resolve(data) ) ); Function wait(time, callback) {setTimeout(() => {callback(null, 'done'); }, time); } const asyncWait = promisify(wait); ayscWait(1000);Copy the code
Asynchronous chain call
Any function that returns a Promise can be called via the.then() chain. The result of the former resolve is passed to the latter:
AsyncDBconnect ('http://localhost:1234').then(asyncGetSession) // Passes the result of asyncDBconnect. Then (asyncGetUser) // passes AsyncGetSession result. Then (asyncLogAccess) // Pass the result of asyncGetUser. Then (result => {// sync function console.log('complete'); // (passing asyncLogAccess) return result; // (the result is passed to the next.then())}). Catch (err => {// any reject triggers console.log('error', err); });Copy the code
The synchronization function can also execute.then(), passing the returned value to the next.then() (if any).
The.catch() function is called when either of the preceding reject methods fires. The.then() after the reject function is no longer executed. Multiple.catch() methods can exist throughout the chain to catch different errors.
ES2018 introduced the.finally() method, which performs the final logic regardless of the result returned – for example, cleaning up operations, closing database connections, and so on. Currently only Chrome and Firefox support it, but the TC39 Technical Committee has released a.finally() patch.
function doSomething() { doSomething1() .then(doSomething2) .then(doSomething3) .catch(err => { console.log(err); }).finally(() => { }); }Copy the code
Use promise.all () to handle multiple asynchronous operations
The promise.then () method is used for asynchronous functions that execute sequentially. If you don’t care about the order – for example, initializing unrelated components – all asynchronous functions start at the same time until the slowest function executes resolve, ending the process.
Promise.all() works for this scenario, which takes an array of functions and returns another Promise. For example:
Promise.all([async1, async2, async3]).then(values => {// Return the value of the array console.log(values); // return values; }). Catch (err => {// any reject is triggered console.log('error', err); });Copy the code
Any asynchronous function reject, promise.all () ends immediately.
Use promise.race () to handle multiple asynchronous operations
Promise.race() is very similar to promise.all (), except that when the first Promise resolves or rejects, it will resolve or reject. Only the fastest asynchronous functions are executed:
Promise.race([async1, async2, async3]).then(value => {// single value console.log(value); return value; }). Catch (err => {// any reject is triggered console.log('error', err); });Copy the code
Is the future bright?
Promise reduces callback hell, but introduces other problems.
Tutorials often fail to mention that the entire Promise chain is asynchronous, and that a series of Promise functions must either return their own promises or perform callbacks in the final.then(),.catch(), or.finally() methods.
I’ll admit it: Promise haunted me for a long time. The syntax looks more complicated than the callback, with lots of things going wrong and debugging becoming a problem. However, it is important to learn the basics.
Read more:
- MDN Promise documentation
- JavaScript Promises: an Introduction
- JavaScript Promises… In Wicked Detail
- Promises for asynchronous programming
Async/Await
Promises look a bit complicated, so ES2017 introduces async and await. While just syntactic candy, it makes promises more convenient and avoids the.then() chain-call problem. Look at the following example using Promise:
function connect() { return new Promise((resolve, reject) => { asyncDBconnect('http://localhost:1234') .then(asyncGetSession) .then(asyncGetUser) .then(asyncLogAccess) .then(result => resolve(result)) .catch(err => reject(err)) }); } // Run connect(self-executing method) (() => {connect(); .then(result => console.log(result)) .catch(err => console.log(err)) })();Copy the code
Rewrite the above code with async/await:
- External methods
async
The statement - Asynchronous methods based on Promise
await
Declaration to ensure that the next command is executed before it has been completed
async function connect() { try { const connection = await asyncDBconnect('http://localhost:1234'), session = await asyncGetSession(connection), user = await asyncGetUser(session), log = await asyncLogAccess(user); return log; } catch (e) { console.log('error', err); return null; }} // Run connect(self-executing asynchronous function) (async () => {await connect(); }) ();Copy the code
Await makes each asynchronous call look synchronous without delaying single-threaded JavaScript processing. In addition, async functions always return a Promise object, so it can be called by other async functions.
Async/await may not make code less, but it has many advantages:
- The syntax is clearer. There are fewer parentheses and fewer errors.
- Debugging is easier. Can be found at any
await
Set a breakpoint at the declaration. - Error handling is good.
try
/catch
You can use the same processing as synchronized code. - Good support. All browsers (except IE and Opera Mini) and Node7.6+ are implemented.
As such, there is no perfect…
Promises, Promises
Async/await still relies on Promise objects and ultimately on callbacks. You need to understand how Promise works, and it’s not the same as promise.all () and promise.race (). What is easier to overlook is promise.all (), which is more efficient than using a series of unrelated await commands.
Asynchronous wait in a synchronous loop
In some cases, you want to call asynchronous functions in a synchronous loop. Such as:
async function process(array) { for (let i of array) { await doSomething(i); }}Copy the code
Does not work, nor does the following code:
async function process(array) {
array.forEach(async i => {
await doSomething(i);
});
}Copy the code
The loop itself remains synchronous and always completes before the internal asynchronous operation.
ES2018 introduces asynchronous iterators, similar to regular iterators, except that the next() method returns a Promise object. Therefore, the await keyword can be associated with for… The of loop is used together to run asynchronous operations in a serial fashion. Such as:
async function process(array) { for await (let i of array) { doSomething(i); }}Copy the code
However, until asynchronous iterators are implemented, the best scenario is to map each item of the array into an async function and execute them with promise.all (). Such as:
const
todo = ['a', 'b', 'c'],
alltodo = todo.map(async (v, i) => {
console.log('iteration', i);
await processSomething(v);
});
await Promise.all(alltodo);Copy the code
This makes it easier to perform parallel tasks, but there is no way to pass the results of one iteration to another, and mapping large arrays can consume computational performance.
Ugly try/catch
Async exits silently if an await fails without wrapping a try/catch. If you have a long list of asynchronous await commands, you need multiple try/catch packages.
The alternative is to use higher-order functions to catch errors, eliminating the need for try/catch (thanks @wesbos for the suggestion) :
async function connect() { const connection = await asyncDBconnect('http://localhost:1234'), session = await asyncGetSession(connection), user = await asyncGetUser(session), log = await asyncLogAccess(user); return true; } function catchErrors(fn) {return function (... args) { return fn(... args).catch(err => { console.log('ERROR', err); }); } } (async () => { await catchErrors(connect)(); }) ();Copy the code
This approach is not very practical when applications must return errors that distinguish them from others.
Async /await is a very useful complement to JavaScript despite its drawbacks. More resources:
- MDN async and await
- Asynchronous functions – Improve the ease of use of Promises
- TC39 specification for asynchronous functions
- Simplify asynchronous coding with asynchronous functions
JavaScript trip
Asynchronous programming is an unavoidable challenge for JavaScript. Callbacks are essential in most applications, but it’s easy to get bogged down in deeply nested functions.
Promise abstracts the callback, but has many syntactic pitfalls. Converting existing functions can be a chore, and the then() chain calls look messy.
Fortunately async/await is clear. The code appears to be synchronous, but does not monopolize a single processing thread. It will change the way you write JavaScript and even make you appreciate Promise more if you haven’t.