El diff del bundle en cada petición de cambios
Cómo comparar dos compilaciones cuando los nombres llevan hash, qué se informa además de los bytes, el diagnóstico automático de qué dependencia entró, y el formato de comentario que la gente lee.
El presupuesto detiene lo que se pasa del límite. El diff informa de todo lo demás, que es la mayor parte: los crecimientos pequeños que suman, la dependencia nueva que trae otras cinco, el fragmento que se ha partido en dos, el módulo que ha migrado de un paquete a otro. Es la herramienta que convierte el tamaño del paquete en algo que se revisa junto al código, en el mismo sitio y en el mismo momento, que es donde las decisiones se pueden todavía cambiar.
- Emparejar ficheros entre dos compilaciones cuyos nombres contienen hashes distintos.
- Generar un manifiesto comparable y calcular la diferencia por nombre lógico.
- Detectar automáticamente qué dependencias han entrado o cambiado de tamaño.
- Redactar el comentario de forma que se lea y no se ignore.
El problema del emparejamiento
Comparar dos compilaciones parece trivial y no lo es, por tres motivos que hay que resolver antes de poder informar de nada.
Los nombres llevan hash. principal.a3f9c1d2.js y principal.7b2e4f80.js son el mismo paquete, y un diff de nombres de fichero informa de uno borrado y otro creado. Hay que quitar el hash y emparejar por nombre lógico.
Los fragmentos se dividen y se juntan. Un cambio en la configuración del empaquetador puede convertir un fragmento en tres, o mover un módulo de uno a otro. El total puede no haber cambiado y el informe por fragmento mostrar cinco alteraciones enormes. Por eso hay que informar siempre del total además del desglose.
El nivel de compresión debe ser el mismo. Comparar un fichero comprimido con nivel 6 contra otro con nivel 11 produce diferencias del quince por ciento que no existen. Fija el nivel en el guion, no lo heredes del servidor.
El manifiesto que resuelve las tres cosas:
// scripts/manifiesto.mjs -> node scripts/manifiesto.mjs dist manifiesto.json
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { brotliCompressSync, constants } from 'node:zlib';
import { join, extname } from 'node:path';
const [, , raiz, salida] = process.argv;
function ficheros(dir) {
const lista = [];
for (const entrada of readdirSync(dir)) {
const ruta = join(dir, entrada);
if (statSync(ruta).isDirectory()) lista.push(...ficheros(ruta));
else lista.push(ruta);
}
return lista;
}
// Quita el hash y deja nombre logico mas extension.
function nombreLogico(ruta) {
const base = ruta.slice(raiz.length + 1);
return base.replace(/[.-][0-9a-f]{8,}(\.[\w.]+)$/, '$1');
}
const manifiesto = {};
for (const ruta of ficheros(raiz)) {
const ext = extname(ruta);
if (!['.js', '.css', '.mjs'].includes(ext)) continue;
const bytes = readFileSync(ruta);
const clave = nombreLogico(ruta);
const comprimido = brotliCompressSync(bytes, {
// Nivel fijo: comparar con niveles distintos no significa nada.
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).length;
const previo = manifiesto[clave] || { crudo: 0, brotli: 0 };
manifiesto[clave] = {
crudo: previo.crudo + bytes.length,
brotli: previo.brotli + comprimido,
};
}
writeFileSync(salida, JSON.stringify(manifiesto, null, 2));
Y la comparación:
// scripts/comparar.mjs -> node scripts/comparar.mjs base.json actual.json
import { readFileSync } from 'node:fs';
const [, , rutaBase, rutaActual] = process.argv;
const base = JSON.parse(readFileSync(rutaBase, 'utf8'));
const actual = JSON.parse(readFileSync(rutaActual, 'utf8'));
const claves = new Set([...Object.keys(base), ...Object.keys(actual)]);
const filas = [];
let totalBase = 0;
let totalActual = 0;
for (const clave of claves) {
const b = base[clave]?.brotli ?? 0;
const a = actual[clave]?.brotli ?? 0;
totalBase += b;
totalActual += a;
if (a === b) continue;
filas.push({
fichero: clave,
antes: b,
ahora: a,
delta: a - b,
// Sin base no hay porcentaje: es un fichero nuevo.
pct: b === 0 ? null : ((a - b) / b) * 100,
estado: b === 0 ? 'nuevo' : a === 0 ? 'eliminado' : 'cambiado',
});
}
filas.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
// Doble umbral: absoluto y relativo. Los dos hacen falta.
const UMBRAL_BYTES = 1024;
const UMBRAL_PCT = 2;
const relevantes = filas.filter(
(f) => Math.abs(f.delta) >= UMBRAL_BYTES ||
(f.pct !== null && Math.abs(f.pct) >= UMBRAL_PCT)
);
console.log(JSON.stringify({
totalBase, totalActual, deltaTotal: totalActual - totalBase,
filas: relevantes,
}, null, 2));
El doble umbral es más importante de lo que parece. Solo absoluto y un fichero de tres kilobytes que se duplica pasa desapercibido, cuando duplicarse es exactamente el tipo de cambio que hay que mirar. Solo relativo y un fichero de cuatrocientos kilobytes que crece ocho, un dos por ciento, no aparece, cuando ocho kilobytes son ocho kilobytes. Con los dos, se informa de lo que importa por cualquiera de los dos criterios.
Informar de más que bytes
Los bytes son el efecto. Para que el diff sea útil hay que informar también de la causa, y hay tres señales que la identifican casi siempre.
Dependencias nuevas en el árbol resuelto. El fichero de bloqueo de versiones es la fuente más fiable, porque incluye las transitivas, que son las que sorprenden. Alguien añade una utilidad pequeña y entran seis paquetes más.
# Dependencias que aparecen o desaparecen respecto a la rama base.
git show origin/main:package-lock.json > /tmp/lock-base.json
node -e '
const base = require("/tmp/lock-base.json").packages || {};
const actual = require("./package-lock.json").packages || {};
const nombres = (p) => new Set(
Object.keys(p).filter((k) => k.startsWith("node_modules/"))
);
const b = nombres(base), a = nombres(actual);
const nuevas = [...a].filter((x) => !b.has(x));
const idas = [...b].filter((x) => !a.has(x));
if (nuevas.length) console.log("Dependencias nuevas:\n " + nuevas.join("\n "));
if (idas.length) console.log("Dependencias eliminadas:\n " + idas.join("\n "));
'
Módulos nuevos dentro del paquete. El empaquetador puede emitir un informe con qué módulos han entrado en cada fragmento. Comparar esas listas dice, con nombre y ruta, qué se ha incluido de más. Es la señal más precisa de las tres y también la que da los mensajes de error mejores: no “el paquete creció 18 KB” sino “entró este módulo de esta biblioteca importado desde este fichero”.
Cambios en el número de fragmentos. Un fragmento que aparece o desaparece indica un cambio en la estrategia de división, casi siempre no intencionado: un import dinámico que se volvió estático porque alguien añadió una importación normal del mismo módulo en otro sitio. Es una regresión clásica y silenciosa, no produce ningún error, y el único sitio donde se ve es precisamente en el recuento de fragmentos.
El comentario que se lee
El formato importa tanto como el contenido. Un comentario largo con una tabla de cuarenta filas se ignora desde la segunda vez. La estructura que funciona tiene cuatro partes y este orden:
Una línea de veredicto. Lo primero y lo único que mucha gente va a leer. Con un signo claro y el total.
Las cinco filas más grandes. No más de cinco. El resto, plegado.
La causa probable, si se ha detectado. La dependencia nueva, el módulo nuevo, el fragmento que ha cambiado de forma.
Qué hacer, si algo pasa del umbral. Un enlace al procedimiento, no una explicación.
Tamaño del paquete: +18,4 KB comprimido (612,1 KB -> 630,5 KB, +3,0 %)
| Fichero | Antes | Ahora | Delta |
|--------------------|----------|----------|-----------|
| entrada-panel.js | 244,0 KB | 259,8 KB | +15,8 KB |
| compartido.js | 81,2 KB | 83,8 KB | +2,6 KB |
Dependencia nueva: biblioteca-de-fechas 4.1.0 (+3 transitivas)
Importada en: src/paneles/InformeMensual.tsx linea 4
Presupuesto de entrada-panel: 260 KB. Estas a 0,2 KB del limite.
Como pedir una excepcion: docs/rendimiento/excepciones.md
Dos prácticas operativas que evitan que el comentario se convierta en ruido. Actualiza el comentario existente en lugar de añadir uno nuevo en cada envío de cambios: veinte comentarios del robot en una petición larga garantizan que nadie lea el vigésimo primero. Y no comentes cuando no hay cambio relevante: un comentario que dice “sin cambios” en cada petición entrena a la gente a ignorar al robot, y esa costumbre se traslada al día en que sí tiene algo que decir.
Existen herramientas ya hechas para todo esto, tanto para comprobar tamaños con límites como para analizar informes del empaquetador, y usar una es perfectamente razonable. Merece la pena entender el mecanismo de todas formas, por dos razones: casi siempre hay que adaptar el emparejamiento a la estructura concreta de tu compilación, y el día que el número no cuadre tendrás que saber qué está midiendo.
Piensa en la vida de una decisión de añadir una dependencia como una curva de coste de revertirla, porque explica por qué la misma información tiene efectos completamente distintos según cuándo llegue. En el minuto cero, mientras la persona escribe el código, revertir cuesta nada: todavía está eligiendo, tiene el problema fresco, sabe que hay una alternativa más pequeña porque acaba de verla en los resultados de búsqueda y la descartó por comodidad. A las pocas horas, en la revisión de la petición de cambios, revertir cuesta poco: el autor sigue teniendo el contexto, el código está aislado y cambiar el enfoque es media hora. A la semana, ya integrado, cuesta bastante: hay otro código encima, alguien lo ha usado en dos sitios más, y la conversación pasa de “usa esta otra” a “hay que refactorizar”. Al mes, en el informe trimestral de rendimiento donde alguien descubre que el paquete ha crecido cien kilobytes, revertir cuesta un proyecto: hay que arqueologizar qué entró, quién lo puso, si se puede quitar, y negociarlo con tres equipos. La curva sube muy deprisa y esa es toda la explicación de por qué el diff en la petición de cambios es más eficaz que cualquier panel de rendimiento, por bonito que sea el panel: no porque la información sea mejor, sino porque llega mientras la ventana de reversibilidad sigue abierta, y esa ventana dura horas, no semanas. De aquí salen dos consecuencias que conviene tener claras. La primera es un criterio para priorizar tu propio trabajo de tooling: entre invertir una semana en un panel histórico precioso y dos días en un comentario automático en las peticiones de cambios, el comentario gana con una diferencia enorme, y gana aunque el panel tenga datos mejores. La segunda es más sutil y explica un fracaso común: una herramienta de rendimiento que llega tarde no es una herramienta peor, es una herramienta de otra categoría. El informe mensual no sirve para prevenir regresiones y no hay que evaluarlo por eso; sirve para detectar derivas lentas que ninguna comparación local ve, que es un trabajo distinto y también necesario. Confundir las dos categorías lleva a equipos que tienen paneles espectaculares y regresiones constantes, porque han puesto todo el esfuerzo en el lado de la curva donde ya no se puede hacer nada barato.
- Genera el manifiesto de tu rama principal y de tu rama de trabajo y compáralos con los dos guiones.
- Comprueba que el emparejamiento por nombre lógico funciona con tu patrón de hashes concreto.
- Añade la detección de dependencias nuevas comparando el fichero de bloqueo con el de la rama base.
- Publica el comentario automático con la estructura de cuatro partes y actualiza el existente en vez de añadir otro.
- Prueba a añadir a propósito una biblioteca grande y comprueba que el comentario identifica la dependencia y el fichero que la importa.