Time-based selective event management: immediate first, debounce next

Say there are random sequences of external actions (for example, scroll events). I need to immediately process the first action, and then undo all the actions that occurred at intervals shorter than any given delta, and then process the next, which should be delayed for this delta. Further actions should be handled in the same way.

This is like a combination of debounce-immediate and simple debounce. I prepared a chart to demonstrate this idea.

enter image description here

What is the best solution / approach here? I wonder if there is a ready-made template ...

UPDATE

I would like to thank all the participants! For research, I created a plunker with fourfive different implementations proposed in the answers: https://plnkr.co/N9nAwQ .

const handler = [
  processEvent, // normal
  debounceNext(processEvent, DELAY), // dhilt
  makeRateLimitedEventHandler(DELAY, processEvent), // user650881
  debounceWithDelay(processEvent, DELAY, 0), // willem-dhaeseleer
  _.debounce(processEvent, DELAY, {leading: true}) // lodash debounce + leading,
  debounceish(DELAY, processEvent) //Mikk3lRo
];

The great news was that Lodash had a deb deb-debug implementation that satisfies this problem (thanks to Willem D'Aseler). And here is a great demonstration from Mikk3lRo's answer, it also provided some useful synthesis.

: form only ... , okey. , ultima . ES6 ( Plunker), . ( , , , ). timestamp ! postDelay , ( lodash).

lodash ( lodash debounce ), debounceish by Mikk3lRo.

PS ( , ) ( 200, , 100). ... Nevermind.

+4
5

JS :

function debounceish(delta, fn) {
    var timer = null;
    return function(e) {
        if (timer === null) {
            //Do now
            fn(e);
            //Set timer that does nothing (but is not null until it done!)
            timer = setTimeout(function(){
                timer = null;
            }, delta);
        } else {
            //Clear existing timer
            clearTimeout(timer);
            //Set a new one that actually does something
            timer = setTimeout(function(){
                fn(e);
                //Set timer that does nothing again
                timer = setTimeout(function(){
                    timer = null;
                }, delta);
            }, delta);
        }
    };
}

function markEvt(e) {
    var elm = document.createElement('div');
    elm.style.cssText = 'position:absolute;background:tomato;border-radius:3px;width:6px;height:6px;margin:-3px;';
    elm.style.top = e.clientY + 'px';
    elm.style.left = e.clientX + 'px';
    document.body.appendChild(elm);
}

document.addEventListener('click', debounceish(2000, markEvt));
<p>Click somewhere (2000ms delta) !</p>
Hide result

6 :

var methods = {
    default: function(delay, fn) {
        return fn;
    },
    dhilt_debounceNext: (delay, cb) => { 
      let timer = null;
      let next = null;

      const runTimer = (delay, event) => {
        timer = setTimeout(() => {
          timer = null;
          if(next) {
            next(event);
            next = null;
            runTimer(delay);
          }
        }, delay);
      };  

      return (event) => {
        if(!timer) {
          cb(event);
        }
        else {
          next = cb;
          clearTimeout(timer);
        }
        runTimer(delay, event);
      }
    },
    
    Mikk3lRo_debounceish(delta, fn) {
        var timer = null;
        return function(e) {
            if (timer === null) {
                //Do now
                fn(e);
                //Set timer that does nothing (but is not null until it done!)
                timer = setTimeout(function(){
                    timer = null;
                }, delta);
            } else {
                //Clear existing timer
                clearTimeout(timer);
                //Set a new one that actually does something
                timer = setTimeout(function(){
                    fn(e);
                    //Set timer that does nothing again
                    timer = setTimeout(function(){
                        timer = null;
                    }, delta);
                }, delta);
            }
        };
    },
    
    user650881_makeRateLimitedEventHandler: function(delta_ms, processEvent) {
        var timeoutId = 0;  // valid timeoutId are positive.
        var lastEventTimestamp = 0;

        var handler = function (evt) {
            // Any untriggered handler will be discarded.
            if (timeoutId) {
                clearTimeout(timeoutId);
                timeoutId = 0;
            }
            var curTime = Date.now();
            if (curTime < lastEventTimestamp + delta_ms) {
                // within delta of last event, postpone handling
                timeoutId = setTimeout(function () {
                    processEvent(evt);
                }, delta_ms);
            } else {
                // long enough since last event, handle now
                processEvent(evt);
            }

            // Set lastEventTimestamp to time of last event after delta test.
            lastEventTimestamp = Date.now();
        };
        return handler;
    },
    
    Willem_DHaeseleer_debounceWithDelay: (delay, func) => {
        let postDebounceWait;
        let timeOutLeading = false;
        const debounced = _.debounce((...args) => {
            // wrap the handler so we can add an additional timeout to the debounce invocation
            if (timeOutLeading) {
                /*
                 for the first invocation we do not want an additional timeout.
                 We can know this is the leading invocation because,
                 we set timeOutLeading immediately to false after invoking the debounced function.
                 This only works because the debounced leading functionality is synchronous it self.
                 ( aka it does not use a trampoline )
                 */
                func(...args);
            } else {
                postDebounceWait = setTimeout(() => {
                    func(...args)
                }, delay);
            }
        }, delay, {leading: true});
        return (...args) => {
            // wrap the debounced method it self so we can cancel the post delay timer that was invoked by debounced on each invocation.
            timeOutLeading = true;
            clearTimeout(postDebounceWait);
            debounced(...args);
            timeOutLeading = false;
        }
    },
    
    Willem_DHaeseleer_lodashWithLeading: (delta, cb) => {
        return _.debounce(cb, delta * 2, {leading: true});
    },
    
    Javier_Rey_selfCancelerEventListener: function (delta, fn) {
        return function(ev) {
            var time = new Date().getTime();
            if (ev.target.time && time - ev.target.time < delta) {return;}
            ev.target.time = time;
            fn(ev);
        };
    },
};

var method_count = 0;
var colors = ['grey', 'tomato', 'green', 'blue', 'red', 'orange', 'yellow', 'black'];
function markEvt(method) {
    var style = 'position:absolute;border-radius:3px;width:6px;height:6px;margin:-3px;';
    style += 'background:' + colors[method_count] + ';';
    if (method_count > 0) {
      style += 'transform:rotate(' + Math.floor(360 * method_count / (Object.keys(methods).length - 1)) + 'deg) translateY(-8px);';
    }
    var elm = document.createElement('div');
    elm.innerHTML = '<span style="width:.8em;height:.8em;border-radius:.4em;display:inline-block;background:' + colors[method_count] + '"></span> ' + method;
    document.body.appendChild(elm);
    
    method_count++;
    return function(e) {
        elm = document.createElement('div');
        elm.style.cssText = style;
        elm.style.top = e.clientY + 'px';
        elm.style.left = e.clientX + 'px';
        document.body.appendChild(elm);
    };
}

for (var method in methods) {
    document.addEventListener('click', methods[method](2000, markEvt(method)));
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>
Hide result

, , . , , , OP .

, . Dhilt, user650881 . ' ( ), , , . , , , .

, lodash Willem D'Haeseleer, , - lodash. - ;)

+2

.

function makeRateLimitedEventHandler(delta_ms, processEvent) {
    var timeoutId = 0;  // valid timeoutId are positive.
    var lastEventTimestamp = 0;

    var handler = function (evt) {
        // Any untriggered handler will be discarded.
        if (timeoutId) {
            clearTimeout(timeoutId);
            timeoutId = 0;
        }
        var curTime = Date.now();
        if (curTime < lastEventTimestamp + delta_ms) {
            // within delta of last event, postpone handling
            timeoutId = setTimeout(function () {
                processEvent(evt);
            }, delta_ms);
        } else {
            // long enough since last event, handle now
            processEvent(evt);
        }

        // Set lastEventTimestamp to time of last event after delta test.
        lastEventTimestamp = Date.now();
    };
    return handler;
}

var DELTA_MS = 5000;
var processEvent = function (evt) { console.log('handling event'); };
el.addEventHandler('some-event', makeRateLimitedEventHandler(DELTA_MS, processEvent));
+1

debudcing lodash , , .
, .

_.debounce(cb, delta * 2, {leading: true});

https://lodash.com/docs/4.17.4#debounce

If you want the last delay to be longer, you can solve this by wrapping both debounced methods and a handler. This way you can set the timeout in the handler and cancel it in the debounce shell.
You need to check if the current call was the lead, so as not to add a timeout in this case.

It might look like this:

const _ = require('lodash');
const bb = require('bluebird');

function handler(arg) {
    console.log(arg, new Date().getSeconds());
}

const debounceWithDelay = (func, delay, postDelay) => {
    let postDebounceWait;
    let timeOutLeading = false;
    const debounced = _.debounce((...args) => {
        // wrap the handler so we can add an additional timeout to the debounce invocation
        if (timeOutLeading) {
            /*
             for the first invocation we do not want an additional timeout.
             We can know this is the leading invocation because,
             we set timeOutLeading immediately to false after invoking the debounced function.
             This only works because the debounced leading functionality is synchronous it self.
             ( aka it does not use a trampoline )
             */
            func(...args);
        } else {
            postDebounceWait = setTimeout(() => {
                func(...args)
            }, postDelay);
        }
    }, delay, {leading: true});
    return (...args) => {
        // wrap the debounced method it self so we can cancel the post delay timer that was invoked by debounced on each invocation.
        timeOutLeading = true;
        clearTimeout(postDebounceWait);
        debounced(...args);
        timeOutLeading = false;
    }
};

const debounceDelay = debounceWithDelay(handler, 50, 2000);

(async function () {
    console.log(new Date().getSeconds());
    debounceDelay(1);
    debounceDelay(2);
    debounceDelay(3);
    debounceDelay(4);
    await bb.delay(3000);
    debounceDelay(5);
    await bb.delay(3000);
    debounceDelay(6);
    debounceDelay(7);
    debounceDelay(8);
})();

Runnable script:

Edit 40zq8y59p9

+1
source

Something here that I think works the way you described. If not, then at least something to leave.

// set up the event bus

const start = getMilli()
const bus = createBus()
bus.on('event', e => console.log(`[${getPassage(start)}] [${e}] original bus: saw event`))

const wrappedBus = wrapBus(1600, 'event', bus)
wrappedBus.on('event', e => console.log(`[${getPassage(start)}] [${e}] wrapped bus: saw event`))
wrappedBus.on('skipped', e => console.log(`[${getPassage(start)}] [${e}] skipped by wrapped bus`))
wrappedBus.on('last before interval', e => console.log(`[${getPassage(start)}] [${e}] this was the last event before the end of the interval`))
wrappedBus.on('interval tick', _ => console.log(`[${getPassage(start)}] interval tick`))

// trigger events on the bus every so often

let totalTime = 0
const intervalTime = 300
setInterval(() => {
  totalTime += intervalTime
  bus.trigger('event', totalTime)
}, intervalTime)

function getMilli() {
  return (new Date()).getTime()
}

function getPassage(from) {
  return getMilli() - from
}

// creates a simple event bus
function createBus() {
  const cbs = {}
  
  return {
    on: (label, cb) => {
      if(cbs.hasOwnProperty(label)) cbs[label].push(cb)
      else cbs[label] = [cb]
    },
    
    trigger: (label, e) => {
      if(cbs.hasOwnProperty(label)) cbs[label].forEach(f => f(e))
    },
  }
}

// creates a new bus that should trigger the way you described
function wrapBus(waitInterval, eventLabel, bus) {
  const newBus = createBus()
  
  let deliveredFirst = false
  let gotIgnoredEvent = false
  let lastIgnoredEvent = undefined

  setInterval(() => {
    // just here so we know when this interval timer is ticking
    newBus.trigger('interval tick', null)

    // push the last event before the end of this interval
    if(gotIgnoredEvent) {
      gotIgnoredEvent = false
      deliveredFirst = false
      newBus.trigger(eventLabel, lastIgnoredEvent)
      newBus.trigger('last before interval', lastIgnoredEvent)
    }
  }, waitInterval)
  
  bus.on(eventLabel, function(e) {
    if(!deliveredFirst) {
      newBus.trigger(eventLabel, e)
      deliveredFirst = true
      gotIgnoredEvent = false
    }
    else {
      gotIgnoredEvent = true
      lastIgnoredEvent = e
      // this is here just to see when the wrapped bus skipped events
      newBus.trigger('skipped', e)
    }
  })
  
  return newBus
}
Run codeHide result
0
source

Here is my attempt:

const debounceNext = (cb, delay) => { 
  let timer = null;
  let next = null;

  const runTimer = (delay, event) => {
    timer = setTimeout(() => {
      timer = null;
      if(next) {
        next(event);
        next = null;
        runTimer(delay);
      }
    }, delay);
  };  

  return (event) => {
    if(!timer) {
      cb(event);
    }
    else {
      next = cb;
      clearTimeout(timer);
    }
    runTimer(delay, event);
  }
};

const processEvent = (event) => console.log(event);
const debouncedHandler = debounceNext(processEvent, 125);
myElement.addEventListener('scroll', debouncedHandler);
0
source

Source: https://habr.com/ru/post/1687119/


All Articles