debouncing - ¿Qué es la función "debounce" en JavaScript?
Estoy interesado en la función de "debounce" en JavaScript, en Función Debounce de JavaScript.
Desafortunadamente, el código no está explicado lo suficientemente claro para que pueda entenderlo. ¿Cómo funciona (dejé mis comentarios abajo)? En resumen, simplemente no entiendo cómo funciona esto.
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds.
function debounce(func, wait, immediate) {
var timeout;
return function() {
var context = this, args = arguments;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
};
El fragmento de código copiado anteriormente tenía callNow en el lugar incorrecto.
Respuesta #1
El código en la pregunta se modificó ligeramente con respecto al código en el enlace. En el enlace, existe una comprobación de (immediate && !timeout) antes de crear un nuevo tiempo de espera. Tenerlo después hace que el modo inmediato nunca se active. He actualizado mi respuesta para anotar la versión funcional del enlace.
function debounce(func, wait, immediate) {
// 'private' variable for instance
// The returned function will be able to reference this due to closure.
// Each call to the returned function will share this common timer.
var timeout;
// Calling debounce returns a new anonymous function
return function() {
// reference the context and args for the setTimeout function
var context = this,
args = arguments;
// Should the function be called now? If immediate is true
// and not already in a timeout then the answer is: Yes
var callNow = immediate && !timeout;
// This is the basic debounce behaviour where you can call this
// function several times, but it will only execute once
// (before or after imposing a delay).
// Each time the returned function is called, the timer starts over.
clearTimeout(timeout);
// Set the new timeout
timeout = setTimeout(function() {
// Inside the timeout function, clear the timeout variable
// which will let the next execution run when in 'immediate' mode
timeout = null;
// Check if the function already ran with the immediate flag
if (!immediate) {
// Call the original function with apply
// apply lets you define the 'this' object as well as the arguments
// (both captured before setTimeout)
func.apply(context, args);
}
}, wait);
// Immediate mode and no wait timer? Execute the function...
if (callNow) func.apply(context, args);
}
}
/////////////////////////////////
// DEMO:
function onMouseMove(e){
console.clear();
console.log(e.x, e.y);
}
// Define the debounced function
var debouncedMouseMove = debounce(onMouseMove, 50);
// Call the debounced function on every mouse move
window.addEventListener('mousemove', debouncedMouseMove);Respuesta #2
Lo importante a notar aquí es que debounce produce una función que está "cerrada sobre" la variable timeout. La variable timeout permanece accesible durante cada llamada de la función producida incluso después de que debounce haya retornado, y puede cambiar en diferentes llamadas.
La idea general de debounce es la siguiente:
- Comenzar sin tiempo de espera.
- Si se llama a la función producida, borra y reinicia el tiempo de espera.
- Si se alcanza el tiempo de espera, llama a la función original.
El primer punto es simplemente var timeout;, en realidad es solo undefined. Afortunadamente, clearTimeout es bastante permisivo con su entrada: pasar un identificador de temporizador undefined hace que simplemente no haga nada, no lanza un error ni nada por el estilo.
El segundo punto es realizado por la función producida. Primero almacena alguna información sobre la llamada (el contexto this y los arguments) en variables para que pueda usar estos más tarde para la llamada con retardo (debounced). Luego borra el temporizador (si había uno establecido) y después crea uno nuevo para reemplazarlo usando setTimeout. Nota que esto sobrescribe el valor de timeout y este valor persiste a lo largo de múltiples llamadas a la función! Esto permite que el debounce funcione realmente: si la función se llama varias veces, timeout se sobrescribe varias veces con un nuevo temporizador. Si no fuera así, llamadas múltiples causarían que múltiples temporizadores se iniciaran que todos permanecerían activos - las llamadas simplemente se retrasarían, pero no serían debounceadas.
El tercer punto se realiza en la devolución de llamada del tiempo de espera. Desconfigura la variable timeout y realiza la llamada real a la función utilizando la información de llamada almacenada.
Se supone que la bandera immediate controla si la función debe ser llamada antes o después del temporizador. Si es false, la función original no se llama hasta después de que el temporizador se cumpla. Si es true, la función original se llama primero y no se volverá a llamar hasta que el temporizador se cumpla.
Sin embargo, sí creo que la verificación if (immediate && !timeout) es incorrecta: timeout acaba de establecerse como el identificador del temporizador devuelto por setTimeout, por lo que !timeout siempre es false en ese punto y, por lo tanto, la función nunca puede llamarse. La versión actual de underscore.js parece tener una verificación ligeramente diferente, donde evalúa immediate && !timeout antes de llamar a setTimeout. (El algoritmo también es un poco diferente, por ejemplo, no usa clearTimeout.) Por eso siempre deberías intentar usar la versión más reciente de tus bibliotecas. :-)
Respuesta #3
Las funciones con desaceleración no se ejecutan cuando se invocan. Esperan una pausa de invocaciones durante una duración configurable antes de ejecutarse; cada nueva invocación reinicia el temporizador.
Las funciones limitadas se ejecutan y luego esperan una duración configurable antes de ser elegibles para ejecutarse nuevamente.
Debounce es ideal para eventos de pulsación de teclas; cuando el usuario comienza a escribir y luego se detiene, envías todas las pulsaciones de teclas como un solo evento, reduciendo así las invocaciones de manejo.
Throttle es excelente para endpoints en tiempo real que solo deseas permitir que el usuario invoque una vez por un período de tiempo establecido.
Consulta Underscore.js también para sus implementaciones.
Respuesta #4
Yo tampoco entendía completamente cómo funcionaba una función debounce cuando me encontré con una por primera vez. Aunque son relativamente pequeñas en tamaño, ¡en realidad emplean algunos conceptos bastante avanzados de JavaScript! Tener un buen dominio del alcance, los closures y el método setTimeout ayudará.
Dicho esto, a continuación se muestra la función básica de debounce explicada y demostrada en mi publicación mencionada arriba.
El producto terminado
// Create JD Object
// ----------------
var JD = {};
// Debounce Method
// ---------------
JD.debounce = function(func, wait, immediate) {
var timeout;
return function() {
var context = this,
args = arguments;
var later = function() {
timeout = null;
if ( !immediate ) {
func.apply(context, args);
}
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait || 200);
if ( callNow ) {
func.apply(context, args);
}
};
};
La explicación
// Create JD Object
// ----------------
/*
It's a good idea to attach helper methods like `debounce` to your own
custom object. That way, you don't pollute the global space by
attaching methods to the `window` object and potentially run in to
conflicts.
*/
var JD = {};
// Debounce Method
// ---------------
/*
Return a function, that, as long as it continues to be invoked, will
not be triggered. The function will be called after it stops being
called for `wait` milliseconds. If `immediate` is passed, trigger the
function on the leading edge, instead of the trailing.
*/
JD.debounce = function(func, wait, immediate) {
/*
Declare a variable named `timeout` variable that we will later use
to store the *timeout ID returned by the `setTimeout` function.
*When setTimeout is called, it retuns a numeric ID. This unique ID
can be used in conjunction with JavaScript's `clearTimeout` method
to prevent the code passed in the first argument of the `setTimout`
function from being called. Note, this prevention will only occur
if `clearTimeout` is called before the specified number of
milliseconds passed in the second argument of setTimeout have been
met.
*/
var timeout;
/*
Return an anomymous function that has access to the `func`
argument of our `debounce` method through the process of closure.
*/
return function() {
/*
1) Assign `this` to a variable named `context` so that the
`func` argument passed to our `debounce` method can be
called in the proper context.
2) Assign all *arugments passed in the `func` argument of our
`debounce` method to a variable named `args`.
*JavaScript natively makes all arguments passed to a function
accessible inside of the function in an array-like variable
named `arguments`. Assinging `arguments` to `args` combines
all arguments passed in the `func` argument of our `debounce`
method in a single variable.
*/
var context = this, /* 1 */
args = arguments; /* 2 */
/*
Assign an anonymous function to a variable named `later`.
This function will be passed in the first argument of the
`setTimeout` function below.
*/
var later = function() {
/*
When the `later` function is called, remove the numeric ID
that was assigned to it by the `setTimeout` function.
Note, by the time the `later` function is called, the
`setTimeout` function will have returned a numeric ID to
the `timeout` variable. That numeric ID is removed by
assiging `null` to `timeout`.
*/
timeout = null;
/*
If the boolean value passed in the `immediate` argument
of our `debouce` method is falsy, then invoke the
function passed in the `func` argument of our `debouce`
method using JavaScript's *`apply` method.
*The `apply` method allows you to call a function in an
explicit context. The first argument defines what `this`
should be. The second argument is passed as an array
containing all the arguments that should be passed to
`func` when it is called. Previously, we assigned `this`
to the `context` variable, and we assigned all arguments
passed in `func` to the `args` variable.
*/
if ( !immediate ) {
func.apply(context, args);
}
};
/*
If the value passed in the `immediate` argument of our
`debounce` method is truthy and the value assigned to `timeout`
is falsy, then assign `true` to the `callNow` variable.
Otherwise, assign `false` to the `callNow` variable.
*/
var callNow = immediate && !timeout;
/*
As long as the event that our `debounce` method is bound to is
still firing within the `wait` period, remove the numerical ID
(returned to the `timeout` vaiable by `setTimeout`) from
JavaScript's execution queue. This prevents the function passed
in the `setTimeout` function from being invoked.
Remember, the `debounce` method is intended for use on events
that rapidly fire, ie: a window resize or scroll. The *first*
time the event fires, the `timeout` variable has been declared,
but no value has been assigned to it - it is `undefined`.
Therefore, nothing is removed from JavaScript's execution queue
because nothing has been placed in the queue - there is nothing
to clear.
Below, the `timeout` variable is assigned the numerical ID
returned by the `setTimeout` function. So long as *subsequent*
events are fired before the `wait` is met, `timeout` will be
cleared, resulting in the function passed in the `setTimeout`
function being removed from the execution queue. As soon as the
`wait` is met, the function passed in the `setTimeout` function
will execute.
*/
clearTimeout(timeout);
/*
Assign a `setTimout` function to the `timeout` variable we
previously declared. Pass the function assigned to the `later`
variable to the `setTimeout` function, along with the numerical
value assigned to the `wait` argument in our `debounce` method.
If no value is passed to the `wait` argument in our `debounce`
method, pass a value of 200 milliseconds to the `setTimeout`
function.
*/
timeout = setTimeout(later, wait || 200);
/*
Typically, you want the function passed in the `func` argument
of our `debounce` method to execute once *after* the `wait`
period has been met for the event that our `debounce` method is
bound to (the trailing side). However, if you want the function
to execute once *before* the event has finished (on the leading
side), you can pass `true` in the `immediate` argument of our
`debounce` method.
If `true` is passed in the `immediate` argument of our
`debounce` method, the value assigned to the `callNow` variable
declared above will be `true` only after the *first* time the
event that our `debounce` method is bound to has fired.
After the first time the event is fired, the `timeout` variable
will contain a falsey value. Therfore, the result of the
expression that gets assigned to the `callNow` variable is
`true` and the function passed in the `func` argument of our
`debounce` method is exected in the line of code below.
Every subsequent time the event that our `debounce` method is
bound to fires within the `wait` period, the `timeout` variable
holds the numerical ID returned from the `setTimout` function
assigned to it when the previous event was fired, and the
`debounce` method was executed.
This means that for all subsequent events within the `wait`
period, the `timeout` variable holds a truthy value, and the
result of the expression that gets assigned to the `callNow`
variable is `false`. Therefore, the function passed in the
`func` argument of our `debounce` method will not be executed.
Lastly, when the `wait` period is met and the `later` function
that is passed in the `setTimeout` function executes, the
result is that it just assigns `null` to the `timeout`
variable. The `func` argument passed in our `debounce` method
will not be executed because the `if` condition inside the
`later` function fails.
*/
if ( callNow ) {
func.apply(context, args);
}
};
};
Respuesta #5
todos estamos usando Promesas ahora
Muchas implementaciones que he visto complican en exceso el problema o tienen otros problemas de higiene. Es 2021 y hemos estado usando Promesas durante mucho tiempo, y con razón. Las Promesas limpian los programas asincrónicos y reducen las oportunidades de que ocurran errores. En este post escribiremos nuestro propio debounce. Esta implementación -
- tener como máximo una promesa pendiente en cualquier momento (por cada tarea con retardo)
- detener las fugas de memoria cancelando correctamente las promesas pendientes
- resolver solo la última promesa
- demostrar un comportamiento adecuado con demostraciones de código en vivo
Escribimos debounce con sus dos parámetros, la task a amortiguar, y la cantidad de milisegundos de retraso, ms. Introducimos una sola vinculación local para su estado local, t -
function debounce (task, ms) {
let t = { promise: null, cancel: _ => void 0 }
return async (...args) => {
try {
t.cancel()
t = deferred(ms)
await t.promise
await task(...args)
}
catch (_) { /* prevent memory leak */ }
}
}
Dependemos de una función deferred reutilizable, que crea una nueva promesa que se resuelve en ms milisegundos. Introduce dos vinculaciones locales, la propia promise y la capacidad de cancelarla -
function deferred (ms) {
let cancel, promise = new Promise((resolve, reject) => {
cancel = reject
setTimeout(resolve, ms)
})
return { promise, cancel }
}
ejemplo de contador de clics
En este primer ejemplo, tenemos un botón que cuenta los clics del usuario. El oyente de eventos está adjunto usando debounce, por lo que el contador solo se incrementa después de una duración especificada -
// debounce, deferred
function debounce (task, ms) { let t = { promise: null, cancel: _ => void 0 }; return async (...args) => { try { t.cancel(); t = deferred(ms); await t.promise; await task(...args); } catch (_) { console.log("cleaning up cancelled promise") } } }
function deferred (ms) { let cancel, promise = new Promise((resolve, reject) => { cancel = reject; setTimeout(resolve, ms) }); return { promise, cancel } }
// dom references
const myform = document.forms.myform
const mycounter = myform.mycounter
// event handler
function clickCounter (event) {
mycounter.value = Number(mycounter.value) + 1
}
// debounced listener
myform.myclicker.addEventListener("click", debounce(clickCounter, 1000))<form id="myform">
<input name="myclicker" type="button" value="click" />
<output name="mycounter">0</output>
</form>ejemplo de consulta en vivo, "autocompletar"
En este segundo ejemplo, tenemos un formulario con un campo de texto. Nuestra consulta search se adjunta usando debounce -
// debounce, deferred
function debounce (task, ms) { let t = { promise: null, cancel: _ => void 0 }; return async (...args) => { try { t.cancel(); t = deferred(ms); await t.promise; await task(...args); } catch (_) { console.log("cleaning up cancelled promise") } } }
function deferred (ms) { let cancel, promise = new Promise((resolve, reject) => { cancel = reject; setTimeout(resolve, ms) }); return { promise, cancel } }
// dom references
const myform = document.forms.myform
const myresult = myform.myresult
// event handler
function search (event) {
myresult.value = `Searching for: ${event.target.value}`
}
// debounced listener
myform.myquery.addEventListener("keypress", debounce(search, 1000))<form id="myform">
<input name="myquery" placeholder="Enter a query..." />
<output name="myresult"></output>
</form>múltiples rebotes, hook de React useDebounce
En otra sesión de preguntas y respuestas, alguien pregunta si es posible exponer el mecanismo de cancelación del debounce y crear un hook de React useDebounce. Usando deferred arriba, es un ejercicio trivial.
// revised implementation
function debounce(task, ms) {
let t = { promise: null, cancel: _ => void 0 }
return [
// ...,
_ => t.cancel() // ✅ return cancellation mechanism
]
}
// revised usage
const [inc, cancel] = debounce(clickCounter, 1000) // ✅ two controls
myform.mybutton.addEventListener("click", inc)
myform.mycancel.addEventListener("click", cancel)
Implementar un hook de React useDebounce es muy fácil -
function useDebounce(task, ms) {
const [f, cancel] = debounce(task, ms)
useEffect(_ => cancel) // ✅ auto-cancel when component unmounts
return [f, cancel]
}
Dirígete a la sesión de preguntas y respuestas original para una demostración completa
