Reexportaciones y ficheros barril: la comodidad que cuesta kilobytes
Cómo funciona una reexportación por dentro, por qué un barril multiplica el trabajo del empaquetador y a veces impide el recorte, y las tres alternativas que conservan la ergonomía sin el coste.
El fichero barril —ese index.js que reexporta todo un directorio para poder importar desde un solo sitio— es una de las convenciones más extendidas del ecosistema y una de las que más daño hace al tamaño y a la velocidad de compilación. No siempre: con módulos ES puros y sideEffects bien declarado, un barril se recorta perfectamente. El problema es que esas dos condiciones fallan más a menudo de lo que la gente cree, y cuando fallan el barril convierte una importación de 2 KB en una de 200.
- Describir qué hace el empaquetador con cada forma de reexportación.
- Explicar por qué
export *es peor que la reexportación nombrada. - Cuantificar el coste de un barril en tamaño y en tiempo de compilación.
- Aplicar las tres alternativas que mantienen la comodidad sin el coste.
Las formas de reexportar y qué hace cada una
Hay tres, y no son equivalentes.
Reexportación nombrada explícita. El empaquetador sabe exactamente qué nombres vienen de dónde:
export { Boton } from './Boton.js';
export { Modal } from './Modal.js';
Es la forma que mejor se analiza. Si el consumidor solo usa Boton, el enlace a Modal.js es candidato a eliminarse, y con sideEffects: false desaparece con todas sus dependencias.
Reexportación con estrella. El empaquetador tiene que resolver el módulo de origen para saber qué nombres exporta:
export * from './Boton.js';
export * from './Modal.js';
Funciona y se puede recortar, con dos costes reales. El primero es que el empaquetador tiene que abrir y parsear los dos módulos solo para saber qué exportan, incluso si al final los descarta; en un barril con cuarenta ficheros eso es trabajo de compilación multiplicado por cuarenta. El segundo es la ambigüedad: si dos módulos exportan el mismo nombre, la especificación dice que el nombre queda excluido, y eso es un fallo silencioso que aparece como un undefined inexplicable.
Reexportación por defecto renombrada. La más costosa de analizar:
export { default as Boton } from './Boton.js';
Funciona, y obliga al empaquetador a mantener la correspondencia entre el nombre externo y el default interno a través de toda la cadena. En barriles anidados —un barril que reexporta de otro barril— esa cadena se alarga y algunos analizadores se rinden y conservan todo.
El coste que se paga aunque el recorte funcione
Aquí está la parte que sorprende: incluso cuando el barril se recorta perfectamente, cuesta tiempo de compilación, y en proyectos grandes ese coste es el que primero se nota.
El mecanismo es directo. Para resolver import { Boton } from './componentes', el empaquetador tiene que:
- Leer y parsear
componentes/index.js. - Descubrir que reexporta cuarenta módulos.
- Leer y parsear los cuarenta para conocer sus exportaciones.
- Descubrir que veinte de ellos importan otras cosas, y seguir la cadena.
- Determinar que solo
Boton.jses alcanzable. - Descartar el resto.
Los pasos 1 a 4 se pagan enteros. En una aplicación con doscientos ficheros que importan del barril, el grafo que hay que resolver es órdenes de magnitud mayor que el necesario. Los síntomas son reconocibles: el servidor de desarrollo tarda mucho en el arranque en frío, la recarga en caliente de un fichero cualquiera es lenta, y la comprobación de tipos tarda minutos.
Y hay un efecto en tiempo de ejecución en desarrollo que multiplica el problema: con un servidor que sirve módulos sin empaquetar, importar del barril hace que el navegador pida los cuarenta ficheros, cada uno con su petición. Abrir una página en desarrollo puede generar mil peticiones de módulo.
Cuando el barril rompe el recorte del todo
Tres situaciones concretas, y todas aparecen en producción.
El barril importa algo con efectos secundarios. Un solo import './estilos-globales.css' o un import './registrar.js' dentro del barril, y todo el que importe cualquier cosa del barril arrastra ese efecto y su cadena de dependencias.
El paquete no declara sideEffects. Sin la promesa, la evaluación de los cuarenta módulos se conserva, y con ella todas sus dependencias. Es el caso que convierte una importación de un botón en 200 KB.
Hay una dependencia circular a través del barril. El componente A importa del barril para coger B, y B importa del barril para coger A. El grafo tiene un ciclo, el orden de evaluación se vuelve dependiente del orden de resolución, y el empaquetador conserva todo el ciclo por seguridad. Además de peso, esto produce los undefined en tiempo de ejecución más difíciles de depurar que existen, porque dependen del orden en que se evalúan los módulos, que cambia entre desarrollo y producción.
Las tres alternativas
Alternativa uno: importar por ruta concreta. La más simple y la que siempre funciona:
// En lugar de esto
import { Boton, Modal } from '@/componentes';
// Esto
import { Boton } from '@/componentes/Boton';
import { Modal } from '@/componentes/Modal';
Es más verboso al escribir y los editores lo autocompletan igual de bien. El coste real de esta alternativa es de ergonomía y es menor de lo que la gente supone; el beneficio en tiempo de compilación en un proyecto grande es enorme.
Alternativa dos: el mapa de subrutas en el package.json. Para una biblioteca publicada, el campo exports permite ofrecer puntos de entrada por componente sin barril:
{
"name": "mi-libreria",
"sideEffects": false,
"exports": {
".": "./dist/index.js",
"./boton": "./dist/Boton.js",
"./modal": "./dist/Modal.js",
"./*": "./dist/*.js"
}
}
Con eso, import { Boton } from 'mi-libreria/boton' va directo al fichero, sin pasar por ningún barril, y el usuario obtiene el mismo resultado que si importara por ruta interna pero con una API estable que tú controlas.
Alternativa tres: la reescritura automática de importaciones. Algunas herramientas permiten transformar en tiempo de compilación una importación de barril en importaciones por ruta, dando lo mejor de los dos mundos. Es una solución válida y tiene una pega: añade una capa de magia a la cadena de compilación, y cuando algo falla, el fuente que escribiste y el que se compila ya no coinciden. Úsala si el coste de migrar las importaciones a mano es prohibitivo, no como opción por defecto.
La conversación sobre barriles se centra siempre en el bundle, y en proyectos con TypeScript el coste mayor suele estar en otro sitio: la comprobación de tipos.
El mecanismo es el mismo que en el empaquetador y peor, porque el comprobador de tipos no puede recortar nada: para resolver el tipo de Boton en import { Boton } from '@/componentes', tiene que construir la tabla de símbolos completa del barril, y para eso tiene que abrir, parsear y tipar los cuarenta módulos, y todos los que ellos importen. Cada fichero que importa del barril paga esa resolución, con caché pero no gratis.
Los números que se ven en proyectos reales: una base de código mediana con barriles en todos los directorios puede tardar entre tres y ocho veces más en la comprobación de tipos que la misma base con importaciones directas. Y esa comprobación se ejecuta en cada guardado en el editor, en cada cambio propuesto y en cada compilación.
Hay dos ajustes que reducen el daño sin migrar nada.
import type explícito para todo lo que sea solo un tipo. Con verbatimModuleSyntax activado, el compilador exige la palabra clave y elimina la importación por completo en la emisión, en lugar de dejar una importación de valor que el empaquetador tiene que resolver:
{ "compilerOptions": { "verbatimModuleSyntax": true, "isolatedModules": true } }import type { PropsBoton } from '@/componentes/Boton'; // desaparece al emitir
import { Boton } from '@/componentes/Boton'; // se quedaEsto arregla además el fallo de división rota por un import de tipos, que es la misma causa vista desde el otro lado.
Y la medición que decide si merece la pena migrar, porque conviene tener el dato antes de una refactorización grande:
# Cuanto tarda y cuantos ficheros toca la comprobacion completa
npx tsc --noEmit --extendedDiagnostics | grep -E 'Files:|Total time:|Check time:'
# Que ficheros arrastra una importacion concreta
npx tsc --noEmit --traceResolution 2>&1 | grep -c 'Resolving module'Si Files es mucho mayor que el número de ficheros de tu proyecto, estás arrastrando grafos completos por barriles. Migrar los tres o cuatro barriles más importados suele bajar esa cifra a la mitad, y con ella el tiempo de todas las comprobaciones futuras. Es una de las pocas refactorizaciones cuyo beneficio se nota el mismo día en la máquina de cada persona del equipo.
Cuándo un barril sí está bien
No es una prohibición. Un barril está bien cuando se cumplen las tres condiciones:
El paquete declara sideEffects: false y es honesto. Sin efectos ocultos en ninguno de los módulos reexportados.
Todo es módulo ES puro. Sin CommonJS transpilado en la cadena.
El número de módulos reexportados es pequeño. Cinco o diez es cómodo y barato; cuarenta es un problema de compilación.
Y hay un caso donde el barril es claramente la opción correcta: la API pública de una biblioteca. Ahí el barril documenta la superficie del paquete, permite renombrar sin romper a los consumidores, y el coste de resolución lo paga quien compila, una vez, con el beneficio de una API estable. Lo que hay que evitar es el barril interno, ese que existe solo para ahorrar teclas dentro del propio proyecto y que multiplica el grafo en cada fichero.
Cuenta cuántos ficheros barril tiene tu proyecto y cuántos módulos reexporta cada uno. Coge el más grande y el más importado, mide el tiempo de tsc --noEmit y el tamaño del punto de entrada. Sustituye sus importaciones por rutas directas en los diez ficheros que más lo usan y vuelve a medir las dos cifras. Con ese dato en la mano, decide si la migración completa merece la pena.