From 864e14cb20093e872039a45065402de91eb65860 Mon Sep 17 00:00:00 2001 From: Brandon Wallace Date: Mon, 7 Mar 2016 12:30:30 -0600 Subject: [PATCH] Refactor debounce to simplify, reduce timers, fix bugs. --- lodash.js | 166 +++++++++++++++++++++++++++------------------------ test/test.js | 78 +++++++++++++++++++++++- 2 files changed, 163 insertions(+), 81 deletions(-) diff --git a/lodash.js b/lodash.js index 8690ee567..e09d446a4 100644 --- a/lodash.js +++ b/lodash.js @@ -9000,14 +9000,12 @@ * jQuery(window).on('popstate', debounced.cancel); */ function debounce(func, wait, options) { - var args, - maxTimeoutId, + var lastArgs, + lastThis, result, - stamp, - thisArg, - timeoutId, - trailingCall, - lastCalled = 0, + timerId, + lastCallTime = 0, + lastInvokeTime = 0, leading = false, maxWait = false, trailing = true; @@ -9022,94 +9020,104 @@ trailing = 'trailing' in options ? !!options.trailing : trailing; } - function cancel() { - if (timeoutId) { - clearTimeout(timeoutId); - } - if (maxTimeoutId) { - clearTimeout(maxTimeoutId); - } - lastCalled = 0; - args = maxTimeoutId = thisArg = timeoutId = trailingCall = undefined; + function invoke(time) { + var args = lastArgs, + thisArg = lastThis; + + lastArgs = lastThis = undefined; + lastInvokeTime = time; + result = func.apply(thisArg, args); + return result; } - function complete(isCalled, id) { - if (id) { - clearTimeout(id); + function leadingEdge() { + // Reset any `maxWait` timer. + lastInvokeTime = lastCallTime; + // Start the timer to the trailing edge. + timerId = setTimeout(timerExpired, wait); + // Invoke the leading edge. + return leading ? invoke(lastCallTime) : result; + } + + function trailingEdge(time) { + if (timerId !== undefined) { + clearTimeout(timerId); + timerId = undefined; } - maxTimeoutId = timeoutId = trailingCall = undefined; - if (isCalled) { - lastCalled = now(); - result = func.apply(thisArg, args); - if (!timeoutId && !maxTimeoutId) { - args = thisArg = undefined; + // Only invoke if we have `lastArgs`, which means there has been a call + // to `func` since the last invocation + if (trailing && lastArgs) { + return invoke(time); + } + lastArgs = lastThis = undefined; + return result; + } + + function checkTimes(time) { + var timeSinceLastInvoke = time - lastInvokeTime, + waitTime = time - lastCallTime; + + if (waitTime >= wait) { + // Activity has stopped. We are at the trailing edge. + trailingEdge(time); + return; + } + if (waitTime < 0) { + // The system time has gone backwards. Treat it as the trailing edge. + trailingEdge(time); + return; + } + var shouldInvoke = (maxWait !== false && timeSinceLastInvoke >= maxWait); + + // Restart the timer to the smaller of remaining maxWait and remaining wait. + var remainingWait = wait - waitTime; + if (maxWait !== false) { + remainingWait = nativeMin(remainingWait, maxWait - timeSinceLastInvoke); + } + return { + 'shouldInvoke': shouldInvoke, + 'remainingWait': remainingWait + }; + } + + function timerExpired() { + var time = now(), + check = checkTimes(time); + + timerId = undefined; + if (check !== undefined) { + // Restart the timer. + timerId = setTimeout(timerExpired, check.remainingWait); + if (check.shouldInvoke) { + invoke(time); } } } - function delayed() { - var remaining = wait - (now() - stamp); - if (remaining <= 0 || remaining > wait) { - complete(trailingCall, maxTimeoutId); - } else { - timeoutId = setTimeout(delayed, remaining); + function cancel() { + if (timerId !== undefined) { + clearTimeout(timerId); } + lastArgs = lastThis = timerId = undefined; } function flush() { - if ((timeoutId && trailingCall) || (maxTimeoutId && trailing)) { - result = func.apply(thisArg, args); - } - cancel(); - return result; - } - - function maxDelayed() { - complete(trailing, timeoutId); + return timerId === undefined ? result : trailingEdge(now()); } function debounced() { - args = arguments; - stamp = now(); - thisArg = this; - trailingCall = trailing && (timeoutId || !leading); + lastArgs = arguments; + lastThis = this; + lastCallTime = now(); - if (maxWait === false) { - var leadingCall = leading && !timeoutId; - } else { - if (!lastCalled && !maxTimeoutId && !leading) { - lastCalled = stamp; - } - var remaining = maxWait - (stamp - lastCalled); - - var isCalled = (remaining <= 0 || remaining > maxWait) && - (leading || maxTimeoutId); - - if (isCalled) { - if (maxTimeoutId) { - maxTimeoutId = clearTimeout(maxTimeoutId); - } - lastCalled = stamp; - result = func.apply(thisArg, args); - } - else if (!maxTimeoutId) { - maxTimeoutId = setTimeout(maxDelayed, remaining); - } + if (timerId === undefined) { + return leadingEdge(); } - if (isCalled && timeoutId) { - timeoutId = clearTimeout(timeoutId); - } - else if (!timeoutId && wait !== maxWait) { - timeoutId = setTimeout(delayed, wait); - } - if (leadingCall) { - isCalled = true; - result = func.apply(thisArg, args); - } - if (isCalled && !timeoutId && !maxTimeoutId) { - args = thisArg = undefined; - } - return result; + // Check the current times to handle invocations in a tight loop. + var check = checkTimes(lastCallTime); + return (check && check.shouldInvoke) + ? invoke(lastCallTime) + : result; } debounced.cancel = cancel; debounced.flush = flush; diff --git a/test/test.js b/test/test.js index 813fc4c6d..6ed7c2e92 100644 --- a/test/test.js +++ b/test/test.js @@ -4193,6 +4193,76 @@ }, 192); }); + QUnit.test('should honor leading: false when maxWait is not supplied', function(assert) { + assert.expect(6); + + var done = assert.async(); + + var callCount = 0; + + var debounced = _.debounce(function(value) { + ++callCount; + return value; + }, 32); + + // Leading should not fire. + var actual = [debounced(0), debounced(1), debounced(2)]; + assert.deepEqual(actual, [undefined, undefined, undefined]); + assert.strictEqual(callCount, 0); + + setTimeout(function() { + // Trailing should fire by now. + assert.strictEqual(callCount, 1); + + // Do it again. + var actual = [debounced(4), debounced(5), debounced(6)]; + + // Previous result. + assert.deepEqual(actual, [2, 2, 2]); + assert.strictEqual(callCount, 1); + }, 128); + + setTimeout(function() { + assert.strictEqual(callCount, 2); + done(); + }, 256); + }); + + QUnit.test('should honor leading: false when maxWait is supplied', function(assert) { + assert.expect(6); + + var done = assert.async(); + + var callCount = 0; + + var debounced = _.debounce(function(value) { + ++callCount; + return value; + }, 32, { 'maxWait': 64 }); + + // Leading should not fire. + var actual = [debounced(0), debounced(1), debounced(2)]; + assert.deepEqual(actual, [undefined, undefined, undefined]); + assert.strictEqual(callCount, 0); + + setTimeout(function() { + // Trailing should fire by now. + assert.strictEqual(callCount, 1); + + // Do it again. + var actual = [debounced(4), debounced(5), debounced(6)]; + + // Previous result. + assert.deepEqual(actual, [2, 2, 2]); + assert.strictEqual(callCount, 1); + }, 128); + + setTimeout(function() { + assert.strictEqual(callCount, 2); + done(); + }, 256); + }); + QUnit.test('should invoke the `trailing` call with the correct arguments and `this` binding', function(assert) { assert.expect(2); @@ -21271,7 +21341,7 @@ }); QUnit.test('should trigger a second throttled call as soon as possible', function(assert) { - assert.expect(2); + assert.expect(3); var done = assert.async(); @@ -21288,10 +21358,14 @@ throttled(); }, 192); + setTimeout(function() { + assert.strictEqual(callCount, 1); + }, 254); + setTimeout(function() { assert.strictEqual(callCount, 2); done(); - }, 288); + }, 384); }); QUnit.test('should apply default options', function(assert) {