Reduce las cargas útiles de JavaScript con la eliminación de código no utilizado

Las aplicaciones web actuales pueden ser bastante grandes, sobre todo la parte de JavaScript. A partir de mediados de 2018, HTTP Archive coloca el tamaño de transferencia medio de JavaScript en dispositivos móviles en aproximadamente 350 KB. Y esto es solo el tamaño de transferencia. A menudo, JavaScript se comprime cuando se envía a través de la red, lo que significa que la cantidad real de JavaScript es bastante mayor después de que el navegador lo descomprime. Es importante tener esto en cuenta porque, en lo que respecta al procesamiento de recursos, la compresión es irrelevante. 900 KB de JavaScript descomprimido sigue siendo 900 KB para el analizador y el compilador, aunque pueden ser aproximadamente 300 KB cuando se comprimen.

Diagrama que ilustra el proceso de descargar, descomprimir, analizar, compilar y ejecutar JavaScript.
El proceso de descargar y ejecutar JavaScript. Ten en cuenta que, si bien el tamaño de transferencia de la secuencia de comandos se comprime de 300 KB, sigue siendo de 900 KB de JavaScript que se debe analizar, compilar y ejecutar.

JavaScript es un recurso costoso de procesar. A diferencia de las imágenes que solo incurren en un tiempo de decodificación relativamente trivial una vez descargadas, JavaScript debe analizarse, compilarse y, luego, ejecutarse. Byte por byte, esto hace que JavaScript sea más caro que otros tipos de recursos.

Diagrama que compara el tiempo de procesamiento de 170 KB de JavaScript y el de una imagen JPEG de tamaño equivalente. El recurso JavaScript requiere mucho más byte por byte que el JPEG.
El costo de procesamiento del análisis o la compilación de 170 KB de JavaScript en comparación con el tiempo de decodificación de un archivo JPEG de tamaño equivalente. (fuente).

Si bien se realizan mejoras continuas para mejorar la eficiencia de los motores de JavaScript, mejorar el rendimiento de JavaScript es, como siempre, una tarea de los desarrolladores.

Con ese fin, existen técnicas para mejorar el rendimiento de JavaScript. La división del código es una técnica de este tipo que mejora el rendimiento mediante la partición del JavaScript de la aplicación en fragmentos y entrega esos fragmentos solo a las rutas de una aplicación que los necesita.

Si bien esta técnica funciona, no aborda un problema común de las aplicaciones con mucho JavaScript, que es la inclusión de código que nunca se usa. La eliminación de árboles intenta resolver este problema.

¿Qué es la eliminación de código no utilizado?

El movimiento de árboles es una forma de eliminación de código muerto. El término se popularizó en Rollup, pero el concepto de eliminación de código muerto existe desde hace tiempo. El concepto también encontró compras en webpack, lo que se demuestra en este artículo a través de una app de ejemplo.

El término “eliminación de código no utilizado” proviene del modelo mental de tu aplicación y sus dependencias como una estructura en forma de árbol. Cada nodo del árbol representa una dependencia que proporciona una funcionalidad distinta para tu app. En las apps modernas, estas dependencias se incorporan mediante instrucciones import estáticas de la siguiente manera:

// Import all the array utilities!
import arrayUtils from "array-utils";

Cuando una app es joven, es un retoño, por así decirlo, puede tener pocas dependencias. También usa la mayoría de las dependencias que agregas, si no todas. Sin embargo, a medida que la app se desarrolle, se podrán agregar más dependencias. Para combinar asuntos, las dependencias más antiguas ya no se usan, pero es posible que no se reduzcan de tu base de código. El resultado final es que una app se envía con mucho JavaScript sin usar. La eliminación de código no utilizado permite aprovechar cómo las sentencias import estáticas extraen partes específicas de los módulos ES6:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

La diferencia entre este ejemplo de import y el anterior es que, en lugar de importar todo desde el módulo "array-utils" (que podría ser mucho código), este ejemplo importa solo partes específicas. En las compilaciones dev, esto no cambia nada, ya que todo el módulo se importa de todas formas. En las compilaciones de producción, webpack se puede configurar para "sacudir" las exportaciones de módulos ES6 que no se importaron de forma explícita, lo que reduce el tamaño de esas compilaciones de producción. Con esta guía, aprenderás a hacerlo.

Encuentra oportunidades para sacudir un árbol

Para fines ilustrativos, hay una aplicación de ejemplo de una página disponible que demuestra cómo funciona la eliminación de código no utilizado. Puedes clonarlo y seguirlo si lo deseas, pero abordaremos todos los pasos del proceso en esta guía, por lo que la clonación no es necesaria (a menos que el aprendizaje práctico sea lo tuyo).

La app de ejemplo es una base de datos en la que se pueden realizar búsquedas de pedales con efectos de guitarra. Ingresas una consulta y aparecerá una lista de pedales de efectos.

Captura de pantalla de una aplicación de ejemplo de una página para buscar en una base de datos de pedales de efectos de guitarra.
Captura de pantalla de la app de ejemplo.

El comportamiento que impulsa esta aplicación se divide en proveedores (es decir, Preact y Emotion) y paquetes de códigos específicos de la app (o "fragmentos", como los llama webpack):

Captura de pantalla de dos paquetes (o bloques) de códigos de la aplicación que se muestran en el panel de red de Herramientas para desarrolladores de Chrome.
Los dos paquetes de JavaScript de la app Estos son tamaños sin comprimir.

Los paquetes de JavaScript que se muestran en la figura anterior son compilaciones de producción, lo que significa que se optimizan mediante la uglificación. 21.1 KB para un paquete específico de app no está mal, pero se debe tener en cuenta que no se produce ninguna eliminación de código no utilizado. Revisemos el código de la app y veamos qué se puede hacer para solucionar ese problema.

En cualquier aplicación, buscar oportunidades para la eliminación de código no utilizado implicará buscar sentencias import estáticas. Cerca de la parte superior del archivo del componente principal, verás una línea como esta:

import * as utils from "../../utils/utils";

Puedes importar módulos de ES6 de diversas maneras, pero estos son para llamar tu atención. Esta línea específica dice "import todo desde el módulo utils, y colócalo en un espacio de nombres llamado utils". La gran pregunta que debes hacer aquí es: "¿qué cantidad de contenido hay en ese módulo?".

Si observas el código fuente del módulo utils, encontrarás alrededor de 1,300 líneas de código.

¿Necesitas todo eso? Volvamos a verificarlo buscando el archivo del componente principal que importa el módulo utils para ver cuántas instancias de ese espacio de nombres aparecen.

Captura de pantalla de la búsqueda de 'utils.' en un editor de texto que muestra solo 3 resultados.
El espacio de nombres utils desde el que importamos toneladas de módulos solo se invoca tres veces dentro del archivo del componente principal.

Resulta que el espacio de nombres utils aparece solo en tres lugares en nuestra aplicación, pero ¿para qué funciones? Si observas de nuevo el archivo del componente principal, parece ser solo una función, que es utils.simpleSort, que se usa para ordenar la lista de resultados de búsqueda por una serie de criterios cuando se cambian los menús desplegables de orden:

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

De un archivo de 1,300 líneas con muchas exportaciones, solo se usa una de ellas. Como resultado, se envía una gran cantidad de JavaScript sin usar.

Si bien esta app de ejemplo puede decirse un poco forzada, no cambia el hecho de que este tipo de situación sintética se parezca a las oportunidades de optimización reales que puedes encontrar en una app web de producción. Ahora que identificaste una oportunidad para que la eliminación de código no utilizado sea útil, ¿cómo se hace en realidad?

Cómo evitar que Babel transpila módulos ES6 a módulos de CommonJS

Babel es una herramienta indispensable, pero puede hacer que los efectos de la agitación de árboles sean un poco más difíciles de observar. Si usas @babel/preset-env, Babel podría transformar los módulos ES6 en módulos CommonJS más compatibles, es decir, módulos que require en lugar de import.

Dado que la eliminación de código no utilizado es más difícil para los módulos de CommonJS, webpack no sabrá qué reducir de los conjuntos si decides usarlos. La solución es configurar @babel/preset-env para que deje solo los módulos ES6 de forma explícita. Donde sea que configures Babel, ya sea en babel.config.js o package.json, esto implica agregar algo adicional:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

Si especificas modules: false en la configuración de @babel/preset-env, Babel se comporta según lo deseado, lo que permite que Webpack analice tu árbol de dependencias y elimine las dependencias que no se usen.

Para tener en cuenta los efectos secundarios

Otro aspecto que debes tener en cuenta cuando quitas las dependencias de tu app es si los módulos de tu proyecto tienen efectos secundarios. Un ejemplo de un efecto secundario es cuando una función modifica algo fuera de su propio alcance, lo que es un efecto colateral de su ejecución:

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

En este ejemplo, addFruit produce un efecto secundario cuando modifica el array fruits, que está fuera de su alcance.

Los efectos secundarios también se aplican a los módulos ES6 y son importantes en el contexto de la eliminación de código no utilizado. Los módulos que toman entradas predecibles y producen salidas igualmente predecibles sin modificar nada fuera de su propio alcance son dependencias que se pueden descartar de forma segura si no los usamos. Son fragmentos de código modulares independientes. Por lo tanto, se llama "modules".

En lo que respecta a webpack, se puede usar una sugerencia para especificar que un paquete y sus dependencias no tengan efectos secundarios especificando "sideEffects": false en el archivo package.json de un proyecto:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

Como alternativa, puedes indicarle a Webpack qué archivos específicos no tienen efectos secundarios:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

En el último ejemplo, se considerará que cualquier archivo que no se especifique no tiene efectos secundarios. Si no quieres agregar esto a tu archivo package.json, también puedes especificar esta marca en la configuración de tu webpack a través de module.rules.

Importa solo lo necesario

Después de indicarle a Babel que deje los módulos ES6 solos, se requiere un leve ajuste en nuestra sintaxis de import para incorporar solo las funciones necesarias del módulo utils. En el ejemplo de esta guía, lo único que se necesita es la función simpleSort:

import { simpleSort } from "../../utils/utils";

Debido a que solo se importa simpleSort en lugar de todo el módulo utils, cada instancia de utils.simpleSort deberá cambiar a simpleSort:

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

Esto debería ser todo lo que se necesita para que la eliminación de código no funcione en este ejemplo. Este es el resultado del webpack antes de sacudir el árbol de dependencias:

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

Este es el resultado después de que la eliminación de código se realice correctamente:

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

Si bien se redujeron los dos paquetes, lo que más se beneficia es el de main. Cuando quitas las partes sin usar del módulo utils, el paquete main se reduce en aproximadamente un 60%. Esto no solo reduce el tiempo que tarda la secuencia de comandos en descargar, sino también el tiempo de procesamiento.

Sacude algunos árboles.

El kilometraje que obtengas con la eliminación de código no utilizado depende de tu aplicación, sus dependencias y arquitectura. Pruébalo Si sabes de verdad que no configuraste tu agrupador de módulos para que realice esta optimización, no hay problema en probar y ver cómo beneficia a tu aplicación.

Es posible que obtengas una mejora significativa en el rendimiento con la eliminación de código no utilizado o que no obtengas demasiado. Sin embargo, al configurar tu sistema de compilación para aprovechar esta optimización en compilaciones de producción e importar de forma selectiva solo lo que tu aplicación necesita, podrás mantener de forma proactiva los paquetes de aplicaciones lo más pequeños posible.

Agradecemos especialmente a Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone y Philip Walton por sus valiosos comentarios, que mejoraron significativamente la calidad de este artículo.