Считается, что мир 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 это костыль, потому что
- цепочки вызывают функции только в одном заданном порядке,
- если порядок может менятся в соответсвии с какой-то логикой, по-прежнему приходится дробить логику в колбеках на отдельные функции,
- для кодирования логики между функциями по-прежнему приходится что-то изобретать, вместо того, чтобы просто пользоваться стандартными операторами if, for, while и т.п.
- логика с 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(). А почему бы и нет?
Автор: amaksr