JavaScript oferece flexibilidade excepcional ao lidar com funções. Eles podem ser repassados, usados como objetos, e agora veremos como encaminhar chamadas entre eles e decorá- los.
Digamos que temos uma função slow(x)
que exige muita CPU, mas seus resultados são estáveis. Em outras palavras, para o mesmo x
retorna sempre o mesmo resultado.
Se a função for chamada com frequência, podemos querer armazenar em cache (lembrar) os resultados para evitar gastar tempo extra em recálculos.
Mas em vez de adicionar essa funcionalidade em slow()
criaremos uma função wrapper, que adiciona cache. Como veremos, há muitos benefícios em fazer isso.
Aqui está o código e as explicações a seguir:
função lenta(x) { // pode haver um trabalho pesado e intensivo de CPU aqui alert(`Chamado com ${x}`); retornar x; } função cacheDecorator(func) { deixe cache = novo Mapa(); função de retorno(x) { if (cache.has(x)) { // se existe tal chave no cache retornar cache.get(x); //leia o resultado dele } deixe resultado = func(x); // caso contrário, chama func cache.set(x,resultado); // e armazena em cache (lembra) o resultado resultado de retorno; }; } lento = cacheDecorator(lento); alerta(lento(1)); // slow(1) é armazenado em cache e o resultado retornado alert("Novamente: " + lento(1) ); // resultado lento(1) retornado do cache alerta(lento(2)); // slow(2) é armazenado em cache e o resultado retornado alert("Novamente: " + lento(2) ); // resultado lento(2) retornado do cache
No código acima cachingDecorator
é um decorador : uma função especial que pega outra função e altera seu comportamento.
A ideia é que possamos chamar cachingDecorator
para qualquer função e ele retornará o wrapper de cache. Isso é ótimo, porque podemos ter muitas funções que poderiam usar esse recurso, e tudo o que precisamos fazer é aplicar cachingDecorator
a elas.
Ao separar o cache do código da função principal, também mantemos o código principal mais simples.
O resultado de cachingDecorator(func)
é um “wrapper”: function(x)
que “envolve” a chamada de func(x)
na lógica de cache:
De um código externo, a função slow
encapsulada ainda faz o mesmo. Ele acabou de adicionar um aspecto de cache ao seu comportamento.
Para resumir, há vários benefícios em usar um cachingDecorator
separado em vez de alterar o próprio código slow
:
O cachingDecorator
é reutilizável. Podemos aplicá-lo a outra função.
A lógica de cache é separada, não aumentou a complexidade da slow
em si (se houver).
Podemos combinar vários decoradores, se necessário (outros decoradores seguirão).
O decorador de cache mencionado acima não é adequado para trabalhar com métodos de objetos.
Por exemplo, no código abaixo worker.slow()
para de funcionar após a decoração:
// faremos o cache do worker.slow deixe trabalhador = { algumMetodo() { retornar 1; }, lento(x) { // tarefa assustadora com muita CPU aqui alert("Chamado com " + x); return x * this.someMethod(); // (*) } }; //mesmo código de antes função cacheDecorator(func) { deixe cache = novo Mapa(); função de retorno(x) { if (cache.has(x)) { retornar cache.get(x); } deixe resultado = func(x); // (**) cache.set(x,resultado); resultado de retorno; }; } alerta(trabalhador.slow(1)); // o método original funciona trabalhador.lento = cachingDecorator(trabalhador.lento); // agora faça o cache alerta(trabalhador.slow(2)); // Opa! Erro: Não é possível ler a propriedade 'someMethod' de indefinido
O erro ocorre na linha (*)
que tenta acessar this.someMethod
e falha. Você pode ver por quê?
A razão é que o wrapper chama a função original como func(x)
na linha (**)
. E, quando chamada assim, a função obtém this = undefined
.
Observaríamos um sintoma semelhante se tentássemos executar:
deixe func = trabalhador.slow; função(2);
Assim, o wrapper passa a chamada para o método original, mas sem o contexto this
. Daí o erro.
Vamos consertar isso.
Existe um método de função especial integrado func.call(context, …args) que permite chamar uma função definindo explicitamente this
.
A sintaxe é:
func.call(contexto, arg1, arg2, ...)
Ele executa func
fornecendo o primeiro argumento como this
e o próximo como os argumentos.
Simplificando, essas duas chamadas fazem quase a mesma coisa:
função(1, 2, 3); func.call(obj, 1, 2, 3)
Ambos chamam func
com argumentos 1
, 2
e 3
. A única diferença é que func.call
também define this
como obj
.
Como exemplo, no código abaixo chamamos sayHi
no contexto de diferentes objetos: sayHi.call(user)
executa sayHi
fornecendo this=user
, e a próxima linha define this=admin
:
function digaOi() { alerta(este.nome); } deixe usuário = {nome: "John" }; deixe admin = {nome: "Admin" }; // usa call para passar objetos diferentes como "this" digaOi.call(usuário); // John digaOi.call(admin); //Administrador
E aqui usamos call
to call say
com o contexto e a frase fornecidos:
função dizer(frase) { alert(this.name + ': ' + frase); } deixe usuário = {nome: "John" }; // usuário se torna isso e "Hello" se torna o primeiro argumento say.call(usuário, "Olá"); //João: Olá
No nosso caso, podemos usar call
no wrapper para passar o contexto para a função original:
deixe trabalhador = { algumMetodo() { retornar 1; }, lento(x) { alert("Chamado com " + x); return x * this.someMethod(); // (*) } }; função cacheDecorator(func) { deixe cache = novo Mapa(); função de retorno(x) { if (cache.has(x)) { retornar cache.get(x); } deixe resultado = func.call(this, x); // "this" foi passado corretamente agora cache.set(x,resultado); resultado de retorno; }; } trabalhador.lento = cachingDecorator(trabalhador.lento); // agora faça o cache alerta(trabalhador.slow(2)); // funciona alerta(trabalhador.slow(2)); // funciona, não chama o original (em cache)
Agora está tudo bem.
Para deixar tudo claro, vamos ver mais a fundo como this
é repassado:
Após a decoração, worker.slow
agora é a function (x) { ... }
.
Então quando worker.slow(2)
é executado, o wrapper recebe 2
como argumento e this=worker
(é o objeto antes do ponto).
Dentro do wrapper, assumindo que o resultado ainda não está armazenado em cache, func.call(this, x)
passa o this
atual ( =worker
) e o argumento atual ( =2
) para o método original.
Agora vamos tornar cachingDecorator
ainda mais universal. Até agora funcionava apenas com funções de argumento único.
Agora, como armazenar em cache o método worker.slow
com vários argumentos?
deixe trabalhador = { lento(mínimo, máximo) { retornar mínimo + máximo; // um assustador consumo de CPU é assumido } }; // deve lembrar chamadas com o mesmo argumento trabalhador.lento = cachingDecorator(trabalhador.lento);
Anteriormente, para um único argumento x
poderíamos apenas cache.set(x, result)
para salvar o resultado e cache.get(x)
para recuperá-lo. Mas agora precisamos lembrar o resultado de uma combinação de argumentos (min,max)
. O Map
nativo usa um valor único apenas como chave.
Existem muitas soluções possíveis:
Implemente uma nova estrutura de dados semelhante a um mapa (ou use uma de terceiros) que seja mais versátil e permita múltiplas chaves.
Use mapas aninhados: cache.set(min)
será um Map
que armazena o par (max, result)
. Portanto, podemos obter result
como cache.get(min).get(max)
.
Junte dois valores em um. No nosso caso particular, podemos apenas usar uma string "min,max"
como chave Map
. Para maior flexibilidade, podemos fornecer uma função hash para o decorador, que sabe como criar um valor entre muitos.
Para muitas aplicações práticas, a terceira variante é boa o suficiente, por isso vamos nos ater a ela.
Também precisamos passar não apenas x
, mas todos os argumentos em func.call
. Vamos lembrar que em uma function()
podemos obter um pseudo-array de seus argumentos como arguments
, então func.call(this, x)
deve ser substituído por func.call(this, ...arguments)
.
Aqui está um cachingDecorator
mais poderoso:
deixe trabalhador = { lento(mínimo, máximo) { alert(`Chamado com ${min},${max}`); retornar mínimo + máximo; } }; function cachingDecorator(func, hash) { deixe cache = novo Mapa(); função de retorno() { deixe chave = hash(argumentos); // (*) if (cache.has(chave)) { retornar cache.get(chave); } deixe resultado = func.call(this, ...arguments); // (**) cache.set(chave, resultado); resultado de retorno; }; } função hash(argumentos) { retornar args[0] + ',' + args[1]; } trabalhador.lento = cachingDecorator(trabalhador.lento, hash); alerta(trabalhador.slow(3, 5)); // funciona alert("Novamente " + trabalhador.slow(3, 5) ); // mesmo (em cache)
Agora ele funciona com qualquer número de argumentos (embora a função hash também precise ser ajustada para permitir qualquer número de argumentos. Uma maneira interessante de lidar com isso será abordada abaixo).
Existem duas mudanças:
Na linha (*)
ele chama hash
para criar uma única chave a partir de arguments
. Aqui usamos uma função simples de “junção” que transforma os argumentos (3, 5)
na chave "3,5"
. Casos mais complexos podem exigir outras funções de hashing.
Então (**)
usa func.call(this, ...arguments)
para passar o contexto e todos os argumentos que o wrapper obteve (não apenas o primeiro) para a função original.
Em vez de func.call(this, ...arguments)
poderíamos usar func.apply(this, arguments)
.
A sintaxe do método integrado func.apply é:
func.apply(contexto, argumentos)
Ele executa a configuração func
this=context
e usa um objeto semelhante a um array args
como lista de argumentos.
A única diferença de sintaxe entre call
e apply
é que call
espera uma lista de argumentos, enquanto apply
leva consigo um objeto semelhante a um array.
Portanto, essas duas chamadas são quase equivalentes:
func.call(contexto, ...args); func.apply(contexto, argumentos);
Eles executam a mesma chamada de func
com determinados contextos e argumentos.
Há apenas uma diferença sutil em relação args
:
A sintaxe de propagação ...
permite passar args
iteráveis como a lista a ser call
.
O apply
aceita apenas args
do tipo array .
…E para objetos que são iteráveis e semelhantes a array, como um array real, podemos usar qualquer um deles, mas apply
provavelmente será mais rápido, porque a maioria dos mecanismos JavaScript o otimizam melhor internamente.
Passar todos os argumentos junto com o contexto para outra função é chamado de encaminhamento de chamada .
Essa é a forma mais simples:
deixe wrapper = function() { return func.apply(este, argumentos); };
Quando um código externo chama esse wrapper
, ele é indistinguível da chamada da função original func
.
Agora vamos fazer mais uma pequena melhoria na função hash:
função hash(argumentos) { retornar args[0] + ',' + args[1]; }
A partir de agora, funciona apenas com dois argumentos. Seria melhor se pudesse colar qualquer número de args
.
A solução natural seria usar o método arr.join:
função hash(argumentos) { retornar args.join(); }
…Infelizmente, isso não funcionará. Porque estamos chamando hash(arguments)
e o objeto arguments
é iterável e semelhante a um array, mas não um array real.
Portanto, chamar join
falharia, como podemos ver abaixo:
função hash() { alerta(argumentos.join()); // Erro: argumentos.join não é uma função } hash(1, 2);
Ainda assim, há uma maneira fácil de usar a junção de array:
função hash() { alerta( [].join.call(argumentos) ); //1,2 } hash(1, 2);
O truque é chamado de empréstimo de método .
Pegamos (emprestar) um método join de um array regular ( [].join
) e usamos [].join.call
para executá-lo no contexto de arguments
.
Por que isso funciona?
Isso porque o algoritmo interno do método nativo arr.join(glue)
é muito simples.
Retirado da especificação quase “como está”:
Seja glue
o primeiro argumento ou, se não houver argumentos, uma vírgula ","
.
Seja result
uma string vazia.
Anexe this[0]
ao result
.
Anexe glue
e this[1]
.
Anexe glue
e this[2]
.
…Faça isso até que os itens this.length
estejam colados.
result
de retorno.
Então, tecnicamente, é preciso this
e juntar this[0]
, this[1]
…etc. É intencionalmente escrito de uma forma que permite qualquer array como this
(não é uma coincidência, muitos métodos seguem esta prática). É por isso que também funciona com this=arguments
.
Geralmente é seguro substituir uma função ou método por um decorado, exceto por uma pequena coisa. Se a função original contivesse propriedades, como func.calledCount
ou qualquer outra, então a função decorada não as fornecerá. Porque isso é um invólucro. Portanto, é preciso ter cuidado ao usá-los.
Por exemplo, no exemplo acima, se a função slow
tiver alguma propriedade, então cachingDecorator(slow)
será um wrapper sem elas.
Alguns decoradores podem fornecer suas próprias propriedades. Por exemplo, um decorador pode contar quantas vezes uma função foi invocada e quanto tempo demorou, e expor esta informação através das propriedades do wrapper.
Existe uma maneira de criar decoradores que mantêm acesso às propriedades da função, mas isso requer o uso de um objeto Proxy
especial para agrupar uma função. Discutiremos isso mais tarde no artigo Proxy e Reflect.
Decorator é um wrapper em torno de uma função que altera seu comportamento. O trabalho principal ainda é realizado pela função.
Os decoradores podem ser vistos como “recursos” ou “aspectos” que podem ser adicionados a uma função. Podemos adicionar um ou muitos. E tudo isso sem alterar seu código!
Para implementar cachingDecorator
, estudamos métodos:
func.call(context, arg1, arg2…) – chama func
com determinados contexto e argumentos.
func.apply(context, args) – chama func
passando context
como this
e args
semelhantes a array em uma lista de argumentos.
O encaminhamento genérico de chamadas geralmente é feito com apply
:
deixe wrapper = function() { return original.apply(este, argumentos); };
Também vimos um exemplo de empréstimo de método quando pegamos um método de um objeto e o call
no contexto de outro objeto. É bastante comum pegar métodos de array e aplicá-los a arguments
. A alternativa é usar o objeto de parâmetros restantes que é um array real.
Existem muitos decoradores por aí. Verifique se você os obteve resolvendo as tarefas deste capítulo.
importância: 5
Crie um decorator spy(func)
que deve retornar um wrapper que salva todas as chamadas para funcionar em sua propriedade calls
.
Cada chamada é salva como uma série de argumentos.
Por exemplo:
função trabalho(a, b) { alerta(a + b); // trabalho é uma função ou método arbitrário } trabalho = espião(trabalho); trabalho(1, 2); //3 trabalho(4, 5); //9 for (deixe argumentos de work.calls) { alert('chamar:' + args.join() ); // "ligar:1,2", "ligar:4,5" }
PS: Esse decorador às vezes é útil para testes unitários. Sua forma avançada é sinon.spy
na biblioteca Sinon.JS.
Abra uma sandbox com testes.
O wrapper retornado por spy(f)
deve armazenar todos os argumentos e então usar f.apply
para encaminhar a chamada.
função espião(func) { função wrapper(...args) { // usando ...args em vez de argumentos para armazenar array "real" em wrapper.calls wrapper.calls.push(args); retornar func.apply(este, args); } wrapper.calls = []; invólucro de retorno; }
Abra a solução com testes em uma sandbox.
importância: 5
Crie um decorador delay(f, ms)
que atrase cada chamada de f
em ms
milissegundos.
Por exemplo:
função f(x) { alerta(x); } // cria wrappers seja f1000 = atraso (f, 1000); seja f1500 = atraso (f, 1500); f1000("teste"); // mostra "teste" após 1000ms f1500("teste"); // mostra "teste" após 1500ms
Em outras palavras, delay(f, ms)
retorna uma variante “atrasada por ms
” de f
.
No código acima, f
é função de um único argumento, mas sua solução deve passar todos os argumentos e o contexto this
.
Abra uma sandbox com testes.
A solução:
função atraso(f, ms) { função de retorno() { setTimeout(() => f.apply(this, argumentos), ms); }; } deixe f1000 = atraso (alerta, 1000); f1000("teste"); // mostra "teste" após 1000ms
Observe como uma função de seta é usada aqui. Como sabemos, as funções de seta não possuem this
e arguments
próprios, então f.apply(this, arguments)
pega this
e arguments
do wrapper.
Se passarmos uma função regular, setTimeout
a chamaria sem argumentos e this=window
(assumindo que estamos no navegador).
Ainda podemos passar this
para a direita usando uma variável intermediária, mas isso é um pouco mais complicado:
função atraso(f, ms) { função de retorno(...args) { deixe salvoIsto = isto; //armazena isso em uma variável intermediária setTimeout(função(){ f.apply(savedThis, args); // use aqui }, EM); }; }
Abra a solução com testes em uma sandbox.
importância: 5
O resultado do decorador debounce(f, ms)
é um wrapper que suspende as chamadas para f
até que haja ms
milissegundos de inatividade (sem chamadas, “período de espera”) e, em seguida, invoca f
uma vez com os argumentos mais recentes.
Em outras palavras, debounce
é como uma secretária que aceita “telefonemas” e espera até que haja ms
de silêncio. E só então ele transfere as informações da última chamada para “o chefe” (liga para o f
real).
Por exemplo, tínhamos uma função f
e a substituímos por f = debounce(f, 1000)
.
Então, se a função encapsulada for chamada em 0ms, 200ms e 500ms, e não houver chamadas, então o f
real será chamado apenas uma vez, em 1500ms. Ou seja: após o período de espera de 1000ms da última chamada.
…E receberá os argumentos da última chamada, outras chamadas serão ignoradas.
Aqui está o código para isso (usa o decorador debounce da biblioteca Lodash):
deixe f = _.debounce(alert, 1000); f("uma"); setTimeout( () => f("b"), 200); setTimeout( () => f("c"), 500); // função debounce espera 1000 ms após a última chamada e então executa: alert("c")
Agora um exemplo prático. Digamos que o usuário digite algo e gostaríamos de enviar uma solicitação ao servidor quando a entrada for concluída.
Não adianta enviar a solicitação para cada caractere digitado. Em vez disso, gostaríamos de esperar e processar todo o resultado.
Em um navegador web, podemos configurar um manipulador de eventos – uma função que é chamada a cada alteração em um campo de entrada. Normalmente, um manipulador de eventos é chamado com muita frequência, para cada chave digitada. Mas se debounce
em 1000ms, então ele será chamado apenas uma vez, após 1000ms após a última entrada.
Neste exemplo ao vivo, o manipulador coloca o resultado em uma caixa abaixo, experimente:
Ver? A segunda entrada chama a função debounce, portanto seu conteúdo é processado após 1000ms da última entrada.
Portanto, debounce
é uma ótima maneira de processar uma sequência de eventos: seja uma sequência de pressionamentos de teclas, movimentos do mouse ou qualquer outra coisa.
Ele aguarda o tempo determinado após a última chamada e então executa sua função, que pode processar o resultado.
A tarefa é implementar o decorador debounce
.
Dica: são apenas algumas linhas se você pensar bem :)
Abra uma sandbox com testes.
função debounce(func, ms) { deixe o tempo limite; função de retorno() { clearTimeout(tempo limite); timeout = setTimeout(() => func.apply(this, argumentos), ms); }; }
Uma chamada para debounce
retorna um wrapper. Quando chamado, ele agenda a chamada de função original após determinado ms
e cancela o tempo limite anterior.
Abra a solução com testes em uma sandbox.
importância: 5
Crie um decorador de “estrangulamento” throttle(f, ms)
– que retorna um wrapper.
Quando é chamado várias vezes, ele passa a chamada para f
no máximo uma vez por ms
milissegundos.
Comparado ao decorador debounce, o comportamento é completamente diferente:
debounce
executa a função uma vez após o período de “esfriamento”. Bom para processar o resultado final.
throttle
não o executa com mais frequência do que o tempo ms
. Bom para atualizações regulares que não deveriam ocorrer com muita frequência.
Em outras palavras, throttle
é como uma secretária que aceita ligações, mas incomoda o chefe (liga para o real f
) não mais do que uma vez por ms
milissegundos.
Vamos verificar a aplicação na vida real para entender melhor esse requisito e ver de onde ele vem.
Por exemplo, queremos rastrear os movimentos do mouse.
Em um navegador, podemos configurar uma função para ser executada a cada movimento do mouse e obter a localização do ponteiro conforme ele se move. Durante o uso ativo do mouse, esta função geralmente é executada com muita frequência, pode ser algo em torno de 100 vezes por segundo (a cada 10 ms). Gostaríamos de atualizar algumas informações na página da web quando o ponteiro se mover.
…Mas atualizar a função update()
é muito pesado para ser feito em cada micromovimento. Também não faz sentido atualizar com mais frequência do que uma vez a cada 100 ms.
Então, vamos envolvê-lo no decorador: throttle(update, 100)
como a função a ser executada a cada movimento do mouse, em vez do update()
original. O decorador será chamado com frequência, mas encaminhará a chamada para update()
no máximo uma vez a cada 100 ms.
Visualmente, ficará assim:
Para o primeiro movimento do mouse, a variante decorada passa imediatamente a chamada para update
. Isso é importante, o usuário vê nossa reação ao seu movimento imediatamente.
Então, à medida que o mouse avança, nada acontece até 100ms
. A variante decorada ignora chamadas.
Ao final dos 100ms
– acontece mais uma update
com as últimas coordenadas.
Então, finalmente, o mouse para em algum lugar. A variante decorada espera até que 100ms
expirem e então executa update
com as últimas coordenadas. Então, muito importante, as coordenadas finais do mouse são processadas.
Um exemplo de código:
função f(uma) { console.log(a); } // f1000 passa chamadas para f no máximo uma vez a cada 1000 ms seja f1000 = acelerador(f, 1000); f1000(1); //mostra 1 f1000(2); // (aceleração, 1000 ms ainda não lançado) f1000(3); // (aceleração, 1000 ms ainda não lançado) // quando o tempo limite de 1000 ms expirar... // ...saídas 3, valor intermediário 2 foi ignorado
Os argumentos PS e o contexto this
para f1000
devem ser passados para o f
original.
Abra uma sandbox com testes.
função acelerador(func, ms) { deixe isThrottled = falso, salvoArgs, salvouIsso; função wrapper() { if (isThrottled) { // (2) salvoArgs = argumentos; salvoIsto = isto; retornar; } isThrottled = verdadeiro; func.apply(este, argumentos); // (1) setTimeout(função(){ isThrottled = falso; // (3) if (args salvos) { wrapper.apply(savedThis, saveArgs); salvoArgs = salvoEste = null; } }, EM); } invólucro de retorno; }
Uma chamada throttle(func, ms)
retorna wrapper
.
Durante a primeira chamada, o wrapper
apenas executa func
e define o estado de resfriamento ( isThrottled = true
).
Neste estado todas as chamadas são memorizadas em savedArgs/savedThis
. Observe que tanto o contexto quanto os argumentos são igualmente importantes e devem ser memorizados. Precisamos deles simultaneamente para reproduzir a chamada.
Após a passagem de ms
milissegundos, setTimeout
é acionado. O estado de resfriamento é removido ( isThrottled = false
) e, se tivéssemos ignorado as chamadas, wrapper
é executado com os últimos argumentos e contexto memorizados.
A terceira etapa não é executada func
, mas wrapper
, porque não precisamos apenas executar func
, mas mais uma vez entrar no estado de resfriamento e configurar o tempo limite para redefini-lo.
Abra a solução com testes em uma sandbox.