The question
Like a debouncer, you don't want to immediately execute on each call. But unlike a debouncer, you want to have each call queued for future execution.
Like a scheduler, you want to specify when to call the queued calls. More specifically, you want to execute calls with a fixed delay inbetween each call.
This means we can take inspiration from different ideas:
- From debouncers: How to not execute on every call.
- From queues: How to organize calls (first-in-first-out (FIFO) is what we want).
- From schedulers: How to manage calls.
With async/await
Using async/await is a great idea! Note that in JS there is no way to sleep() natively because of its original synchronous design. We could either implement sleep() synchronously by spinning (highly discouraged because it will make your site unresponsive) or asynchronously with Promises.
Example of how a sleep() function can be implemented asynchronously:
function sleep(milli) {
return new Promise(resolve => setTimeout(resolve, milli));
}
Now, to refactor your code to use async/await, we mostly only need to inline the setTimeout() function. Simply put, until the setTimeout() call we can do as usual, then sleep() for the specified time (1000 milliseconds), and then inline the callback:
function sleep(milli) {
return new Promise(resolve => setTimeout(resolve, milli));
}
let available = true;
let requested = 0;
async function notTwiceASecond() {
if (available) {
available = false;
console.log("Executed");
// ...
await sleep(1000);
available = true;
if (requested) {
--requested;
notTwiceASecond();
}
} else {
++requested;
}
}
for (let i = 0; i < 3; ++i) {
console.log("called");
notTwiceASecond();
}
Since we want to await the sleep() call, we have to declare notTwiceASecond() as async.
One important thing I'd like to mention is that I moved available = false to the top of the if-block. I did this for several reasons:
- Semantically, we have just started working on the call, so we should be unavailable from the beginning.
- Programmatically because if we were to call
notTwiceASecond() from within the left-out code (// ...), your function would behave differently from how it should. This bug also exists in your original code.
Refactoring your code like this will change its behaviour with other setTimeout() calls and Promises, because of how the event loop works.
Here a short example of the differences:
setTimeout(() => {
console.log(3);
}, 0);
Promise.resolve().then(() => {
console.log(2);
});
console.log(1);
Initial answer
Simple debouncing can be done with a delayed action. In JS we can use setTimeout() for the delayed action. Do note that a regular debounce will act as if only the last call has been made, like this:
let timeoutId = undefined;
function debounce(callback) {
// Clear previous delayed action, if existent
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
// Start new delayed action for latest call
timeoutId = setTimeout(() => {
callback();
timeoutId = undefined; // Clear timeout
}, 1000);
}
const callback = () => console.log("debounced!");
for (let i = 0; i < 10; ++i) {
debounce(callback);
}
But we don't want to dismiss past calls. Actually, we want to act on the first call, and enqueue future calls!
const queue = [];
let timeoutId = undefined;
function weirdDebounce(callback) {
if (timeoutId === undefined) {
timeoutId = setTimeout(() => {
callback();
timeoutId = undefined;
console.log("[DEBUG] Queue-length:", queue.length);
}, 1000);
} else {
queue.push(callback);
}
}
const callback = () => console.log('"debounced"!');
for (let i = 0; i < 10; ++i) {
weirdDebounce(callback);
}
Now that "act on first call" and "enqueue further calls" is in place, we only need to actually execute the enqueued calls after some delay.
const queue = [];
let timeoutId = undefined;
function weirdDebounceAndSchedule(callback) {
function pauseThenContinue() {
timeoutId = setTimeout(function() {
timeoutId = undefined;
doNext();
}, 1000);
}
function doNext() {
// Only do next if available and no delayed action is in progress
if (queue.length && timeoutId === undefined) {
const nextCallback = queue.shift();
nextCallback();
pauseThenContinue();
}
}
queue.push(callback);
doNext();
}
const callback = () => console.log("now scheduled!");
for (let i = 0; i < 10; ++i) {
weirdDebounceAndSchedule(callback);
}
Better "initial answer"
Maybe you want to be able to have different such "debounced" functions, and maybe you want to have them run independently of each other. If so, you might want to use the following implementation.
From the previous explanations, I hope it is understandable how this one work, though the previous explanations are actually derived from this code:
/**
* Create a scheduler for calling callbacks with a fixed delay inbetween each call.
*
* @param milli the inbetween-delay in milliseconds
* @returns a function to add callbacks to the queue
*/
function withPause(milli) {
const queue = [];
let timeoutId = undefined;
function pauseThenContinue() {
timeoutId = setTimeout(
function() {
timeoutId = undefined;
doNext();
},
milli
);
}
function doNext() {
if (queue.length && timeoutId === undefined) {
queue.shift()();
pauseThenContinue();
}
}
return function(callback) {
queue.push(callback);
doNext();
};
}
// Can be used like this:
const doTask = withPause(1000);
for (let i = 0; i < 4; ++i) {
doTask(() => console.log("example"));
}
// Or like this, with Function.prototype.bind():
const notTwiceASecond = doTask.bind(null, () => console.log("not twice"));
for (let i = 0; i < 4; ++i) {
notTwiceASecond();
}
To add its independence of other such schedulers, I make use of closures; specifically, I use a closure for each scheduler to have its own for queue, timeoutId and milli.
Also, to have it alias a function with the same name as your notTwiceASecond() function, I make use of Function.prototype.bind() and am assigning it to a similarly named variable.
Improve it!
Obviously my code is not final. Further, it isn't even the only way to do it, and most likely not the best!
For example, the delay is fixed upon "scheduler creation"! You can't first wait for 1s, then for 3s, then for 2s, ...
Maybe this is something you want, or just an exercise idea for you!
Also: Did you notice that my code also has the bug mentioned in "With async/await"? The one where calling from within the task would make the code misbehave.
Try fixing it as an exercise!
Regarding your code
Unlike my implementation that uses an array to enqueue each individual "task", you simply increment a counter. Since your notTwiceASecond() is always only doing the same task, I prefer yours over mine.
What bugs me most about your code is that available and requested are declared on the global context. Trying to redeclare similarly named variables, or using them differently from their intended use would cause your code to misbehave.
There are different ways to remove them from the global context. These are the ways I can think of right now:
// As properties on the function. (Is hoisted)
function notTwiceASecond() {
if (!notTwiceASecond.lazyInitialized) {
notTwiceASecond.available = true;
notTwiceASecond.requested = 0;
notTwiceASecond.lazyInitialized = true;
}
// Replace `available` and `requested` to property accessors like above.
// ... (Initial implementation)
}
// Wrapped in IIFE. (Is not hoisted)
const notTwiceASecond = (function() {
let available = true;
let requested = 0;
function notTwiceASecond() { /* ... */ }
return notTwiceASecond;
})();
You inlined the setTimeout() call while I outsourced it to its own function pauseThenContinue(). The only reason I did this is to have my code be easier to read.
The biggest difference then in our implementations is how we handle requested/queue:
You immediately execute the call if available and only enqueue if unavailable; I always enqueue and only execute if enqueued. What to prefer here is subjective.