El treemap: de dónde sale cada kilobyte de tu paquete
El mapa de árbol que descompone el JavaScript por módulo, qué requiere para funcionar, cómo se leen los bytes no usados y los duplicados, y qué hacer con cada hallazgo.
Saber que tu paquete pesa ochocientos kilobytes no sirve de nada; saber que trescientos de esos kilobytes son una librería de fechas con todos sus idiomas incluidos sí. El mapa de árbol del informe de auditoría hace exactamente esa descomposición: dibuja cada fichero como un rectángulo proporcional a su tamaño y, si hay mapas de origen, lo subdivide en los módulos que lo componen. Es la vista que convierte una discusión sobre el peso en una lista de decisiones concretas.
- Abrir el mapa de árbol y leer su representación proporcional.
- Explicar por qué necesita mapas de origen para el desglose por módulo.
- Interpretar la vista de bytes no usados y sus límites.
- Actuar sobre los cuatro hallazgos típicos.
Qué dibuja
Cada rectángulo es un fichero de JavaScript, con área proporcional a su tamaño. Al pulsar sobre uno, se subdivide en los módulos que lo componen, también proporcionalmente, y esos módulos se agrupan por su ruta de origen: las dependencias de terceros por un lado, tu código por otro, con su estructura de carpetas.
Esa jerarquía es lo que permite responder la pregunta útil, que no es cuánto pesa el paquete sino qué proporción de él es cada cosa. Un vistazo basta para ver si tu código es el diez o el ochenta por ciento del total, y qué dependencia domina.
El desglose por módulo requiere mapas de origen. Sin ellos, el mapa muestra los ficheros y nada más, lo que es mucho menos útil. Publicar mapas de origen en producción es una decisión con implicaciones —expone la estructura de tu código— y hay una alternativa que resuelve casi todo: generarlos y no publicarlos, sino subirlos a tu sistema de monitorización de errores, y ejecutar la auditoría con desglose en un entorno de preproducción donde sí estén disponibles.
Los tamaños que muestra pueden ser los transferidos o los descomprimidos según la vista, y la diferencia es grande: la compresión reduce el texto a una fracción. Los dos números importan y miden cosas distintas: el transferido es lo que cuesta descargar; el descomprimido es lo que cuesta analizar y ejecutar, que en dispositivos modestos es el coste dominante. Un fichero que comprime muy bien puede ser barato de bajar y caro de procesar.
La vista de bytes no usados
El mapa puede colorear cada región según qué proporción de su código se ejecutó durante la carga auditada. Esa información viene del mismo mecanismo de cobertura que se trata en el nivel siguiente, y tiene sus mismas limitaciones, que hay que tener presentes para no tomar decisiones equivocadas.
Mide una sola sesión. El código de una funcionalidad que el usuario no usó durante la auditoría aparece como no usado, y no lo es: simplemente no hizo falta todavía.
Un porcentaje alto de no usado no significa código muerto. Significa código que no era necesario en ese momento, que es una afirmación distinta y que apunta a una solución distinta: no borrarlo, sino cargarlo más tarde.
Con esas dos salvedades, la vista es extraordinariamente útil para una pregunta concreta: ¿qué está en el paquete de arranque y no debería estar? Un módulo grande con el noventa y cinco por ciento sin ejecutar durante una carga normal es un candidato claro a carga bajo demanda.
Los cuatro hallazgos típicos
La dependencia desproporcionada. Una librería que ocupa una fracción enorme del paquete para hacer algo pequeño. Los casos habituales son manipulación de fechas, utilidades genéricas de las que se usan tres funciones, y librerías de componentes importadas enteras. Las salidas son sustituir por una alternativa más ligera, usar la capacidad nativa equivalente si existe, o importar solo lo que se usa si la librería lo permite.
El módulo duplicado. La misma librería aparece dos o tres veces, en versiones distintas, porque dos dependencias tuyas la requieren con rangos incompatibles. Se ve inmediatamente en el mapa como rectángulos con el mismo nombre en sitios distintos. Es peso puro y a veces también bugs, cuando dos partes del sistema usan instancias distintas de algo que debería ser único.
El fragmento cargado siempre y usado casi nunca. Un módulo grande que forma parte del paquete inicial y que solo hace falta en una pantalla concreta. La solución es la importación dinámica en el punto donde se necesita.
El poli-relleno innecesario. Código de compatibilidad para navegadores que ya no están en tu público objetivo, y transformaciones de sintaxis que ya no hacen falta. Suele salir de una configuración de compilación que nadie ha revisado en años, y su corrección es cambiar una línea en la lista de navegadores soportados.
Medir sin la herramienta
Para una comprobación rápida, o para vigilar el peso sin ejecutar la auditoría entera:
// Inventario del JavaScript cargado, con transferido y descomprimido
(() => {
const scripts = performance.getEntriesByType('resource')
.filter(e => e.initiatorType === 'script' || /\.m?js(\?|$)/.test(e.name));
const filas = scripts.map(e => {
let origen; try { origen = new URL(e.name).origin; } catch { origen = '-'; }
return {
fichero: e.name.split('/').pop().split('?')[0].slice(0, 42),
origen: origen === location.origin ? '(propio)' : origen.replace(/^https?:\/\//, ''),
transferidoKB: Math.round((e.transferSize || 0) / 1024),
descomprimidoKB: Math.round((e.decodedBodySize || 0) / 1024),
ratio: e.decodedBodySize
? +(e.decodedBodySize / Math.max(1, e.transferSize)).toFixed(1)
: null,
msDescarga: Math.round(e.duration)
};
}).sort((a, b) => b.descomprimidoKB - a.descomprimidoKB);
console.table(filas);
const suma = (c) => filas.reduce((s, f) => s + (f[c] || 0), 0);
const propios = filas.filter(f => f.origen === '(propio)');
const terceros = filas.filter(f => f.origen !== '(propio)');
console.table([{
ficheros: filas.length,
transferidoTotalKB: suma('transferidoKB'),
descomprimidoTotalKB: suma('descomprimidoKB'),
propiosKB: propios.reduce((s, f) => s + f.descomprimidoKB, 0),
tercerosKB: terceros.reduce((s, f) => s + f.descomprimidoKB, 0)
}]);
const sinComprimir = filas.filter(f => f.ratio !== null && f.ratio < 1.5 && f.descomprimidoKB > 20);
if (sinComprimir.length) {
console.warn('Ficheros grandes que parecen no estar comprimidos:');
console.table(sinComprimir);
}
})();
La comprobación final es la que más veces encuentra algo inesperado: un fichero grande cuya proporción entre descomprimido y transferido está cerca de uno no se está sirviendo comprimido, y activar la compresión en el servidor es una mejora de varios cientos de kilobytes por una línea de configuración.
De los hallazgos a un presupuesto
El mapa da una foto; lo que evita que el problema vuelva es un límite. La forma más simple y más eficaz es un presupuesto de tamaño comprobado en cada cambio, con dos propiedades que lo hacen funcionar en la práctica.
Que falle el proceso, no que avise. Un aviso se ignora en la tercera semana.
Que el límite sea por punto de entrada y no global. Un total agregado permite que una pantalla engorde mientras otra adelgaza, y esconde precisamente el problema.
Y una comprobación adicional que evita la discusión más común: mostrar la diferencia respecto a la rama principal en cada propuesta de cambio. Ver “este cambio añade cuarenta kilobytes” en el momento de revisar es infinitamente más eficaz que descubrirlo seis meses después en un mapa de árbol.
Casi todas las conversaciones sobre tamaño de paquete se plantean en términos de descarga, y esa es la parte barata. Un kilobyte de JavaScript cuesta muchísimo más que un kilobyte de imagen del mismo tamaño, y la diferencia no está en la red. Una imagen se descarga, se decodifica en un hilo que no es el principal, y se pinta. Un kilobyte de JavaScript se descarga, se analiza sintácticamente, se compila, se ejecuta, deja objetos en memoria que el recolector tendrá que visitar, y muy probablemente ejecuta código que crea nodos, registra manejadores y programa trabajo futuro. Todo eso ocurre en el hilo principal y todo eso escala con la potencia del dispositivo, que es donde la diferencia se vuelve brutal: el análisis y la compilación en un móvil modesto pueden ser un orden de magnitud más lentos que en un portátil de desarrollo, mientras que la descarga solo depende de la red. Esa asimetría explica dos cosas que confunden a los equipos. La primera: por qué la compresión resuelve menos de lo que parece. Comprimir reduce lo que viaja, no lo que se procesa; un paquete que comprime de novecientos a doscientos kilobytes sigue teniendo novecientos kilobytes que analizar y ejecutar, y ese es el coste que domina en el dispositivo lento. La segunda: por qué las métricas de peso y las de bloqueo no se mueven juntas. Se puede reducir mucho el peso transferido sin mover el tiempo de bloqueo, si lo que se ha reducido eran cadenas o datos, y se puede reducir mucho el bloqueo sin tocar el peso, si lo que se ha hecho es aplazar la ejecución. La conclusión operativa es que la magnitud que hay que vigilar no es el peso del paquete sino el tiempo de ejecución del arranque, y el peso solo es útil como aproximación barata y frecuente a esa magnitud. Cuando las dos discrepan, manda la segunda. Y la comprobación definitiva sigue siendo la misma de todo el track: un perfil, con la ralentización de CPU puesta, mirando cuánto dura el bloque de arranque. Es el único número que corresponde a algo que un usuario experimenta.