background blurbackground mobile blur

1/1/1970

Πώς Έλυσα τις Αλυσιδωτές Αλλαγές Hash με τα Import Maps

Γεια χαρά! Είχα αυτό το πρόβλημα εδώ και 5+ χρόνια, αλλά μόνο τώρα αποφάσισα να το αντιμετωπίσω επειδή έφτασε σε ένα σημείο που δεν μπορούσα πια να το αγνοήσω. Όταν άλλαζα έναν μόνο χαρακτήρα σε ένα αρχείο, τα μισά JavaScript αρχεία στο build μου αποκτούσαν νέα hashed ονόματα, παρόλο που το πραγματικό τους περιεχόμενο δεν είχε αλλάξει. Αυτό προκαλούσε περιττή ακύρωση cache, καθιστούσε σχεδόν αδύνατο να παρακολουθήσω τι πραγματικά άλλαξε μεταξύ των builds, και το χειρότερο: έσπαγε τα Cloudflare Pages builds μου εξαιτίας ενός ορίου αρχείων.

Παρακάτω θα αναλύσω το πρόβλημα, γιατί οι υπάρχουσες λύσεις δεν δούλευαν για μένα, και πώς έφτιαξα ένα custom Vite plugin χρησιμοποιώντας Import Maps για να το λύσω μια για πάντα.

Το Πρόβλημα: Αλυσιδωτές Αλλαγές Hash

Το Vite χρησιμοποιεί content-based hashing για production builds. Όταν κάνεις build την εφαρμογή σου, κάθε JavaScript αρχείο παίρνει ένα hash στο όνομά του βάσει του περιεχομένου του. Αν το button.tsx μεταγλωττίζεται σε 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 δεν άλλαξε καθόλου.

Αυτό κάνει αλυσιδωτή αντίδραση σε όλο το γράφημα εξαρτήσεών σου. Άλλαξε μια utility function, και ξαφνικά τα μισά js αρχεία σου παίρνουν νέα hashes. Στην περίπτωσή μου, η αλλαγή ενός χαρακτήρα στο useBackgroundMusic.ts προκάλεσε re-hash σε πάνω από 500 αρχεία.

Ο αντίκτυπος στην πραγματικότητα ήταν σημαντικός. Συγκεντρώνουμε 8 εκδόσεις των assets των προηγούμενων builds μας ώστε οι χρήστες με ελαφρώς ξεπερασμένες εκδόσεις του client μας να μπορούν ακόμη να τρέχουν την έκδοσή τους όταν κάνουμε deploy τη νέα έκδοση στα Cloudflare Pages. Ωστόσο, τα Cloudflare Pages έχουν ένα όριο 20.000 αρχείων το οποίο αρχίσαμε να χτυπάμε λόγω της πρόσφατης αλλαγής μας στο i18n που εκτόξευσε τον αριθμό των αρχείων που δημιουργούμε.

Η επίλυση των αλυσιδωτών hashes μάς επιτρέπει να αποθηκεύουμε πολύ περισσότερα προηγούμενα builds χωρίς να χτυπάμε αυτά τα όρια, επειδή τώρα τα περισσότερα αρχεία δεν χρειάζεται πλέον να αλλάζουν. Αυτό μειώνει επίσης την πιθανότητα ένας χρήστης σε ξεπερασμένο build να βγάλει σφάλμα, αφού είναι πολύ πιο πιθανό να ζητάει ένα τώρα-αμετάβλητο αρχείο που τυχαίνει να έχουμε.

Γιατί Όχι [Εναλλακτικές Λύσεις];

Όταν πρωτοκοίταξα να λύσω αυτό, εξέτασα μερικές προσεγγίσεις. Καμία δεν ταίριαζε ακριβώς.

Post-build Scripts

Η αρχική μου σκέψη ήταν να γράψω ένα post-build script που θα κανονικοποιούσε όλα τα import paths, θα ξανα-υπολόγιζε τα hashes των αρχείων, και θα ενημέρωνε τις αναφορές. Φαινόταν απλό: απλό regex replace στα hashed ονόματα με σταθερά ονόματα, μετά επανυπολογισμός των hashes.

Απέρριψα αυτή την προσέγγιση εξαιτίας ανησυχιών για "Heisenbugs" και cache poisoning. Παρόλο που αποθηκεύουμε προηγούμενα builds στα Cloudflare Pages, το ρίσκο των ασυνεπειών cache δεν άξιζε. Ένα script που τροποποιεί αρχεία μετά το build θα μπορούσε να εισάγει διακριτικά bugs που εμφανίζονται μόνο σε production, και το debugging αυτών θα ήταν εφιάλτης.

Vite manualChunks

Μια άλλη επιλογή ήταν να χρησιμοποιήσω το manualChunks configuration του Vite για να διαχωρίσω σταθερό κώδικα (όπως το node_modules) από ασταθή κώδικα (business logic). Η ιδέα ήταν ότι ο vendor κώδικας θα άλλαζε λιγότερο συχνά, οπότε λιγότερα αρχεία θα έκαναν αλυσιδωτές αλλαγές.

Αυτό δεν λύνει στην πραγματικότητα το βασικό πρόβλημα, απλώς το μετριάζει. Ακόμη παίρνεις αλυσιδωτά hashes μέσα στα business logic chunks σου. Ήθελα μια λύση που να αντιμετωπίζει το θεμελιώδες πρόβλημα, όχι απλώς να το κάνει ελαφρώς λιγότερο κακό.

Import Maps: Η Σύγχρονη Λύση

Τα Import Maps είναι ένα native χαρακτηριστικό του browser (με υποστήριξη polyfill για παλαιότερους browsers) που αποσυνδέει τα module specifiers από τα file paths. Αντί το Αρχείο Α να κάνει import το "./button-abc123.js", κάνει import το "button". Ο browser χρησιμοποιεί το import map για να αναλύσει το "button" στο πραγματικό hashed όνομα αρχείου.

Αυτό ήταν ακριβώς αυτό που χρειαζόμουν. Το περιεχόμενο του Αρχείου Α παραμένει ίδιο (κάνει πάντα import το "button"), οπότε το hash του παραμένει ίδιο. Μόνο το import map και το αλλαγμένο αρχείο παίρνουν νέα hashes. Έμεινα κάπως άναυδος που κανείς δεν είχε φτιάξει ήδη ένα καλό plugin για αυτό!

Φτιάχνοντας το Vite Plugin

Αποφάσισα να φτιάξω ένα Vite plugin που θα:

  1. Μετασχηματίζει όλα τα relative imports ώστε να χρησιμοποιούν σταθερά module specifiers
  2. Δημιουργεί ένα import map που αντιστοιχίζει αυτά τα specifiers στα πραγματικά hashed ονόματα αρχείων
  3. Εισάγει το import map στο HTML

Το plugin είναι τώρα διαθέσιμο στο GitHub: @foony/vite-plugin-import-map

Αρχική Προσέγγιση

Ξεκίνησα με ένα Vite plugin χρησιμοποιώντας το hook generateBundle. Η πρώτη μου προσπάθεια χρησιμοποίησε regex για να βρει και να αντικαταστήσει import paths. Αυτό ήταν εύκολο να κωδικοποιηθεί και δούλεψε για τη μικρή μας ομάδα στο Foony, αλλά ήταν εύθραυστο και σίγουρα δεν θα δούλευε σε ένα plugin όπου μπορεί να υπάρχουν false-positives που μεταλλάσσονται.

Η προσέγγιση με regex είχε προφανή προβλήματα: τι γίνεται αν ένα string στον κώδικα τυχαίνει να μοιάζει με όνομα αρχείου; Τι γίνεται με τα dynamic imports; Τι γίνεται με τα export statements; Χρειαζόμουν μια πιο ανθεκτική λύση αν ήταν να φτιάξω ένα plugin για άλλους.

AST Parsing

Χρειαζόμουν να αναλύσω σωστά τον JavaScript κώδικα για να βρω όλα τα import statements. Η πρώτη μου προσπάθεια ήταν το es-module-lexer, που έχει σχεδιαστεί ειδικά για το parsing ES modules. Δυστυχώς, προκαλούσε native panics κατά τη φάση ανάλυσης module του Vite. Ακόμη και η δοκιμή του asm.js build δεν βοήθησε να σταματήσουν τα panics.

Κατέληξα στο Acorn, έναν γρήγορο, ελαφρύ, καθαρό JavaScript parser. Σε συνδυασμό με το acorn-walk για AST traversal, μου έδωσε όλα όσα χρειαζόμουν χωρίς τα προβλήματα native εξαρτήσεων.

Βασικές Προκλήσεις που Λύθηκαν

Διαχείριση Όλων των Τύπων 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-export ήταν ιδιαίτερα δύσκολη γιατί μου ξέφυγε μέχρι που είδα ένα αρχείο που δεν μετασχηματιζόταν. Ο κώδικας είχε export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" και το plugin μου το αγνοούσε εντελώς γιατί κοιτούσα μόνο για κόμβους ImportDeclaration και ImportExpression.

Ορίστε πώς τα χειρίζομαι όλα τώρα:

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) ως το πρωτεύον identifier, με fallback στο chunk.moduleIds[0] αν αυτό δεν είναι διαθέσιμο. Αυτό μου δίνει ένα σταθερό source path για ντετερμινιστικό hashing.

Source Map Chaining

Όταν μετασχηματίζω τον κώδικα, σπάω την αλυσίδα του source map. Το υπάρχον source map αντιστοιχίζει από την αρχική TypeScript πηγή μέσω Babel και minification στον τρέχοντα κώδικα. Οι μετασχηματισμοί μου προσθέτουν ένα ακόμη επίπεδο, οπότε χρειάζεται να διατηρήσω αυτή την αλυσίδα.

Χρησιμοποιώ το MagicString για να παρακολουθώ τους μετασχηματισμούς μου και να δημιουργήσω ένα νέο source map. Στη συνέχεια, το συγχωνεύω με τον υπάρχοντα χάρτη διατηρώντας τους αρχικούς πίνακες sources και sourcesContent. Αυτό διατηρεί την πλήρη αλυσίδα: Αρχική Πηγή → (υπάρχων χάρτης) → Μετασχηματισμένος Κώδικας.

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 Μετασχηματισμένου Περιεχομένου

Χρειάζομαι σταθερό περιεχόμενο αρχείων. Για να το κάνω αυτό, μετασχηματίζω τα imports (αντικαθιστώντας τα hashed imports του Vite με τα δικά μου σταθερά imports), και στη συνέχεια αφαιρώ τα source map comments από τον υπολογισμό του hash (αναφέρονται σε παλιά ονόματα αρχείων).

Μετά από αυτό, υπολογίζω ένα νέο hash και ενημερώνω και το όνομα αρχείου και την εγγραφή του import map.

Η Τελική Υλοποίηση

Το plugin χρησιμοποιεί μια στρατηγική τεσσάρων περασμάτων:

  1. Πέρασμα μέτρησης: Εντόπισε συγκρούσεις ονομάτων μετρώντας πόσα αρχεία μοιράζονται κάθε base name
  2. Πέρασμα χάρτη: Δημιούργησε το chunk mapping (hashed όνομα αρχείου → module specifier) και το αρχικό import map
  3. Πέρασμα μετασχηματισμού: Ξαναγράψε τα import paths στον κώδικα, επανυπολόγισε τα hashes, ενημέρωσε τα source maps
  4. Πέρασμα μετονομασίας: Ενημέρωσε τα ονόματα αρχείων του bundle και οριστικοποίησε το import map

Ορίστε η βασική λογική μετασχηματισμού:

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);
}

Για την εισαγωγή του import map στο HTML, χρησιμοποιώ το tag injection API του Vite αντί για χειρισμό regex:

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

Αυτό είναι πολύ πιο αξιόπιστο από το να προσπαθείς να κάνεις regex match HTML tags.

Με Νούμερα

Για να σου δώσω μια αίσθηση του τι κάνει αυτό το plugin:

  • ~1.000+ JavaScript αρχεία επεξεργάζονται ανά build
  • ~2-3 δευτερόλεπτα προστίθενται στον χρόνο build (αποδεκτό trade-off)
  • ~99% μείωση σε περιττές αλλαγές hash (τα περισσότερα αρχεία τώρα αλλάζουν μόνο όταν το πραγματικό τους περιεχόμενο αλλάζει)
  • ~340 γραμμές κώδικα plugin (συμπεριλαμβανομένων σχολίων και χειρισμού σφαλμάτων)

Το plugin χειρίζεται όλες τις edge cases που έχω συναντήσει μέχρι στιγμής, και η διαδικασία build είναι τώρα πολύ πιο προβλέψιμη.

Μαθήματα που Έμαθα

Γιατί το AST parsing είναι απαραίτητο

Το regex σε bundled κώδικα είναι επικίνδυνο. Αν ένα string στον κώδικά σου τυχαίνει να μοιάζει με όνομα αρχείου, το regex θα το ξαναγράψει. Το AST parsing διασφαλίζει ότι μετασχηματίζεις μόνο πραγματικά import/export statements.

Γιατί Acorn αντί για es-module-lexer

Το es-module-lexer είναι πιο γρήγορο και πιο εξειδικευμένο, αλλά τα ζητήματα native panic το έκαναν αχρησιμοποίητο στο πλαίσιο του Vite plugin μου. Το Acorn είναι καθαρό JavaScript, που σημαίνει ότι δεν χρειάζεται να ανησυχείς για native εξαρτήσεις. Θα ήθελα να δω το es-module-lexer στο μέλλον ως βελτιστοποίηση ταχύτητας, αλλά προς το παρόν το Acorn δουλεύει τέλεια.

Γιατί τα Import Maps αντί για εναλλακτικές

Τα Import Maps είναι ένα web standard με native browser support. Είναι ο "σωστός" τρόπος να λυθεί αυτό το πρόβλημα. Το polyfill (es-module-shims) χειρίζεται παλαιότερους browsers (π.χ. Safari < 16.4) με χάρη, και η λύση είναι καθαρή και συντηρήσιμη.

Συμπέρασμα

Το plugin Import Maps εμποδίζει επιτυχώς τις αλυσιδωτές αλλαγές hash στα Vite builds μου. Τα αρχεία τώρα παίρνουν νέα hashes μόνο όταν το πραγματικό τους περιεχόμενο αλλάζει, όχι όταν αλλάζουν οι εξαρτήσεις τους. Αυτό κάνει τα builds πιο προβλέψιμα, μειώνει την περιττή ακύρωση cache και μας βοηθά να παραμένουμε κάτω από τα όρια αρχείων των 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, ή προσθήκη υποστήριξης για πιο σύνθετα σενάρια imports. Αλλά προς το παρόν, το plugin κάνει ακριβώς αυτό που χρειάζομαι.

Και ποιος ξέρει; Ίσως κάποια μέρα το Vite να υποστηρίζει κάτι τέτοιο natively.

(Ενημέρωση: Αφού δοκίμασα το plugin στο build του Foony, κάποιοι χρήστες αντιμετώπιζαν απρόσμενα προβλήματα, οπότε το απενεργοποίησα προς το παρόν. Θα το ξαναδώ αργότερα. Ίσως. Εξακολουθώ να πιστεύω ότι αυτή είναι μια όμορφη λύση.)

8 Ball Pool online multiplayer billiards icon