Если вам доводилось работать с JavaScript, то вы наверняка встречались с синтаксисом async/await. Эта функциональность позволяет прописывать асинхронную логику синхронным образом, упрощая тем самым её понимание. Некоторым ветеранам JS известно, что async/await – это просто синтаксический сахар для существующего Promises API. Это означает, что в JS должен быть способ реализации функциональности async/await без использования ключевых слов
async
и await
, хоть и более громоздкий. Именно об этом и пойдёт речь в данной статье.Видео от автора на ту же тему.
▍ Наша цель
Для понимания нашей задачи рассмотрим пример шаблонного кода.
function wait() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Timeout resolved');
}, 2000);
});
}
async function main() {
console.log('Entry');
const result = await wait();
console.log(result);
console.log('Exit');
return 'Return';
}
main().then(result => {
console.log(result);
});
Вот его вывод:
Entry
// Пауза 2 секунды
Timeout resolved
Exit
Return
Имея приведённый выше код и соответствующий вывод, можем ли мы переписать функцию
main()
без использования async
и await
, по-прежнему получив тот же результат? Условия будут следующими:- Не использовать цепочки промисов в
main()
. Это бы сделало задачу тривиальной и увело нас от изначальной цели. Цепочки промисов могут сработать в примере выше, но это не отразит всю суть async/await и решаемой ими задачи. - Не изменять сигнатуры функций. Изменение сигнатур функций потребует обновления их вызовов, что вызовет сложности в крупных проектах со множеством взаимозависимых функций. Старайтесь этого не делать.
Несмотря на определённые выше условия, если у вас возникнут затруднения, с которыми вы не сможете справиться без их нарушения, можете на это нарушение пойти. Возможно, так вы найдёте подход, который этим условиям будет соответствовать. С первого раза правильное решение найти очень сложно. Даже мне в процессе подготовки статьи это удалось не сразу.
▍ Песочница
Это практическая статья, и я рекомендую вам использовать приведённую ниже песочницу для проверки собственных идей по реализации функциональности async/await без ключевых слов
async
и await
. В оставшейся части статьи приводятся подсказки и решения для этой задачи, но в процессе вы вполне можете отвлекаться, чтобы попробовать свой подход.function wait() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Timeout resolved');
}, 2000);
});
}
async function main() {
console.log('Entry');
const result = await wait();
console.log(result);
console.log('Exit');
return 'Return';
}
main().then(result => {
console.log(result);
});
Вывод:
Entry
Timeout resolved
Exit
Return
Примечание переводчика: интерактивная песочница доступна в оригинале статьи.
▍ Подсказка #1: обратите внимание на приостановку и возобновление функции
Как выглядит функциональность async/await на верхнем уровне? Она заключается в приостановке асинхронной функции, когда та встречает инструкцию
await
, и последующем возобновлении выполнения, когда ожидаемый промис разрешается или выбрасывает ошибку. Здесь логично спросить «Как обработать приостановку/возобновление функции?» Функции стремятся к выполнению до завершения, не так ли?Существует ли в JS возможность, имитирующая такое поведение? Да, и это генераторы.
Генераторы – это особые функции, способные возвращать в процессе своего выполнения несколько фрагментов данных. Традиционные функции могут возвращать множество данных посредством таких структур, как массивы и объекты. Но генераторы возвращают данные тогда, когда об этом просит вызывающий, приостанавливая выполнение, пока их не попросят продолжить генерировать и возвращать очередные данные.
Я не буду углубляться в возможности генераторов. Более подробную информацию можете найти по прикреплённой ссылке.
Ниже вы видите фрагмент кода, поясняющий принцип действия этих функций. Почитайте комментарии и попробуйте разобраться, что конкретно в этом коде происходит.
function* main() {
console.log('Entry');
const message = yield 'Result 1';
console.log(message);
console.log('Exit');
yield 'Result 2';
return 'Return';
}
const it = main();
/**
* Сейчас в консоли вывода нет, несмотря на вызов main().
* Вызов .next() для объекта, возвращённого генератором, запускает выполнение.
*/
console.log(it.next());
/**
* Вывод:
* Entry
* { value: "Result 1", done: false }
*
* Генератор приостановил выполнение на строке 4 и возобновит его после очередного вызова
* .next().
*/
console.log(it.next('Message Passing'));
/**
* .next() также получает аргумент, который становится доступен для инструкции yield, где
* приостановился генератор.
*
* Вывод:
* Message Passing
* Exit
* { value: "Result 2", done: false }
*/
console.log(it.next());
/**
* Вывод:
* { value: "Return", done: true }
*/
Теперь, когда вы знакомы с генераторами, предлагаю вам отвлечься и поэкспериментировать в песочнице.
▍ Подсказка #2: когда возобновлять выполнение функции?
Генераторы предоставляют способ приостановки/возобновления функций. Если вспомнить принцип работы async/await, то асинхронная функция приостанавливается, когда встречает инструкцию
await
. Значит, можно рассматривать эту функцию как генератор и поместить рядом с промисом инструкцию yield
, чтобы функция на этом шаге приостанавливалась. Получится так:function* main() {
console.log('Entry');
const result = wait();
yield;
console.log(result);
console.log('Exit');
return 'Return';
}
const it = main();
it.next(); // Запускает выполнение. Когда нужно снова вызвать it.next()?
Но когда генератор должен возобновить выполнение? Это должно произойти, когда разрешится промис рядом с
yield
. Откуда вызывающий узнает о промисе, если тот находится в генераторе? Можно ли как-то раскрыть этот промис вызывающему, чтобы он мог прикрепить к нему обратный вызов .then()
, который будет вызывать .next()
для объекта генератора, чтобы тот продолжил выполнение?Ответом на все эти вопросы будет просто
yield
промиса, который нужно ожидать, чтобы вызывающий мог использовать этот промис и вызвать .next()
, когда тот разрешится.function* main() {
console.log('Entry');
const result = yield wait();
console.log(result);
console.log('Exit');
return 'Return';
}
const it = main();
it.next().value.then(() => {
it.next();
});
▍ Подсказка #3: сделать данные разрешившегося промиса доступными для генератора
В предыдущем фрагменте кода мы смогли успешно приостановить выполнение функции и возобновить её, когда разрешился промис. Но генератор не получает от промиса разрешившиеся данные. Переменная
result
в функции main
должна содержать Timeout resolved
, что мы видим при использовании async/await. Но в нашей реализации она не получает данные, которые предоставляет промис после своего разрешения.Можно ли как-то передать эти данные генератору? В конце концов у вызывающего есть к ним доступ, поскольку генератор возвращает промис. Так может ли он передать эти данные обратно генератору, когда вызывающий вызывает для объекта генератора
.next
? Выше мы уже встречали Message Passing
. Функция .next()
получает аргумент, оказывающийся доступным для инструкции yield
, где генератор был приостановлен. Значит, для передачи данных из разрешившегося промиса мы просто вызываем .next()
с этими данными.function* main() {
console.log('Entry');
const result = yield wait();
console.log(result);
console.log('Exit');
return 'Return';
}
const it = main();
it.next().value.then(resolvedData => {
it.next(resolvedData);
});
Внеся это изменение, мы получили простую реализацию async/await без использования
async
и await
. Обратите внимание на функцию main()
и сравните её с аналогичной, использующей async
. Они поразительно похожи, не так ли? В нашей реализации вместо async function
используется function *
, а вместо await
– ключевое слово yield
. В этом и заключается её красота!Мы уже значительно продвинулись, и если вам удалось реализовать какие-то из этих шагов самостоятельно, то могу вас только похвалить.
▍ Подсказка #4: расширение реализации для работы с несколькими инструкциями yield
Следующим шагом будет расширение реализации под работу с произвольным числом команд
yield
. Вышеприведённый фрагмент работает лишь с одной, поскольку вызывает .next()
только после разрешения первого промиса. Но генератор может создавать произвольное число промисов. Как написать абстракцию, которая будет динамически ожидать разрешения любого созданного промиса, а затем вызывать .next()
?Этой абстракцией может стать функция (например,
run
), получающая генератор. Что run
должна возвращать? Опять же, если сравнивать с альтернативной функцией, использующей async
, то эта функция неявно возвращает Promise
, который разрешается, когда она завершает выполнение. Мы можем сымитировать это поведение, возвращая Promise
от функции run
и разрешая его, только когда генератор закончил своё выполнение.Ниже показан соответствующий код. Ваша реализация может отличаться.
run(main).then(result => {
console.log(result);
});
function run(fn, ...args) {
const it = fn(...args);
return new Promise((resolve, reject) => {
// TODO: Вызывать it.next(), пока есть, что yield.
});
}
▍ Подсказка #5: вызов .next() произвольное число раз
А теперь сосредоточимся на реализации функции
run
. Она должна вызывать .next()
для объекта генератора, пока для промисов выполняется yield
. Можно ли сделать это с помощью циклов? Будет ли такой вариант работать ожидаемым образом при использовании промисов? Конечно, нет. Циклы не подойдут, поскольку тогда .next()
будет продолжать вызываться для объекта генератора, не ожидая создания и разрешения промисов. Можно ли реализовать поочерёдное выполнение как-то более удачно?Да, с помощью рекурсии! Используя рекурсию, мы сможем продолжать вызывать
.next()
для объекта генератора при каждом разрешении промиса. Каково будет условие выхода или базовый кейс для завершения рекурсии? Она должна прекращаться, когда завершается выполнение генератора. Что возвращает .next()
, когда это происходит? Свойство done
в возвращаемом объекте устанавливается на true
. function run(fn, ...args) {
const it = fn(...args);
return new Promise((resolve, reject) => {
function step() {
const result = it.next();
// Условие выхода
if (result.done) {
return;
}
result.value.then(resolvedValue => {
step();
});
}
// Вызов step() для начала рекурсии
step();
});
}
Сейчас мы не передаём
resolvedValue
из промиса обратно в генератор. Для этого нужно сделать так, чтобы функция step
получала аргумент. Также обратите внимание, что возвращаемый run
промис никогда не разрешается, потому что мы нигде не вызываем функцию resolve()
. Когда этот промис должен разрешиться? Когда генератор завершает выполнение, и выполнять больше нечего. С чем тогда должен разрешаться промис? С тем, что возвращает генератор, так как это соответствует поведению асинхронных функций. function run(fn, ...args) {
const it = fn(...args);
return new Promise((resolve, reject) => {
function step(resolvedValue) {
const result = it.next(resolvedValue);
// Условие выхода
if (result.done) {
resolve(result.value);
return;
}
result.value.then(resolvedValue => {
step(resolvedValue);
});
}
// Нет необходимости передавать что-либо для начала выполнения генератора.
step();
});
}
Вот мы и получили функциональность async/await без
async
и await
!На этом наша реализация завершается. В ней асинхронные функции представлены как генераторы, а вместо
await
для ожидания разрешения промисов мы используем инструкции yield
. Для разработчика, использующего нашу реализацию, она по-прежнему будет похожей на вариант с async/await и при этом не нарушит два обозначенных в начале статьи условия.function* main() {
console.log('Entry');
const result = yield wait();
console.log(result);
const result2 = yield wait();
console.log(result2);
// ...
console.log('Exit');
return 'Return';
}
run(main).then(result => {
console.log(result);
});
function run(fn, ...args) {
const it = fn(...args);
return new Promise((resolve, reject) => {
function step(resolvedValue) {
const result = it.next(resolvedValue);
// Условие выхода
if (result.done) {
resolve(result.value);
return;
}
result.value.then(resolvedValue => {
step(resolvedValue);
});
}
Вывод:
Entry
Timeout resolved
Timeout resolved
Exit
Return
Примечание переводчика: интерактивная песочница доступна в оригинале статьи.
Именно это делают транспиляторы вроде Babel при преобразовании async/await в более старые версии JS, где этой функциональности ещё не было. Если вы взглянете на транспилированный код, то сможете провести множество параллелей с нашей реализацией.
▍ Дальнейшие шаги
Полученная реализация охватывает только успешный путь async/await. Она не обрабатывает сценарии ошибок, когда промис отклоняется. Я хочу оставить эту доработку в качестве упражнения для вас, так как она окажется аналогична реализации успешного пути, и отличаться будут только используемые функции. Для начала взгляните на Generators API, чтобы понять, есть ли способ обратной передачи ошибок в генератор по аналогии с функцией
.next()
.▍ Заключение
Когда я впервые пришёл к этой реализации, то был поражён её красотой и простотой. Я знал, что async/await является синтаксическим сахаром, но не знал, как эта функциональность выглядит изнутри. В статье я осветил конкретно этот аспект async/await, а также его связь с промисами и генераторами. Мне нравится временами разбираться в абстракциях, чтобы лучше понять, как всё работает изнутри. Так я узнаю новые интересные концепции. Надеюсь, что моя статья стала для вас мотивирующей, подогрев любопытство и стремление изучать что-либо на практике, а не просто через чтение готовых руководств.
Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх 🕹️