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

Las aplicaciones web actuales pueden ser bastante grandes, en especial la parte de JavaScript. A mediados de 2018, HTTP Archive estableció el tamaño de transferencia promedio 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 señalar esto, ya que, en lo que respecta al procesamiento de recursos, la compresión es irrelevante. 900 KB de JavaScript sin comprimir siguen siendo 900 KB para el analizador y el compilador, aunque pueden ser alrededor de 300 KB cuando se comprimen.

Un 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, aunque el tamaño de transferencia de la secuencia de comandos es de 300 KB comprimidos, aún son 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 un tiempo de decodificación relativamente trivial una vez que se descargan, 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 en comparación con una imagen JPEG de tamaño equivalente. El recurso de JavaScript es mucho más intensivo en recursos byte por byte que el JPEG.
El costo de procesamiento de analizar o compilar 170 KB de JavaScript en comparación con el tiempo de decodificación de un JPEG de tamaño equivalente. (fuente).

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

Para ello, existen técnicas que permiten mejorar el rendimiento de JavaScript. La división de código es una de esas técnicas que mejora el rendimiento particionando el código JavaScript de la aplicación en fragmentos y publicándolos solo en las rutas de una aplicación que los necesite.

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

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

La eliminación de código muerto es una forma de eliminar el código no alcanzado. Rollup popularizó el término, pero el concepto de eliminación de código muerto existe desde hace algún tiempo. El concepto también se implementó en webpack, que se muestra en este artículo a través de una app de ejemplo.

El término "tree shaking" 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 sentencias import estáticas de la siguiente manera:

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

Cuando una app es joven (un retoño, si quieres), 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 madure, se pueden agregar más dependencias. Para complicar las cosas, las dependencias más antiguas dejan de usarse, pero es posible que no se quiten de tu base de código. El resultado final es que una app termina publicándose con mucho código JavaScript no utilizado. El desprendimiento de árboles aborda esto aprovechando la forma en que 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 del módulo "array-utils" (que podría ser mucho código), este ejemplo solo importa partes específicas. En las compilaciones para desarrolladores, esto no cambia nada, ya que se importa todo el módulo de todos modos. En las compilaciones de producción, webpack se puede configurar para “sacudir” las exportaciones de los módulos ES6 que no se importaron de forma explícita, lo que hace que esas compilaciones de producción sean más pequeñas. En esta guía, aprenderás a hacerlo.

Busca oportunidades para sacudir un árbol

A modo de ejemplo, hay disponible una app de una sola página de muestra que demuestra cómo funciona el movimiento de árboles. Puedes clonarlo y seguir el proceso si lo deseas, pero en esta guía, analizaremos cada paso del proceso juntos, por lo que no es necesario clonarlo (a menos que te guste el aprendizaje práctico).

La app de ejemplo es una base de datos de pedales de efectos de guitarra que se puede buscar. Ingresas una consulta y aparecerá una lista de pedales de efectos.

Captura de pantalla de una aplicación de una página de ejemplo 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ódigo específicos de la app (o "fragmentos", como los llama webpack):

Captura de pantalla de dos paquetes de código de aplicación (o fragmentos) que se muestran en el panel de red de DevTools 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 están optimizados a través de la uglificación. 21.1 KB para un paquete específico de la app no es malo, pero se debe tener en cuenta que no se está realizando ningún movimiento de árbol. Veamos el código de la app y averigüemos qué se puede hacer para solucionarlo.

En cualquier aplicación, encontrar oportunidades de agitación de árboles 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 ES6 de varias maneras, pero los siguientes deberían llamar tu atención. Esta línea específica dice "import todo del módulo utils y lo coloca en un espacio de nombres llamado utils". La gran pregunta que debemos hacernos aquí es: "¿Cuánta cosa 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 todo eso? Para verificarlo, busquemos el archivo de componentes principal que importa el módulo utils para ver cuántas instancias de ese espacio de nombres aparecen.

Una captura de pantalla de una búsqueda en un editor de texto para "utils", que muestra solo 3 resultados.
El espacio de nombres utils del 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 de nuestra aplicación, pero ¿para qué funciones? Si vuelves a mirar el archivo del componente principal, parece que solo hay una función, que es utils.simpleSort, que se usa para ordenar la lista de resultados de la búsqueda según varios criterios cuando se cambian los menús desplegables de ordenamiento:

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. Esto genera el envío de mucho código JavaScript sin usar.

Si bien esta app de ejemplo es un poco artificial, no cambia el hecho de que este tipo de situación sintética se asemeja a las oportunidades de optimización reales que puedes encontrar en una app web de producción. Ahora que identificaste una oportunidad para que el movimiento de árboles sea útil, ¿cómo se hace en realidad?

Evita que Babel transpile módulos ES6 a módulos CommonJS.

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

Debido a que la reducción de árboles es más difícil de realizar para los módulos de CommonJS, webpack no sabrá qué podar de los paquetes si decides usarlos. La solución es configurar @babel/preset-env para que deje los módulos ES6 en paz de forma explícita. Dondequiera que configures Babel, ya sea en babel.config.js o package.json, esto implica agregar algo más:

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

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

Ten en cuenta los efectos secundarios

Otro aspecto que debes tener en cuenta cuando sacudes 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, 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 eso es importante en el contexto de la eliminación de árboles. Los módulos que toman entradas predecibles y producen resultados igualmente predecibles sin modificar nada fuera de su propio alcance son dependencias que se pueden descartar de forma segura si no las usamos. Son piezas de código modulares independientes. Por lo tanto, "módulos".

En el caso de webpack, se puede usar una sugerencia para especificar que un paquete y sus dependencias no tienen efectos secundarios. Para ello, especifica "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 están libres de 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 webpack a través de module.rules.

Cómo importar solo lo que se necesita

Después de indicarle a Babel que deje en paz los módulos ES6, se requiere un ligero ajuste en nuestra sintaxis import para incluir solo las funciones necesarias del módulo utils. En el ejemplo de esta guía, todo lo 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, se deberá cambiar cada instancia de utils.simpleSort 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 el movimiento de árboles funcione en este ejemplo. Este es el resultado de 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 el movimiento de árboles se realiza 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 ambos paquetes se redujeron, el paquete main es el que más se beneficia. Si se quitan las partes sin usar del módulo utils, el paquete main se reduce en aproximadamente 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 rendimiento que obtengas del movimiento de árboles depende de tu app, sus dependencias y su arquitectura. Pruébalo Si sabes con certeza que no configuraste el empaquetador de módulos para realizar esta optimización, no te hará daño intentarlo y ver cómo beneficia a tu aplicación.

Es posible que obtengas un aumento significativo del rendimiento con el movimiento de árboles o que no obtengas mucho. Sin embargo, si configuras tu sistema de compilación para aprovechar esta optimización en las compilaciones de producción y solo importas de forma selectiva lo que necesita tu aplicación, mantendrás de forma proactiva tus 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.