

1/1/1970
Πώς έλυσα τις αλυσιδωτές αλλαγές hash με Import Maps
Γεια χαρά! Έχω αυτό το πρόβλημα πάνω από 5 χρόνια τώρα, αλλά μόλις τώρα αποφάσισα να το πάρω απόφαση γιατί έφτασε σε σημείο που δεν γινόταν άλλο να το αγνοώ. Όταν άλλαζα έναν μόνο χαρακτήρα σε ένα αρχείο, τα μισά αρχεία JavaScript στο build μου έπαιρναν καινούργια ονόματα με hash, παρόλο που το πραγματικό τους περιεχόμενο δεν είχε αλλάξει. Αυτό προκαλούσε άχρηστο άδειασμα cache, έκανε σχεδόν αδύνατο να δω τι πραγματικά άλλαξε από build σε build και, το χειρότερο από όλα, έσπαγε τα Cloudflare Pages builds μου εξαιτίας ενός ορίου αρχείων.
Παρακάτω θα εξηγήσω το πρόβλημα, γιατί οι υπάρχουσες λύσεις δεν δούλεψαν για μένα και πώς έφτιαξα ένα custom Vite plugin με χρήση Import Maps για να το λύσω μια και καλή.
Το πρόβλημα: Αλυσιδωτές αλλαγές hash
Το Vite χρησιμοποιεί content-based hashing για production builds. Όταν κάνεις build την εφαρμογή σου, κάθε αρχείο JavaScript παίρνει ένα hash στο όνομα του αρχείου, βασισμένο στο περιεχόμενό του. Αν το button.tsx γίνει compile σε button-abc12345.js και το περιεχόμενο αλλάξει, γίνεται button-def45678.js. Αυτό είναι τέλειο για cache busting, οι χρήστες παίρνουν το καινούργιο αρχείο όταν αλλάζει.
Το πρόβλημα ξεκινάει όταν το Α αρχείο κάνει import το Β αρχείο. Ας πούμε ότι έχεις:
// main.js
import { Button } from "./button-abc12345.js";
Όταν το button.tsx αλλάξει, το Vite φτιάχνει το button-def45678.js. Αλλά τώρα αλλάζει και το main.js γιατί περιέχει το string "./button-abc12345.js", που πλέον είναι λάθος. Οπότε και το main.js παίρνει καινούργιο hash, παρόλο που η πραγματική λογική μέσα στο main.js δεν άλλαξε καθόλου.
Αυτό συνεχίζεται αλυσιδωτά σε όλο το dependency graph σου. Αλλάζεις μια μικρή utility function και ξαφνικά τα μισά σου js αρχεία παίρνουν καινούργια hashes. Στη δική μου περίπτωση, αλλάζοντας έναν χαρακτήρα στο useBackgroundMusic.ts προκάλεσα πάνω από 500 αρχεία να πάρουν νέο hash.
Στον πραγματικό κόσμο, αυτό είχε μεγάλη επίπτωση. Πακετάρουμε 8 εκδόσεις από τα assets του προηγούμενου build μας, ώστε χρήστες που είναι σε λίγο παλιότερες εκδόσεις του client να μπορούν ακόμα να τρέχουν τη δική τους έκδοση όταν κάνουμε deploy τη νέα έκδοση στο Cloudflare Pages. Όμως το Cloudflare Pages έχει όριο 20.000 αρχεία, το οποίο αρχίσαμε να χτυπάμε εξαιτίας της προηγούμενης αλλαγής μας στο i18n, που εκτόξευσε το πόσα αρχεία δημιουργούμε.
Λύνοντας τα cascading hashes μπορούμε να αποθηκεύουμε πολύ περισσότερα παλιά builds χωρίς να φτάνουμε αυτά τα όρια, γιατί πλέον τα περισσότερα αρχεία δεν χρειάζεται να αλλάζουν. Αυτό επίσης μειώνει την πιθανότητα ένας χρήστης σε παλιό build να σκάσει σε error, αφού είναι πολύ πιο πιθανό να ζητήσει ένα αρχείο που πλέον δεν αλλάζει και τυχαίνει να το έχουμε ακόμα.
Γιατί όχι [Alternative Solutions];
Όταν άρχισα να το ψάχνω, σκέφτηκα μερικές προσεγγίσεις. Καμία δεν ταίριαξε πραγματικά.
Post-build scripts
Η πρώτη μου σκέψη ήταν να γράψω ένα post-build script που θα έκανε normalize όλα τα import paths, θα ξανα-έκανε hash τα αρχεία και θα ενημέρωνε τα references. Φαινόταν απλό, απλά ένα regex replace στα hashed filenames με σταθερά ονόματα και μετά ξανά υπολογισμός των hashes.
Άφησα αυτή την προσέγγιση στην άκρη λόγω των Heisenbugs και του κινδύνου για cache poisoning. Παρόλο που αποθηκεύουμε παλιά builds στο Cloudflare Pages, ο κίνδυνος να έχουμε ασυνέπειες στην cache δεν άξιζε. Ένα script που αλλάζει αρχεία μετά το build μπορεί να δημιουργήσει ύπουλα bugs που εμφανίζονται μόνο σε production, και το debugging εκεί θα ήταν σκέτος εφιάλτης.
Vite manualChunks
Μια άλλη επιλογή ήταν να χρησιμοποιήσω τη ρύθμιση manualChunks του Vite για να χωρίσω σταθερό κώδικα (όπως node_modules) από τον πιο «ασταθή» κώδικα (business logic). Η ιδέα ήταν ότι ο vendor κώδικας αλλάζει πιο σπάνια, οπότε λιγότερα αρχεία θα επηρεάζονται αλυσιδωτά.
Αυτό όμως δεν λύνει πραγματικά το βασικό πρόβλημα, απλώς το μετριάζει. Συνεχίζεις να έχεις αλυσιδωτά hashes μέσα στα chunks της business logic σου. Ήθελα μια λύση που να πιάνει το πρόβλημα στη ρίζα του, όχι κάτι που απλώς το κάνει ελαφρώς λιγότερο κακό.
Import Maps: Η σύγχρονη λύση
Τα Import Maps είναι ένα browser-native feature (με polyfill υποστήριξη για παλιότερους browsers) που αποσυνδέει τα module specifiers από τα file paths. Αντί το Α αρχείο να κάνει import "./button-abc123.js", κάνει import "button". Ο browser χρησιμοποιεί το import map για να αντιστοιχίσει το "button" στο πραγματικό όνομα αρχείου με hash.
Αυτό ήταν ακριβώς αυτό που χρειαζόμουν. Το περιεχόμενο του Α αρχείου μένει ίδιο (πάντα κάνει import "button"), οπότε το hash του μένει ίδιο. Μόνο το import map και το πραγματικά αλλαγμένο αρχείο παίρνουν καινούργιο hash. Ειλικρινά απόρησα που δεν υπήρχε ήδη κάποιο καλό plugin για αυτό!
Το ταξίδι της υλοποίησης
Αποφάσισα να φτιάξω ένα Vite plugin που θα:
- Μετατρέπει όλα τα relative imports ώστε να χρησιμοποιούν σταθερά module specifiers
- Δημιουργεί ένα import map που χαρτογραφεί αυτά τα specifiers στα πραγματικά filenames με hash
- Κάνει inject το import map στο HTML
Το plugin είναι πλέον διαθέσιμο στο GitHub: @foony/vite-plugin-import-map
Πρώτη προσέγγιση
Ξεκίνησα με ένα Vite plugin χρησιμοποιώντας το hook generateBundle. Η πρώτη μου προσπάθεια χρησιμοποίησε regex για να βρω και να αντικαταστήσω τα import paths. Αυτό ήταν εύκολο να γραφτεί και δούλευε για τη μικρή μας ομάδα Foony, αλλά ήταν εύθραυστο και σίγουρα δεν θα δούλευε ως plugin, όπου μπορεί να υπάρχουν false-positives που θα αλλοιώνονται.
Η προσέγγιση με regex είχε προφανή προβλήματα: τι γίνεται αν ένα string στον κώδικα μοιάζει τυχαία με filename; Τι γίνεται με dynamic imports; Τι γίνεται με export statements; Χρειαζόμουν κάτι πολύ πιο στιβαρό αν επρόκειτο να το δώσω σε άλλους ως plugin.
AST parsing
Έπρεπε να κάνω κανονικό parsing του JavaScript κώδικα για να βρω όλα τα import statements. Η πρώτη μου προσπάθεια ήταν με το es-module-lexer, που είναι φτιαγμένο ειδικά για parsing ES modules. Δυστυχώς, προκαλούσε native panics κατά τη φάση ανάλυσης modules του Vite. Ακόμα και η asm.js έκδοση δεν σταμάτησε τα panics.
Κατέληξα στο Acorn, έναν γρήγορο, ελαφρύ, pure JavaScript parser. Σε συνδυασμό με το acorn-walk για AST traversal, μου έδωσε ό,τι χρειαζόμουν χωρίς προβλήματα με native dependencies.
Τα βασικά προβλήματα που λύθηκαν
Υποστήριξη όλων των τύπων imports
Τα imports εμφανίζονται σε πολλές μορφές και στο AST αντιμετωπίζονται διαφορετικά. Έπρεπε να καλύψω:
- Static imports:
import x from "./file.js" - Dynamic imports:
import("./file.js") - Named re-exports:
export { x } from "./file.js"(αυτό το έχασα στην αρχή!) - Re-export all:
export * from "./file.js"
Η περίπτωση των re-exports ήταν ιδιαίτερα tricky, γιατί την έχασα μέχρι που είδα ένα αρχείο που δεν μετατρεπόταν. Ο κώδικας είχε export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" και το plugin μου το αγνοούσε εντελώς επειδή έψαχνα μόνο για ImportDeclaration και ImportExpression nodes.
Τώρα τα χειρίζομαι όλα έτσι:
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
},
});
Ντετερμινιστική επίλυση συγκρούσεων
Όταν πολλά αρχεία έχουν το ίδιο base name (όπως πολλά index.tsx σε διαφορετικούς φακέλους), πρέπει να τα ξεχωρίζω. Δεν μπορώ απλά να χρησιμοποιήσω "index" για όλα.
Η λύση μου: αν υπάρχει σύγκρουση, κάνω hash το αρχικό source path μαζί με το base name. Για παράδειγμα, το src/client/games/chess/index.tsx:index γίνεται hash σε κάτι σαν index-abc123. Έτσι εξασφαλίζω ότι το ίδιο αρχείο παίρνει πάντα τον ίδιο module specifier σε όλα τα builds, ακόμα κι αν προστεθούν ή αφαιρεθούν άλλα αρχεία με το ίδιο όνομα.
Χρησιμοποιώ το chunk.facadeModuleId (το entry point) σαν κύριο αναγνωριστικό και αν αυτό δεν υπάρχει, πέφτω στο chunk.moduleIds[0]. Αυτό μου δίνει ένα σταθερό source path για ντετερμινιστικό hashing.
Chaining των source maps
Όταν μετασχηματίζω τον κώδικα, «σπάω» την αλυσίδα των source maps. Το υπάρχον source map χαρτογραφεί από το αρχικό TypeScript source μέσω Babel και minification στον τωρινό κώδικα. Οι δικοί μου μετασχηματισμοί προσθέτουν άλλο ένα επίπεδο, άρα πρέπει να κρατήσω αυτή την αλυσίδα.
Χρησιμοποιώ το MagicString για να παρακολουθώ τους μετασχηματισμούς μου και να φτιάχνω ένα νέο source map. Μετά το συγχωνεύω με το υπάρχον map, κρατώντας τα αρχικά sources και sourcesContent. Έτσι διατηρείται όλη η αλυσίδα: Original Source → (existing map) → Transformed 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,
};
Ξανα-υπολογισμός hash στο μετασχηματισμένο περιεχόμενο
Χρειάζομαι σταθερό περιεχόμενο αρχείων. Για να το πετύχω, μετασχηματίζω τα imports (αντικαθιστώ τα hashed imports του Vite με τα δικά μου σταθερά imports) και μετά αφαιρώ τα source map comments από τον υπολογισμό του hash (γιατί αναφέρονται σε παλιά filenames).
Μετά από αυτό, υπολογίζω ένα καινούργιο hash και ενημερώνω και το όνομα του αρχείου και την αντίστοιχη εγγραφή στο import map.
Η τελική υλοποίηση
Το plugin χρησιμοποιεί στρατηγική τεσσάρων περασμάτων:
- Count pass: Εντοπίζει συγκρούσεις ονομάτων μετρώντας πόσα αρχεία μοιράζονται το ίδιο base name
- Map pass: Δημιουργεί το chunk mapping (hashed filename → module specifier) και το αρχικό import map
- Transform pass: Ξαναγράφει τα import paths στον κώδικα, ξανα-υπολογίζει hashes, ενημερώνει τα source maps
- Rename pass: Ενημερώνει τα bundle filenames και ολοκληρώνει το import map
Να το βασικό κομμάτι του transformation logic:
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);
}
Για να κάνω inject το import map στο HTML, χρησιμοποιώ το tag injection API του Vite αντί για regex πάνω στο HTML:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Αυτό είναι πολύ πιο αξιόπιστο από το να προσπαθώ να ταιριάξω HTML tags με regex.
Με αριθμούς
Για να πάρεις μια ιδέα τι κάνει αυτό το plugin:
- ~1.000+ αρχεία JavaScript επεξεργασμένα ανά build
- ~2-3 δευτερόλεπτα επιπλέον στον χρόνο build (δεκτό trade-off)
- ~99% μείωση στις άχρηστες αλλαγές hash (τα περισσότερα αρχεία αλλάζουν μόνο όταν αλλάζει πραγματικά το περιεχόμενό τους)
- ~340 γραμμές κώδικα plugin (μαζί με σχόλια και error handling)
Το plugin χειρίζεται όλα τα edge cases που έχω συναντήσει μέχρι τώρα και η διαδικασία build είναι πλέον πολύ πιο προβλέψιμη.
Τι έμαθα
Γιατί το AST parsing είναι απαραίτητο
Regex πάνω σε bundled κώδικα είναι επικίνδυνο. Αν ένα string στον κώδικα τυχαίνει να μοιάζει με filename, το regex θα το αλλάξει. Το AST parsing εξασφαλίζει ότι μετατρέπεις μόνο πραγματικά import/export statements.
Γιατί Acorn αντί για es-module-lexer
Το es-module-lexer είναι πιο γρήγορο και φτιαγμένο ακριβώς για αυτό, αλλά τα native panics το έκαναν άχρηστο στο context του Vite plugin μου. Το Acorn είναι pure JavaScript, άρα δεν έχεις native dependencies να σε ανησυχούν. Θέλω στο μέλλον να ξανακοιτάξω το es-module-lexer ως βελτιστοποίηση ταχύτητας, αλλά προς το παρόν το Acorn κάνει τη δουλειά τέλεια.
Γιατί Import Maps αντί για άλλες λύσεις
Τα Import Maps είναι web standard με native υποστήριξη στους browsers. Είναι ο «σωστός» τρόπος να λυθεί αυτό το πρόβλημα. Το polyfill (es-module-shims) καλύπτει άνετα παλιότερους browsers (π.χ. Safari < 16.4) και η λύση είναι καθαρή και εύκολη στη συντήρηση.
Συμπέρασμα
Το Import Maps plugin σταμάτησε τις αλυσιδωτές αλλαγές hash στα Vite builds μου. Τα αρχεία πλέον παίρνουν καινούργιο hash μόνο όταν αλλάζει πραγματικά το περιεχόμενό τους, όχι όταν αλλάζουν τα dependencies τους. Αυτό κάνει τα builds πιο προβλέψιμα, μειώνει το άχρηστο άδειασμα cache και μας βοηθά να μένουμε κάτω από τα limits αρχείων του Cloudflare Pages.
Η λύση είναι απλή, εύκολη στη συντήρηση και βασίζεται σε σύγχρονα web standards. Είναι ένα καλό παράδειγμα του πώς μερικές φορές η «σωστή» λύση είναι και η πιο απλή, μόλις καταλάβεις αρκετά βαθιά το πρόβλημα ώστε να τη δεις.
Το plugin είναι open source και διαθέσιμο στο GitHub: @foony/vite-plugin-import-map. Μπορείς να το εγκαταστήσεις με npm install @foony/vite-plugin-import-map και να το χρησιμοποιήσεις στα δικά σου Vite projects.
Βελτιώσεις στο μέλλον μπορεί να περιλαμβάνουν βελτιστοποίηση με es-module-lexer όταν λυθούν τα native panic issues ή υποστήριξη για ακόμα πιο πολύπλοκα σενάρια imports. Αλλά προς το παρόν, το plugin κάνει ακριβώς αυτό που χρειάζομαι.
Και ποιος ξέρει; Ίσως κάποια μέρα το Vite να υποστηρίζει κάτι τέτοιο εγγενώς.