Understanding Async/Await in JavaScript with Examples

The author of the article analyzes Async / Await in JavaScript with examples. In general, Async/Await is a convenient way to write asynchronous code. Before this feature appeared, such code was written using callbacks and promises. The author of the original article breaks down the benefits of Async/Await by looking at various examples.

We remind you: for all readers of "Habr" - a discount of 10 rubles when enrolling in any Skillbox course using the "Habr" promotional code.

Skillbox recommends: Educational online course "Java developer".

call Back

Callback is a function whose call is delayed indefinitely. Previously, callbacks were used in those parts of the code where the result could not be obtained immediately.

Here is an example of asynchronously reading a file in Node.js:

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

Problems arise when you need to perform several asynchronous operations at once. Let's imagine the following scenario: a request is made to the database of the Arfat user, you need to read his profile_img_url field and download the image from the someserver.com server.
After uploading, we convert the image to another format, for example, from PNG to JPEG. If the conversion was successful, a letter is sent to the user's mail. Further information about the event is entered into the transformations.log file with the date.

It is worth paying attention to the overlapping callbacks and the large number of }) in the final part of the code. It's called Callback Hell or Pyramid of Doom.

The disadvantages of this method are obvious:

  • This code is hard to read.
  • It is also difficult to handle errors, which often leads to poor code quality.

Promises have been added to JavaScript to solve this problem. They allow you to replace the deep nesting of callbacks with the word .then.

The good thing about promises is that they make code much easier to read, from top to bottom instead of left to right. However, promises also have their own problems:

  • You need to add a lot of .then.
  • Instead of try/catch .catch is used to handle all errors.
  • Working with several promises within one cycle is far from always convenient, in some cases they complicate the code.

Here is a task that will show the value of the last item.

Suppose you have a for loop that outputs a sequence of numbers from 0 to 10 at a random interval (0-n seconds). Using promises, you need to change this loop so that the numbers are displayed in sequence from 0 to 10. So, if the output of zero takes 6 seconds, and the units - 2 seconds, zero should be output first, and then the count of the output of one will begin.

And of course, we don't use Async/Await or .sort to solve this problem. An example solution is at the end.

Async Functions

The addition of async functions in ES2017 (ES8) has made the task of working with promises easier. I note that async functions work "on top" of promises. These functions do not represent qualitatively different concepts. Async functions were conceived as an alternative to code that uses promises.

Async/Await makes it possible to organize work with asynchronous code in a synchronous style.

Thus, knowledge of promises makes it easier to understand the principles of Async/Await.

Syntax

In a normal situation, it consists of two keywords: async and await. The first word makes the function asynchronous. Such functions allow the use of await. In any other case, using this function will cause an error.

// With function declaration
 
async function myFn() {
  // await ...
}
 
// With arrow function
 
const myFn = async () => {
  // await ...
}
 
function myFn() {
  // await fn(); (Syntax Error since no async)
}
 

Async is inserted at the very beginning of the function declaration, and in the case of an arrow function, between the "=" sign and the brackets.

These functions can be placed on an object as methods, or they can be used in a class declaration.

// As an object's method
 
const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}
 
// In a class
 
class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}

NB! It is worth remembering that class constructors and getters/setters cannot be asynchronous.

Semantics and execution rules

Async functions are similar in principle to standard JS functions, but there are exceptions.

So, async functions always return promises:

async function fn() {
  return 'hello';
}
fn().then(console.log)
// hello

In particular, fn returns the string hello. Well, since this is an asynchronous function, the string value is wrapped in a promise using a constructor.

Here is an alternative construction without Async:

function fn() {
  return Promise.resolve('hello');
}
 
fn().then(console.log);
// hello

In this case, the return of the promise is done "manually". An asynchronous function is always wrapped in a new promise.

In the event that the return value is a primitive, the async function returns the value, wrapping it in a promise. In the event that the returned value is a promise object, its solution is returned in a new promise.

const p = Promise.resolve('hello')
p instanceof Promise;
// true
 
Promise.resolve(p) === p;
// true
 

But what happens if there is an error inside the asynchronous function?

async function foo() {
  throw Error('bar');
}
 
foo().catch(console.log);

If it is not processed, foo() will return a rejected promise. In this situation, instead of Promise.resolve, a Promise.reject containing an error will be returned.

Async functions always return a promise, no matter what is returned.

Asynchronous functions are suspended on every await .

Await affects expressions. So, if the expression is a promise, the async function is suspended until the promise is fulfilled. If the expression is not a promise, it is converted to a promise via Promise.resolve and then terminated.

// utility function to cause delay
// and get random value
 
const delayAndGetRandom = (ms) => {
  return new Promise(resolve => setTimeout(
    () => {
      const val = Math.trunc(Math.random() * 100);
      resolve(val);
    }, ms
  ));
};
 
async function fn() {
  const a = await 9;
  const b = await delayAndGetRandom(1000);
  const c = await 5;
  await delayAndGetRandom(1000);
 
  return a + b * c;
}
 
// Execute fn
fn().then(console.log);

And here is a description of how the fn function works.

  • After calling it, the first line is converted from const a = await 9; in const a = await Promise.resolve(9);.
  • After using Await, the execution of the function is suspended until a gets its value (in the current situation it is 9).
  • delayAndGetRandom(1000) pauses the fn function until it terminates on its own (after 1 second). This is effectively stopping the fn function for 1 second.
  • delayAndGetRandom(1000) through resolve returns a random value, which is then assigned to b.
  • Well, the case with variable c is similar to the case with variable a. After that everything stops for a second, but now delayAndGetRandom(1000) doesn't return anything as it's not needed.
  • As a result, the values ​​are calculated according to the formula a + b * c. The result is wrapped in a promise using Promise.resolve and returned by the function.

These pauses may be similar to generators in ES6, but this one has your reasons.

We solve the problem

Well, now let's look at the solution to the problem that was indicated above.

The finishMyTask function uses Await to wait for the results of operations such as queryDatabase, sendEmail, logTaskInFile, and others. If we compare this solution with the one where promises were used, the similarity becomes obvious. However, the Async/Await version simplifies the syntactic complexities quite a bit. In this case, there are not a lot of callbacks and chains like .then/.catch.

Here is a solution with numbers output, there are two options here.

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
 
// Implementation One (Using for-loop)
const printNumbers = () => new Promise((resolve) => {
  let pr = Promise.resolve(0);
  for (let i = 1; i <= 10; i += 1) {
    pr = pr.then((val) => {
      console.log(val);
      return wait(i, Math.random() * 1000);
    });
  }
  resolve(pr);
});
 
// Implementation Two (Using Recursion)
 
const printNumbersRecursive = () => {
  return Promise.resolve(0).then(function processNextPromise(i) {
 
    if (i === 10) {
      return undefined;
    }
 
    return wait(i, Math.random() * 1000).then((val) => {
      console.log(val);
      return processNextPromise(i + 1);
    });
  });
};

And here is a solution using async functions.

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

Error processing

Unhandled errors are wrapped in a rejected promise. However, you can use the try/catch construct in async functions to perform synchronous error handling.

async function canRejectOrReturn() {
  // wait one second
  await new Promise(res => setTimeout(res, 1000));
 
// Reject with ~50% probability
  if (Math.random() > 0.5) {
    throw new Error('Sorry, number too big.')
  }
 
return 'perfect number';
}

canRejectOrReturn() is an asynchronous function that either succeeds (“perfect number”) or fails with an error (“Sorry, number too big”).

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

Since the example above expects canRejectOrReturn to execute, the native failure will cause the catch block to be executed. As a result, foo will either end with undefined (when nothing is returned in the try block) or with an error caught. As a result, this function will not fail, because try / catch will process the foo function itself.

Here is another example:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

It is worth paying attention to the fact that canRejectOrReturn is returned from foo in the example. Foo in this case either completes with a perfect number or returns an Error (“Sorry, number too big”). The catch block will never be executed.

The problem is that foo returns the promise passed from canRejectOrReturn. So the decision of foo becomes the decision of canRejectOrReturn. In this case, the code will consist of only two lines:

try {
    const promise = canRejectOrReturn();
    return promise;
}

But what happens if you use await and return together:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

In the code above, foo will successfully complete with both perfect number and error caught. There will be no failures here. But foo will end with canRejectOrReturn, not undefined. Let's verify this by removing the return await canRejectOrReturn() line:

try {
    const value = await canRejectOrReturn();
    return value;
}
// …

Common mistakes and pitfalls

In some cases, using Async/Await can lead to errors.

Forgotten await

This happens quite often - the await keyword is forgotten before the promise:

async function foo() {
  try {
    canRejectOrReturn();
  } catch (e) {
    return 'caught';
  }
}

As you can see, there is neither await nor return in the code. So foo always exits with undefined without a 1 second delay. But the promise will be fulfilled. If it throws an error or a reject, then UnhandledPromiseRejectionWarning will be called in this case.

Async functions in callbacks

Async functions are often used in .map or .filter as callbacks. An example is the fetchPublicReposCount(username) function, which returns the number of repositories open on GitHub. Let's say there are three users whose metrics we want. Here is the code for this task:

const url = 'https://api.github.com/users';
 
// Utility fn to fetch repo counts
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

We need ArfatSalman, octocat, norvig accounts. In this case we execute:

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];
 
const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

Note the Await in the .map callback. Here counts is an array of promises, and .map is an anonymous callback for each specified user.

Overly consistent use of await

Let's take this code as an example:

async function fetchAllCounts(users) {
  const counts = [];
  for (let i = 0; i < users.length; i++) {
    const username = users[i];
    const count = await fetchPublicReposCount(username);
    counts.push(count);
  }
  return counts;
}

Here, the number of repos is placed in the count variable, then this number is added to the counts array. The problem with the code is that until the data of the first user comes from the server, all subsequent users will be in standby mode. Thus, only one user is processed at a time.

If, for example, it takes about 300 ms to process one user, then for all users it is already a second, the time spent linearly depends on the number of users. But since getting the number of repos does not depend on each other, the processes can be parallelized. This requires working with .map and Promise.all:

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}

Promise.all takes an array of promises as input, returning a promise. The last one after completion of all promises in the array or at the first rejection is completed. It may happen that all of them do not start at the same time - in order to ensure that they start at the same time, you can use p-map.

Conclusion

Async functions are becoming more and more important for development. Well, for adaptive use of async functions, you should use Async Iterators. A JavaScript developer should be well versed in this.

Skillbox recommends:

Source: habr.com

Add a comment