Alguns truques para acelerar a sua aplicação JavaScript
Bartosz Slysz
Software Engineer
Com o avanço da tecnologia dos browsers, as aplicações Web começaram a transferir cada vez mais lógica para o front-end, aliviando assim o servidor e reduzindo o número de operações que este tem de efetuar. Nos CRUDs básicos, o papel do servidor resume-se à autorização, validação, comunicação com as bases de dados e à lógica comercial necessária. O resto da lógica de dados, como se vê, pode ser facilmente tratado pelo código responsável pela representação da aplicação no lado da IU.
Neste artigo, vou tentar mostrar-vos alguns exemplos e padrões que ajudarão a manter a nossa código eficiente, limpo e rápido.
Antes de nos aprofundarmos em exemplos específicos - neste artigo, gostaria de me concentrar apenas em mostrar exemplos que, na minha opinião, podem afetar a velocidade da aplicação de uma forma surpreendente. No entanto, isto não significa que a utilização de soluções mais rápidas seja a melhor escolha em todos os casos possíveis. As dicas abaixo devem ser tratadas como algo que deve ser analisado quando a nossa aplicação está a correr lentamente, por exemplo, em produtos que requerem a renderização de jogos ou gráficos mais avançados no ecrã, operações de vídeo ou actividades que pretende sincronizar em tempo real o mais rapidamente possível.
Em primeiro lugar - métodos Array.prototype
Baseamos uma grande parte da lógica da aplicação em arrays - o seu mapeamento, ordenação, filtragem, soma de elementos, etc. De uma forma fácil, transparente e natural, utilizamos os seus métodos incorporados que permitem simplesmente nós para efetuar vários tipos de cálculos, agrupamentos, etc. Funcionam de forma semelhante em cada instância - como argumento, passamos uma função em que, na maioria dos casos, o valor do elemento, o índice e a matriz são empurrados alternadamente durante cada iteração. A função especificada é executada para cada elemento da matriz e o resultado é interpretado de forma diferente consoante o método. Não vou entrar em pormenores sobre os métodos do protótipo Array.prototype, pois quero concentrar-me na razão pela qual funciona lentamente num grande número de casos.
Os métodos de matriz são lentos porque executam uma função para cada elemento. Uma função chamada da perspetiva do motor tem de preparar uma nova chamada, fornecer o âmbito apropriado e muitas outras dependências, o que torna o processo muito mais longo do que repetir um bloco de código específico num âmbito específico. E este é provavelmente o conhecimento de base suficiente para nos permitir compreender o exemplo seguinte:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
consola.time('Soma por redução');
const reduceSum = randomArray
.map(({ valor }) => valor)
.reduce((a, b) => a + b);
consola.timeEnd('Soma por redução');
console.time('Soma por loop for');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Soma por loop for');
console.log(reduzirSoma === paraSoma);
})();
Sei que este teste não é tão fiável como os benchmarks (voltaremos a eles mais tarde), mas acende uma luz de aviso. Para um caso aleatório no meu computador, verifica-se que o código com o ciclo for pode ser cerca de 50 vezes mais rápido se comparado com o mapeamento e posterior redução de elementos que obtêm o mesmo efeito! Trata-se de operar sobre um objeto estranho criado apenas para atingir um objetivo específico de computação. Por isso, vamos criar algo mais legítimo para sermos objectivos em relação aos métodos Array:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
consola.time('Soma por redução');
const reduceSum = randomArray
.reduce((a, b) => ({ valor: a.valor + b.valor })).value
consola.timeEnd('Soma por redução');
consola.time('Soma por loop for');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Soma por loop for');
console.log(reduceSum === forSum);
})();
Sei que este teste não é tão fiável como os benchmarks (voltaremos a eles mais tarde), mas acende uma luz de aviso. Para um caso aleatório no meu computador, verifica-se que o código com o ciclo for pode ser cerca de 50 vezes mais rápido se comparado com o mapeamento e redução de elementos que obtêm o mesmo efeito! Isto deve-se ao facto de, neste caso específico, obter a soma utilizando o método reduzir requer o mapeamento da matriz para os valores puros que queremos resumir. Então, vamos criar algo mais legítimo para sermos objetivos sobre os métodos Array:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
consola.time('Soma por redução');
const reduceSum = randomArray
.reduce((a, b) => ({ valor: a.valor + b.valor })).value
consola.timeEnd('Soma por redução');
consola.time('Soma por loop for');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Soma por loop for');
console.log(reduceSum === forSum);
})();
E, como se vê, o nosso aumento de 50x diminuiu para 4x. Peço desculpa se ficou desiludido! Para sermos objectivos até ao fim, vamos analisar novamente os dois códigos. Em primeiro lugar, diferenças aparentemente inocentes duplicaram a queda na nossa complexidade computacional teórica; em vez de primeiro mapear e depois somar elementos puros, continuamos a operar sobre objectos e um campo específico, para finalmente obter a soma que nos interessa. O problema surge quando outro programador dá uma vista de olhos ao código - nesse caso, em comparação com os códigos mostrados anteriormente, este último perde a sua abstração em algum ponto.
Isto deve-se ao facto de, desde a segunda operação, operarmos sobre um objeto estranho, com o campo que nos interessa e o segundo objeto padrão da matriz iterada. Não sei o que pensa sobre isto, mas na minha perspetiva, no segundo exemplo de código, a lógica do ciclo for é muito mais clara e intencional do que esta estranha redução. E mesmo assim, apesar de já não ser o mítico 50, continua a ser 4 vezes mais rápido em termos de tempo de computação! Como cada milissegundo é valioso, a escolha neste caso é simples.
O exemplo mais surpreendente
A segunda coisa que queria comparar diz respeito ao método Math.max ou, mais precisamente, encher um milhão de elementos e extrair os maiores e os mais pequenos. Preparei o código, os métodos para medir o tempo também, depois iniciei o código e recebi um erro muito estranho - o tamanho da pilha foi excedido. Aqui está o código:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max com operador de dispersão ES6');
const maxBySpread = Math.max(...randomValues);
consola.timeEnd('Math.max com operador de dispersão ES6');
consola.time('Math.max com o ciclo for');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max com loop for');
consola.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max com operador de dispersão ES6');
const maxBySpread = Math.max(...randomValues);
consola.timeEnd('Math.max com operador de dispersão ES6');
consola.time('Math.max com o ciclo for');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max com loop for');
consola.log(maxByFor === maxBySpread);
})();
Acontece que os métodos nativos usam recursão, que na v8 é limitada pelas pilhas de chamadas e o seu número depende do ambiente. Isto é algo que me surpreendeu bastante, mas leva a uma conclusão: o método nativo é mais rápido, desde que o nosso array não exceda um certo número mágico de elementos, que no meu caso acabou por ser 125375. Para este número de elementos, o resultado do for foi 5x mais rápido se comparado com o loop. No entanto, acima do número de elementos mencionado, o ciclo for ganha definitivamente - ao contrário do adversário, permite-nos obter resultados corretos.
Recursão
O conceito que quero mencionar neste parágrafo é o de recursão. No exemplo anterior, vimos isso no método Math.max e na dobragem de argumentos, onde se verificou que é impossível obter um resultado para chamadas recursivas que excedam um número específico devido à limitação do tamanho da pilha.
Vamos ver agora como é a recursão no contexto de código escrito em JS, e não com métodos embutidos. Talvez a coisa mais clássica que podemos mostrar aqui é, obviamente, encontrar o enésimo termo na sequência de Fibonacci. Então, vamos escrever isso!
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1];
for (let i = 0; i {
se(n < 2) {
retorna n;
}
return fiboRecursivo(n - 2) + fiboRecursivo(n - 1);
};
consola.time('Sequência de Fibonacci por laço for');
const resultIterative = fiboIterative(30);
consola.timeEnd('Sequência de Fibonacci por laço de for');
consola.time('Sequência de Fibonacci por recursão');
const resultRecursive = fiboRecursive(30);
consola.timeEnd('Sequência de Fibonacci por recursão');
consola.log(resultadoRecursivo === resultadoIterativo);
})();
Ok, neste caso particular de calcular o 30º item da sequência no meu computador, obtemos o resultado em cerca de 200x menos tempo com o algoritmo iterativo.
Há, no entanto, uma coisa que pode ser rectificada no algoritmo recursivo - como se verifica, funciona de forma muito mais eficiente quando utilizamos uma tática chamada recursão de cauda. Isto significa que passamos o resultado que obtivemos na iteração anterior usando argumentos para chamadas mais profundas. Isto permite-nos reduzir o número de chamadas necessárias e, consequentemente, acelera o resultado. Vamos corrigir o nosso código em conformidade!
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1];
for (let i = 0; i {
se(n === 0) {
retorna primeiro;
}
return fiboTailRecursive(n - 1, second, first + second);
};
consola.time('Sequência de Fibonacci por laço para');
const resultIterative = fiboIterative(30);
consola.timeEnd('Sequência de Fibonacci por loop');
console.time('Sequência de Fibonacci por recursão de cauda');
const resultRecursive = fiboTailRecursive(30);
consola.timeEnd('Sequência de Fibonacci por recursão da cauda');
consola.log(resultadoRecursivo === resultadoIterativo);
})();
Algo que eu não esperava aconteceu aqui - o resultado do algoritmo de recursão de cauda foi capaz de entregar o resultado (computar o 30º elemento de uma sequência) quase duas vezes mais rápido que o algoritmo iterativo em alguns casos. Não tenho a certeza absoluta se isto se deve à otimização da recursão da cauda por parte da v8 ou à falta de otimização do ciclo for para este número específico de iterações, mas o resultado é inequívoco - a recursão da cauda ganha.
Isto é estranho porque, essencialmente, o ciclo for impõe muito menos abstração nas actividades de computação de nível inferior, e pode dizer-se que está mais próximo da operação básica do computador. No entanto, os resultados são inegáveis - a recursão inteligentemente concebida acaba por ser mais rápida do que a iteração.
Utilizar instruções de chamada assíncronas sempre que possível
Gostaria de dedicar o último parágrafo a uma breve chamada de atenção para um método de execução de operações que também pode afetar grandemente a velocidade da nossa aplicação. Como deve saber, JavaScript é uma linguagem single-threaded que mantém todas as operações com o mecanismo de event-loop. Trata-se de um ciclo que se repete continuamente e todas as etapas deste ciclo são acções específicas.
Para tornar este ciclo rápido e permitir que todos os ciclos esperem menos pela sua vez, todos os elementos devem ser tão rápidos quanto possível. Evitar executar operações longas na thread principal - se algo demorar muito tempo, tentar mover esses cálculos para o WebWorker ou dividi-los em partes que sejam executadas de forma assíncrona. Pode tornar algumas operações mais lentas, mas melhora todo o ecossistema de JS, incluindo as operações de IO, como o tratamento de movimentos do rato ou pedidos HTTP pendentes.
Resumo
Em conclusão, tal como mencionado anteriormente, a procura de milissegundos que podem ser poupados através da seleção de um algoritmo pode revelar-se inútil em alguns casos. Por outro lado, negligenciar estes aspectos em aplicações que requerem um funcionamento suave e resultados rápidos pode ser fatal para a sua aplicação. Nalguns casos, para além da velocidade do algoritmo, deve ser feita uma pergunta adicional - a abstração é operada ao nível correto? O programador que lê o código será capaz de o compreender sem problemas?
A única forma é garantir o equilíbrio entre desempenho, facilidade de implementação e abstração adequada, e ter a certeza de que o algoritmo funciona corretamente tanto para pequenas como para grandes quantidades de dados. A forma de o fazer é bastante simples - seja inteligente, considere os diferentes casos ao conceber o algoritmo e organize-o de modo a comportar-se da forma mais eficiente possível para execuções médias. Além disso, é aconselhável conceber testes - certifique-se de que o algoritmo devolve a informação adequada para diferentes dados, independentemente da forma como funciona. Cuide das interfaces corretas - para que tanto a entrada como a saída dos métodos sejam legíveis, claras e reflictam exatamente o que estão a fazer.
Mencionei anteriormente que voltarei à questão da fiabilidade da medição da velocidade dos algoritmos nos exemplos acima. Medi-los com console.time não é muito fiável, mas reflecte melhor o caso de utilização padrão. De qualquer forma, apresento os benchmarks abaixo - alguns deles parecem um pouco diferentes de uma única execução devido ao facto de os benchmarks simplesmente repetirem uma determinada atividade num determinado momento e utilizarem a otimização v8 para os loops.
https://jsben.ch/KhAqb - reduzir vs for loop
https://jsben.ch/F4kLY - redução optimizada vs for loop
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - fibo recursivo vs fibo iterativo