JavaScript ofrece una flexibilidad excepcional al tratar con funciones. Se pueden pasar, utilizar como objetos, y ahora veremos cómo desviar llamadas entre ellos y decorarlos .
Digamos que tenemos una función slow(x)
que requiere mucha CPU, pero sus resultados son estables. En otras palabras, para la misma x
siempre devuelve el mismo resultado.
Si la función se llama con frecuencia, es posible que deseemos almacenar en caché (recordar) los resultados para evitar perder tiempo extra en recálculos.
Pero en lugar de agregar esa funcionalidad a slow()
crearemos una función contenedora que agrega almacenamiento en caché. Como veremos, existen muchos beneficios al hacerlo.
Aquí está el código y las explicaciones a continuación:
función lenta (x) { // aquí puede haber un trabajo que requiera un uso intensivo de la CPU alert(`Llamado con ${x}`); devolver x; } función cachéDecorator(func) { dejar caché = nuevo mapa(); función de retorno (x) { if (cache.has(x)) { // si existe dicha clave en el caché devolver caché.get(x); // lee el resultado } dejar resultado = func(x); // de lo contrario llama a func cache.set(x, resultado); // y almacenar en caché (recordar) el resultado resultado de devolución; }; } lento = almacenamiento en cachéDecorator(lento); alerta (lento (1)); // slow(1) se almacena en caché y se devuelve el resultado alerta( "Otra vez: " + lento(1) ); // resultado lento(1) devuelto desde la caché alerta (lento (2)); // slow(2) se almacena en caché y se devuelve el resultado alerta( "Otra vez: " + lento(2) ); // resultado lento(2) devuelto desde la caché
En el código anterior, cachingDecorator
es un decorador : una función especial que toma otra función y altera su comportamiento.
La idea es que podemos llamar cachingDecorator
para cualquier función y devolverá el contenedor de almacenamiento en caché. Eso es genial, porque podemos tener muchas funciones que podrían usar dicha característica, y todo lo que tenemos que hacer es aplicarles cachingDecorator
.
Al separar el almacenamiento en caché del código de la función principal, también mantenemos el código principal más simple.
El resultado de cachingDecorator(func)
es un “contenedor”: function(x)
que “envuelve” la llamada de func(x)
en la lógica de almacenamiento en caché:
Desde un código externo, la función slow
envuelta sigue haciendo lo mismo. Se acaba de agregar un aspecto de almacenamiento en caché a su comportamiento.
Para resumir, existen varios beneficios al usar un cachingDecorator
separado en lugar de alterar el código slow
en sí:
El cachingDecorator
es reutilizable. Podemos aplicarlo a otra función.
La lógica del almacenamiento en caché es separada, no aumentó la complejidad de slow
en sí (si la hubiera).
Podemos combinar varios decoradores si es necesario (le seguirán otros decoradores).
El decorador de caché mencionado anteriormente no es adecuado para trabajar con métodos de objetos.
Por ejemplo, en el siguiente código, worker.slow()
deja de funcionar después de la decoración:
// haremos que el trabajo sea un almacenamiento en caché lento dejar trabajador = { algún método() { devolver 1; }, lento(x) { // tarea aterradora que requiere mucha CPU aquí alerta("Llamado con " + x); devolver x * this.someMethod(); // (*) } }; //mismo código que antes función cachéDecorator(func) { dejar caché = nuevo mapa(); función de retorno (x) { si (caché.tiene(x)) { devolver caché.get(x); } dejar resultado = func(x); // (**) cache.set(x, resultado); resultado de devolución; }; } alerta( trabajador.lento(1) ); // el método original funciona trabajador.slow = almacenamiento en cachéDecorator(trabajador.slow); // ahora hazlo almacenamiento en caché alerta( trabajador.lento(2) ); // ¡Vaya! Error: no se puede leer la propiedad 'algún método' de indefinido
El error ocurre en la línea (*)
que intenta acceder a this.someMethod
y falla. ¿Puedes ver por qué?
La razón es que el contenedor llama a la función original como func(x)
en la línea (**)
. Y, cuando se llama así, la función obtiene this = undefined
.
Observaríamos un síntoma similar si intentáramos ejecutar:
let func = trabajador.slow; función(2);
Entonces, el contenedor pasa la llamada al método original, pero sin el contexto this
. De ahí el error.
Arreglemoslo.
Hay un método de función incorporado especial func.call(context,…args) que permite llamar a una función que configura explícitamente this
.
La sintaxis es:
func.call(contexto, arg1, arg2, ...)
Ejecuta func
proporcionando el primer argumento como this
y el siguiente como argumentos.
En pocas palabras, estas dos llamadas hacen casi lo mismo:
función(1, 2, 3); función.llamada(obj, 1, 2, 3)
Ambos llaman func
con los argumentos 1
, 2
y 3
. La única diferencia es que func.call
también this
establece en obj
.
Como ejemplo, en el código siguiente llamamos sayHi
en el contexto de diferentes objetos: sayHi.call(user)
ejecuta sayHi
proporcionando this=user
y la siguiente línea establece this=admin
:
función decir Hola() { alerta(este.nombre); } dejar usuario = { nombre: "Juan" }; let admin = { nombre: "Administrador" }; // usa llamada para pasar diferentes objetos como "esto" decirHola.llamar(usuario); // John decir Hola.llamar (administrador); // administrador
Y aquí usamos call
to call say
con el contexto y la frase dados:
función decir (frase) { alerta(este.nombre + ': ' + frase); } dejar usuario = { nombre: "Juan" }; // el usuario se convierte en esto y "Hola" se convierte en el primer argumento say.call(usuario, "Hola"); // Juan: Hola
En nuestro caso, podemos usar call
en el contenedor para pasar el contexto a la función original:
dejar trabajador = { algún método() { devolver 1; }, lento(x) { alerta("Llamado con " + x); devolver x * this.someMethod(); // (*) } }; función cachéDecorator(func) { dejar caché = nuevo mapa(); función de retorno (x) { si (caché.tiene(x)) { devolver caché.get(x); } let resultado = func.call(this, x); // "esto" se pasó correctamente ahora cache.set(x, resultado); resultado de devolución; }; } trabajador.slow = almacenamiento en cachéDecorator(trabajador.slow); // ahora hazlo almacenamiento en caché alerta( trabajador.lento(2) ); // obras alerta( trabajador.lento(2) ); // funciona, no llama al original (en caché)
Ahora todo está bien.
Para que quede todo claro, veamos más profundamente cómo se transmite this
:
Después de la decoración, worker.slow
ahora está la function (x) { ... }
.
Entonces, cuando se ejecuta worker.slow(2)
, el contenedor obtiene 2
como argumento y this=worker
(es el objeto antes del punto).
Dentro del contenedor, asumiendo que el resultado aún no está almacenado en caché, func.call(this, x)
pasa el this
actual ( =worker
) y el argumento actual ( =2
) al método original.
Ahora hagamos que cachingDecorator
sea aún más universal. Hasta ahora solo funcionaba con funciones de un solo argumento.
Ahora, ¿cómo almacenar en caché el método worker.slow
de múltiples argumentos?
dejar trabajador = { lento(mín, máx) { devolver mínimo + máximo; // se supone que el aterrador acaparador de CPU } }; // debería recordar las llamadas con el mismo argumento trabajador.slow = almacenamiento en cachéDecorator(trabajador.slow);
Anteriormente, para un único argumento x
podíamos simplemente cache.set(x, result)
para guardar el resultado y cache.get(x)
para recuperarlo. Pero ahora necesitamos recordar el resultado de una combinación de argumentos (min,max)
. El Map
nativo toma un valor único solo como clave.
Hay muchas soluciones posibles:
Implemente una nueva estructura de datos similar a un mapa (o utilice una de terceros) que sea más versátil y permita múltiples claves.
Utilice mapas anidados: cache.set(min)
será un Map
que almacena el par (max, result)
. Entonces podemos obtener result
como cache.get(min).get(max)
.
Une dos valores en uno. En nuestro caso particular podemos usar una cadena "min,max"
como clave Map
. Para mayor flexibilidad, podemos permitir proporcionar una función hash para el decorador, que sabe cómo generar un valor a partir de muchos.
Para muchas aplicaciones prácticas, la tercera variante es suficiente, así que nos atendremos a ella.
También necesitamos pasar no solo x
, sino todos los argumentos en func.call
. Recordemos que en una function()
podemos obtener una pseudomatriz de sus argumentos como arguments
, por lo que func.call(this, x)
debe reemplazarse con func.call(this, ...arguments)
.
Aquí hay un cachingDecorator
más potente:
dejar trabajador = { lento(mín, máx) { alert(`Llamado con ${min},${max}`); devolver mínimo + máximo; } }; función cachéDecorator(func, hash) { dejar caché = nuevo mapa(); función de retorno() { let clave = hash(argumentos); // (*) si (cache.tiene (clave)) { devolver cache.get(clave); } let resultado = func.call(this, ...argumentos); // (**) cache.set(clave, resultado); resultado de devolución; }; } función hash(argumentos) { devolver argumentos[0] + ',' + argumentos[1]; } trabajador.slow = cachingDecorator(trabajador.slow, hash); alerta( trabajador.lento(3, 5) ); // obras alerta( "Otra vez " + trabajador.slow(3, 5) ); // mismo (en caché)
Ahora funciona con cualquier número de argumentos (aunque la función hash también debería ajustarse para permitir cualquier número de argumentos. A continuación se cubrirá una forma interesante de manejar esto).
Hay dos cambios:
En la línea (*)
llama hash
para crear una clave única a partir de arguments
. Aquí usamos una función simple de "unión" que convierte los argumentos (3, 5)
en la clave "3,5"
. Los casos más complejos pueden requerir otras funciones hash.
Luego (**)
usa func.call(this, ...arguments)
para pasar tanto el contexto como todos los argumentos que obtuvo el contenedor (no solo el primero) a la función original.
En lugar de func.call(this, ...arguments)
podríamos usar func.apply(this, arguments)
.
La sintaxis del método integrado func.apply es:
func.apply(contexto, argumentos)
Ejecuta la func
configurando this=context
y usando args
de objeto similar a una matriz como lista de argumentos.
La única diferencia de sintaxis entre call
y apply
es que call
espera una lista de argumentos, mientras que apply
lleva consigo un objeto similar a una matriz.
Entonces estas dos llamadas son casi equivalentes:
func.call(contexto, ...args); func.apply(contexto, argumentos);
Realizan la misma llamada de func
con contexto y argumentos dados.
Sólo hay una diferencia sutil con respecto a args
:
La sintaxis extendida ...
permite pasar args
iterables como lista a call
.
La apply
solo acepta args
tipo matriz .
…Y para objetos que son iterables y similares a una matriz, como una matriz real, podemos usar cualquiera de ellos, pero apply
probablemente será más rápido, porque la mayoría de los motores de JavaScript lo optimizan internamente mejor.
Pasar todos los argumentos junto con el contexto a otra función se llama desvío de llamadas .
Esa es la forma más simple:
dejar envoltura = función() { return func.apply(esto, argumentos); };
Cuando un código externo llama a dicho wrapper
, no se puede distinguir de la llamada de la función original func
.
Ahora hagamos una pequeña mejora más en la función hash:
función hash(argumentos) { devolver argumentos[0] + ',' + argumentos[1]; }
Por ahora, sólo funciona con dos argumentos. Sería mejor si pudiera pegar cualquier número de args
.
La solución natural sería utilizar el método arr.join:
función hash(argumentos) { devolver args.join(); }
…Desafortunadamente, eso no funcionará. Porque estamos llamando hash(arguments)
y el objeto arguments
es iterable y similar a una matriz, pero no es una matriz real.
Entonces, llamar join
fallaría, como podemos ver a continuación:
función hash() { alerta( argumentos.join() ); // Error: arguments.join no es una función } hash(1, 2);
Aun así, existe una forma sencilla de utilizar la unión de matrices:
función hash() { alerta( [].join.call(argumentos) ); // 1,2 } hash(1, 2);
El truco se llama préstamo de métodos .
Tomamos (pedimos prestado) un método de unión de una matriz normal ( [].join
) y usamos [].join.call
para ejecutarlo en el contexto de arguments
.
¿Por qué funciona?
Esto se debe a que el algoritmo interno del método nativo arr.join(glue)
es muy simple.
Tomado de la especificación casi "tal cual":
Deje que glue
sea el primer argumento o, si no hay argumentos, entonces una coma ","
.
Sea result
una cadena vacía.
Agregue this[0]
al result
.
Añade glue
y this[1]
.
Añade glue
y this[2]
.
…Hágalo hasta que los artículos this.length
estén pegados.
result
de retorno.
Entonces, técnicamente toma this
y une this[0]
, this[1]
…etc. Está escrito intencionalmente de una manera que permite cualquier matriz como this
(no es una coincidencia, muchos métodos siguen esta práctica). Por eso también funciona con this=arguments
.
Generalmente es seguro reemplazar una función o un método por uno decorado, excepto por una pequeña cosa. Si la función original tenía propiedades, como func.calledCount
o lo que sea, entonces la decorada no las proporcionará. Porque eso es un envoltorio. Por eso hay que tener cuidado si se utilizan.
Por ejemplo, en el ejemplo anterior, si la función slow
tenía alguna propiedad, entonces cachingDecorator(slow)
es un contenedor sin ellas.
Algunos decoradores pueden ofrecer sus propias propiedades. Por ejemplo, un decorador puede contar cuántas veces se invocó una función y cuánto tiempo tomó, y exponer esta información a través de propiedades contenedoras.
Existe una forma de crear decoradores que mantienen el acceso a las propiedades de la función, pero esto requiere el uso de un objeto Proxy
especial para encapsular una función. Lo discutiremos más adelante en el artículo Proxy y Reflect.
Decorador es un envoltorio alrededor de una función que altera su comportamiento. El trabajo principal todavía lo realiza la función.
Los decoradores pueden verse como "características" o "aspectos" que se pueden agregar a una función. Podemos agregar uno o agregar muchos. ¡Y todo ello sin cambiar su código!
Para implementar cachingDecorator
, estudiamos métodos:
func.call(context, arg1, arg2…): llama func
con el contexto y los argumentos dados.
func.apply(context, args): llama func
pasando context
como this
y args
tipo matriz en una lista de argumentos.
El desvío de llamadas genérico se suele realizar con apply
:
dejar envoltura = función() { devolver original.apply(esto, argumentos); };
También vimos un ejemplo de préstamo de métodos cuando tomamos un método de un objeto y lo call
en el contexto de otro objeto. Es bastante común tomar métodos de matriz y aplicarlos a arguments
. La alternativa es utilizar un objeto de parámetros de descanso que sea una matriz real.
Hay muchos decoradores en la naturaleza. Comprueba qué tan bien los obtuviste resolviendo las tareas de este capítulo.
importancia: 5
Cree un decorador spy(func)
que debería devolver un contenedor que guarde todas las llamadas para funcionar en su propiedad calls
.
Cada llamada se guarda como una serie de argumentos.
Por ejemplo:
función trabajo(a, b) { alerta (a + b); // el trabajo es una función o método arbitrario } trabajo = espiar(trabajo); trabajo(1, 2); // 3 trabajo(4, 5); // 9 for (dejemos argumentos de work.calls) { alerta ('llamar:' + args.join()); // "llamar:1,2", "llamar:4,5" }
PD: Ese decorador a veces es útil para pruebas unitarias. Su forma avanzada es sinon.spy
en la biblioteca Sinon.JS.
Abra una caja de arena con pruebas.
El contenedor devuelto por spy(f)
debe almacenar todos los argumentos y luego usar f.apply
para reenviar la llamada.
función espía (func) { contenedor de funciones (... argumentos) { // usando ...args en lugar de argumentos para almacenar una matriz "real" en wrapper.calls envoltura.calls.push(args); return func.apply(this, args); } envoltura.calls = []; envoltorio de devolución; }
Abra la solución con pruebas en un sandbox.
importancia: 5
Cree un delay(f, ms)
que retrase cada llamada de f
en ms
milisegundos.
Por ejemplo:
función f(x) { alerta(x); } // crear envoltorios sea f1000 = retraso(f, 1000); sea f1500 = retraso(f, 1500); f1000("prueba"); // muestra "prueba" después de 1000 ms f1500("prueba"); // muestra "prueba" después de 1500 ms
En otras palabras, delay(f, ms)
devuelve una variante "retrasada por ms
" de f
.
En el código anterior, f
es una función de un único argumento, pero su solución debe pasar todos los argumentos y el contexto this
.
Abra una caja de arena con pruebas.
La solución:
retardo de función (f, ms) { función de retorno() { setTimeout(() => f.apply(esto, argumentos), ms); }; } let f1000 = retraso(alerta, 1000); f1000("prueba"); // muestra "prueba" después de 1000 ms
Tenga en cuenta cómo se utiliza aquí una función de flecha. Como sabemos, las funciones de flecha no tienen arguments
this
y propios, por lo que f.apply(this, arguments)
toma this
y arguments
del contenedor.
Si pasamos una función normal, setTimeout
la llamará sin argumentos y this=window
(asumiendo que estamos en el navegador).
Todavía podemos pasar this
a la derecha usando una variable intermedia, pero eso es un poco más engorroso:
retardo de función (f, ms) { función de retorno (... argumentos) { let guardadoEsto = esto; // almacena esto en una variable intermedia setTimeout(función() { f.apply(savedThis, argumentos); // úsalo aquí }, EM); }; }
Abra la solución con pruebas en un sandbox.
importancia: 5
El resultado del decorador debounce(f, ms)
es un contenedor que suspende las llamadas a f
hasta que haya ms
milisegundos de inactividad (sin llamadas, "período de recuperación"), luego invoca f
una vez con los últimos argumentos.
En otras palabras, debounce
es como una secretaria que acepta “llamadas telefónicas” y espera hasta que haya ms
milisegundos de silencio. Y solo entonces transfiere la información de la última llamada al "jefe" (llama al f
real).
Por ejemplo, teníamos una función f
y la reemplazamos con f = debounce(f, 1000)
.
Luego, si la función encapsulada se llama a 0 ms, 200 ms y 500 ms, y luego no hay llamadas, entonces la f
real solo se llamará una vez, a 1500 ms. Es decir: después del período de recuperación de 1000 ms desde la última llamada.
…Y obtendrá los argumentos de la última llamada, las demás llamadas se ignoran.
Aquí está el código (usa el decorador antirrebote de la biblioteca Lodash):
let f = _.debounce(alerta, 1000); fa"); setTimeout( () => f("b"), 200); setTimeout( () => f("c"), 500); // la función antirrebote espera 1000 ms después de la última llamada y luego ejecuta: alert("c")
Ahora un ejemplo práctico. Digamos que el usuario escribe algo y nos gustaría enviar una solicitud al servidor cuando finalice la entrada.
No tiene sentido enviar la solicitud por cada carácter escrito. En lugar de eso, nos gustaría esperar y luego procesar todo el resultado.
En un navegador web, podemos configurar un controlador de eventos, una función que se llama con cada cambio de un campo de entrada. Normalmente, se llama a un controlador de eventos con mucha frecuencia para cada clave escrita. Pero si lo debounce
1000 ms, solo se llamará una vez, después de 1000 ms después de la última entrada.
En este ejemplo en vivo, el controlador coloca el resultado en un cuadro a continuación, pruébelo:
¿Ver? La segunda entrada llama a la función antirrebote, por lo que su contenido se procesa después de 1000 ms desde la última entrada.
Por lo tanto, debounce
es una excelente manera de procesar una secuencia de eventos: ya sea una secuencia de pulsaciones de teclas, movimientos del mouse o cualquier otra cosa.
Espera el tiempo dado después de la última llamada y luego ejecuta su función, que puede procesar el resultado.
La tarea es implementar un decorador debounce
.
Pista: si lo piensas bien, son sólo unas pocas líneas :)
Abra una caja de arena con pruebas.
función rebote(func, ms) { dejar que se agote el tiempo; función de retorno() { clearTimeout(tiempo de espera); timeout = setTimeout(() => func.apply(this, argumentos), ms); }; }
Una llamada para debounce
devuelve un envoltorio. Cuando se llama, programa la llamada a la función original después de ms
determinado y cancela el tiempo de espera anterior.
Abra la solución con pruebas en un sandbox.
importancia: 5
Cree un decorador de "estrangulación" throttle(f, ms)
, que devuelva un contenedor.
Cuando se llama varias veces, pasa la llamada a f
como máximo una vez por ms
milisegundos.
Comparado con el decorador antirrebote, el comportamiento es completamente diferente:
debounce
ejecuta la función una vez después del período de "reutilización". Bueno para procesar el resultado final.
throttle
no lo ejecuta con más frecuencia que el tiempo ms
. Bueno para actualizaciones periódicas que no deberían ser muy frecuentes.
En otras palabras, throttle
es como una secretaria que acepta llamadas telefónicas, pero molesta al jefe (llama a la f
real) no más de una vez por ms
milisegundos.
Revisemos la aplicación de la vida real para comprender mejor ese requisito y ver de dónde viene.
Por ejemplo, queremos rastrear los movimientos del mouse.
En un navegador podemos configurar una función para que se ejecute con cada movimiento del mouse y obtener la ubicación del puntero a medida que se mueve. Durante el uso activo del mouse, esta función generalmente se ejecuta con mucha frecuencia, puede ser aproximadamente 100 veces por segundo (cada 10 ms). Nos gustaría actualizar cierta información en la página web cuando se mueve el puntero.
…Pero actualizar la función update()
es demasiado pesado para hacerlo en cada micromovimiento. Tampoco tiene sentido actualizar más de una vez cada 100 ms.
Así que lo incluiremos en el decorador: use throttle(update, 100)
como función para ejecutar en cada movimiento del mouse en lugar de la update()
original. Se llamará al decorador con frecuencia, pero se reenviará la llamada a update()
como máximo una vez cada 100 ms.
Visualmente se verá así:
Para el primer movimiento del mouse, la variante decorada pasa inmediatamente la llamada a update
. Eso es importante, el usuario ve inmediatamente nuestra reacción a su movimiento.
Luego, a medida que avanza el mouse, hasta 100ms
no pasa nada. La variante decorada ignora las llamadas.
Al final de 100ms
, se produce una update
más con las últimas coordenadas.
Entonces, finalmente, el ratón se detiene en algún lugar. La variante decorada espera hasta que expiren 100ms
y luego ejecuta update
con las últimas coordenadas. Entonces, algo muy importante, se procesan las coordenadas finales del mouse.
Un ejemplo de código:
función f(a) { consola.log(a); } // f1000 pasa llamadas a f como máximo una vez cada 1000 ms sea f1000 = acelerador(f, 1000); f1000(1); // muestra 1 f1000(2); // (aceleración, 1000 ms aún no han llegado) f1000(3); // (aceleración, 1000 ms aún no han llegado) // cuando se agota el tiempo de 1000 ms... // ...salidas 3, el valor intermedio 2 fue ignorado
Los argumentos de PS y el contexto this
se pasó a f1000
deben pasarse al f
original.
Abra una caja de arena con pruebas.
función acelerador(func, ms) { let isThrottled = falso, argumentos guardados, guardado esto; contenedor de función() { si (está acelerado) { // (2) saveArgs = argumentos; guardadoEsto = esto; devolver; } está acelerado = verdadero; func.apply(esto, argumentos); // (1) setTimeout(función() { está acelerado = falso; // (3) si (argumentos guardados) { wrapper.apply(savedThis, saveArgs); saveArgs = saveThis = null; } }, EM); } envoltorio de devolución; }
Una llamada a throttle(func, ms)
devuelve wrapper
.
Durante la primera llamada, el wrapper
simplemente ejecuta func
y establece el estado de enfriamiento ( isThrottled = true
).
En este estado, todas las llamadas se memorizan en savedArgs/savedThis
. Tenga en cuenta que tanto el contexto como los argumentos son igualmente importantes y deben memorizarse. Los necesitamos simultáneamente para reproducir la llamada.
Después de que pasan ms
milisegundos, setTimeout
se activa. Se elimina el estado de enfriamiento ( isThrottled = false
) y, si habíamos ignorado las llamadas, wrapper
se ejecuta con los últimos argumentos y contexto memorizados.
El tercer paso no ejecuta func
, sino wrapper
, porque no solo necesitamos ejecutar func
, sino que una vez más ingresamos al estado de enfriamiento y configuramos el tiempo de espera para restablecerlo.
Abra la solución con pruebas en un sandbox.