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

Обложка поста

Перевод статьи «Deeply Understanding JavaScript Async and Await with Examples»

Callback — это не что-то замысловатое или особенное, а просто функция, вызов которой отложен на неопределённое время. Благодаря асинхронному характеру JavaScript, обратные вызовы нужны были везде, где результат не может быть получен сразу.

Ниже приведён пример асинхронного чтения файла на Node.js:

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

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

queryDatabase({ username: 'Arfat'}, (err, user) => {
  // Обработка ошибок при запросе в БД
  const image_url = user.profile_img_url;
  getImageByURL('someServer.com/q=${image_url}', (err, image) => {
    // Обработка ошибок получения изображения
    transformImage(image, (err, transformedImage) => {
      // Обработка ошибок конвертирования 
      sendEmail(user.email, (err) => {
        // Обработка ошибок отсылки по почте
        logTaskInFile('Конвертирование файла и отсылка по почте', (err) 
          // Обработка ошибок лога
        })
      })
    })
  })
})

Обратите внимание на вложенность обратных вызовов и пирамиду из }) в конце. Подобные случаи принято называть Callback Hell или Pyramid of Doom. Вот основные недостатки:

  • Такой код сложно читать.
  • В таком коде сложно обрабатывать ошибки и одновременно сохранять его «качество».

Для решения этой проблемы в JavaScript были придуманы промисы (англ. promises). Теперь глубокую вложенность коллбэков можно заменить ключевым словом then:

queryDatabase({ username: 'Arfat'})
  .then((user) => {
    const image_url = user.profile_img_url;
    return getImageByURL('someServer.com/q=${image_url}') 
      .then(image => transformImage(image))
      .then(() => sendEmail(user.email))
})
.then(() => logTaskInFile('...'))
.catch(() => handleErrors()) // Обработка ошибок

Код стал читаться сверху вниз, а не слева направо, как это было в случае с обратными вызовами. Это плюс к читаемости. Однако и у промисов есть свои проблемы:

  • Всё ещё нужно работать с кучей .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-функций возможно организовать работу с асинхронным кодом в синхронном стиле.

Как видите, знание промисов всё же необходимо для понимания работы async/await.

Синтаксис

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

// В объявлении функции
async function myFn() {
  // await ...
}

// В стрелочной функции
const myFn = async () => {
  // await ...
}

function myFn() {
  // await fn(); (синтаксическая ошибка, т. к. нет async)
}

Обратите внимание, что async вставляется в начале объявления функции, а в случае стрелочной функции — между знаком = и скобками.

Async-функции могут быть помещены в объект в качестве методов или же просто использоваться в объявлении класса.

// В качестве метода объекта
const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}

// В самом классе
class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}

Примечание Конструкторы класса и геттеры/сеттеры не могут быть асинхронными.

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

Async-функции похожи на обычные функции в JavaScript, за исключением нескольких вещей:

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, код вручную возвращает промис.

Тело асинхронной функции всегда обёртывается в новый промис

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

// В случае примитивного типа значения
const p = Promise.resolve('hello')
p instanceof Promise; 
// true

// p возвращается как есть

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 и потом завершается.

// Функция задержки
// с возвращением случайного числа
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;
}

// Вызов fn
fn().then(console.log);

Как работает fn функция?

  1. После вызова fn функции первая строка конвертируется из const a = await 9; в const a = await Promise.resolve(9);.
  2. После использования await, выполнение функции приостанавливается, пока a не получит своё значение (в данном случае это 9).
  3. delayAndGetRandom(1000) приостанавливает выполнение fn функции, пока не завершится сама (после 1 секунды). Это, фактически, можно назвать остановкой fn функции на 1 секунду.
  4. Также delayAndGetRandom(1000) через resolve возвращает случайное значение, которое присваивается переменной b.
  5. Случай с переменной c идентичен случаю переменной a. После этого опять происходит пауза на 1 секунду, но теперь delayAndGetRandom(1000) ничего не возвращает, т. к. этого не требуется.
  6. Под конец эти значения считаются по формуле a + b * c. Результат обёртывается в промис с помощью Promise.resolve и возвращается функцией.

Примечание Если такие паузы напоминают вам генераторы в ES6, то на это есть свои причины.

Решение задачи

Вот решение задачи, поставленной в начале статьи, с использованием async/await.

async function finishMyTask() {
  try {
    const user = await queryDatabase({ username: 'Arfat' }); 
    const image_url = user.profile_img_url;
    const image = await getImageByURL('someServer.com/q=${image_url}); 
    const transformedlmage = await transformImage(image); 
    await sendEmail(user.email); 
    await logTaskInFile(' ... ');
  } catch(err) {
    // Обработка всех ошибок
  }
}

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

Вот то решение с выводом чисел. Тут есть два способа:

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));

// Решение #1 (с использованием цикла for)
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);
});

// Решение #2 (с использованием рекурсии)

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() {
  // Ждём секунду
  await new Promise(res => setTimeout(res, 1000));
// Реджектим в 50% случае
  if (Math.random() > 0.5) {
    throw new Error('Простите, число больше, чем нужно.')
  }

return 'Число подошло';
}

canRejectOrReturn() — это асинхронная функция, которая будет удачно завершатся с 'Число подошло', либо неудачно завершаться с Error('Простите, число больше, чем нужно.').

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'Ошибка обработана';
  }
}

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

Вот другой пример:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'Ошибка обработана';
  }
}

Обратите внимание, что в коде выше из foo возвращается (без ожидания) canRejectOrReturn. foo завершится либо с 'число подошло', либо с реджектом Error('Простите, число больше, чем нужно.'). Блок catch никогда не будет исполняться.

Это происходит из-за того, что foo возвращает промис, который передан от canRejectOrReturn. Следовательно, решение функции foo становится решением canRejectOrReturn. Такой код можно представить всего в двух строках:

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

Вот что получится, если использовать await и return разом:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'Ошибка обработана';
  }
}

В коде выше foo будет удачно завершаться и с 'число подошло', и с 'Ошибка обработана'. В таком коде реджектов не будет. Но в отличие от одного из примеров выше, 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 'Обработка';
  }
}

Обратите внимание, здесь не используется ни await, ни return. Функция foo всегда будет завершаться с undefined (без задержки в 1 секунду). Тем не менее, промис будет выполняться. Если промис будет выдавать ошибку либо реджект, то будет вызываться UnhandledPromiseRejectionWarning.

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

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

const url = 'https://api.github.com/users';

// Получает количество открытых репозиториев
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 будет содержать число — количество репозиториев. Но как было сказано ранее, все async-функции возвращают промисы. Следовательно, 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 Iterators.