Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
schmerg opened this issue Sep 22, 2011 · 11 comments

Comments

@schmerg
Copy link

schmerg commented Sep 22, 2011

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.

@schmerg
Copy link
Author

schmerg commented Sep 23, 2011

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....

@jashkenas
Copy link
Owner

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.

@schmerg
Copy link
Author

schmerg commented Oct 24, 2011

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.

@jashkenas
Copy link
Owner

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.

@schmerg
Copy link
Author

schmerg commented Oct 24, 2011

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 :)

@schmerg
Copy link
Author

schmerg commented Oct 24, 2011

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]

@markjaquith
Copy link

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

@schmerg
Copy link
Author

schmerg commented Jul 10, 2013

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

@kmannislands
Copy link

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();
    };
  },
});

@markjaquith
Copy link

@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);

@nevf
Copy link

nevf commented Apr 16, 2020

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants