Глубокое погружение в асинхронные JavaScript функции

JavaScript функции async и await – то, что важно понимать web-разработчику в 2019 году. В статье примеры кода и детальное погружение в тему.

Вначале были обратные вызовы.

Обратный вызов – функция, которая выполняется позднее.

Из-за асинхронной природы языка JavaScript обратные вызовы часто используются там, где результаты недоступны сразу.

Так выглядит асинхронное чтение файла в Node.js:

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

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

  • Делаем запрос в базу данных для пользователя Arfat.
  • Считываем profile_img_url и получаем изображение с someServer.com.
  • Далее преобразуем изображение в другой формат: PNG в JPEG.
  • Если преобразование получилось, отправляем пользователю электронное письмо.
  • И записываем эту задачу в наш файл transfors.log с отметкой времени.

Код выглядит так:

Обратите внимание на вложенность обратных вызовов и лестницу из }) в конце. Это ласково называется Ад обратных вызовов или Пирамида Судьбы (Pyramid of Doom). Главные недостатки:

  • Код становится труднее читать, потому что читать приходится слева направо.
  • Обработка ошибок сложна и часто приводит к ужасному коду.

Для решения этой проблемы боги JavaScript JS создали Promise. Теперь вместо вложенности обратных вызовов получаем цепочку.

Пример:

Поток стал привычным – сверху вниз, а не слева направо, как в обратных вызовах, что плюс. Тем не менее, с Promise по-прежнему проблемы:

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

Для демонстрации последнего пункта примем этот вызов!

Задача

Предположим, цикл for выводит от 0 до 10 с произвольными интервалами (от 0 до n секунд). Требуется изменить поведение с использованием Promise так, чтобы числа печатались последовательно от 0 до 10. Например, если 0 отображается за 6 секунд, а 1 – за две секунды, то 1 ждёт печати 0 и так далее.

Само собой разумеется, не используйте JavaScript функции async и await или sort. Решение будет к концу.

Асинхронные JavaScript функции

После ES2017(ES8) JavaScript основы языка дополнились асинхронными функциями, которые упростили работу с Promise.

  • Асинхронные функции JavaScript работают поверх Promise.
  • Это не диаметрально другая концепция.
  • Функции рассматриваются как альтернативный способ написания кода на основе Promise.
  • С использованием async и await избегаем создания цепочки Promise.
  • В итоге получаем асинхронное выполнение при сохранении нормального синхронного подхода.

Следовательно, требуется понимание Promise для осознания концепции async/await.

Синтаксис

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

// с объявлением функции
async function myFn() {
  // await ...
}
// со стрелочной функцией
const myFn = async () => {
  // await ...
}
function myFn() {
  // await fn(); (Синтаксическая ошибка, поскольку нет async) 
}

Видите async в начале объявления функции? Если функция стрелочная, async ставится после знака = и перед скобками.

Асинхронные функции используются и как методы объектов или в объявлениях класса. Это иллюстрируют JavaScript примеры:

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

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

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

Асинхронные функции – обычные функции JavaScript с такими отличиями:

Асинхронные JavaScript функции всегда возвращают Promise.

async function fn() {
  return 'привет';
}
fn().then(console.log)
// привет

Функция fn возвращает 'привет'. Поскольку использовали async, возвращаемое значение 'привет' оборачивается в Promise посредством Promise.resolve.

Теперь посмотрим на эквивалентное альтернативное представление без использования async:

function fn() {
  return Promise.resolve('привет');
}
fn().then(console.log);
// привет

В этом случае вручную возвращаем Promise вместо использования async.

Точнее сказать, возвращаемое значение асинхронной функции JavaScript всегда оборачивается в Promise.resolve.

Для примитивов Promise.resolve возвращает обёрнутое в Promise значение. Но для объектов Promise возвращается тот же объект без оборачивания.

// для примитивных значений
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() вернёт отклонённый (rejected) Promise, если ошибку не перехватили. Вместо Promise.resolve Promise.reject оборачивает и возвращает ошибку. Смотрите раздел Обработка ошибок дальше.

В результате, что бы мы ни возвращали, всегда получаем Promise из асинхронной функции.

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

await действует на выражение. Если выражение – Promise, выполнение асинхронной функции останавливается до получения результата Promise. Если выражение – другое значение, происходит преобразование в Promise с помощью Promise.resolve и выполнение 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 построчно:

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

Решение

Воспользуемся async/await для решения гипотетической задачи, поставленной в начале статьи:

Создаём асинхронную функцию finishMyTask и используем await для ожидания результата таких операций, как queryDatabase, sendEmail и logTaskInFile.

Если сравним с первым решением на базе Promise, обнаружим, что это примерно та же строчка кода. Тем не менее, async/await упростил синтаксис. Отсутствуют множественные обратные вызовы и .then/.catch.

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

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

// Реализация Один (С использованием цикла 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);
});

// Реализация Два(С использованием рекурсии)

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

Если хотите, запустите код самостоятельно в консоли repl.it.

Использование асинхронной функции с самого начала упростило бы задачу намного.

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

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

Помните, что необработанная Error() оборачивается в отклонённый Promise? Несмотря на это, допускается использование 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 'ошибка перехвачена';
  }
}

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

Почему так? Просто возвращаем Promise, который вернул canRejectOrReturn. Следовательно, выполнение foo становится выполнением canRejectOrReturn. Разделим return canRejectOrReturn() на две строки для большей ясности. Обратите внимание на отсутствие await в первой строке:

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

И посмотрим, как использовать await и return вместе:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'ошибка перехвачена';
  }
}

В этом случае foo завершится либо с результатом 'идеальное число', либо с 'ошибка перехвачена'. Здесь нет отклонения. Это как первый пример, только с await. За исключением того, что получаем значение, которое создаёт canRejectOrReturn, а не undefined.

Прервём return await canRejectOrReturn();, чтобы увидеть эффект:

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

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

Отсутствие await

Иногда забываем добавить ключевое слово await перед Promise или вернуть его. Вот пример:

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

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

Асинхронные функции в обратных вызовах

Часто используем асинхронные функции в .map или .filter в качестве обратных вызовов. Рассмотрим пример. Предположим, функция fetchPublicReposCount(username) извлекает количество общедоступных GitHub-репозиториев пользователя. Три пользователя для обработки. Посмотрим код:

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

Обратите внимание на async в обратном вызове .map. Ожидаем, что переменная counts будет содержать количество репов. Но асинхронные функции возвращают Promise. Следовательно, counts на самом деле – массив из Promise. .map запускает анонимный обратный вызов для каждого username, и при каждом вызове возвращается Promise, который .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;
}

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

Если для одной выборки требуется 300 мс, то fetchAllCounts будет занимать ~ 900 мс для 3 пользователей. Как видим, время линейно растёт с увеличением количества пользователей. Поскольку выборка репов не взаимозависимая, распараллелим операцию.

Получаем пользователей одновременно, а не последовательно с использованием .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 принимает массив Promise на входе и возвращает Promise на выходе. Конечный Promise получает массив результатов всех Promise или становится rejected при первом отклонении. Для частичного параллелизма смотрите p-map.

Заключение

С введением асинхронных итераторов асинхронные функции получат ещё большее распространение. Тем, кто изучает программирование JavaScript, важно понимание этих концепций. Надеемся, что статья прольёт свет на await и async.

Оригинал

А с какими проблемами в асинхронном программировании сталкивались вы?

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

eFusion
01 марта 2020

ТОП-15 книг по JavaScript: от новичка до профессионала

В этом посте мы собрали переведённые на русский язык книги по JavaScript – ...
admin
10 июня 2018

Лайфхак: в какой последовательности изучать JavaScript

Огромный инструментарий JS и тонны материалов по нему. С чего начать? Расск...