Эволюция асинхронного JavaScript
До async
-функций уже рукой подать, однако путь к ним был достаточно долгим.
Кажется, ещё недавно мы просто писали коллбеки, и вот уже одна за другой появились спецификация Promise/A+, генераторы, а теперь ещё и async
-функции.
Давайте оглянемся назад и вспомним, как на протяжении времени эволюционировал асинхронный JavaScript.
Коллбеки
Все началось с коллбеков.
Асинхронный JavaScript
Асинхронное программирование на JavaScript в его нынешнем виде возможно благодаря
функциям-объектам первого класса: их можно передавать в другие функции как обычные переменные.
Коллбеки получаются так: если в одну функцию (т.н. функцию высшего порядка) в качестве
параметра передать другую, её можно будет вызвать по завершении работы. Никаких return
,
только передача значений в коллбек.
Something.save(function(err) {
if (err) {
// обработка ошибок
return;
}
console.log('успех');
});
Это — так называемые error-first коллбеки, они пронизывают Node.js: их использует как ядро Node, так и большая часть модулей из NPM.
Сложности с коллбеками:
- пользуясь ими неправильно, легко оказаться в аду коллбеков или получить спагетти-код;
- легко пропустить обработку ошибок;
- невозможно вернуть значение с помощью
return
, равно как и использоватьthrow
, чтобы cгенерировать ошибку.
В основном, по этим причинам JavaScript-коммьюнити начало поиск решений, способных сделать асинхронную разработку проще.
Одним из таких решений был модуль async. Если вы много работали с коллбеками, вы знаете как сложно организовывать параллельное и последовательное выполнение асинхронного кода или преобразование массивов с его помощью. Для таких случаев Каоланом Макмахоном был написан модуль async.
Например, с async вы легко можете преобразовывать массивы при помощи асинхронных функций:
async.map([1, 2, 3], AsyncSquaringLibrary.square,
function(err, result){
// результатом будет [1, 4, 9]
});
Тем не менее, читать и писать такой код все ещё непросто — и тут на помощь приходят промисы.
Промисы
Нынешняя спецификация промисов в JavaScript датируется 2012 годом и доступна также в стандарте ES6 — тем не менее, промисы не были изобретением JavaScript-сообщества. Сам термин был введен Дэниэлом Фридманом ещё в 1976 году.
Промис представляет собой ожидаемый результат асинхронной операции.
С промисами предыдущий пример выглядел бы так:
Something.save()
.then(function() {
console.log('успех');
})
.catch(function() {
// обработка ошибок
})
Как вы могли заметить, промисы тоже используют коллбеки.
Как then
, так и catch
регистрируют коллбеки, которые будут вызваны при успешном
завершении операции или сбое, при котором она не может быть выполнена. Ещё одно
замечательное свойство промисов — то, что их можно вызывать по цепочке:
saveSomething()
.then(updateOtherthing)
.then(deleteStuff)
.then(logResults);
В средах, пока ещё не поддерживающих промисы нативно, можно использовать полифилы. Популярный выбор в таких случаях — bluebird. Библиотеки могут предоставлять гораздо большую функциональность, чем нативные промисы, но даже в таких случаях ограничьтесь возможностями, предоставленными спецификацией Promises/A+.
Почему не стоит использовать синтаксический сахар? Почитайте «Промисы: проблема расширения», а если хотите узнать о промисах больше, то обратитесь к спецификации.
Вы спросите: как пользоваться промисами, если большинство библиотек предоставляют только интерфейс коллбеков?
Ну, это очень просто: единственное, что надо сделать — это обернуть коллбек, который вызывает исходная функция, в промис:
function saveToTheDb(value) {
return new Promise(function(resolve, reject) {
db.values.insert(value, function(err, user) { // помните, сначала ошибка ;)
if (err) {
return reject(err); // не забудьте return
}
resolve(user);
})
}
}
Некоторые библиотеки и фреймворки уже поддерживают и то и другое, предоставляя интерфейс коллбеков и промисов одновременно. На данный момент, если пишете библиотеку, хорошей практикой будет поддерживать оба варианта. Вы легко можете сделать это следующим образом:
function foo(cb) {
if (cb) {
return cb();
}
return new Promise(function (resolve, reject) {
});
}
Или даже проще, вы можете начать с интерфейса, построенного исключительно на промисах, и предоставить обратную совместимость при помощи такого инструмента, как callbackify. Callbackify в целом, делает то же, что и вышеприведенный код, только в более общем виде.
Генераторы / yield
Генераторы — относительно новая концепция, впервые они появились в ES6 (также известном как ES2015).
Было бы неплохо, выполняя функцию, в любой момент поставить ее на паузу, вычислить что-то ещё, сделать что-то другое, и потом вернуться к ней, даже с каким-то значением и продолжить?
Это именно то, что делают функции-генераторы. Когда вы вызываете генератор, он не начинает выполняться, мы должны будем итерировать по нему вручную:
function* foo () {
var index = 0;
while (index < 2) {
yield index++;
}
}
var bar = foo();
console.log(bar.next()); // { value: 0, done: false }
console.log(bar.next()); // { value: 1, done: false }
console.log(bar.next()); // { value: undefined, done: true }
Если хотите использовать генераторы для написания асинхронного JavaScript, вам также понадобится co.
Co — это библиотека управления потоком выполнения для Node.js и браузера, основанная на генераторах, использующая промисы и позволяющая писать неблокирующий код в удобном виде.
С co
наш предыдущий пример становится таким:
co(function* (){
yield Something.save();
}).then(function() {
// успех
}).catch(function(err) {
// обработка ошибок
});
Вы спросите: а как насчет параллельного выполнения операций? Ответ проще,
чем вы могли бы подумать (под капотом это всего лишь Promise.all
):
yield [Something.save(), Otherthing.save()];
Async / await
Асинхронные функции (async functions) появились в ES7 и сейчас доступны
только в транспайлерах, таких как babel. (прим.: сейчас мы говорим о
ключевом слове async
, а не о библиотеке)
Вкратце, при помощи ключевого слова async
мы можем делать то же, что и
с комбинацией co
и генераторов, только без хаков.
Под капотом async
-функции используют промисы — вот почему асинхронные функции
возвращают промис
.
Так что, если мы хотим сделать то же, что и в предыдущих примерах, мы могли бы привести сниппет к следующему виду:
async function save(Something) {
try {
await Something.save()
} catch (ex) {
// обработка ошибок
}
console.log('успех');
}
Как видите, чтобы использовать асинхронную функцию, вы должны написать ключевое
слово async
перед объявлением функции. После этого вы можете использовать ключевое
слово await
внутри свежесозданной асинхронной функции.
Запуск параллельного кода с async
-функциями довольно близок к подходу с yield
кроме того, что Promise.all
вызывается явно:
async function save(Something) {
await Promise.all[Something.save(), Otherthing.save()]
}
Koa уже поддерживает async
-функции, так что вы можете попробовать их уже
сегодня при помощи babel
.
import koa from koa;
let app = koa();
app.experimental = true;
app.use(async function (){
this.body = await Promise.resolve('Привет, читатель!')
})
app.listen(3000);
Материалы для дальнейшего изучения
Сейчас в продакшене большинства наших новых проектов, мы используем Koa и Hapi с генераторами.
Какой из вариантов вы предпочитаете? Почему? Я был бы рад услышать комментарии!