От обратных вызовов к обещаниям

Асинхронность лежит в основе современного JavaScript, поэтому стоит иметь четкое представление о том, что это такое и как к нему подходить.

Давайте начнем с простого примера: асинхронная функция, использующая setTimeout, которая возвращает целое число. Само по себе бессмысленно, но с небольшим воображением это может быть вызов API, взаимодействие с пользователем, событие веб-сокета: какое-то действие, которое произойдет в будущем.

const asyncTen = (cb) => 
  setTimeout(() =>  cb(10), 1000);

async 10 принимает обратный вызов cb и через одну секунду вызывает обратный вызов со значением 10. Самый простой способ использовать эту функцию — напрямую сделать что-то с результатом: например, записать в файл, в HTML или просто вывести его на консоль.

const log = value => console.log(value);
asyncTen(log);  // Logs '10' after 1000ms

Очень хорошо, но часто нам нужно объединить значение, возвращаемое асинхронным действием, с другими данными или выполнить какую-то другую дополнительную обработку, прежде чем мы его используем. Например, может быть, мы хотим удвоить результат нашей асинхронной функции, прежде чем регистрировать его. Вот самая простая реализация:

asyncTen(x => log(x * 2));  // Logs '20' after 1000ms

Этот подход отлично работает для простых процессов, но становится сложнее следовать, если у нас есть несколько процессов. Скажем, мы хотим удвоить его, минус два, а затем преобразовать в красивое сообщение:

asyncTen(x => log(`Congratulations your score is ${x * 2 - 2}`);
// Logs: 'Congratulations your score is 18'

Это, вероятно, все еще нормально, но, надеюсь, вы начинаете видеть проблему с таким подходом: каждый процесс должен быть вложен в следующий. Представьте, что x — это массив целых чисел, и нам нужно отобразить их, а затем записать в строки. Код станет более вложенным, за ним будет сложнее следить, его будет сложнее отлаживать и его будет сложнее использовать повторно. Вот почему полезно разбивать наши процессы на более мелкие повторно используемые функции, которые затем можно скомпоновать вместе:

Составление функций

const double = x => x * 2;
const minus2 = x => x - 2;
const toMessage = x => `Congratulations your score is ${x}`;
asyncTen(x => log(toMessage(minus2(double(x)))));
// Logs: 'Congratulations your score is 18'

Хотя некоторые могут возразить, что приведенный выше код менее удобочитаем, чем предыдущая реализация, этот метод имеет то преимущество, что он разбивает все на более мелкие, тестируемые и повторно используемые функции.

Одно большое улучшение, которое мы можем внести в эту первую реализацию, состоит в том, чтобы распознать простой шаблон составления двух унарных функций (то есть функций, которые принимают один аргумент), посредством чего первая функция вызывается с начальным аргументом, а результат передается непосредственно второй функции. , поэтому double и minus2 можно комбинировать как:

const doubleMinus2 = x => minus2(double(x));
doubleMinus2(20); // Returns 18

Этот шаблон можно абстрагировать в общую функцию compose, которая принимает две функции f и g и возвращает новую функцию:

const compose = (f, g) => (x) => f(g(x));
const doubleMinus2 = compose(minus2, double);
doubleMinus2(20); // Returns 18

Применив это к нашему предыдущему примеру:

const logMessage =
  compose(log, compose(toMessage, compose(minus2, double)));
asyncTen(logMessage); // Logs: 'Congratulations your score is 18'

Там намного чище! Но мы можем еще больше улучшить синтаксис, поняв, что с помощью простого редуктора мы можем заставить нашу функцию компоновки работать с более чем двумя функциями:

const simpleCompose = (f, g) => (x) => f(g(x));
const compose = (...fns) => fns.reduce(simpleCompose);
const logMessage = compose(log, toMessage, minus2, double);
asyncTen(logMessage); // Logs: 'Congratulations your score is 18'

Вот и все, асинхронное программирование решено! Все, что требуется, — это структурировать ваш код как серию преобразований, выполняемых простыми функциями, которые полагаются только на предоставленные им значения и не полагаются на какое-либо внешнее состояние и не изменяют его.

За исключением того, что это только половина истории. Цепочки простых функций — это большой шаг вперед, но у нас по-прежнему остаются две проблемы: во-первых, как поступать с ошибками и, во-вторых, как поступать с цепочкой асинхронных функций.

Составление обещаний

К счастью, в ES6 есть промисы, решающие обе эти проблемы.

Распространение ошибок

Промисы позволяют распространять ошибки по цепочке промисов, поэтому ошибки можно обрабатывать по вашему выбору.

Чтобы показать, как это работает, давайте создадим асинхронную функцию, которая может вызвать ошибку:

const flakyAsyncTen = () =>
  new Promise((resolve, reject) =>
    Math.random() > 0.5
      ? resolve(10)
      : reject(new Error('Random Error')));

С промисом мы можем предоставить обработчик успеха и ошибки, а промис будет вызывать соответствующий обработчик в зависимости от того, разрешается ли он в 10 или отклоняется с ошибкой:

const logError = (error) => console.log('Error: ', error);
flakyAsyncTen()
.then(logMessage, logError);
// 50% returns: 'Congratulations your score is 18'
// 50% returns: 'Error: Random Error'

Цепочка функций

Поскольку Promise.then сама возвращает промис с предоставленным обработчиком onFullfilled, примененным к разрешенному значению промисов, мы можем добиться аналогичного эффекта для композиции функций, но с возможностью обрабатывать ошибки отдельно:

flakyAsyncTen()
.then(double)
.then(minus2)
.then(toMessage)
.then(log, logError);
// 50% chance: 'Congratulations your score is 18'
// 50% chance: 'Error: Random Error'

Цепочка асинхронных функций

У Promise.then есть еще один партийный трюк, который можно проиллюстрировать, введя в нашу цепочку вторую асинхронную функцию, которая также может Error, например сказать, что doubleAsync была определена как:

const doubleAsync = (x) =>
  new Promise((resolve, reject) =>
    setTimeout(() => 
      Math.random() > 0.5
        ? resolve(x * 2)
        : reject(new Error('Random Doubling Error')), 1000));

С промисами мы можем просто заменить double на doubleAsync и все будет работать:

flakyAsyncTen()
.then(doubleAsync)
.then(minus2)
.then(toMessage)
.then(log, logError);
// 25% chance: 'Congratulations your score is 18'
// 50% chance: 'Error: Random Error'
// 25% chance: 'Error: Random Doubling Error'

Понимание Promise.then

Важно понимать, что Promise.then обрабатывает два разных типа функций-обработчиков onFullfilled: простые функции, т.е. double (которая принимает число и возвращает число Number -> Number) и асинхронные функции, такие как doubleAsync (которая принимает число и возвращает обещание Number -> Promise Number Error), которые могут либо разрешаться в число, либо отклоняться с ошибкой.

Ошибки по-прежнему распространяются по цепочке обещаний до тех пор, пока они не будут обработаны, а простые функции могут быть объявлены без необходимости отдельного учета любой потенциальной обработки ошибок.

Последние мысли

Надеюсь, этот пример показывает, почему промисы предоставляют такой хороший способ работы с асинхронностью в JavaScript и, как часть ES6, теперь должны быть идиоматическим подходом.

Для меня ключом к получению максимальной отдачи от промисов является переход к более функциональному подходу к программированию на JavaScript, поскольку, представляя свою программу как серию преобразований данных, выполняемых рядом простых функций, вы можете избежать многих проблем. асинхронные ловушки условий гонки, непредсказуемые мутации состояния и сложный для понимания спагетти-код.