wandres.dev
TREE SHAKING · Eliminar lo muerto

Verificar que de verdad se eliminó, en vez de suponerlo

Los cuatro métodos para comprobar que un fragmento de código no llegó al bundle, el experimento diferencial que no admite discusión, y cómo convertir la comprobación en una prueba automática.

⏱ 17 min

Todo lo anterior de este nivel son condiciones necesarias que no garantizan nada. La configuración puede ser perfecta y el recorte no ocurrir por un plugin en medio, por una versión de una herramienta, por un import de tipos sin marcar o por una dependencia circular. La diferencia entre un equipo que tiene el tamaño bajo control y uno que cree tenerlo es que el primero comprueba y el segundo supone.

🎯 Al terminar esta lección sabrás
  • Ejecutar el experimento diferencial que demuestra si un módulo se eliminó.
  • Buscar directamente en el fichero de salida con criterios que no den falsos negativos.
  • Interpretar el informe del bundle sabiendo qué etapa lo genera.
  • Automatizar la comprobación para que una regresión falle la compilación.

Método 1: el experimento diferencial

Es el único método que no admite interpretación. Compila dos veces, con y sin la importación sospechosa, y compara el tamaño final comprimido.

#!/usr/bin/env bash
# diferencial.sh — cuanto pesa de verdad una importacion
set -e
ENTRADA=src/main.ts

cp "$ENTRADA" /tmp/entrada.bak
npm run build --silent >/dev/null
BASE=$(cat dist/assets/*.js | brotli -c | wc -c)

# Anade la importacion sospechosa y un uso minimo que impida su eliminacion
cat >> "$ENTRADA" <<'EOF'
import { funcionSospechosa } from 'la-biblioteca';
globalThis.__sonda = funcionSospechosa;
EOF

npm run build --silent >/dev/null
CON=$(cat dist/assets/*.js | brotli -c | wc -c)

cp /tmp/entrada.bak "$ENTRADA"
echo "base:  $BASE bytes"
echo "con:   $CON bytes"
echo "coste: $(( (CON - BASE) / 1024 )) KB brotli"

La asignación a globalThis.__sonda no es decorativa: sin un uso que el empaquetador no pueda eliminar, la importación misma se recortaría y medirías cero. Es el error más común al hacer este experimento a mano.

El resultado es incontestable y es el número que hay que llevar a cualquier discusión. Si funcionSospechosa cuesta 2 KB, el recorte funciona. Si cuesta 90, la biblioteca entra entera.

Método 2: buscar en la salida

Rápido y con una trampa: el minificador renombra los identificadores, así que buscar el nombre de una función no sirve. Lo que sobrevive a la minificación son las cadenas de texto, las claves de objeto no minificadas y los mensajes de error.

cd dist/assets

# Cadenas literales de la biblioteca: sobreviven a la minificacion
grep -o 'moment/locale\|Invalid date\|__esModule' *.js | sort | uniq -c

# Un mensaje de error caracteristico del modulo que no deberia estar
grep -c 'El editor requiere un contenedor' *.js

La técnica fiable consiste en plantar una baliza: una cadena única e improbable dentro del módulo que quieres comprobar, y buscarla en la salida.

// dentro de EditorRico.js
const _BALIZA = 'BALIZA-EDITOR-RICO-7f21';
export function crearEditor(el) {
  if (!el) throw new Error(`${_BALIZA}: falta contenedor`);
  /* ... */
}
grep -l 'BALIZA-EDITOR-RICO-7f21' dist/assets/*.js || echo 'ELIMINADO correctamente'

Si la baliza aparece en el chunk de arranque cuando debería estar en un fragmento diferido, has encontrado una división rota. Si aparece cuando no debería estar en ningún sitio, el recorte no funcionó. Las balizas se pueden dejar en el código: pesan veinte bytes y forman parte de un mensaje de error que ya existía.

Método 3: el informe del bundle, leído con cuidado

El informe visual sirve, con la advertencia que ya vimos: se genera a partir del mapa de fuentes, y según la herramienta puede reflejar el estado antes de la minificación. Un módulo que aparece con 12 KB en el informe puede haberse quedado en 3 tras el minificador, o desaparecer del todo.

La forma correcta de usarlo aquí es comparativa, no absoluta: genera el informe antes y después del cambio y mira si el módulo desapareció del gráfico, no cuánto ocupaba.

Con esbuild, el metafichero da la respuesta exacta porque se genera sobre la salida final:

esbuild src/main.ts --bundle --minify --metafile=meta.json --outfile=dist/app.js
node -e "
  const m=JSON.parse(require('fs').readFileSync('meta.json','utf8'));
  const objetivo=process.argv[1];
  let encontrado=false;
  for(const [salida,info] of Object.entries(m.outputs)){
    for(const [entrada,d] of Object.entries(info.inputs??{})){
      if(entrada.includes(objetivo)){ encontrado=true; console.log(salida,entrada,d.bytesInOutput,'bytes'); }
    }
  }
  if(!encontrado) console.log('NO esta en la salida:', objetivo);
" node_modules/la-biblioteca

bytesInOutput es la cifra real en el fichero final. Es el dato más preciso que se puede obtener sin ejecutar el experimento diferencial.

Método 4: la cobertura en tiempo de ejecución

Los tres métodos anteriores dicen si el código está. La cobertura dice si el código se ejecuta, que es una pregunta distinta y complementaria. Un módulo que está y no se ejecuta nunca es un candidato a recorte que el análisis estático no pudo demostrar.

El procedimiento y sus advertencias están en analizar el bundle. Aquí solo la conexión: los módulos con cero por ciento de cobertura tras una sesión completa son la lista de candidatos, y para cada uno hay que averiguar cuál de los patrones de bloqueo lo mantiene vivo.

Automatiza la comprobación con una prueba de tamaño por importación, no solo del total

El presupuesto de tamaño total detecta que algo creció; no detecta que una biblioteca concreta dejó de recortarse mientras otra adelgazaba. Esas compensaciones ocultan regresiones durante meses.

Lo que de verdad protege es una prueba que compile un punto de entrada mínimo por cada importación crítica y compruebe su coste aislado. Es el experimento diferencial convertido en prueba automática, y se ejecuta en segundos porque los puntos de entrada son de dos líneas.

// pruebas/tamano-importaciones.test.mjs
import { test } from 'node:test';
import assert from 'node:assert';
import { build } from 'esbuild';
import { brotliCompressSync } from 'node:zlib';

const limites = [
  { modulo: 'date-fns',            simbolo: 'format',        maxKB: 12 },
  { modulo: '@/componentes/Boton', simbolo: 'Boton',         maxKB: 4 },
  { modulo: '@/util/validacion',   simbolo: 'validarCorreo', maxKB: 3 },
];

for (const { modulo, simbolo, maxKB } of limites) {
  test(`${simbolo} de ${modulo} cabe en ${maxKB} KB`, async () => {
    const r = await build({
      stdin: {
        contents: `import { ${simbolo} } from '${modulo}';\nglobalThis.__s = ${simbolo};`,
        resolveDir: process.cwd(),
        loader: 'ts',
      },
      bundle: true,
      minify: true,
      format: 'esm',
      target: 'es2022',
      write: false,
      logLevel: 'silent',
    });
    const kb = brotliCompressSync(r.outputFiles[0].contents).length / 1024;
    assert.ok(kb <= maxKB, `${simbolo}: ${kb.toFixed(1)} KB, límite ${maxKB} KB`);
  });
}

Tres detalles que hacen que esta prueba funcione y no dé falsos positivos.

globalThis.__s = simbolo es imprescindible. Sin ese uso, el empaquetador elimina la importación entera y la prueba mide el bundle vacío, pasando siempre.

Los límites se fijan con margen, un 20 o 30 por ciento por encima del valor actual. Un límite pegado al valor real falla en cuanto la biblioteca añade una función, y una prueba que falla por ruido se acaba desactivando.

El formato de salida y el objetivo tienen que coincidir con los de producción. Medir con format: 'esm' y target: 'es2022' cuando tu producción emite otra cosa da cifras que no corresponden a nada.

El mensaje de fallo de esta prueba es lo mejor que tiene: cuando alguien cambia una importación y format pasa de 11 a 68 KB, el error dice exactamente qué símbolo, cuánto y cuál era el límite. Comparado con «el bundle creció 57 KB», es la diferencia entre diez minutos y una tarde.

El orden en que se comprueba

Cuando sospechas que algo no se está recortando, este es el orden que menos tiempo pierde:

Uno: el experimento diferencial. Dos minutos y te dice si hay problema o no. Si el coste es el esperado, para aquí.

Dos: el metafichero o el informe. Te dice qué ficheros concretos de la dependencia entraron. A menudo revela que entra un submódulo que no esperabas, y ahí está la pista.

Tres: el package.json de la dependencia. Formato del módulo y sideEffects. Explica el 60 por ciento de los casos.

Cuatro: tu propio código. La lista de siete puntos de lo que rompe el recorte.

Cinco: la cadena de plugins. El último sospechoso y el más laborioso: desactivar plugins uno a uno hasta que el recorte vuelve a funcionar.

Y una nota final sobre expectativas. El tree shaking no es una herramienta de reducción masiva. En una base de código bien estructurada elimina entre el 5 y el 20 por ciento del código de dependencias, y su valor real está en que ese porcentaje no crezca con el tiempo. Las reducciones grandes vienen del troceado y de quitar dependencias, no del recorte. Un equipo que espera que el recorte resuelva un bundle de 800 KB va a decepcionarse; uno que lo usa para que las cuarenta utilidades que importa cuesten las tres que usa está usándolo bien.

⚔️ Reto práctico

Escribe la prueba de tamaño por importación para las cinco importaciones más críticas de tu proyecto, con los límites fijados un 25 por ciento por encima de los valores actuales. Ejecútala en integración continua. Después, deliberadamente, cambia una importación puntual por una del barril y comprueba que la prueba falla con un mensaje útil. Si no falla, el límite está demasiado holgado.