Разбираем Async/Await в JavaScript на примерах

Автор статьи разбирает на примерах Async/Await в JavaScript. В целом, Async/Await — удобный способ написания асинхронного кода. До появления этой возможности подобный код писали с использованием коллбэков и промисов. Автор оригинальной статьи раскрывает преимущества Async/Await, разбирая различные примеры.

Напоминаем: для всех читателей «Хабра» — скидка 10 000 рублей при записи на любой курс Skillbox по промокоду «Хабр».

Skillbox рекомендует: Образовательный онлайн-курс «Java-разработчик».

Callback

Callback представляет собой функцию, вызов которой отложен на неопределенное время. Раньше обратные вызовы использовались в тех участках кода, где результат не мог быть получен сразу.

Вот пример асинхронного чтения файла на Node.js:

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

Проблемы возникают в тот момент, когда требуется выполнить сразу несколько асинхронных операций. Давайте представим себе вот такой сценарий: выполняется запрос в БД пользователя Arfat, нужно считать его поле profile_img_url и загрузить картинку с сервера someserver.com.
После загрузки конвертируем изображение в иной формат, например из PNG в JPEG. Если конвертация прошла успешно, на почту пользователя отправляется письмо. Далее информация о событии заносится в файл transformations.log с указанием даты.

Стоит обратить внимание на наложенность обратных вызовов и большое количество }) в финальной части кода. Это называется Callback Hell или Pyramid of Doom.

Недостатки такого способа очевидны:

  • Этот код сложно читать.
  • В нем также сложно обрабатывать ошибки, что зачастую приводит к ухудшению качества кода.

Для того чтобы решить эту проблему, в JavaScript были добавлены промисы. Они позволяют заменить глубокую вложенность коллбэков словом .then.

Положительным моментом промисов стало то, что с ними код читается гораздо лучше, причем сверху вниз, а не слева направо. Тем не менее у промисов тоже есть свои проблемы:

  • Нужно добавлять большое количество .then.
  • Вместо try/catch используется .catch для обработки всех ошибок.
  • Работа с несколькими промисами в рамках одного цикла далеко не всегда удобна, в некоторых случаях они усложняют код.

Вот задача, которая покажет значение последнего пункта.

Предположим, что есть цикл for, выводящий последовательность чисел от 0 до 10 со случайным интервалом (0–n секунд). Используя промисы, нужно изменить этот цикл таким образом, чтобы числа выводились в последовательности от 0 до 10. Так, если вывод нуля занимает 6 секунд, а единицы — 2 секунды, сначала должен быть выведен ноль, а потом уже начнется отсчет вывода единицы.

И конечно, для решения этой задачи мы не используем Async/Await либо .sort. Пример решения — в конце.

Async-функции

Добавление async-функций в ES2017 (ES8) упростило задачу работы с промисами. Отмечу, что async-функции работают «поверх» промисов. Эти функции не представляют собой качественно другие концепции. Async-функции задумывались как альтернатива коду, который использует промисы.

Async/Await дает возможность организовать работу с асинхронным кодом в синхронном стиле.

Таким образом, знание промисов облегчает понимание принципов Async/Await.

Синтаксис

В обычной ситуации он состоит из двух ключевых слов: async и await. Первое слово и превращает функцию в асинхронную. В таких функциях разрешается использование await. В любом другом случае использование этой функции вызовет ошибку.

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

Async вставляется в самом начале объявления функции, а в случае использования стрелочной функции — между знаком «=» и скобками.

Эти функции можно поместить в объект в качестве методов либо же использовать в объявлении класса.

// 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! Стоит помнить, что конструкторы класса и геттеры/сеттеры не могут быть асинхронными.

Семантика и правила выполнения

Async-функции, в принципе, похожи на стандартные JS-функции, но есть и исключения.

Так, async-функции всегда возвращают промисы:

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

В частности, fn возвращает строку hello. Ну а поскольку это асинхронная функция, то значение строки обертывается в промис при помощи конструктора.

Вот альтернативная конструкция без Async:

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

В этом случае возвращение промиса производится «вручную». Асинхронная функция всегда обертывается в новый промис.

В том случае, если возвращаемое значение — примитив, async-функция выполняет возврат значения, обертывая его в промис. В том случае, если возвращаемое значение и есть объект промиса, его решение возвращается в новом промисе.

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

Но что произойдет в том случае, если внутри асинхронной функции окажется ошибка?

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

Если она не будет обработана, foo() вернет промис с реджектом. В этой ситуации вместо Promise.resolve вернется Promise.reject, содержащий ошибку.

Async-функции на выходе всегда дают промис, вне зависимости от того, что возвращается.

Асинхронные функции приостанавливаются при каждом await .

Await влияет на выражения. Так, если выражение является промисом, async-функция приостанавливается до момента выполнения промиса. В том случае, если выражение не является промисом, оно конвертируется в промис через Promise.resolve и потом завершается.

// 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);

А вот описание того, как работает fn-функция.

  • После ее вызова первая строка конвертируется из const a = await 9; в const a = await Promise.resolve(9);.
  • После использования Await выполнение функции приостанавливается, пока а не получает свое значение (в текущей ситуации это 9).
  • delayAndGetRandom(1000) приостанавливает выполнение fn-функции, пока не завершится сама (после 1 секунды). Это фактически является остановкой fn-функции на 1 секунду.
  • delayAndGetRandom(1000) через resolve возвращает случайное значение, которое затем присваивается переменной b.
  • Ну а случай с переменной с аналогичен случаю с переменной а. После этого все останавливается на секунду, но теперь delayAndGetRandom(1000) ничего не возвращает, поскольку этого не требуется.
  • В итоге значения считаются по формуле a + b * c. Результат же обертывается в промис при помощи Promise.resolve и возвращается функцией.

Эти паузы могут напоминать генераторы в ES6, но этому есть свои причины.

Решаем задачу

Ну а теперь давайте рассмотрим решение задачи, которая была указана выше.

В функции finishMyTask используется Await для ожидания результатов таких операций, как queryDatabase, sendEmail, logTaskInFile и других. Если же сравнивать это решение с тем, где использовались промисы, станет очевидным сходство. Тем не менее версия с Async/Await довольно сильно упрощает все синтаксические сложности. В этом случае нет большого количества коллбэков и цепочек вроде .then/.catch.

Вот решение с выводом чисел, здесь есть два варианта.

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);
    });
  });
};

А вот решение с использованием async-функций.

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

Обработка ошибок

Необработанные ошибки обертываются в rejected промис. Тем не менее в async-функциях можно использовать конструкцию try/catch для того, чтобы выполнить синхронную обработку ошибок.

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() — это асинхронная функция, которая либо удачно выполняется (“perfect number”), либо неудачно завершается с ошибкой (“Sorry, number too big”).

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

Поскольку в примере выше ожидается выполнение canRejectOrReturn, то собственное неудачное завершение повлечет за собой исполнение блока catch. В результате функция foo завершится либо с undefined (когда в блоке try ничего не возвращается), либо с error caught. В итоге у этой функции не будет неудачного завершения, поскольку try/catch займется обработкой самой функции foo.

Вот еще пример:

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

Стоит уделить внимание тому, что в примере из foo возвращается canRejectOrReturn. Foo в этом случае завершается либо perfect number, либо возвращается ошибка Error (“Sorry, number too big”). Блок catch никогда не будет исполняться.

Проблема в том, что foo возвращает промис, переданный от canRejectOrReturn. Поэтому решение функции foo становится решением для canRejectOrReturn. В этом случае код будет состоять всего из двух строк:

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

А вот что будет, если использовать вместе await и return:

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

В коде выше foo удачно завершится как с perfect number, так и с error caught. Здесь отказов не будет. Но foo завершится с canRejectOrReturn, а не с undefined. Давайте убедимся в этом, убрав строку return await canRejectOrReturn():

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

Распространенные ошибки и подводные камни

В некоторых случаях использование Async/Await может приводить к ошибкам.

Забытый await

Такое случается достаточно часто — перед промисом забывается ключевое слово await:

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

В коде, как видно, нет ни await, ни return. Поэтому foo всегда завершается с undefined без задержки в 1 секунду. Но промис будет выполняться. Если же он выдает ошибку или реджект, то в этом случае будет вызываться UnhandledPromiseRejectionWarning.

Async-функции в обратных вызовах

Async-функции довольно часто используются в .map или .filter в качестве коллбэков. В качестве примера можно привести функцию fetchPublicReposCount(username), которая возвращает количество открытых на GitHub репозиториев. Допустим, есть три пользователя, чьи показатели нам нужны. Вот код для этой задачи:

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'];
}

Нам нужны аккаунты ArfatSalman, octocat, norvig. В этом случае выполняем:

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

Стоит обратить внимание на Await в обратном вызове .map. Здесь counts — массив промисов, ну а .map — анонимный обратный вызов для каждого указанного пользователя.

Чрезмерно последовательное использование await

В качестве примера возьмем такой код:

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;
}

Здесь в переменную count помещается число репо, затем это число добавляется в массив counts. Проблема кода в том, что пока с сервера не придут данные первого пользователя, все последующие пользователи будут находиться в режиме ожидания. Таким образом, в единый момент обрабатывается лишь один пользователь.

Если, например, на обработку одного пользователя уходит около 300 мс, то для всех пользователей это уже секунда, затрачиваемое время линейно зависит от числа пользователей. Но раз получение количества репо не зависит друг от друга, процессы можно распараллелить. Для этого нужна работа с .map и 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 на входе получает массив промисов с возвращением промиса. Последний после завершения всех промисов в массиве или при первом реджекте завершается. Может случиться так, что все они не запустятся одновременно, — для того чтобы обеспечить одновременный запуск, можно использовать p-map.

Заключение

Async-функции становятся все более важными для разработки. Ну а для адаптивного использования async-функций стоит воспользоваться Async Iterators. JavaScript-разработчик должен хорошо разбираться в этом.

Skillbox рекомендует:

Источник: habr.com