Эволюция асинхронного JavaScript

До async-функций уже рукой подать, однако путь к ним был достаточно долгим. Кажется, ещё недавно мы просто писали коллбеки, и вот уже одна за другой появились спецификация Promise/A+, генераторы, а теперь ещё и async-функции.

Давайте оглянемся назад и вспомним, как на протяжении времени эволюционировал асинхронный JavaScript.

Коллбеки

Все началось с коллбеков.

Асинхронный JavaScript

Асинхронное программирование на JavaScript в его нынешнем виде возможно благодаря функциям-объектам первого класса: их можно передавать в другие функции как обычные переменные. Коллбеки получаются так: если в одну функцию (т.н. функцию высшего порядка) в качестве параметра передать другую, её можно будет вызвать по завершении работы. Никаких return, только передача значений в коллбек.

Something.save(function(err) {
  if (err) {
    // обработка ошибок
    return;
  }
  console.log('успех');
});

Это — так называемые error-first коллбеки, они пронизывают Node.js: их использует как ядро Node, так и большая часть модулей из NPM.

Сложности с коллбеками:

В основном, по этим причинам 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 с генераторами.

Какой из вариантов вы предпочитаете? Почему? Я был бы рад услышать комментарии!