Sacudir árboles: una guía de referencia
Publicado: 2022-03-10Antes de comenzar nuestro viaje para aprender qué es el movimiento de árboles y cómo prepararnos para tener éxito con él, debemos comprender qué módulos hay en el ecosistema de JavaScript.
Desde sus inicios, los programas de JavaScript han crecido en complejidad y en la cantidad de tareas que realizan. Se hizo evidente la necesidad de compartimentar tales tareas en ámbitos cerrados de ejecución. Estos compartimentos de tareas, o valores, son lo que llamamos módulos . Su objetivo principal es evitar la repetición y aprovechar la reutilización. Por lo tanto, las arquitecturas se diseñaron para permitir estos tipos especiales de alcance, para exponer sus valores y tareas, y para consumir valores y tareas externos.
Para profundizar en qué son los módulos y cómo funcionan, recomiendo "Módulos ES: una inmersión profunda de dibujos animados". Pero para comprender los matices de la sacudida de árboles y el consumo de módulos, la definición anterior debería ser suficiente.
¿Qué significa realmente sacudir los árboles?
En pocas palabras, sacudir el árbol significa eliminar el código inalcanzable (también conocido como código muerto) de un paquete. Como dice la documentación de la versión 3 de Webpack:
“Puedes imaginar tu aplicación como un árbol. El código fuente y las bibliotecas que usa representan las hojas verdes y vivas del árbol. El código muerto representa las hojas marrones y muertas del árbol que se consumen en otoño. Para deshacerte de las hojas muertas, tienes que sacudir el árbol y hacer que se caigan”.
El término fue popularizado por primera vez en la comunidad front-end por el equipo de Rollup. Pero los autores de todos los lenguajes dinámicos han estado lidiando con el problema desde mucho antes. La idea de un algoritmo de agitación de árboles se remonta al menos a principios de la década de 1990.
En la tierra de JavaScript, la sacudida de árboles ha sido posible desde la especificación del módulo ECMAScript (ESM) en ES2015, anteriormente conocido como ES6. Desde entonces, la sacudida de árboles se ha habilitado de forma predeterminada en la mayoría de los paquetes porque reducen el tamaño de salida sin cambiar el comportamiento del programa.
La razón principal de esto es que los ESM son estáticos por naturaleza. Analicemos lo que eso significa.
Módulos ES frente a CommonJS
CommonJS es anterior a la especificación ESM por algunos años. Surgió para abordar la falta de soporte para módulos reutilizables en el ecosistema de JavaScript. CommonJS tiene una función require()
que obtiene un módulo externo en función de la ruta proporcionada y lo agrega al alcance durante el tiempo de ejecución.
Ese require
es una function
como cualquier otra en un programa lo que hace que sea bastante difícil evaluar el resultado de su llamada en tiempo de compilación. Además de eso, está el hecho de que es posible agregar llamadas require
en cualquier parte del código, envueltas en otra llamada de función, dentro de declaraciones if/else, en declaraciones switch, etc.
Con el aprendizaje y las dificultades que han resultado de la amplia adopción de la arquitectura CommonJS, la especificación ESM se ha decidido por esta nueva arquitectura, en la que los módulos se importan y exportan mediante las respectivas palabras clave import
y export
. Por lo tanto, no más llamadas funcionales. Los ESM también se permiten solo como declaraciones de nivel superior: no es posible anidarlos en ninguna otra estructura, ya que son estáticos : los ESM no dependen de la ejecución en tiempo de ejecución.
Alcance y efectos secundarios
Sin embargo, hay otro obstáculo que debe superar la sacudida de árboles para evitar la hinchazón: los efectos secundarios. Se considera que una función tiene efectos secundarios cuando altera o depende de factores externos al ámbito de ejecución. Una función con efectos secundarios se considera impura . Una función pura siempre producirá el mismo resultado, independientemente del contexto o el entorno en el que se haya ejecutado.
const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c
Los empaquetadores cumplen su propósito al evaluar el código proporcionado tanto como sea posible para determinar si un módulo es puro. Pero la evaluación del código durante el tiempo de compilación o el tiempo de empaquetado solo puede llegar hasta cierto punto. Por lo tanto, se supone que los paquetes con efectos secundarios no pueden eliminarse adecuadamente, incluso cuando son completamente inalcanzables.
Debido a esto, los empaquetadores ahora aceptan una clave dentro del archivo package.json
del módulo que permite al desarrollador declarar si un módulo no tiene efectos secundarios. De esta manera, el desarrollador puede optar por no participar en la evaluación del código y dar pistas al empaquetador; el código dentro de un paquete en particular se puede eliminar si no hay una importación accesible o una declaración require
que lo vincule. Esto no solo lo convierte en un paquete más delgado, sino que también puede acelerar los tiempos de compilación.
{ "name": "my-package", "sideEffects": false }
Por lo tanto, si usted es un desarrollador de paquetes, haga un uso consciente de sideEffects
antes de publicar y, por supuesto, revíselo en cada versión para evitar cambios inesperados.
Además de la clave raíz sideEffects
, también es posible determinar la pureza archivo por archivo, anotando un comentario en línea, /*@__PURE__*/
, a su llamada de método.
const x = */@__PURE__*/eliminated_if_not_called()
Considero que esta anotación en línea es una vía de escape para el desarrollador del consumidor, que se debe realizar en caso de que un paquete no haya declarado sideEffects: false
o en caso de que la biblioteca presente un efecto secundario en un método en particular.
Optimización del paquete web
Desde la versión 4 en adelante, Webpack ha requerido progresivamente menos configuración para que las mejores prácticas funcionen. La funcionalidad de un par de complementos se ha incorporado al núcleo. Y debido a que el equipo de desarrollo se toma muy en serio el tamaño del paquete, han hecho que sacudir árboles sea fácil.
Si no es un experto en retoques o si su aplicación no tiene casos especiales, entonces, sacudir los árboles de sus dependencias es cuestión de una sola línea.
El archivo webpack.config.js
tiene una propiedad raíz llamada mode
. Siempre que el valor de esta propiedad sea production
, sacudirá los árboles y optimizará por completo sus módulos. Además de eliminar el código muerto con TerserPlugin
, el mode: 'production'
habilitará nombres alterados deterministas para módulos y fragmentos, y activará los siguientes complementos:
- uso de dependencia de banderas,
- bandera incluye trozos,
- concatenación de módulos,
- no emitir en errores.
No es casualidad que el valor desencadenante sea la production
. No querrá que sus dependencias estén completamente optimizadas en un entorno de desarrollo porque hará que los problemas sean mucho más difíciles de depurar. Así que sugeriría hacerlo con uno de dos enfoques.
Por un lado, podría pasar un indicador de mode
a la interfaz de línea de comandos de Webpack:
# This will override the setting in your webpack.config.js webpack --mode=production
Alternativamente, puede usar la variable process.env.NODE_ENV
en webpack.config.js
:
mode: process.env.NODE_ENV === 'production' ? 'production' : development
En este caso, debe recordar pasar --NODE_ENV=production
en su canalización de implementación.
Ambos enfoques son una abstracción además del muy conocido definePlugin
de Webpack versión 3 y anteriores. La opción que elija no hace absolutamente ninguna diferencia.
Webpack versión 3 y anteriores
Vale la pena mencionar que los escenarios y ejemplos de esta sección pueden no aplicarse a las versiones recientes de Webpack y otros paquetes. Esta sección considera el uso de UglifyJS versión 2, en lugar de Terser. UglifyJS es el paquete del que se bifurcó Terser, por lo que la evaluación del código puede diferir entre ellos.
Debido a que Webpack versión 3 y anteriores no admiten la propiedad sideEffects
en package.json
, todos los paquetes deben evaluarse por completo antes de que se elimine el código. Esto solo hace que el enfoque sea menos efectivo, pero también se deben considerar varias advertencias.
Como se mencionó anteriormente, el compilador no tiene forma de averiguar por sí mismo cuándo un paquete está manipulando el alcance global. Pero esa no es la única situación en la que se salta el movimiento de árboles. Hay escenarios más confusos.
Tome este ejemplo de paquete de la documentación de Webpack:
// transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });
Y aquí está el punto de entrada de un paquete de consumo:
// index.js import { someVar } from './transforms.js'; // Use `someVar`...
No hay forma de determinar si mylib.transform
efectos secundarios. Por lo tanto, no se eliminará ningún código.
Aquí hay otras situaciones con un resultado similar:
- invocar una función de un módulo de terceros que el compilador no puede inspeccionar,
- funciones de reexportación importadas de módulos de terceros.
Una herramienta que podría ayudar al compilador a hacer funcionar la sacudida de árboles es babel-plugin-transform-imports. Dividirá todas las exportaciones de miembros y con nombre en exportaciones predeterminadas, lo que permitirá que los módulos se evalúen individualmente.
// before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';
También tiene una propiedad de configuración que advierte al desarrollador que evite declaraciones de importación problemáticas. Si está en Webpack versión 3 o superior, y ha realizado su diligencia debida con la configuración básica y ha agregado los complementos recomendados, pero su paquete aún parece inflado, le recomiendo que pruebe este paquete.
Elevación del alcance y tiempos de compilación
En la época de CommonJS, la mayoría de los empaquetadores simplemente envolvían cada módulo dentro de otra declaración de función y los asignaban dentro de un objeto. Eso no es diferente a cualquier objeto de mapa por ahí:
(function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")
Además de ser difícil de analizar estáticamente, esto es fundamentalmente incompatible con los ESM, porque hemos visto que no podemos encapsular declaraciones de import
y export
. Entonces, hoy en día, los empaquetadores elevan cada módulo al nivel superior:
// moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()
Este enfoque es totalmente compatible con los ESM; además, permite que la evaluación del código detecte fácilmente los módulos que no se están llamando y los elimine. La advertencia de este enfoque es que, durante la compilación, toma mucho más tiempo porque toca cada declaración y almacena el paquete en la memoria durante el proceso. Esa es una gran razón por la que el rendimiento de los paquetes se ha convertido en una preocupación aún mayor para todos y por la que los lenguajes compilados se aprovechan en las herramientas para el desarrollo web. Por ejemplo, esbuild es un paquete escrito en Go y SWC es un compilador TypeScript escrito en Rust que se integra con Spark, un paquete también escrito en Rust.
Para comprender mejor la elevación del alcance, recomiendo encarecidamente la documentación de la versión 2 de Parcel.
Evite la transpilación prematura
Hay un problema específico que, lamentablemente, es bastante común y puede ser devastador para el movimiento de árboles. En resumen, sucede cuando trabaja con cargadores especiales, integrando diferentes compiladores a su paquete. Las combinaciones comunes son TypeScript, Babel y Webpack, en todas las permutaciones posibles.
Tanto Babel como TypeScript tienen sus propios compiladores y sus respectivos cargadores permiten que el desarrollador los use para una fácil integración. Y ahí radica la amenaza oculta.
Estos compiladores llegan a su código antes de la optimización del código. Y ya sea por configuración predeterminada o incorrecta, estos compiladores a menudo generan módulos CommonJS, en lugar de ESM. Como se mencionó en una sección anterior, los módulos CommonJS son dinámicos y, por lo tanto, no se pueden evaluar correctamente para la eliminación de código inactivo.
Este escenario se está volviendo aún más común hoy en día, con el crecimiento de las aplicaciones "isomorfas" (es decir, aplicaciones que ejecutan el mismo código tanto en el lado del servidor como en el del cliente). Debido a que Node.js aún no tiene soporte estándar para ESM, cuando los compiladores están destinados al entorno del node
, generan CommonJS.
Por lo tanto, asegúrese de verificar el código que recibe su algoritmo de optimización .
Lista de verificación para sacudir árboles
Ahora que conoce los entresijos de cómo funcionan la agrupación y la sacudida de árboles, dibujemos una lista de verificación que pueda imprimir en algún lugar útil para cuando revise su implementación actual y su base de código. Con suerte, esto le ahorrará tiempo y le permitirá optimizar no solo el rendimiento percibido de su código, ¡sino incluso los tiempos de compilación de su canalización!
- Use ESM, y no solo en su propia base de código, sino que también favorezca los paquetes que generan ESM como sus consumibles.
- Asegúrese de saber exactamente cuáles (si las hay) de sus dependencias no han declarado
sideEffects
o las han establecido comotrue
. - Utilice la anotación en línea para declarar las llamadas a métodos que son puras cuando se consumen paquetes con efectos secundarios.
- Si está generando módulos CommonJS, asegúrese de optimizar su paquete antes de transformar las declaraciones de importación y exportación.
Creación de paquetes
Con suerte, en este punto todos estamos de acuerdo en que los ESM son el camino a seguir en el ecosistema de JavaScript. Sin embargo, como siempre en el desarrollo de software, las transiciones pueden ser complicadas. Afortunadamente, los autores de paquetes pueden adoptar medidas ininterrumpidas para facilitar una migración rápida y sin problemas para sus usuarios.
Con algunas pequeñas adiciones a package.json
, su paquete podrá indicar a los empaquetadores los entornos que admite el paquete y cuál es la mejor forma de admitirlos. Aquí hay una lista de verificación de Skypack:
- Incluya una exportación de ESM.
- Agregue
"type": "module"
. - Indique un punto de entrada a través de
"module": "./path/entry.js"
(una convención de la comunidad).
Y aquí hay un ejemplo que resulta cuando se siguen todas las mejores prácticas y desea admitir entornos web y Node.js:
{ // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }
Además de esto, el equipo de Skypack ha introducido un puntaje de calidad del paquete como punto de referencia para determinar si un paquete determinado está configurado para la longevidad y las mejores prácticas. La herramienta es de código abierto en GitHub y se puede agregar como una devDependency
de desarrollo a su paquete para realizar las comprobaciones fácilmente antes de cada lanzamiento.
Terminando
Espero que este artículo te haya sido útil. Si es así, considere compartirlo con su red. Espero poder interactuar contigo en los comentarios o en Twitter.
Recursos útiles
Artículos y Documentación
- "Módulos ES: una inmersión profunda de dibujos animados", Lin Clark, Mozilla Hacks
- “Tree Shaking”, Webpack
- “Configuración”, Webpack
- “Optimización”, Webpack
- “Scope Hoisting”, documentación de la versión 2 de Parcel
Proyectos y Herramientas
- Terser
- babel-plugin-transform-importaciones
- Skypack
- paquete web
- Paquete o empaquetar
- Enrollar
- esconstruir
- SWC
- Comprobación del paquete