Написать пост

Углубляемся в JavaScript: всё ли может async/await, или когда использовать Promise

Аватар Типичный программист

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

Что такое async/await и promise?

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

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

Асинхронность нужна нам, чтобы делать несколько операций и функций параллельно. Асинхронное программирование — это инструмент для оптимизации высоконагруженных сайтов с долгими и частыми ожиданиями обратной связи. Например, когда одна функция создаёт canvas на странице, другая функция может подготовить данные необходимые для отрисовки внутри canvas. Ещё пример, когда пользователь кладет товар в корзину ему не обязательно ждать ответа сервера, мы заранее можем показать анимацию добавления товара в корзину, а всю остальную логику проверок сделать после ответа сервера, не блокируя интерфейс пользователю.

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

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

Promise — обертка (класс, для простоты понимания) для отложенных и асинхронных вычислений. Ожидает выполнения колбэк функций и никак иначе. Есть два колбэка: один заявляет об успешном выполнении, другой об ошибке. Promise может находиться в трёх состояниях: ожидание (pending), исполнено (fulfilled), отклонено (rejected). Промис начинает выполняться когда мы вызываем метод .then.

Давайте посмотрим практические маленькие примеры синтаксиса.

Пример 1:

			(async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
  const json = await response.json();
  console.log(json);
})();
		
			fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => response.json())
  .then(json => console.log(json))
		

Пример 2:

			let data = [];

const myFunction = async () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([1,2,3]);
    }, 3000);
  });
};

(async () => {
  data = await myFunction();
  console.log('выполнится позже', data);
})();

console.log('выполнится первым', data);
		
			let data = [];

const promise = new Promise(resolve => {
  setTimeout(() => {
    resolve([1,2,3]);
  }, 3000);
});

promise.then(value => {
  data = value;
  console.log('выполнится позже', data);
});

console.log('выполнится первым', data);
		

Пример 3:

			const loadData = async () => {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
    const json = await response.json();
    return json;
  } catch (error) {
    throw error;
  }
}

(async () => {
  try {
    const data= await loadData();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
})();
		
			const loadData = new Promise((resolve, reject) => {
  fetch('https://jsonplaceholder.typicode.com/todos/1').then(response => {
    return response.json();
  }).then(data => {
    resolve(data);
  }).catch(error => {
    reject(error);
  });
});

loadData.then(data => {
      console.log(data);
  }).catch(console.error);
		

Так как async является надстройкой над промисами, то мы можем смешивать код, например так:

			const peopleCount = async () => {
  return 1;
}

peopleCount().then(console.log); // 1
		

или так

			const peopleCount = new Promise(resolve => {
  resolve(1);
});

console.log(await peopleCount); // 1
		

Плюсы и минусы в теории

Async/await
Плюсы

  • Удобство и простота чтения
  • Возможность использования последовательного стиля программирования

Минусы

  • Легко наткнуться на избыточное ожидание последовательного кода. Для истинной параллельности нужно модифицировать код.
  • Неочевидность возвращаемых значений try…catch.

Promise
Плюсы

  • Использует традиционный подход колбэков.
  • Данные с ошибками и данные с успешным результатом операции однозначно понимаемы.
  • Возможность использовать Promise.all без оглядки на синтаксис.
  • Оповещения Promise.resolve и Promise.reject доступны везде.
  • Наглядное использование метода Promise.finally.

Минусы

  • При неправильном использовании возможно создание слишком глубоких использований цепочек .then
На примерах выше видно, что Promise субъективно является более чистым кодом. Более того я заранее заложил одну противную пакость в примерах, о которой расскажу позже. Эта особенность не позволяет выбранному нами синтаксису использовать асинхронность в полной мере. Кто её нашел сходу, может дальше не читать ?

Вера в обещание

Мое знакомство с асинхронным js-кодом началось с библиотеки «КриптоПро ЭЦП Browser plug-in». Те, кто сталкивался с данной библиотекой, должны меня понять, у меня не было выбора, я искренне влюбился в промисы ? Она вся утыкана промисами. И первой техникой, которой пришлось овладеть, были .then и .catch. Порой вложенность кода составляла 10-15 уровней .then. Спустя годы я понимаю почему разработчикам плагина пришлось так поступить, но она прекрасна в своей ужасности.

Шло время, навыки оттачивались, и с тех пор я всегда пишу js-код на промисах.

А теперь о пакости.

Сложный кейс с промисами, и главное преимущество промисов — колбеки

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

Архитектура:

  1. Интервал запросов к серверу 1 секунда.
  2. Необходим один большой (глобальный) промис, чтобы было удобно отключить прелоадер.
  3. До входа в асинхронный код нужно включить прелоадер.
  4. Внутри асинхронного кода должно произойти «нечто ужасное» без потери читабельности.
  5. Отключение прелоадера должно происходить в финальном коде, независимо от того, успешная оплата, или ошибка, и независимо от того, сколько промисов будет использоваться в асинхронном коде.

Для удобства сопоставления алгоритма и архитектуры код совсем чуть-чуть упрощен, и совпадает с оригинальным на 90%.

Результат:

			createOrder() { // вызывается при нажатии на кнопку
    // блокируем форму от ввода данных (изменение inputs недоступно)
    this.lockForm = true;

    this.createStripeOrder({ // ajax запрос на бэк
        paymentMethod: this.paymentMethodForBackend,
    }).then(orderData => {
        return this.confirmCardPayment(orderData.stripePaymentIntentClientSecret); // “нечто ужасное” вынесено в отдельный promise
    }).then(() => {
        this.changeShowSuccessPopup(true);
        this.resetCart(); // получим актуальный статус корзины после успешной оплаты (корзина будет пустая)
    }).catch(error => { // new Error
        this.changeShowFailedPopup({
            isShow: true,
            message: error.message || (error.data && error.data.message),
        });
    }).finally(() => {
        this.lockForm = false; // отключаем прелоадер
    });
},

confirmCardPayment(secret) {
    return this.stripe.instance.confirmCardPayment(secret)
        .then(result => new Promise((resolve, reject) => {
            if (result.error) {
                reject(new Error(result.error.message)); // catch
            } else if (result.paymentIntent.status === 'succeeded') {
                // прокидываем result дальше потому что в он требуется других местах
                resolve(result); // then
            } else if (result.paymentIntent.status === 'pending') {
                // если мы сюда попали значит оплата ещё происходит
                // запускаем цикл на получение реального статуса оплаты по номеру заказа,
                // раз в секунду, ограничение на кол-во запросов - 15 раз

                let counter = 0;
                let timer = null;

                const stopRepeatIfNeeded = () => {
                    counter += 1;

                    if (counter > 14) {
                        clearInterval(timer);
                        reject(new Error('Timeout for payment exceeded'));
                    }
                };

                timer = setInterval(() => {
                    this.checkPaymentStatus().then(orderData2 => {
                        // если completed - заказ оплатился
                        if (orderData2.status === 'completed') {
                            resolve(result); // then
                        } else if (orderData2.status === 'pending') {
                            // pending - продолжаем цикл
                            stopRepeatIfNeeded();
                        } else {
                            // любой другой статус, значит ошибка оплаты
                            reject(new Error('Payment status: ' + orderData2.status)); // catch
                        }
                    }).finally(() => {
                        stopRepeatIfNeeded();
                    });
                }, 1000);
            }

            reject(new Error('Unknown error')); // catch
        }));
},
		

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

Требование заказчика

История закончилась бы замечательно, если бы не одно «но». ТЗ требовало, чтобы весь код был написан на async/await. Глядя на код выше можно сказать, что это достаточно сложный кейс. Первое, что можно подумать: «Это невозможно! Ведь async/await не могут ждать колбека, они только выполняют код и ничего не ждут.»

Ну хорошо… требование заказчика — закон… переписываем.

			async sleep(ms) {
   return new Promise(resolve => setTimeout(resolve, ms));
},

async checkOrderPaymentStatus(orderData) {
    if (orderData.status === 'completed' || orderData.status === 'succeeded') return true;

    if (orderData.status === 'pending') {
        // если мы сюда попапали значит оплата ещё происходит
        // запускаем цикл на получение реального статуса оплаты по номеру заказа,
        // раз в секунду, ограничение на кол-во запросов - 15 раз

        for (let i = 0; i < 10; i += 1) {
            await this.sleep(2500);

            const orderData2 = await this.checkPaymentStatus();

            // если completed, то заказ оплатился
            if (orderData2.status === 'completed') return true;

            if (orderData2.status !== 'pending') {
                // какой-то неизвествестный статус оплаты
                throw new Error('Payment status: ' + orderData2.status);
            }
        }

        throw new Error('Timeout for payment exceeded');
    } else {
        // какой-то неизвествестный статус оплаты
        throw new Error('Unknown error');
    }
},

async createOrder() {
    // блокируем форму от ввода данных (изменение inputs недоступно)
    this.lockForm = true;

    try {
        const orderData = await this.createStripeOrder({
            paymentMethod: this.paymentMethodForBackend,
        });

        this.setOrderNumber(orderData.number); // потребуется при показе successPopup и упрощения запроса vuex checkPaymentStatus

        const result = await this.stripe.instance.confirmCardPayment(orderData.stripePaymentIntentClientSecret);

        if (result.error) {
            throw new Error(result.error.message);
        } else {
            await this.checkOrderPaymentStatus(orderData);

            this.changeShowSuccessPopup(true);
            this.resetCart(); // получим актуальный статус корзины после успешной оплаты (корзина будет пустая)
        }
    } catch (error) {
        this.changeShowFailedPopup({
            isShow: true,
            message: error.message || (error.data && error.data.message),
        });
    }

    this.lockForm = false;
},
		

Задача действительно оказалась не решаема на уровне async/await. Потому что async, await и timeout не работают в связке. Пришлось совсем чуть-чуть смешать два разных синтаксиса с помощью функции sleep. Хорошо это или плохо? Вопрос субъективный. Мы лишь в очередной раз убедились, что async/await является лишь надстройкой над промисами.

Вывод

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

Следите за новыми постами
Следите за новыми постами по любимым темам
18К открытий18К показов