1/7/2026
Hoe ik trapsgewijze hashwijzigingen oploste met Import Maps
Hoi! Ik liep al meer dan 5 jaar tegen dit probleem aan, maar besloot het pas nu aan te pakken omdat het een punt bereikte waarop ik het niet meer kon negeren. Wanneer ik één enkel teken in één bestand veranderde, kreeg de helft van de JavaScript-bestanden in mijn build nieuwe hash-bestandsnamen, ook al was de werkelijke inhoud niet veranderd. Dit veroorzaakte onnodige cache-invalidatie, maakte het bijna onmogelijk om bij te houden wat er werkelijk tussen builds was veranderd, en het ergste van alles: het brak mijn Cloudflare Pages-builds vanwege een bestandslimiet.
Hieronder leg ik het probleem uit, waarom bestaande oplossingen niet voor mij werkten, en hoe ik een aangepaste Vite-plugin bouwde met Import Maps om het voor eens en altijd op te lossen.
Het probleem: trapsgewijze hashwijzigingen
Vite gebruikt content-gebaseerde hashing voor productiebuilds. Wanneer je je app bouwt, krijgt elk JavaScript-bestand een hash in de bestandsnaam op basis van de inhoud. Als button.tsx compileert naar button-abc12345.js, en de inhoud verandert, wordt het button-def45678.js. Dit is geweldig voor cache busting: gebruikers krijgen het nieuwe bestand wanneer het verandert.
Het probleem ontstaat wanneer Bestand A Bestand B importeert. Stel je hebt:
// main.js
Wanneer button.tsx verandert, genereert Vite button-def45678.js. Maar nu verandert main.js ook, omdat het de string "./button-abc12345.js" bevat, die nu verkeerd is. Dus main.js krijgt ook een nieuwe hash, ook al is de werkelijke logica in main.js helemaal niet veranderd.
Dit cascadeert door je hele afhankelijkheidsgrafiek. Wijzig één hulpprogrammafunctie, en plotseling krijgen de helft van je js-bestanden nieuwe hashes. In mijn geval zorgde het wijzigen van één enkel teken in useBackgroundMusic.ts ervoor dat meer dan 500 bestanden opnieuw werden gehasht.
De impact in de praktijk was aanzienlijk. We bundelen 8 versies van de assets van onze eerdere build, zodat gebruikers met enigszins verouderde versies van onze client hun versie nog kunnen draaien wanneer we de nieuwe versie naar Cloudflare Pages deployen. Cloudflare Pages heeft echter een limiet van 20.000 bestanden, die we begonnen te raken vanwege onze i18n-wijziging eerder, waardoor het aantal bestanden dat we creëren explodeerde.
Het oplossen van trapsgewijze hashes stelt ons in staat om veel meer eerdere builds op te slaan zonder deze limieten te bereiken, omdat de meeste bestanden nu niet meer hoeven te veranderen. Dit verkleint ook de kans dat een gebruiker met een verouderde build een fout krijgt, omdat de kans veel groter is dat ze een nu-onveranderd bestand opvragen dat we toevallig hebben.
Waarom geen [alternatieve oplossingen]?
Toen ik dit voor het eerst probeerde op te lossen, overwoog ik een paar benaderingen. Geen ervan paste echt.
Post-build scripts
Mijn eerste gedachte was om een post-build script te schrijven dat alle importpaden zou normaliseren, de bestanden opnieuw zou hashen en de verwijzingen zou bijwerken. Dit leek eenvoudig: gewoon met regex de hash-bestandsnamen vervangen door stabiele namen, en vervolgens hashes opnieuw berekenen.
Ik wees deze aanpak af vanwege "Heisenbugs" en zorgen over cache-vervuiling. Hoewel we eerdere builds opslaan in Cloudflare Pages, was het risico op cache-inconsistenties het niet waard. Een script dat bestanden na de build wijzigt, kan subtiele bugs introduceren die alleen in productie verschijnen, en het debuggen daarvan zou een nachtmerrie zijn.
Vite manualChunks
Een andere optie was om Vite's manualChunks-configuratie te gebruiken om stabiele code (zoals node_modules) te scheiden van instabiele code (bedrijfslogica). Het idee was dat vendor-code minder vaak zou veranderen, dus minder bestanden zouden cascaderen.
Dit lost het basisprobleem niet op: het beperkt het slechts. Je krijgt nog steeds trapsgewijze hashes binnen je bedrijfslogica-chunks. Ik wilde een oplossing die het kernprobleem aanpakte, niet één die het slechts iets minder erg maakte.
Import Maps: de moderne oplossing
Import Maps zijn een browser-native functie (met polyfill-ondersteuning voor oudere browsers) die module-specifiers loskoppelt van bestandspaden. In plaats van dat Bestand A "./button-abc123.js" importeert, importeert het "button". De browser gebruikt de import map om "button" om te zetten naar de werkelijke hash-bestandsnaam.
Dit was precies wat ik nodig had. De inhoud van Bestand A blijft identiek (het importeert altijd "button"), dus zijn hash blijft hetzelfde. Alleen de import map en het gewijzigde bestand krijgen nieuwe hashes. Ik was nogal verbaasd dat niemand hier al een goede plugin voor had gemaakt!
De Vite-plugin bouwen
Ik besloot een Vite-plugin te bouwen die:
- Alle relatieve imports zou transformeren om stabiele module-specifiers te gebruiken
- Een import map zou genereren die deze specifiers koppelt aan de werkelijke hash-bestandsnamen
- De import map in de HTML zou injecteren
De plugin is nu beschikbaar op GitHub: @foony/vite-plugin-import-map
Initiële aanpak
Ik begon met een Vite-plugin die de generateBundle-hook gebruikte. Mijn eerste poging gebruikte regex om importpaden te zoeken en te vervangen. Dit was makkelijk te coderen en werkte voor ons kleine team Foony, maar het was kwetsbaar en zou zeker niet werken in een plugin waar mogelijk false-positives gemuteerd zouden worden.
De regex-aanpak had duidelijke problemen: wat als een string in de code toevallig op een bestandsnaam leek? Wat met dynamische imports? Wat met export-statements? Ik had een robuustere oplossing nodig als ik een plugin voor anderen wilde bouwen.
AST-parsing
Ik moest de JavaScript-code goed parsen om alle import-statements te vinden. Mijn eerste poging was es-module-lexer, dat specifiek is ontworpen voor het parsen van ES-modules. Helaas veroorzaakte het native paniek tijdens Vite's module-analysefase. Zelfs het proberen van de asm.js-build hielp niet om de paniek te stoppen.
Ik koos uiteindelijk voor Acorn, een snelle, lichtgewicht, pure JavaScript-parser. Gecombineerd met acorn-walk voor AST-traversal, gaf het me alles wat ik nodig had zonder de native afhankelijkheidsproblemen.
Belangrijkste uitdagingen opgelost
Alle importtypes afhandelen
Imports komen in vele vormen, en ze worden anders behandeld in de AST. Ik moest het volgende afhandelen:
- Statische imports:
import x from "./file.js" - Dynamische imports:
import("./file.js") - Benoemde re-exports:
export { x } from "./file.js"(deze had ik aanvankelijk gemist!) - Re-export all:
export * from "./file.js"
Het re-export geval was vooral lastig, omdat ik het miste totdat ik een bestand zag dat niet werd getransformeerd. De code had export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" en mijn plugin negeerde het volledig, omdat ik alleen zocht naar ImportDeclaration en ImportExpression nodes.
Zo handel ik ze nu allemaal af:
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
},
});
Deterministische conflictoplossing
Wanneer meerdere bestanden dezelfde basisnaam hebben (zoals meerdere index.tsx-bestanden in verschillende mappen), moet ik ze onderscheiden. Ik kan niet zomaar "index" voor allemaal gebruiken.
Mijn oplossing: als er een conflict is, hash ik het oorspronkelijke bronpad plus de basisnaam. Bijvoorbeeld, src/client/games/chess/index.tsx:index wordt gehasht om index-abc123 te creëren. Dit zorgt ervoor dat hetzelfde bestand altijd dezelfde module-specifier krijgt over builds heen, zelfs als andere bestanden met dezelfde naam worden toegevoegd of verwijderd.
Ik gebruik chunk.facadeModuleId (het entry point) als primaire identificatie, met chunk.moduleIds[0] als terugval als die niet beschikbaar is. Dit geeft me een stabiel bronpad voor deterministische hashing.
Source map chaining
Wanneer ik de code transformeer, breek ik de source map-keten. De bestaande source map mapt van de oorspronkelijke TypeScript-bron via Babel en minificatie naar de huidige code. Mijn transformaties voegen nog een laag toe, dus ik moet die keten behouden.
Ik gebruik MagicString om mijn transformaties te volgen en een nieuwe source map te genereren. Vervolgens voeg ik die samen met de bestaande map door de oorspronkelijke sources- en sourcesContent-arrays te behouden. Dit behoudt de volledige keten: Originele bron → (bestaande map) → Getransformeerde code.
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,
};
Getransformeerde inhoud opnieuw hashen
Ik heb stabiele bestandsinhoud nodig. Om dit te bereiken, transformeer ik de imports (Vite's gehashte imports vervang ik door mijn stabiele imports), en daarna verwijder ik source map-commentaren uit de hashberekening (die verwijzen naar oude bestandsnamen).
Daarna bereken ik een nieuwe hash, en werk ik zowel de bestandsnaam als de import map-entry bij.
De definitieve implementatie
De plugin gebruikt een vier-pass-strategie:
- Tel-pass: Detecteer naamconflicten door te tellen hoeveel bestanden elke basisnaam delen
- Map-pass: Maak de chunk-mapping (gehashte bestandsnaam → module-specifier) en initiële import map
- Transform-pass: Schrijf importpaden in de code opnieuw, herbereken hashes, werk source maps bij
- Hernoem-pass: Werk bundelbestandsnamen bij en finaliseer de import map
Hier is de kerntransformatielogica:
// 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);
}
Voor het injecteren van de import map in HTML gebruik ik Vite's tag-injection API in plaats van regex-manipulatie:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Dit is veel betrouwbaarder dan proberen HTML-tags met regex te matchen.
In cijfers
Om je een idee te geven van wat deze plugin doet:
- ~1.000+ JavaScript-bestanden verwerkt per build
- ~2-3 seconden toegevoegd aan de buildtijd (acceptabele afweging)
- ~99% reductie in onnodige hashwijzigingen (de meeste bestanden veranderen nu alleen wanneer hun werkelijke inhoud verandert)
- ~340 regels plugincode (inclusief commentaar en foutafhandeling)
De plugin handelt alle randgevallen af die ik tot nu toe ben tegengekomen, en het buildproces is nu veel voorspelbaarder.
Geleerde lessen
Waarom AST-parsing essentieel is
Regex op gebundelde code is gevaarlijk. Als een string in je code toevallig op een bestandsnaam lijkt, zal regex die herschrijven. AST-parsing zorgt ervoor dat je alleen daadwerkelijke import/export-statements transformeert.
Waarom Acorn boven es-module-lexer
es-module-lexer is sneller en meer doelgericht, maar de native paniekproblemen maakten het onbruikbaar in de context van mijn Vite-plugin. Acorn is pure JavaScript, wat betekent dat er geen native afhankelijkheden zijn om je zorgen over te maken. Ik wil in de toekomst nog naar es-module-lexer kijken als snelheidsoptimalisatie, maar voor nu werkt Acorn perfect.
Waarom Import Maps boven alternatieven
Import Maps zijn een webstandaard met native browser-ondersteuning. Het is de "juiste" manier om dit probleem op te lossen. De polyfill (es-module-shims) handelt oudere browsers (bijv. Safari < 16.4) netjes af, en de oplossing is schoon en onderhoudbaar.
Conclusie
De Import Maps-plugin voorkomt met succes trapsgewijze hashwijzigingen in mijn Vite-builds. Bestanden krijgen nu alleen nieuwe hashes wanneer hun werkelijke inhoud verandert, niet wanneer hun afhankelijkheden veranderen. Dit maakt builds voorspelbaarder, vermindert onnodige cache-invalidatie, en helpt ons onder Cloudflare Pages' bestandslimieten te blijven.
De oplossing is eenvoudig, onderhoudbaar, en gebruikt moderne webstandaarden. Het is een mooi voorbeeld van hoe de "juiste" oplossing soms ook de eenvoudigste is, zodra je het probleem diep genoeg begrijpt om het te zien.
De plugin is open source en beschikbaar op GitHub: @foony/vite-plugin-import-map. Je kunt het installeren met npm install @foony/vite-plugin-import-map en het beginnen te gebruiken in je eigen Vite-projecten.
Toekomstige verbeteringen zouden kunnen zijn: optimaliseren met es-module-lexer zodra de native paniekproblemen zijn opgelost, of ondersteuning toevoegen voor complexere import-scenario's. Maar voor nu doet de plugin precies wat ik nodig heb.
En wie weet? Misschien ondersteunt Vite ooit zoiets natively.
(Update: Na het uitproberen van de plugin op de build van Foony hadden sommige gebruikers onverwachte problemen, dus heb ik hem voorlopig uitgeschakeld. Ik kom er later misschien op terug. Misschien. Ik vind het nog steeds een mooie oplossing.)