

1/1/1970
Wie ich kaskadierende Hash-Änderungen mit Import Maps gelöst habe
Hallo zusammen! Dieses Problem begleitet mich seit über 5 Jahren, aber erst jetzt habe ich beschlossen, es anzugehen, weil es einen Punkt erreicht hat, an dem ich es nicht mehr ignorieren konnte. Wenn ich ein einziges Zeichen in einer Datei änderte, bekamen die Hälfte der JavaScript-Dateien meines Builds neue Hash-Dateinamen, obwohl sich ihr eigentlicher Inhalt nicht geändert hatte. Das verursachte unnötige Cache-Invalidierung, machte es nahezu unmöglich nachzuverfolgen, was sich tatsächlich zwischen Builds geändert hatte, und am schlimmsten: meine Cloudflare-Pages-Builds gingen wegen eines Datei-Limits kaputt.
Im Folgenden erkläre ich das Problem, warum bestehende Lösungen für mich nicht funktioniert haben und wie ich ein eigenes Vite-Plugin mit Import Maps gebaut habe, um es ein für alle Mal zu lösen.
Das Problem: Kaskadierende Hash-Änderungen
Vite verwendet inhaltsbasiertes Hashing für Production-Builds. Wenn du deine App baust, bekommt jede JavaScript-Datei einen Hash im Dateinamen, basierend auf ihrem Inhalt. Wenn button.tsx zu button-abc12345.js kompiliert wird und sich der Inhalt ändert, wird daraus button-def45678.js. Das ist großartig fürs Cache-Busting: Nutzer bekommen die neue Datei, sobald sie sich ändert.
Das Problem entsteht, wenn Datei A Datei B importiert. Nehmen wir an, du hast:
// main.js
import { Button } from "./button-abc12345.js";
Wenn sich button.tsx ändert, generiert Vite button-def45678.js. Aber jetzt ändert sich auch main.js, weil sie den String "./button-abc12345.js" enthält, der jetzt falsch ist. Also bekommt main.js ebenfalls einen neuen Hash, obwohl sich an der eigentlichen Logik in main.js gar nichts geändert hat.
Das kaskadiert durch deinen gesamten Abhängigkeitsgraphen. Ändere eine einzelne Hilfsfunktion und plötzlich bekommen die Hälfte deiner JS-Dateien neue Hashes. In meinem Fall führte die Änderung eines einzigen Zeichens in useBackgroundMusic.ts dazu, dass über 500 Dateien neu gehasht wurden.
Die Auswirkungen in der Praxis waren erheblich. Wir bündeln 8 Versionen der Assets unseres letzten Builds, damit Nutzer mit leicht veralteten Versionen unseres Clients ihre Version weiterhin nutzen können, wenn wir die neue Version auf Cloudflare Pages deployen. Cloudflare Pages hat jedoch ein Limit von 20.000 Dateien, an das wir wegen unserer i18n-Umstellung von neulich zu stoßen begannen, was die Anzahl der erzeugten Dateien explodieren ließ.
Das Lösen kaskadierender Hashes erlaubt es uns, deutlich mehr vergangene Builds zu speichern, ohne an diese Limits zu stoßen, weil sich die meisten Dateien nun nicht mehr ändern müssen. Das verringert auch die Wahrscheinlichkeit, dass ein Nutzer mit einem veralteten Build einen Fehler bekommt, da er viel wahrscheinlicher eine inzwischen unveränderte Datei anfragt, die wir noch haben.
Warum nicht [alternative Lösungen]?
Als ich das Problem zum ersten Mal anging, habe ich mir verschiedene Ansätze überlegt. Keiner davon passte so richtig.
Post-Build-Skripte
Mein erster Gedanke war, ein Post-Build-Skript zu schreiben, das alle Importpfade normalisiert, die Dateien neu hasht und die Referenzen aktualisiert. Das schien einfach: einfach per Regex die gehashten Dateinamen durch stabile Namen ersetzen und dann die Hashes neu berechnen.
Ich habe diesen Ansatz wegen Sorgen über "Heisenbugs" und Cache-Vergiftung verworfen. Auch wenn wir vergangene Builds in Cloudflare Pages speichern, war mir das Risiko von Cache-Inkonsistenzen nicht wert. Ein Skript, das Dateien nach dem Build verändert, könnte subtile Bugs einführen, die nur in der Produktion auftreten, und das Debuggen wäre ein Albtraum.
Vites manualChunks
Eine andere Option war, Vites manualChunks-Konfiguration zu verwenden, um stabilen Code (wie node_modules) von instabilem Code (Geschäftslogik) zu trennen. Die Idee war, dass sich Vendor-Code seltener ändert, sodass weniger Dateien kaskadieren würden.
Das löst das eigentliche Problem nicht, es mildert es nur ab. Innerhalb deiner Geschäftslogik-Chunks bekommst du immer noch kaskadierende Hashes. Ich wollte eine Lösung, die das Kernproblem angeht, nicht nur ein bisschen weniger schlimm macht.
Import Maps: Die moderne Lösung
Import Maps sind ein nativer Browser-Standard (mit Polyfill-Unterstützung für ältere Browser), der Modul-Bezeichner von Dateipfaden entkoppelt. Statt dass Datei A "./button-abc123.js" importiert, importiert sie "button". Der Browser nutzt die Import Map, um "button" zum tatsächlichen gehashten Dateinamen aufzulösen.
Das war genau das, was ich brauchte. 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 ehrlich gesagt schockiert, dass noch niemand ein gutes Plugin dafür gemacht hatte!
Das Vite-Plugin bauen
Ich entschied mich, ein Vite-Plugin zu bauen, das:
- Alle relativen Imports in stabile Modul-Bezeichner umwandelt
- Eine Import Map generiert, die diese Bezeichner auf die tatsächlichen gehashten Dateinamen abbildet
- Die Import Map ins HTML injiziert
Das Plugin ist jetzt auf GitHub verfügbar: @foony/vite-plugin-import-map
Erster Ansatz
Ich begann mit einem Vite-Plugin, das den generateBundle-Hook nutzt. Mein erster Versuch verwendete Regex, um Importpfade zu finden und zu ersetzen. Das war einfach zu programmieren und funktionierte für unser kleines Team Foony, war aber fragil und würde definitiv nicht in einem Plugin funktionieren, in dem es Falsch-Positive geben könnte, die mutiert werden.
Der Regex-Ansatz hatte offensichtliche Probleme: Was, wenn ein String im Code zufällig wie ein Dateiname aussieht? Was ist mit dynamischen Imports? Was mit Export-Statements? Ich brauchte eine robustere Lösung, 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, der speziell für das Parsen von ES-Modulen entwickelt wurde. Leider verursachte er native Panics während Vites Modulanalyse-Phase. Selbst der Versuch mit dem asm.js-Build half nicht gegen die Panics.
Ich entschied mich für Acorn, einen schnellen, leichtgewichtigen, reinen JavaScript-Parser. Kombiniert mit acorn-walk für die AST-Traversierung gab er mir alles, was ich brauchte, ohne die Probleme nativer Abhängigkeiten.
Wichtige gelöste Herausforderungen
Umgang mit allen Import-Typen
Imports kommen in vielen Formen vor und werden im AST unterschiedlich behandelt. Ich musste folgende Fälle abdecken:
- Statische Imports:
import x from "./file.js" - Dynamische Imports:
import("./file.js") - Benannte Re-Exporte:
export { x } from "./file.js"(den habe ich anfangs übersehen!) - Re-Export aller:
export * from "./file.js"
Der Re-Export-Fall war besonders knifflig, weil ich ihn übersehen habe, bis ich eine Datei sah, die nicht transformiert wurde. Der Code hatte export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" und mein Plugin ignorierte ihn komplett, weil ich nur nach ImportDeclaration- und ImportExpression-Knoten gesucht habe.
So behandle ich sie jetzt alle:
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
},
});
Deterministische Konfliktauflösung
Wenn mehrere Dateien denselben Basisnamen haben (z. B. mehrere index.tsx-Dateien in unterschiedlichen Verzeichnissen), muss ich sie eindeutig machen. Ich kann nicht einfach für alle "index" verwenden.
Meine Lösung: Bei einem Konflikt hashe ich den ursprünglichen Quellpfad zusammen mit dem Basisnamen. Zum Beispiel wird src/client/games/chess/index.tsx:index gehasht zu index-abc123. Das stellt sicher, dass dieselbe Datei über Builds hinweg immer denselben Modul-Bezeichner bekommt, selbst wenn andere Dateien mit demselben Namen hinzugefügt oder entfernt werden.
Ich verwende chunk.facadeModuleId (den Einstiegspunkt) als primären Identifier und falle auf chunk.moduleIds[0] zurück, falls dieser nicht verfügbar ist. Das gibt mir einen stabilen Quellpfad fürs deterministische Hashing.
Source-Map-Verkettung
Wenn ich den Code transformiere, breche ich die Source-Map-Kette. Die existierende Source Map verbindet die ursprüngliche TypeScript-Quelle über Babel und Minifizierung mit dem aktuellen Code. Meine Transformationen fügen eine weitere Schicht hinzu, also muss ich diese Kette erhalten.
Ich verwende MagicString, um meine Transformationen zu verfolgen und eine neue Source Map zu generieren. Dann führe ich sie mit der existierenden Map zusammen, indem ich die ursprünglichen sources- und sourcesContent-Arrays beibehalte. Das erhält die volle Kette: Original-Quelle → (existierende 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: use new map's mappings but preserve original sources
chunk.map = {
...newMap,
sources: existingMap.sources || newMap.sources,
sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
file: newFileName,
};
Neu-Hashing transformierter Inhalte
Ich brauche stabile Dateiinhalte. Dazu transformiere ich die Imports (ersetze Vites gehashte Imports durch meine stabilen Imports) und entferne dann Source-Map-Kommentare aus der Hash-Berechnung (sie verweisen 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 verwendet eine Vier-Pass-Strategie:
- Zähl-Pass: Erkennt Namenskollisionen, indem gezählt wird, wie viele Dateien sich einen Basisnamen teilen
- Map-Pass: Erstellt das Chunk-Mapping (gehashter Dateiname → Modul-Bezeichner) und die initiale Import Map
- Transform-Pass: Schreibt Importpfade im Code um, berechnet Hashes neu, aktualisiert Source Maps
- Rename-Pass: Aktualisiert Bundle-Dateinamen und finalisiert die Import Map
Hier ist die Kerntransformations-Logik:
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);
}
Zum Injizieren der Import Map ins HTML verwende ich Vites Tag-Injection-API anstatt Regex-Manipulation:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Das ist deutlich zuverlässiger, als zu versuchen, HTML-Tags per Regex zu matchen.
In Zahlen
Damit du ein Gefühl bekommst, was dieses Plugin leistet:
- ~1.000+ verarbeitete JavaScript-Dateien pro Build
- ~2-3 Sekunden zusätzliche Build-Zeit (akzeptabler Kompromiss)
- ~99% Reduktion unnötiger Hash-Änderungen (die meisten Dateien ändern sich jetzt nur noch, wenn sich ihr tatsächlicher Inhalt ändert)
- ~340 Zeilen Plugin-Code (inklusive Kommentaren und Fehlerbehandlung)
Das Plugin behandelt alle Edge Cases, die mir bisher begegnet sind, und der Build-Prozess ist nun deutlich vorhersehbarer.
Erkenntnisse
Warum AST-Parsing essenziell ist
Regex auf gebündeltem Code ist gefährlich. Wenn ein String in deinem Code zufällig wie ein Dateiname aussieht, schreibt Regex ihn um. AST-Parsing stellt sicher, dass du nur tatsächliche Import/Export-Statements transformierst.
Warum Acorn statt es-module-lexer
es-module-lexer ist schneller und zweckgerichteter, aber die nativen Panic-Probleme machten ihn in meinem Vite-Plugin-Kontext unbrauchbar. Acorn ist reines JavaScript, was bedeutet, dass man sich keine Sorgen um native Abhängigkeiten machen muss. Ich werde mir es-module-lexer in der Zukunft als Geschwindigkeitsoptimierung ansehen, aber für den Moment funktioniert Acorn perfekt.
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. Der Polyfill (es-module-shims) handhabt ältere Browser (z. B. Safari < 16.4) elegant, und die Lösung ist sauber und wartbar.
Fazit
Das Import-Maps-Plugin verhindert erfolgreich kaskadierende Hash-Änderungen in meinen Vite-Builds. Dateien bekommen jetzt nur noch neue Hashes, wenn sich ihr eigentlicher Inhalt ändert, nicht wenn sich ihre Abhängigkeiten ändern. Das macht Builds vorhersehbarer, reduziert unnötige Cache-Invalidierung und hilft uns, unter den Datei-Limits von Cloudflare Pages zu bleiben.
Die Lösung ist einfach, wartbar und nutzt moderne Web-Standards. Sie ist ein gutes Beispiel dafür, dass die "richtige" Lösung manchmal auch die einfachste ist, sobald du das Problem tief genug verstehst, um sie zu erkennen.
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 in deinen eigenen Vite-Projekten verwenden.
Zukünftige Verbesserungen könnten eine Optimierung mit es-module-lexer sein, sobald die nativen Panic-Probleme behoben sind, oder Unterstützung für komplexere Import-Szenarien. Aber für den Moment macht das Plugin genau das, was ich brauche.
Und wer weiß? Vielleicht unterstützt Vite so etwas eines Tages nativ.
(Update: Nachdem ich das Plugin im Build von Foony ausprobiert habe, hatten einige Nutzer unerwartete Probleme, also habe ich es vorerst deaktiviert. Ich werde es später noch einmal angehen. Vielleicht. Ich finde es trotzdem eine schicke Lösung.)