Привет всем!
Как вы, возможно, помните, еще в октябре у нас переводилась интересная статья о применении таймеров в Javascript. Она вызвала огромную дискуссию, по результатам которой мы давно хотели вернуться к этой теме и предложить вам подробный разбор асинхронного программирования в этом языке. Рады, что нам удалось найти достойный материал и опубликовать его еще до конца года. Приятного чтения!
Асинхронное программирование в Javascript прошло многоэтапную эволюцию: от обратных вызовов к промисам и далее к генераторам, а вскоре – и к async/await
. На каждом этапе асинхронное программирование в Javascript немного упрощалось для тех, кто уже по колено протоптал себе путь в этом языке, однако для новичков становилось лишь более устрашающим, поскольку требовалось разбирать нюансы каждой парадигмы, осваивая применение каждой и, что не менее важно, понимать, как все это работает.
В этой статье мы решили кратко напомнить, как использовать обратные вызовы и промисы, дать краткое введение в генераторы, а потом помочь вам интуитивно усвоить, как именно «под капотом» устроено асинхронное программирование с применением генераторов и async/await. Надеемся, что так вы сможете уверенно применять различные парадигмы именно там, где они уместны.
Предполагается, что читатель уже пользовался обратными вызовами, промисами и генераторами для асинхронного программирования, а также вполне знаком с замыканиями и каррированием в Javascript.
Ад обратных вызовов
Вначале были обратные вызовы. В Javascript нет синхронного ввода/вывода (далее — I/O) и вообще не поддерживаются блокировки. Так что, для организации какого угодно I/O или для отсрочки любого действия избиралась такая стратегия: код, который требовалось выполнить асинхронно, передавался в функцию с отложенным выполнением, которая запускалась где-нибудь ниже в цикле событий. Один обратный вызов не так уж и плох, но код растет, а обратные вызовы обычно порождают все новые обратные вызовы. В итоге получается нечто подобное:
getUserData(function doStuff(e, a) {
getMoreUserData(function doMoreStuff(e, b) {
getEvenMoreUserData(function doEvenMoreStuff(e, c) {
getYetMoreUserData(function doYetMoreStuff(e, c) {
console.log('Welcome to callback hell!');
});
});
});
})
Не считая мурашек, пробегающих при виде такого фрактального кода, есть еще одна проблема: теперь мы делегировали управление наше логикой do*Stuff
другим функциям (get*UserData()
), к которым у вас может не быть исходного кода, и вы не можете быть уверены, а выполняют ли они ваш обратный вызов. Отлично, не правда ли?
Промисы
Промисы оборачивают вспять инверсию управления, обеспечиваемую обратными вызовами и помогают распутать клубок обратных вызовов в ровную цепочку.
Теперь предыдущий пример можно преобразовать в нечто подобное:
getUserData()
.then(getUserData)
.then(doMoreStuff)
.then(getEvenMoreUserData)
.then(doEvenMoreStuff)
.then(getYetMoreUserData)
.then(doYetMoreStuff);
Уже не так неказисто, а?
Но, позвольте!!! Давайте рассмотрим более жизненный (но все равно во многом надуманный) пример обратных вызовов:
// Допустим, у нас есть метод fetchJson(), выполняющий запросы GET и имеющий интерфейс,
// который выглядит примерно так: обратный вызов должен принимать ошибку в качестве первого аргумента, а разобранные данные отклика – в качестве
// второго.
function fetchJson(url, callback) { ... }
fetchJson('/api/user/self', function(e, user) {
fetchJson('/api/interests?userId=' + user.id, function(e, interests) {
var recommendations = [];
interests.forEach(function () {
fetchJson('/api/recommendations?topic=' + interest, function(e, recommendation) {
recommendations.push(recommendation);
if (recommendations.length == interests.length) {
render(profile, interests, recommendations);
}
});
});
});
});
Итак, мы выбираем профиль пользователя, затем его интересы, далее, исходя из его интересов, подбираем рекомендации и, наконец, собрав все рекомендации, отображаем страницу. Такой набор обратных вызовов, которым, наверное, можно гордиться, но, все-таки, какой-то он лохматый. Ничего, применим здесь промисы – и все наладится. Верно?
Давайте изменим наш метод fetchJson()
так, чтобы он возвращал промис, а не принимал обратный вызов. Промис разрешается телом отклика, разобранным в формате JSON.
fetchJson('/api/user/self')
.then(function (user) {
return fetchJson('/api/user/interests?userId=' + self.id);
})
.then(function (interests) {
return Promise.all[interests.map(i => fetchJson('/api/recommendations?topic=' + i))];
})
.then(function (recommendations) {
render(user, interests, recommendations);
});
Красиво, правда? Что же теперь не так с этим кодом?
… Упс!..
У нас нет доступа к профилю или интересам в последней функции этой цепочки? Значит, ничего не работает! Что же делать? Попробуем вложенные промисы:
fetchJson('/api/user/self')
.then(function (user) {
return fetchJson('/api/user/interests?userId=' + self.id)
.then(interests => {
user: user,
interests: interests
});
})
.then(function (blob) {
return Promise.all[blob.interests.map(i => fetchJson('/api/recommendations?topic=' + i))]
.then(recommendations => {
user: blob.user,
interests: blob.interests,
recommendations: recommendations
});
})
.then(function (bigBlob) {
render(bigBlob.user, bigBlob.interests, bigBlob.recommendations);
});
Да… теперь выглядит гораздо корявее, чем мы надеялись. Не из-за таких ли безумных матрешек мы, не в последнюю очередь, стремились вырваться из ада обратных вызовов? Что же теперь делать?
Код можно немного причесать, налегая на замыкания:
// Объявляем эти переменные, которые хотим сохранить заранее
var user, recommendations;
fetchJson('/api/user/self')
.then(function (fetchedUser) {
user = fetchedUser;
return fetchJson('/api/user/interests?userId=' + self.id);
})
.then(function (fetchedInterests) {
interests = fetchedInterests;
return Promise.all(interests.map(i => fetchJson('/api/recommendations?topic=' + i)));
})
.then(function (recomendations) {
render(user, interests, recommendations);
})
.then(function () {
console.log('We are done!');
});
Да, теперь все практически так, как мы хотели, но с одной причудой. Обратили внимание, как мы вызывали аргументы внутри обратных вызовов в промисах fetchedUser
и fetchedInterests
, а не user
и interests
? Если да – то вы весьма наблюдательны!
Изъян этого подхода таков: нужно быть очень и очень внимательным, чтобы не поименовать что-либо во внутренних функциях так же, как и переменные «из кэша», которые вы собираетесь использовать в вашем замыкании. Даже если вам хватит сноровки, чтобы избежать затенения, ссылаться на переменную так высоко в замыкании все равно кажется довольно опасным, и от этого определенно нехорошо.
Асинхронные генераторы
Генераторы помогут! Если пользоваться генераторами, то вся волнительность исчезает. Просто волшебство. Правда. Взгляните только:
co(function* () {
var user = yield fetchJson('/api/user/self');
var interests = yield fetchJson('/api/user/interests?userId=' + self.id);
var recommendations = yield Promise.all(
interests.map(i => fetchJson('/api/recommendations?topic=' + i)));
render(user, interests, recommendations);
});
Вот и все. Оно сработает. Вас не пробивает на слезу, когда вы видите, как прекрасны генераторы, не жалеете ли вы, что были столь недальновидны и стали учить Javascript еще до того, как в нем появились генераторы? Признаюсь, меня такая мысль однажды посетила.
Но… как же все это работает? В самом деле магия?
Конечно!.. Нет. Переходим к разоблачению.
Генераторы
В нашем примере кажется, что генераторы просты в использовании, но на самом деле в них много что происходит. Чтобы подробнее разобраться с асинхронными генераторами, нужно лучше понимать, как действуют генераторы и как они обеспечивают асинхронное выполнение, на вид кажущееся синхронным.
Как понятно из названия, генератор делает значения:
function* counts(start) {
yield start + 1;
yield start + 2;
yield start + 3;
return start + 4;
}
const counter = counts(0);
console.log(counter.next()); // {value: 1, done: false}
console.log(counter.next()); // {value: 2, done: false}
console.log(counter.next()); // {value: 3, done: false}
console.log(counter.next()); // {value: 4, done: true}
console.log(counter.next()); // {value: undefined, done: true}
Это довольно просто, но, все равно, давайте проговорим, что здесь происходит:
const counter = counts();
— инициализируем генератор и сохраняем его в переменной counter. Генератор находится в подвешенном состоянии, никакой код в теле генератора до сих пор не выполнен.console.log(counter.next());
— Интерпретируется выдача (yield
) 1, после чего 1 возвращается какvalue
, иdone
результирует вfalse
, так как на этом выдача не оканчиваетсяconsole.log(counter.next());
— Теперь 2!console.log(counter.next());
— Теперь 3! Закончили. Все правильно? Нет. Выполнение приостанавливается на шагеyield 3;
Для завершения нужно еще раз вызвать next().console.log(counter.next());
— Теперь 4, и оно возвращается, а не выдается, так что теперь мы выходим из функции, и все готово.console.log(counter.next());
— Генератор работу окончил! Ему нечего сообщить кроме как «все сделано».
Вот мы и разобрались, как работают генераторы! Но, подождите, а как же шокирующая правда: генераторы могут не только изрыгать значения, но и пожирать их!
function* printer() {
console.log("We are starting!");
console.log(yield);
console.log(yield);
console.log(yield);
console.log("We are done!");
}
const counter = printer();
counter.next(1); // Начинаем!
counter.next(2); // 2
counter.next(3); // 3
counter.next(4); // 4n Готово!
counter.next(5); // Ничего не выводит
Уф, что?! Генератор потребляет значения, вместо того, чтобы порождать их. Как такое возможно?
Секрет в функции next
. Она не только возвращает значения от генератора, но и может возвращать их генератору. Если сообщить next()
аргумент, то операция yield
, которую сейчас ожидает генератор, фактически результирует в аргумент. Вот почему первый counter.next(1)
зарегистрирован как undefined
. Просто еще нет выдачи, которую можно было бы разрешать.
Все равно, как если бы генератор разрешал вызывающему коду (процедуре) и коду генератора (процедуре) партнерское взаимодействие, чтобы те передавали значения друг другу по мере выполнения и дожидались друг друга. Ситуация практически такова, словно для генераторов Javascript задумывалась бы возможность реализовывать кооперативные конкурентно выполняемые процедуры, они же «корутины». На самом деле, довольно напоминает co()
, правда?
Но давайте не будем спешить, а то сами себя перехитрим. В данном случае важно, чтобы читатель интуитивно усвоил суть генераторов и асинхронного программирования, а лучший способ это сделать – самому собрать генератор. Не написать функцию генератора и не воспользоваться готовой, а самому воссоздать нутрянку функции генератора.
Внутреннее устройство генератора – генерируем генераторы
Ладно, я в самом деле не знаю, как именно выглядят внутренности генератора в разных средах выполнения JS. Но это не столь важно. Генераторы соответствуют интерфейсу. «Конструктор» для инстанцирования генератора, метод next(value? : any)
, при помощи которого мы приказываем генератору продолжать работу и давать ему значения, еще метод throw(error)
на случай, если вместо значения будет выдана ошибка, и, наконец, метод return()
, о котором пока умолчим. Если соответствие интерфейсу будет достигнуто – тогда все хорошо.
Итак, давайте попробуем собрать вышеупомянутый генератор counts()
на чистом ES5, без ключевого слова function*
. Пока можно игнорировать throw()
и передавать значение в next()
, поскольку метод не принимает никакого ввода. Как это сделать?
Но в Javascript же есть и другой механизм для приостановки и возобновления выполнения программы: замыкания! Знакомо выглядит?
function makeCounter() {
var count = 1;
return function () {
return count++;
}
}
var counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
Если ранее вы пользовались замыканиями – уверен, вам уже доводилось писать нечто подобное. Функция, возвращаемая makeCounter, может генерировать бесконечную последовательность чисел, точно как генератор.
Однако, эта функция не соответствует интерфейсу генератора, и ее нельзя напрямую применить в нашем примере с counts()
, который возвращает 4 значения и завершает работу. Что нужно для универсального подхода к написанию генератороподобных функций?
Замыкания, машины состояний и каторжный труд!
function counts(start) {
let state = 0;
let done = false;
function go() {
let result;
switch (state) {
case 0:
result = start + 1;
state = 1;
break;
case 1:
result = start + 2;
state = 2;
break;
case 2:
result = start + 3;
state = 3;
break;
case 3:
result = start + 4;
done = true;
state = -1;
break;
default:
break;
}
return {done: done, value: result};
}
return {
next: go
}
}
const counter = counts(0);
console.log(counter.next()); // {value: 1, done: false}
console.log(counter.next()); // {value: 2, done: false}
console.log(counter.next()); // {value: 3, done: false}
console.log(counter.next()); // {value: 4, done: true}
console.log(counter.next()); // {value: undefined, done: true}
Запустив этот код, вы увидите те же результаты, что и в версии с генератором. Мило, правда?
Итак, мы разобрали порождающую сторону генератора; давайте разберем потребляющую?
На самом деле, отличий не много.
function printer(start) {
let state = 0;
let done = false;
function go(input) {
let result;
switch (state) {
case 0:
console.log("We are starting!");
state = 1;
break;
case 1:
console.log(input);
state = 2;
break;
case 2:
console.log(input);
state = 3;
break;
case 3:
console.log(input);
console.log("We are done!");
done = true;
state = -1;
break;
default:
break;
return {done: done, value: result};
}
}
return {
next: go
}
}
const counter = printer();
counter.next(1); // Начинаем!
counter.next(2); // 2
counter.next(3); // 3
counter.next(4); // 4
counter.next(5); // Готово!
Всего-то и нужно, добавить input
в качестве аргумента go
, и значения выдаются по конвейеру. Опять смахивает на магию? Почти как генераторы?
Ура! Вот мы и воссоздали генератор в качестве поставщика и в качестве потребителя. Почему бы не попытаться объединить в нем эти функции? Вот еще один довольно искусственный пример генератора:
function* adder(initialValue) {
let sum = initialValue;
while (true) {
sum += yield sum;
}
}
Поскольку все мы уже спецы по генераторам, нам понятно, что этот генератор прибавляет значение, данное в next(value)
к sum
, после чего возвращает sum. Он работает точно как мы рассчитывали:
const add = adder(0);
console.log(add.next()); // 0
console.log(add.next(1)); // 1
console.log(add.next(2)); // 3
console.log(add.next(3)); // 6
Круто. Теперь давайте напишем этот интерфейс как обычную функцию!
function adder(initialValue) {
let state = 'initial';
let done = false;
let sum = initialValue;
function go(input) {
let result;
switch (state) {
case 'initial':
result = initialValue;
state = 'loop';
break;
case 'loop':
sum += input;
result = sum;
state = 'loop';
break;
default:
break;
}
return {done: done, value: result};
}
return {
next: go
}
}
function runner() {
const add = adder(0);
console.log(add.next()); // 0
console.log(add.next(1)); // 1
console.log(add.next(2)); // 3
console.log(add.next(3)); // 6
}
runner();
Ого, мы реализовали полноценную корутину.
Остается еще кое-что обсудить о работе генераторов. Как работают исключения? С исключениями, возникающими внутри генераторов, все просто: next()
сделает так, чтобы исключение проникло до вызывающей стороны, и генератор погибнет. Передача исключения генератору делается в методе throw()
, о котором мы умолчали выше.
Давайте обогатим наш слагатель крутой новой возможностью. Если вызывающая сторона передает исключение генератору, он будет возвращаться к последнему значению суммы.
function* adder(initialValue) {
let sum = initialValue;
let lastSum = initialValue;
let temp;
while (true) {
try {
temp = sum;
sum += yield sum;
lastSum = temp;
} catch (e) {
sum = lastSum;
}
}
}
const add = adder(0);
console.log(add.next()); // 0
console.log(add.next(1)); // 1
console.log(add.next(2)); // 3
console.log(add.throw(new Error('BOO)!'))); // 1
console.log(add.next(4)); // 5
Задача на программирование – проникновение ошибки генератора
Товарищ, как же нам реализовать throw()?
Запросто! Ошибка – просто еще одно значение. Мы можем передать ее в go()
как следующий аргумент. На самом деле, здесь нужна некоторая осторожность. При вызове throw(e)
оператор yield
сработает так же, как если бы мы написали throw e. Это значит, что мы должны проверять на наличие ошибок каждое состояние нашей машины состояний, и валить программу, если не сможем обработать ошибку.
Начнем с предыдущей реализации слагателя, скопировано
Бум! Мы реализовали набор корутин, способных передавать друг другу сообщения и исключения, точно как настоящий генератор.
Но ситуация усугубляется, не правда ли? Реализация машины состояний все сильнее отдаляется от реализации генератора. Мало того, что из-за обработки ошибок код обрастает мусором; код тем более усложняется из-за такого длинного цикла while
, который здесь у нас получился. Для преобразования цикла while
его нужно «расплести» в состояния. Так, наш случай 1 фактически включает 2,5 итерации цикла while
, поскольку yield
обрывается на середине. Наконец, приходится добавить лишний код для продвижения исключений от вызывающей стороны и обратно, если в генераторе не найдется блока try/catch
для обработки этого исключения.
Вы сделали это!!! Мы завершили подробный разбор возможных вариантов реализации генераторов и, надеюсь, вы уже лучше усвоили, как генераторы работают. В сухом остатке:
- Генератор может порождать значения, потреблять значения, либо и то, и другое.
- Состояние генератора можно ставить на паузу (состояние, машина состояний, улавливаете?)
- Вызывающая сторона и генератор позволяют сформировать набор корутин, взаимодействующих друг с другом
- Исключения пересылаются в любом направлении.
Теперь, когда мы лучше разбираемся в генераторах, предлагаю потенциально удобный способ рассуждения о них: это синтаксические конструкции, при помощи которых можно писать конкурентно выполняемые процедуры, передающие друг другу значения через канал, пропускающий значения по одному (инструкция yield
). Это пригодится нам в следующем разделе, где мы произведем реализацию co()
от корутин.
Инверсия управления при помощи корутин
Теперь, поднаторев в работе с генераторами, давайте подумаем, как их можно применять при асинхронном программировании. Если мы умеем писать генераторы как таковые, это еще не означает, что промисы в генераторах автоматически будут разрешаться. Но, подождите, генераторы и не предназначены работать сами по себе. Они должны взаимодействовать с другой программой, основной процедурой, той, что вызывает .next()
и .throw()
.
Что, если помещать нашу бизнес-логику не в основную процедуру, а именно в генератор? Всякий раз, когда бизнес-логике попадется некоторое асинхронное значение, скажем, промис, генератор сообщит: «не хочу возиться с этой дурью, разбудите меня, когда она разрешится», приостановится и выдаст промис обслуживающей процедуре. Обслуживающая процедура: «хорошо, попозже тебя позову». После чего она регистрирует обратный вызов с этим промисом, выходит и дожидается, пока можно будет вызвать цикл событий (то есть, когда промис разрешится). Когда это произойдет, процедура возвестит: «эй, твоя очередь», и отправит значение через .next()
спящему генератору. Будет ждать, пока генератор сделает свое дело, а сама тем временем займется другими асинхронными делами… и так далее. Вы прослушали грустную историю о том, как живется процедуре на услужении у генератора.
Так, вернемся к основной теме. Теперь, когда мы знаем, как работают генераторы и промисы, нам не составит труда создать такую «служебную процедуру». Служебная процедура сама будет конкурентно выполняться как промис, инстанцировать и обслуживать генератор, а затем возвращаться к конечному результату нашей основной процедуры при помощи обратного вызова .then()
.
Далее давайте вернемся к программе co() и подробнее ее обсудим. co()
– это служебная процедура, берущая на себя рабский труд, чтобы генератор мог работать только с синхронными значениями. Уже гораздо логичнее выглядит, правда?
co(function* () {
var user = yield fetchJson('/api/user/self');
var interests = yield fetchJson('/api/user/interests?userId=' + self.id);
var recommendations = yield Promise.all(
interests.map(i => fetchJson('/api/recommendations?topic=' + i)));
render(user, interests, recommendations);
});
Те, кто знаком с трамплинными функциями, могут представить co()
именно как асинхронную версию трамплинной функции, забрасывающей промисы.
Задача на программирование — co() простая
Отлично! Теперь давайте сами соберем co()
, чтобы интуитивно понять, как именно работает такая вспомогательная процедура. co()
должна
- Возвращать промис вызывающей стороне, которая его ждет
- Инстанцировать генератор
- Вызывать
.next()
в генераторе для получения первого выданного результата, который должен иметь вид{done: false, value: [a Promise]}
- Зарегистрировать обратный вызов с промисом
- Когда промис разрешится (будет сделан обратный вызов), вызвать
.next()
в генераторе, с разрешившимся значением и получить обратно другое значение - Повторить все, начиная с шага 4
- Если в какой-то момент генератор вернет
{done: true, value: ...}
, разрешить промис, возвращенныйco()
Пока давайте не задумываться об ошибках, напишем простой метод co(), позволяющий обработать приведенный ниже искусственный пример:
function deferred(val) {
return new Promise((resolve, reject) => resolve(val));
}
co(function* asyncAdds(initialValue) {
console.log(yield deferred(initialValue + 1));
console.log(yield deferred(initialValue + 2));
console.log(yield deferred(initialValue + 3));
});
function co(generator) {
return new Promise((resolve, reject) => {
// Ваш код
});
}
Вообще неплохо, правда? В каких-нибудь 10 строках кода мы в общих чертах воспроизвели функционал co()
, которая еще недавно казалась нам волшебной и всемогущей. Давайте посмотрим, что здесь можно добавить. Как насчет обработки исключений?
Задача на программирование – обработка исключений в co()
Когда промис, выданный генератором, отклонен, мы хотим, чтобы co()
сигнализировала процедуре генератора об исключении. Как вы помните, в интерфейсе генератора предоставляется метод .throw()
для отправки исключений.
function deferred(val) {
return new Promise((resolve, reject) => resolve(val));
}
function deferReject(e) {
return new Promise((resolve, reject) => reject(e));
}
co(function* asyncAdds() {
console.log(yield deferred(1));
try {
console.log(yield deferredError(new Error('To fail, or to not fail.')));
} catch (e) {
console.log('To not fail!');
}
console.log(yield deferred(3));
});
function co(generator) {
return new Promise((resolve, reject) => {
// Ваш код
});
}
Тут все немного усложняется. Нам понадобятся разные обратные вызовы в зависимости от того, разрешен был выданный промис или отклонен, поэтому в решении следующий вызов .next()
выносится в отдельный метод onResolve()
. Также здесь используется отдельный метод onReject()
, который при необходимости будет вызывать .throw()
. Оба этих обратных вызова обернуты в блоки try/catch
каждый, чтобы сразу же отклонять промис, если в генераторе не предусмотрен try/catch
на случай ошибок.
Итак, мы построили co()
! Почти! co()
также поддерживает трамплинные функции, вложенные генераторы, массивы из вышеперечисленного, а также глубокие объекты. Но волшебства почти не осталось, правда?
Священный грааль: async/await
Вот мы и разобрались с генераторами и с co()
. Но есть ли в них какой-нибудь прок, если в нашем распоряжении будет async/await? Ответ — ДА! Поскольку мы со всеми ними уже разобрались, нам не составит труда понять и async await
.
При помощи ключевого слова async можно объявлять функции, выполнение которых придерживается при помощи ключевого слова await
, точно как генератор можно приостановить при помощи ключевого слова yield
. await
может использоваться только с промисами и только в таких стеках выполнения функций, которые обернуты в async
. При выполнении async
-функции возвращают промисы.
Итак, чтобы наша функция использовала async/await
, а не генераторы, всего-то и нужно заменить co()
на async
и yield
на await
, а также убрать из функции *
, чтобы она перестала быть генератором.
co(function* () {
var user = yield fetchJson('/api/user/self');
var interests = yield fetchJson('/api/user/interests?userId=' + self.id);
var recommendations = yield Promise.all(
interests.map(i => fetchJson('/api/recommendations?topic=' + i)));
render(user, interests, recommendations);
});
Становится:
async function () {
var user = await fetchJson('/api/user/self');
var interests = await fetchJson('/api/user/interests?userId=' + self.id);
var recommendations = await Promise.all(
interests.map(i => fetchJson('/api/recommendations?topic=' + i)));
render(user, interests, recommendations);
}();
Однако, здесь нужно отметить пару небольших особенностей:
co()
сразу же выполняет асинхронный генератор. async создает функцию, но ее вам все равно еще нужно вызвать.async
больше напоминает вариантco()
под названиемco.wrap()
.- С
co()
можно выдавать (yield
) промисы, трамплинные функции, массивы промисов или объекты промисов. Сasync
можно только ожидать (await
) промисы.
Конец
Мы рассмотрели историю асинхронного программирования в Javascript с некоторыми сокращениями, разобрались, как «за кулисами» устроена работа генераторов и co()
, а затем, опираясь на изученный материал, освоили работу с async/await
. Гордитесь? Правильно.
Автор: ph_piter