Задний план

Глядя на статью, на которую отвечает этот пост, я возвращаюсь в эпоху веб-разработки, когда промисы и синтаксис async/await были несбыточной мечтой для внедрения Javascript. Таким образом, асинхронное программирование в Javascript ограничивалось парадигмой обратных вызовов и страдало от проблем с читабельностью, которые оно приносило кодовой базе.

Связанная статья подробно описывает некоторые из этих проблем, упоминая чрезмерное использование отступов и показывая трудности, возникающие при попытке написать слабосвязанные асинхронные блоки кода. Я согласен с выводом статьи о превосходстве промисов для написания асинхронного Javascript, но не согласен с предложенным методом работы с асинхронными обратными вызовами. Ниже я буду отстаивать другой способ обработки обратных вызовов без промисов, сохраняя при этом слабую связь между функциями.

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

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

Болевые точки

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

var getName = function(surnameCallback , ageCallback ){
    prompt.get('enter the name' , function(err , value){
        data = {};    
        data.name = value;
        surnameCallback(ageCallback , data);
    });
}
var getSurname = function(ageCallback , data){    
    prompt.get('enter the surname' , function(err , value ){
        data.surname = value;
        ageCallback(data);
    });
}
var getAge = function(data){    
    prompt.get('enter the date' , function(err , value){
        doSomething( data.name , data.surname , value);
    });
};
//now all the code is used by the next line: no indentation hell at //all
getName(getSurname , getAge);

Проблема с приведенным выше кодом заключается в том, что сигнатуры для каждой функции обратного вызова зависят от порядка асинхронных обратных вызовов. Например, если getName был переключен на последний вызываемый обратный вызов, а getAge — на первый, getName теперь будет принимать данные как единственный параметр, в то время как getAge будет принимать 2 параметра — фамилияCallback и nameCallback. Также потребуется инициализировать переменную data в getAge, а не в getName. Это делает изменение порядка обратных вызовов сложной задачей, поскольку изменение последовательности обратных вызовов требует изменения кода для всех задействованных функций.

Корень проблемы

В основе проблемы использования обратных вызовов Javascript для обработки асинхронного кода лежит тот факт, что Javascript был построен таким образом, что запрещал синхронный запуск асинхронных функций. Я предполагаю, что это было сделано для того, чтобы отговорить разработчиков от написания блокирующего кода на стороне браузера, поскольку парадигма браузерного программирования, управляемого событиями, требует, чтобы единственный поток Javascript всегда был доступен.

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

Возьмем следующий пример:

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

Мы также можем видеть, что определение встроенных обратных вызовов в качестве параметров асинхронных функций дает нам преимущество доступа к переменным верхнего уровня. Это связано с концепцией под названием замыкание, которая позволяет встроенным функциям получать доступ к переменным из области действия функции, в которой она определена. Когда обратные вызовы определены вне области действия вызывающей функции, некоторый тип объекта «данных» должен начать передаваться, чтобы представить среду, которая может инкапсулировать переменные в «асинхронной области». Это нужно иметь в виду, так как это повлияет на дизайн абстракции для асинхронного кода, который будет обсуждаться позже.

Найти более простой способ

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

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

Построение абстракции

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

  1. Все обратные вызовы должны принимать одни и те же стандартизированные параметры.
  2. Асинхронные функции должны иметь возможность легко переупорядочиваться (по крайней мере, для функций, которые не имеют зависимостей данных друг от друга).

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

Для начала давайте рассмотрим пример, приведенный в статье по ссылке. Приведенный здесь код обратного вызова не работает для нас, поскольку он нарушает требование (1) и имеет каждую асинхронную функцию, принимающую различное количество параметров. Обойти это сложно, поскольку как первая асинхронная функция может вызвать вторую асинхронную функцию с обратным вызовом, если у нее нет ссылки на сам обратный вызов? Если бы только был способ предварительно загрузить параметры во вторую асинхронную функцию, переданную в…

Использование привязки

Для тех, кто не знает, что такое привязка, это конструкция в Javascript, которая позволяет вам клонировать функции, но с жестко заданными параметрами. Это очень упрощенный способ объяснить, что такое привязка, и он не представляет всей полноты ее функциональности, но объяснения должно быть достаточно для нашего варианта использования. Чтобы показать вам краткий пример того, как это работает, взгляните на фрагмент кода ниже:

function animalCall(animalSound){
  console.log(animalSound);
}
let catCall = animalCall.bind(this, "meow");
catCall();
OUTPUT
>>"meow"

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

var getName = function(surnameCallback){
    prompt.get('enter the name' , function(err , value){
        data = {};    
        data.name = value;
        surnameCallback(data);
    });
}
var getSurname = function(ageCallback , data){    
    prompt.get('enter the surname' , function(err , value ){
        data.surname = value;
        ageCallback(data);
    });
}
var getAge = function(data){    
    prompt.get('enter the date' , function(err , value){
        doSomething( data.name , data.surname , value);
    });
};
//now all the code is used by the next line: no indentation hell at //all
getSurnameAndAge = getSurname.bind(this, getAge);
getName(getSurnameAndAge);

Объем и состояние

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

let asyncResult1 = asyncFunction1();
let asyncResult2 = asyncFunction2();
let asyncResult3 = asyncFunction3(asyncResult1, asyncResult2)

В приведенном выше примере asyncFunction2 полагается на asyncResult1, что нормально, поскольку они находятся в одной области видимости. При использовании обратных вызовов один результат может не охватывать все асинхронные функции, вызываемые впоследствии, и поэтому нам нужен какой-то объект состояния, чтобы отслеживать все переменные, созданные в асинхронной цепочке. Я думаю об этих асинхронных функциях, используемых как обратные вызовы, как промежуточное программное обеспечение в экспресс-конвейере. Как и в экспресс-конвейере, нам нужно передавать данные, такие как объекты запроса и ответа, которые распространяются через все функции промежуточного программного обеспечения.

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

var getName = function(data, callback){
    prompt.get('enter the name' , function(err , value){
        data.name = value;
        callback && callback(data);
    });
}
var getSurname = function(data, callback){    
    prompt.get('enter the surname' , function(err , value ){
        data.surname = value;
        callback && callback(data);
    });
}
var getAge = function(data, callback){    
    prompt.get('enter the date' , function(err , value){
        callback && callback(data);
    });
};
//now all the code is used by the next line: no indentation hell at //all
let data = {};
getAgeAndDoSomething = getAge.bind(this, data, doSomething);
getSurnameAndAgeAndDoSomething = getSurname.bind(this, data, getAgeAndDoSomething);
getName(getSurnameAndAgeAndDoSomething);

Это удовлетворяет нашему первому требованию здесь:

  1. ̶A̶l̶l̶ ̶c̶a̶l̶l̶b̶a̶c̶k̶s̶ ̶s̶h̶o̶u̶l̶d̶ ̶t̶a̶k̶e̶ ̶i̶n̶ ̶t̶h̶e̶ ̶s̶a̶m̶e̶ ̶s̶t̶a̶n̶d̶a̶r̶d̶i̶z̶e̶d̶ ̶p̶a̶r̶a̶m̶e̶t̶e̶r̶s̶
  2. Асинхронные функции должны иметь возможность легко переупорядочиваться (по крайней мере, для функций, которые не имеют зависимостей данных друг от друга).

Изменение порядка асинхронных функций

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

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

promptNoCallback = prompt;
prompt = function (promptText, callback) {
var promptResponse = promptNoCallback(promptText);
  callback(null, promptResponse);
};
var getName = function (data, callback) {
prompt("enter the name", function (err, value) {
  data.name = value;
  callback && callback(data);
  });
};
var getSurname = function (data, callback) {
  prompt("enter the surname", function (err, value) {
    data.surname = value;
    callback && callback(data);
  });
};
var getAge = function (data, callback) {
  prompt("enter the date", function (err, value) {
    data.age = value;
    callback && callback(data);
  });
};
function printData(data, callback) {
  console.log(data);
}
function consolidateFunctions(data, functionArray) {
  if (!data) {
    data = {};
  }
  if (!functionArray || !functionArray.length) {
    throw new Error("Empty function array");
  }
  for (
  var functionIndex = functionArray.length - 1;
  functionIndex >= 0;
  functionIndex--
  ) {
    functionArray[functionIndex] = functionArray[functionIndex].bind(
    this,
    data,
    functionArray[functionIndex + 1]
    );
}
return functionArray[0];
}
let data = {};
getNameAndAgeAndSurname = consolidateFunctions(data, [
  getName,
  getAge,
  getSurname,
  printData,
]);

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

  1. ̶A̶l̶l̶ ̶c̶a̶l̶l̶b̶a̶c̶k̶s̶ ̶s̶h̶o̶u̶l̶d̶ ̶t̶a̶k̶e̶ ̶i̶n̶ ̶t̶h̶e̶ ̶s̶a̶m̶e̶ ̶s̶t̶a̶n̶d̶a̶r̶d̶i̶z̶e̶d̶ ̶p̶a̶r̶a̶m̶e̶t̶e̶r̶s̶
  2. ̶A̶s̶y̶n̶c̶h̶r̶o̶n̶o̶u̶s̶ ̶f̶u̶n̶c̶t̶i̶o̶n̶s̶ ̶s̶h̶o̶u̶l̶d̶ ̶b̶e̶ ̶a̶b̶l̶e̶ ̶t̶o̶ ̶g̶e̶t̶ ̶r̶e̶-̶o̶r̶d̶e̶r̶e̶d̶ ̶e̶a̶s̶i̶l̶y̶ ̶(̶a̶t̶ ̶l̶e̶a̶s̶t̶ ̶f̶o̶r̶ ̶t̶h̶e̶ ̶f̶u̶n̶c̶t̶i̶o̶n̶s̶ ̶t̶h̶a̶t̶ ̶d̶o̶n̶’̶t̶ ̶h̶a̶v̶e̶ ̶d̶a̶t̶a̶ ̶d̶e̶p̶e̶n̶d̶e̶n̶c̶i̶e̶s̶ ̶o̶n̶ ̶e̶a̶c̶h̶ ̶o̶t̶h̶e̶r̶)̶

Дальнейшее чтение