background blurbackground mobile blur

1/1/1970

Cómo resolví los cambios de hash en cascada con Import Maps

¡Buenas! Llevaba más de 5 años con este problema, pero hasta ahora no me había decidido a abordarlo porque ya había llegado a un punto que no podía ignorar. Cuando cambiaba un solo carácter en un archivo, la mitad de los archivos JavaScript de mi build recibían nuevos nombres con hash, aunque su contenido real no hubiera cambiado. Esto provocaba una invalidación de caché innecesaria, hacía casi imposible saber qué había cambiado realmente entre builds y, lo peor de todo: rompía mis builds en Cloudflare Pages por culpa de un límite de archivos.

A continuación voy a desglosar el problema, por qué las soluciones existentes no me servían y cómo construí un plugin de Vite personalizado usando Import Maps para resolverlo de una vez por todas.

El problema: cambios de hash en cascada

Vite usa hashes basados en contenido para los builds de producción. Cuando compilas tu app, cada archivo JavaScript recibe un hash en su nombre basado en su contenido. Si button.tsx se compila en button-abc12345.js y el contenido cambia, pasa a ser button-def45678.js. Esto es genial para invalidar la caché: los usuarios reciben el archivo nuevo cuando cambia.

El problema aparece 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 válida. Así que main.js también recibe un nuevo hash, aunque la lógica real de main.js no haya cambiado en absoluto.

Esto se propaga en cascada por todo el grafo de dependencias. Cambias una función de utilidad y, de repente, la mitad de tus archivos js tienen nuevos hashes. En mi caso, cambiar un solo carácter en useBackgroundMusic.ts provocó que más de 500 archivos recibieran nuevos hashes.

El impacto en el mundo real fue notable. Empaquetamos 8 versiones de los assets de nuestros builds anteriores para que los usuarios con versiones algo desactualizadas de nuestro cliente puedan seguir usando su versión cuando desplegamos la nueva en Cloudflare Pages. Sin embargo, Cloudflare Pages tiene un límite de 20.000 archivos que empezamos a alcanzar por culpa de nuestro cambio de i18n anterior, que multiplicó la cantidad de archivos que generamos.

Resolver los hashes en cascada nos permite almacenar muchos más builds antiguos sin alcanzar estos límites, porque ahora la mayoría de los archivos ya no necesitan cambiar. Esto también reduce la probabilidad de que un usuario con un build desactualizado tenga errores, ya que es mucho más probable que esté solicitando un archivo que ya no ha cambiado y que casualmente tenemos.

¿Por qué no [otras soluciones]?

Cuando empecé a buscar una solución, contemplé varios enfoques. Ninguno me convencía del todo.

Scripts post-build

Mi primera idea fue escribir un script post-build que normalizara todas las rutas de import, recalculara los hashes de los archivos y actualizara las referencias. Parecía sencillo: hacer un replace con regex de los nombres con hash por nombres estables y luego recalcular hashes.

Descarté este enfoque por las preocupaciones con "Heisenbugs" y posible envenenamiento de caché. Aunque almacenamos los builds antiguos en Cloudflare Pages, el riesgo de inconsistencias de caché no merecía la pena. Un script que modifica archivos después del build podría introducir bugs sutiles que solo aparecen en producción, y depurarlos sería una pesadilla.

manualChunks de Vite

Otra opción era usar la configuración manualChunks de Vite para separar el código estable (como node_modules) del código inestable (lógica de negocio). La idea era que el código de vendor cambiaría con menor frecuencia, así que se propagarían menos archivos en cascada.

Esto en realidad no soluciona el problema de raíz, solo lo mitiga. Sigues teniendo cascadas de hash dentro de los chunks de tu lógica de negocio. Yo quería una solución que abordara el problema de fondo, no que solo lo hiciera un poco menos malo.

Import Maps: la solución moderna

Los Import Maps son una característica nativa del navegador (con soporte mediante polyfill para navegadores antiguos) que desacopla los especificadores de módulo 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 real con hash.

Esto era exactamente lo que necesitaba. El contenido del archivo A se mantiene idéntico (siempre importa "button"), así que su hash no cambia. Solo el import map y el archivo modificado reciben nuevos hashes. ¡Me sorprendió bastante que nadie hubiera hecho ya un buen plugin para esto!

Construyendo el plugin de Vite

Decidí construir un plugin de Vite que hiciera lo siguiente:

  1. Transformar todos los imports relativos para que usaran especificadores de módulo estables
  2. Generar un import map que asociara esos especificadores con los nombres reales con hash
  3. 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 encontrar y reemplazar las rutas de import. Era fácil de programar y funcionaba para nuestro pequeño equipo de Foony, pero era frágil y desde luego no funcionaría en un plugin donde podría haber falsos positivos que se acabaran modificando.

El enfoque con regex tenía problemas evidentes: ¿qué pasa si una cadena en el código resulta parecerse a un nombre de archivo? ¿Y los imports dinámicos? ¿Y las sentencias export? Necesitaba una solución más robusta si iba a construir un plugin para otros.

Análisis de AST

Tenía que parsear el código JavaScript correctamente para encontrar todas las sentencias import. Mi primer intento fue es-module-lexer, que está específicamente diseñado para parsear módulos ES. Por desgracia, provocaba pánicos nativos durante la fase de análisis de módulos de Vite. Ni siquiera probar el build de asm.js detenía los pánicos.

Me decanté por Acorn, un parser de JavaScript puro, rápido y ligero. Combinado con acorn-walk para recorrer el AST, me daba todo lo que necesitaba sin los problemas de dependencias nativas.

Retos clave resueltos

Manejar todos los tipos de import

Los imports vienen de muchas formas y se tratan de forma distinta en el AST. Necesitaba manejar:

  • Imports estáticos: import x from "./file.js"
  • Imports dinámicos: import("./file.js")
  • Re-exports con nombre: export { x } from "./file.js" (¡este se me escapó al principio!)
  • Re-export de todo: export * from "./file.js"

El caso de re-export fue especialmente delicado porque no me di cuenta hasta que vi un archivo que no se estaba transformando. El código tenía export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" y mi plugin lo estaba ignorando por completo porque solo buscaba nodos ImportDeclaration e 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 (como varios archivos index.tsx en directorios distintos), tengo que diferenciarlos. No puedo usar simplemente "index" para todos.

Mi solución: si hay un conflicto, hago un hash de la ruta original más el nombre base. Por ejemplo, src/client/games/chess/index.tsx:index se hashea para crear index-abc123. Esto asegura que el mismo archivo reciba siempre el mismo especificador de módulo entre builds, incluso si se añaden o eliminan otros archivos con el mismo nombre.

Uso chunk.facadeModuleId (el punto de entrada) como identificador principal, recurriendo a chunk.moduleIds[0] si no está disponible. Esto me da una ruta estable para el hashing determinista.

Encadenamiento de source maps

Cuando transformo el código, rompo la cadena de source maps. El source map existente mapea desde el código original de TypeScript a través de Babel y la minificación hasta el código actual. Mis transformaciones añaden otra capa, así que necesito preservar esa cadena.

Uso MagicString para registrar mis transformaciones y generar un nuevo source map. Luego lo combino con el mapa existente preservando los arrays originales sources y sourcesContent. Así se mantiene la cadena completa: 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,
};

Recalcular el hash del contenido transformado

Necesito que el contenido del archivo sea estable. Para conseguirlo, transformo los imports (sustituyendo los imports con hash de Vite por mis imports estables) y luego elimino los comentarios de source map del cálculo del hash (porque hacen referencia a los nombres antiguos).

Después calculo un nuevo hash y actualizo tanto el nombre del archivo como la entrada del import map.

La implementación final

El plugin usa una estrategia en cuatro pasadas:

  1. Pasada de conteo: Detectar colisiones de nombres contando cuántos archivos comparten cada nombre base
  2. Pasada de mapeo: Crear el mapeo de chunks (nombre con hash → especificador de módulo) y el import map inicial
  3. Pasada de transformación: Reescribir las rutas de import en el código, recalcular hashes y actualizar source maps
  4. Pasada de renombrado: Actualizar los nombres de archivo del bundle y finalizar el import map

Esta es la lógica principal 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 manipular 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 hacer match con regex sobre etiquetas HTML.

En cifras

Para que te hagas una idea de lo que hace este plugin:

  • ~1.000+ archivos JavaScript procesados por build
  • ~2-3 segundos añadidos al tiempo de build (un compromiso aceptable)
  • ~99% de reducción en cambios de hash innecesarios (la mayoría de archivos solo cambian cuando su contenido real cambia)
  • ~340 líneas de código del plugin (incluyendo comentarios y manejo de errores)

El plugin gestiona todos los casos límite que me he encontrado hasta ahora, y el proceso de build es ahora mucho más predecible.

Lecciones aprendidas

Por qué el análisis de AST es esencial

Aplicar regex sobre código empaquetado es peligroso. Si una cadena en tu código resulta parecerse a un nombre de archivo, la regex la reescribirá. El análisis de AST garantiza que solo transformes sentencias reales de import/export.

Por qué Acorn en lugar de es-module-lexer

es-module-lexer es más rápido y está más enfocado al propósito, pero los problemas de pánico nativo lo hacían inviable en mi contexto de plugin de Vite. Acorn es JavaScript puro, lo que significa que no hay que preocuparse por dependencias nativas. En el futuro me gustaría echarle otro vistazo a es-module-lexer como optimización de velocidad, pero por ahora Acorn funciona perfectamente.

Por qué los Import Maps frente a otras alternativas

Los Import Maps son un estándar web con soporte nativo en navegadores. Son la forma "correcta" de resolver este problema. El polyfill (es-module-shims) gestiona los navegadores antiguos (por ejemplo, Safari < 16.4) con elegancia, y la solución es limpia y mantenible.

Conclusión

El plugin de Import Maps consigue evitar los cambios de hash en cascada en mis builds de Vite. Los archivos ahora solo reciben nuevos hashes cuando su contenido real cambia, 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, mantenible 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, una vez que 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 de Vite.

Posibles mejoras futuras incluyen optimizar con es-module-lexer cuando se resuelvan los problemas de pánico nativo, o añadir soporte para escenarios de import más complejos. Pero por ahora, el plugin hace exactamente lo que necesito.

¿Y quién sabe? Quizá algún día Vite acabe soportando algo así de forma nativa.

(Actualización: Después de probar el plugin en el build de Foony, algunos usuarios tuvieron problemas inesperados, así que lo desactivé por ahora. Quizá lo retome más adelante. Quizá. Sigo pensando que es una solución muy chula.)

8 Ball Pool online multiplayer billiards icon