background blurbackground mobile blur

1/1/1970

Come ho risolto i cambi di hash a cascata con le Import Maps

Ehilà! Ho questo problema da più di 5 anni, ma solo ora ho deciso di affrontarlo perché è arrivato a un punto in cui non potevo più far finta di niente. Quando cambiavo un singolo carattere in un file, metà dei file JavaScript del mio build si ritrovavano con nuovi nomi file hashati, anche se il loro contenuto reale non era cambiato. Questo causava invalidazioni della cache inutili, rendeva quasi impossibile capire cosa fosse davvero cambiato tra un build e l'altro e, cosa peggiore, mandava in errore i miei build su Cloudflare Pages a causa di un limite sul numero di file.

Qui sotto racconto il problema, perché le soluzioni esistenti non facevano al caso mio e come ho costruito un plugin Vite personalizzato che usa le Import Maps per risolverlo una volta per tutte.

Il problema: cambi di hash a cascata

Vite usa hash basati sul contenuto per i build di produzione. Quando fai il build della tua app, ogni file JavaScript ottiene un hash nel nome file calcolato sul suo contenuto. Se button.tsx viene compilato in button-abc12345.js e il contenuto cambia, diventa button-def45678.js. È ottimo per il cache busting, così gli utenti scaricano il nuovo file quando cambia.

Il problema nasce quando il File A importa il File B. Mettiamo di avere:

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

Quando button.tsx cambia, Vite genera button-def45678.js. Ma a questo punto cambia anche main.js, perché contiene la stringa "./button-abc12345.js", che ora è sbagliata. Quindi anche main.js riceve un nuovo hash, anche se la logica in main.js non è cambiata per niente.

Questo effetto si propaga a cascata in tutto il grafo delle dipendenze. Modifichi una sola funzione di utility e all'improvviso metà dei tuoi file js ottengono nuovi hash. Nel mio caso, cambiare un singolo carattere in useBackgroundMusic.ts ha fatto ri-hashare più di 500 file.

L'impatto concreto era notevole. Impacchettiamo 8 versioni degli asset dei build precedenti, così gli utenti che usano una versione leggermente vecchia del nostro client possono ancora eseguire la loro versione quando distribuiamo quella nuova su Cloudflare Pages. Però Cloudflare Pages ha un limite di 20.000 file e abbiamo iniziato a sbatterci contro questo limite a causa della nostra modifica per l'i18n di qualche tempo fa, che ha fatto esplodere il numero di file generati.

Risolvere il problema degli hash a cascata ci permette di conservare molte più versioni passate senza superare questi limiti, perché ora la maggior parte dei file non deve più cambiare. Questo riduce anche la probabilità che un utente con un build vecchio finisca in errore, visto che è molto più probabile che richieda un file rimasto invariato che abbiamo ancora a disposizione.

Perché non usare [soluzioni alternative]?

Quando ho iniziato a pensare a come risolvere la cosa, ho valutato un po' di approcci diversi. Nessuno però mi convinceva davvero.

Script post-build

La mia prima idea era scrivere uno script post-build che normalizzasse tutti i percorsi degli import, ri-hashasse i file e aggiornasse i riferimenti. Sembrava abbastanza semplice: fare un replace con regex dei nomi file hashati con nomi stabili e poi ricalcolare gli hash.

Ho scartato questo approccio per paura di Heisenbug e problemi di cache poisoning. Anche se conserviamo i build passati su Cloudflare Pages, il rischio di inconsistenze nella cache non valeva la pena. Uno script che modifica i file dopo il build potrebbe introdurre bug sottili che compaiono solo in produzione, e fare debug di cose del genere sarebbe un incubo.

Vite manualChunks

Un'altra opzione era usare la configurazione manualChunks di Vite per separare il codice stabile (tipo node_modules) da quello instabile (la business logic). L'idea era che il codice dei vendor cambiasse meno spesso e quindi meno file finissero nel vortice degli hash a cascata.

Questo però non risolve il problema alla radice, lo attenua soltanto. Gli hash a cascata continuano a verificarsi all'interno dei chunk della business logic. Volevo una soluzione che affrontasse il problema di base, non qualcosa che lo rendesse solo un po' meno fastidioso.

Import Maps: la soluzione moderna

Le Import Maps sono una funzionalità nativa del browser (con polyfill per i browser più vecchi) che scollega gli identificatori dei moduli dai percorsi dei file. Invece di far importare al File A "./button-abc123.js", gli fai importare "button". Il browser usa la import map per risolvere "button" nel vero nome file hashato.

Esattamente quello che mi serviva. Il contenuto del File A rimane identico (importa sempre "button"), quindi il suo hash resta lo stesso. Solo la import map e il file modificato ricevono nuovi hash. Sono rimasto un po' sorpreso che nessuno avesse già fatto un plugin decente per questa cosa!

Il viaggio dell'implementazione

Ho deciso di costruire un plugin Vite che:

  1. Trasforma tutti gli import relativi per usare identificatori di modulo stabili
  2. Genera una import map che associa quegli identificatori ai veri nomi file hashati
  3. Inietta la import map nell'HTML

Il plugin è ora disponibile su GitHub: @foony/vite-plugin-import-map

Primo approccio

Sono partito da un plugin Vite usando l'hook generateBundle. Il mio primo tentativo usava regex per trovare e sostituire i percorsi degli import. Era facile da scrivere e funzionava per il nostro piccolo team di Foony, ma era fragile e sicuramente non avrebbe retto in un plugin generico, dove potrebbero esserci falsi positivi che verrebbero modificati a sproposito.

L'approccio con le regex aveva problemi evidenti: e se una stringa nel codice assomigliasse per caso a un nome file? E i dynamic import? E le istruzioni di export? Mi serviva una soluzione molto più robusta se volevo creare un plugin che potessero usare anche altre persone.

Parsing dell'AST

Avevo bisogno di fare il parsing del codice JavaScript come si deve per trovare tutte le istruzioni di import. Il mio primo tentativo è stato usare es-module-lexer, che è pensato apposta per fare il parsing dei moduli ES. Purtroppo però causava panic nativi durante la fase di analisi dei moduli di Vite. Anche provare la build asm.js non è servito a fermare questi panic.

Alla fine mi sono fermato su Acorn, un parser veloce, leggero e scritto interamente in JavaScript. Insieme ad acorn-walk per attraversare l'AST mi dava tutto quello che mi serviva, senza problemi di dipendenze native.

Le sfide principali risolte

Gestire tutti i tipi di import

Gli import possono presentarsi in un sacco di forme diverse e nell'AST vengono trattati in modi diversi. Dovevo gestire:

  • Import statici: import x from "./file.js"
  • Import dinamici: import("./file.js")
  • Named re-exports: export { x } from "./file.js" (questo all'inizio mi era completamente sfuggito)
  • Re-export di tutto: export * from "./file.js"

Il caso dei re-export era particolarmente insidioso, perché me ne sono accorto solo quando ho visto un file che non veniva trasformato. Il codice conteneva export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" e il mio plugin lo ignorava del tutto, perché stavo guardando solo i nodi ImportDeclaration e ImportExpression.

Ecco come li gestisco tutti adesso:

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

Risoluzione deterministica dei conflitti

Quando più file hanno lo stesso nome base (per esempio diversi index.tsx in cartelle diverse), devo disambiguarli. Non posso usare semplicemente "index" per tutti.

La mia soluzione: se c'è un conflitto, faccio l'hash del percorso sorgente originale più il nome base. Per esempio, src/client/games/chess/index.tsx:index viene hashato per creare index-abc123. Questo garantisce che lo stesso file riceva sempre lo stesso identificatore di modulo tra un build e l'altro, anche se vengono aggiunti o rimossi altri file con lo stesso nome.

Uso chunk.facadeModuleId (il punto di ingresso) come identificatore principale, e se non è disponibile ripiego su chunk.moduleIds[0]. In questo modo ottengo un percorso sorgente stabile per calcolare l'hash in modo deterministico.

Concatenazione delle source map

Quando trasformo il codice interrompo la catena delle source map. La source map esistente collega il sorgente TypeScript originale passando da Babel e dalla minificazione fino al codice attuale. Le mie trasformazioni aggiungono un altro livello, quindi devo preservare quella catena.

Uso MagicString per tracciare le trasformazioni e generare una nuova source map. Poi la unisco a quella esistente preservando gli array sources e sourcesContent originali. Così mantengo l'intera catena: sorgente originale → (mappa esistente) → codice trasformato.

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

Ri-hashare il contenuto trasformato

Ho bisogno di contenuti dei file stabili. Per ottenerli trasformo gli import (sostituisco gli import hashati di Vite con i miei import stabili) e poi escludo i commenti delle source map dal calcolo dell'hash, perché fanno riferimento ai vecchi nomi file.

Dopo di che calcolo un nuovo hash e aggiorno sia il nome del file sia la voce corrispondente nella import map.

L'implementazione finale

Il plugin usa una strategia in quattro passaggi:

  1. Count pass: rileva le collisioni di nomi contando quanti file condividono lo stesso nome base
  2. Map pass: crea la mappa dei chunk (nome file hashato → identificatore di modulo) e la import map iniziale
  3. Transform pass: riscrive i percorsi degli import nel codice, ricalcola gli hash, aggiorna le source map
  4. Rename pass: aggiorna i nomi dei file nel bundle e finalizza la import map

Ecco la logica di trasformazione principale:

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

Per iniettare la import map nell'HTML uso la tag injection API di Vite invece di manipolare l'HTML con le regex:

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

È molto più affidabile che provare a fare match con regex sui tag HTML.

Qualche numero

Per dare un'idea di cosa fa questo plugin:

  • ~1.000+ file JavaScript processati per ogni build
  • ~2-3 secondi in più sul tempo di build (un compromesso più che accettabile)
  • ~99% di riduzione nei cambi di hash inutili (ora la maggior parte dei file cambia solo quando cambia davvero il contenuto)
  • ~340 righe di codice del plugin (commenti ed error handling inclusi)

Il plugin gestisce tutti gli edge case che ho incontrato finora e il processo di build è diventato molto più prevedibile.

Cosa mi porto a casa

Perché il parsing dell'AST è essenziale

Usare le regex sul codice bundle è pericoloso. Se una stringa nel tuo codice assomiglia per caso a un nome file, la regex la riscriverà. Il parsing dell'AST ti assicura di trasformare solo le vere istruzioni di import/export.

Perché Acorn e non es-module-lexer

es-module-lexer è più veloce e più specializzato, ma i problemi di panic nativi lo rendevano inutilizzabile nel contesto del mio plugin Vite. Acorn è puro JavaScript, quindi niente dipendenze native di cui preoccuparsi. Più avanti mi piacerebbe tornare a guardare es-module-lexer come ottimizzazione di performance, ma per ora Acorn fa il suo lavoro alla grande.

Perché le Import Maps e non le alternative

Le Import Maps sono uno standard del web con supporto nativo nei browser. Sono il modo giusto per risolvere questo problema. Il polyfill (es-module-shims) gestisce senza drammi i browser più vecchi (per esempio Safari < 16.4) e la soluzione resta pulita e facile da mantenere.

Conclusione

Il plugin basato sulle Import Maps blocca con successo i cambi di hash a cascata nei miei build Vite. I file ora ricevono un nuovo hash solo quando cambia davvero il loro contenuto, non quando cambiano le dipendenze. Questo rende i build più prevedibili, riduce le invalidazioni di cache inutili e ci aiuta a restare sotto i limiti di file di Cloudflare Pages.

La soluzione è semplice, facile da mantenere e sfrutta gli standard moderni del web. È un buon esempio di come a volte la soluzione giusta sia anche la più semplice, una volta che hai capito a fondo il problema.

Il plugin è open source e disponibile su GitHub: @foony/vite-plugin-import-map. Puoi installarlo con npm install @foony/vite-plugin-import-map e iniziare a usarlo nei tuoi progetti Vite.

I miglioramenti futuri potrebbero includere un'ottimizzazione con es-module-lexer una volta risolti i problemi di panic nativi, oppure il supporto per scenari di import ancora più complessi. Per ora però il plugin fa esattamente quello che mi serve.

E chissà, magari un giorno Vite supporterà qualcosa di simile in modo nativo.

8 Ball Pool online multiplayer billiards icon