javascript

Async/await: 6 причин забыть о промисах

  • среда, 12 апреля 2017 г. в 03:15:04
https://habrahabr.ru/company/ruvds/blog/326074/
  • Node.JS
  • JavaScript
  • Блог компании RUVDS.com


Если вы не в курсе, в Node.js, начиная с версии 7.6, встроена поддержка механизма async/await. Говорят о нём, конечно, уже давно, но одно дело, когда для использования некоей функциональности нужны «костыли», и совсем другое, когда всё это идёт, что называется, «из коробки». Если вы ещё не пробовали async/await — обязательно попробуйте.

image

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

Основы async/await


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

  • Async/await — это новый способ написания асинхронного кода. Раньше подобный код писали, пользуясь коллбэками и промисами.

  • Наше предложение «забыть о промисах» не означает, что они потеряли актуальность в свете новой технологии. На самом деле, в основе async/await лежат промисы. Нужно учитывать, что этот механизм нельзя использовать с коллбэками.

  • Конструкции, построенные с использованием async/await, как и промисы, не блокируют главный поток выполнения программы.

  • Благодаря async/await, асинхронный код становится похожим на синхронный, да и в его поведении появляются черты такого кода, весьма полезные в некоторых ситуациях, в которых промисами пользоваться было, по разным причинам, неудобно. Но раньше без них было не обойтись. Теперь же всё изменилось. Именно тут кроется мощь async/await.

Синтаксис


Представим, что у нас имеется функция getJSON, которая возвращает промис, при успешном разрешении которого возвращается JSON-объект. Мы хотим эту функцию вызвать, вывести в лог JSON-объект и вернуть done.

С использованием промисов подобное можно реализовать так:

const makeRequest = () =>
  getJSON()
    .then(data => {
      console.log(data)
      return "done"
    })

makeRequest()

Вот как то же самое делается с использованием async/await:

const makeRequest = async () => {
  console.log(await getJSON())
  return "done"
}

makeRequest()

Если сопоставить два вышеприведённых фрагмента кода, можно обнаружить следующее:

  1. При описании функции использовано ключевое слово async. Ключевое слово await можно использовать только в функциях, определённых с использованием async. Любая подобная функция неявно возвращает промис, а значением, возвращённым при разрешении этого промиса, будет то, что возвратит инструкция return, в нашем случае это строка done.

  2. Вышесказанное подразумевает, что ключевое слово await нельзя использовать вне async-функций, на верхнем уровне кода.

    // Эта конструкция на верхнем уровне кода работать не будет
    // await makeRequest()
    
    // А такая - будет
    makeRequest().then((result) => {
      // do something
    })

  3. Запись await getJSON() означает, что вызов console.log будет ожидать разрешения промиса getJSON(), после чего выведет то, что возвратит функция.

Почему async/await лучше промисов?


Рассмотрим обещанные шесть преимуществ async/await перед традиционными промисами.

▍1. Лаконичный и чистый код


Сравнивая два вышеприведённых примера, обратите внимание на то, насколько тот, где используется async/await, короче. И ведь речь, в данном случае, идёт о маленьких кусках кода, а если говорить о реальных программах, экономия будет ещё больше. Всё дело в том, что не нужно писать .then, создавать анонимную функцию для обработки ответа, или включать в код переменную с именем data, которая нам, по сути, не нужна. Кроме того, мы избавились от вложенных конструкций. Полезность этих мелких улучшений станет заметнее, когда мы рассмотрим другие примеры.

▍2. Обработка ошибок


Конструкция async/await наконец сделала возможной обработку синхронных и асинхронных ошибок с использованием одного и того же механизма — старого доброго try/catch. В следующем примере с промисами try/catch не обработает сбой, возникший при вызове JSON.parse, так как он выполняется внутри промиса. Для обработки подобной ошибки нужно вызвать метод .catch() промиса и продублировать в нём код обработки ошибок. В коде рабочего проекта обработка ошибок будет явно сложнее вызова console.log из примера.

const makeRequest = () => {
  try {
    getJSON()
      .then(result => {
        // парсинг JSON может вызвать ошибку
        const data = JSON.parse(result)
        console.log(data)
      })
      // раскомментируйте этот блок для обработки асинхронных ошибок
      // .catch((err) => {
      //   console.log(err)
      // })
  } catch (err) {
    console.log(err)
  }

Вот то же самое, переписанное с использованием async/await. Блок catch теперь будет обрабатывать и ошибки, возникшие при парсинге JSON.

const makeRequest = async () => {
  try {
    // парсинг JSON может вызвать ошибку
    const data = JSON.parse(await getJSON())
    console.log(data)
  } catch (err) {
    console.log(err)
  }
}

▍3. Проверка условий и выполнение асинхронных действий


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

const makeRequest = () => {
  return getJSON()
    .then(data => {
      if (data.needsAnotherRequest) {
        return makeAnotherRequest(data)
          .then(moreData => {
            console.log(moreData)
            return moreData
          })
      } else {
        console.log(data)
        return data
      }
    })
}

Только от взгляда на эту конструкцию может разболеться голова. Очень легко потеряться во вложенных конструкциях (тут их 6 уровней), скобках, командах возврата, которые нужны лишь для того, чтобы доставить итоговый результат главному промису.

Код будет гораздо легче читать, если решить задачу с использованием async/await.

const makeRequest = async () => {
  const data = await getJSON()
  if (data.needsAnotherRequest) {
    const moreData = await makeAnotherRequest(data);
    console.log(moreData)
    return moreData
  } else {
    console.log(data)
    return data    
  }
}

▍4. Промежуточные значения


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

const makeRequest = () => {
  return promise1()
    .then(value1 => {
      // do something
      return promise2(value1)
        .then(value2 => {
          // do something          
          return promise3(value1, value2)
        })
    })
}

Если для promise3 не нужно value1, можно без особых сложностей упростить структуру программы, особенно если вам режут глаз подобные конструкции, использованные без необходимости. В такой ситуации можно обернуть value1 и value2 в вызов Promise.all и избежать ненужных вложенных конструкций.

const makeRequest = () => {
  return promise1()
    .then(value1 => {
      // do something
      return Promise.all([value1, promise2(value1)])
    })
    .then(([value1, value2]) => {
      // do something          
      return promise3(value1, value2)
    })
}

При таком подходе семантика приносится в жертву читабельности кода. Нет причин для того, чтобы помещать value1 и value2 в один и тот же массив за исключением того, чтобы избежать вложенности промисов.

То же самое можно написать с применением async/await, причём делается это удивительно просто, а то, что получается, оказывается интуитивно понятным. Тут поневоле задумаешься о том, сколько полезного можно сделать за то время, которое тратится на написание хоть сколько-нибудь приличного кода с использованием промисов.

const makeRequest = async () => {
  const value1 = await promise1()
  const value2 = await promise2(value1)
  return promise3(value1, value2)
}

▍5. Стеки ошибок


Представьте себе фрагмент кода, в котором имеется цепочка вызовов промисов, а где-то в этой цепочке выбрасывается ошибка.

const makeRequest = () => {
  return callAPromise()
    .then(() => callAPromise())
    .then(() => callAPromise())
    .then(() => callAPromise())
    .then(() => callAPromise())
    .then(() => {
      throw new Error("oops");
    })
}

makeRequest()
  .catch(err => {
    console.log(err);
    // вывод
// Error: oops at callAPromise.then.then.then.then.then (index.js:8:13)

Стек ошибки, возвращённый из цепочки промисов, не содержит указания на то, где именно произошла ошибка. Более того, сообщение об ошибке способно направить усилия по поиску проблемы по по ложному пути. Единственное имя функции, которое содержится в сообщении — callAPromise, а эта функция никаких ошибок не вызывает (хотя, конечно, тут есть и полезная информация — сведения о файле, где произошла ошибка, и о номере строки).

Если же взглянуть на подобную ситуацию при использовании async/await, стек ошибки укажет на ту функцию, в которой возникла проблема.

const makeRequest = async () => {
  await callAPromise()
  await callAPromise()
  await callAPromise()
  await callAPromise()
  await callAPromise()
  throw new Error("oops");
}

makeRequest()
  .catch(err => {
    console.log(err);
    // вывод
    // Error: oops at makeRequest (index.js:7:9)
  })

Подобное нельзя назвать огромным плюсом, если речь идёт о разработке в локальном окружении, когда файл с кодом открыт в редакторе. Но это оказывается весьма полезным, если вы пытаетесь понять причину ошибки, анализируя лог-файл, полученный с продакшн-сервера. В подобных случаях знать, что ошибка произошла в makeRequest, лучше, чем знать, что источник ошибки — некий then, вызванный после ещё одного then, который следует за ещё каким-то then

▍6. Отладка


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

1. Нельзя установить точку останова в стрелочных функциях, которые возвращают результаты выполнения выражений (нет тела функции).


Попробуйте поставить где-нибудь в этом коде точку останова

2. Если вы установите точку останова внутри блока .then и воспользуетесь командой отладчика вроде «шаг с обходом» (step-over), отладчик не перейдёт к следующему .then, так как он может «перешагивать» только через синхронные блоки кода.

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


Отладка при использовании async/await

Замечания


Вполне возможно, у вас возникнут некоторые соображения не в пользу применения async/await, продиктованные здоровым скептицизмом. Прокомментируем пару наиболее вероятных.

  • Эта конструкция делает менее очевидными асинхронные вызовы. Да, это так, и тот, кто привык видеть асинхронность там, где есть коллбэк или .then, может потратить несколько недель на то, чтобы перестроиться на автоматическое восприятие инструкций async/await. Однако, например, в C# подобная функциональность есть уже многие годы, и те, кто с этим знакомы, знают, что польза от неё стоит временных неудобств при чтении кода.

  • Node 7 — не LTS-релиз. Это так, но совсем скоро выйдет Node 8, и перевод кода на новую версию, вероятно, не потребует особых усилий.

Выводы


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

Уважаемые читатели! Как вы относитесь к async/await? Пользуетесь ли вы этой возможностью в своих проектах?