Cobertura de JavaScript: granularidad, ramas y carga diferida
Qué mide exactamente la cobertura de código, la diferencia entre granularidad por función y por bloque, y cómo convertir el resultado en una estrategia de división del paquete.
La cobertura de JavaScript es más precisa que la de CSS y por eso más peligrosa: da la sensación de estar señalando código muerto cuando lo que señala es código que no se ejecutó esta vez. Bien leída, es la mejor herramienta que existe para decidir qué sacar del paquete de arranque, que es una decisión con impacto directo sobre la métrica que más pesa en cualquier auditoría.
- Explicar qué unidad de código marca la cobertura como ejecutada.
- Distinguir granularidad por función de granularidad por bloque.
- Convertir el resultado en una lista de candidatos a carga diferida.
- Implementar la carga bajo demanda de un módulo pesado.
Qué marca como ejecutado
El motor registra la ejecución con una granularidad que se puede elegir en el panel, y la diferencia entre las dos opciones cambia mucho la interpretación.
Por función. Una función cuenta como ejecutada si se llamó al menos una vez, entera. Es la granularidad de menor coste y la que da un número más optimista.
Por bloque. Se registra qué bloques dentro de cada función se ejecutaron, así que una rama condicional no tomada aparece como no ejecutada. Es más precisa, más cara de recoger y da un número más pesimista.
La elección depende de la pregunta. Para decidir si un módulo entero se puede sacar del paquete, la granularidad por función basta y sobra. Para entender por qué una función concreta aparece parcialmente usada, hace falta la de bloque.
El código de nivel superior de un módulo se ejecuta al importarlo, siempre. Por eso un módulo importado y nunca usado aparece con una pequeña parte ejecutada: la parte que define las cosas. Esa firma —un fichero con un porcentaje muy pequeño de uso, concentrado en las primeras líneas y en las declaraciones— es exactamente la de una importación que sobra en el arranque.
Las tres lecturas del resultado
Fichero casi enteramente sin usar. El caso más valioso. Un módulo que se carga y del que apenas se ejecuta nada. Dos causas posibles: se importa y no se usa, o se usa solo en una situación que no se dio. La primera se arregla borrando la importación; la segunda, cargándolo bajo demanda.
Fichero parcialmente usado con bloques grandes sin tocar. Un módulo del que se usa una parte. Si los bloques sin usar son funciones completas y coherentes, se pueden separar. Si están entremezclados, no.
Fichero con uso repartido y muchas líneas sueltas sin usar. Manejo de errores, ramas de compatibilidad, casos límite. No se toca. Es la firma del código defensivo, y su ausencia de ejecución es una buena noticia, no un desperdicio.
Distinguir el tercer caso del segundo es lo único que hay que hacer bien, y se hace mirando el margen del código en lugar de la tabla de porcentajes.
De la cobertura a la división del paquete
El resultado accionable de una sesión de cobertura es una lista de candidatos a salir del arranque. Los tres tipos de candidato, en orden de rentabilidad:
Funcionalidad detrás de una interacción. Un editor de texto enriquecido, un selector de fechas, un visor de gráficos, un mapa. Nada de eso hace falta hasta que el usuario abre lo que lo contiene.
Funcionalidad de una ruta concreta. Todo lo específico de una pantalla, cargado cuando se navega a ella.
Funcionalidad condicional. Herramientas de administración, funciones detrás de un indicador de característica, código de un idioma concreto.
La herramienta para los tres es la misma, la importación dinámica, y su implementación correcta tiene más detalles de los que parece.
// Carga bajo demanda con precarga oportunista, estado y manejo de errores
function cargadorPerezoso(importar, { nombre = 'modulo' } = {}) {
let promesa = null;
const cargar = () => {
if (!promesa) {
const t0 = performance.now();
promesa = importar()
.then(m => {
performance.measure('carga de ' + nombre, { start: t0, end: performance.now() });
return m;
})
.catch(e => {
promesa = null; // permitir reintento tras un fallo de red
throw e;
});
}
return promesa;
};
return {
cargar,
// Precarga cuando el navegador esta ocioso: el modulo estara listo antes
// de que el usuario lo pida, sin competir con el arranque.
precargar() {
const cuandoSePueda = globalThis.requestIdleCallback ?? (fn => setTimeout(fn, 1500));
cuandoSePueda(() => cargar().catch(() => {}));
},
// Precarga al acercar el puntero: casi siempre llega a tiempo
precargarAlAcercarse(elemento) {
const alHacer = () => { this.precargar(); quitar(); };
const quitar = () => {
elemento.removeEventListener('pointerenter', alHacer);
elemento.removeEventListener('focus', alHacer);
};
elemento.addEventListener('pointerenter', alHacer, { once: true });
elemento.addEventListener('focus', alHacer, { once: true });
}
};
}
// Uso completo y ejecutable
const editor = cargadorPerezoso(
() => import('https://esm.sh/nanoid@5'), // sustituye por tu modulo real
{ nombre: 'editor' }
);
const boton = document.createElement('button');
boton.textContent = 'Abrir editor';
document.body.append(boton);
// Precarga al acercar el puntero: el modulo llega antes que el clic
editor.precargarAlAcercarse(boton);
boton.onclick = async () => {
boton.disabled = true;
boton.textContent = 'Cargando...';
try {
const modulo = await editor.cargar();
console.log('Modulo listo:', Object.keys(modulo));
boton.textContent = 'Editor cargado';
} catch (e) {
boton.textContent = 'Error al cargar. Reintentar';
boton.disabled = false;
console.error(e);
}
};
Tres detalles de ese código resuelven los problemas que hacen que la carga diferida se sienta peor que no tenerla.
La precarga al acercar el puntero. El tiempo entre que el puntero entra en un botón y se pulsa es de unos cientos de milisegundos, suficiente para descargar un módulo mediano. Con eso, la carga diferida se vuelve imperceptible para la mayoría de los usuarios.
El reintento tras fallo. Sin poner la promesa a nulo en el error, un fallo de red deja el módulo permanentemente inaccesible: la promesa rechazada queda cacheada y todos los intentos posteriores fallan igual. Es un bug muy común y muy difícil de diagnosticar.
La medida en el perfil. La llamada a la API de medición hace que la carga aparezca en la pista de tiempos, lo que permite comprobar si llegó a tiempo o si el usuario esperó.
Verificar la mejora
Después de dividir, la comprobación no es que el número de cobertura mejore —mejorará casi seguro— sino que la métrica que importa se ha movido.
Mira el tamaño del paquete de arranque, que es lo que se quería reducir.
Mira el tiempo de bloqueo en un perfil con ralentización de CPU, que es lo que el usuario nota.
Y comprueba que no has empeorado la interacción. Una funcionalidad que antes estaba disponible al instante y ahora requiere descargar un módulo puede haber empeorado, y la precarga es lo que evita ese intercambio.
Conviene ser preciso sobre qué compra exactamente la carga diferida, porque se presenta muchas veces como una optimización sin contrapartida y no lo es. El trabajo total no baja: sube ligeramente. Más ficheros significa más peticiones, más sobrecarga de protocolo, más límites de módulo, y el código dividido comprime peor que el mismo código junto porque cada fragmento comprime contra su propio diccionario. Lo que la división compra no es menos trabajo sino mejor orden: el trabajo imprescindible ocurre primero y el resto después, idealmente cuando nadie espera. Eso significa que la división mejora si y solo si el orden nuevo es realmente mejor, y hay dos formas muy comunes de conseguir que no lo sea. La primera es dividir demasiado fino: cincuenta fragmentos pequeños cargándose en cascada durante el arranque son mucho peores que un paquete único, porque cada uno paga latencia y porque las dependencias entre ellos serializan las descargas. Hay un tamaño mínimo por debajo del cual dividir es contraproducente, y suele estar en el orden de unas decenas de kilobytes. La segunda, más sutil y más dañina, es crear cascadas de descubrimiento tardío: un fragmento que solo se puede pedir después de ejecutar otro, que a su vez dependía de un tercero. En el panel de red eso se ve como una escalera de peticiones donde cada una empieza cuando acaba la anterior, y el tiempo total puede ser peor que el del paquete monolítico aunque el peso sea el mismo. Por eso la comprobación después de dividir no es mirar el tamaño sino mirar la forma de la cascada: si tras la división aparece una escalera nueva, la división ha empeorado las cosas. Y de ahí sale la regla que gobierna todo esto: divide por momento de necesidad, no por tamaño. Lo que hace falta para pintar la primera pantalla va junto y va primero; lo que hace falta tras una interacción va aparte y se precarga en el tiempo muerto; lo que solo hace falta en una ruta concreta viaja con esa ruta. Cualquier criterio de división basado en números de tamaño y no en el momento en que el código se necesita produce fragmentos que no corresponden a nada y cascadas que nadie previó.