

1/1/1970
Cómo solucioné los cambios de hash en cascada con Import Maps
¡Muy buenas! Llevo más de 5 años con este problema, pero solo ahora he decidido atacarlo porque ha llegado a un punto en el que ya no podía ignorarlo. Cuando cambiaba un solo carácter en un archivo, la mitad de los archivos JavaScript de mi build acababan con nuevos nombres con hash, aunque su contenido real no hubiera cambiado. Esto provocaba invalidaciones de caché innecesarias, hacía casi imposible seguir qué había cambiado realmente entre builds y, lo peor de todo: rompía mis builds de Cloudflare Pages por culpa de un límite de archivos.
Aquí abajo desgloso el problema, por qué las soluciones existentes no me servían y cómo acabé creando un plugin de Vite usando Import Maps para solucionarlo de una vez por todas.
El problema: cambios de hash en cascada
Vite usa hashes basados en el contenido para los builds de producción. Cuando haces el build de tu app, cada archivo JavaScript recibe un hash en el nombre de archivo según su contenido. Si button.tsx se compila como button-abc12345.js y el contenido cambia, pasa a ser button-def45678.js. Esto viene genial para hacer cache busting: los usuarios reciben el archivo nuevo cuando cambia.
El problema llega cuando el archivo A importa el archivo B. Imagina que tienes:
// main.js
import { Button } from "./button-abc12345.js";
Cuando button.tsx cambia, Vite genera button-def45678.js. Pero ahora main.js también cambia porque contiene la cadena "./button-abc12345.js", que ya no es correcta. Así que main.js recibe también un nuevo hash aunque la lógica real de main.js no haya cambiado nada.
Esto se va propagando por todo tu grafo de dependencias. Modificas una sola función de utilidades y, de repente, la mitad de tus archivos JS reciben hashes nuevos. En mi caso, cambiar un solo carácter en useBackgroundMusic.ts hacía que más de 500 archivos volvieran a ser hasheados.
El impacto en el mundo real era considerable. Empaquetamos 8 versiones de los assets de builds anteriores para que los usuarios que están en versiones un poco antiguas de nuestro cliente puedan seguir usando su versión cuando desplegamos una nueva en Cloudflare Pages. Pero Cloudflare Pages tiene un límite de 20.000 archivos que empezamos a tocar por culpa de nuestro cambio de i18n anterior, que disparó la cantidad de archivos que generamos.
Al solucionar los hashes en cascada podemos guardar muchas más builds antiguas sin llegar a esos límites, porque ahora la mayoría de archivos ya no necesitan cambiar. Esto también reduce la probabilidad de que un usuario con un build antiguo reciba errores, ya que es mucho más probable que pida un archivo que ya no cambia y que nosotros aún conservamos.
¿Por qué no [soluciones alternativas]?
Cuando empecé a pensar en cómo solucionar esto, valoré varias opciones. Ninguna encajaba del todo.
Post-build Scripts
Mi primera idea fue escribir un script post-build que normalizara todas las rutas de importación, volviera a hacer el hash de los archivos y actualizara las referencias. Parecía algo sencillo: simplemente reemplazar con regex los nombres de archivo con hash por nombres estables y, después, recalcular los hashes.
Descarté esta opción por miedo a los "Heisenbugs" y a posibles problemas de contaminación de caché. Aunque almacenamos builds anteriores en Cloudflare Pages, el riesgo de inconsistencias en caché no merecía la pena. Un script que modifica archivos después del build puede introducir errores sutiles que solo aparecen en producción, y depurarlos sería una pesadilla.
Vite manualChunks
Otra opción era usar la configuración manualChunks de Vite para separar el código estable (como node_modules) del código inestable (la lógica de negocio). La idea era que el código de terceros cambiara con menos frecuencia, de modo que menos archivos se vieran afectados en cascada.
Esto en realidad no resuelve el problema de raíz, solo lo mitiga. Sigues teniendo hashes en cascada dentro de tus chunks de lógica de negocio. Yo quería una solución que atacara el problema principal, no solo que lo hiciera un poco menos malo.
Import Maps: la solución moderna
Los Import Maps son una característica nativa del navegador (con polyfill para navegadores más antiguos) que separa los identificadores de los módulos de las rutas de archivo. En lugar de que el archivo A importe "./button-abc123.js", importa "button". El navegador usa el import map para resolver "button" al nombre de archivo real con hash.
Esto era justo lo que necesitaba. El contenido del archivo A permanece idéntico (siempre importa "button"), así que su hash se mantiene. Solo el import map y el archivo que ha cambiado reciben hashes nuevos. Me sorprendió bastante que nadie hubiera hecho ya un buen plugin para esto.
El viaje de implementación
Decidí crear un plugin de Vite que hiciera lo siguiente:
- Transformar todas las importaciones relativas para que usen identificadores de módulo estables
- Generar un import map que asocie esos identificadores con los nombres de archivo reales con hash
- Inyectar el import map en el HTML
El plugin ya está disponible en GitHub: @foony/vite-plugin-import-map
Enfoque inicial
Empecé con un plugin de Vite usando el hook generateBundle. Mi primer intento usaba regex para buscar y reemplazar rutas de importación. Era fácil de programar y funcionaba para nuestro pequeño equipo en Foony, pero era frágil y desde luego no valía para un plugin en el que podría haber falsos positivos que se modificaran por error.
El enfoque con regex tenía problemas evidentes: ¿y si una cadena en el código parecía un nombre de archivo? ¿Qué pasaba con las importaciones dinámicas? ¿Y con las sentencias de exportación? Necesitaba una solución más robusta si quería crear un plugin que otra gente pudiera usar.
Análisis por AST
Tenía que parsear el código JavaScript en condiciones para encontrar todas las sentencias de importación. Lo primero que probé fue es-module-lexer, que está pensado justo para analizar módulos ES. Por desgracia, provocaba errores nativos durante la fase de análisis de módulos de Vite. Ni siquiera usando el build en asm.js conseguí evitar esos fallos.
Al final me quedé con Acorn, un parser rápido, ligero y escrito en JavaScript puro. Combinado con acorn-walk para recorrer el AST me daba todo lo que necesitaba sin los problemas de dependencias nativas.
Problemas clave que tuve que resolver
Manejar todos los tipos de import
Las importaciones vienen en muchos sabores y el AST las trata de formas distintas. Tenía que manejar:
- Importaciones estáticas:
import x from "./file.js" - Importaciones dinámicas:
import("./file.js") - Re-exportaciones con nombre:
export { x } from "./file.js"(esta me la dejé al principio) - Re-exportar todo:
export * from "./file.js"
El caso de las re-exportaciones fue especialmente puñetero porque me lo dejé sin cubrir hasta que vi un archivo que no se estaba transformando. Ese archivo tenía export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" y mi plugin lo ignoraba por completo porque solo estaba buscando nodos ImportDeclaration y ImportExpression.
Así es como los manejo todos ahora:
walk(ast, {
ImportDeclaration(node: any) {
// Static imports: import x from "spec"
const specifier = node.source.value;
// ... transform logic
},
ExportNamedDeclaration(node: any) {
// Named exports with source: export { x, y } from "spec"
if (!node.source?.value) return;
// ... transform logic
},
ExportAllDeclaration(node: any) {
// Export all: export * from "spec"
if (!node.source?.value) return;
// ... transform logic
},
ImportExpression(node: any) {
// Dynamic imports: import("spec")
// ... transform logic
},
});
Resolución determinista de conflictos
Cuando varios archivos comparten el mismo nombre base (por ejemplo, varios index.tsx en directorios distintos), tengo que desambiguarlos. No puedo usar simplemente "index" para todos.
Mi solución: si hay conflicto, hago un hash de la ruta original del archivo junto con el nombre base. Por ejemplo, src/client/games/chess/index.tsx:index se hashea para crear index-abc123. Así me aseguro de que el mismo archivo reciba siempre el mismo identificador de módulo entre builds, incluso si aparecen o desaparecen otros archivos con el mismo nombre.
Uso chunk.facadeModuleId (el punto de entrada) como identificador principal y, si no está disponible, recurro a chunk.moduleIds[0]. Esto me da una ruta de origen estable para poder hacer hashing de forma determinista.
Encadenado de source maps
Cuando transformo el código, rompo la cadena de los source maps. El source map existente enlaza desde el código TypeScript original, pasando por Babel y la minificación, hasta el código actual. Mis transformaciones añaden otra capa, así que necesito conservar esa cadena.
Uso MagicString para seguir mis transformaciones y generar un source map nuevo. Luego lo fusiono con el mapa existente conservando los arrays originales sources y sourcesContent. De este modo se mantiene toda la cadena: código original → (mapa existente) → código transformado.
const existingMap = typeof chunk.map === 'string' ? JSON.parse(chunk.map) : chunk.map;
const newMap = magicString.generateMap({
source: fileName,
file: newFileName,
includeContent: true,
hires: true,
});
// Merge: use new map's mappings but preserve original sources
chunk.map = {
...newMap,
sources: existingMap.sources || newMap.sources,
sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
file: newFileName,
};
Rehacer el hash del contenido transformado
Necesito que el contenido de los archivos sea estable. Para conseguirlo, transformo las importaciones (reemplazo las importaciones con hash de Vite por mis importaciones estables) y después elimino los comentarios de source map de la parte del código que uso para calcular el hash, porque hacen referencia a nombres de archivo antiguos.
Después calculo un hash nuevo y actualizo tanto el nombre de archivo como la entrada correspondiente en el import map.
La implementación final
El plugin usa una estrategia de cuatro pasadas:
- Pasada de conteo: detectar colisiones de nombres contando cuántos archivos comparten cada nombre base
- Pasada de mapeo: crear el mapa de chunks (nombre de archivo con hash → identificador de módulo) y el import map inicial
- Pasada de transformación: reescribir las rutas de importación en el código, recalcular los hashes y actualizar los source maps
- Pasada de renombrado: actualizar los nombres de archivo del bundle y finalizar el import map
Este es el núcleo de la lógica de transformación:
import {simple as walk} from 'acorn-walk';
// Parse the code to get an AST
const ast = Parser.parse(chunk.code, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
});
const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];
// Traverse the AST to find all imports/exports
walk(ast, {
ImportDeclaration(node: any) {
const specifier = node.source.value;
const filename = specifier.split('/').pop()!;
const moduleSpec = chunkMapping.get(filename);
if (moduleSpec) {
importsToTransform.push({
start: node.source.start + 1, // +1 to skip opening quote
end: node.source.end - 1, // -1 to skip closing quote
replacement: moduleSpec,
});
}
},
// ... handle other node types
});
// Apply transformations in reverse order to preserve positions
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
magicString.overwrite(transform.start, transform.end, transform.replacement);
}
Para inyectar el import map en el HTML, uso la API de inyección de etiquetas de Vite en lugar de manipularlo con regex:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Esto es mucho más fiable que intentar casar etiquetas HTML con regex.
En cifras
Para que te hagas una idea de lo que hace este plugin:
- ~1.000+ archivos JavaScript procesados por build
- ~2-3 segundos extra en el tiempo de build (un coste aceptable)
- ~99% de reducción en cambios de hash innecesarios (la mayoría de archivos ahora solo cambian cuando cambia su contenido real)
- ~340 líneas de código del plugin (incluidos comentarios y manejo de errores)
El plugin cubre todos los edge cases que me he ido encontrando y el proceso de build ahora es mucho más predecible.
Lecciones aprendidas
Por qué el análisis por AST es esencial
Usar regex sobre código ya empacado es peligroso. Si alguna cadena en tu código se parece a un nombre de archivo, la regex la va a reescribir. Analizar el AST te garantiza que solo transformas sentencias reales de import/export.
Por qué Acorn y no es-module-lexer
es-module-lexer es más rápido y está más enfocado a este caso, pero los problemas de fallos nativos lo hicieron inútil para mi plugin de Vite. Acorn está escrito en JavaScript puro, lo que significa que no hay dependencias nativas de las que preocuparse. En el futuro me gustaría volver a mirar es-module-lexer como posible optimización de velocidad, pero por ahora Acorn funciona perfecto.
Por qué Import Maps y no otras alternativas
Los Import Maps son un estándar web con soporte nativo en los navegadores. Son la forma "correcta" de resolver este problema. El polyfill (es-module-shims) se encarga de los navegadores más viejos (por ejemplo Safari < 16.4) sin dramas, y la solución queda limpia y fácil de mantener.
Conclusión
El plugin de Import Maps evita con éxito los cambios de hash en cascada en mis builds de Vite. Los archivos ahora solo reciben un hash nuevo cuando cambia su contenido real, no cuando cambian sus dependencias. Esto hace que los builds sean más predecibles, reduce la invalidación de caché innecesaria y nos ayuda a mantenernos por debajo de los límites de archivos de Cloudflare Pages.
La solución es sencilla, fácil de mantener y se apoya en estándares web modernos. Es un buen ejemplo de cómo, a veces, la solución "correcta" también es la más simple, cuando entiendes el problema lo bastante a fondo como para verla.
El plugin es open source y está disponible en GitHub: @foony/vite-plugin-import-map. Puedes instalarlo con npm install @foony/vite-plugin-import-map y empezar a usarlo en tus propios proyectos con Vite.
Entre las mejoras futuras podría estar optimizarlo con es-module-lexer cuando se resuelvan los problemas de fallos nativos, o añadir soporte para escenarios de importación más complejos. Pero por ahora, el plugin hace exactamente lo que necesito que haga.
Y quién sabe, quizá algún día Vite tenga algo así de forma nativa.