Почему async/await больше, чем синтаксический сахар

Источник: «Why Async/Await Is More Than Just Syntactic Sugar»
Несмотря на то, что тысячи статей об async/await и Promise уже существуют, многие из них оставляют желать лучшего. Поэтому хочу написать свою статью на эту тему.

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

Эта статья предполагает наличие базовых знаний о Promise и async/await. Она не для того, чтобы конкурировать с руководствами на MDN и javascript.info (learn.javascript.ru).

Немного истории асинхронного программирования в JavaScript

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

До серьёзного обновления JavaScript в ES2015 (ES6) обратные вызовы были тем, чем люди справлялись с асинхронным программированием. Единственный способ выразить временную зависимость (то есть порядок выполнения асинхронных операций) — это вложить один обратный вызов в другой. Это привило к так называемому Callback Hell/Ад обратных вызовов.

Пользователь Reddit @theQuandary отметил, что до ES6 существовали другие лучшие альтернативы асинхронному программированию JavaScript, чем обратные вызовы. Извините, что не на 100% точен, так как большая часть этой истории прошла мимо меня.

Затем Promise был представлен в JavaScript в ES2015. Это первоклассный объект для асинхронных операций, который можно легко передавать, компоновать, агрегировать и применять к нему преобразования. Временная зависимость чётко выражается через цепочку методов then.

Ещё немного истории…

Идея Promise в JavaScript не была оригинальной. Она была вдохновлена старым языком под название E. Его создатель Марк Миллер также является представителем TC39. А синтаксис `async/await` был заимствован из C#.

Поскольку Promise служит мощным примитивом, похоже, что асинхронное программирование — это решённая проблема в JavaScript, верно?

Ну, пока не совсем, потому что иногда уровень Promise может быть слишком низким для работы с…

Иногда Promise может быть слишком низкого уровня для работы с

Несмотря на появление Promise, по-прежнему существовала потребность в языковой конструкции более высокого уровня для асинхронного программирования в JavaScript.

Давайте рассмотрим пример, где нужна функция для опроса API с некоторым интервалом. Она разрешает значение null, когда достигнуто максимальное количество повторных попыток.

Вот одно из возможных решений с Promise:

let count = 0;

function apiCall() {
return new Promise((resolve) =>
// на шестой попытке, разрешает `value`
count++ === 5 ? resolve('value') : resolve(null)
);
}

function sleep(interval) {
return new Promise((resolve) => setTimeout(resolve, interval));
}

function poll(retry, interval) {
return new Promise((resolve) => {
// пропускаем обработку ошибок для краткости...

if (retry === 0) resolve(null);
apiCall().then((val) => {
if (val !== null) resolve(val);
else {
sleep(interval).then(() => {
resolve(poll(retry - 1, interval));
});
}
});
});
}

poll(6, 1000).then(console.log); // 'value'

Насколько интуитивно понятным и удобочитаемым будет это решение, зависит от знакомства с Promise, от того, как Promise.resolve плоские Promise и рекурсию.

Вместо этого вы можете использовать setInterval

Почти всегда есть другой способ написать функцию. Вот решение с setInterval написанное моим другом Джеймсом:

const pollInterval = (retry, interval) => {
return new Promise((resolve) => {

let intervalToken, timeoutToken;

intervalToken = setInterval(async () => {
const result = await apiCall();
if (result !== null) {
clearInterval(intervalToken);
clearTimeout(timeoutToken);
resolve(result);
}
}, interval);

timeoutToken = setTimeout(() => {
clearInterval(intervalToken);
resolve(null);
}, retry * interval);
});
};

Вводим async/await

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

async function poll(retry, interval) {
while (retry >= 0) {
const value = await apiCall().catch((e) => {}); // пропускаем обработку ошибок для краткости...
if (value !== null) return value;
await sleep(interval);
retry--;
}

return null;
}

Я ожидаю, что большинство людей сочтут это решение более читабельным, потому что мы можем использовать все нормальные языковые конструкции, такие как циклы, try-catch для асинхронных операций.

Рекурсивный подход

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

const pollAsyncAwait = async (retry, interval) => {
if (retry < 0) return null;

const value = await apiCall().catch((e) => {}); // пропускаем обработку ошибок для краткости...
if (value !== null) return value;

await sleep(interval);
return pollAsyncAwait(retry - 1, interval);
};

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

Между прочим, await даже имеет правильный приоритет оператора, так что wait a + await b действительно означает (await a) + (await b), а не, скажем, await(a + await b).

async/await предлагает унифицированный опыт синхронизации асинхронного кода

Ещё одна приятная особенность async/await заключается в том, что await автоматически превращает любые не-Promise (non-thenables) в Promise. Семантика await приравнивается к Promise.resolve, что означает, что вы можете ждать что угодно:

function fetchValue() {
return 1;
}

async function fn() {
const val = await fetchValue();
console.log(val); // 1
}

// 👆 это равно следующему

function fn() {
Promise.resolve(fetchValue()).then((val) => {
console.log(val); // 1
});
}
Обратите внимание, что это поведение зависит от браузера…

Утверждение, что await foo равно Promise.resolve(foo).then(...) не является на 100% точным.

До Chrome 97 спецификация ECMAScript переводила await foo в Promise.resolve(foo).then(...). Затем в этом PR было внесено изменение в спецификацию. Но до сих пор не каждый браузер поддерживал изменения спецификации; на момент написания этой статьи Safari ещё не реализовал обновлённую спецификацию. В результате запуска [этого фрагмента](https://gist.github.com/zhenghaohe/c90ec960b890eca60b7bd8008f856a70) в Safari будет результат отличный от результата Chrome.

Если бы мы присоединили метод then к числу 1, возвращаемому из fetchValue, возникла бы следующая ошибка:

function fetchValue() {
return 1;
}

function fn() {
fetchValue().then((val) => {
console.log(val);
});
}

fn(); // ❌ Uncaught TypeError: fetchValue(...).then is not a function

Наконец, всё, что возвращается из async функции, всегда Promise:

Object.prototype.toString.call((async function () {})()); // '[object Promise]'

async/await обеспечивает лучшую трассировку стека ошибок

Инженер V8 Матиас написал статью Asynchronous stack traces: why await beats Promise#then(), в которой рассказывается, почему движку легче захватывать и хранить трассировку стека для async/await по сравнению с Promise.

Вот демо:

async function foo() {
await bar();
return 'value';
}

function bar() {
throw new Error('BEEP BEEP');
}

foo().catch((error) => console.log(error.stack));

// Error: BEEP BEEP
// at bar (<anonymous>:7:9)
// at foo (<anonymous>:2:9)
// at <anonymous>:10:1

Асинхронная версия корректно захватывает трассировку стека ошибок.

Давайте посмотрим на Promise версию:

function foo() {
return bar().then(() => 'value');
}

function bar() {
return Promise.resolve().then(() => {
throw new Error('BEEP BEEP');
});
}

foo().catch((error) => console.log(error.stack));

// Error: BEEP BEEP at <anonymous>:7:11

Трассировка стека потеряна. Переключение с анонимной стрелочной функции на объявление именованной функции немного помогает, но ненамного:

function foo() {
return bar().then(() => 'value');
}

function bar() {
return Promise.resolve().then(function thisWillThrow() {
throw new Error('BEEP BEEP');
});
}

foo().catch((error) => console.log(error.stack));

// Error: BEEP BEEP
// at thisWillThrow (<anonymous>:7:11)

Распространённые возражения против async/await

Я видел два распространённых возражения против async/await.

Первое, async/await может выстрелить в ногу, когда кто-то излишне секвенирует независимые вызовы асинхронных функций, когда они могут обрабатываться параллельно (или параллельно, если мы используем этот термин в широком смысле) с Promise.all.

Обычно это происходит, когда люди пытаются разобраться с асинхронным программированием, не понимая, как работает Promise за кулисами.

Во втором больше нюансов. Некоторые энтузиасты функционального программирования считают, что async/await предполагает программирование в императивном стиле. С точки зрения FP-программиста, возможность использовать циклы и try catch не является благом, поскольку эти языковые конструкции подразумевают побочные эффекты и способствуют неидеальной обработке ошибок.

Я симпатизирую этому аргументу. FP-программисты по праву заботятся об определённости в своих программах. Они хотят быть абсолютно уверенными в своём коде. Чтобы добиться этого необходима сложная система типов с такими типами, как Result. Но я не думаю, что async/await сам по себе несовместим с FP. Мой друг Джеймс, специалист по FP, сказал, что в Haskell есть аналог async/await — возможности Do-нотации.

В любом случае, я думаю, что для большинства людей, включая меня, FP остаётся приобретённым вкусом (хотя я действительно думаю, что FP это очень круто, и я медленно изучаю его). Обычные операторы управления потока и обработки ошибок try catch, предоставляемые async/await, бесценны для нас при организации сложных асинхронных операций в JavaScript. Именно поэтому, сказать, что async/await — это просто синтаксический сахар, — это преуменьшение.

Дополнительные материалы

Предыдущая Статья

Как исправить: Using $this when not in object context

Следующая Статья

Введение в CSS Viewport (Область просмотра)