Lo que rompe el recorte: efectos en el nivel superior, defineProperty y las clases
Los patrones concretos que impiden al empaquetador demostrar que un fragmento es eliminable, por qué Object.defineProperty es especialmente destructivo, y cómo reescribirlos sin perder funcionalidad.
El tree shaking falla casi siempre por el mismo motivo: hay algo en el módulo que el empaquetador no puede demostrar que sea inocuo, así que lo conserva, y con ello conserva todo lo que ese algo alcanza. La lista de patrones que producen ese bloqueo es corta y muy reconocible una vez la conoces, y la mayoría de ellos se reescriben en cinco minutos sin cambiar nada de la funcionalidad.
- Reconocer los cinco patrones que bloquean la eliminación de código.
- Explicar por qué
Object.definePropertyobliga al empaquetador a conservarlo todo. - Reescribir cada patrón en su forma eliminable.
- Usar las anotaciones de pureza cuando la reescritura no sea posible.
Patrón 1: trabajo en el nivel superior
Cualquier expresión en el nivel superior de un módulo que no sea una declaración pura obliga al empaquetador a conservarla, porque podría hacer algo. Es el patrón más común y el más fácil de arreglar.
// BLOQUEA: la llamada se conserva aunque nadie use TABLA
export const TABLA = construirTabla();
// BLOQUEA: la instancia se crea siempre
export const cliente = new ClienteApi({ base: '/api' });
// BLOQUEA: el registro ocurre al importar
registrarFormato('fecha', formateadorFecha);
// ELIMINABLE: inicializacion perezosa, cero trabajo al importar
let _tabla = null;
export const obtenerTabla = () => (_tabla ??= construirTabla());
// ELIMINABLE: la instancia se crea en la primera llamada
let _cliente = null;
export const obtenerCliente = () => (_cliente ??= new ClienteApi({ base: '/api' }));
// ELIMINABLE: el registro es explicito y esta en el punto de entrada
export function registrarFormatos() { registrarFormato('fecha', formateadorFecha); }
Cuando la reescritura no es posible porque de verdad necesitas el valor como constante, la anotación de pureza le da al empaquetador el permiso que le falta:
export const TABLA = /* @__PURE__ */ construirTabla();
El comentario declara que la llamada no tiene efectos observables. Si nadie usa TABLA, la llamada entera desaparece. Y si mientes, el efecto que esperabas no ocurre; la anotación es una promesa igual que sideEffects.
Patrón 2: Object.defineProperty
Este es el más destructivo de todos y merece explicación aparte.
// El empaquetador no puede saber nada de este objeto a partir de aqui
Object.defineProperty(exports, '__esModule', { value: true });
Object.defineProperty(api, 'version', { get: () => calcularVersion() });
El problema es doble. Primero, defineProperty acepta la clave como valor, lo que significa que puede ser una variable calculada y el análisis estático se queda ciego. Segundo, permite definir descriptores con captadores, lo que convierte un acceso a propiedad aparentemente inocente en la ejecución de código arbitrario.
Para un empaquetador, un módulo que llama a defineProperty sobre su objeto de exportaciones es un módulo cuya forma no puede conocer, y la respuesta conservadora es incluirlo entero con todas sus dependencias.
La aparición más frecuente no está en tu código: está en la salida de los transpiladores. La línea Object.defineProperty(exports, "__esModule", { value: true }) es la firma de un módulo ES transpilado a CommonJS, y aparece en la parte superior de miles de paquetes publicados. Cuando la veas en el fuente de una dependencia, ya sabes que ese paquete no se va a recortar.
En tu propio código, las alternativas:
// En lugar de un captador que calcula
Object.defineProperty(api, 'version', { get: () => calcularVersion() });
// Una funcion normal, analizable y eliminable si nadie la llama
export const version = () => calcularVersion();
// En lugar de propiedades definidas dinamicamente en un bucle
for (const n of nombres) Object.defineProperty(api, n, { value: crear(n) });
// Un objeto literal explicito, que el empaquetador entiende
export const api = {
buscar: crearBuscar(),
filtrar: crearFiltrar(),
ordenar: crearOrdenar(),
};
Y el caso legítimo donde defineProperty es la herramienta correcta —definir una propiedad no enumerable o no configurable en un objeto propio— no bloquea nada si el objeto es local al módulo y no es el objeto de exportaciones.
Patrón 3: las clases con propiedades estáticas asignadas fuera
Este bloquea de una forma sutil que confunde mucho.
class Boton { render() { /* ... */ } }
Boton.displayName = 'Boton'; // asignacion en el nivel superior
Boton.defaultProps = { tipo: 'primario' };
export { Boton };
Las dos asignaciones son expresiones en el nivel superior que mutan un objeto. El empaquetador las conserva, y al conservarlas conserva Boton, aunque nadie lo importe.
La versión eliminable usa campos estáticos dentro de la clase, que son parte de la declaración y por tanto puros:
export class Boton {
static displayName = 'Boton';
static defaultProps = { tipo: 'primario' };
render() { /* ... */ }
}
El mismo problema aparece con la herencia de una clase que se define en otro módulo, y con los decoradores, que son llamadas a función aplicadas en el momento de la declaración. Con decoradores, el recorte de esa clase deja de ser posible en la práctica.
Patrón 4: los efectos que el empaquetador no puede ver
Hay construcciones cuyo efecto es real pero indirecto, y el empaquetador es conservador con todas ellas.
// Modificar un prototipo nativo: efecto global, se conserva siempre
Array.prototype.ultimo = function () { return this[this.length - 1]; };
// Asignar a globalThis
globalThis.__MI_APP__ = { version: '3.2' };
// Instalar un manejador global
window.addEventListener('error', reportarError);
// Crear un observador que se queda vivo
new PerformanceObserver(cb).observe({ type: 'longtask', buffered: true });
Todos son legítimos en un punto de entrada y todos son veneno en un módulo de biblioteca. La regla que ordena esto: los efectos globales van en el punto de entrada de la aplicación, nunca en un módulo importable. Un módulo de biblioteca exporta una función que instala el efecto; quien lo importa decide cuándo llamarla.
Patrón 5: el acceso dinámico a propiedades
// El empaquetador no puede saber que exportaciones se usan
import * as utiles from './utiles.js';
const fn = utiles[nombreCalculado];
fn(datos);
Importar el espacio de nombres completo y acceder con clave calculada obliga a conservar todas las exportaciones del módulo. Es correcto: cualquiera podría ser la que se usa.
La versión eliminable convierte el acceso dinámico en un mapa explícito, que es exactamente el mismo patrón que se recomienda para el import() dinámico con ruta variable:
import { normalizar, validar, formatear } from './utiles.js';
const acciones = { normalizar, validar, formatear };
const fn = acciones[nombreCalculado];
fn?.(datos);
Sigue siendo dinámico en tiempo de ejecución, y ahora el conjunto de posibilidades es explícito y analizable. Si más adelante dejas de usar formatear, quitarlo del mapa hace que se elimine de verdad.
Hay una interacción entre las dos etapas que explica resultados aparentemente aleatorios, y que conviene entender antes de dar por perdido un recorte.
El tree shaking lo hace el empaquetador sobre el grafo de módulos. La eliminación de código muerto dentro de una función la hace el minificador, que trabaja fichero a fichero y no ve el grafo. Son dos análisis distintos con visibilidad distinta, y las dos direcciones de fallo existen.
Dirección uno: el empaquetador no puede, el minificador sí. Una constante que el empaquetador conserva porque hay una llamada a función, pero cuyo resultado el minificador puede resolver por propagación de constantes y eliminar. Esto significa que el informe del bundle, que se genera antes de minificar, puede sobreestimar el peso de algo que va a desaparecer. Es la causa número uno de esa sensación de «el informe dice 40 KB pero el fichero final no crece 40 KB».
Dirección dos, y es la que duele: el minificador crea código que el empaquetador ya no puede analizar. La integración agresiva de funciones puede convertir una exportación bien delimitada en código incrustado en el medio de otra, momento en el que ya no es una unidad eliminable. Con una configuración de compresión muy agresiva, es posible acabar con un bundle mayor que con una configuración moderada, porque la integración duplicó cuerpos de función que antes se compartían.
La consecuencia práctica es que hay que medir el fichero final, siempre, y no fiarse ni del informe ni del razonamiento. Y hay una comprobación que merece la pena hacer una vez en cada proyecto: compilar con tres niveles de agresividad del minificador y comparar los tres tamaños finales.
# Con esbuild, tres configuraciones
esbuild src/main.js --bundle --minify --outfile=/tmp/a.js
esbuild src/main.js --bundle --minify-syntax --minify-whitespace --outfile=/tmp/b.js
esbuild src/main.js --bundle --minify --tree-shaking=true --outfile=/tmp/c.js
for f in /tmp/a.js /tmp/b.js /tmp/c.js; do
echo "$f $(brotli -c "$f" | wc -c) bytes brotli"
doneNo es infrecuente que la diferencia entre la mejor y la peor configuración sea del 5 al 10 por ciento, y que la mejor no sea la más agresiva.
Y un último detalle que ahorra horas de desconcierto: el empaquetador solo recorta lo que llega hasta él como módulos ES. Si tienes un plugin que transforma código y devuelve CommonJS a mitad de la cadena, el recorte de todo lo que pase por ahí se apaga en silencio. Cuando un recorte que debería funcionar no funciona, revisa el orden y la salida de tus plugins antes de revisar el código.
La lista de comprobación
Cuando un módulo no se elimina y crees que debería, recórrela por orden.
- ¿Hay alguna expresión en el nivel superior que no sea una declaración? Muévela a una función.
- ¿Hay
Object.definePropertysobre las exportaciones? Es CommonJS transpilado; el recorte no va a funcionar. - ¿Hay asignaciones a propiedades estáticas fuera de la clase? Muévelas dentro.
- ¿Se modifica algún global o prototipo? Sácalo al punto de entrada.
- ¿Hay un
import * ascon acceso calculado? Conviértelo a mapa explícito. - ¿El paquete declara
sideEffects? Sin él, la evaluación se conserva. - ¿La cadena de plugins conserva los módulos ES hasta el empaquetador?
Y después de cada cambio, la única verificación que vale: medir el fichero final, que es lo que hace la última lección de este nivel.
Coge el módulo de utilidades más grande de tu proyecto y recorre la lista de siete puntos sobre él. Antes de tocar nada, mide el tamaño del bundle importando solo una función de ese módulo. Corrige los patrones que encuentres, uno a uno, midiendo después de cada corrección. Anota cuál produjo la mayor caída: en la mayoría de los proyectos es el punto uno, y por un margen grande.