Pull to refresh

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

Reading time 8 min
Views 26K


Считается, что мир 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<friends.length; i++) {
    friend = await getUser(friends[i].id);
    var sent = await sendEmail(freind.email,"subject","body");
  }
}

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

Cпрятано в спойлер
"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<urls.length; i++) {
			var res = modules.fetch(_synjsContext, urls[i]);
			SynJS.wait(res.done);
			if(res.finalUrl.indexOf('github')>=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.

Проект на гитхабе
NPM

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

Articles