background blurbackground mobile blur

1/1/1970

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

Oi! Eu convivo com esse problema há mais de 5 anos, mas só agora decidi encarar de frente porque chegou num 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 hash, mesmo sem o conteúdo de verdade ter mudado. Isso causava invalidação de cache desnecessária, deixava quase impossível entender o que realmente tinha mudado entre um build e outro e, o pior de tudo: quebrava meus builds do Cloudflare Pages por causa de um limite de arquivos.

Aqui embaixo eu explico o problema, por que as soluções existentes não funcionaram para mim e como eu criei um plugin personalizado de Vite usando Import Maps para resolver isso de vez.

O Problema: Mudanças de Hash em Cascata

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

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

// main.js
import { Button } from "./button-abc12345.js";

Quando button.tsx muda, o Vite gera button-def45678.js. Só que agora main.js também muda, porque ele contém a string "./button-abc12345.js", que passou a estar errada. Então main.js ganha um hash novo também, mesmo sem a lógica de verdade em main.js ter mudado.

Isso se espalha em cascata pelo seu grafo de dependências inteiro. Mude uma função utilitária e, de repente, metade dos seus arquivos JS ganha hashes novos. No meu caso, mudar um único caractere em useBackgroundMusic.ts fazia mais de 500 arquivos serem re-hasheados.

O impacto na prática era grande. A gente empacota 8 versões dos assets de builds antigos para que usuários em versões um pouco desatualizadas do nosso client ainda consigam rodar a versão deles quando a gente faz deploy da nova versão no Cloudflare Pages. Só que o Cloudflare Pages tem um limite de 20.000 arquivos, que a gente começou a atingir por causa da nossa mudança de i18n anterior, que explodiu a quantidade de arquivos que estamos criando.

Resolver os hashes em cascata permite guardar muito mais builds antigos sem bater nesses limites, porque agora a maior parte dos arquivos não precisa mais mudar. Isso também reduz a chance de um usuário em um build antigo tomar erro, já que é bem mais provável que ele peça um arquivo que continua igual e que a gente ainda tem guardado.

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

Quando comecei a pensar em como resolver isso, considerei algumas abordagens. Nenhuma encaixou direito.

Scripts pós-build

Minha primeira ideia foi escrever um script pós-build que normalizasse todos os caminhos de import, refizesse o hash dos arquivos e atualizasse as referências. Parecia simples, era só fazer um replace com regex nos nomes com hash para nomes estáveis e depois recalcular os hashes.

Eu descartei essa abordagem por medo de "Heisenbugs" e de envenenar o cache. Mesmo guardando builds antigos no Cloudflare Pages, o risco de inconsistências de cache não valia a pena. Um script que modifica arquivos depois do build pode introduzir bugs sutis que só aparecem em produção, e depurar isso seria um pesadelo.

Vite manualChunks

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

Isso na verdade não resolve o problema na raiz, só dá uma amenizada. Você ainda acaba com hashes em cascata dentro dos chunks da sua lógica de negócio. Eu queria uma solução que atacasse a causa principal, não só deixasse o problema um pouco menos ruim.

Import Maps: A Solução Moderna

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

Era exatamente o que eu precisava. O conteúdo do Arquivo A fica idêntico (ele sempre importa "button"), então o hash dele continua igual. Só o import map e o arquivo que mudou ganham hashes novos. Fiquei meio chocado que ninguém ainda tinha feito um plugin bom para isso!

A Jornada da Implementação

Eu decidi criar um plugin de Vite que faria:

  1. Transformar todos os imports relativos para usarem identificadores de módulo estáveis
  2. Gerar um import map que mapeia esses identificadores para os nomes reais de arquivos com hash
  3. Injetar o import map no HTML

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

Abordagem Inicial

Eu comecei com um plugin de Vite usando o hook generateBundle. Minha primeira tentativa usava regex para encontrar e substituir caminhos de import. Era fácil de codar e funcionava para o nosso time pequeno na Foony, mas era frágil e com certeza não funcionaria em um plugin onde poderiam rolar falsos positivos sendo modificados.

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

Análise de AST

Eu precisava fazer o parsing do código JavaScript do jeito certo para encontrar todas as declarações de import. Minha primeira tentativa foi usar es-module-lexer, que é feito especificamente para analisar módulos ES. Infelizmente, ele causou panics nativos durante a fase de análise de módulos do Vite. Nem mesmo usando o build em asm.js resolveu os panics.

Acabei ficando com o Acorn, um parser rápido, leve e em JavaScript puro. Junto com o acorn-walk para percorrer a AST, ele me deu tudo que eu precisava sem os problemas de dependências nativas.

Principais Desafios Resolvidos

Lidando com Todos os Tipos de Import

Imports aparecem de vários jeitos e são tratados de formas diferentes 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 deixei passar no começo!)
  • Re-export de tudo: export * from "./file.js"

O caso de re-export foi particularmente chato porque eu só percebi quando vi um arquivo que não estava sendo transformado. O código tinha export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" e meu plugin simplesmente ignorava isso, porque eu só estava procurando por nós ImportDeclaration e ImportExpression.

Hoje eu trato todos eles assim:

walk(ast, {
  ImportDeclaration(node: any) {
    // Imports estáticos: import x from "spec"
    const specifier = node.source.value;
    // ... lógica de transformação
  },
  ExportNamedDeclaration(node: any) {
    // Exports nomeados com source: export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... lógica de transformação
  },
  ExportAllDeclaration(node: any) {
    // Export de tudo: export * from "spec"
    if (!node.source?.value) return;
    // ... lógica de transformação
  },
  ImportExpression(node: any) {
    // Imports dinâmicos: import("spec")
    // ... lógica de transformação
  },
});

Resolução Determinística de Conflitos

Quando vários arquivos têm o mesmo nome base (tipo vários index.tsx em diretórios diferentes), eu preciso desambiguar. Não dá para usar só "index" para todos.

Minha solução: se existe conflito, eu faço hash do caminho original do arquivo junto com o nome base. Por exemplo, src/client/games/chess/index.tsx:index vira um hash que gera index-abc123. Isso garante que o mesmo arquivo sempre receba o mesmo identificador de módulo entre builds, mesmo que outros arquivos com o mesmo nome sejam adicionados ou removidos.

Eu uso chunk.facadeModuleId (o entry point) como identificador principal, caindo para chunk.moduleIds[0] se ele não existir. Isso me dá um caminho de origem estável para fazer hashing de forma determinística.

Encadeamento de Source Maps

Quando eu transformo o código, eu quebro a cadeia de source maps. O source map que já existe faz o mapeamento desde o TypeScript original, passando por Babel e minificação, até o código atual. As minhas transformações adicionam mais uma camada, então eu preciso preservar essa cadeia.

Eu uso MagicString para rastrear minhas transformações e gerar um novo source map. Depois eu mesclo esse mapa com o que já existia, preservando os arrays originais de sources e sourcesContent. Isso mantém a cadeia 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,
});

// Mescla: usa os mapeamentos do novo mapa mas preserva as sources originais
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

Refazendo o Hash do Conteúdo Transformado

Eu preciso de conteúdo de arquivo estável. Para isso, eu transformo os imports (trocando os imports com hash do Vite pelos meus imports estáveis) e depois removo os comentários de source map da parte que entra no cálculo do hash (eles fazem referência a nomes de arquivo antigos).

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

A Implementação Final

O plugin usa uma estratégia em quatro passos:

  1. Passo de contagem: detectar colisões de nomes contando quantos arquivos compartilham cada nome base
  2. Passo de mapeamento: criar o mapeamento de chunks (nome de arquivo com hash → identificador de módulo) e o import map inicial
  3. Passo de transformação: reescrever caminhos de import no código, recalcular hashes, atualizar source maps
  4. Passo de renomeação: atualizar os nomes dos arquivos do bundle e finalizar o import map

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

import {simple as walk} from 'acorn-walk';

// Faz o parse do código para obter uma AST
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];

// Percorre a AST para encontrar todos os 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 para pular a aspa de abertura
        end: node.source.end - 1,     // -1 para pular a aspa de fechamento
        replacement: moduleSpec,
      });
    }
  },
  // ... tratar outros tipos de nó
});

// Aplica as transformações em ordem reversa para preservar as posições
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, eu uso a API de injeção de tags do Vite em vez de mexer com regex:

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

Isso é bem mais confiável do que tentar bater tags de HTML com regex.

Em Números

Para dar uma noção do que esse plugin faz:

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

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

Lições Aprendidas

Por que o parsing de AST é essencial

Usar regex em código empacotado é perigoso. Se alguma string no seu código parecer um nome de arquivo, a regex vai acabar trocando aquilo. Fazer parsing da AST garante que você só transforma declarações reais de import/export.

Por que Acorn em vez de es-module-lexer

es-module-lexer é mais rápido e mais focado nesse tipo de tarefa, mas os panics nativos tornaram ele inutilizável no contexto do meu plugin de Vite. Acorn é puro JavaScript, o que significa nenhuma dependência nativa para se preocupar. No futuro eu ainda quero olhar para o es-module-lexer como otimização de performance, mas por enquanto o Acorn funciona direitinho.

Por que Import Maps em vez das alternativas

Import Maps são um padrão da web com suporte nativo nos navegadores. É o jeito "certo" de resolver esse problema. O polyfill (es-module-shims) cuida bem dos navegadores mais antigos (por exemplo Safari < 16.4), e a solução fica limpa e fácil de manter.

Conclusão

O plugin de Import Maps impede de verdade as mudanças de hash em cascata nos meus builds de Vite. Agora os arquivos só ganham hashes novos quando o conteúdo de verdade muda, não quando as dependências mudam. Isso deixa os builds mais previsíveis, reduz invalidações de cache desnecessárias e ajuda a gente a ficar dentro dos limites de arquivos do Cloudflare Pages.

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

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

Melhorias futuras podem incluir otimizar com es-module-lexer quando os problemas de panics nativos forem resolvidos, ou adicionar suporte para cenários de import mais complexos. Mas por enquanto o plugin faz exatamente o que eu preciso que ele faça.

E vai saber, talvez um dia o Vite passe a oferecer algo assim de forma nativa.

8 Ball Pool online multiplayer billiards icon