

1/1/1970
Hoe ik cascaderende hash-wijzigingen heb opgelost met Import Maps
Hoi! Dit probleem speelt al meer dan 5 jaar bij mij, maar pas nu besloot ik het aan te pakken omdat het op een punt kwam waarop ik het niet langer kon negeren. Wanneer ik één enkel teken in een bestand veranderde, kreeg de helft van de JavaScript-bestanden in mijn build nieuwe gehashte bestandsnamen, ook al was de daadwerkelijke inhoud niet veranderd. Dit zorgde voor onnodige cache-invalidatie, maakte het bijna onmogelijk om te volgen wat er werkelijk veranderde tussen builds, en het ergste van alles: mijn Cloudflare Pages-builds liepen vast door een bestandslimiet.
Hieronder leg ik het probleem uit, waarom bestaande oplossingen voor mij niet werkten, en hoe ik een aangepaste Vite-plugin heb gebouwd met Import Maps om het voor eens en altijd op te lossen.
Het probleem: cascaderende hash-wijzigingen
Vite gebruikt content-based hashing voor productie-builds. Wanneer je je app bouwt, krijgt elk JavaScript-bestand een hash in zijn 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 zodra het verandert.
Het probleem ontstaat wanneer Bestand A Bestand B importeert. Stel je hebt:
// main.js
import { Button } from "./button-abc12345.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 fout is. Dus krijgt main.js ook een nieuwe hash, ook al is de werkelijke logica in main.js helemaal niet veranderd.
Dit cascadeert door je hele dependency-graaf. Verander één utility-functie, en plotseling krijgt de helft van je js-bestanden nieuwe hashes. In mijn geval zorgde één enkel teken aanpassen in useBackgroundMusic.ts ervoor dat meer dan 500 bestanden opnieuw werden gehasht.
De impact in de praktijk was significant. We bundelen 8 versies van de assets van eerdere builds zodat gebruikers met een ietwat verouderde versie 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 door onze i18n-wijziging eerder, die het aantal bestanden dat we creëren deed exploderen.
Door cascaderende hashes op te lossen kunnen we veel meer eerdere builds opslaan zonder deze limieten te raken, 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 niet [alternatieve oplossingen]?
Toen ik dit aanvankelijk probeerde op te lossen, overwoog ik een paar benaderingen. Geen daarvan paste echt.
Post-build scripts
Mijn eerste gedachte was om een post-build script te schrijven dat alle import-paden zou normaliseren, de bestanden opnieuw zou hashen, en de verwijzingen zou bijwerken. Dat leek eenvoudig: gewoon met regex de gehashte bestandsnamen vervangen door stabiele namen, en daarna de hashes opnieuw berekenen.
Ik verwierp deze aanpak vanwege "Heisenbugs" en zorgen over cache poisoning. Ook al slaan we eerdere builds op in Cloudflare Pages, het risico op cache-inconsistenties was het niet waard. Een script dat bestanden na de build aanpast kan subtiele bugs introduceren die alleen in productie verschijnen, en die debuggen zou een nachtmerrie zijn.
Vite manualChunks
Een andere optie was Vite's manualChunks-configuratie gebruiken om stabiele code (zoals node_modules) te scheiden van onstabiele code (business logic). Het idee was dat vendor-code minder vaak verandert, dus zouden er minder bestanden cascaderen.
Dit lost het kernprobleem niet echt op, het verzacht het alleen. Je krijgt nog steeds cascaderende hashes binnen je business logic chunks. Ik wilde een oplossing die het kernprobleem aanpakte, niet eentje die het alleen iets minder erg maakte.
Import Maps: de moderne oplossing
Import Maps zijn een browser-native feature (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" op te lossen naar de daadwerkelijke gehashte bestandsnaam.
Dit was precies wat ik nodig had. De inhoud van Bestand A blijft identiek (het importeert altijd "button"), dus de 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 omzet naar stabiele module-specifiers
- Een import map genereert die deze specifiers koppelt aan de daadwerkelijke gehashte bestandsnamen
- De import map in de HTML injecteert
De plugin is nu beschikbaar op GitHub: @foony/vite-plugin-import-map
Eerste aanpak
Ik begon met een Vite-plugin die de generateBundle-hook gebruikte. Mijn eerste poging gebruikte regex om import-paden te vinden en te vervangen. Dit was eenvoudig te coderen en werkte voor ons kleine team Foony, maar was broos en zou zeker niet werken in een plugin waar mogelijk false-positives gemuteerd worden.
De regex-aanpak had duidelijke problemen: wat als een string in de code er toevallig uitzag als een bestandsnaam? En dynamische imports? En 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 ontworpen is voor het parsen van ES-modules. Helaas veroorzaakte het native panics tijdens Vite's module-analyse-fase. Zelfs de asm.js-build proberen hielp de panics niet stoppen.
Ik koos 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 problemen met native dependencies.
Belangrijkste uitdagingen opgelost
Alle import-types 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 bijzonder 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 op zoek was 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 conflict-oplossing
Wanneer meerdere bestanden dezelfde basisnaam hebben (zoals meerdere index.tsx-bestanden in verschillende mappen), moet ik ze van elkaar 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 tot index-abc123. 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 de primaire identifier, met chunk.moduleIds[0] als fallback 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 vanaf de oorspronkelijke TypeScript-bron via Babel en minificatie naar de huidige code. Mijn transformaties voegen een laag toe, dus moet ik die keten behouden.
Ik gebruik MagicString om mijn transformaties bij te houden en een nieuwe source map te genereren. Daarna voeg ik die samen met de bestaande map door de oorspronkelijke sources- en sourcesContent-arrays te bewaren. 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. Daarvoor transformeer ik de imports (Vite's gehashte imports vervang ik door mijn stabiele imports), en daarna verwijder ik source map-commentaar uit de hash-berekening (die verwijzen naar oude bestandsnamen).
Daarna bereken ik een nieuwe hash, en werk ik zowel de bestandsnaam als het import map-item bij.
De uiteindelijke 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 de initiële import map
- Transform-pass: Herschrijf import-paden in de code, herbereken hashes, werk source maps bij
- Rename-pass: Werk bundle-bestandsnamen bij en finaliseer de import map
Hier is de kern van de transformatie-logica:
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);
}
Voor het injecteren van de import map in HTML gebruik ik Vite's tag-injectie-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 te matchen met regex.
In cijfers
Om je een idee te geven van wat deze plugin doet:
- ~1.000+ JavaScript-bestanden verwerkt per build
- ~2-3 seconden extra build-tijd (acceptabele trade-off)
- ~99% reductie in onnodige hash-wijzigingen (de meeste bestanden veranderen nu alleen wanneer hun werkelijke inhoud verandert)
- ~340 regels plugin-code (inclusief commentaar en error handling)
De plugin behandelt alle edge cases die ik tot nu toe ben tegengekomen, en het build-proces is nu veel voorspelbaarder.
Geleerde lessen
Waarom AST-parsing essentieel is
Regex op gebundelde code is gevaarlijk. Als een string in je code er toevallig uitziet als een bestandsnaam, herschrijft regex het. 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 panic-problemen maakten het onbruikbaar in mijn Vite-plugin-context. Acorn is pure JavaScript, wat betekent dat er geen native dependencies 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) elegant af, en de oplossing is schoon en onderhoudbaar.
Conclusie
De Import Maps-plugin voorkomt met succes cascaderende hash-wijzigingen in mijn Vite-builds. Bestanden krijgen nu alleen nieuwe hashes wanneer hun werkelijke inhoud verandert, niet wanneer hun dependencies 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 goed voorbeeld van hoe soms de "juiste" oplossing 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 hem installeren met npm install @foony/vite-plugin-import-map en gebruiken in je eigen Vite-projecten.
Toekomstige verbeteringen zouden optimalisatie met es-module-lexer kunnen omvatten zodra de native panic-problemen zijn opgelost, of ondersteuning 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: Nadat ik de plugin op Foony's build had geprobeerd, kregen sommige gebruikers onverwachte problemen, dus heb ik hem voorlopig uitgeschakeld. Ik kom er later op terug. Waarschijnlijk. Ik vind dit nog steeds een nette oplossing.)