Underscore: Suggestion: _.debounce and _.throttle take extra parameter for how to combine arguments

Created on 22 Sep 2011  ·  11Comments  ·  Source: jashkenas/underscore

If I use _.debounce() to make a debounced function and then call it 3 times in succession with 3 different sets of arguments, then (as of v1.1.7) the wrapped payload function will finally be called with the arguments specified by the 3rd call - that is the first and second arguments are discarded.

While this is often valid (and what key debouncing typically does, hence a reasonable default) I find myself wanting to use debounce to accumulate arguments, for example I have an AJAX call that can get multiple keys at once, so I use debounce to buffer up keys for a second and then issue a combined request.

My suggestion is therefore that debounce takes an optional 3rd "combine" argument that will be called with 2 args,

  • the 1st being the list of args accumulated so far (possibly undefined - ie no list on first call)
  • the 2nd being the list of args for the latest call (possibly an empty list)
    and returns the new list of accumulated args. When the payload function is called, the accumulated list of args is cleared.

If no value is passed for the combine parameter, the default combine preserves existing behaviour
function(acc, newargs) { return newargs; }
but you can also decide to use the first set of arguments
function(acc, newargs) { return acc || newargs; }
or what I want to do which is simply append all the arguments
function(acc,newargs) { return (acc || []).concat(newargs); }
and of course others may want to do something fancier

This would require the following change to the internal limit function

  // Internal function used to implement `_.throttle` and `_.debounce`.
  var limit = function(func, wait, debounce, combine) {
    var timeout, allargs;
    return function() {
      var context = this;
      allargs = combine(allargs,  slice.call(arguments,0))
      var throttler = function() {
        timeout = null;
        var args = allargs;
        allargs = undefined;
        func.apply(context, args);
      };
      if (debounce) clearTimeout(timeout);
      if (debounce || !timeout) timeout = setTimeout(throttler, wait);
    };
  };

and then a change to debounce to accept and pass thru the new argument with default value if not specified.

  _.debounce = function(func, wait, combine) {
    return limit(func, wait, true, combine || function(acc,newargs) { return newargs; });
  };

The corresponding throttle function currently uses the first set of arguments alone (throttle effectively ignores calls happening within wait milliseconds of a first call and uses the first call set of args, debounce effectively ignores all but the last call in a sequence occurring within the wait period of each other), so I'd suggest the below to again preserve the current default behaviour

  _.throttle = function(func, wait, combine) {
    return limit(func, wait, false, combine || function(acc,newargs) { return acc || newargs; });
  };

This would seem the easiest and most general way to achieve this functionality without excessive wrappers to maintain the argument lists, but I'd be interested to know if there's an easy way to achieve this without changing underscore.

enhancement wontfix

Most helpful comment

OK, not to keep banging on, but in case anyone's looking at this sometime later and wondering how to do the same, I figured this was about the cleanest way without modifying debounce itself (I add it to the _ object, others may prefer not to)

_.mixin({
  debounceReduce: function(func, wait, combine) {
    var allargs,
        context,
        wrapper = _.debounce(function() {
            var args = allargs;
            allargs = undefined;
            func.apply(context, args);
        }, wait);
        return function() {
            context = this;
            allargs = combine.apply(context, [allargs,  Array.prototype.slice.call(arguments,0)]);
            wrapper();
        };
    }
})

This gives a debounced function that has its arguments reduced by the combine function so, for example,

  delayLog = _.debounceReduce(function() { console.log(arguments); }, 5000, 
                              function(acc,args) { return (acc || []).concat(args); });
  delayLog(3,4);
  delayLog(7,8,9);

will a few second later call console.log with the array [3,4,7,8,9]

All 11 comments

Commenting on my own suggestion, the call to combine() should specify the same context as the payload function, hence

allargs = combine.apply(this, [allargs, slice.call(arguments,0)])

in case the arguments need to access the context object....

Should now be fixed on master. throttle should exhibit the correct behavior where it always uses the latest copy of your arguments, fires once immediately, and every N seconds thereafter ... and resets itself N seconds after the last trailing trigger has occurred.

I think your close comment applies to another issue (probably #170), as the issue raised by this request still applies on master.
There's still no easy way to have debounce or throttle accumulate arguments from the calls that are being combined, and I still think it's a useful optional addition which leaves the default behaviour unchanged when the optional combining argument is not specified.

Ah, you're right. Accumulating arguments is outside of the scope of Underscore -- feel free to stash your accumulated data in a good place external to the _.throttle and _.debounce functions.

That's a pity, I consider debounce to be a sort of fold-left (reduce) over multiple calls with timeout hence the accumulator... but it's your call :)

OK, not to keep banging on, but in case anyone's looking at this sometime later and wondering how to do the same, I figured this was about the cleanest way without modifying debounce itself (I add it to the _ object, others may prefer not to)

_.mixin({
  debounceReduce: function(func, wait, combine) {
    var allargs,
        context,
        wrapper = _.debounce(function() {
            var args = allargs;
            allargs = undefined;
            func.apply(context, args);
        }, wait);
        return function() {
            context = this;
            allargs = combine.apply(context, [allargs,  Array.prototype.slice.call(arguments,0)]);
            wrapper();
        };
    }
})

This gives a debounced function that has its arguments reduced by the combine function so, for example,

  delayLog = _.debounceReduce(function() { console.log(arguments); }, 5000, 
                              function(acc,args) { return (acc || []).concat(args); });
  delayLog(3,4);
  delayLog(7,8,9);

will a few second later call console.log with the array [3,4,7,8,9]

@schmerg — this looks tremendously useful. Would you be willing to license that code under the MIT license? (A "yes" will suffice!)

@markjaquith Sure thing - yes. Glad to...

If anyone comes along and wants updated/commented modern js version of the above:

_.mixin({
  debounceReduce(func, wait, combine) {
    let allArgs; // accumulator for args across calls

    // normally-debounced fn that we will call later with the accumulated args
    const wrapper = _.debounce(() => func(allArgs), wait);

    // what we actually return is this function which will really just add the new args to
    // allArgs using the combine fn
    return (...args) => {
      allArgs = combine(allArgs,  [...args]);
      wrapper();
    };
  },
});

@kmannislands Hey, your version doesn't reset allArgs inside wrapper(), so subsequent calls to func() get historical batches of args as well as the current batch.

Shouldn't it be:

const wrapper = _.debounce(() => {
    const args = allArgs;
    allArgs = undefined;
    func(args);
}, wait);

@markjaquith +1

Also func( args ) differs from the original version that uses func.apply(context, args);.
For the former, args is used as is in the the target func(), whereas in the later _(original code)_ you need to use either arguments in a normal function or ( ...args ) in an es6 fat arrow function.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

zackschuster picture zackschuster  ·  5Comments

markvr picture markvr  ·  3Comments

Francefire picture Francefire  ·  5Comments

arypbatista picture arypbatista  ·  3Comments

umarfarooq125 picture umarfarooq125  ·  8Comments