Inicio / Artículos

Escribir una utilidad de desactivación por rebote desde cero: temporizadores, argumentos y esto

Construye una función reutilizable de debounce línea por línea, aprende por qué transfiere argumentos y cuándo usar throttle en su lugar, así como cómo combinarla con AbortController.

Editor: REACTAPP.TOP8 min de lectura1742 palabras

Fuentes: Debouncing in JavaScript: How to Stop Sending Useless API Requests

Un cuadro de búsqueda que llama al servidor backend con cada tecla pulsada convierte una palabra escrita en una serie de solicitudes, la mayoría de las cuales son descartadas. La solución es una pequeña herramienta llamada debounce. Aquí lo implementarás, comprenderás cada línea, elegirás entre el método debounce y el throttling, y lo combinarás con la cancelación de solicitudes para obtener un flujo de búsqueda que funcione bien incluso con una escritura real.

Cómo las teclas pulsadas se convierten en solicitudes desperdiciadas

Imagina a un usuario que busca un framework. El valor de entrada cambia poco a poco, carácter por carácter:

r
re
rea
reac
react

Si cada cambio dispara una llamada a la API, el servidor debe manejar cinco solicitudes por cada consulta. Una búsqueda más específica empeora aún más la situación:

r
re
rea
reac
react
react j
react js
react js d
react js de
react js dev

Eso suma diez solicitudes, y solo la última refleja lo que quería el usuario; las demás siguen consumiendo ancho de banda, tiempo del servidor y cupo de límites de velocidad.

La idea detrás del debounce

El rebote pospone la llamada a una función hasta que haya transcurrido un tiempo determinado sin más llamadas: deja que el usuario termine y luego se ejecuta la tarea.

Supongamos que la llamada de búsqueda está rebotada con un retraso de 500 ms y el usuario escribe nuevamente los mismos cinco caracteres:

r
re
rea
reac
react

Cada tecla presionada reinicia el conteo regresivo, por lo que no se envía nada mientras continúa la escritura. Después de 500 ms sin actividad, se envía una solicitud:

react → API request

El bucle de decisión en cada llamada:

Typing
  ↓
Wait 500ms
  ↓
Did user type again?
  ↓
Yes → Reset timer
  ↓
No → Execute function

Un cuadro de búsqueda sin ninguna protección

Comencemos con un elemento de entrada simple:

<input id="search" placeholder="Search..." />

Obtenga una referencia a él en JavaScript:

const searchInput = document.querySelector("#search");

Luego adjunte un oyente input que envíe el valor a una función de búsqueda, la cual solo registra los datos para contar cuántas veces se activa:

searchInput.addEventListener("input", (event) => {
    searchProducts(event.target.value);
});function searchProducts(query) {
    console.log("API request:", query);
}

Ahora supongamos que alguien escribe el nombre de un producto:

iphone

El evento input se dispara una vez por cada carácter, más o menos en esta secuencia:

i
ip
iph
ipho
iphon
iphone

Por lo tanto, el manejador se ejecuta seis veces y registra seis solicitudes:

API request: i
API request: ip
API request: iph
API request: ipho
API request: iphon
API request: iphone

En comparación con una API real que requiere seis idas y venidas, mientras que al usuario solo le importa:

iphone

Implementar debounce en unas pocas líneas

La utilidad es un cierre: toma una función y un retraso, y devuelve un wrapper que se llama en lugar de la función original:

function debounce(fn, delay) {
    let timer;
return function (...args) {
        clearTimeout(timer);
        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };
}

Mantener un temporizador por cada función con debounce

let timer;

timer permanece en el cierre, por lo que cada llamada al wrapper ve el mismo valor; así es como una llamada sabe que otra anterior sigue en espera. Cada llamada a debounce obtiene su propio temporizador independiente.

Cancelar la llamada pendiente

clearTimeout(timer);

Cada llamada cancela la llamada pendiente, si la hay. Pasar undefined a clearTimeout en la primera ejecución no causa problemas.

Programar una nueva llamada

timer = setTimeout(() => {
    fn.apply(this, args);
}, delay);

Un nuevo plazo hace que se ejecute la función original después del retraso. Con 500 ms, las llamadas rápidas se desarrollan de la siguiente manera:

Call
 ↓
500ms timer starts
Call again
 ↓
Old timer cancelled
 ↓
New 500ms timer starts
Call again
 ↓
Old timer cancelled
 ↓
New 500ms timer starts

La función solo se ejecuta una vez que ha transcurrido un período completo de 500 ms sin otra llamada, y prevalecen los últimos argumentos.

Conectar la herramienta al cuadro de búsqueda

Sustituya el manejo directo por una versión con atenuación (esto reemplaza la declaración anterior de searchProducts, ya que ambas usan el mismo nombre):

const searchProducts = debounce((query) => {
    console.log("API request:", query);
}, 500);
searchInput.addEventListener("input", (event) => {
    searchProducts(event.target.value);
});

Cuando el usuario escribe los cinco caracteres habituales:

r
re
rea
reac
react

cada tecla presionada cancela el temporizador anterior, y eventualmente se completa el período de silencio:

react
   ↓
500ms
   ↓
API request

El resultado es una sola solicitud API en lugar de cinco.

Por qué el envoltorio reenvía los argumentos

Hay dos detalles que parecen opcionales pero hacen que la utilidad sea reutilizable. El primero es el parámetro rest:

return function (...args)

El segundo es la forma en que se invoca la función original:

fn.apply(this, args);

Soporte para cualquier número de argumentos

Un debounce genérico no debe asumir nada sobre la firma de la función envuelta. Puede envolver una búsqueda con un solo argumento:

const search = debounce((query) => {
    console.log(query);
}, 500);

o una función que acepta dos valores:

const calculate = debounce((a, b) => {
    console.log(a + b);
}, 500);

Recopilar todo con la sintaxis rest y pasarlo mantiene ambas funcionalidades activas:

...args

Preservación del contexto de llamada

La misma línea también transmite this:

fn.apply(this, args);

El contenedor es una function normal, por lo que recibe el this del llamante, como un objeto cuando la función con retardo se almacena como método. La función de flecha dentro de setTimeout no tiene su propio this, por lo que captura el del contenedor, y apply se lo pasa. Un manejador de búsquedas nunca se da cuenta, pero una utilidad compartida que pierde el contexto causa errores confusos en código basado en objetos. Tenga en cuenta que una función de flecha pasada como fn mantiene su this léxico sin importar nada más.

Dónde resulta útil el retardo

Este patrón funciona para cualquier evento que se dispare en ráfagas cuando solo importa el estado final.

Sugerencias de búsqueda

El flujo ingenuo envía una solicitud por cada tecla pulsada:

User types → API request

El flujo con retardo espera primero a que pase el período de pausa:

User types
     ↓
Wait
     ↓
User stops
     ↓
API request

Redimensionamiento de la ventana

Al arrastrar un borde de la ventana se emiten eventos resize de forma continua, y un listener directo ejecuta el trabajo costoso cada vez:

window.addEventListener("resize", () => {
    expensiveCalculation();
});

Eso puede significar cientos de ejecuciones por arrastre. Al encapsularlo, el trabajo se ejecuta una vez que el redimensionamiento finaliza:

const handleResize = debounce(() => {
    expensiveCalculation();
}, 300);
window.addEventListener("resize", handleResize);

Guardado automático

En un editor, el debouncing convierte una secuencia de ediciones en un único guardado después de que el usuario haga una pausa:

User types
User types
User types
User types
...
User stops
      ↓
Save document

Advertencia: un debouncing posterior nunca se activa mientras el usuario escribe sin parar, y un guardado pendiente se pierde si la página se cierra. Los editores suelen establecer un tiempo máximo de espera o realizar un guardado al ocurrir visibilitychange.

Debouncing versus throttling

Es fácil confundir estos dos conceptos. El debouncing espera a que cese la actividad y luego se ejecuta una vez:

Typing → Typing → Typing → STOP
                              ↓
                           Execute

El throttling ejecuta la función como máximo una vez por intervalo fijo, independientemente de cuántos eventos lleguen:

Event → Execute
  ↓
100ms
  ↓
Ignore
  ↓
100ms
  ↓
Execute

La ejecución al inicio frente a la ejecución al final añade matices, pero esta distinción cubre la mayoría de los casos.

El debounce no soluciona las respuestas obsoletas

El debounce limita cuántas solicitudes se inician, no lo que sucede con aquellas que ya están en tránsito. Supongamos que un usuario espera lo suficiente para que se envíe una solicitud y luego modifica la consulta:

User searches: react
       ↓
Request A

User changes search: react native
       ↓
Request B

Si la solicitud B regresa antes que la solicitud A, la respuesta más antigua llega por última y reemplaza los resultados más recientes. La interfaz de usuario entonces muestra coincidencias para:

react

aunque ahora el texto de entrada dice:

react native

La solución es cancelar las solicitudes obsoletas con AbortController, integrado sobre el mecanismo de debounce:

Debouncing
    ↓
Reduce unnecessary requests
    ↓
AbortController
    ↓
Cancel outdated requests

Esta condición de carrera se aborda en profundidad en cómo solucionar las condiciones de carrera que el debounce no puede resolver.

Combinar las dos capas

Este flujo mantiene un controller a nivel de módulo. Cada vez que se ejecuta la función con debounce, se cancela la solicitud anterior, se crea un nuevo controller y su signal se pasa a fetch. encodeURIComponent protege los caracteres especiales, y un AbortError se ignora porque indica una cancelación intencional y no un fallo:

const searchInput = document.querySelector("#search");
let controller;
const searchProducts = debounce(async (query) => {
    controller?.abort();
    controller = new AbortController();
    try {
        const response = await fetch(
            `/api/products?q=${encodeURIComponent(query)}`,
            {
                signal: controller.signal
            }
        );
        const data = await response.json();
        renderResults(data);
    } catch (error) {
        if (error.name !== "AbortError") {
            console.error(error);
        }
    }
}, 500);
searchInput.addEventListener("input", (event) => {
    searchProducts(event.target.value);
});

El debounce evita una solicitud por cada tecla pulsada; el AbortController detiene las solicitudes cuyos resultados ya no son relevantes.

En entornos de producción, también se deben omitir las consultas vacías y comprobar response.ok antes de parsear, ya que fetch no rechaza las solicitudes con códigos de error HTTP.

Un modelo de decisión rápido

Cuando un evento se dispara repetidamente, pregúntate si necesitas responder a cada uno. Si no es así, otra pregunta ayuda a decidir. Para la entrada deseas el valor final, por lo que se utiliza debounce:

Input events
    ↓
Do I need every event?
    ↓
No
    ↓
Do I care about the final state?
    ↓
Yes
    ↓
Debounce

Para el desplazamiento deseas actualizaciones regulares durante el gesto, por lo que se utiliza throttle:

Scroll events
    ↓
Do I need every event?
    ↓
No
    ↓
Do I need periodic updates while scrolling?
    ↓
Yes
    ↓
Throttle

Conclusión

El debounce se trata realmente de controlar la frecuencia con la que se ejecutan operaciones costosas, y su mecanismo consta de dos líneas:

clearTimeout(timer);
timer = setTimeout(() => {
    fn();
}, delay);

Úsalo cuando eventos como estos se disparen en ráfagas continuas:

input
resize
search
auto-save

Algunos puntos a tener en cuenta:

  • La función debounce debe crearse una sola vez y reutilizarse; crear una nueva por cada evento (o por cada renderizado de React sin useMemo o useRef) genera un temporizador distinto para cada llamada y desactiva el efecto deseado.
  • Reenviar ...args y this permite que la función auxiliar sea reutilizable de forma segura.
  • Elija debounce para los eventos de “estado final” y throttle para los eventos de “actualizaciones constantes mientras ocurren”.
  • Debounce decide cuándo comienza una solicitud; AbortController decide si se permite que una solicitud obsoleta finalice. Utilice ambos para búsquedas respaldadas por red.
  • Lecturas relacionadas