Как стать автором
Обновить

Асинхронный JavaScript: без колбеков и промисов

Время на прочтение 7 мин
Количество просмотров 42K
Наверное, каждый, кто использовал JavaScript, когда-либо сталкивался (или столкнётся в будущем) с асинхронными вызовами. Может быть, это будет обращение к базе на стороне сервера. Может быть — работа с таймером для создания анимации на сайте.

Для того, чтобы «побороть» асинхронность, используются разные инструменты от промисов до смены языка программирования. Но иногда очень хочется бросить всё и написать на чистом JS линейный код:

timeout(1000);
console.log('Hello, world!');


Можно ли реализовать нечто подобное? Разумеется, можно.
В данной статье мы рассмотрим один опасный, но действенный способ.

Варианты действий


Если Вы недовольны колбеками, можно найти несколько путей развития:
  • Смириться и продолжать использовать колбеки,
  • Перейти на абсолютно другой язык,
  • Перейти на язык, компилируемый в JavaScript,
  • Использовать возможности языка.

Разумеется, первый и второй варианты мы рассматривать не будем, оставив претворение их в жизнь на совести читателя. Третий вариант более интересен: мы как бы и не пишем на JS, но вопреки всему, несмотря на все наши наивные ожидания, на выходе получается код на нём. Это наводит на мысль: «А давайте я расширю JS, добавлю туда оператор async и назову AJS?»
Реализация подобного решения приводит к добавлению излишней сущности — компилятора нового языка. Автору при этом придётся хорошо разрекламировать свой Новый Инновационный Продукт и заставить общественность установить ещё один компилятор, а также ввести в процесс разработки ещё одно явное преобразование кода. Если автор не представляет интересы крупных компаний и не является признанным авторитетом своего времени, сделать ничего не удастся.

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

Выбор формы записи


В первом приближении можно записывать асинхронный код с помощью строковых литералов, обрабатывать его и вызывать eval. Правда, это по громоздкости не сильно отличается от стопки колбеков. Немного подумав, можно использовать комментарии внутри функций. Метод toString, применённый к функции, возвращает нам её исходный код в виде строки. Реализацией многострочных строк внутри комментариев уже никого не удивишь. В зависимости от желания автора добавлением пары строк кода удаляются или не удаляются пробелы в начале или переносы строк. С помощью этой технологии можно, например, реализовать многострочные регулярные выражения с комментариями или интерпретатор какого-нибудь языка вроде Brainfuck или самого JavaScript, стоит только добавить ещё пару строк.
Многострочные регулярные выражения
function createRegExp(func){
  if(typeof func !== 'function') throw new TypeError(func + ' is not a function');
 
  var m = func.toString().
    replace(/\s+|--.*?((?=\*\/)|$)/gm, ''). // удаление всего внешнего по отношению к /* */, удаление комментариев после --
    match(/^.*?\/\*\/((?:\\\/|.)*?)\/(?:([img]+?)\/?)?\*\/\}$/); // разбор регулярных выражений
 
  if(!m) throw new TypeError('Invalid RegExp format');
  return new RegExp(m[1], m[2] || undefined);
}


И для примера — разбор регулярного выражения для разбора регулярных выражений в комментариях многострочного регулярного выражения:

var re = createRegExp(function(){/*
  /
    ^.*?\/\*            -- какие-то символы и открытие комментария в начале строки
    \/                  -- символ "/" - начало регулярного выражения
    ( (?: \\\/ | . )*? )-- сохраняем наименьшую последовательность строки "\/" или любых символов,
    \/                  -- идущих до символа "/"
    (?:([img]+?)\/?)?   -- если есть последовательность из букв i, m, g, вероятно, заканчивающаяся на "/",
                        -- сохраняем её
    \*\/\}$             -- конец регулярного выражения сопровождается концом комментария и
                        -- исходный код функции завершается
  /
*/});


Заметим, что последовательность символов "--" всё ещё можно использовать в регулярном выражении, разделив дефисы пробелом.


Подобные ухищрения помогают при работе со строками, но полностью убивают подсветку синтаксиса, а она для исходного кода на ЯВУ крайне важна. Следовательно, код надо использовать не закомментированный, а рабочий. То есть тот, который бы хотя бы может быть разобран и представлен в виде AST, иначе получим ошибку интерпретатора.

Конструкции, которые мы можем использовать

Для реализации своего диалекта, оставаясь при этом в рамках языка, мы можем использовать:

  • Директивы в комментариях: var a = getAsync(b); //! async
  • Специальные имена переменных: var _async_a = getAsync(b);
  • Специальные имена функций: var a = getAsync(async(b));
  • Редко используемые конструкции: var a = +++getAsync(b);


Всё это позволит нам, получив исходный код функции, найти места, где используется «заветная» конструкция и заменить их на использование промисов/вложенных функций/отправку жалобы на незаконное использование асинхронных вызовов.
Каждая конструкция имеет свои плюсы и минусы. Например, можно получить предупреждение или ошибку о неопределённой переменной, предупреждения от статических анализаторов и т.п. Но, в конце концов, что использовать, разработчик решает сам.

Реализация


Для примера выберем вариант, изображённый на картинке.



В исходную функцию добавив аргумент __cb — функцию, в которую перейдёт управление после завершения последней асинхронной операции. Асинхронные вызовы будем обозначать стрелкой (<-), указывающей на то, что в переменные слева от неё неплохо было бы положить результат выполнения функции справа. Все стрелки заменим на генерацию вызова вложенного колбека; весь последующий код будет «упакован» в него. Каждый возврат из функции заменим на возврат с вызовом колбека __cb.

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

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

function async(func){

  // разбиваем код на "function..." (prefix) и тело (code)
  var parsed = func.toString()
    .match(/(function.*?\{)([\s\S]*)\}.*/);
  var prefix = parsed[1], code = parsed[2];
  
  // в lines храним неизменённые строки кода и заменённые строки, использовавщие "<-"
  // в nends храним уровень вложенности колбеков - количество последовательностей
  // закрывающихся скобок плюс один
  // по умолчанию функция имеет одну закрывающуюся скобку
  var lines = ['(' + prefix], nends = 2;
  
  // для кажлой строки... (для простоты разделяем по \n)
  code.split('\n').forEach(function(line){
  
    // ... проверяем, есть ли в ней "<-",
    // если нет - сохраняем строку как есть
    if(!/<-/.test(line))
      return void lines.push(line, '\n');
      
    // если есть - берём список имён слева от стрелки в качестве аргументов колбека,
    // формируем код вызова анонимной функции и добавляем в lines
    var parsed = /([\w\d_$\s,]+)<-(.+)\)/.exec(line);
    lines.push(parsed[2], ', function(', parsed[1], '){\n');
    
    ++nends; // не забываем про увеличение уровня вложенности
    
  });
  
  // Соединяем список строк и восстанавливаем уровень вложенности
  return lines.join('') + Array(nends).join('\n});');
}


Выглядит и записывается довольно просто для создаваемой функциональности!

Желающие могут посмотреть более подробный вариант, описывающий заявленное преобразование.
function async(func){
  if(typeof func != 'function')
    throw new TypeError('First argument of "async" must be a function.');
  
  // удаляем комментарии, выделяем префикс "function...", аргументы и тело
  var parsed = func.toString()
    .replace(/\/\*[\s\S]*?\*\/|\/\/.*$/mg, '')
    .match(/(function.*?)\((.*?)\)\s*\{([\s\S]*)\}.*/);
  var prefix = parsed[1], args = parsed[2], code = parsed[3];
  
  // если аргументы есть, добавляется запятая перед аргументом __cb
  if(!/^\s*$/.test(args)) args += ',';
  
  // имеем список строк и список "})"
  // если расширять функционал до поддержки работы с исключениями, ends понадобится
  // именно как список
  var lines = ['(', prefix, '(', args, '__cb', '){'], ends = ['\n})'];
  
  code.split('\n').forEach(function(line){
  
    // каждый выход из функции сопровождаем вызовом колбека
    line = line.replace(/return\s*(.*?);/, 'return void __cb($1);');
    
    // проверяем, встречается ли "<-" ровно один раз
    if(!/<-/.test(line)) return void lines.push(line, '\n');
    if(/<-.*?<-/.test(line)) throw new Error('"<-" is found more than 1 times in "'+line+'".');
    
    // заменяем стрелку на код вызова колбека
    var parsed = /([\w\d_$\s,]+)<-(.+)\((.*)\)/.exec(line);
    if(!parsed) throw new Error('"<-" is used incorrectly in "' + line + '".');
    lines.push(parsed[2], '(');
    if(parsed[3]) lines.push(parsed[3], ', ');
    lines.push('function(', parsed[1], '){\n');
    ends.push('\n});');
  });
  
  // склеиваем собранные строки и "})"
  return lines.concat(ends.reverse()).join('');
}



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


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



Наивное решение выглядит как:
function getAvatarData(eMail, callback){
  db1.getID(eMail, function(id){
    db2.getData(id, function(user){
      fs.exists(user.avatarPath, function(exists){
        if(!exists) return void callback(null);
        fs.readFile(user.avatarPath, callback);
      });
    });
  });
}


А с помощью стрелки можно сделать его более «плоским»:
function getAvatarData_src (eMail) {
  id <- db1.getID(eMail);
  user <- db2.getData(id);
  exists <- fs.exists(user.avatarPath);
  if (!exists) return null;
  data <- fs.readFile(user.avatarPath);
  return data;
}
var getAvatarData = eval(async(getAvatarData_src));


Результаты


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

Возможно, придётся отказаться от минимизации кода, поскольку конструкция «a < — f()» может быть оптимизирована или преобразована в иную форму. Также придётся следить за контекстом. Как заметил внимательный читатель, описанные выше функции возвращают строку, а не готовую функцию. Подобное поведение выбрано из-за возможного разделения async и использующего её кода на разные файлы — в этом случае eval не «захватит» нужный лексический контекст функции; eval нужно вызывать в том же файле, что и пользовательский код.

Также представленная реализация работает некорректно с исключениями, но их обработку можно легко реализовать. И конечно же, она конфликтует с серьёзными начальниками и über-энтерпрайз-проектами.

Посему, повторяйте подобное только у себя дома!
Теги:
Хабы:
+44
Комментарии 32
Комментарии Комментарии 32

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн