

1/1/1970
Sådan løste jeg kaskadehash-ændringer med Import Maps
Hejsa! Jeg har haft dette problem i 5+ år, men har først nu besluttet mig for at tage fat på det, fordi det er nået til et punkt, hvor jeg ikke længere kan ignorere det. Når jeg ændrede et enkelt tegn i én fil, fik halvdelen af JavaScript-filerne i mit build nye hash-filnavne, selvom deres faktiske indhold ikke havde ændret sig. Det forårsagede unødvendig cache-invalidering, gjorde det næsten umuligt at spore, hvad der faktisk havde ændret sig mellem builds, og værst af alt: det ødelagde mine Cloudflare Pages-builds på grund af en filgrænse.
Nedenfor gennemgår jeg problemet, hvorfor eksisterende løsninger ikke virkede for mig, og hvordan jeg byggede et brugerdefineret Vite-plugin med Import Maps for at løse det én gang for alle.
Problemet: Kaskadehash-ændringer
Vite bruger indholdsbaseret hashing til produktions-builds. Når du bygger din app, får hver JavaScript-fil et hash i sit filnavn baseret på indholdet. Hvis button.tsx kompileres til button-abc12345.js, og indholdet ændrer sig, bliver det til button-def45678.js. Det er fantastisk til cache-busting: brugerne får den nye fil, når den ændrer sig.
Problemet opstår, når Fil A importerer Fil B. Lad os sige, du har:
// main.js
import { Button } from "./button-abc12345.js";
Når button.tsx ændrer sig, genererer Vite button-def45678.js. Men nu ændrer main.js sig også, fordi den indeholder strengen "./button-abc12345.js", som nu er forkert. Så main.js får også et nyt hash, selvom den faktiske logik i main.js slet ikke har ændret sig.
Dette kaskaderer gennem hele dit afhængighedsgraf. Ændr én utility-funktion, og pludselig får halvdelen af dine js-filer nye hashes. I mit tilfælde forårsagede en ændring af et enkelt tegn i useBackgroundMusic.ts, at over 500 filer blev re-hashet.
Den reelle indvirkning var betydelig. Vi bundler 8 versioner af vores tidligere builds aktiver, så brugere på lidt forældede versioner af vores klient stadig kan køre deres version, når vi deployer den nye version til Cloudflare Pages. Cloudflare Pages har dog en grænse på 20.000 filer, som vi begyndte at ramme på grund af vores i18n-ændring tidligere, som eksploderede antallet af filer, vi opretter.
Ved at løse kaskadehashes kan vi gemme langt flere tidligere builds uden at ramme disse grænser, fordi de fleste filer nu ikke længere behøver at ændre sig. Det reducerer også sandsynligheden for, at en bruger på et forældet build får en fejl, da det er langt mere sandsynligt, at de anmoder om en nu uændret fil, som vi tilfældigvis har.
Hvorfor ikke [alternative løsninger]?
Da jeg først kiggede på at løse dette, overvejede jeg et par tilgange. Ingen af dem passede helt.
Post-build-scripts
Min første tanke var at skrive et post-build-script, der ville normalisere alle import-stier, re-hashe filerne og opdatere referencerne. Det virkede ligetil: bare regex-erstat de hashede filnavne med stabile navne, og genberegn så hashes.
Jeg afviste denne tilgang på grund af "Heisenbugs" og bekymringer om cache-forgiftning. Selvom vi gemmer tidligere builds i Cloudflare Pages, var risikoen for cache-uoverensstemmelser ikke det værd. Et script, der ændrer filer efter build, kunne introducere subtile fejl, der kun dukker op i produktion, og at debugge dem ville være et mareridt.
Vites manualChunks
En anden mulighed var at bruge Vites manualChunks-konfiguration til at adskille stabil kode (som node_modules) fra ustabil kode (forretningslogik). Idéen var, at vendor-koden ville ændre sig sjældnere, så færre filer ville kaskadere.
Det løser faktisk ikke rodproblemet, det afbøder det bare. Du får stadig kaskadehashes inden for dine forretningslogik-chunks. Jeg ville have en løsning, der adresserede kerneproblemet, ikke bare gjorde det lidt mindre slemt.
Import Maps: Den moderne løsning
Import Maps er en browser-native funktion (med polyfill-understøttelse til ældre browsere), der afkobler modul-specifiers fra filstier. I stedet for at Fil A importerer "./button-abc123.js", importerer den "button". Browseren bruger import map'et til at oversætte "button" til det faktiske hashede filnavn.
Det var præcis, hvad jeg havde brug for. Fil As indhold forbliver identisk (den importerer altid "button"), så dens hash forbliver den samme. Kun import map'et og den ændrede fil får nye hashes. Jeg var lidt chokeret over, at ingen allerede havde lavet et godt plugin til dette!
Bygning af Vite-pluginnet
Jeg besluttede at bygge et Vite-plugin, der ville:
- Transformere alle relative imports til at bruge stabile modul-specifiers
- Generere et import map, der mapper disse specifiers til de faktiske hashede filnavne
- Indsætte import map'et i HTML'en
Pluginnet er nu tilgængeligt på GitHub: @foony/vite-plugin-import-map
Indledende tilgang
Jeg startede med et Vite-plugin, der brugte generateBundle-hooket. Mit første forsøg brugte regex til at finde og erstatte import-stier. Det var let at kode og virkede for vores lille team Foony, men det var skrøbeligt og ville bestemt ikke virke i et plugin, hvor der kunne være falske positiver, der ville blive muteret.
Regex-tilgangen havde åbenlyse problemer: hvad hvis en streng i koden tilfældigvis lignede et filnavn? Hvad med dynamiske imports? Hvad med export-statements? Jeg havde brug for en mere robust løsning, hvis jeg skulle bygge et plugin til andre.
AST-parsing
Jeg havde brug for at parse JavaScript-koden ordentligt for at finde alle import-statements. Mit første forsøg var es-module-lexer, som er specifikt designet til at parse ES-moduler. Desværre forårsagede det native panics under Vites modul-analysefase. Selv at prøve asm.js-buildet hjalp ikke med at stoppe panics.
Jeg endte med Acorn, en hurtig, letvægts, ren JavaScript-parser. Kombineret med acorn-walk til AST-traversering gav den mig alt, hvad jeg havde brug for, uden de native afhængighedsproblemer.
Vigtige udfordringer løst
Håndtering af alle import-typer
Imports kommer i mange former, og de behandles forskelligt i AST'en. Jeg havde brug for at håndtere:
- Statiske imports:
import x from "./file.js" - Dynamiske imports:
import("./file.js") - Navngivne re-eksporter:
export { x } from "./file.js"(jeg overså denne i starten!) - Re-eksporter alle:
export * from "./file.js"
Re-eksport-tilfældet var særligt drilsk, fordi jeg overså det, indtil jeg så en fil, der ikke blev transformeret. Koden indeholdt export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js", og mit plugin ignorerede det fuldstændigt, fordi jeg kun kiggede efter ImportDeclaration- og ImportExpression-noder.
Sådan håndterer jeg dem alle 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 flere filer har samme basisnavn (som flere index.tsx-filer i forskellige mapper), skal jeg adskille dem. Jeg kan ikke bare bruge "index" til alle.
Min løsning: hvis der er en konflikt, hasher jeg den oprindelige kilde-sti plus basisnavnet. For eksempel bliver src/client/games/chess/index.tsx:index hashet til at skabe index-abc123. Dette sikrer, at den samme fil altid får samme modul-specifier på tværs af builds, selv hvis andre filer med samme navn tilføjes eller fjernes.
Jeg bruger chunk.facadeModuleId (entry point'et) som primær identifier og falder tilbage til chunk.moduleIds[0], hvis den ikke er tilgængelig. Det giver mig en stabil kilde-sti til deterministisk hashing.
Source map-kæde
Når jeg transformerer koden, bryder jeg source map-kæden. Det eksisterende source map mapper fra den oprindelige TypeScript-kilde gennem Babel og minificering til den nuværende kode. Mine transformationer tilføjer endnu et lag, så jeg er nødt til at bevare den kæde.
Jeg bruger MagicString til at spore mine transformationer og generere et nyt source map. Derefter fletter jeg det med det eksisterende map ved at bevare de oprindelige sources- og sourcesContent-arrays. Det opretholder den fulde kæde: Oprindelig kilde → (eksisterende map) → Transformeret kode.
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,
};
Re-hashing af transformeret indhold
Jeg har brug for stabilt filindhold. For at opnå det transformerer jeg imports (erstatter Vites hashede imports med mine stabile imports), og så fjerner jeg source map-kommentarer fra hash-beregningen (de refererer til gamle filnavne).
Derefter beregner jeg et nyt hash og opdaterer både filnavnet og import map-indgangen.
Den endelige implementering
Pluginnet bruger en strategi i fire passes:
- Tællings-pass: Registrer navnekollisioner ved at tælle, hvor mange filer der deler hvert basisnavn
- Map-pass: Opret chunk-mappingen (hashet filnavn → modul-specifier) og det indledende import map
- Transform-pass: Omskriv import-stier i koden, genberegn hashes, opdater source maps
- Omdøbnings-pass: Opdater bundle-filnavne og færdiggør import map'et
Her er den centrale transformationslogik:
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);
}
Til at indsætte import map'et i HTML bruger jeg Vites tag-injection API i stedet for regex-manipulation:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Det er meget mere pålideligt end at forsøge at regex-matche HTML-tags.
I tal
For at give dig en fornemmelse af, hvad pluginnet gør:
- ~1.000+ JavaScript-filer behandlet pr. build
- ~2-3 sekunder lagt til build-tiden (acceptabel afvejning)
- ~99 % reduktion i unødvendige hash-ændringer (de fleste filer ændrer sig nu kun, når deres faktiske indhold ændrer sig)
- ~340 linjer plugin-kode (inklusive kommentarer og fejlhåndtering)
Pluginnet håndterer alle de edge cases, jeg er stødt på indtil videre, og build-processen er nu meget mere forudsigelig.
Lærdomme
Hvorfor AST-parsing er afgørende
Regex på bundlet kode er farligt. Hvis en streng i din kode tilfældigvis ligner et filnavn, vil regex omskrive den. AST-parsing sikrer, at du kun transformerer faktiske import/export-statements.
Hvorfor Acorn frem for es-module-lexer
es-module-lexer er hurtigere og mere formålsbygget, men de native panic-problemer gjorde den ubrugelig i min Vite-plugin-kontekst. Acorn er ren JavaScript, hvilket betyder ingen native afhængigheder at bekymre sig om. Jeg vil gerne kigge på es-module-lexer i fremtiden som en hastighedsoptimering, men indtil videre virker Acorn perfekt.
Hvorfor Import Maps frem for alternativer
Import Maps er en webstandard med native browser-understøttelse. De er den "rigtige" måde at løse dette problem på. Polyfillen (es-module-shims) håndterer ældre browsere (f.eks. Safari < 16.4) elegant, og løsningen er ren og vedligeholdelsesvenlig.
Konklusion
Import Maps-pluginnet forhindrer succesfuldt kaskadehash-ændringer i mine Vite-builds. Filer får nu kun nye hashes, når deres faktiske indhold ændrer sig, ikke når deres afhængigheder ændrer sig. Det gør builds mere forudsigelige, reducerer unødvendig cache-invalidering og hjælper os med at holde os under Cloudflare Pages' filgrænser.
Løsningen er enkel, vedligeholdelsesvenlig og bruger moderne webstandarder. Det er et godt eksempel på, hvordan den "rigtige" løsning nogle gange også er den enkleste, når man først forstår problemet dybt nok til at se det.
Pluginnet er open source og tilgængeligt på GitHub: @foony/vite-plugin-import-map. Du kan installere det med npm install @foony/vite-plugin-import-map og begynde at bruge det i dine egne Vite-projekter.
Fremtidige forbedringer kunne inkludere optimering med es-module-lexer, når de native panic-problemer er løst, eller tilføjelse af understøttelse for mere komplekse import-scenarier. Men indtil videre gør pluginnet præcis, hvad jeg har brug for, at det skal gøre.
Og hvem ved? Måske vil Vite en dag understøtte noget lignende natively.
(Opdatering: Efter at have prøvet pluginnet på Foonys build oplevede nogle brugere uventede problemer, så jeg har deaktiveret det indtil videre. Jeg vender tilbage til det senere. Måske. Jeg synes stadig, det er en smart løsning.)