JavaScript

Механизм async/await , представленный ES7, является фантастическим улучшением асинхронного программирования с использованием JavaScript. Он предоставил возможность использовать код, написанный в синхронном стиле, для асинхронного доступа к ресурсам, при котором не блокируется основной поток. Однако, применение этого механизма — задача непростая. В этой статье мы рассмотрим async / wait с разных точек зрения и покажем, как использовать его правильно и эффективно.

Что хорошего в async/await

Важнейшим преимуществом async/await является синхронный стиль программирования. Давайте посмотрим на следующий пример:

// async/await
async getBooksByAuthorWithAwait(authorId) {
  const books = await bookModel.fetchAll();
  return books.filter(b => b.authorId === authorId);
}
// promise
getBooksByAuthorWithPromise(authorId) {
  return bookModel.fetchAll()
    .then(books => books.filter(b => b.authorId === authorId));
}

Очевидно, что вариант сasync/await читать и понимать проще, чем версию того же кода с промисами (promise). Если убрать ключевое слово await, то код станет выглядеть так, как будто написан на любом другом синхронном языке программированя, такие как Python.

И еще из приятного — это не только читаемость: async/await по умолчанию поддерживается всеми основными современными браузерами.

Нативная поддержка асинхронных функций популярными браузерами. Источник — https://caniuse.com/

Встроенная поддержка означает, что вам не нужно транспилировать код. Что еще более важно, это облегчает отладку. Если установить контрольную точку (breakpoint) в точке входа функции, то после выполнения строки сawaitотладчик (debugger) ненадолго подвиснет на врем, которое требуетсяbookModel.fetchAll() для выполнения своей работы, а затем перейдет к следующей строке .filter Это намного проще, чем в ситуации с промисами, в которой вам пришлось бы настраивать другую контрольную точку в строке с .filter

Отладка функции async. Отладчик сделает остановку на строчке await, а когда дождется выполнения функции, двинется к следующей

Другим менее очевидным преимуществом моно назвать ключевое словоasync. Оно свидетельствует о том, что функцияgetBooksByAuthorWithAwait() вернет значение, которое гарантированно будет промисом, поэтому можно безопасно вызывать методыgetBooksByAuthorWithAwait().then(...) или await getBooksByAuthorWithAwait() . Взгляните внимательно на этот случай (но не пишите так! Это плохая практика):

getBooksByAuthorWithPromise(authorId) {
  if (!authorId) {
    return null;
  }
  return bookModel.fetchAll()
    .then(books => books.filter(b => b.authorId === authorId));
}

В приведенном выше примере кода getBooksByAuthorWithPromise может вернуть промис (нормальное поведение) или null (исключение), и в этом случае не удастся безопасно вызвать метод .then(). При использовании async такой кейс не возможен в принципе.

Async / wait может ввести в заблуждение

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

По сути, асинхронные функции по-прежнему остаются промисами. Вы должны понять, как работают промисы, прежде чем сможете научиться правильно использовать асинхронные функции , и что еще хуже, большую часть времени вы должны использовать промисы вместе с асинхронными функциями.

Рассмотрим функции getBooksByAuthorWithAwait() и getBooksByAuthorWithPromises() в приведенном выше примере. Обратите внимание, что они не только идентичны функционально, но и имеют точно такой же интерфейс!

Это означает, что getBooksByAuthorWithAwait() вернет промис, если вы вызовете эту функцию напрямую.

Ну, это не обязательно плохо. Только название await может вызвать мысль: «О, отлично, так можно преобразовать асинхронные функции в синхронные функции»,- что на самом деле неверно.

Ловушки Async/await

Итак, какие ошибки разработчик может допустить при использовании async/await? Вот некоторые наиболее общие.

Слишком много последовательностей

Благодаряawait может создастся впечатление, что код будет исполняться последовательно, имейте в виду, что он все еще асинхронный, и нужно быть осторожными, чтобы не нагромождать слишком много последовательностей.

async getBooksAndAuthor(authorId) {
  const books = await bookModel.fetchAll();
  const author = await authorModel.fetch(authorId);
  return {
    author,
    books: books.filter(book => book.authorId === authorId),
  };
}

Этот код выглядит правильно с точки зрения логики. Однако работать он будет некорректно.

  1. await bookModel.fetchAll() будет ждать ответа от функции fetchAll().
  2. Затем будет вызван метод await authorModel.fetch(authorId).

Обратите внимание, что authorModel.fetch(authorId) не зависит от результата bookModel.fetchAll(), и на самом деле их можно вызывать параллельно! Однако, используя await, эти два вызова становятся последовательными, и общее время выполнения будет намного больше, чем при параллельном варианте выполнения функций.

Вот правильный способ:

async getBooksAndAuthor(authorId) {
  const bookPromise = bookModel.fetchAll();
  const authorPromise = authorModel.fetch(authorId);
  const book = await bookPromise;
  const author = await authorPromise;
  return {
    author,
    books: books.filter(book => book.authorId === authorId),
  };
}

Или того хуже, вдруг вам захочется получить список книг одна за другой, тогда вам придется прибегнуть к промисам:

async getAuthors(authorIds) {
  // WRONG, this will cause sequential calls
  // const authors = _.map(
  //   authorIds,
  //   id => await authorModel.fetch(id));
  // CORRECT
  const promises = _.map(authorIds, id => authorModel.fetch(id));
  const authors = await Promise.all(promises);
}

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

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

При использовании промисов функция async может возвращать два значения: разрешенное значение и отклоненное значение. И мы можем использовать .then() для обычного случая и .catch() для случая с ошибкой. Однако обработвать ошибки в случае с async/await — дело сложное.

try…catch

Самый стандартный способ, его же я обычно рекомендую — использовать конструкцию try...catch. При ожидании вызова, то есть в случае с await,любое отклоненное значение будет выброшено как исключение. Вот пример:

class BookModel {
  fetchAll() {
    return new Promise((resolve, reject) => {
      window.setTimeout(() => { reject({'error': 400}) }, 1000);
    });
  }
}
// async/await
async getBooksByAuthorWithAwait(authorId) {
try {
  const books = await bookModel.fetchAll();
} catch (error) {
  console.log(error);    // { "error": 400 }
}

Error вcatch — это то самое отклоненное значение. После того, как мы поймали исключение, у нас есть несколько способов работы с ним:

  • Обработать исключение и вернуть нормальное значение. (Неиспользование выражения return в блоке catch эквивалентно применениюreturn undefinedкоторое также является нормальным значением.)
  • Выбросить ошибку, если хотите, чтобы функция вызова обработала её. Вы можете либо выбросить простой объект ошибки напрямую, с помощью throw error;, что позволит вам использовать функцию async getBooksByAuthorWithAwait() в цепочке промисов (другими словами, вы все равно можете вызвать её следующим образом — getBooksByAuthorWithAwait().then(...).catch(error => ...)); другой вариант — можно обернуть ошибку с помощью объекта Error например, throw new Error(error), что позволит увидеть полную трассировку стека, когда эта ошибка будет отображаться в консоли.
  • Отклонить ошибку, например, return Promise.reject(error). Это эквивалентно throw error, поэтому не рекомендуется.

Преимущества использования try...catch:

  • Простой, традиционный способ. Если у вас есть опыт работы с другими языками, такими как Java или C ++, вам не составит труда понять данную концепцию.
  • Дает возможность поместить несколько вызовов awaitв один блок try...catch для обработки ошибок в одном месте, если обработка ошибок на каждом шаге не требуется.

В этом подходе есть и один недостаток. Так как try...catch поймает любое исключение в блоке, то будут выброшены ошибки, которые в обычных случаях промисами не отлавливаются. Для того, чтобы понять эту идею, взгляните на пример:

class BookModel {
  fetchAll() {
    cb();    // note `cb` is undefined and will result an exception
    return fetch('/books');
  }
}
try {
  bookModel.fetchAll();
} catch(error) {
  console.log(error);  // This will print "cb is not defined"
}

Запустите этот код и вы получите ошибку ReferenceError: cb is not definedв консоли, черного цвета. Ошибка выводилась с помощью console.log(),но не самим JavaScript. Иногда это может быть фатальным: если BookModelзаключен глубоко в ряд вызовов функций, и один из вызовов проглатывает ошибку, тогда будет очень сложно найти неопределенную ошибку, подобную этой.

Функция возвращает оба значения

Другой способ обработки ошибок практикуется в языке Go. Он позволяет async-функции возвращать как ошибку, так и результат. За более подробным описанием направляю вас в эту статью.

Короче говоря, вы можете использовать async-функцию следующим образом:

[err, user] = await to(UserModel.findById(1));

Лично мне не нравится этот подход, поскольку он привносит стиль Go в JavaScript, который кажется неестественным, но в некоторых случаях это может оказаться весьма полезным.

Использование .catch

И последний способ вызова ошибок, которым мы поделимся здесь, — продолжить использование .catch().

Вспомните функциональность await: эта функция будет ждать, пока промис завершит свою работу. Также, пожалуйста, помните, что promise.catch()тоже вернет промис! Поэтому мы можем написать обработку ошибок следующим образом:

// books === undefined if error happens,
// since nothing returned in the catch statement
let books = await bookModel.fetchAll()
  .catch((error) => { console.log(error); });

В этом подходе есть две незначительные проблемы:

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

Вывод

Ключевые слова async/await, введенные ES7, безусловно, значительно упростили асинхронное программирование JavaScript. Это помогает делать код более легким для чтения и отладки. Однако, чтобы правильно использовать их, нужно полностью понимать промисы, так как они представляют собой всего лишь синтаксический сахар, в основе которого лежат всё те же промисы.

Перевод статьи Charlee Li: JavaScript async/await: The Good Part, Pitfalls and How to Use