Почему async/await больше, чем синтаксический сахар
В этой статье я хотел бы подчеркнуть, что async/await
— это больше, чем синтаксический сахар поверх Promise
, поскольку async/await
предлагает ощутимые преимущества:
async/await
позволяет использовать все языковые конструкции, доступные в синхронном программировании, что приводит к более выразительному и читабельному коду;async/await
объединяет возможности асинхронного программирования;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
— это просто синтаксический сахар