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


Считается, что мир 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:

спрятано в спойлер

"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.

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

P.S. Чуть не забыл, в SynJS имеется оператор SynJS.goto(). А почему бы и нет?
ссылка на оригинал статьи https://habrahabr.ru/post/319094/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *