background blurbackground mobile blur

1/1/1970

Sådan løste jeg kaskader af hash-ændringer med Import Maps

Hej! Jeg har haft det her problem i over 5 år, men først nu besluttede jeg at tage det alvorligt, fordi det nåede et punkt, hvor jeg ikke længere kunne ignorere det. Når jeg ændrede ét enkelt tegn i én fil, fik halvdelen af JavaScript-filerne i mit build nye hash-baserede filnavne, selv om deres faktiske indhold slet ikke havde ændret sig. Det gav unødvendig cache-invalidering, det gjorde det nær umuligt at se, hvad der faktisk havde ændret sig mellem builds, og værst af alt: det fik mine Cloudflare Pages builds til at fejle på grund af en filgrænse.

Herunder gennemgår jeg problemet, hvorfor eksisterende løsninger ikke virkede for mig, og hvordan jeg byggede et skræddersyet Vite-plugin med Import Maps, der løser det én gang for alle.

Problemet: kaskader af hash-ændringer

Vite bruger indholdsbasseret hashing til produktions-builds. Når du bygger din app, får hver JavaScript-fil en hash i sit filnavn baseret på indholdet. Hvis button.tsx kompilerer til button-abc12345.js, og indholdet ændrer sig, bliver det til button-def45678.js. Det er super til cache busting, for 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å en ny hash, selv om logikken i main.js slet ikke har ændret sig.

Det her kaskader gennem hele din afhængighedsgraf. Ændr én lille hjælpefunktion, og pludselig får halvdelen af dine JS-filer nye hashes. I mit tilfælde gjorde en enkelt tegnændring i useBackgroundMusic.ts, at over 500 filer blev re-hashed.

I praksis havde det ret stor betydning. Vi bundler 8 versioner af vores tidligere builds-assets, så brugere på lidt forældede versioner af klienten stadig kan køre deres version, når vi ruller en ny version ud til Cloudflare Pages. Men Cloudflare Pages har en grænse på 20.000 filer, som vi begyndte at ramme på grund af vores tidligere i18n-ændring, der fik antallet af filer, vi opretter, til at eksplodere.

Ved at løse de kaskaderende hashes kan vi gemme langt flere tidligere builds uden at ramme grænserne, fordi de fleste filer nu ikke længere behøver at ændre sig. Det mindsker også risikoen for, at en bruger på et forældet build får fejl, fordi det er meget mere sandsynligt, at de beder om en fil, der nu er uændret, og som vi stadig har liggende.

Hvorfor ikke [Alternative Solutions]?

Da jeg først kiggede på at løse det her, overvejede jeg nogle forskellige tilgange. Ingen af dem passede helt.

Post-build scripts

Min første tanke var at skrive et post-build script, der normaliserede alle import-stier, re-hashede filerne og opdaterede referencerne. Det virkede ret lige til: bare regex-erstat de hash-baserede filnavne med stabile navne og beregn hashes igen.

Jeg droppede den tilgang på grund af frygt for "Heisenbugs" og cache poisoning. Selvom vi gemmer tidligere builds i Cloudflare Pages, var risikoen for cache-inkonsistens ikke det værd. Et script, der ændrer filer efter buildet, kan introducere subtile fejl, som kun viser sig i produktion, og det ville være et mareridt at debugge.

Vite manualChunks

En anden mulighed var at bruge Vites manualChunks-konfiguration til at skille stabil kode (som node_modules) fra ustabil kode (forretningslogik). Tanken var, at vendor-kode ændrer sig sjældnere, så færre filer ville kaskadere.

Det løser bare ikke selve rodproblemet, det dæmper det kun. Du får stadig kaskaderende hashes inden for dine forretningslogik-chunks. Jeg ville have en løsning, der tog fat i kernen af problemet, ikke bare gjorde det lidt mindre slemt.

Import Maps: den moderne løsning

Import Maps er en browser-native funktion (med polyfill-support til ældre browsere), der adskiller modul-specifiers fra filstier. I stedet for at Fil A importerer "./button-abc123.js", importerer den "button". Browseren bruger import-mappet til at slå "button" op til det rigtige hash-baserede filnavn.

Det var præcis det, jeg havde brug for. Indholdet i Fil A forbliver identisk (den importerer altid "button"), så dens hash forbliver den samme. Kun import-mappet og selve den ændrede fil får nye hashes. Jeg var ærligt talt lidt chokeret over, at ingen allerede havde lavet et godt plugin til det her!

Rejsen mod implementeringen

Jeg besluttede at bygge et Vite-plugin, som skulle:

  1. Omskrive alle relative imports til stabile modulnavne
  2. Generere et import-map, der mapper de navne til de rigtige hash-baserede filnavne
  3. Indsprøjte import-mappet i HTML'en

Pluginet er nu tilgængeligt på GitHub: @foony/vite-plugin-import-map

Første forsøg

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 nemt at kode og virkede fint til vores lille team Foony, men det var skrøbeligt og ville helt sikkert ikke fungere i et plugin, hvor der kan være false positives, der bliver ændret ved en fejl.

Regex-tilgangen havde åbenlyse problemer: hvad nu hvis en streng i koden tilfældigvis ligner 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 var nødt til at parse JavaScript-koden rigtigt for at finde alle import-statements. Mit første forsøg var es-module-lexer, som er designet specifikt til at parse ES-moduler. Desværre gav det native panics under Vites moduledanalyse-fase. Selv asm.js-buildet hjalp ikke på de panics.

Jeg endte med Acorn, en hurtig, letvægts, ren JavaScript-parser. Kombineret med acorn-walk til at traversere AST'en gav det mig alt, jeg havde brug for, uden problemer med native afhængigheder.

Centrale udfordringer, jeg fik løst

Håndtering af alle import-typer

Imports kommer i mange former, og de bliver behandlet forskelligt i AST'en. Jeg skulle håndtere:

  • Statiske imports: import x from "./file.js"
  • Dynamiske imports: import("./file.js")
  • Navngivne re-eksporter: export { x } from "./file.js" (den her missede jeg i starten!)
  • Eksporter alt: export * from "./file.js"

Re-eksport-tilfældet var især tricky, fordi jeg først opdagede det, da jeg så en fil, der ikke blev transformeret. Der stod export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js", og mit plugin ignorerede den fuldstændigt, fordi jeg kun kiggede efter ImportDeclaration og ImportExpression-noder.

Sådan håndterer jeg dem alle nu:

walk(ast, {
  ImportDeclaration(node: any) {
    // Statisk import: import x from "spec"
    const specifier = node.source.value;
    // ... transform-logik
  },
  ExportNamedDeclaration(node: any) {
    // Navngivne exports med kilde: export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... transform-logik
  },
  ExportAllDeclaration(node: any) {
    // Eksporter alt: export * from "spec"
    if (!node.source?.value) return;
    // ... transform-logik
  },
  ImportExpression(node: any) {
    // Dynamiske imports: import("spec")
    // ... transform-logik
  },
});

Deterministisk konflikthåndtering

Når flere filer har det samme basisnavn (som flere index.tsx-filer i forskellige mapper), skal jeg kunne skelne imellem dem. Jeg kan ikke bare bruge "index" til dem alle.

Min løsning: hvis der er en konflikt, hasher jeg den originale kilde-sti plus basisnavnet. For eksempel bliver src/client/games/chess/index.tsx:index hashet til at danne index-abc123. Det sikrer, at den samme fil altid får det samme modulnavn på tværs af builds, selvom andre filer med samme navn bliver tilføjet eller fjernet.

Jeg bruger chunk.facadeModuleId (entry point) som primær identifikator 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 chaining

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 holde styr på mine transformationer og generere et nyt source map. Derefter fletter jeg det med det eksisterende map ved at bevare de originale sources- og sourcesContent-arrays. Det bevarer hele kæden: Original 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,
});

// Flet: brug det nye maps mappings, men bevar de originale 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 få det transformerer jeg imports (jeg erstatter Vites hash-baserede imports med mine stabile imports) og fjerner derefter source map-kommentarer fra hash-beregningen (de refererer til gamle filnavne).

Derefter beregner jeg en ny hash og opdaterer både filnavnet og posten i import-mappet.

Den endelige implementering

Pluginet bruger en strategi med fire gennemløb:

  1. Tælle-pass: Find navnekollisioner ved at tælle, hvor mange filer der deler hvert basisnavn
  2. Map-pass: Opret chunk-mapping (hash-baseret filnavn → modulnavn) og det første import-map
  3. Transform-pass: Omskriv import-stier i koden, beregn hashes igen, opdater source maps
  4. Rename-pass: Opdater bundle-filnavne og færdiggør import-mappet

Her er den centrale transformationslogik:

import {simple as walk} from 'acorn-walk';

// Parse koden for at få et AST
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];

// Gå igennem AST'en for at finde alle 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 for at springe åbningsanførselstegnet over
        end: node.source.end - 1,     // -1 for at springe slut-anførselstegnet over
        replacement: moduleSpec,
      });
    }
  },
  // ... håndter andre nodetyper
});

// Anvend transformationerne i omvendt rækkefølge for at bevare positionerne
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-mappet 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 robust end at prøve at regex-matche HTML-tags.

Tallene

For at give dig en fornemmelse af, hvad pluginet gør:

  • ~1.000+ JavaScript-filer bliver behandlet per build
  • ~2-3 sekunder ekstra buildtid (en helt fin trade-off)
  • ~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)

Pluginet håndterer alle de edge cases, jeg indtil videre er stødt på, og build-processen er blevet meget mere forudsigelig.

Det har jeg lært

Hvorfor AST-parsing er vigtigt

Regex på bundlet kode er farligt. Hvis en streng i din kode tilfældigvis ligner et filnavn, vil regex'en omskrive den. AST-parsing sikrer, at du kun transformerer rigtige import/export-statements.

Hvorfor Acorn i stedet for es-module-lexer

es-module-lexer er hurtigere og mere specialiseret, men problemerne med native panics gjorde det ubrugeligt i mit Vite-plugin-setup. Acorn er ren JavaScript, så der er ingen native afhængigheder at bekymre sig om. Jeg vil gerne kigge på es-module-lexer igen i fremtiden som en hastighedsoptimering, men lige nu fungerer Acorn helt fint.

Hvorfor Import Maps frem for alternativer

Import Maps er en webstandard med native browser-support. Det er den "rigtige" måde at løse det her problem på. Polyfillen (es-module-shims) håndterer ældre browsere (fx Safari < 16.4) pænt, og løsningen er ren og til at vedligeholde.

Konklusion

Import Maps-pluginet forhindrer effektivt kaskaderende hash-ændringer i mine Vite-builds. Filer får nu kun nye hashes, når deres faktiske indhold ændrer sig, ikke bare fordi deres afhængigheder gør. 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, til at vedligeholde og bruger moderne webstandarder. Det er et godt eksempel på, at den "rigtige" løsning nogle gange også er den simpleste, når først du forstår problemet dybt nok til at få øje på den.

Pluginet er open source og ligger 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 kan være at optimere med es-module-lexer, når problemerne med native panics er løst, eller at tilføje support til endnu mere komplekse import-scenarier. Men lige nu gør pluginet præcis det, jeg har brug for, at det skal gøre.

Og hvem ved? Måske understøtter Vite en dag sådan noget her direkte.

8 Ball Pool online multiplayer billiards icon