

1/1/1970
Hur jag löste kaskaderande hash-ändringar med Import Maps
Tjena! Jag har haft det här problemet i 5+ år, men först nu bestämde jag mig för att tackla det eftersom det nådde en punkt där jag inte längre kunde ignorera det. När jag ändrade ett enda tecken i en fil fick hälften av JavaScript-filerna i mitt bygge nya hash-baserade filnamn, trots att deras faktiska innehåll inte hade ändrats. Detta orsakade onödig cache-invalidering, gjorde det nästan omöjligt att se vad som faktiskt hade ändrats mellan bygg, och värst av allt: det förstörde mina Cloudflare Pages-bygg på grund av en filgräns.
Nedan bryter jag ner problemet, varför befintliga lösningar inte fungerade för mig, och hur jag byggde ett anpassat 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 hashning för produktionsbygg. När du bygger din app får varje JavaScript-fil en hash i sitt filnamn baserat på sitt innehåll. Om button.tsx kompileras till button-abc12345.js och innehållet ändras, blir det button-def45678.js. Detta är toppen för cache-busting: användarna får den nya filen när den ändras.
Problemet uppstår 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 även main.js eftersom den innehåller strängen "./button-abc12345.js", som nu är fel. Så main.js får också en ny hash, även om själva logiken i main.js inte har ändrats alls.
Detta kaskaderar genom hela ditt beroendegraf. Ändra en hjälpfunktion, och plötsligt får hälften av dina js-filer nya hashar. I mitt fall ledde en ändring av ett enda tecken i useBackgroundMusic.ts till att över 500 filer hashades om.
Den verkliga effekten var betydande. Vi paketerar 8 versioner av tidigare byggas tillgångar så att användare på något föråldrade versioner av vår klient fortfarande kan köra sin version när vi distribuerar den nya versionen till Cloudflare Pages. Cloudflare Pages har dock en filgräns på 20 000 filer, vilken vi började nå på grund av vår i18n-ändring tidigare som exploderade antalet filer vi skapar.
Att lösa kaskaderande hashar gör att vi kan lagra långt fler tidigare bygg utan att slå i dessa gränser, eftersom de flesta filer nu inte längre behöver ändras. Det minskar också sannolikheten att en användare på ett föråldrat bygge får fel, eftersom det är mycket troligare att de begär en numera oförändrad fil som vi råkar ha.
Varför inte [alternativa lösningar]?
När jag först funderade på att lösa detta övervägde jag några olika tillvägagångssätt. Inget av dem passade riktigt.
Skript efter bygget
Min första tanke var att skriva ett post-build-skript som skulle normalisera alla import-sökvägar, hasha om filerna och uppdatera referenserna. Det verkade enkelt: bara ersätta de hashade filnamnen med stabila namn med regex, och sedan räkna om hashar.
Jag avvisade det här tillvägagångssättet på grund av "Heisenbugs" och oro för cache-förgiftning. Även om vi lagrar tidigare bygg i Cloudflare Pages var risken för cache-inkonsekvenser inte värd det. Ett skript som modifierar filer efter bygget kan introducera subtila buggar som bara dyker upp i produktion, och att felsöka dem skulle vara en mardröm.
Vite manualChunks
Ett annat alternativ var att använda Vites manualChunks-konfiguration för att separera stabil kod (som node_modules) från instabil kod (affärslogik). Tanken var att leverantörskod skulle ändras mindre ofta, så färre filer skulle kaskadera.
Detta löser inte rotproblemet, det mildrar det bara. Du får fortfarande kaskaderande hashar inom dina affärslogik-chunks. Jag ville ha en lösning som adresserade kärnproblemet, inte bara gjorde det lite mindre dåligt.
Import Maps: Den moderna lösningen
Import Maps är en webbläsarinbyggd funktion (med polyfill-stöd för äldre webbläsare) som frikopplar modulspecificerare från filsökvägar. Istället för att Fil A importerar "./button-abc123.js" importerar den "button". Webbläsaren använder import-mappen för att lösa "button" till det faktiska hashade filnamnet.
Detta var precis vad jag behövde. Fil A:s innehåll förblir identiskt (den importerar alltid "button"), så dess hash förblir densamma. Endast import-mappen och den ändrade filen får nya hashar. Jag blev faktiskt lite chockad över att ingen redan hade gjort ett bra plugin för detta!
Att bygga Vite-pluginet
Jag bestämde mig för att bygga ett Vite-plugin som skulle:
- Omvandla alla relativa importer till att använda stabila modulspecificerare
- Generera en import-map som mappar dessa specificerare till de faktiska hashade filnamnen
- Injicera import-mappen i HTML
Pluginet är nu tillgängligt på GitHub: @foony/vite-plugin-import-map
Inledande tillvägagångssätt
Jag började med ett Vite-plugin som använde generateBundle-hooken. Mitt första försök använde regex för att hitta och ersätta import-sökvägar. Det var enkelt att koda och fungerade för vårt lilla team Foony, men var sprött och skulle definitivt inte fungera i ett plugin där det kan finnas falska positiva träffar som blir muterade.
Regex-metoden hade uppenbara problem: vad händer om en sträng i koden råkar se ut som ett filnamn? Vad händer med dynamiska importer? Vad händer med export-satser? Jag behövde en mer robust lösning om jag skulle bygga ett plugin för andra.
AST-parsning
Jag behövde parsa JavaScript-koden ordentligt för att hitta alla import-satser. Mitt första försök var es-module-lexer, som är specifikt designad för att parsa ES-moduler. Tyvärr orsakade den native-panik under Vites modulanalys-fas. Att försöka med asm.js-bygget hjälpte inte heller mot panikerna.
Jag bestämde mig för Acorn, en snabb, lättviktig och ren JavaScript-parser. Tillsammans med acorn-walk för AST-traversering gav den mig allt jag behövde utan problemen med native-beroenden.
Viktiga utmaningar som lösts
Hantering av alla import-typer
Importer kommer i många former, och de behandlas olika i AST:n. Jag behövde hantera:
- Statiska importer:
import x from "./file.js" - Dynamiska importer:
import("./file.js") - Namngivna re-exporter:
export { x } from "./file.js"(jag missade denna inledningsvis!) - Re-export av allt:
export * from "./file.js"
Re-export-fallet var särskilt knepigt eftersom jag missade det tills jag såg en fil som inte transformerades. Koden hade 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 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 konfliktlösning
När flera filer har samma basnamn (som flera index.tsx-filer i olika mappar) behöver jag särskilja dem. Jag kan inte bara använda "index" för dem alla.
Min lösning: om det finns en konflikt hashar jag den ursprungliga källsökvägen plus basnamnet. Till exempel hashas src/client/games/chess/index.tsx:index för att skapa index-abc123. Detta säkerställer att samma fil alltid får samma modulspecificerare över bygg, även om andra filer med samma namn läggs till eller tas bort.
Jag använder chunk.facadeModuleId (ingångspunkten) som primär identifierare, och faller tillbaka till chunk.moduleIds[0] om det inte är tillgängligt. Detta ger mig en stabil källsökväg för deterministisk hashning.
Sammanlänkning av source maps
När jag transformerar koden bryter jag source map-kedjan. Den befintliga source map:en mappar från den ursprungliga TypeScript-källan via Babel och minifiering till den nuvarande koden. Mina transformationer lägger till ytterligare ett lager, så jag måste bevara den kedjan.
Jag använder MagicString för att spåra mina transformationer och generera en ny source map. Sedan slår jag samman den med den befintliga genom att bevara de ursprungliga sources- och sourcesContent-arrayerna. Detta upprätthåller hela kedjan: Ursprunglig källa → (befintlig map) → 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,
};
Omhashning av transformerat innehåll
Jag behöver stabilt filinnehåll. För att åstadkomma det transformerar jag importerna (ersätter Vites hashade importer med mina stabila importer), och sedan tar jag bort source map-kommentarer från hash-beräkningen (de refererar till gamla filnamn).
Därefter beräknar jag en ny hash och uppdaterar både filnamnet och import map-posten.
Den slutliga implementationen
Pluginet använder en strategi i fyra pass:
- Räkningspass: Upptäck namnkollisioner genom att räkna hur många filer som delar varje basnamn
- Mappningspass: Skapa chunk-mappningen (hashat filnamn → modulspecificerare) och initial import-map
- Transformationspass: Skriv om import-sökvägar i koden, räkna om hashar, uppdatera source maps
- Omdöpningspass: Uppdatera buntfilnamn och färdigställ import-mappen
Här är kärntransformationslogiken:
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-mappen i HTML använder jag Vites tagginjektions-API istället för regex-manipulation:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Detta är mycket mer pålitligt än att försöka regex-matcha HTML-taggar.
I siffror
För att ge dig en känsla av vad det här pluginet gör:
- ~1 000+ JavaScript-filer bearbetade per bygge
- ~2-3 sekunder tillagda till byggtiden (acceptabel kompromiss)
- ~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 kantfall jag stött på hittills, och byggprocessen är nu mycket mer förutsägbar.
Lärdomar
Varför AST-parsning är väsentligt
Regex på buntad kod är farligt. Om en sträng i din kod råkar se ut som ett filnamn kommer regex att skriva om den. AST-parsning säkerställer att du bara transformerar faktiska import/export-satser.
Varför Acorn framför es-module-lexer
es-module-lexer är snabbare och mer ändamålsenlig, men problemen med native-panik gjorde den oanvändbar i mitt Vite-plugin-sammanhang. Acorn är ren JavaScript, vilket innebär inga native-beroenden att oroa sig för. Jag vill titta på es-module-lexer i framtiden som en hastighetsoptimering, men för nu fungerar Acorn perfekt.
Varför Import Maps framför alternativen
Import Maps är en webbstandard med inbyggt webbläsarstöd. De är det "rätta" sättet att lösa det här problemet. Polyfilen (es-module-shims) hanterar äldre webbläsare (t.ex. Safari < 16.4) på ett snyggt sätt, och lösningen är ren och underhållbar.
Slutsats
Import Maps-pluginet förhindrar framgångsrikt kaskaderande hash-ändringar i mina Vite-bygg. Filer får nu bara nya hashar när deras faktiska innehåll ändras, inte när deras beroenden ändras. Detta gör bygg 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, underhållbar och använder moderna webbstandarder. Det är ett bra exempel på hur den "rätta" lösningen ibland också är den enklaste, när du väl förstår problemet tillräckligt djupt för att se den.
Pluginet är öppen källkod och tillgängligt 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 inkludera optimering med es-module-lexer när problemen med native-panik är lösta, eller stöd för mer komplexa import-scenarier. Men för tillfället gör pluginet exakt vad jag behöver.
Och vem vet? Kanske kommer Vite någon dag att stödja något liknande inbyggt.
(Uppdatering: Efter att ha provat pluginet på Foonys bygge stötte vissa användare på oväntade problem, så jag har inaktiverat det för stunden. Jag återkommer till det senare. Kanske. Jag tycker fortfarande att det här är en snygg lösning.)