В этой статье я расскажу о результате своей второй попытки борьбы с колбеками в JavaScript. Первая попытка была описана в предыдущей статье. В комментариях к ней мне подсказали некоторые идеи, которые были реализованы в новом проекте — nsynjs (next synjs).
TLDR: nsynjs — это JavaScript-движок, который умеет дожидаться исполнения колбеков и исполнять инструкции последовательно.
Это достигается тем, что nsynjs разбивает код исполняемой функции на отдельные операторы и выражения, оборачивает их во внутренние функции, и исполняет их по одной.
Nsynjs позволяет писать полностью последовательный код, наподобие такого:
var i=0;
while(i<5) {
wait(1000); // <<-- долгоживущая функция с колбеком внутри
console.log(i, new Date());
i++;
}
или такого
function getStats(userId) {
return { // <<-- выражение, содержащее несколько функций с колбеками
friends: dbQuery("select * from firends where user_id = "+userId).data,
comments: dbQuery("select * from comments where user_id = "+userId).data,
likes: dbQuery("select * from likes where user_id = "+userId).data,
};
}
Nsynjs поддерживает большинство конструкций ECMAScript 2015, включая циклы, условные операторы, исключения, блоки try-catch, замыкания (правильнее было бы перевести как «контекстные переменные»), и т.п.
По-сравнению с Babel он:
- все ещё легче (81кб без минимизации),
- не имеет зависимостей,
- не требует компиляции,
- исполняется значительно быстрее,
- позволяет запускать и останавливать долгоживущие потоки.
Для иллюстрации разберем небольшой пример веб-приложения, которое:
- Получает список файлов через ajax-запрос
- Для каждого файла из списка:
- Получает файл через ajax-запрос
- Пишет содержимое файла на страницу
- Ждет 1 сек
Синхронный псевдокод для этого приложения выглядел бы так (забегая вперёд, реальный код почти такой же):
var data = ajaxGetJson("data/index.json");
for(var i in data) {
var el = ajaxGetJson("data/"+data[i]);
progressDiv.append("<div>"+el+"</div>");
wait(1000);
};
Первое, о чем следует позаботиться, это идентифицировать все асинхронные функции, которые нам понадобятся, и обернуть их в функции-обёртки, чтобы в дальнейшем вызывать их из синхронного кода.
Функция-обёртка обычно должна сделать следующее:
- принять указатель на состояние вызывающего потока в качестве параметра (например ctx)
- вызвать обёртываемую функцию с колбеком
- вернуть объект в качестве параметра оператора return, результат колбека присвоить какому-либо свойству этого объекта
- в колбеке вызвать ctx.resume() (если колбеков несколько, то выбрать самый последний)
- установить функцию-деструктор, которая будет вызвана в случае прерывания потока.
Для всех функций-обёрток свойство 'synjsHasCallback' должно быть установлено в true.
Создадим простейшую обёртку для setTimeout. Так как мы не получаем никакие данные из этой функции, то оператор return здесь не нужен. В итоге получится такой код:
var wait = function (ctx, ms) {
setTimeout(function () {
console.log('firing timeout');
ctx.resume(); // <<-- продолжить исполнение вызывающего потока
}, ms);
};
wait.synjsHasCallback = true; // <<-- указывает движку nsynjs, что эта функция-обёртка с колбеком внутри
Она, в принципе, будет работать. Но проблема может возникнуть в случае, если в процессе ожидания колбека вызвающий поток был остановлен: колбек функцией setTimeout будет все равно вызван, и сообщение напечатано. Чтобы избежать этого нужно при остановке потока отменить также и таймаут. Это можно сделать установив деструктор.
Обёртка тогда получится такой:
var wait = function (ctx, ms) {
var timeoutId = setTimeout(function () {
console.log('firing timeout');
ctx.resume();
}, ms);
ctx.setDestructor(function () {
console.log('clear timeout');
clearTimeout(timeoutId);
});
};
wait.synjsHasCallback = true;
Также нам понадобится обёртка над функцией getJSON библиотеки jQuery. В простейшем случае она будет иметь такой вид:
var ajaxGetJson = function (ctx,url) {
var res = {};
$.getJSON(url, function (data) {
res.data = data;
ctx.resume();
});
return res;
};
ajaxGetJson.synjsHasCallback = true;
Этот код будет работать только если getJSON успешно получила данные. При ошибке ctx.resume() вызван не будет, и вызывающий поток никогда не возобновится. Чтобы обработать ошибки, код необходимо модифицировать код так:
var ajaxGetJson = function (ctx,url) {
var res = {};
var ex;
$.getJSON(url, function (data) {
res.data = data; // <<-- в случае успеха, сохранить данные
})
.fail(function(e) {
ex = e; // <<-- в случае ошибки, сохранить её
})
.always(function() {
ctx.resume(ex); // <<-- продолжить вызывающий поток в любом случае,
// вызвать в нём исключение если была ошибка
});
return res;
};
ajaxGetJson.synjsHasCallback = true;
Чтобы getJSON принудительно останавливался в случае остановки вызывающего потока, можно добавить деструктор:
var ajaxGetJson = function (ctx,url) {
var res = {};
var ex;
var ajax = $.getJSON(url, function (data) {
res.data = data; // <<-- в случае успеха, сохранить данные
})
.fail(function(e) {
ex = e; // <<-- в случае ошибки, сохранить её
})
.always(function() {
ctx.resume(ex); // <<-- продолжить вызывающий поток в любом случае,
// вызвать в нём исключение если была ошибка
});
ctx.setDestructor(function () {
ajax.abort();
});
return res;
};
Когда обёртки готовы, мы можем написать саму логику приложения:
function process() {
var log = $('#log');
log.append("<div>Started...</div>");
// внутри синхронного кода нам доступна переменная synjsCtx, в которой
// содержится указатель на контекст текущего потока
var data = ajaxGetJson(synjsCtx, "data/index.json").data;
log.append("<div>Length: "+data.length+"</div>");
for(var i in data) {
log.append("<div>"+i+", "+data[i]+"</div>");
var el = ajaxGetJson(synjsCtx, "data/"+data[i]);
log.append("<div>"+el.data.descr+","+"</div>");
wait(synjsCtx,1000);
}
log.append('Done');
}
Так как функция ajaxGetJson может в некоторых случая выбрасывать исключение, то имеет смысл заключить ее в блок try-catch:
function process() {
var log = $('#log');
log.append("<div>Started...</div>");
var data = ajaxGetJson(synjsCtx, "data/index.json").data;
log.append("<div>Length: "+data.length+"</div>");
for(var i in data) {
log.append("<div>"+i+", "+data[i]+"</div>");
try {
var el = ajaxGetJson(synjsCtx, "data/"+data[i]);
log.append("<div>"+el.data.descr+","+"</div>");
}
catch (ex) {
log.append("<div>Error: "+ex.statusText+"</div>");
}
wait(synjsCtx,1000);
}
log.append('Done');
}
Последний шаг — это вызов нашей синхронной функции через движок nsynjs:
nsynjs.run(process,{},function () {
console.log('process() done.');
});
nsynjs.run принимает следующие параметры:
var ctx = nsynjs.run(myFunct,obj, param1, param2 [, param3 etc], callback)
- myFunct: указатель на функцию, которую требуется выполнить в синхронном режиме
- obj: объект, который будет доступен через this в функции myFunct
- param1, param2, etc – параметры для myFunct
- callback: колбек, который будет вызван при завершении myFunct.
Возвращаемое значение:
Контекст состояния потока.
Под капотом
При вызове какой-либо функции через nsynjs, движок проверят наличие и, при необходимости, создает свойство synjsBin у этой функции. В этом свойстве хранится древовидная структура, эквивалентная откомпилированному коду функции. Далее движок создает контекст состояния потока, в котором сохраняются локальные переменные, стеки, программные счетчики и прочая информация, необходимая для остановки/возобновления исполнения. После этого запускается основной цикл, в котором программный счетчик последовательно перебирает элементы synjsBin, и исполняет их, используя контекст состояния в качестве хранилища.
При исполнении синхронного кода, в котором содержатся вызовы других функций, nsynjs распознает три типа вызываемых функций:
- синхронные
- обёртки над колбеками
- нативные.
Тип функции определяется в рантайме путем анализа следующих свойств:
- если указатель на функцию имеет свойство synjsBin, то функция будет исполнена через nsynjs в синхронном режиме
- если указатель на функцию имеет свойство synjsHasCallback, то это функция-обёртка, поэтому nsynjs остановит на ней выполнение. Функция-обёртка должна сама в позаботиться о возобновлении вызывающего синхронного потока путем вызова ctx.resume() в колбаке.
- Все остальные функции считаются нативными, и возвращающими результат немедленно.
Производительность
При парсинге nsynjs-движок пытается анализировать и оптимизировать элементы кода исходной функции. Например, рассмотрим цикл:
for(i=0; i<arr.length; i++) {
res += arr[i];
}
Этот цикл будет оптимизирован и скомпилирован в одну внутреннюю функцию, которая будет выполняться почти также быстро, как и нативный код:
this.execute = function(state) {
for(state.localVars.i=0; state.localVars.i<arr.length; state.localVars.i++) {
state.localVars.res += state.localVars.arr[state.localVars.i];
}
}
Однако, если в элементе кода встречаются вызовы функций, а также операторы continue, break и return, то оптимизация для них, а также для всех родительских элементов не выполнится.
Невозможность оптимизации выражений с вызовами функций обусловлена тем, что указатель на функцию, а следовательно её тип, может быть вычислен только во время исполнения.
Например оператор
var n = Math.E
будет оптимизирован в одну функцию:
this.execute = function(state,prev, v) {
return state.localVars.n = Math.E
}
Если же в операторе имеется вызов функции, то nsynjs не может знать тип вызываемой функции заранее:
var n = Math.random()
Поэтому весь оператор будет выполнен по-шагам:
this.execute = function(state) {
return Math
}
..
this.execute = function(state,prev) {
return prev.random
}
..
this.execute = function(state,prev) {
return prev()
}
..
this.execute = function(state,prev, v) {
return state.localVars.n = v
}
Ссылки
Репозиторий на GitHub: github.com/amaksr/nsynjs
Примеры: github.com/amaksr/nsynjs/tree/master/examples
Тесты: github.com/amaksr/nsynjs/tree/master/test
NPM: www.npmjs.com/package/nsynjs
Автор: amaksr