Funciones reguladoras en JavaScript: cómo limitar el número de veces que se puede llamar a una función cada segundo (throttling y debouncing)

05/08/2018
Artículo original

En muchas ocasiones tenemos la necesidad de ejecutar en una aplicación la misma función JavaScript muchas veces seguidas, incluso sin pretenderlo.

El ejemplo habitual que se suele poner es detectar el redimensionado de una página. Si debes redistribuir y redimensionar mediante código muchos elementos o redibujar un canvas a medida que cambia y que tiene que repintar muchas entidades que el usuario ha definido (en un programa de dibujo, por ejemplo), el proceso que se lleva a cabo en cada cambio de tamaño puede ser largo. Y este evento de redimensionado se llama muchas veces a medida que el usuario modifica el tamaño de la ventana. Pero existen muchas otras situaciones en las que puede ocurrir: encadenar muchas llamadas AJAX en paralelo a un servicio para traer información (p.ej. una búsqueda) a medida que un usuario teclea algo en un cuadro de texto... Cosas por el estilo.

Los motores modernos de JavaScript son capaces de conseguir rendimientos para el código cercanos al del código nativo y, en contra de lo que mucha gente suele pensar, es un lenguaje muy rápido incluso ejecutándolo en un navegador. A pesar de eso en ciertos casos esa llamada indiscriminada a un evento o a una función, si es costosa, puede llegar a bloquear la interfaz de usuario o al menos hacer que ésta no vaya todo lo ágil que debiera. Si se lo permitimos, el navegador hará la llamada todas las veces que sea necesario, acaparando el hilo de ejecución.

En otras ocasiones, aunque no se note ese bloqueo por ser un proceso sencillo y rápido, es una tontería estar llamando 300 veces por segundo a una función si no es necesario. Si con llamarla 5 o 10 veces por segundo es suficiente y los usuarios no van a notar la diferencia, ¿para qué hacer otra cosa?.

Para evitar estos problemas, existen un par de técnicas avanzadas de control denominadas regulación (throttling) y anti-rebote (debouncing, por cierto un término derivado de la electrónica, como muchos otros en programación). Ambas limitan el número de veces que se puede ejecutar una función en un intervalo de tiempo determinado, pero el primero la ejecuta y bloquea la ejecución hasta que pase el intervalo señalado, y la segunda es al revés: no permite que se ejecute hasta que pase como mínimo dicho intervalo (o sea, retrasa la ejecución también) y además mientras se repita no la ejecuta tampoco. En esta página de demo podemos ver la diferencia. La primera es la que más interesante me parece y a la que más casos prácticos le veo, mientras que la segunda la encuentro interesante para casos más restringidos, como por ejemplo no ejecutar un evento keypress hasta que el usuario deje de teclear en un cuadro de texto, y luego ejecutarlo al cabo de x ms.

Nota: quiero agradecer a Pedro J. Molina sus comentarios al respecto de estos matices entre ambos métodos, que había dejado fuera en la versión inicial del artículo y acabo de añadir en el párrafo anterior para mayor claridad a raíz de un comentario suyo en Twitter. ¡Gracias Pedro!

Vamos a ver cómo se suele abordar el problema y luego entraremos en detalle de una solución propia que he creado para la ocasión.

La función clásica de regulación (throttling) y anti-rebote (debouncing)

El método más conocido y replicado hasta la saciedad por Internet de hacer la regulación es el que define la archiconocida biblioteca de utilidades JavaScript Underscore (que te recomiendo conocer porque es muy útil). Esta biblioteca tiene un método llamado debounce() que sirve precisamente para hacer esto. Se usa de la siguiente manera:

var funcionRegulada = _.debounce(funcionNormal, 200);

lo que hace es devolver una versión de la función que queremos llamar que solo se podrá llamar una vez cada 200 milisegundos (es decir 5 veces cada segundo como máximo).

El código que utiliza actualmente lo puedes ver aquí, pero es un poquito más complicado que el original, aunque añade dependencias de otros métodos de underscore.

Es más directo y sencillo el que usaba originalmente hace unos pocos años, que no tenía dependencias en otras funciones propias y era así:

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);
	};
};

No voy a entrar en explicarlo detalladamente porque, como digo, si tienes los fundamentos avanzados de JavaScript claros no es complejo, pero básicamente lo que hace es devolver una función nueva, que sustituye a la original, y que establece un temporizador para ejecutar la verdadera función al cabo del tiempo que se le indique.

Posee un parámetro opcional para forzar la ejecución de la función al principio del intervalo, no al final. Si no lo usamos lo que hace la función es meter un retraso y no permitir la ejecución como mínimo hasta que pase éste, por mucho que sea llamado varias veces antes.

En su versión actual además se pueden cancelar las llamadas reguladas, pero tanto esto como lo anterior no dejan de ser florituras.

En mi opinión complica un poco el uso (yo generalmente lo usaría con true en el tercer parámetro opcional o pierde bastante la gracia).

Mi propia función reguladora (throttling)

Conociendo lo anterior me apetecía intentar mi propia función reguladora que funcionase exactamente cómo yo quería (regulación, no anti-rebote), más fácil de usar (sin tener que pensar en ese tercer parámetro y sus implicaciones) y más obvia sobre lo que está haciendo: bloqueando de verdad la ejecución y limitándola a un determinado número de veces por segundo como máximo, que me parece más natural que indicar el tiempo en milisegundos y decidir si se debe ejecutar antes o después.

Así, he creado la función limitarLLamadas() a la cual se le pasa la función original que queremos limitar y se le indica el número máximo de llamadas que queremos permitir en un segundo.

Ahora explico cómo funciona y te dejo una descarga, pero antes vamos a ver cuál es su efecto.

He creado una página sencilla que tiene mucho texto y que por lo tanto permite hacer scroll arriba y abajo en el contenido (mejor si lo haces estrechando la ventana en caso de que tengas mucha resolución de pantalla). Se detecta y gestiona el evento scroll de la página de modo que cada vez que se mueve el contenido se lleva a cabo una acción como resultado de dicho evento. En este caso simplemente he definido dos funciones: una que cuenta cuántas veces se llama al evento de scroll y la otra que cuenta cuántas veces se llama a una función específica que en este caso no hace nada (solo aumenta un contador) pero que en un caso real sería nuestra función "costosa" que se llama muchas veces. Ambas funciones muestran en una esquinita de la página los contadores, de modo que primero tenemos el número de eventos de scroll que se han lanzado y luego el número de veces que se ha ejecutado la función que nos interesa. Por ejemplo, si pone "100 / 32" significa que el evento de scroll se ha lanzado 100 veces pero que nuestra función (regulada) solo se ha ejecutado 32 veces.

window.addEventListener('scroll', scrollDetectado);
window.addEventListener('scroll', scrollGestionado, 10);

Con esto llamaríamos a las dos funciones cada vez que se haga scroll. La segunda es la que nos interesa limitar. En este caso, al no estar regulada y ser una función normal, se llaman ambas siempre el mismo número de veces. Si lanzamos la página y empezamos a hacer scroll arriba y abajo a toda velocidad con la rueda del ratón veremos esto:

Como podemos apreciar, los dos números del contador siempre son iguales ya que ambas funciones (la que cuenta y la que "gestiona" el evento) se llaman el mismo numero de veces. Y puede llegar a ser muy alto.

Sin embargo si en el código ponemos esto (fíjate en cómo limitamos las llamadas a la función a un máximo de 10 veces por segundo):

window.addEventListener('scroll', scrollDetectado);
window.addEventListener('scroll', limitarLLamadas(scrollGestionado, 10));

...lo que veríamos en este caso es algo similar a lo de este pequeño vídeo:

¿Cómo funciona?

Vamos a echar un vistazo el código de la función que he creado, que como verás no es nada complicado (parece largo pero es por los comentarios explicativos. Sin ellos se queda en nada):

function limitarLLamadas(func, maxXSeg) {
    var bloqueoActivado = false;  //Sirve para indicar que está bloqueada a la función
    
	return function() {
        //Esta función interna simplemente desbloquea las llamadas
		var anularBloqueo = function() {
			bloqueoActivado = false;   //Anulamos el bloqueo
        };

        if (!bloqueoActivado) {
            //Si no hay un bloqueo, llamamos a la función inmediatamente
            func.apply(this, arguments);
            //Bloqueamos
            bloqueoActivado = true;
            //Y desbloqueamos cuando sea necesario para evitar llamadas innecesarias
            setTimeout(anularBloqueo, 1000/maxXSeg);
        }
	};
}

Lo que hace es, en el fondo, parecido a lo que hace underscore (tampoco hay muchas otras formas de plantear la base) en el sentido de que devuelve una nueva función que "envuelve" a la original para dotarla de limitaciones a la hora de llamarla. Pero en este caso utilizo el temporizador solamente para desbloquear después de un tiempo las posibles llamadas a la función original. De este modo se regula muy bien esta limitación y me parece una forma mucho más natural de hacerlo. Aunque puedo estar equivocado, por supuesto.

Se establece una variable booleana para determinar si las llamadas están bloqueadas o no (la primera y la siguiente tras el ultimo bloqueo no lo están nunca, claro), y defino una función anularBloqueo() que, como su propio nombre indica, lo único que hace es establecer la variable anterior de nuevo a false.

La primera vez que se llama a la función se llama al método original y acto seguido se establece un bloqueo (bloqueoActivado = true;). Mientras dure éste no se permiten más llamadas a la función original (debido al if (!bloqueoActivado)). Además se establece un temporizador que llama a la función anularBloqueo() que hemos visto hace un instante para liberar dicho bloqueo al cabo del tiempo apropiado, que se obtiene de dividir el número de milisegundos en un segundo (1000, claro) entre el número máximo de veces que queremos permitir que se llame a la función.

Por cierto, si quisiésemos limitar el máximo de llamadas aún más, por ejemplo a 1 cada dos segundos, podríamos pasarle un decimal. Por ejemplo: limitarLLamadas(scrollGestionado, 0.5) limitaría las llamadas a 1 cada dos segundos.

Parece (y es) más sencillo y creo que funciona muy bien.

Te dejo una descarga (ZIP, 7.24KB) con el ejemplo y un pequeño archivo debounce.js con el código de la función.

¡Espero que te haya resultado interesante y que te sea útil!