@byojs/scheduler
Advanced tools
Comparing version 0.0.0-preA to 0.0.9
{ | ||
"name": "@byojs/scheduler", | ||
"description": "Debounce scheduler", | ||
"version": "0.0.0-preA", | ||
"description": "Throttle/debounce scheduler", | ||
"version": "0.0.9", | ||
"exports": { | ||
@@ -6,0 +6,0 @@ "./": "./dist/scheduler.mjs" |
122
README.md
@@ -6,6 +6,11 @@ # Scheduler | ||
**Scheduler** ... // TODO | ||
**Scheduler** is a tool to manage *async scheduling* (debouncing, throttling) of repeated tasks. | ||
```js | ||
// TODO | ||
var debouncer = Scheduler(100,500); | ||
var throttler = Scheduler(300,300); | ||
window.addEventListener("scroll",() => debouncer(onScroll)); | ||
button.addEventListener("click",() => throttler(onButtonClick)); | ||
``` | ||
@@ -21,4 +26,28 @@ | ||
The main purpose of **Scheduler** is... // TODO | ||
The main purpose of **Scheduler** is to provide [debouncing and throttling](https://css-tricks.com/debouncing-throttling-explained-examples/) controls for managing async task scheduling. | ||
Both scheduling schemes reduce how many repeated calls to a single function will be processed, over a defined interval of time, but use different strategies for determining when to schedule those calls. And both strategies may operate in two forms: *leading* and *trailing*. | ||
### Throttling | ||
Throttling prevents a repeated function call from being processed more than once per defined interval of time (e.g., 100ms); an interval timer is started with the *first call* (which resets after each interval transpires). | ||
With leading throttling, the initial call is processed immediately, and any subsequent call attempts, during the interval, will be ignored. With trailing throttling, only the last call is processed, *after* the full interval has transpired (since the first attempted call). | ||
### Debouncing | ||
Debouncing resets the delay interval with each attempted call of a function, meaning that the delay of processing an attempted call will continue to increase (unbounded), with each subsequent call attempt during the defined interval. | ||
With leading debouncing, the initial call is immediately processed, after which subsequent calls are debounced; once a full interval transpires without attempted calls, the most recent call is processed. With trailing debouncing, no initial call is processed, and every call is debounced. | ||
Debouncing *might* effectively delay a function call indefinitely, if at least one call attempt is made during each defined interval of time. This is usually not preferred, so you can set an upper bound for the total debouncing delay, after which the most recent call will be processed and the debouncing interval reset. | ||
### Canceling | ||
Any throttled or debounced call that has not yet happened yet, may be canceled before it is processed. | ||
For example, you might debounce the initial display of a spinner (e.g., 500ms) for an async task that can vary in duration (like a network request); debouncing prevents the spinner from flashing visible and then being hidden very quickly -- if the network request finishes very quickly. But if the network request finishes even faster than the 500ms, you can cancel the scheduled display of the spinner. | ||
**Tip:** Debouncing the spinner showing, as described, still risks a potential UX hiccup. The network request might finish shortly after the debounce interval delay has transpired, which still quickly flickers the spinner. And this gets even worse if a subsequent async operation might be triggered (debounced) right after, such that the user might see a series of spinner flickers (on and off). One solution is to *also* debounce the canceling of a previous operation's debounce. In other words, the spinner might delay in being shown, but once shown, delay in its hiding. This approach [is essentially a debounced toggle (see **byojs/Toggler**)](https://github.com/byojs/toggler). | ||
## Deployment / Import | ||
@@ -41,3 +70,3 @@ | ||
```js | ||
import { /* TODO */ } from "@byojs/scheduler"; | ||
import Scheduler from "@byojs/scheduler"; | ||
``` | ||
@@ -64,3 +93,3 @@ | ||
```js | ||
import { /* TODO */ } from "scheduler"; | ||
import Scheduler from "scheduler"; | ||
``` | ||
@@ -72,10 +101,87 @@ | ||
The API provided by **Scheduler**... // TODO | ||
The API provided by **Scheduler** is a single function -- the default export of the module. | ||
This function receives one to three arguments, to initialize a scheduler instance -- represented by another function as its return value -- using either [the throttle strategy](#throttling) or [the debounce strategy](#debouncing). | ||
### Configuring *unbounded* debouncing | ||
To initialize an unbounded-debounce scheduler (in *leading* or *trailing* mode): | ||
```js | ||
// .. TODO | ||
// leading unbounded debounce | ||
var debouncer1 = Scheduler(250); | ||
// trailing unbounded debounce | ||
var debouncer2 = Scheduler(250,Infinity,/*leading=*/false); | ||
``` | ||
// TODO | ||
**Note:** The second argument represents the upper bound for total debouncing delay; `Infinity` (default) allows unbounded debouncing delay. | ||
### Configuring *bounded* debouncing | ||
To initialize a bounded-debounce scheduler (in *leading* or *trailing* mode): | ||
```js | ||
// leading bounded (400ms) debounce | ||
var debouncer3 = Scheduler(250,400); | ||
// trailing bounded (400ms) debounce | ||
var debouncer4 = Scheduler(250,400,/*leading=*/false); | ||
``` | ||
### Configuring throttling | ||
To initialize a throttle scheduler (in *leading* or *trailing* mode): | ||
```js | ||
// leading throttle | ||
var throttler1 = Scheduler(250,250); | ||
// trailing throttle | ||
var throttler2 = Scheduler(250,250,/*leading=*/false); | ||
``` | ||
**Note:** As shown, the throttling strategy is activated by passing the same value for the first two arguments (delay and upper-bound). | ||
### Scheduling tasks | ||
Once you've setup a scheduler instance, you can *schedule* repeated function calls by passing the (same) function value in (each time). | ||
For example, with `debouncer1` (as configured above): | ||
```js | ||
debouncer1(someTask); | ||
// later (but within 250ms of previous call) | ||
debouncer1(someTask); | ||
// later (but within 250ms of previous call) | ||
debouncer1(someTask); | ||
``` | ||
In this snippet, `someTask` will only be called once (with no arguments), ~250ms after the last call (within the interval) to `debouncer1()`. | ||
You can share the same scheduler instance can for debouncing/throttling as many different functions as desired, assuming the same timing settings should apply for each of them. | ||
**Warning:** The internal tracking of repeated and async scheduled calls is based on function reference identity. If you pass an inline function expression (such as an `=>` arrow), the function reference will be different each time, and will be treated as entirely separate functions -- thereby defeating the debouncing/throttling. Make sure to use the same stable function reference for all scheduling-related invocations of the scheduler instance function. | ||
### Canceling a scheduled task | ||
The scheduler instance (e.g., `debouncer1` from above) returns yet another function, which is a *canceler*: | ||
```js | ||
var canceler = debouncer1(someTask); | ||
// later (but within 250ms of previous call) | ||
canceler(); | ||
``` | ||
If `canceler()` (as shown) is called before the debounced `someTask()` function is actually called, that debounced scheduling will be canceled. The same *canceler* will be returned for all subsequent `debouncer1()` calls **within the same interval**: | ||
```js | ||
debouncer1(someTask) === debouncer1(someTask); // true | ||
``` | ||
Since the *canceler* function is stable (within the same interval), it's safe to preserve a reference to that function, to use at any time during that interval. Once the interval transpires, the function becomes *dead* (no-op), and should be discarded. | ||
## Re-building `dist/*` | ||
@@ -82,0 +188,0 @@ |
@@ -16,3 +16,2 @@ #!/usr/bin/env node | ||
const MAIN_COPYRIGHT_HEADER = path.join(SRC_DIR,"copyright-header.txt"); | ||
const NODE_MODULES_DIR = path.join(PKG_ROOT_DIR,"node_modules"); | ||
@@ -19,0 +18,0 @@ const DIST_DIR = path.join(PKG_ROOT_DIR,"dist"); |
@@ -6,3 +6,4 @@ export default Scheduler; | ||
function Scheduler(debounceMin,throttleMax) { | ||
function Scheduler(debounceMin,throttleMax = Infinity,leading = false) { | ||
throttleMax = Math.max(debounceMin,throttleMax); | ||
var entries = new WeakMap(); | ||
@@ -31,18 +32,32 @@ | ||
if (!entry.timer) { | ||
if (entry.timer == null) { | ||
entry.last = now; | ||
} | ||
if ( | ||
// fire first, then debounce? | ||
if (leading) { | ||
if ( | ||
// no timer running yet? | ||
entry.timer == null || | ||
// NO room left to debounce while still under the throttle-max? | ||
!((now - entry.last) <= throttleMax) | ||
) { | ||
clearTimer(entry); | ||
fn(); | ||
entry.timer = setTimeout(clearTimer,debounceMin,entry); | ||
} | ||
else { | ||
setTimer(fn,entry,now); | ||
} | ||
} | ||
// fire first only *after* at least debounce minimum | ||
else if ( | ||
// no timer running yet? | ||
entry.timer == null || | ||
// room left to debounce while still under the throttle-max? | ||
(now - entry.last) < throttleMax | ||
) { | ||
if (entry.timer) { | ||
clearTimeout(entry.timer); | ||
} | ||
let time = Math.min(debounceMin,Math.max(0,(entry.last + throttleMax) - now)); | ||
entry.timer = setTimeout(run,time,fn,entry); | ||
setTimer(fn,entry,now); | ||
} | ||
@@ -52,6 +67,4 @@ | ||
entry.cancelFn = function cancel(){ | ||
if (entry.timer) { | ||
clearTimeout(entry.timer); | ||
entry.timer = entry.cancelFn = null; | ||
} | ||
clearTimer(entry); | ||
entry.cancel = null; | ||
}; | ||
@@ -62,7 +75,21 @@ } | ||
function setTimer(fn,entry,now) { | ||
clearTimer(entry); | ||
var time = Math.min(debounceMin,Math.max(0,(entry.last + throttleMax) - now)); | ||
entry.timer = setTimeout(run,time,fn,entry); | ||
} | ||
function run(fn,entry) { | ||
entry.timer = entry.cancelFn = null; | ||
clearTimer(entry); | ||
entry.cancelFn = null; | ||
entry.last = Date.now(); | ||
fn(); | ||
} | ||
function clearTimer(entry) { | ||
if (entry.timer != null) { | ||
clearTimeout(entry.timer); | ||
} | ||
entry.timer = null; | ||
} | ||
} |
100
test/test.js
@@ -9,2 +9,4 @@ // note: this module specifier comes from the import-map | ||
var testResultsEl; | ||
if (document.readyState == "loading") { | ||
@@ -21,5 +23,101 @@ document.addEventListener("DOMContentLoaded",ready,false); | ||
async function ready() { | ||
// TODO | ||
var runTestsBtn = document.getElementById("run-tests-btn"); | ||
testResultsEl = document.getElementById("test-results"); | ||
runTestsBtn.addEventListener("click",runTests,false); | ||
} | ||
async function runTests() { | ||
testResultsEl.innerHTML = "Running... please wait."; | ||
var [ leadingResult, trailingResult, ] = await Promise.all([ | ||
runLeadingTests(), | ||
runTrailingTests(), | ||
]); | ||
testResultsEl.innerHTML = `${leadingResult}<br>${trailingResult}`; | ||
} | ||
async function runLeadingTests() { | ||
var results = []; | ||
var waiter = Scheduler(100,500,/*leading=*/true); | ||
waiter(logResult); | ||
await timeout(110); | ||
waiter(logResult); | ||
waiter(logResult); | ||
await timeout(50); | ||
waiter(logResult); | ||
waiter(logResult); | ||
waiter(logResult); | ||
await timeout(500); | ||
for (let i = 0; i < 12; i++) { | ||
waiter(logResult); | ||
await timeout(60); | ||
} | ||
await timeout(500); | ||
var EXPECTED = 7; | ||
var FOUND = results.length; | ||
return ( | ||
`(Leading) ${ | ||
results.length == EXPECTED ? | ||
"PASSED." : | ||
`FAILED: expected ${EXPECTED}, found ${FOUND}` | ||
}` | ||
); | ||
// *********************** | ||
function logResult() { | ||
results.push("result"); | ||
} | ||
} | ||
async function runTrailingTests() { | ||
var results = []; | ||
var waiter = Scheduler(100,500,/*leading=*/false); | ||
testResultsEl.innerHTML = "Running... please wait."; | ||
waiter(logResult); | ||
await timeout(110); | ||
waiter(logResult); | ||
waiter(logResult); | ||
await timeout(50); | ||
waiter(logResult); | ||
waiter(logResult); | ||
waiter(logResult); | ||
await timeout(500); | ||
for (let i = 0; i < 30; i++) { | ||
waiter(logResult); | ||
await timeout(60); | ||
} | ||
await timeout(500); | ||
var EXPECTED = 6; | ||
var FOUND = results.length; | ||
return ( | ||
`(Trailing) ${ | ||
results.length == EXPECTED ? | ||
"PASSED." : | ||
`FAILED: expected ${EXPECTED}, found ${FOUND}` | ||
}` | ||
); | ||
// *********************** | ||
function logResult() { | ||
results.push("result"); | ||
} | ||
} | ||
function timeout(ms) { | ||
return new Promise(res => setTimeout(res,ms)); | ||
} | ||
function logError(err,returnLog = false) { | ||
@@ -26,0 +124,0 @@ var err = `${ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Deprecated
MaintenanceThe maintainer of the package marked it as deprecated. This could indicate that a single version should not be used, or that the package is no longer maintained and any new vulnerabilities will not be fixed.
Found 1 instance in 1 package
24376
384
0
216