wandres.dev
TREE SHAKING · Eliminar lo muerto

Por qué el tree shaking depende de los módulos ES y no funciona con CommonJS

La diferencia entre enlaces estáticos y objetos de exportación en tiempo de ejecución, qué puede demostrar un análisis estático y qué no, y qué hacer con una dependencia que solo se publica en CommonJS.

⏱ 18 min

Eliminar el código que no se usa parece un problema de análisis y es en realidad un problema de demostración: el empaquetador solo puede borrar algo si puede probar que borrarlo no cambia el comportamiento del programa. Con módulos ES esa prueba es posible en muchos casos porque la forma del módulo es conocida antes de ejecutar nada. Con CommonJS la prueba es imposible en el caso general, y no por falta de esfuerzo de las herramientas, sino por la semántica del formato.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la estructura de un módulo ES es conocida antes de ejecutarlo.
  • Demostrar con un ejemplo por qué CommonJS impide el análisis.
  • Reconocer cuándo una dependencia se está incluyendo entera y por qué.
  • Elegir la salida correcta para una dependencia que solo publica CommonJS.

Enlaces estáticos frente a un objeto que se rellena

La diferencia de raíz es que en un módulo ES las importaciones y exportaciones son declaraciones, no expresiones. Están en el nivel superior, no pueden estar dentro de un if, no pueden construirse con una cadena calculada, y el motor las resuelve antes de ejecutar una sola línea del módulo.

// modulo.js — la forma del modulo es conocida sin ejecutarlo
export function alfa() { return 1; }
export function beta() { return 2; }
export const GAMMA = 3;
// consumidor.js
import { alfa } from './modulo.js';
console.log(alfa());

El empaquetador, sin ejecutar nada, sabe tres cosas: que modulo.js exporta exactamente alfa, beta y GAMMA; que consumidor.js usa exclusivamente alfa; y que nadie más importa ese módulo. Con eso puede demostrar que beta y GAMMA no son alcanzables, y borrarlas es una transformación que preserva el comportamiento.

Ahora el equivalente en CommonJS:

// modulo.cjs
exports.alfa = function () { return 1; };
exports.beta = function () { return 2; };
exports.GAMMA = 3;
// consumidor.cjs
const m = require('./modulo.cjs');
console.log(m.alfa());

Aquí exports es un objeto normal de JavaScript, y require es una función que se ejecuta. Para saber qué exporta el módulo hay que ejecutarlo. Y como es un objeto normal, cualquier cosa puede pasar:

// Todo esto es CommonJS valido, y ninguna herramienta puede analizarlo estaticamente
if (process.env.MODO === 'completo') {
  exports.extra = require('./extra.cjs');
}
for (const nombre of Object.keys(tabla)) {
  exports[nombre] = crearManejador(nombre);
}
module.exports = new Proxy({}, { get: (_, k) => generar(k) });

Un empaquetador que quisiera recortar tendría que resolver, en tiempo de compilación, qué vale process.env.MODO, qué claves tiene tabla y qué devuelve un proxy arbitrario. Eso es equivalente al problema de la parada. No es que las herramientas no lo hagan bien: es que no se puede hacer.

De ahí la regla operativa: ante un módulo CommonJS, el empaquetador conservador lo incluye entero. Es la única transformación que garantiza no romper nada.

Qué sí puede probar el análisis estático

Merece la pena precisar qué demuestra realmente el análisis, porque también se sobreestima.

Puede probar que una exportación no se importa desde ningún sitio. Es el caso base y el que más rinde.

Puede probar que una rama es inalcanzable con constantes conocidas. Si process.env.NODE_ENV se sustituye por la cadena "production" en tiempo de compilación, el bloque del if de desarrollo se convierte en código muerto y desaparece. Esto es lo que hace que el mismo código pese distinto en desarrollo y en producción.

Puede probar que una función pura cuyo resultado no se usa se puede borrar. «Pura» significa sin efectos secundarios observables, y ahí está el problema: el empaquetador no puede demostrar la pureza en el caso general. Lo que hace es asumir que ciertas construcciones son puras —una declaración de función, una expresión de clase, un literal de objeto— y ser conservador con todo lo demás. Por eso existen las anotaciones explícitas de pureza:

// El comentario le dice al empaquetador: si nadie usa 'config', borra la llamada
export const config = /* @__PURE__ */ construirConfiguracion();

Sin ese comentario, una llamada a función en el nivel superior se conserva siempre, porque podría estar registrando algo, modificando un global o lanzando una excepción.

Lo que no puede probar nunca es lo que ocurre a través de una indirección dinámica. Acceso a propiedad con clave calculada, eval, proxies, Function construida desde una cadena. Cualquiera de esas cosas obliga a conservar.

Cómo saber si una dependencia se está incluyendo entera

Tres señales, de más rápida a más fiable.

El campo del package.json de la dependencia. Si no hay module ni un exports con condición import, el paquete solo publica CommonJS:

npm view la-biblioteca --json | grep -E '"main"|"module"|"exports"|"type"'

Un paquete con "main": "dist/index.js" y nada más, sin "type": "module", es CommonJS puro.

El peso en el informe del bundle. Si importas una función de una biblioteca y el informe muestra la biblioteca completa, no se ha recortado.

La prueba directa. Compila con la importación y sin ella, y compara. Es lo único que no admite discusión, y es el tema de verificar que se eliminó.

Qué hacer con una dependencia solo en CommonJS

Cuatro salidas, en orden de preferencia.

Buscar el paquete con módulos ES equivalente. Muchas bibliotecas populares publican una variante o han añadido el punto de entrada moderno en versiones recientes. Actualizar la versión mayor es a menudo todo lo que hace falta.

Importar el submódulo concreto. Aunque el paquete sea CommonJS, si su estructura de ficheros permite importar directamente el fichero que necesitas, te llevas solo ese fichero y sus dependencias:

// Arrastra el paquete entero
const { throttle } = require('la-biblioteca');

// Arrastra solo un fichero y lo que el importe
const throttle = require('la-biblioteca/throttle');

Funciona si el paquete no restringe las rutas internas con el campo exports. Si lo hace, esta salida se cierra.

Cargarla de forma diferida. Si no se puede recortar, al menos que no esté en el arranque. Un import() la saca de la ruta crítica y el problema del tamaño se convierte en un problema de latencia, que es mucho más manejable.

Sustituirla. La última salida, con el coste que ya conoces del árbol de decisión de dependencias.

La interoperabilidad entre los dos formatos añade un coste que nadie contabiliza, y a veces duplica el módulo

Hay una capa de este problema que no es de tamaño de código sino de la maquinaria que el empaquetador tiene que emitir para que los dos mundos convivan. Tiene tres manifestaciones y las tres se ven en producción.

Uno: los ayudantes de interoperabilidad. Cuando un módulo ES importa un módulo CommonJS, el empaquetador emite código que envuelve el objeto de exportaciones y simula un espacio de nombres de módulo, incluyendo la lógica de __esModule y del default sintético. Es poco código por caso, y con cincuenta dependencias CommonJS suma entre 2 y 6 KB de puro pegamento.

Dos, y este sí es caro: la doble instancia. Muchos paquetes publican las dos versiones, y el campo exports decide cuál se sirve según cómo se importe. Si en tu grafo una dependencia lo importa con require y otra con import, el empaquetador puede acabar incluyendo las dos copias. No es hipotético: ocurre constantemente con bibliotecas de estado y con clientes de datos, y el síntoma es doblemente desagradable porque además de pesar el doble, las dos copias tienen estado independiente. Un almacén global que aparece vacío desde una parte de la aplicación y lleno desde otra es casi siempre esto.

La detección es directa: busca el mismo nombre de paquete apareciendo dos veces en el informe del bundle, o compruébalo sobre el metafichero:

node -e "
  const m=JSON.parse(require('fs').readFileSync('meta.json','utf8'));
  const cuenta={};
  for(const k of Object.keys(m.inputs)){
    const mm=k.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/);
    if(!mm) continue;
    (cuenta[mm[1]] ??= new Set()).add(k.includes('/esm/')||k.endsWith('.mjs')?'esm':'cjs');
  }
  for(const [p,f] of Object.entries(cuenta)) if(f.size>1) console.log('DOBLE FORMATO:', p);
"

La cura es forzar una única resolución. Con un empaquetador que lo permita, se hace con un alias explícito al punto de entrada moderno:

// vite.config.js
export default {
  resolve: {
    alias: { 'la-biblioteca': 'la-biblioteca/dist/index.mjs' },
    dedupe: ['la-biblioteca', 'react', 'react-dom'],
  },
};

Tres: el orden de evaluación cambia. Los módulos ES se evalúan en un orden determinado por el grafo, tras resolverlo entero; los CommonJS se evalúan cuando se ejecuta el require. Mezclar los dos produce órdenes de inicialización que difieren entre el entorno de desarrollo y el bundle de producción. El síntoma es el peor de todos: un fallo que solo ocurre en producción, en forma de valor undefined al importar algo que sí existe. Cuando te encuentres eso, el sospechoso número uno es una dependencia circular que cruza la frontera entre los dos formatos.

El resumen operativo

Tres frases que resumen el nivel entero desde el punto de vista de las dependencias.

Publica y consume módulos ES. Si mantienes una biblioteca, publica con el campo exports y condición import, y declara sideEffects. Es lo que permite a tus usuarios recortarla.

Comprueba el formato antes de instalar. Dos comandos, cinco minutos, y evitas una dependencia que no se puede recortar nunca.

No confíes en que el recorte ha funcionado. Compruébalo. La cantidad de configuraciones en las que el tree shaking está teóricamente activado y prácticamente desactivado es enorme, y la única forma de saberlo es medir el bundle.

⚔️ Reto práctico

Lista todas las dependencias de producción de tu proyecto y clasifícalas en tres grupos según el package.json publicado: solo CommonJS, solo módulos ES, y dobles. Para el primer grupo, mide cuánto pesa cada una en tu bundle y comprueba si estás usando más del 20 por ciento de su superficie. Después ejecuta la comprobación de doble formato: si aparece alguna, resuélvela con alias antes de seguir, porque es peso duplicado y un fallo latente a la vez.