Проблема
Некоторое время назад в работе над клиентской (javascript) частью движка josi возникла, кстати, достаточно часто встречающаяся проблема переполнения стека:
Uncaught RangeError: Maximum call stack size exceeded (google chrome)
В статье рассматривается решение без использования setTimout или setInterval.
Суть
Причина такого поведения известна и понятна, и в той или иной форме всегда вызвана следующим. Классическая(прямая) рекурсия порождает цепочку последовательных вызовов, что соответственно ведет к наполнению стека вызовов, однако, стек вызовов браузера достаточно мал, в chrome на момент тестирования это 500 вызовов, в safari, если не ошибаюсь, тоже. В любом случае- это предельное значение, а значит его можно превысить и получить exception. Естественно, столь долгое выполнение кода не желательно в принципе, и этого стоит избегать. И все же лично мне не хочеться полагаться на удачу, не смотря на то, что ситуация в которой пришлось столкнуться с проблемой на продакшен возникнуть не должна, я потратил время на изучение данного вопроса.
Решение
Классическим решением (имею ввиду подавляющее количество статей предлагающих его) является использование косвенной рекурсии посредством: setTimeout либо setInterval.
В качестве примера приведу простенькую рекурсивную функция, единственное назначение которой рано или поздно вернуть Вам предел размера стека вместе с exeption о превышении этого предела…
function f(args)
{
var self=this;
var k=args.k;
//вызываем себя же
try
{
f({k:k+1});
}
catch(ex)
{
alert(k);
}
}
та же бесполезная функция, но теперь теоретически бесконечная, разве что k переполнится
function f(args)
{
var self=this;
var k=args.k;
//косвенно вызываем себя же, через посредника setTimeout
setTimeout(function(){ f({k:k+1}) }, 0);
}
Текущая функция сразу завершается за счет использования для рекурсии посредника setTimout, а следующий вызов выполняется по событию.
Отрицательной стороной такого подхода является его крайне низкая производительность, несмотря на то, что мы указываем нулевую задержку. Вызвана функция будет в зависимости от браузера в среднем не раньше чем через 10 мс. Но ведь мы боремся с превышением стека вызовов, а значит наша функция вызывается сотни раз, что означает потерю в производительности ~1 с на каждые 100 вызовов. Детальное тестирование нашел тут.
Самое простое, что пришло в голову — организовать симбиоз из попеременного использования прямого и косвенного вызовов, чтобы при достижении некоторого значения счетчика прерывать стек косвенным вызовом. Отчасти такое решение сейчас и используется. Но здесь тоже все не так просто, особенно если рекурсия представлена петлей из нескольких функций.
Вот простенький пример отражающий суть такого решения:
var max_call_i=300;
function f(args)
{
var self=this;
var k=args.k;
var call_i=args.call_i
//alert(k);
if (call_i>=max_call_i)
{
//косвенно вызываем себя же, через посредника setTimeout
setTimeout(function(){ f({k:k+1, call_i:call_i+1}) }, 0);
}
else
{
//напрямую вызываем себя же
f({k:k+1, call_i:call_i+1});
}
}
В моем коде проблема возникла в шаблонизаторе, который как раз незадолго до этого был переписан согласно новой парадигме. Не хотелось отказываться от принятой архитектуры. В тоже время реальное падение производительности составило 20-30% — что было просто чудовищно. Предложенное выше решение тоже не идеал: сохранялось падение производительности на 5-7%. Это меня не устроило: много гуглил, и напал на то, что нужно.
А это тест от туда же, из которого видно что, предложенный подход, в сравнении с setTimeout 0, гораздо более производительный, что на практике дало не более 3% падения производительности в моем случае…
Данное решение основано на связке window.postMessage и element.addEventListener, для меня достаточно кроссбраузерно (ie8+).
Я переработал функцию из приведенной выше статьи в AMD модуль. Возможно, кому-то это будет полезным…
define([], function ()
{
return
{
args: //аргументы
{
indirect_call:
{
f_arr:[],
msg_name:"indirect_call-message",
handler_f:null,
}
},
/*** работа с событиями ***/
f_indirect_call:function(f)
{
var self=this;
//если это первый вызов то создаем обработчик события и привязываем к window
if (t_uti.f_is_empty(self.args.indirect_call.handler_f))
{
//создаем обработчик события
self.args.indirect_call.handler_f=function(event)
{
if (event.source == window && event.data == self.args.indirect_call.msg_name)
{
event.stopPropagation();
if (self.args.indirect_call.f_arr.length> 0)
{
var f = self.args.indirect_call.f_arr.shift();
f();
}
}
}
window.addEventListener("message", self.args.indirect_call.handler_f, true);
}
self.args.indirect_call.f_arr.push(f);
window.postMessage(self.args.indirect_call.msg_name, "*");
},
};
});