

1/1/1970
Come ho risolto le modifiche a cascata degli hash con le Import Map
Salve a tutti! Ho avuto questo problema per più di 5 anni, ma solo ora ho deciso di affrontarlo perché è arrivato a un punto in cui non potevo più ignorarlo. Quando cambiavo un singolo carattere in un file, metà dei file JavaScript della mia build ottenevano nuovi nomi con hash, anche se il loro contenuto reale non era cambiato. Questo causava un'invalidazione della cache non necessaria, rendeva quasi impossibile tracciare cosa fosse effettivamente cambiato tra le build e, peggio di tutto, rompeva le mie build su Cloudflare Pages a causa di un limite sul numero di file.
Qui sotto analizzerò il problema, perché le soluzioni esistenti non funzionavano per me e come ho costruito un plugin Vite personalizzato usando le Import Map per risolverlo una volta per tutte.
Il problema: modifiche a cascata degli hash
Vite usa l'hashing basato sul contenuto per le build di produzione. Quando compili la tua app, ogni file JavaScript riceve un hash nel nome basato sul suo contenuto. Se button.tsx viene compilato in button-abc12345.js e il contenuto cambia, diventa button-def45678.js. Questo è ottimo per il cache busting: gli utenti ricevono il nuovo file quando cambia.
Il problema sorge quando il File A importa il File B. Diciamo che hai:
// main.js
import { Button } from "./button-abc12345.js";
Quando button.tsx cambia, Vite genera button-def45678.js. Ma ora anche main.js cambia perché contiene la stringa "./button-abc12345.js", che ora è sbagliata. Quindi anche main.js riceve un nuovo hash, anche se la logica reale in main.js non è cambiata affatto.
Questo si propaga a cascata attraverso l'intero grafo delle dipendenze. Cambia una funzione di utilità e improvvisamente metà dei tuoi file js riceve nuovi hash. Nel mio caso, cambiare un singolo carattere in useBackgroundMusic.ts ha causato il rehash di oltre 500 file.
L'impatto pratico era significativo. Raggruppiamo 8 versioni delle risorse delle build precedenti in modo che gli utenti su versioni leggermente obsolete del nostro client possano comunque eseguire la loro versione quando rilasciamo la nuova versione su Cloudflare Pages. Tuttavia, Cloudflare Pages ha un limite di 20.000 file che abbiamo iniziato a raggiungere a causa della nostra modifica i18n di qualche tempo fa, che ha fatto esplodere il numero di file che creiamo.
Risolvere gli hash a cascata ci permette di memorizzare molte più build precedenti senza raggiungere questi limiti, perché ora la maggior parte dei file non ha più bisogno di cambiare. Questo riduce anche la probabilità che un utente su una build obsoleta riscontri un errore, poiché è molto più probabile che richieda un file ora invariato che abbiamo per caso.
Perché non [soluzioni alternative]?
Quando ho iniziato a pensare a una soluzione, ho considerato alcuni approcci. Nessuno andava bene del tutto.
Script post-build
Il mio primo pensiero è stato di scrivere uno script post-build che normalizzasse tutti i percorsi di import, riapplicasse l'hash ai file e aggiornasse i riferimenti. Sembrava semplice: bastava una sostituzione regex dei nomi di file con hash con nomi stabili, per poi ricalcolare gli hash.
Ho scartato questo approccio per problemi di "Heisenbug" e di cache poisoning. Anche se memorizziamo le build precedenti su Cloudflare Pages, il rischio di incoerenze nella cache non valeva la pena. Uno script che modifica i file dopo la build potrebbe introdurre bug sottili che appaiono solo in produzione, e fare il debug sarebbe un incubo.
manualChunks di Vite
Un'altra opzione era usare la configurazione manualChunks di Vite per separare il codice stabile (come node_modules) dal codice instabile (logica di business). L'idea era che il codice vendor cambiasse meno frequentemente, quindi meno file si sarebbero propagati a cascata.
Questo non risolve davvero il problema alla radice: lo mitiga soltanto. Hai comunque modifiche a cascata degli hash all'interno dei chunk di logica di business. Volevo una soluzione che affrontasse il problema centrale, non che lo rendesse solo leggermente meno grave.
Import Map: la soluzione moderna
Le Import Map sono una funzionalità nativa del browser (con supporto polyfill per i browser più vecchi) che disaccoppia gli specificatori dei moduli dai percorsi dei file. Invece che il File A importi "./button-abc123.js", importa "button". Il browser usa l'import map per risolvere "button" nel nome di file con hash effettivo.
Era esattamente quello che mi serviva. Il contenuto del File A rimane identico (importa sempre "button"), quindi il suo hash rimane lo stesso. Solo l'import map e il file modificato ricevono nuovi hash. Sono rimasto un po' scioccato dal fatto che nessuno avesse già creato un buon plugin per questo!
Costruire il plugin Vite
Ho deciso di costruire un plugin Vite che facesse:
- Trasformare tutti gli import relativi in modo da usare specificatori di modulo stabili
- Generare un'import map che mappi quegli specificatori ai nomi di file con hash effettivi
- Iniettare l'import map nell'HTML
Il plugin è ora disponibile su GitHub: @foony/vite-plugin-import-map
Approccio iniziale
Sono partito con un plugin Vite che usava l'hook generateBundle. Il mio primo tentativo usava regex per trovare e sostituire i percorsi di import. Era facile da scrivere e funzionava per il nostro piccolo team Foony, ma era fragile e sicuramente non avrebbe funzionato in un plugin dove potrebbero esserci falsi positivi che vengono mutati.
L'approccio regex aveva problemi evidenti: cosa succede se una stringa nel codice sembra per caso un nome di file? E gli import dinamici? E le istruzioni export? Avevo bisogno di una soluzione più robusta se volevo costruire un plugin per altri.
Parsing dell'AST
Avevo bisogno di analizzare correttamente il codice JavaScript per trovare tutte le istruzioni di import. Il mio primo tentativo è stato es-module-lexer, progettato specificamente per il parsing dei moduli ES. Sfortunatamente, causava panic nativi durante la fase di analisi dei moduli di Vite. Anche provare la build asm.js non aiutava a fermare i panic.
Mi sono accontentato di Acorn, un parser JavaScript veloce, leggero e puramente in JavaScript. Combinato con acorn-walk per la traversata dell'AST, mi ha dato tutto ciò di cui avevo bisogno senza i problemi delle dipendenze native.
Sfide chiave risolte
Gestire tutti i tipi di import
Gli import si presentano in molte forme e vengono trattati diversamente nell'AST. Dovevo gestire:
- Import statici:
import x from "./file.js" - Import dinamici:
import("./file.js") - Re-export con nome:
export { x } from "./file.js"(questo me lo sono dimenticato all'inizio!) - Re-export totale:
export * from "./file.js"
Il caso del re-export era particolarmente complicato perché me ne sono accorto solo quando ho visto un file che non veniva trasformato. Il codice aveva export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" e il mio plugin lo ignorava completamente perché stavo cercando 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 (come più file index.tsx in directory diverse), devo distinguerli. Non posso semplicemente usare "index" per tutti.
La mia soluzione: se c'è un conflitto, calcolo l'hash del percorso sorgente originale più il nome base. Per esempio, src/client/games/chess/index.tsx:index viene trasformato in hash per creare index-abc123. Questo garantisce che lo stesso file riceva sempre lo stesso specificatore di modulo tra le build, anche se altri file con lo stesso nome vengono aggiunti o rimossi.
Uso chunk.facadeModuleId (il punto di ingresso) come identificatore primario, ricadendo su chunk.moduleIds[0] se non è disponibile. Questo mi dà un percorso sorgente stabile per l'hashing deterministico.
Concatenazione delle source map
Quando trasformo il codice, sto interrompendo la catena delle source map. La source map esistente mappa dal sorgente TypeScript originale, attraverso Babel e la minificazione, fino al codice attuale. Le mie trasformazioni aggiungono un altro strato, quindi devo preservare quella catena.
Uso MagicString per tracciare le mie trasformazioni e generare una nuova source map. Poi la unisco con quella esistente preservando gli array originali sources e sourcesContent. Questo mantiene la catena completa: 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,
};
Ricalcolo dell'hash del contenuto trasformato
Ho bisogno di un contenuto file stabile. Per farlo, trasformo gli import (sostituendo gli import con hash di Vite con i miei import stabili), e poi rimuovo i commenti delle source map dal calcolo dell'hash (fanno riferimento ai vecchi nomi di file).
Dopo di che, calcolo un nuovo hash e aggiorno sia il nome del file sia la voce nell'import map.
L'implementazione finale
Il plugin usa una strategia in quattro passaggi:
- Passaggio di conteggio: rileva le collisioni di nomi contando quanti file condividono ciascun nome base
- Passaggio di mappatura: crea la mappatura dei chunk (nome file con hash → specificatore di modulo) e l'import map iniziale
- Passaggio di trasformazione: riscrive i percorsi di import nel codice, ricalcola gli hash, aggiorna le source map
- Passaggio di rinomina: aggiorna i nomi dei file del bundle e finalizza l'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 l'import map nell'HTML, uso l'API di iniezione tag di Vite invece della manipolazione tramite regex:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Questo è molto più affidabile che cercare di fare match con regex sui tag HTML.
I numeri
Per darti un'idea di cosa fa questo plugin:
- ~1.000+ file JavaScript elaborati per build
- ~2-3 secondi aggiunti al tempo di build (compromesso accettabile)
- ~99% di riduzione delle modifiche di hash non necessarie (la maggior parte dei file ora cambia solo quando il loro contenuto reale cambia)
- ~340 righe di codice del plugin (inclusi commenti e gestione degli errori)
Il plugin gestisce tutti i casi limite che ho incontrato finora, e il processo di build è ora molto più prevedibile.
Lezioni imparate
Perché il parsing dell'AST è essenziale
Le regex sul codice raggruppato sono pericolose. Se una stringa nel tuo codice sembra per caso un nome di file, la regex la riscriverà. Il parsing dell'AST garantisce che tu trasformi solo le effettive istruzioni di import/export.
Perché Acorn invece di es-module-lexer
es-module-lexer è più veloce e più mirato allo scopo, ma i problemi di panic nativi lo rendevano inutilizzabile nel mio contesto di plugin Vite. Acorn è puro JavaScript, il che significa nessuna dipendenza nativa di cui preoccuparsi. In futuro vorrò guardare a es-module-lexer come ottimizzazione di velocità, ma per ora Acorn funziona perfettamente.
Perché le Import Map invece delle alternative
Le Import Map sono uno standard web con supporto nativo del browser. Sono il modo "giusto" per risolvere questo problema. Il polyfill (es-module-shims) gestisce con eleganza i browser più vecchi (ad esempio Safari < 16.4) e la soluzione è pulita e manutenibile.
Conclusione
Il plugin Import Map previene con successo le modifiche a cascata degli hash nelle mie build Vite. I file ora ricevono nuovi hash solo quando il loro contenuto reale cambia, non quando cambiano le loro dipendenze. Questo rende le build più prevedibili, riduce l'invalidazione della cache non necessaria e ci aiuta a rimanere sotto i limiti di file di Cloudflare Pages.
La soluzione è semplice, manutenibile e usa standard web moderni. È un buon esempio di come a volte la soluzione "giusta" sia anche la più semplice, una volta che capisci il problema abbastanza a fondo da vederla.
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.
Miglioramenti futuri potrebbero includere l'ottimizzazione con es-module-lexer una volta risolti i problemi di panic nativi, o l'aggiunta di supporto per scenari di import più complessi. Ma per ora, il plugin fa esattamente ciò di cui ho bisogno.
E chi lo sa? Forse un giorno Vite supporterà qualcosa del genere nativamente.
(Aggiornamento: dopo aver provato il plugin sulla build di Foony, alcuni utenti hanno riscontrato problemi inaspettati, quindi per ora l'ho disabilitato. Lo riprenderò più tardi. Forse. Continuo a pensare che sia una soluzione interessante.)