1/7/2026

Como Resolvi Mudanças em Cascata de Hashes com Import Maps

Olá! Tenho esse problema há mais de 5 anos, mas só agora decidi enfrentá-lo porque chegou a um ponto em que não dava mais para ignorar. Quando eu mudava um único caractere em um arquivo, metade dos arquivos JavaScript do meu build ganhava novos nomes com hashes, mesmo que o conteúdo real deles não tivesse mudado. Isso causava invalidação desnecessária de cache, tornava praticamente impossível rastrear o que de fato havia mudado entre builds e, pior de tudo: quebrava meus builds no Cloudflare Pages por causa de um limite de arquivos.

Abaixo vou detalhar o problema, por que as soluções existentes não funcionaram para mim e como construí um plugin Vite personalizado usando Import Maps para resolver isso de uma vez por todas.

O Problema: Mudanças de Hash em Cascata

O Vite usa hashing baseado em conteúdo para builds de produção. Quando você compila seu app, cada arquivo JavaScript recebe um hash em seu nome com base no conteúdo. Se button.tsx é compilado para button-abc12345.js e o conteúdo muda, ele se torna button-def45678.js. Isso é ótimo para invalidação de cache: os usuários recebem o novo arquivo quando ele muda.

O problema aparece quando o Arquivo A importa o Arquivo B. Digamos que você tenha:

// main.js

Quando button.tsx muda, o Vite gera button-def45678.js. Mas agora main.js também muda, porque ele contém a string "./button-abc12345.js", que agora está errada. Então main.js ganha um novo hash também, mesmo que a lógica real dele não tenha mudado em nada.

Isso se propaga em cascata por todo o seu grafo de dependências. Mude uma função utilitária e, de repente, metade dos seus arquivos js ganha novos hashes. No meu caso, mudar um único caractere em useBackgroundMusic.ts causou o re-hashing de mais de 500 arquivos.

O impacto no mundo real foi significativo. Empacotamos 8 versões dos assets de builds anteriores para que usuários em versões ligeiramente desatualizadas do nosso cliente ainda possam executar a versão deles quando fazemos o deploy da nova versão no Cloudflare Pages. No entanto, o Cloudflare Pages tem um limite de 20.000 arquivos, que começamos a atingir por causa da nossa mudança de i18n recente, que explodiu a quantidade de arquivos que estamos criando.

Resolver os hashes em cascata permite armazenar muito mais builds anteriores sem atingir esses limites, porque agora a maioria dos arquivos não precisa mais mudar. Isso também reduz a probabilidade de um usuário em um build desatualizado ter erros, já que é muito mais provável que ele esteja solicitando um arquivo agora inalterado que por acaso temos.

Por Que Não [Soluções Alternativas]?

Quando comecei a pensar em resolver isso, considerei algumas abordagens. Nenhuma delas se encaixou perfeitamente.

Scripts Pós-build

Minha primeira ideia foi escrever um script pós-build que normalizaria todos os caminhos de import, recalcularia os hashes dos arquivos e atualizaria as referências. Parecia simples: usar regex para substituir os nomes com hash por nomes estáveis e depois recalcular os hashes.

Rejeitei essa abordagem por causa de "Heisenbugs" e preocupações com envenenamento de cache. Mesmo armazenando builds anteriores no Cloudflare Pages, o risco de inconsistências de cache não compensava. Um script que modifica arquivos depois do build poderia introduzir bugs sutis que só apareceriam em produção, e debugar isso seria um pesadelo.

manualChunks do Vite

Outra opção era usar a configuração manualChunks do Vite para separar código estável (como node_modules) do código instável (lógica de negócio). A ideia era que o código de terceiros mudaria com menos frequência, então menos arquivos cascatariam.

Isso, na verdade, não resolve o problema de raiz, apenas o ameniza. Você ainda tem hashes em cascata dentro dos chunks de lógica de negócio. Eu queria uma solução que resolvesse o problema central, não só o tornasse um pouco menos ruim.

Import Maps: A Solução Moderna

Import Maps são um recurso nativo do navegador (com suporte via polyfill para navegadores mais antigos) que desacopla os especificadores de módulo dos caminhos de arquivo. Em vez de o Arquivo A importar "./button-abc123.js", ele importa "button". O navegador usa o import map para resolver "button" para o nome de arquivo com hash real.

Era exatamente o que eu precisava. O conteúdo do Arquivo A permanece idêntico (ele sempre importa "button"), então o hash dele permanece o mesmo. Apenas o import map e o arquivo modificado ganham novos hashes. Fiquei meio chocado de ninguém ter feito um bom plugin para isso ainda!

Construindo o Plugin Vite

Decidi construir um plugin Vite que faria o seguinte:

  1. Transformar todas as importações relativas para usar especificadores de módulo estáveis
  2. Gerar um import map que mapeia esses especificadores para os nomes de arquivos com hash reais
  3. Injetar o import map no HTML

O plugin agora está disponível no GitHub: @foony/vite-plugin-import-map

Abordagem Inicial

Comecei com um plugin Vite usando o hook generateBundle. Minha primeira tentativa usava regex para encontrar e substituir caminhos de import. Foi fácil de codificar e funcionou para a nossa pequena equipe na Foony, mas era frágil e definitivamente não funcionaria em um plugin onde poderia haver falsos positivos sendo modificados.

A abordagem com regex tinha problemas óbvios: e se uma string no código por acaso parecesse um nome de arquivo? E os imports dinâmicos? E as instruções de export? Eu precisava de uma solução mais robusta se quisesse construir um plugin para outras pessoas.

Análise de AST

Eu precisava analisar o código JavaScript adequadamente para encontrar todas as instruções de import. Minha primeira tentativa foi com o es-module-lexer, que é especificamente projetado para analisar módulos ES. Infelizmente, ele causava panics nativos durante a fase de análise de módulos do Vite. Mesmo tentando o build asm.js, os panics não pararam.

Acabei optando pelo Acorn, um parser JavaScript puro, rápido e leve. Combinado com o acorn-walk para travessia de AST, ele me dava tudo que eu precisava sem os problemas de dependência nativa.

Principais Desafios Resolvidos

Lidando com Todos os Tipos de Import

Imports vêm em muitas formas e são tratados de maneira diferente na AST. Eu precisava lidar com:

  • Imports estáticos: import x from "./file.js"
  • Imports dinâmicos: import("./file.js")
  • Re-exports nomeados: export { x } from "./file.js" (esse eu inicialmente esqueci!)
  • Re-export geral: export * from "./file.js"

O caso de re-export foi particularmente complicado, porque eu o esqueci até ver um arquivo que não estava sendo transformado. O código tinha export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" e meu plugin estava ignorando completamente porque eu só estava procurando por nós ImportDeclaration e ImportExpression.

Veja como lido com todos eles agora:

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
  },
});

Resolução Determinística de Conflitos

Quando vários arquivos têm o mesmo nome base (como vários index.tsx em diretórios diferentes), preciso desambiguá-los. Não posso simplesmente usar "index" para todos.

Minha solução: se houver conflito, faço hash do caminho de origem original somado ao nome base. Por exemplo, src/client/games/chess/index.tsx:index recebe um hash que cria index-abc123. Isso garante que o mesmo arquivo sempre receba o mesmo especificador de módulo entre builds, mesmo que outros arquivos com o mesmo nome sejam adicionados ou removidos.

Uso chunk.facadeModuleId (o ponto de entrada) como identificador primário, recorrendo a chunk.moduleIds[0] se ele não estiver disponível. Isso me dá um caminho de origem estável para o hashing determinístico.

Encadeamento de Source Maps

Quando transformo o código, estou quebrando a cadeia do source map. O source map existente mapeia desde o código TypeScript original, passando por Babel e minificação, até o código atual. Minhas transformações adicionam outra camada, então preciso preservar essa cadeia.

Uso MagicString para rastrear minhas transformações e gerar um novo source map. Depois, faço o merge com o map existente, preservando os arrays originais sources e sourcesContent. Isso mantém a cadeia completa: Código Original → (map 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,
};

Re-hashing do Conteúdo Transformado

Eu preciso de conteúdo de arquivo estável. Para isso, transformo os imports (substituindo os imports com hash do Vite pelos meus imports estáveis) e depois removo os comentários de source map do cálculo do hash (eles referenciam nomes de arquivo antigos).

Depois disso, calculo um novo hash e atualizo tanto o nome do arquivo quanto a entrada no import map.

A Implementação Final

O plugin usa uma estratégia de quatro passagens:

  1. Passagem de contagem: detecta colisões de nomes contando quantos arquivos compartilham cada nome base
  2. Passagem de mapeamento: cria o mapeamento de chunks (nome de arquivo com hash → especificador de módulo) e o import map inicial
  3. Passagem de transformação: reescreve os caminhos de import no código, recalcula hashes e atualiza source maps
  4. Passagem de renomeação: atualiza os nomes de arquivos do bundle e finaliza o import map

Aqui está a lógica central da transformação:


// 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 injetar o import map no HTML, uso a API de injeção de tags do Vite em vez de manipulação com regex:

transformIndexHtml() {
  return {
    tags: [
      {
        tag: 'script',
        attrs: {type: 'importmap'},
        children: JSON.stringify(importMap, null, 2),
        injectTo: 'head-prepend',
      },
    ],
  };
}

Isso é muito mais confiável do que tentar fazer match de tags HTML com regex.

Em Números

Para você ter uma ideia do que esse plugin faz:

  • ~1.000+ arquivos JavaScript processados por build
  • ~2-3 segundos adicionados ao tempo de build (uma troca aceitável)
  • ~99% de redução em mudanças de hash desnecessárias (a maioria dos arquivos agora só muda quando o conteúdo real deles muda)
  • ~340 linhas de código de plugin (incluindo comentários e tratamento de erros)

O plugin lida com todos os casos extremos que encontrei até agora, e o processo de build agora é muito mais previsível.

Lições Aprendidas

Por que a análise de AST é essencial

Regex em código empacotado é perigoso. Se uma string no seu código por acaso parecer um nome de arquivo, o regex vai reescrevê-la. A análise de AST garante que você só transforma instruções reais de import/export.

Por que Acorn em vez de es-module-lexer

O es-module-lexer é mais rápido e específico para o propósito, mas os problemas de panic nativo o tornaram inutilizável no contexto do meu plugin Vite. O Acorn é JavaScript puro, o que significa que não há dependências nativas para se preocupar. Vou querer dar uma olhada no es-module-lexer no futuro como otimização de velocidade, mas, por enquanto, o Acorn funciona perfeitamente.

Por que Import Maps em vez das alternativas

Import Maps são um padrão web com suporte nativo nos navegadores. Eles são a forma "certa" de resolver esse problema. O polyfill (es-module-shims) lida com navegadores mais antigos (por exemplo, Safari < 16.4) com elegância, e a solução é limpa e fácil de manter.

Conclusão

O plugin de Import Maps previne com sucesso as mudanças de hash em cascata nos meus builds Vite. Os arquivos agora só ganham novos hashes quando o conteúdo real deles muda, não quando suas dependências mudam. Isso torna os builds mais previsíveis, reduz invalidação desnecessária de cache e nos ajuda a ficar abaixo dos limites de arquivos do Cloudflare Pages.

A solução é simples, fácil de manter e usa padrões web modernos. É um bom exemplo de como, às vezes, a solução "certa" também é a mais simples, desde que você entenda o problema profundamente o suficiente para enxergá-la.

O plugin é open source e está disponível no GitHub: @foony/vite-plugin-import-map. Você pode instalá-lo com npm install @foony/vite-plugin-import-map e começar a usar nos seus próprios projetos Vite.

Melhorias futuras podem incluir otimização com es-module-lexer quando os problemas de panic nativo forem resolvidos, ou adicionar suporte para cenários de import mais complexos. Mas, por enquanto, o plugin faz exatamente o que eu preciso.

E quem sabe? Talvez algum dia o Vite suporte algo assim nativamente.

(Atualização: depois de testar o plugin no build da Foony, alguns usuários tiveram problemas inesperados, então o desativei por enquanto. Vou revisitá-lo mais tarde. Talvez. Ainda acho que é uma solução bem legal.)

8 Ball Pool online multiplayer billiards icon