Еще один велосипед для борьбы с callback hell в JavaScript

aeed54935bc74d30b61fd9b072102164.jpg
Считается, что мир JavaScript бурно развивается: регулярно выходят новые стандарты языка, появляются новые синтаксические фишки, а разработчики моментально все это адаптируют и переписывают свои фреймворки, библиотеки и прочие проекты с тем, чтобы все это использовалось. Сейчас, например, если вы всё ещё пишете в коде var, а не const или let, то это уже вроде как моветон. А уж если функция описана не через стрелочный синтаксис, то вообще позор…

Однако, все эти const-ы, let-ы, class-ы и большинство других нововведений не более чем косметика, которая хоть и делает код красивее, но действительно острых проблем не решает.

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

Чем хороши callbacks?


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

Чем плохи callbacks?


Первое, с чем обычно сталкивается новичок, это тот факт, что с ростом сложности код быстро превращается в малопонятные многократно вложенные блоки — «callback hell»:
	fetch("list_of_urls”, function(array_of_urls){
		for(var i=0;  array_of_urls.length; i++) {
			fetch(array_of_urls[i], function(profile){
				fetch(profile.imageUrl, function(image){
					...
				});
			});
		}
	});

Во-вторых, если функции с колбеками соединены друг с другом логикой, то эту логику приходится дробить и выносить в отдельные именованные функции или модули. Например, код выше выполнит цикл «for» и запустит множество fetch (array_of_urls[i]… мгновенно, и если array_of_urls слишком большой, то движок JavaScript зависнет и/или упадет с ошибкой.

С этим можно бороться путем переписывания цикла «for» в рекурсивную функцию с колбеком, но рекурсия может переполнить стек и также уронить движок. Кроме того, рекурсивные программы труднее для понимания.

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

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

    firstMethod ().then (secondMethod).then (thirdMethod);

    На мой взгляд Promises это костыль, потому что

    1. цепочки вызывают функции только в одном заданном порядке,
    2. если порядок может менятся в соответсвии с какой-то логикой, по-прежнему приходится дробить логику в колбеках на отдельные функции,
    3. для кодирования логики между функциями по-прежнему приходится что-то изобретать, вместо того, чтобы просто пользоваться стандартными операторами if, for, while и т.п.
    4. логика с Promises выглядит малопонятно.

  • async (библиотека) — позволяет объявить массив функций с колбеками, и исполнять их одну за другой, или одновременно. Недостатки те же, что и у Promises.
  • async/await — новая возможность в JavaScript, основанная на generators, позволяет останавливать и возобновлять исполнение функции.

Будущее, судя по всему, за async/await, но пока это будущее не наступило, и многие движки эту возможность не поддерживают.

Чтобы иметь возможность исполнять код с async/await на актуальных на данный момент движках JavaScript 2015, были созданы транспиляторы — преобразователи кода из нового JavaScript в старый. Самый известный из них, Babel, позволяет конвертировать код Javascript 2017 с async/await в JavaScript 2015 и запускать его на практически всех используемых в данный момент движках.

Выглядит это примерно так:

Исходный код на JavaScript 2017:

async function notifyUserFriends(user_id) {
  var friends = await getUserFriends(user_id);

  for(var i=0; i

Конвертированный код на JavaScript 2015:

спрятано в спойлер
"use strict";

var notifyUserFriends = function () {
  var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee(user_id) {
    var friends, i, sent;
    return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _context.next = 2;
            return getUserFriends(user_id);

          case 2:
            friends = _context.sent;
            i = 0;

          case 4:
            if (!(i < friends.length)) {
              _context.next = 14;
              break;
            }

            _context.next = 7;
            return getUser(friends[i].id);

          case 7:
            friend = _context.sent;
            _context.next = 10;
            return sendEmail(freind.email, "subject", "body");

          case 10:
            sent = _context.sent;

          case 11:
            i++;
            _context.next = 4;
            break;

          case 14:
          case "end":
            return _context.stop();
        }
      }
    }, _callee, this);
  }));

  return function notifyUserFriends(_x) {
    return _ref.apply(this, arguments);
  };
}();

function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; }


Чтобы иметь возможность отлаживать такой код, необходимо настроить и задействовать многое из того, что перечислено в этой статье.

Всё это само по себе требует нетривиальных усилий. Кроме того, Babel тянет за собой около 100 кб минифицированного кода «babel-polyfill», а сконвертированный код работает медленно (на что косвенно намекают многочисленные конструкции case номер_строки в сгенерированном коде).

Посмотрев на все это, я решил написать свой велосипед — SynJS. Он позволяет писать и синхронно исполнять код с колбеками:

function myTestFunction1(paramA,paramB) {
    var res, i = 0;
    while (i < 5) {
        setTimeout(function () {
            res = 'i=' + i;
            SynJS.resume(_synjsContext); // < –- функция для сигнализации, что колбек закончен
        }, 1000);
        SynJS.wait(); // < – оператор, останавливающий исполнение
        console.log(res, new Date());
        i++;
    }
    return "myTestFunction1 finished";
}

Исполнить функцию можно следующим образом:
SynJS.run(myTestFunction1,null, function (ret) {
    console.log('done all:', ret);
});

Результат будет такой:
i=0 Wed Dec 21 2016 11:45:33 GMT-0700 (Mountain Standard Time)
i=1 Wed Dec 21 2016 11:45:34 GMT-0700 (Mountain Standard Time)
i=2 Wed Dec 21 2016 11:45:35 GMT-0700 (Mountain Standard Time)
i=3 Wed Dec 21 2016 11:45:36 GMT-0700 (Mountain Standard Time)
i=4 Wed Dec 21 2016 11:45:37 GMT-0700 (Mountain Standard Time)

По-сравнению с Babel он
  • легче (35 кб без минимизации),
  • не имеет зависимостей,
  • не требует компиляции,
  • исполняется примерно в 40 раз быстрее (хотя это может быть не так критично при работе с медленными функциями).

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

Функция может быть выполнена через SynJS следующим образом:

SynJS.run(funcPtr,obj, param1, param2 [, more params],callback)

Параметры:
 — funcPtr: указатель на функцию, которую надо выполнит синхронно
 — obj: объект, который будет доступен в функции через this
 — param1, param2: параметры
 — callback: функция, которая будет выполнена по завершении

Чтобы можно было дожидаться завершения колбека в SynJS существует оператор SynJS.wait (), который позволяет остановить исполнение функции, запущенной через SynJS.run (). Оператор может принимать 3 формы:
 — SynJS.wait () — останавливает исполнение пока не будет вызван SynJS.resume ()
 — SynJS.wait (number_of_milliseconds) — приостанавливает исполнение на время number_of_milliseconds
 — SynJS.wait (some_non_numeric_expr) — проверяет (! some_non_numeric_expr), и останавливает исполнение в случае false.

С помощью SynJS.wait можно ожидать завершения одного или нескольких колбеков:

        var cb1, cb2;
        setTimeout(function () {
            cb1 = true;
            SynJS.resume(_synjsContext);
        }, 1000);
        setTimeout(function () {
            cb2 = true;
            SynJS.resume(_synjsContext);
        }, 2000);
        SynJS.wait(cb1 && cb2);

Чтобы дать сигнал о завершении колбека в основной поток используется функция

SynJS.resume (context)

Обязательный параметр context содержит ссылку на контекст исполнения, который необходимо уведомить (так как каждый вызов SynJS.run создает и запускает отдельный контекст, в системе может существовать одновременно несколько запущенных контекстов).
При парсинге SynJS оборачивает каждый оператор оборачивается в функцию следующим образом:

function(_synjsContext) {
	... код оператора ...
}

Таким образом можно использовать параметр _synjsContext в коде колбека для сигнализации о завершении:
SynJS.resume(_synjsContext);

Обработка локальных переменных.


При парсинге тела функции SynJS определяет декларации локальных переменных по ключевому слову var, и создаёт для них хеш в контексте исполнения. При обёртывании в функцию код оператора модифицируется, и все ссылки на локальные переменные заменяются ссылками на хеш в контексте исполнения.
Например, если исходный оператор в теле функции выглядел так:
	var i, res;
	...
    setTimeout(function() {
        res = 'i='+i;
        SynJS.resume(_synjsContext);
    },1000);

то оператор, обернутый в функцию будет выглядеть так:
function(_synjsContext) {
    setTimeout(function() {
         _synjsContext.localVars.res = 'i='+_synjsContext.localVars.i;
         SynJS.resume(_synjsContext);
    },1000);
}

Несколько примеров использования SynJS
1. Выбрать из БД массив родительских записей, для каждой из них получить список детей

2. По списку URL-ов, получать их один за другим, пока содержимое URL-а не будет удовлетворять условию

код
	var SynJS = require('synjs');
	var fetchUrl = require('fetch').fetchUrl;
	
	function fetch(context,url) {
		console.log('fetching started:', url);
		var result = {};
		fetchUrl(url, function(error, meta, body){
			result.done = true;
			result.body = body;
			result.finalUrl = meta.finalUrl; 
			console.log('fetching finished:', url);
		    SynJS.resume(context);
		} );
		
		return result;
	}

	function myFetches(modules, urls) {
		for(var i=0; i=0) {
				console.log('found correct one!', urls[i]);
				break;
			}
		}
	};
	
	var modules = {
			SynJS: 	SynJS,
			fetch:	fetch,
	};
	
	const urls = [
	              'http://www.google.com', 
	              'http://www.yahoo.com', 
	              'http://www.github.com', // This is the valid one
	              'http://www.wikipedia.com'
	          ];
	
	SynJS.run(myFetches,null,modules,urls,function () {
	    console.log('done');
	});

3. В базе данных, обойти всех детей, внуков и т.д. некоторого родителя

код
	global.SynJS = global.SynJS || require('synjs');
	var mysql      = require('mysql');
	var connection = mysql.createConnection({
	  host     : 'localhost',
	  user     : 'tracker',
	  password : 'tracker123',
	  database : 'tracker'
	});

	function mysqlQueryWrapper(modules,context,query, params){
		var res={};
		modules.connection.query(query,params,function(err, rows, fields){
			if(err) throw err;
			res.rows = rows;
			res.done = true;
			SynJS.resume(context);
		})
		return res;
	}
	
	function getChildsWrapper(modules, context, doc_id, children) {
		var res={};
		SynJS.run(modules.getChilds,null,modules,doc_id, children, function (ret) {
			res.result = ret;
			res.done = true;
		    SynJS.resume(context);
		});
		return res;
	}
	
	function getChilds(modules, doc_id, children) {
		var ret={};
		console.log('processing getChilds:',doc_id,SynJS.states);
		var docRec = modules.mysqlQueryWrapper(modules,_synjsContext,"select * from docs where id=?",[doc_id]);
		SynJS.wait(docRec.done);
		ret.curr = docRec.rows[0];
		
		ret.childs = [];
		var docLinks = modules.mysqlQueryWrapper(modules,_synjsContext,"select * from doc_links where doc_id=?",[doc_id]);
		SynJS.wait(docLinks.done);

		for(var i=0; docLinks.rows && i < docLinks.rows.length; i++) {
			var currDocId = docLinks.rows[i].child_id;
			if(currDocId) {
				console.log('synjs run getChilds start');
				var child = modules.getChildsWrapper(modules,_synjsContext,currDocId,children);
				SynJS.wait(child.done);
				children[child.result.curr.name] = child.result.curr.name;
			}
		}
		return ret;
	};
	
	
	var modules = {
			SynJS: 	SynJS,
			mysqlQueryWrapper: mysqlQueryWrapper,
			connection: connection,
			getChilds: getChilds,
			getChildsWrapper: getChildsWrapper,
	};
	
	var children={};
	SynJS.run(getChilds,null,modules,12,children,function (ret) {
	    connection.end();
	    console.log('done',children);
	});

На данный момент я использую SynJS для написания браузерных тестов, в которых требуется имитировать сложные пользовательские сценарии (кликнуть «New», заполнить форму, кликнуть «Save», подождать, проверить через API что записалось, и т. п.) — SynJS позволяет сократить код, и самое главное, повысить его понятность.

Надеюсь, кому-то он тоже окажется полезен до тех пор, пока не наступило светлое будущее с async/await.

Проект на гитхабе: github.com/amaksr/SynJS
NPM: www.npmjs.com/package/synjs

P.S. Чуть не забыл, в SynJS имеется оператор SynJS.goto (). А почему бы и нет?

Комментарии (12)

  • 8 января 2017 в 09:03

    +1

    Судя по ридми на github в библиотеке отсутствует обработка ошибок


    Following operators are not yet supported:
    • const
    • let
    • for… of
    • try… catch
      , а значит вся её ценность равна нулю.

    Тот же костыльPromise отлично с этим справляется.


    Кстати, так вами любимый async/await тоже использует Promise


    When async function is called, it returns a promise. When the async function returns a value, the promise will be resolved with the returned value. When the async function throws an exception or some value, the promise will be rejected with the thrown value.

    Async function can contain await expression, that pauses the execution of the async function and waits for the passed promise’s resolution, and resumes the async function’s execution and returns the resolved value.

    подробнее можно например тут посмотреть https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

    • 8 января 2017 в 09:20

      0

      try…catch недоступно только в теле самой вызываемой функции, которая запускается через SynJS.run (myTestFunction1 в статье). В любых других функциях, в том числе вызываемых из myTestFunction1 try…catch доступен.
      Кстати, так вами любимый async/await тоже использует Promise
      да, но async/await стал возможен не благодаря Promises, а благодаря генераторам, которые нативно позволяют останавливать/возобновлять выполнение контекста в движке. Чтобы получить Promises достаточно подключить небольшой полифил, а вот чтобы получить остановку/возобновление контекста исполнения в ES2015 в Babel фактически пришлось создать State machine, парсить и эмулировать исполнение операторов функции примерно так же, как это делает SynJS.
    • 8 января 2017 в 09:20 (комментарий был изменён)

      0

      плюс похоже будет проблема с замкнутыми переменными вне SynJS.run

      • 8 января 2017 в 09:40

        0

        Функция, вызываемая через SynJS.run, ничего не будет знать о своем окружении, так как она не вызывается JS-движком напрямую:
        var i=123;
        function myTestFunction1() {
            console.log(i); <--- i будет undefined
        }
        SynJS.run(myTestFunction1,obj, function () {
            console.log('done all');
        });
        

        К ней надо относится так, как если бы она была определена где-то в другом модуле, и передавать необходимые переменные через параметры, obj (this внутри функции), или global.
  • 8 января 2017 в 09:19 (комментарий был изменён)

    0

    del
  • 8 января 2017 в 09:33 (комментарий был изменён)

    0

    SynJS.run(myFetches,null,modules,urls,function () {
            console.log('done');
        });

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


    SynJS.run(myFetches,null,modules,urls,function () {
            SynJS.run(myAfterFetches,null,modules,??result?? /*где бы его получить*/,function () {
            console.log('done');
        });
    
        });
    

    или есть техника как избежать SynjsHell, простите за каламбур

    • 8 января 2017 в 09:54

      0

      Если в myFetches есть return, то его результат будет параметром колбека:
      function myFetches(modules, urls) {
          ...
          return 123;
      }
      SynJS.run(myFetches,null,modules,urls,function (res) {
              console.log(res); <-- напечатает 123
          });
      

      Можно вызывать вложенные SynJS.run, в этой части все как в обычном JavaScript. Ограничения касаются, в основном, функции, которая исполняется через SynJS.run,
      В 3-м примере показано как SynJS.run вызывается рекурсивно чтобы обойти дерево.
      • 8 января 2017 в 10:57 (комментарий был изменён)

        0

        В 3-м примере показано как SynJS.run вызывается рекурсивно чтобы обойти дерево.

        я имею ввиду после того как мы получили дерево и хотим в с ним что-то сделать. Например отфильтровать узлы.


        Можно вызывать вложенные SynJS.run, в этой части все как в обычном JavaScript

        ну то есть от callback hell мы никуда не ушли?


        SynJS.run(myFetches,null,modules,urls,function (res) {
                // обработка ошибки 1?
                SynJS.run(filterTree, null,modules,res,function (res) {
                    // обработка ошибки 2?
                   SynJS.run(doSomethingWithFilteredTree, null,modules,res,function (res) {
                       // обработка ошибки 3?
                       console.log(res); 
                   });
                });
            });
        

        @amaksr так?

        • 8 января 2017 в 11:24

          0

          ну то есть от callback hell мы никуда не ушли?

          Мы ушли от callback-hell только внутри функции, вызываемой через SynJS.run. Все остальные функции подчиняются тем же законам JavaScript, что и раньше. Точно так же в случае async/await мы должны объявить функцию через async, если мы собираемся в ней ждать коллбеки (ну и плюс еще сделать оболочки с Promises для функций с колбеками, которые мы собираемся вызывать).

          Вообще этот момент мне более всего непонятен: почему нельзя было ввести в JavaScript оператор, который бы приостанавливал исполнение контекста без блокировки других контекстов, лет так 10 назад? Тогда никто и не знал бы сейчас про callback hell. Почему только недавно такая возможность появилась, но и то в виде генераторов? Выглядит так, что кто-то сильно ошибся с дизайном когда-то давно, поэтому мы сейчас и имеем все эти костыли.

          • 8 января 2017 в 12:12

            0

            В NodeJS эта возможность появилась 6 лет назад. А не стандартизировали это потому, что дизайном языка занимаются не грамотные архитекторы, а толпа леммингов.

  • 8 января 2017 в 12:15

    0

    Не хотите ли добавить SynJS в эту коллекцию асинхронных паттернов? https://github.com/nin-jin/async-js

  • 8 января 2017 в 12:49

    +3

    Не пойму, почему немного не потерпеть пока async/await пойдет в масссы, а пока пересидеть на babel?

© Habrahabr.ru