Optimización de JavaScript

Ramón Saquete

Escrito por Ramón Saquete

Con el paso de los usuarios de dispositivos de escritorio a dispositivos móviles, la optimización de las páginas web en el cliente cobra mayor interés que en el pasado, equiparándose en importancia a las optimizaciones en el servidor. Y es que lograr que una web funcione rápido en unos dispositivos que son más lentos y con conexiones que, dependiendo de la cobertura, pueden ser malas o incluso sufrir cortes, no es una tarea fácil, pero es necesario, puesto que disponer de mayor velocidad implicará usuarios más satisfechos que nos visitarán más a menudo y mejor posicionamiento en búsquedas desde móvil.

En este artículo voy a tratar algunos de los puntos en los que hay que prestar mayor atención a la hora de la optimización del JavaScript que se ejecuta en el cliente. En primer lugar vamos a ver como optimizar su descarga y compilación, y en segundo lugar, como optimizar su ejecución para que la página tenga un buen rendimiento. Entendiendo por rendimiento, la definición que nos da el modelo RAIL de Google. Estas siglas significan:

  • Respuesta de la interfaz en menos de 100ms.
  • Animaciones con dibujado completo cada 16ms que son 60 FPS o 60 imágenes por segundo.
  • Inhabilitado: cuando el usuario no interactua con la página lo que se ejecuta en segundo plano no debe durar más de 50ms.
  • Load: la página debe cargar en 1000ms.

Estos tiempos debemos lograrlos en el peor de los casos (cuando se ejecuta la web en un móvil antiguo), con pocos recursos de procesador y memoria.

Tiempo de carga de web en móvil comparada con escritorio.
Comparación de la carga de un sitio en móvil y en escritorio obtenidos de la base de datos pública Chrome User Experience Report. El histograma muestra el número de usuarios que obtuvieron cada tiempo de respuesta. Como se puede observar el pico máximo en móvil está en 2.5s y en escritorio en 1.5s.

A continuación explico, en primer lugar, las acciones necesarias para optimizar la descarga y compilación y, en segundo lugar, las acciones necesarias para optimizar la ejecución del código, siendo esta parte más técnica, pero no menos importante.

Acciones para optimizar la descarga y compilación del código JavaScript

Cachear en el navegador

Aquí tenemos dos opciones. La primera es usar la API Cache de JavaScript, de la que podemos hacer uso instalando un service worker. La segunda es usar la caché del protocolo HTTP. Si usamos la API Cache nuestra aplicación podría tener la opción de funcionar en modo desconectado. Si usamos la caché del protocolo HTTP, a groso modo, debemos configurarla usando el parámetro Cache-control con los valores public y max-age, con un tiempo de caché grande, como por ejemplo un año. Después, si queremos invalidar esta caché cambiaremos el nombre al archivo.

Comprimir con Brotli q11 y Gzip

Al comprimir el código JavaScript, estamos reduciendo los bits que se transmiten por la red y, por lo tanto, el tiempo de transmisión, pero hay que tener en cuenta que estamos aumentando el tiempo de procesamiento tanto en el servidor como en el cliente, ya que el primero deberá comprimir el archivo y el segundo descomprimirlo. Podemos ahorrar el primer tiempo si tenemos una caché de archivos comprimidos en el servidor, pero el tiempo de descompresión en el cliente más el tiempo de transmisión comprimido, puede ser mayor que el tiempo de transmisión del archivo descomprimido, haciendo que esta técnica haga la descarga más lenta. Esto ocurrirá sólo con archivos muy pequeños y con velocidades de transmisión altas. La velocidad de transmisión del usuario no la podemos saber, pero sí que se le puede decir a nuestro servidor que no comprima los archivos muy pequeños, por ejemplo, decirle que no comprima los archivos menores a 280 bytes. En conexiones con velocidades altas, por encima de los 100Mb/s, este valor debería ser mucho mayor, pero se trata de optimizar para aquellos que tienen conexiones móviles con poca cobertura, donde la pérdida de rendimiento es más acusada, aunque en conexiones rápidas vaya un poco más lento.

El nuevo algoritmo de compresión Brotli mejora la compresión respecto a Gzip un 17%. Sí el navegador envía, en la cabecera del protocolo HTTP, el valor «br» dentro del parámetro accept-encoding, esto significará que el servidor puede enviarle el archivo en formato Brotli en lugar de en Gzip.

Minimizar

Consiste en usar una herramienta automática para eliminar comentarios, espacios, tabuladores y sustituir variables para hacer que el código ocupe menos espacio. Los archivos minimizados deben cachearse en el servidor o generarse ya minimizados a la hora de subirlos, ya que si el servidor tiene que minimizarlos con cada petición, repercutirá negativamente en el rendimiento.

Unificar el código JavaScript

Esta es una técnica de optimización que no tiene mucha importancia si nuestra web funciona con HTTPS y HTTP2, puesto que este protocolo envía los archivos como si fueran uno sólo, pero si nuestra web trabaja con HTTP1.1 o esperamos tener muchos clientes con navegadores antiguos que usen este protocolo, la unificación es necesaria para optimizar la descarga. Pero no hay que pasarse y unificar todo el código de la web en único archivo, ya que si se envía sólo el código que necesita el usuario en cada página, se pueden reducir bastante los bytes a descargar. Para ello separaremos el código base que es necesario para todo el portal de lo que se va a ejecutar en cada página individual. De esta forma tendremos dos archivos JavaScript para cada página, uno con las librerías básicas que será común para todas las páginas y otro con el código especifico de la página. Con una herramienta como webpack podemos unificar y minimizar estos dos grupos de archivos en nuestro proyecto de desarrollo. Procura que la herramienta que uses para esto te genere los llamados «source maps«. Estos son archivos .map que se asocian en la cabecera del archivo final y en los que se establece la relación entre el código minimizado y unificado y los archivos reales de código fuente. De esta manera luego podemos depurar el código sin problemas.

La opción de unificar todo en un único archivo más grande, tiene el lado bueno de que se puede cachear todo el código JavaScript de la web en el navegador, en la primera visita y, en la siguiente carga, el usuario no tendrá que descargar todo el código JavaScript. Así que esta opción la recomiendo sólo si el ahorro de bytes es prácticamente despreciable respecto a la técnica anterior y tenemos una tasa de rebote baja.

Marcar el JavaScript como asíncrono

Debemos incluir el JavaScript de la siguiente manera:

<script async src="/codigo.js" />

De esta forma estamos evitando que la aparición de la etiqueta script bloquee la etapa de construcción del DOM de la página.

No usar JavaScript embebido en la página

Al usar la etiqueta script para empotrar código en la página, también se bloquea la construcción del DOM y más aún si se usa la función document.write(). En otras palabras, esto está prohibido:

<script>documente.write(«Hello world!»);</script>

Cargar el JavaScript en la cabecera de la página con async

Antes de disponer de la etiqueta async, se recomendaba poner todas las etiquetas de script al final de la página para evitar bloquear la construcción de ésta. Esto ya no es necesario, de hecho es mejor que esté arriba dentro de la etiqueta <head>, para que el JavaScript se empiece a descargar, analizar y compilar lo antes posible, puesto que estas fases son las que más van a tardar. Si no se usa este atributo, el JavaScript debe estar al final.

Eliminar el JavaScript que no se utiliza

En este punto no sólo estamos reduciendo el tiempo de transmisión, sino también el tiempo que le lleva al navegador analizar y compilar el código.  Para hacerlo hemos de tener en cuenta los siguientes puntos:

  • Si se detecta que una funcionalidad no está siendo utilizada por los usuarios, podemos eliminarla con todo su código JavaScript asociado, de manera que la web cargará más rápido y los usuarios lo agradecerán.
  • También es posible que hayamos incluido por error alguna librería que no sea necesaria o que tengamos librerías que ofrezcan alguna funcionalidad de la que ya dispongamos de forma nativa en todos los navegadores, sin necesidad de usar código adicional y de manera más rápida.
  • Por último, si queremos optimizar al extremo, sin importar el tiempo que lleve, deberíamos eliminar dentro de las librerías el código del que no estamos haciendo uso. Pero no lo recomiendo, porque nunca sabemos cuando nos puede volver a hacer falta.

Aplazar la carga del JavaScript que no sea necesario:

Se debe hacer con aquellas funcionalidades que no son necesarias para el dibujado inicial de la página. Son funcionalidades para las que el usuario deberá realizar una determinada acción para ejecutarla. De esa forma evitamos cargar y compilar código JavaScript que retrasaría la visualización inicial. Una vez cargada completamente la página, podemos empezar la carga de esas funcionalidades para que estén disponibles inmediatamente cuando el usuario empiece a interactuar. Google en el modelo RAIL recomienda que esa carga aplazada se haga en bloques de 50ms para que no influya con la interacción del usuario con la página. Si el usuario interactúa con una funcionalidad que aún no ha sido cargada, deberemos cargarla en ese momento.

Acciones para optimizar la ejecución del código JavaScript

Evitar usar demasiada memoria

No se puede decir cuánta memoria será demasiada, pero sí se puede decir que siempre debemos de tratar no usar más de lo necesario, porque no sabemos cuanta memoria tendrá el dispositivo que ejecutará la web. Cuando se ejecuta el recolector de basura del navegador, se para la ejecución de JavaScript, y esto ocurre cada vez que nuestro código solicita al navegador reservar nueva memoria. Si esto ocurre con frecuencia la página funcionará con lentitud.

En la pestaña «Memory» de las herramientas para desarrolladores de Chrome, podemos ver la memoria ocupada por cada función de JavaScript:

Memoria reservada por función de JavaScript.
Memoria reservada por función

Evitar fugas de memoria

Si tenemos una fuga de memoria en un bucle, la página irá reservando cada vez más memoria ocupando toda la disponible del dispositivo y haciendo que todo vaya cada vez más lento. Este fallo se suele dar en carruseles y sliders de imágenes.

En Chrome podemos analizar si nuestra web tiene fugas de memoria con la grabación de una línea de tiempo en la pestaña rendimiento de las herramientas para desarrolladores:

Visualización de fuga de memoria en la pestaña de performance de Google Chrome.
Este es el aspecto que tiene una fuga de memoria en la pestaña «Performance» de Google Chrome dónde podemos observar un crecimiento constante de nodos del DOM y del Heap de JS.

Usualmente las fugas de memoria vienen por trozos del DOM que se eliminan de la página pero que tienen alguna variable que les hace referencia y, por tanto, el recolector de basura no puede eliminarlos y por no entender cómo funciona el ámbito de las variables y las clausuras en JavaScript.

Árboles desacoplados del DOM.
Árboles desacoplados del DOM (detached DOM tree) que están ocupando memoria porque hay variables que les hacen referencia.

Usa web workers cuando necesites ejecutar código que necesite mucho tiempo de ejecución

Todos los procesadores hoy en día son multihilo y multinúcleo pero JavaScript tradicionalmente ha sido un lenguaje monohilo y, aunque tiene temporizadores, estos se ejecutan en el mismo hilo de ejecución, en el que, además, se ejecuta la interacción con la interfaz, así que mientras se ejecuta JavaScript la interfaz se bloquea y si tarda más de 50ms será perceptible. Los web workers y service workers nos traen la ejecución multihilo a JavaScript, aunque no permiten el acceso directo al DOM, así que tendremos que pensar como retrasar el acceso a éste, para poder aplicar web workers en los casos que tengamos código que tarde más de 50ms en ejecutarse.

Usar la API Fetch (AJAX)

El uso de la API Fetch o AJAX también es una buena forma de que el usuario perciba un tiempo de carga más rápido, pero no debemos usarlo en la carga inicial, sino en la navegación subsiguiente y de forma que sea indexable. Para ello la mejor forma de implementarlo es hacer uso de un framework que use Universal JavaScript.

Prioriza el acceso a variables locales

JavaScript primero busca si la variable existe de forma local y sigue buscándola en los ámbitos superiores, siendo las últimas las variables globales. JavaScript accede más rápido a variables locales porque no tiene que buscar la variable en ambitos superiores para encontrarla, así que es una buena estrategia guardar en variables locales aquellas variables de un ámbito superior a las que vamos a acceder varias veces y, además, no crear nuevos ámbitos con clausuras o con las sentencias with y try catch, sin que sea necesario.

Si accedes varias veces a un elemento del DOM guárdalo en una variable local

El acceso al DOM es lento. Así que si vamos a leer el contenido de un elemento varias veces, mejor guárdalo en una variable local, así el JavaScript no tendrá que buscar el elemento en el DOM cada vez que quieras acceder a su contenido. Pero pon atención, si guardas en una variable un trozo del DOM que luego vas quitar de la página y no vas a usar más, procura luego asignar a «null» la variable donde te lo habías guardado para no provocar una fuga de memoria.

Agrupar y minimizar las lecturas y escrituras del DOM y CSSOM


Cuando el navegador dibuja una página recorre la ruta de representación crítica que sigue los siguientes pasos en la primera carga:

  1. Se recibe el HTML.
  2. Empieza a construirse el DOM.
  3. Mientras se construye el DOM, se solicitan los recursos externos (CSS y JS).
  4. Se construye el CCSOM (mezcla del DOM y el CSS).
  5. Se crea el árbol de representación (son las partes del CSSOM que se van a dibujar).
  6. A partir del árbol de representación se calcula la geometría de cada parte visible del árbol en una capa. Esta etapa se llama layout o reflow.
  7. En la etapa final de pintado, se pintan las capas del paso 6 que se procesan y van componiendo una encima de otra para mostrar la página al usuario.
  8. Si se ha terminado de compilar el JavaScript, éste se ejecuta (en realidad, este paso se podría ocurrir en cualquier punto después del paso 3, siendo mejor cuanto antes).
  9. Si en el paso anterior el código JavaScript obliga a rehacer parte del DOM o el CSSOM volvemos varios pasos atrás que se ejecutarán hasta el  punto 7.
Construcción del árbol de representación o árbol de renderizado.
Construcción del árbol de representación

Aunque los navegadores van encolando los cambios del árbol de representación y deciden cuando hacer el repintado, si tenemos un bucle en el que leemos el DOM y/o el CSSOM y lo modificamos en la siguiente línea, puede que el navegador se vea forzado a ejecutar el reflow o repintar la página varias veces, sobre todo si la lectura siguiente depende de la escritura anterior. Por eso es recomendable:

  • Separar todas las lecturas en un bucle independiente y hacer todas las escrituras de una sola vez con la propiedad cssText si es el CSSOM o innerHTML si es el DOM, así el navegador sólo tendrá que lanzar un repintado.
  • Si las lecturas dependen de las escrituras anteriores, busca una manera de reescribir el algoritmo para que esto no sea así.
  • Si no tienes más remedio que aplicar muchos cambios a un elemento del DOM, sácalo del DOM, haz los cambios y vuélvelo a introducir donde estaba.
  • En Google Chrome, podemos analizar lo que ocurre en la ruta de representación crítica con la herramienta Lighthouse de la pestaña «Audits» o en la pestaña «Performance» grabando lo que ocurre mientras se carga la página.
Análisis de rendimiento de la herramienta Lighthouse.
En este análisis de rendimiento de Lighthouse de la página principal de Google, podemos ver qué recursos bloquean la ruta de representación crítica.

Usa la función requestAnimationFrame(callback) en animaciones y los efectos que dependen del scroll

La función requestAnimationFrame(), hace que la función que se le pasa como parámetro no provoque un repintado, hasta el siguiente programado. Esto, además de evitar repintados innecesarios, tiene el efecto de que las animaciones se paran mientras el usuario está en otra pestaña, ahorrando CPU y batería del dispositivo.

Los efectos que dependen del scroll son los más lentos porque las siguientes propiedades del DOM fuerzan un reflow (paso 7 del punto anterior) al acceder a ellas:

offsetTop, offsetLeft, offsetWidth, offsetHeight
scrollTop, scrollLeft, scrollWidth, scrollHeight
clientTop, clientLeft, clientWidth, clientHeight
getComputedStyle() (currentStyle en IE)

Si además de acceder a una de estas propiedades, luego en base a ellas pintamos un banner o menú que te siguen al mover el scroll o un efecto de scroll parallax, se hará el repintado de varias capas cada vez que desplazamos el scroll, afectando negativamente al tiempo de respuesta de la interfaz, con lo que podemos tener un scroll que vaya dando saltos en lugar de deslizarse suavemente. Por eso, con estos efectos, se debe guardar en una variable global la última posición del scroll en el evento onscroll, y despúes usar la función requestAnimationFrame() sólo si la anterior animación ha terminado.

Si hay muchos eventos parecidos agrúpalos

Si tienes 300 botones que al hacer clic hacen prácticamente lo mismo, podemos asignar un evento al elemento padre de los 300 botones en lugar de asignar 300 eventos a cada uno de ellos. Al hacer clic en un botón, el evento «burbujea» hasta el padre y desde éste podemos saber a qué botón hizo clic el usuario y modificar el comportamiento en consecuencia.

Cuidado con los eventos que se disparan varias veces seguidas

Los eventos como onmousemove u onscroll, se disparan varias veces seguidas mientras se realiza la accción. Así que procura controlar que el código asociado no se ejecute más veces de las necesarias, ya que este es un error bastante común.

Evita la ejecución de cadenas con código con eval(), Function(), setTimeout() y setInterval()

El hecho de introducir código en un literal para que sea analizado y compilado durante la ejecución del resto del código es bastante lento, por ejemplo: eval(«c = a + b»);. Siempre se puede rehacer la programación para evitar tener que hacer esto.

Implementa las optimizaciones que aplicarías en cualquier otro lenguaje de programación

  • Usa siempre los algoritmos con la menor complejidad computacional o complejidad ciclomática para la tarea a resolver.
  • Usa las estructuras de datos óptimas para lograr el punto anterior.
  • Reescribe el algoritmo para obtener el mismo resultado con menos cálculos.
  • Evita llamadas recursivas, cambiando el algoritmo por uno equivalente que haga uso de una pila.
  • Haz que una función con un coste alto y repetidas llamadas a lo largo de distintos bloques de código guarde en memoria el resultado para la próxima llamada.
  • Pon en variables los cálculos y llamadas a funciones repetidas.
  • A la hora de recorrer un bucle, guárdate primero el tamaño del bucle en una variable, para evitar calcularlo de nuevo, en su condición de finalización, en cada iteración.
  • Factoriza y simplifica las fórmulas matemáticas.
  • Reemplaza cálculos que no dependan de variables por constantes y deja el cálculo comentado.
  • Usa arrays de búsqueda: sirven para obtener un valor en base a otro en lugar de usar un bloque switch.
  • Haz que las condiciones siempre sean con mayor probabilidad ciertas para aprovechar mejor la ejecución especulativa del procesador, puesto que así la predicción de saltos fallará menos.
  • Simplifica expresiones booleanas con las reglas de la lógica booleana o mejor aún con mapas de Karnaugh.
  • Usa los operadores a nivel de bit cuando puedas usarlos para reemplazar ciertas operaciones, ya que estos operadores usan menos ciclos de procesador. Usarlos requiere saber aritmética binaria, por ejemplo: siendo x un valor de una variable entera, podemos poner «y=x>>1;» en lugar de «y=x/2;» o «y=x&0xFF;» en lugar de «y=x%256».

Estas son algunas de mis preferidas, siendo las más importantes las tres primeras y las que más estudio y práctica requieren. Las últimas son micro-optimizaciones que sólo merecen la pena si las llevas a cabo mientras escribes el código o si es algo computacionalmente muy costoso como un editor de video o un videojuego, aunque en esos casos será mejor que uses WebAssembly en lugar de JavaScript.

Herramientas para detectar problemas

Ya hemos visto varias. De todas ellas, Lighthouse es la más fácil de interpretar, ya que simplemente nos da una serie de puntos a mejorar, como también nos puede dar la herramienta Google PageSpeed Insights o, muchas otras, como GTmetrix. En Chrome también podemos usar, en la opción «Más herramientas» del menú principal, el administrador de tareas, para ver la memoria y la CPU utilizada por cada pestaña. Para análisis aún más técnicos, tenemos las herramientas para desarrolladores de Firefox y Google Chrome, en donde disponemos de la pestaña llamada «Rendimiento» y que nos permite analizar bastante bien los tiempos de cada fase, las fugas de memoria, etc. Veamos un ejemplo:

Analísis de rendimiento de Google Chrome.
En el análisis de rendimiento de Google Chrome, en el menú de herramientas nos permite simular una CPU y una red más lentas, y en él vemos, entre otras cosas, las imágenes o cuadros por segundo (recordemos que debe ser menor a 16ms) y las fases de la ruta de representación crítica con colores: en azul el tiempo de carga de los archivos, en amarillo el tiempo de ejecución de los scripts, en morado el tiempo de construcción del árbol de representación (incluyendo el reflows o construcción del layout) y en verde el tiempo de pintado. Además, aparece el tiempo que ha llevado pintar cada frame y como ha quedado éste.

Toda la información que vemos arriba se puede grabar mientras se carga la página, ejecutamos una acción o hacemos scroll. Después podemos hacer zoom a una parte del gráfico para verla en detalle y, si como en este caso, lo que más tarda es la ejecución de JavaScript, podemos desplegar el apartado Main y pinchar encima de los scripts que llevan más tiempo . Así la herramienta nos mostrará detalladamente en la pestaña Bottom-Up como está afectando el JavaScript a cada fase de la ruta de representación crítica y en la pestaña Summary nos indicará con un aviso si ha detectado un problema de rendimiento en el JavaScript . Pinchando encima del archivo que indica, nos llevará concretamente a la línea que produce el retardo.

Resumen de la herramienta devtools con aviso de problema de rendimiento.
Aviso de la pestaña resumen de las herramientas de rendimiento

 

Finalmente, para análisis aún más finos, es recomendable usar la API de JavaScript Navigation Timing que nos permite medir detalladamente lo que tarda cada parte de nuestro código desde la propia programación.

Recomendaciones finales

Como ves, la optimización de JavaScript no es una tarea fácil y lleva un proceso de análisis y optimización laborioso que puede sobrepasar facilmente el presupuesto destinado al desarrollo que teníamos pensado inicialmente. Por ello no son pocas las webs, plugins y temas más famosos para los gestores de contenido habituales que presentan muchos de los problemas que he enumerado.

Si tu web presenta estos problemas, intenta solucionar primero aquellos que mayor impacto tengan en el rendimiento y procura siempre que las optimizaciones no afecten a la mantenibilidad y calidad del código. Por eso no recomiendo el uso de técnicas de optimización más extremas, como quitar llamadas a funciones reemplazándolas por el código al que llaman, el desenrollado de bucles, o la utilización de la misma variable para todo, para que así cargue desde caché o desde los registros del procesador, ya que son técnicas que ensucian el código y, en el compilado en tiempo de ejecución de JavaScript, ya se aplican algunas de ellas. Así que recuerda:

El rendimiento no es un requisito que deba estar nunca por encima de la facilidad de detectar errores y añadir funcionalidades.

Ramón Saquete
Autor: Ramón Saquete
Desarrollador web y consultor SEO técnico en la agencia de marketing online Human Level, es experto en WPO e indexabilidad.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *