Algunos trucos para acelerar su aplicación JavaScript
Bartosz Slysz
Software Engineer
Con el avance de la tecnología de los navegadores, las aplicaciones web han empezado a transferir cada vez más lógica al front-end, aliviando así al servidor y reduciendo el número de operaciones que tiene que realizar. En los CRUD básicos, el papel del servidor se reduce a la autorización, la validación, la comunicación con las bases de datos y la lógica de negocio necesaria. El resto de la lógica de datos, como se ve, puede ser manejado fácilmente por el código responsable de la representación de la aplicación en el lado de la interfaz de usuario.
En este artículo, intentaré mostrarles algunos ejemplos y patrones que ayudarán a mantener nuestro código eficiente, pulcra y rápida.
Antes de profundizar en ejemplos concretos, en este artículo me gustaría centrarme únicamente en mostrar casos que, en mi opinión, pueden afectar a la velocidad de la aplicación de forma sorprendente. Sin embargo, esto no significa que el uso de soluciones más rápidas sea la mejor opción en todos los casos posibles. Los consejos que se ofrecen a continuación deberían considerarse más bien como algo a tener en cuenta cuando nuestra aplicación funcione lentamente, por ejemplo, en productos que requieran el renderizado de juegos o gráficos más avanzados en el lienzo, operaciones de vídeo o actividades que se quieran sincronizar en tiempo real lo antes posible.
En primer lugar - Array.prototype métodos
Basamos gran parte de la lógica de la aplicación en arrays: su asignación, ordenación, filtrado, suma de elementos, etc. De forma fácil, transparente y natural, utilizamos sus métodos incorporados que simplemente nos permiten realizar diversos tipos de cálculos, agrupaciones, etc. Funcionan de forma similar en cada instancia - como argumento, pasamos una función en la que, en la mayoría de los casos, el valor del elemento, el índice y el array son empujados por turnos durante cada iteración. La función especificada se ejecuta para cada elemento del array y el resultado se interpreta de forma diferente dependiendo del método. No me extenderé sobre los métodos de Array.prototype ya que quiero centrarme en por qué se ejecuta lentamente en un gran número de casos.
Los métodos Array son lentos porque realizan una función para cada elemento. Una función llamada desde la perspectiva del motor debe preparar una nueva llamada, proporcionar el ámbito apropiado y un montón de otras dependencias, lo que hace que el proceso sea mucho más largo que repetir un bloque específico de código en un ámbito específico. Y esto es probablemente suficiente conocimiento de fondo para permitirnos entender el siguiente ejemplo:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ valor: Math.random() });
console.time('Suma por reducir');
const reducirSuma = randomArray
.map(({ valor }) => valor)
.reduce((a, b) => a + b);
console.timeEnd('Suma por reducción');
console.time('Suma por bucle for');
let forSuma = randomArray[0].valor;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Suma por bucle for');
console.log(reduceSuma === forSuma);
})();
Sé que esta prueba no es tan fiable como los puntos de referencia (volveremos a ellos más adelante), pero dispara una luz de alarma. Para un caso aleatorio en mi ordenador, ¡resulta que el código con el bucle for puede ser unas 50 veces más rápido si se compara con mapear y luego reducir elementos que consiguen el mismo efecto! Se trata de operar sobre algún objeto extraño creado sólo para alcanzar un objetivo específico de cómputos. Por lo tanto, vamos a crear algo más legítimo para ser objetivo sobre los métodos Array:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ valor: Math.random() });
console.time('Suma por reducir');
const reducirSuma = randomArray
.reduce((a, b) => ({ valor: a.valor + b.valor })).value
console.timeEnd('Suma por reducción');
console.time('Suma por bucle for');
let forSuma = randomArray[0].valor;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Suma por bucle for');
console.log(reduceSuma === forSuma);
})();
Sé que esta prueba no es tan fiable como los puntos de referencia (volveremos a ellos más adelante), pero dispara una luz de alarma. Para un caso aleatorio en mi ordenador, ¡resulta que el código con el bucle for puede ser unas 50 veces más rápido si se compara con mapear y luego reducir elementos que consiguen el mismo efecto! Esto es porque obtener la suma en este caso particular usando el método reduce requiere mapear el array para valores puros que queremos resumir. Por lo tanto, vamos a crear algo más legítimo para ser objetivos acerca de los métodos Array:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ valor: Math.random() });
console.time('Suma por reducir');
const reducirSuma = randomArray
.reduce((a, b) => ({ valor: a.valor + b.valor })).value
console.timeEnd('Suma por reducción');
console.time('Suma por bucle for');
let forSuma = randomArray[0].valor;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Suma por bucle for');
console.log(reduceSuma === forSuma);
})();
Y resulta que nuestro aumento de 50x se redujo a 4x. ¡Disculpas si te sientes decepcionado! Para ser objetivos hasta el final, analicemos de nuevo ambos códigos. En primer lugar, las diferencias de apariencia inocente duplicaron la caída de nuestra complejidad computacional teórica; en lugar de mapear primero y sumar después elementos puros, seguimos operando sobre objetos y un campo específico, para finalmente sacar para obtener la suma que nos interesa. El problema surge cuando otro programador echa un vistazo al código: entonces, en comparación con los códigos mostrados anteriormente, este último pierde su abstracción en algún punto.
Esto es debido a que desde la segunda operación que operamos sobre un objeto extraño, con el campo que nos interesa y el segundo, objeto estándar del array iterado. No sé lo que pensáis al respecto, pero desde mi punto de vista, en el segundo ejemplo de código, la lógica del bucle for es mucho más clara e intencionada que esta reducción de aspecto extraño. Y aún así, aunque ya no sea el mítico 50, ¡sigue siendo 4 veces más rápido en cuanto a tiempo de cálculo! Como cada milisegundo es valioso, la elección en este caso es sencilla.
El ejemplo más sorprendente
La segunda cosa que quería comparar se refiere al método Math.max o, más exactamente, rellenar un millón de elementos y extraer de ellos el mayor y el menor. He preparado el código, los métodos para medir el tiempo también, entonces me disparar el código y obtener un error muy extraño - el tamaño de la pila se excede. Aquí está el código:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max con operador de dispersión ES6');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max con operador de dispersión ES6');
console.time('Math.max con bucle for');
let maxByFor = valoresaleatorios[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max con bucle for');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max con operador de dispersión ES6');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max con operador de dispersión ES6');
console.time('Math.max con bucle for');
let maxByFor = valoresaleatorios[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max con bucle for');
console.log(maxByFor === maxBySpread);
})();
Resulta que los métodos nativos usan recursión, que en v8 está limitada por las pilas de llamadas y su número depende del entorno. Esto es algo que me sorprendió mucho, pero conlleva una conclusión: el método nativo es más rápido, siempre y cuando nuestro array no supere un cierto número mágico de elementos, que en mi caso resultó ser 125375. Para este número de elementos, el resultado para era 5x más rápido si lo comparamos con el bucle. Sin embargo, por encima del número de elementos mencionado, el bucle for definitivamente gana - a diferencia de su oponente, nos permite obtener resultados correctos.
Recursión
El concepto que quiero mencionar en este párrafo es la recursividad. En el ejemplo anterior, lo vimos en el método Math.max y el plegado de argumentos, donde resultó que es imposible obtener un resultado para llamadas recursivas que excedan un número específico debido a la limitación del tamaño de la pila.
Veremos ahora qué aspecto tiene la recursividad en el contexto de código escrito en JS, y no con métodos incorporados.Quizás lo más clásico que podemos mostrar aquí es, por supuesto, encontrar el enésimo término de la secuencia de Fibonacci. Entonces, ¡escribamos esto!
(() => {
const fiboIterativo = (n) => {
deje [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
return n;
}
return fiboRecursiva(n - 2) + fiboRecursiva(n - 1);
};
console.time('Secuencia Fibonacci mediante bucle for');
const resultadoIterativo = fiboIterativo(30);
console.timeEnd('Secuencia Fibonacci por bucle for');
console.time('Secuencia Fibonacci por recursión');
const resultadoRecursivo = fiboRecursivo(30);
console.timeEnd('Secuencia Fibonacci por recursión');
console.log(resultadoRecursivo === resultadoIterativo);
})();
Bien, en este caso particular de calcular el 30º elemento de la secuencia en mi ordenador, obtenemos el resultado en aproximadamente 200 veces menos tiempo con el algoritmo iterativo.
Sin embargo, hay una cosa que se puede rectificar en el algoritmo recursivo - resulta que funciona mucho más eficientemente cuando utilizamos una táctica llamada recursión de cola. Esto significa que pasamos el resultado que obtuvimos en la iteración anterior utilizando argumentos para llamadas más profundas. Esto nos permite reducir el número de llamadas necesarias y, en consecuencia, acelera el resultado. ¡Corrijamos nuestro código en consecuencia!
(() => {
const fiboIterativo = (n) => {
deje [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
return primero;
}
return fiboTailRecursive(n - 1, segundo, primero + segundo);
};
console.time('Secuencia Fibonacci mediante bucle for');
const resultadoIterativo = fiboIterativo(30);
console.timeEnd('Secuencia Fibonacci por bucle for');
console.time('Secuencia Fibonacci por recursividad de cola');
const resultadoRecursivo = fiboRecursivoDeCola(30);
console.timeEnd('Secuencia Fibonacci por recursividad de cola');
console.log(resultadoRecursivo === resultadoIterativo);
})();
Algo que no esperaba ocurrió aquí - el resultado del algoritmo de recursión de cola fue capaz de entregar el resultado (calcular el elemento 30 de una secuencia) casi dos veces más rápido que el algoritmo iterativo en algunos casos. No estoy totalmente seguro de si esto se debe a la optimización para la recursión de cola por parte de v8 o a la falta de optimización para el bucle for para este número específico de iteraciones, pero el resultado es inequívoco - la recursión de cola gana.
Esto es extraño porque, esencialmente, el bucle for impone mucha menos abstracción a las actividades de computación de nivel inferior, y se podría decir que está más cerca del funcionamiento básico del ordenador. Sin embargo, los resultados son innegables: la recursividad inteligentemente diseñada resulta ser más rápida que la iteración.
Utilice declaraciones de llamada asíncronas siempre que pueda
Me gustaría dedicar el último párrafo a un breve recordatorio sobre un método de realizar operaciones que también puede afectar en gran medida a la velocidad de nuestra aplicación. Como usted debe saber, JavaScript es un lenguaje single-threaded que mantiene todas las operaciones con mecanismo event-loop. Se trata de un ciclo que se ejecuta una y otra vez y todos los pasos de este ciclo tienen que ver con acciones específicas dedicadas.
Para que este bucle sea rápido y todos los ciclos esperen menos su turno, todos los elementos deben ser lo más rápidos posible. Evita ejecutar operaciones largas en el hilo principal - si algo lleva demasiado tiempo, intenta mover estos cálculos al WebWorker o dividirlos en partes que se ejecuten de forma asíncrona. Esto puede ralentizar algunas operaciones pero mejora todo el ecosistema de JS, incluyendo las operaciones IO, como el manejo del movimiento del ratón o las peticiones HTTP pendientes.
Resumen
En conclusión, como ya se ha mencionado, perseguir los milisegundos que se pueden ahorrar seleccionando un algoritmo puede resultar absurdo en algunos casos. Por otro lado, descuidar estas cosas en aplicaciones que requieren un funcionamiento fluido y resultados rápidos puede ser mortal para tu aplicación. En algunos casos, aparte de la velocidad del algoritmo, hay que plantearse una pregunta adicional: ¿la abstracción funciona al nivel adecuado? ¿El programador que lea el código podrá entenderlo sin problemas?
La única manera es garantizar el equilibrio entre rendimiento, facilidad de implementación y abstracción adecuada, y estar seguro de que el algoritmo funciona correctamente tanto para pequeñas como para grandes cantidades de datos. La forma de hacerlo es bastante sencilla: sé inteligente, ten en cuenta los distintos casos a la hora de diseñar el algoritmo y haz que se comporte de la forma más eficiente posible para ejecuciones medias. Además, es aconsejable diseñar pruebas - asegúrese de que el algoritmo devuelve la información adecuada para diferentes datos, independientemente de cómo funcione. Cuide las interfaces adecuadas - para que tanto la entrada como la salida de los métodos sean legibles, claras y reflejen exactamente lo que están haciendo.
He mencionado antes que volveré sobre la fiabilidad de medir la velocidad de los algoritmos en los ejemplos anteriores. Medirlos con console.time no es muy fiable, pero refleja mejor el caso de uso estándar. De todos modos, presento los puntos de referencia a continuación - algunos de ellos se ven un poco diferente de una sola ejecución debido al hecho de que los puntos de referencia simplemente repetir una actividad determinada en un momento determinado y utilizar la optimización v8 para bucles.
https://jsben.ch/KhAqb - reducir vs bucle for
https://jsben.ch/F4kLY - reducción optimizada frente a bucle for
https://jsben.ch/MCr6g - Math.max vs bucle for
https://jsben.ch/A0CJB - fibo recursivo vs fibo iterativo