

1/1/1970
Så löste jag kaskaderande hashändringar med Import Maps
Hallå där! Jag har haft det här problemet i över 5 år, men först nu bestämde jag mig för att ta tag i det, för det hade blivit så stort att jag inte kunde ignorera det längre. När jag ändrade ett enda tecken i en fil fick hälften av JavaScript-filerna i min build nya hashade filnamn, trots att deras faktiska innehåll inte hade ändrats. Det här orsakade onödig cache-invalidering, gjorde det nästan omöjligt att följa vad som faktiskt ändrats mellan buildar och, värst av allt, fick mina Cloudflare Pages-buildar att gå sönder på grund av en filbegränsning.
Här nedanför går jag igenom problemet, varför befintliga lösningar inte funkade för mig och hur jag byggde ett eget Vite-plugin med Import Maps för att lösa det en gång för alla.
Problemet: Kaskaderande hashändringar
Vite använder innehållsbaserad hashing för produktionsbuildar. När du bygger din app får varje JavaScript-fil en hash i filnamnet baserat på sitt innehåll. Om button.tsx kompileras till button-abc12345.js och innehållet ändras blir det button-def45678.js. Det här är grymt för cache busting, användarna får den nya filen när den ändras.
Problemet dyker upp när fil A importerar fil B. Säg att du har:
// main.js
import { Button } from "./button-abc12345.js";
När button.tsx ändras genererar Vite button-def45678.js. Men nu ändras också main.js, eftersom den innehåller strängen "./button-abc12345.js" som nu är fel. Så main.js får också en ny hash, trots att logiken i main.js inte ändrats alls.
Det här kaskaderar genom hela din beroendegraf. Ändra en enda hjälpfunktion och plötsligt får halva dina js-filer nya hashar. I mitt fall räckte det att ändra ett enda tecken i useBackgroundMusic.ts för att över 500 filer skulle få ny hash.
Konsekvensen i verkligheten var rätt stor. Vi buntar ihop 8 versioner av våra tidigare buildars assets så att användare som sitter på en lite föråldrad version av klienten ändå kan köra sin variant när vi rullar ut en ny version till Cloudflare Pages. Men Cloudflare Pages har en gräns på 20 000 filer som vi började slå i på grund av vår i18n‑ändring tidigare som fick antalet filer vi skapar att skjuta i höjden.
Genom att lösa problemet med kaskaderande hashar kan vi lagra många fler tidigare buildar utan att slå i gränserna, eftersom de flesta filer nu inte behöver ändras. Det minskar också risken att en användare på en gammal build kraschar, eftersom chansen är mycket större att de begär en fil som numera är oförändrad och som vi faktiskt har kvar.
Varför inte [alternativa lösningar]?
När jag först började kika på att lösa det här funderade jag på några olika angreppssätt. Inget av dem kändes riktigt rätt.
Post-build-skript
Min första tanke var att skriva ett post-build-skript som skulle normalisera alla import-sökvägar, rehasha filerna och uppdatera referenserna. Det lät ganska rakt på sak: bara regex-söka upp de hashade filnamnen, ersätta dem med stabila namn och sedan räkna om hasharna.
Jag skrotade det här spåret på grund av "Heisenbugs" och oro för cache poisoning. Även om vi sparar gamla buildar i Cloudflare Pages var risken för inkonsistenta cacher inte värd det. Ett skript som ändrar filer efter själva builden kan smyga in subtila buggar som bara dyker upp i produktion, och att felsöka sånt är rena mardrömmen.
Vite manualChunks
Ett annat alternativ var att använda Vites manualChunks-konfiguration för att separera stabil kod (som node_modules) från mer rörlig kod (vår egen logik). Tanken var att vendorkoden skulle ändras mer sällan, så att färre filer kaskaderar.
Det löser egentligen inte grundproblemet, det dämpar det bara lite. Du får fortfarande kaskaderande hashar inne i dina business logic‑chunks. Jag ville ha en lösning som angriper själva kärnproblemet, inte bara gör det lite mindre jobbigt.
Import Maps: den moderna lösningen
Import Maps är en inbyggd webbläsarfunktion (med polyfill-stöd för äldre webbläsare) som kopplar loss modulnamn från filvägar. I stället för att fil A importerar "./button-abc123.js" importerar den "button". Webbläsaren använder import-kartan för att slå upp att "button" egentligen pekar på det hashade filnamnet.
Det här var precis vad jag behövde. Innehållet i fil A är alltid identiskt (den importerar alltid "button"), så dess hash förblir densamma. Bara import-kartan och den ändrade filen får nya hashar. Jag blev faktiskt lite förvånad över att ingen redan hade gjort ett bra plugin för det här!
Implementationsresan
Jag bestämde mig för att bygga ett Vite-plugin som skulle:
- Göra om alla relativa imports till stabila modulnamn
- Generera en import-karta som mappar de namnen till de faktiska hashade filnamnen
- Injicera import-kartan i HTML:en
Pluginet finns nu på GitHub: @foony/vite-plugin-import-map
Första försöket
Jag började med ett Vite-plugin som använde hooken generateBundle. Mitt första försök använde regex för att hitta och ersätta import-sökvägar. Det var lätt att koda och funkade för vårt lilla team på Foony, men det var skört och skulle definitivt inte hålla i ett plugin där det kan finnas falska träffar som råkar bli ändrade.
Regex-varianten hade uppenbara problem: tänk om en sträng i koden råkar se ut som ett filnamn? Vad händer med dynamiska imports? Och med export‑satser? Jag behövde en mer robust lösning om jag skulle bygga ett plugin som andra kan använda.
AST-parsning
Jag behövde parsa JavaScript-koden på riktigt för att hitta alla import-satser. Mitt första försök var es-module-lexer, som är byggt just för att parsa ES-moduler. Tyvärr ledde det till native-krascher under Vites modulanalyser. Inte ens när jag testade asm.js‑builden försvann krascherna.
Till slut landade jag i Acorn, en snabb, lättviktig parser skriven helt i JavaScript. Tillsammans med acorn-walk för att gå igenom AST:en gav den mig allt jag behövde, utan strul med native-beroenden.
Viktiga utmaningar jag löste
Att hantera alla typer av imports
Imports dyker upp i många olika former och de representeras olika i AST:en. Jag behövde hantera:
- Statisk import:
import x from "./file.js" - Dynamisk import:
import("./file.js") - Namngivna re-exports:
export { x } from "./file.js"(den här missade jag först!) - Re-export allt:
export * from "./file.js"
Re-export-fallet var extra lurigt eftersom jag missade det tills jag såg en fil som inte blev transformerad alls. Koden innehöll export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" och mitt plugin ignorerade den helt, eftersom jag bara letade efter ImportDeclaration- och ImportExpression-noder.
Så här hanterar jag alla de här fallen nu:
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
},
});
Deterministisk konflikthantering
När flera filer har samma grundnamn (som flera index.tsx i olika kataloger) måste jag kunna skilja dem åt. Jag kan inte bara använda "index" för allihop.
Min lösning: om det finns en krock hashar jag den ursprungliga sökvägen plus grundnamnet. Till exempel blir src/client/games/chess/index.tsx:index hashat till något i stil med index-abc123. Det gör att samma fil alltid får samma modulnamn mellan olika buildar, även om andra filer med samma namn läggs till eller tas bort.
Jag använder chunk.facadeModuleId (entry pointen) som primär identitet och faller tillbaka till chunk.moduleIds[0] om den inte finns. Det ger mig en stabil källsökväg att hasha deterministiskt.
Kedjade source maps
När jag transformerar koden bryter jag kedjan av source maps. Den befintliga source mapen går från den ursprungliga TypeScript-källan via Babel och minifiering till den nuvarande koden. Mina transformationer lägger på ytterligare ett lager, så jag behöver bevara hela kedjan.
Jag använder MagicString för att hålla koll på mina ändringar och generera en ny source map. Sedan slår jag ihop den med den befintliga kartan genom att behålla de ursprungliga arrayerna sources och sourcesContent. På så sätt finns hela kedjan kvar: Ursprunglig källa → (befintlig karta) → transformerad kod.
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,
};
Rehasha transformerat innehåll
Jag behöver stabilt filinnehåll. För att få det transformerar jag importsatserna (byter ut Vites hashade imports mot mina stabila) och tar sedan bort source map-kommentarer inför hash-beräkningen (de pekar ofta på gamla filnamn).
Sedan räknar jag ut en ny hash och uppdaterar både filnamnet och posten i import-kartan.
Den färdiga implementationen
Pluginet använder en fyrstegstrategi:
- Räkningspass: upptäck namn-krockar genom att räkna hur många filer som delar samma grundnamn
- Kartpass: skapa chunk‑mappningen (hashat filnamn → modulnamn) och en initial import‑karta
- Transformpass: skriv om import-sökvägar i koden, räkna om hashar, uppdatera source maps
- Döp om-pass: uppdatera filnamn i bundlet och färdigställ import‑kartan
Själva kärnan i transformationslogiken ser ut så här:
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);
}
För att injicera import-kartan i HTML använder jag Vites API för tagginjektion i stället för regex-pill:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Det här är betydligt mer pålitligt än att försöka regexa sig fram bland HTML-taggarna.
I siffror
För att ge en känsla för vad pluginet gör:
- ~1 000+ JavaScript-filer som behandlas per build
- ~2-3 sekunder extra buildtid (en helt okej avvägning)
- ~99 % minskning av onödiga hashändringar (de flesta filer ändras nu bara när deras faktiska innehåll ändras)
- ~340 rader plugin-kod (inklusive kommentarer och felhantering)
Pluginet hanterar alla edge cases jag stött på hittills och buildprocessen är nu mycket mer förutsägbar.
Lärdomar
Varför AST-parsning är nödvändigt
Regex på bundlad kod är farligt. Om en sträng i koden råkar se ut som ett filnamn kommer regexen att skriva om den. Med AST-parsning ser du till att du bara rör riktiga import- och export-satser.
Varför Acorn i stället för es-module-lexer
es-module-lexer är snabbare och mer specialiserat, men problemen med native-panics gjorde det oanvändbart i mitt Vite-plugin. Acorn är ren JavaScript, så det finns inga native-beroenden att oroa sig för. Jag vill gärna kika på es-module-lexer igen framöver som en prestandaoptimering, men just nu funkar Acorn hur bra som helst.
Varför Import Maps i stället för alternativen
Import Maps är en webbstandard med inbyggt stöd i moderna webbläsare. Det är det "rätta" sättet att lösa det här problemet. Polyfillen (es-module-shims) tar hand om äldre webbläsare (till exempel Safari < 16.4) på ett snyggt sätt, och lösningen är både ren och lätt att underhålla.
Slutsats
Import Maps-pluginet stoppar effektivt kaskaderande hashändringar i mina Vite-buildar. Filer får nu bara nya hashar när deras faktiska innehåll ändras, inte när deras beroenden gör det. Det gör buildarna mer förutsägbara, minskar onödig cache-invalidering och hjälper oss att hålla oss under Cloudflare Pages filgränser.
Lösningen är enkel, lätt att underhålla och bygger på moderna webbstandarder. Det är ett bra exempel på hur den "rätta" lösningen ibland också är den enklaste, bara man förstår problemet tillräckligt bra.
Pluginet är open source och finns på GitHub: @foony/vite-plugin-import-map. Du kan installera det med npm install @foony/vite-plugin-import-map och börja använda det i dina egna Vite-projekt.
Framtida förbättringar kan bli att optimera med es-module-lexer när problemen med native-panics är lösta, eller att lägga till stöd för ännu mer komplexa import-scenarier. Men just nu gör pluginet precis det jag behöver att det ska göra.
Och vem vet, kanske får Vite inbyggt stöd för något liknande en dag.