28 мая 2009 г.

Микропаттерны оптимизации в JavaScript: декораторы функций debouncing и throttling

Декораторы функций позволяют добавить дополнительное поведение функции, не изменяя ее. Сигнатура оригинальной и декорированной функции полностью совпадают.

Debouncing

Если дословно переводить — «устранение дребезга». Такой декоратор позволяет превратить несколько вызовов функции в течение определенного времени в один, причем задержка начинает заново отсчитываться с каждой новой попыткой вызова. Возможно два варианта:

  1. Реальный вызов происходит только в случае, если с момента последней попытки прошло время, большее или равное задержке.
  2. Реальный вызов происходит сразу, а все остальные попытки вызова игнорируются, пока не пройдет время, большее или равное задержке, отсчитанной от времени последней попытки.

Пример использования

Например, у вас есть suggest. Посылать запросы к серверу на каждый keyup расточительно и не нужно. Можно декорировать обработчик, чтобы он срабатывал только после того, как пользователь перестал нажимать на клавиши, допустим, в течение 300 миллисекунд:

function onKeyUp() { ... };

$('input[name=suggest]').keyup($.debounce(onKeyUp, 300));

Или у вас есть один обработчик на несколько событий, и нужно, чтобы если в течение некоторого времени происходит оба события, обработчик срабатывал только на последнем произошедшем событии:

$('input').bind('keyup blur', $.debounce(process, 300));

Throttling

Данный декоратор позволяет «затормозить» функцию — функция будет выполняться не чаще одного раза в указанный период, даже если она будет вызвана много раз в течение этого периода. Т.е. все промежуточные вызовы будут игнорироваться.

Пример использования

Например, на resize окна (или, допустим, на mousemove) у вас срабатывает какой-то тяжелый обработчик. Можно его «затормозить»:

$(window).resize($.throttle(doComplexСomputation, 300));

В итоге, функция будет выполняться не чаще, чем раз в 300 миллисекунд.

Мои реализации в виде jQuery-плагина можно скачать с code.google.

Ps. Навеяно книжкой Николаса Закаса «Professional JavaScript for Web Developers, 2nd Edition», хотя в ней он путает debounce и throttle, называя первое вторым. На ajaxian тоже поднималась эта тема.

6 комментариев:

Gomolyako Eduard комментирует...

Дим, в рамках своего кода и использования jQuery 1.3.2 я нашел в твоих функциях, в моем случае, существенный недостаток. Суть в том, что при биндинге обработчика события через обертку одной из твоих функций, в теле обработчика перестает быть доступной переменная this. Опишу на примере:

У меня есть обработчик события для hover:

var
onHover = function() {
    $(this).addClass('hover');
},
onLeave = function() {
    $(this).removeClass('hover');
};

Как известно при биндинге обработчиков jQuery контекстом выполнения функции является DOM элемент, породивший событие.

Биндинг:
$('.i-have-hover').hover(
    onHover, onLeave
);

Пример выше работает, но как только мы обернем один из обработчиков функциями throttle или debounce, то код перестанет работать из-за отсутствия контекста.

При это мы не можем указать контекст в обертке, т.к., естественно, не знаем его.

Что-то мне подсказывает, что следующий код не будет работать так, как нужно, т.к. на каждое событие throttle вернет новую функцию со своим персональным таймером.

$('.i-have-hover').hover(function() {
    $.throttle(onHover, 300)();
});

Соответственно выход один: исправить ситуация в коде плагина. Тут можно пойти двумя путями:

Первый, который я реализовал и мне он больше нравится, но на данный момент у меня нет времени подумать глубже, может быть он и плох, поэтому ниже есть второй :)

И так изменения к плагину: поскольку каждая функция (throttle, debounce) возвращает указатель на вновь созданную функцию, то в теле этой новой функции я добавляю переменную ctx:

var ctx = context || this;

Она заполняется либо указанным при инициализации обретки контекстом, либо контекстом, в котором вызывают нашу обертку. Далее в вызове самой функции, которую оборачивали, я указываю контекстом выполнения переменную ctx вместо context.

Вариант для throttle:

throttle: function(fn, timeout, context) {
    var timer;

    return function() {
        var args = arguments, ctx = context || this;

        if (!timer) {
            (function() {
                if (args) {
                    fn.apply(ctx, args);
                    args = null;
                    timer = setTimeout(arguments.callee, timeout);
                }
                else {
                    timer = null;
                }
            })();
        }
    };
}


Во втором варианте я предлагаю создать объект-хеш, в котором хранить таймеры для оборачиваемых функций. Но тут нужно подумать, я не рассматривал этот вариант глубоко.

Спасибо за внимание :)

alpha комментирует...

Ага, первый способ правильный, причем у себя-то я именно его тоже реализовал (причем так же :)), когда столкнулся с подобной проблемой, а на code.google не выложил.

Gomolyako Eduard комментирует...

Я на google code завел issue и даже приложил файл с фиксами :)

Gomolyako Eduard комментирует...

Дим привет, это снова я по библиотеке.

Смотри, возникла ситуация, когда плагин не работает. Я конечно пофиксил, но хочется узнать, может я неверно его использую.

Ситуация такая:

у меня есть два списка выпадающих, я хочу сделать так, чтобы выпадающее меню убиралось когда клиент уведет мышку но с debounce.

Делаю следующее:
var hideHover = $.debounce(function() {
$(this).data('hovered') && $(this).removeClass('hovered');
});

$('.drop-list').hover(function() {
$(this).data('hovered', true);
}, function() {
$(this).data('hovered', false);

hideHover.call(this);
});

Кода у меня элементов .drop-list на странице больше одного, timer внутри кода debounce работает один на все эти элементы. Ну дальше ты можешь представить что происходит.

Я сделал в коде переменную timers = {}, в которой создаю и очищаю таймеры относительно контекста функции. Т.е. timers[ctx] = setTimeout(...) или clearTimeout(timers[ctx]) или delete timers[ctx].

Хотел бы узнать твое мнение

alpha комментирует...

А как ты идентифицируешь контексты? Я про timers[ctx] - дописываешь к каждому контексту какое-то поле при первом использовании?

plutov.by комментирует...

Мой pure-JS вариант debounce функции - http://plutov.by/post/fn_delay