В JavaScript есть два основных способа обработки асинхронного кода: Promise (ES6) и async / await (ES7). Эти синтаксисы дают нам равные базовые функции, но по-разному влияют на читаемость и область видимости. В этой статье мы увидим, как один синтаксис помогает, а другой отправляет нас в callback hell! Материал адаптирован на русский язык совместно с Тимофеем Тиуновым, системным архитектором в Сбермегамаркете и автором курса “JavaScript” в Skillbox.

Тимофей Тиунов: “На самом деле основных способов обработки асинхронного кода три, есть ещё коллбэки. Суть третьего подхода в том, что вы просто передаете функцию как аргумент при вызове другой функции. Например, так работает addEventListener. В современном JavaScript этот способ занял свое место именно в обработке событий, а для написания кода, в котором нужно дождаться выполнения какой-то операции, он оказался неудобным и громоздким. Поэтому с развитием языка появился сначала Promise, а затем синтаксис с async/await”. 

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

К счастью, блокировать основной поток нет необходимости, JavaScript позволяет выполнять задачи параллельно. В ES6 был представлен объект Promise, с методами then, catch и finally. Год спустя, в ES7, в язык добавили еще один подход и два новых ключевых слова: async и await.

Эта статья не ставит своей целью объяснить асинхронный JavaScript; для этого есть много хороших источников. Вместо этого освещается менее раскрытая тема: какой синтаксис — then / catch / finally или async / await — лучше?

Тимофей Тиунов, автор курса “JavaScript” в Skillbox: “В действительности оба способа используются и часто сочетаются друг с другом. Автор создает ложное впечатление, что один лучше другого, но это не так”.

Сначала обратимся к главным функциям каждого синтаксиса, а затем перейдем к примерам.

then, catch и finally

then, catch и finally методы объекта Promise, они объединены в цепочку и идут один за другим. Каждый принимает функцию обратного вызова в качестве аргумента и возвращает Promise.

Создадим простой Promise:

const greeting = new Promise((resolve, reject) => {

  resolve("Hello!");

});

Используя then, catch и finally можно выполнить серию действий, в зависимости от того, разрешен (then) или отклонен (cach) Promise. В то время, как finally позволяет нам выполнять код после выполнения Promise, независимо от того, было ли оно разрешено или отклонено.

greeting

  .then((value) => {

    console.log("The Promise is resolved!", value);

  })

  .catch((error) => {

    console.error("The Promise is rejected!", error);

  })

  .finally(() => {

    console.log("The Promise is settled, meaning it has been resolved or rejected.");

  });

Тимофей Тиунов: “Пример не совсем корректный, так как если сам коллбэк в catch не выкинет ошибку через throw или return Promise.reject, после него можно снова писать then. То есть в данном случае finally бессмысленный”.

В соответствии с целью статьи, использовать можно then. Объединив несколько then методов, мы получаем возможность выполнять последовательные операции над разрешенным Promise. Типичный шаблон для выборки данных с использованием then будет выглядеть примерно так:

fetch(url)

  .then((response) => response.json())

  .then((data) => {

    return {

      data: data,

      status: response.status,

    };

  })

  .then((res) => {

    console.log(res.data, res.status);

  });

Тимофей Тиунов: “Код в последнем примере написан некорректно, так как response недоступен”.

async и await

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

Тимофей Тиунов: “Ключевое слово async ставится перед функцией при ее объявлении. Это позволяет внутри такой функции использовать другое ключевое слово await. await же даёт возможность остановить выполнение функции, чтобы дождаться разрешения promise'а или завершения другой async-функции”.

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

async function doSomethingAsynchronous() {

  // logic

}

const doSomethingAsynchronous = async () => {

  // logic

};

await можно написать перед любой асинхронной функцией или объектом Promise. Таким образом выполнение кода асинхронной функции “зависнет” на этой строке кода до окончания выполнения операции. Пример с greeting:

async function doSomethingAsynchronous() {

  const value = await greeting;

}

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

async function doSomethingAsynchronous() {

  try {

    const value = await greeting;

    console.log("The Promise is resolved!", value);

  } catch((error) {

    console.error("The Promise is rejected!", error);

  } finally {

    console.log("The Promise is settled, meaning it has been resolved or rejected.");

  }

}

Для возвращения Promise в рамки функции async не нужно использовать await. Пример:

async function getGreeting() {

  return greeting;

}

Тимофей Тиунов: “В данном примере async не нужен. Вот пример, где как бы есть await, но в конце возвращается promise:

const res = await fetch(...);

return await res.json(); // тут await можно убрать”.

Однако есть одно исключение из этого правила: вам действительно нужно написать return await, если вы хотите обработать Promise, отклоненный в блоке try ... catch.

async function getGreeting() {

  try {

    return await greeting;

  } catch (e) {

    console.error(e);

  }

}

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

Проблема

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

  • getAuthors - returns all the authors in the database;

  • getBooks - returns all the books in the database;

  • getBio - returns the bio of a specific author.

Объекты выглядят следующим образом:

  • Author: { id: "3b4ab205", name: "Frank Herbert Jr.", bioId: "1138089a" }

  • Book: { id: "e31f7b5e", title: "Dune", authorId: "3b4ab205" }

  • Bio: { id: "1138089a", description: "Franklin Herbert Jr. was an American science-fiction author..." }

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

function filterProlificAuthors(authors, books, minBookCount = 10) {

  return authors.filter(

    ({ id }) => books.filter(({ authorId }) => authorId === id).length > minBookCount

  );

}

Решение 

Часть 1.

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

FETCH all authors

FETCH all books

FILTER authors with more than 10 books

FOR each filtered author

FETCH the author’s bio

Каждый раз, когда мы видим FETCH, нужно выполнить асинхронную задачу. Итак, как мы могли превратить это в JavaScript? Во-первых, давайте посмотрим, как мы можем закодировать эти шаги, используя then:

getAuthors().then((authors) =>

  getBooks()

    .the((books) => {

      const prolificAuthorIds = filterProlificAuthors(authors, books);

      return Promise.all(prolificAuthorIds.map((id) => getBio(id)));

    })

    .then((bios) => {

      // Do something with the bios

    })

);

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

Тимофей Тиунов: “Это скорее должно выглядеть так (ниже). Получение авторов и книг происходит последовательно, хотя ничего не мешает сделать это параллельно. Для этого можно передать вызовы getAuthors/Books в Promise.all, который вернёт promise, разрешающийся тогда, когда все переданные в него promise'ы тоже разрешатся.

Promise.all([

  getAuthors(),

  getBooks(),

]).then(([authors, books]) => {

  const prolificAuthorIds = filterProlificAuthors(authors, books);

  return Promise.all(prolificAuthorIds.map((id) => getBio(id)));

}).then(bios => {

  // …

})”

Код мог бы стать немного более читаемым, если использовать then для возврата даже синхронного кода. Можно дать filterProlificAuthors собственный метод then, как показано ниже:

getAuthors().then((authors) =>

  getBooks()

    .then((books) => filterProlificAuthors(authors, books))

    .then((ids) => Promise.all(ids.map((id) => getBio(id))))

    .then((bios) => {

      // Do something with the bios

    })

);

У этой версии есть преимущество - каждый then помещается в одну строку. Правда, и это не избавляет от нескольких уровней вложенности.

Ну а что насчет async и await? Вот примерно такое решение:

async function getBios() {

  const authors = await getAuthors();

  const books = await getBooks();

  const prolificAuthorIds = filterProlificAuthors(authors, books);

  const bios = await Promise.all(prolificAuthorIds.map((id) => getBio(id)));

  // Do something with the bios

}

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

Тимофей Тиунов: “А вот это очень вредный пример. Вызовы с await, которые идут подряд сверху вниз, будут исполняться последовательно, хотя в данном случае можно параллельно. Здесь подойдёт гибридное решение на основе promise'ов и await:

const [authors, books] = await Promise.all([

getAuthors(),

getBooks(),

]);”

Часть 2

Введем новое требование. На этот раз, когда у нас есть массив bios, мы хотим создать объект, содержащий bios, общее количество авторов и общее количество книг.

Сразу начнем с async/await:

async function getBios() {

  const authors = await getAuthors();

  const books = await getBooks();

  const prolificAuthorIds = filterProlificAuthors(authors, books);

  const bios = await Promise.all(prolificAuthorIds.map((id) => getBio(id)));

  const result = {

    bios,

    totalAuthors: authors.length,

    totalBooks: books.length,

  };

}

Легко и просто. С кодом ничего не нужно делать, поскольку все переменные уже есть. Стоит просто определить результирующий объект в конце.

С then это непросто. В решении из предыдущей части переменные books и bios находятся на разных уровнях. В принципе, можно было бы ввести еще и переменную books, но это усложнило бы ситуацию. Одно из решений - ввести третий уровень вложенности:

getAuthors().then((authors) =>

  getBooks().then((books) => {

    const prolificAuthorIds = filterProlificAuthors(authors, books);

    return Promise.all(prolificAuthorIds.map((id) => getBio(id))).then(

      (bios) => {

        const result = {

          bios,

          totalAuthors: authors.length,

          totalBooks: books.length,

        };

      }

    );

  })

);

Кроме того, можно деструктурировать массив для передачи books по цепочке на каждом этапе:

getAuthors().then((authors) =>

  getBooks()

    .then((books) => [books, filterProlificAuthors(authors, books)])

    .then(([books, ids]) =>

      Promise.all([books, ...ids.map((id) => getBio(id))])

    )

    .then(([books, bios]) => {

      const result = {

        bios,

        totalAuthors: authors.length,

        totalBooks: books.length,

      };

    })

);

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

Тимофей Тиунов: “А вот тут прям классный пример, где действительно async/await сильно упрощает код. В коде на async/await все значения находятся в рамках одной фукнции, то есть в одной области видимости. Это значит, что в любом месте функции после объявления нужного значения мы имеем к нему доступ. В случае с promise'ами результаты вычислений одного блока then/catch нам нужно явно передавать в другой, если это необходимо. В таких ситуациях, конечно, async/await может сильно выручить.”

Часть 3

В качестве оптимального варианта можно улучшить предыдущее решение, немного его очистив. Для этого можно использовать Promise.all, что дает возможность выбрать и авторов и книги параллельно.

Promise.all([getAuthors(), getBooks()]).then(([authors, books]) => {

  const prolificAuthorIds = filterProlificAuthors(authors, books);

  return Promise.all(prolificAuthorIds.map((id) => getBio(id))).then((bios) => {

    const result = {

      bios,

      totalAuthors: authors.length,

      totalBooks: books.length,

    };

  });

});

Это может быть лучшим решением для then. Код работает быстрее, нет многоуровневой вложенности.

async function getBios() {

  const [authors, books] = await Promise.all([getAuthors(), getBooks()]);

  const prolificAuthorIds = filterProlificAuthors(authors, books);

  const bios = await Promise.all(prolificAuthorIds.map((id) => getBio(id)));

  const result = {

    bios,

    totalAuthors: authors.length,

    totalBooks: books.length,

  };

}

Вывод

Использование методов chained then во многих случаях может потребовать утомительных изменений, особенно когда хочется убедиться, что определенные переменные находятся в области видимости. Даже для простого сценария, подобного тому, который указан выше, не было очевидного лучшего решения. Так, каждое из пяти решений, где использовалось then, имело разные компромиссы для удобочитаемости. Напротив, async / await предоставил самое удобочитаемое решение, которое практически нет необходимости модифицировать.

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

Тимофей Тиунов, автор курса “JavaScript” в Skillbox: “Утверждать, что один инструмент лучше другого — не совсем корректно, что показано на примерах выше. Promise и async/await существуют вместе и часто дополняют друг друга. Бывают ситуации, когда удобно одно, а когда другое. Вы вольны смешивать оба подхода для максимальной читаемости и эффективности кода. Ниже еще один пример в контексте краткости кода: 

// на await

const res = await fetch('...');

const json = await res.json();

// на promise + await

const json = await fetch('...').then(res => res.json());

Второй вариант гибридный и просто компактнее.”

Комментарии (22)


  1. AndyPike
    15.02.2022 22:02

    return result куда потеряли?


    1. vinregion
      16.02.2022 09:19

      Неявный возврат, можно без retirn


  1. Sadler
    15.02.2022 22:54
    +1

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

    Обычно превращается во что-то такое (пример из головы, не обязан компилиться)
    const inv: Inventory = await Inventories.create({ id: 'efrvukij' }, { persistent: false });
    const bread = await inv.items.create({ name: 'bread', amount: 4});
    const foundWater = await inv.items.findOne({ name: 'water' });
    const len = await inv.items.count();
    if (len > 3 && foundWater) {
      if (await foundWater.delete()) {
        UI.Notify(`It's done!`)
      }
    }
    

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


    1. dimatsourkan
      16.02.2022 09:20
      +1

      Не вижу ничего плохого в приведенном примере, с колбеками или прямая работа с промисами выглядела бы куда хуже


      1. Sadler
        16.02.2022 12:32

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


    1. NN1
      16.02.2022 12:23
      -1

      Ну так это плата за корутины без стека.

      Либо у нас stackless и растём снизу вверх либо мы сверху вниз сохраняя стек (stackful).

      Производительнее для модели JS стек не сохранять.


  1. alexesDev
    15.02.2022 22:57
    +6

    Можно параллельно и без гибридного подхода...

    a = getPromise();
    b = getPromise();
    
    await a;
    await b;


    1. dolfinus
      16.02.2022 01:06
      -2

      А где тут параллельность?


      1. Aquahawk
        16.02.2022 03:10
        +5

        Обе функции начнуттсвою асинронную работу сразу. Даже если b закончит раньше, дождёмся a потом сразу(черезтитерацию лупа) b. Это полностью эквивалентно promice all


    1. mayorovp
      16.02.2022 13:46
      -1

      Два исключения сразу в a и в b при таком подходе рискуют уронить всю программу (если это нода) или некрасиво "выплыть" в консоли (если дело в браузере).


      1. faiwer
        16.02.2022 18:36

        не роняет

        image


        … но пачкается, да


  1. Devoter
    16.02.2022 01:10
    +1

    Тимофей Тиунов: “В данном примере async не нужен. Вот пример, где как бы есть await, но в конце возвращается promise:

    Полагаю, что в примере, к которому написано это замечание, могут быть асинхронные вопросы, результат которых возвращается, например, что-то вроде этого (пример условный):

    async function loadData(id) {
      let response;
      
      try {
        response = await fetch(`/data/${id}`);
      } catch (e) {
        console.error('request failed by the reason:', e);
        return null;
      }
      
      return response;
    }

    Эта функция возвращает Promise, хотя сам по себе объект response таковым и не является.

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


    1. megahertz
      16.02.2022 07:29

      Согласен. Практика показывает, что если функция возвращает Promise, она всегда должна быть помечена как async. Это и нагляднее, и убережет от случая когда функция будет изменена и вместо Promise будет возвращено обычное значение, к чему клиентский код может быть не готов.


      1. faiwer
        16.02.2022 18:29
        +1

        … или просто возьмите Typescript.


        1. megahertz
          16.02.2022 20:26
          -1

          одно другому не мешает


          1. faiwer
            16.02.2022 21:00
            +1

            Здравый смысл мешает. Нет нужды городить лишнюю абстрацию поверх вызова promise, если проблемы с "клиентский код может быть не готов" не существует как класс.


            1. megahertz
              16.02.2022 22:13

              async перед именем функции - на лишнюю абстракцию не тянет. Было бы интересно услышать доводы против этого (как для JS так и в TS).


              1. faiwer
                16.02.2022 22:30
                +1

                async перед именем функции это не чёрная магия. Это runtime. По сути:


                function a() {
                    return new Promise((resolve, reject) => {
                        Promise.resolve(1).then(resolve, reject);
                    });
                }
                
                async function b() {
                  return await Promise.resolve(1); // либо без await
                }

                Это плюс минус одно и то же. async тут просто сахар. А можно просто так:


                function c() {
                    return Promise.resolve(1);
                }

                С практической точки зрения разница (a и c):


                • могут быть отличия в stack traces
                • немного лишнего runtime т.к. создаётся лишний сегмент promise-матрёшки со всеми вытекающими. Promise сам по себе тяжёлая абстракция, а async-await втройне (там ведь finite machine)
                • в non-async функции случайно не напишешь await
                • некоторые тонкости в обработке ошибок (это камень в сторону варианта c)
                • в случае какой-нибудь утиной типизации нельзя вернуть не Promise (хотя ну её эту утиную типизацию, если честно)
                • полагаю на уровне lib-uv можно поймать приколы вроде разной обработки очереди (но тут надо вникать, мне лень)


                1. megahertz
                  16.02.2022 23:28

                  Спасибо, список внушительный. Оверхед, пожалуй главный аргумент. Сравнил два варанта, без async выходит в полтора раза быстрее.


  1. bruian
    16.02.2022 05:21
    +4

    Я конечно душнила ещё тот. Но статьи пережевывающие одну и ту же тему уже просто в глазах мельтешат. Реферат за рефератом, это похоже на то что люди просто себе делают заметку в процессе обучения, в виде статьи на хабре. Оригинальные темы, исследования, гипотезы уже исчерпали себя. Только хардкорная перепечатка давно известных тем. Копни Хабр поглубже найдётся статей 5 про as/aw. Копни медиум, индусы насыпят ещё 50. Мне что-то это напомнило систему в университете, когда у научного сотрудника оклад может зависить от цитируемости. Теперь это ушло в hh плоскость, потенциальные сотрудники стараются увеличить объём публикаций в надежде на строчечку в резюме: публикации - хабр.


  1. vlasenkofedor
    18.02.2022 09:50
    +2

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

    это один запрос с join, count в базу. Не стоит разбивать на задачи и обрабатывать на беке


    1. Devoter
      18.02.2022 12:36

      Абсолютно верно, тот самый случай, когда важно поставить правильную задачу, а не вот это вот "10 запросов на бекенд", хотя случаи всякие бывают, конечно. Думаю, именно из-за подобных проблем и родился GraphQL.