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

Las aplicaciones web actuales pueden ser bastante grandes, especialmente en la parte de JavaScript. A partir de mediados de 2018, HTTP Archive coloca el tamaño de transferencia mediana de JavaScript en dispositivos móviles en aproximadamente 350 KB. Esto es solo el tamaño de la transferencia. JavaScript a menudo 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 señalar esto, porque en lo que respecta al procesamiento de recursos, la compresión es irrelevante. 900 KB de JavaScript descomprimido siguen siendo 900 KB para el analizador y el compilador, aunque pueden ser alrededor de 300 KB cuando se comprimen.

Diagrama que ilustra el proceso de descarga, descompresión, análisis, compilación y ejecución de JavaScript.
Es el proceso de descargar y ejecutar JavaScript. Ten en cuenta que, a pesar de que el tamaño de transferencia de la secuencia de comandos está comprimido, sigue siendo 900 KB de JavaScript que se deben analizar, compilar y ejecutar.

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

Un diagrama que compara el tiempo de procesamiento de 170 KB de JavaScript con una imagen JPEG de tamaño equivalente. El recurso JavaScript requiere más byte por byte de recursos que el JPEG.
El costo de procesamiento de analizar/compilar 170 KB de JavaScript y el tiempo de decodificación de un archivo JPEG de tamaño equivalente (fuente).

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

Con ese fin, existen técnicas para mejorar el rendimiento de JavaScript. La división de código es una técnica de ese tipo que mejora el rendimiento, ya que particiona el 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 soluciona un problema común de las aplicaciones con mucho contenido de JavaScript, que es la inclusión de código que nunca se usa. El movimiento de los árboles intenta resolver este problema.

¿Qué es la eliminación de árboles?

El sismo de los árboles es una forma de eliminar el código muerto. El término se popularizó en Rollup, pero el concepto de eliminación del código no muerto existe desde hace tiempo. El concepto también encontró la compra en webpack, que se demuestra en este artículo mediante una app de ejemplo.

El término “movimiento de árboles” 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 a través de declaraciones import estáticas de la siguiente manera:

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

Cuando una app es joven (un nuevo reciente, 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 tu app madura, se pueden agregar más dependencias. Para combinar cuestiones, las dependencias más antiguas quedan obsoletas, pero es posible que no se reduzcan de tu base de código. El resultado final es que una app termina con el envío con mucho JavaScript sin usar. La sacación de árboles soluciona esto aprovechando la forma en que las instrucciones 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 del módulo "array-utils", que podría ser mucho código, en este ejemplo solo se importan partes específicas. En las compilaciones de desarrollo, esto no cambia nada, ya que, de todos modos, se importa todo el módulo. En compilaciones de producción, webpack se puede configurar para que “agite” exportaciones de módulos de ES6 que no se importaron explícitamente, lo que hace que esas compilaciones de producción sean más pequeñas. ¡En esta guía aprenderás cómo hacerlo!

Encontrar oportunidades para hacer cambios en los árboles

A fines ilustrativos, hay disponible una aplicación de ejemplo de una página que muestra cómo funciona la eliminación de árboles. Puedes clonarla y seguirla si lo deseas, pero, en esta guía, analizaremos cada paso del proceso juntos, 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 buscar pedales de efectos de guitarra. Si ingresas una consulta, 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 app se divide en proveedores (es decir, Preact y Emotion) y paquetes de códigos específicos de la app (o "fragmentos", como los denomina el paquete web):

Captura de pantalla de dos paquetes de códigos de aplicación (o fragmentos) que se muestran en el panel de red de Herramientas para desarrolladores de Chrome.
Son 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 a través de la uglificación. 21.1 KB para un paquete específico de una app no es malo, pero debes tener en cuenta que no habrá ninguna eliminación de código no utilizado. Veamos el código de la app y veamos qué se puede hacer para solucionar esto.

En cualquier aplicación, la búsqueda de oportunidades de eliminación de código implican la búsqueda de sentencias import estáticas. Cerca de la parte superior del archivo de componente principal, verás una línea como la siguiente:

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

Puedes importar módulos ES6 de diferentes maneras, pero ten en cuenta que el uso de estos son los módulos que necesitas. 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 hay que hacer aquí es: “¿qué cantidad de cosas hay en ese módulo?”.

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

¿Necesitas todos esos elementos? Busquemos 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 una búsqueda en un editor de texto de "utils.", 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.

Como resulta, el espacio de nombres utils aparece en solo tres lugares en nuestra aplicación, pero ¿para qué funciones? Si observas el archivo del componente principal otra vez, 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 mucho JavaScript sin usar.

Si bien esta app de ejemplo es un poco forzada, no cambia el hecho de que este tipo de situación sintética se asemeja a oportunidades de optimización reales que puedes encontrar en una app web de producción. Ahora que identificaste una oportunidad para que sea útil la eliminación de árboles, ¿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 sacudida de árboles sean un poco más difíciles de observar. Si usas @babel/preset-env, Babel puede transformar módulos de ES6 en módulos de CommonJS más compatibles, es decir, módulos require en lugar de import.

Debido a que la eliminación de código no utilizado en los módulos de CommonJS es más difícil, webpack no sabrá qué reducir de los paquetes si decides usarlos. La solución es configurar @babel/preset-env para que deje solos los módulos de ES6 de forma explícita. Dondequiera 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
      }
    ]
  ]
}

La especificación de modules: false en la configuración de @babel/preset-env hace que Babel se comporte como desees, lo que permite que webpack analice tu árbol de dependencias y elimine las dependencias que no se usen.

Tener en cuenta los efectos secundarios

Otro aspecto que debes tener en cuenta a la hora de sacudir dependencias de tu app es si los módulos de tu proyecto tienen efectos secundarios. Un ejemplo de efecto colateral es cuando un la función modifica algo fuera de su propio alcance, que es un efecto secundario 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 denominan “módulos”.

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 supondrá que cualquier archivo que no se especifique no tendrá efectos secundarios. Si no quieres agregar esto a tu archivo package.json, también puedes especificar esta marca en la configuración de webpack a través de module.rules.

Importa solo lo necesario

Después de indicarle a Babel que deje solo los módulos ES6, se requiere un ligero ajuste en la sintaxis de import para introducir 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";

Como solo se importa simpleSort en lugar de todo el módulo utils, cada instancia de utils.simpleSort deberá cambiarse 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 utilizado funcione en este ejemplo. Este es el resultado del paquete web antes de agitar 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 no sea exitosa:

                 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 ambos paquetes se reducen, es el paquete main el que más se beneficia. Al sacudir las partes que no se usan del módulo utils, el paquete main se reduce alrededor de un 60%. Esto no solo reduce la cantidad de tiempo que tarda la secuencia de comandos en descargarse, sino también el tiempo de procesamiento.

¡Ve a sacudir algunos árboles!

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

Puede obtener un aumento significativo en el rendimiento de la eliminación de árboles o simplemente no obtener muchos. Sin embargo, si configuras tu sistema de compilación para aprovechar esta optimización en las compilaciones de producción y, además, importas de forma selectiva solo lo que tu aplicación necesita, mantendrás proactivamente los paquetes de aplicaciones lo más pequeños posible.

Un agradecimiento especial 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.