background blurbackground mobile blur

1/1/1970

Wie ich kaskadierende Hash-Änderungen mit Import Maps gelöst habe

Hallöchen! Dieses Problem begleitet mich seit über 5 Jahren, aber erst jetzt habe ich mich wirklich drangesetzt, weil es irgendwann einfach nicht mehr zu ignorieren war. Wenn ich in einer Datei ein einziges Zeichen geändert habe, bekam plötzlich die Hälfte der JavaScript-Dateien in meinem Build neue gehashte Dateinamen, obwohl sich ihr eigentlicher Inhalt gar nicht verändert hatte. Das hat unnötig Caches ungültig gemacht, es war fast unmöglich nachzuvollziehen, was sich zwischen zwei Builds wirklich geändert hat, und das Schlimmste: Meine Cloudflare-Pages-Builds sind wegen eines Dateilimits ständig kaputtgegangen.

Im Folgenden zerlege ich das Problem, erkläre, warum bestehende Lösungen für mich nicht funktioniert haben, und wie ich ein eigenes Vite-Plugin mit Import Maps gebaut habe, das die Sache ein für alle Mal löst.

Das Problem: Kaskadierende Hash-Änderungen

Vite verwendet in Produktions-Builds Content-Hashes. Wenn du deine App baust, bekommt jede JavaScript-Datei einen Hash im Dateinamen, der auf ihrem Inhalt basiert. Wenn button.tsx zu button-abc12345.js kompiliert und sich der Inhalt ändert, wird daraus button-def45678.js. Das ist super für Cache-Busting: Nutzer bekommen automatisch die neue Datei, wenn sich etwas ändert.

Das Problem taucht auf, wenn Datei A Datei B importiert. Angenommen, du hast:

// main.js
import { Button } from "./button-abc12345.js";

Wenn sich button.tsx ändert, erzeugt Vite button-def45678.js. Aber jetzt ändert sich auch main.js, weil darin der String "./button-abc12345.js" steckt, der nun nicht mehr stimmt. Also bekommt main.js auch einen neuen Hash, obwohl sich die eigentliche Logik in main.js überhaupt nicht geändert hat.

Und das zieht sich durch deinen kompletten Abhängigkeits-Graphen. Ändere eine kleine Utility-Funktion und plötzlich haben die Hälfte deiner JS-Dateien neue Hashes. Bei mir hat das Ändern eines einzigen Zeichens in useBackgroundMusic.ts dazu geführt, dass über 500 Dateien neu gehashed wurden.

In der Praxis hatte das ziemlich große Auswirkungen. Wir bundlen 8 Versionen der Assets der letzten Builds, damit Nutzer mit leicht veralteten Clients ihre Version weiter nutzen können, wenn wir eine neue Version auf Cloudflare Pages deployen. Aber Cloudflare Pages hat ein Limit von 20.000 Dateien, und das haben wir irgendwann gerissen – unter anderem wegen unserer i18n-Änderung neulich, die massiv explodiert hat, was die Anzahl der generierten Dateien angeht.

Indem wir die kaskadierenden Hashes lösen, können wir viel mehr alte Builds speichern, ohne dieses Limit zu treffen, weil jetzt die meisten Dateien nicht mehr geändert werden müssen. Gleichzeitig sinkt die Wahrscheinlichkeit, dass ein Nutzer mit einem alten Build einen Fehler bekommt, weil er viel eher eine unveränderte Datei anfragt, die wir noch da haben.

Warum nicht [Alternative Lösungen]?

Als ich mir das Problem das erste Mal genauer angeschaut habe, hatte ich ein paar Ansätze im Kopf. Aber so richtig passen wollte keiner davon.

Post-Build-Skripte

Mein erster Gedanke war, ein Post-Build-Skript zu schreiben, das alle Import-Pfade normalisiert, die Dateien neu hasht und die Referenzen aktualisiert. Das klang erstmal simpel: per Regex die gehashten Dateinamen durch stabile Namen ersetzen und danach die Hashes neu berechnen.

Diesen Ansatz habe ich wegen „Heisenbugs“ und Cache-Poisoning verworfen. Auch wenn wir alte Builds in Cloudflare Pages speichern, war mir das Risiko von Cache-Inkonsistenzen zu hoch. Ein Skript, das Dateien nach dem Build verändert, kann subtile Bugs einführen, die nur in Produktion auftreten, und das Debuggen wäre die Hölle.

Vite manualChunks

Eine andere Option war, Vites manualChunks-Konfiguration zu nutzen, um stabilen Code (z. B. node_modules) von instabilem Code (Business-Logik) zu trennen. Die Idee: Vendor-Code ändert sich seltener, also gibt es weniger kaskadierende Effekte.

Das löst aber nicht das eigentliche Problem, sondern dämpft es nur ein bisschen. Innerhalb deiner Business-Logic-Chunks bekommst du weiterhin kaskadierende Hashes. Ich wollte eine Lösung, die wirklich an die Wurzel geht und nicht nur die Symptome etwas mildert.

Import Maps: Die moderne Lösung

Import Maps sind ein browser-natives Feature (mit Polyfill-Support für ältere Browser), das Modul-Spezifier von Dateipfaden entkoppelt. Statt dass Datei A "./button-abc123.js" importiert, importiert sie "button". Der Browser nutzt dann die Import Map, um "button" auf den echten, gehashten Dateinamen aufzulösen.

Genau das habe ich gebraucht. Der Inhalt von Datei A bleibt identisch (sie importiert immer "button"), also bleibt auch ihr Hash gleich. Nur die Import Map und die geänderte Datei bekommen neue Hashes. Ich war ein bisschen baff, dass es dafür noch kein gutes Plugin gab!

Die Reise zur Implementierung

Ich habe mich entschieden, ein Vite-Plugin zu bauen, das:

  1. Alle relativen Importe in stabile Modul-Spezifier umwandelt
  2. Eine Import Map generiert, die diese Spezifier auf die echten gehashten Dateinamen abbildet
  3. Die Import Map ins HTML injiziert

Das Plugin ist jetzt auf GitHub verfügbar: @foony/vite-plugin-import-map

Erster Ansatz

Gestartet bin ich mit einem Vite-Plugin über den generateBundle-Hook. Mein erster Versuch hat per Regex Import-Pfade gesucht und ersetzt. Das war leicht zu coden und hat für unser kleines Team bei Foony auch funktioniert, aber es war fragil und definitiv nichts, was ich als Plugin veröffentlichen wollte, wo es jede Menge False Positives geben könnte, die dann kaputttransformiert werden.

Die Probleme mit Regex lagen auf der Hand: Was, wenn ein String im Code zufällig wie ein Dateiname aussieht? Was ist mit dynamischen Importen? Und mit Export-Statements? Ich brauchte etwas deutlich robusteres, wenn ich ein Plugin für andere bauen wollte.

AST-Parsing

Ich musste den JavaScript-Code richtig parsen, um alle Import-Statements zu finden. Mein erster Versuch war es-module-lexer, das extra fürs Parsen von ES-Modulen gebaut ist. Leider hat das während Vites Modulanalyse native Panics ausgelöst. Selbst der asm.js-Build konnte die Panics nicht verhindern.

Am Ende bin ich bei Acorn gelandet, einem schnellen, schlanken Parser in Pure-JavaScript. Zusammen mit acorn-walk für das Traversieren des ASTs hatte ich alles, was ich brauchte, ohne irgendwelche nativen Abhängigkeiten.

Wichtige gelöste Herausforderungen

Alle Import-Typen abdecken

Importe gibt es in vielen Varianten, und im AST werden sie unterschiedlich behandelt. Ich musste folgende Fälle unterstützen:

  • Statische Importe: import x from "./file.js"
  • Dynamische Importe: import("./file.js")
  • Benannte Re-Exports: export { x } from "./file.js" (den habe ich anfangs übersehen!)
  • Re-export all: export * from "./file.js"

Die Re-Export-Fälle waren besonders fies, weil ich sie erst bemerkt habe, als mir eine Datei aufgefallen ist, die gar nicht transformiert wurde. Der Code sah so aus: export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" und mein Plugin hat das komplett ignoriert, weil ich nur nach ImportDeclaration- und ImportExpression-Nodes gesucht hatte.

So behandle ich jetzt alle Varianten:

walk(ast, {
  ImportDeclaration(node: any) {
    // Statische Importe: import x from "spec"
    const specifier = node.source.value;
    // ... Transformations-Logik
  },
  ExportNamedDeclaration(node: any) {
    // Benannte Exports mit Quelle: export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... Transformations-Logik
  },
  ExportAllDeclaration(node: any) {
    // Re-export all: export * from "spec"
    if (!node.source?.value) return;
    // ... Transformations-Logik
  },
  ImportExpression(node: any) {
    // Dynamische Importe: import("spec")
    // ... Transformations-Logik
  },
});

Deterministische Konfliktauflösung

Wenn mehrere Dateien denselben Basisnamen haben (z. B. mehrere index.tsx in unterschiedlichen Verzeichnissen), muss ich sie auseinanderhalten. Ich kann sie nicht alle einfach "index" nennen.

Meine Lösung: Wenn es einen Konflikt gibt, hashe ich den ursprünglichen Source-Pfad plus den Basisnamen. Zum Beispiel wird aus src/client/games/chess/index.tsx:index ein Hash wie index-abc123. So bekommt die gleiche Datei in jedem Build denselben Modul-Spezifier, selbst wenn andere Dateien mit demselben Namen hinzukommen oder wegfallen.

Ich verwende chunk.facadeModuleId (den Entry-Point) als primären Bezeichner und falle auf chunk.moduleIds[0] zurück, wenn der nicht verfügbar ist. So bekomme ich einen stabilen Source-Pfad für einen deterministischen Hash.

Source-Map-Chaining

Wenn ich den Code transformiere, durchbreche ich die Source-Map-Kette. Die bestehende Source Map mappt vom ursprünglichen TypeScript-Quellcode über Babel und Minifier auf den aktuellen Code. Meine Transformation ist eine weitere Schicht, deshalb muss ich die Kette wiederherstellen.

Ich nutze MagicString, um meine Änderungen zu tracken und eine neue Source Map zu erzeugen. Danach merge ich sie mit der bestehenden Map, indem ich sources und sourcesContent der alten Map übernehme. So bleibt die komplette Kette erhalten: Originalquelle → (bestehende Map) → transformierter 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: Verwende die neuen Mappings, aber behalte die ursprünglichen Sources
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

Re-Hashing des transformierten Inhalts

Ich brauche stabilen Dateicontent. Dafür ersetze ich zuerst die von Vite gehashten Importe durch meine stabilen Importe und entferne dann Source-Map-Kommentare aus der Hash-Berechnung (die verweisen ja auf alte Dateinamen).

Danach berechne ich einen neuen Hash und aktualisiere sowohl den Dateinamen als auch den Eintrag in der Import Map.

Die finale Implementierung

Das Plugin arbeitet in vier Durchläufen:

  1. Count-Pass: Kollisionen erkennen, indem gezählt wird, wie viele Dateien sich einen Basisnamen teilen
  2. Map-Pass: Die Chunk-Mapping-Tabelle erstellen (gehashter Dateiname → Modul-Spezifier) und eine erste Import Map bauen
  3. Transform-Pass: Import-Pfade im Code umschreiben, Hashes neu berechnen, Source Maps aktualisieren
  4. Rename-Pass: Dateinamen im Bundle anpassen und die Import Map finalisieren

Hier ist die Kernlogik der Transformation:

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

// Code parsen, um einen AST zu bekommen
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

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

// Den AST traversieren, um alle Importe/Exporte zu finden
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, um das öffnende Anführungszeichen zu überspringen
        end: node.source.end - 1,     // -1, um das schließende Anführungszeichen zu überspringen
        replacement: moduleSpec,
      });
    }
  },
  // ... andere Node-Typen behandeln
});

// Transformationen in umgekehrter Reihenfolge anwenden, damit Positionen stimmen bleiben
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
  magicString.overwrite(transform.start, transform.end, transform.replacement);
}

Um die Import Map ins HTML zu injizieren, nutze ich Vites Tag-Injection-API statt Regex auf dem HTML:

transformIndexHtml() {
  return {
    tags: [
      {
        tag: 'script',
        attrs: {type: 'importmap'},
        children: JSON.stringify(importMap, null, 2),
        injectTo: 'head-prepend',
      },
    ],
  };
}

Das ist deutlich robuster, als zu versuchen, HTML-Tags per Regex zu matchen.

Zahlen, Daten, Fakten

Damit du ein Gefühl dafür bekommst, was das Plugin leistet:

  • ~1.000+ JavaScript-Dateien pro Build verarbeitet
  • ~2–3 Sekunden zusätzliche Build-Zeit (absolut vertretbar)
  • ~99 % weniger unnötige Hash-Änderungen (die meisten Dateien ändern sich jetzt nur, wenn sich ihr tatsächlicher Inhalt ändert)
  • ~340 Zeilen Plugin-Code (inklusive Kommentare und Fehlerbehandlung)

Das Plugin deckt alle Edge-Cases ab, auf die ich bisher gestoßen bin, und der Build-Prozess ist jetzt viel besser vorhersehbar.

Was ich dabei gelernt habe

Warum AST-Parsing unverzichtbar ist

Regex auf gebundletem Code ist riskant. Wenn ein String im Code zufällig wie ein Dateiname aussieht, wird Regex ihn umschreiben. AST-Parsing stellt sicher, dass du wirklich nur echte Import- und Export-Statements anfasst.

Warum Acorn statt es-module-lexer

es-module-lexer ist schneller und speziell für diesen Zweck gebaut, aber die nativen Panics haben es in meinem Vite-Plugin unbenutzbar gemacht. Acorn ist pures JavaScript, also gibt es keine nativen Abhängigkeiten, um die ich mir Sorgen machen müsste. Langfristig will ich mir es-module-lexer nochmal als Speed-Optimierung anschauen, aber im Moment funktioniert Acorn einfach zuverlässig.

Warum Import Maps statt Alternativen

Import Maps sind ein Web-Standard mit nativer Browser-Unterstützung. Sie sind der „richtige“ Weg, dieses Problem zu lösen. Das Polyfill (es-module-shims) kümmert sich problemlos um ältere Browser (z. B. Safari < 16.4), und die Lösung bleibt sauber und gut wartbar.

Fazit

Das Import-Maps-Plugin verhindert kaskadierende Hash-Änderungen in meinen Vite-Builds. Dateien bekommen jetzt nur dann neue Hashes, wenn sich ihr tatsächlicher Inhalt ändert, nicht mehr, wenn sich nur ihre Abhängigkeiten ändern. Das macht Builds berechenbarer, reduziert unnötige Cache-Invalidierung und hilft uns, unter dem Dateilimit von Cloudflare Pages zu bleiben.

Die Lösung ist einfach, wartbar und baut auf modernen Web-Standards auf. Ein schönes Beispiel dafür, dass die „richtige“ Lösung manchmal auch die simpelste ist – sobald man das Problem tief genug verstanden hat.

Das Plugin ist Open Source und auf GitHub verfügbar: @foony/vite-plugin-import-map. Du kannst es mit npm install @foony/vite-plugin-import-map installieren und direkt in deinen eigenen Vite-Projekten einsetzen.

Künftige Verbesserungen könnten Performance-Optimierungen mit es-module-lexer sein, sobald die nativen Panic-Probleme gelöst sind, oder Support für noch komplexere Import-Szenarien. Aber im Moment macht das Plugin genau das, was ich von ihm brauche.

Und wer weiß? Vielleicht unterstützt Vite eines Tages von Haus aus etwas in dieser Richtung.

8 Ball Pool online multiplayer billiards icon