background blurbackground mobile blur

1/1/1970

Comment j'ai résolu le problème des changements de hash en cascade avec les Import Maps

Salut ! J'ai ce problème depuis plus de 5 ans, mais j'ai seulement décidé de m'y attaquer maintenant parce qu'il est devenu impossible à ignorer. Quand je modifiais un seul caractère dans un fichier, la moitié des fichiers JavaScript de mon build se retrouvaient avec de nouveaux noms de fichiers hashés, alors même que leur contenu réel n'avait pas changé. Cela provoquait des invalidations de cache inutiles, rendait quasiment impossible de suivre ce qui avait réellement changé entre les builds, et pire encore : cela cassait mes builds Cloudflare Pages à cause d'une limite de fichiers.

Ci-dessous, je vais détailler le problème, expliquer pourquoi les solutions existantes ne me convenaient pas, et comment j'ai construit un plugin Vite personnalisé en utilisant les Import Maps pour le résoudre une bonne fois pour toutes.

Le problème : les changements de hash en cascade

Vite utilise un hashage basé sur le contenu pour les builds de production. Quand vous buildez votre application, chaque fichier JavaScript reçoit un hash dans son nom de fichier basé sur son contenu. Si button.tsx se compile en button-abc12345.js, et que le contenu change, il devient button-def45678.js. C'est très utile pour invalider le cache : les utilisateurs reçoivent le nouveau fichier quand il change.

Le problème survient quand le Fichier A importe le Fichier B. Imaginons :

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

Quand button.tsx change, Vite génère button-def45678.js. Mais maintenant main.js change aussi parce qu'il contient la chaîne "./button-abc12345.js", qui est désormais incorrecte. Donc main.js reçoit aussi un nouveau hash, alors même que la logique réelle de main.js n'a pas changé du tout.

Cela se propage en cascade dans tout votre graphe de dépendances. Modifiez une fonction utilitaire, et soudain la moitié de vos fichiers js obtiennent de nouveaux hashs. Dans mon cas, changer un seul caractère dans useBackgroundMusic.ts a provoqué le re-hashage de plus de 500 fichiers.

L'impact concret était significatif. Nous regroupons 8 versions des assets de nos builds précédents pour que les utilisateurs ayant des versions légèrement obsolètes de notre client puissent toujours faire tourner leur version quand on déploie la nouvelle sur Cloudflare Pages. Cependant, Cloudflare Pages a une limite de 20 000 fichiers que nous avons commencé à atteindre à cause de notre changement i18n récent qui a fait exploser le nombre de fichiers que nous créons.

Résoudre les hashs en cascade nous permet de stocker bien plus de builds passés sans atteindre ces limites, parce que désormais la plupart des fichiers n'ont plus besoin de changer. Cela réduit aussi la probabilité qu'un utilisateur sur un build obsolète tombe en erreur, puisqu'il est bien plus probable qu'il demande un fichier désormais inchangé que nous avons par hasard.

Pourquoi pas [les solutions alternatives] ?

Quand j'ai commencé à chercher comment résoudre ça, j'ai envisagé plusieurs approches. Aucune ne convenait vraiment.

Scripts post-build

Ma première idée a été d'écrire un script post-build qui normaliserait tous les chemins d'imports, re-hasherait les fichiers et mettrait à jour les références. Ça paraissait simple : juste remplacer par regex les noms de fichiers hashés par des noms stables, puis recalculer les hashs.

J'ai rejeté cette approche à cause des « Heisenbugs » et des risques de pollution du cache. Même si nous stockons les builds passés sur Cloudflare Pages, le risque d'incohérences de cache n'en valait pas la peine. Un script qui modifie les fichiers après le build pourrait introduire des bugs subtils n'apparaissant qu'en production, et déboguer ça serait un cauchemar.

Le manualChunks de Vite

Une autre option était d'utiliser la configuration manualChunks de Vite pour séparer le code stable (comme node_modules) du code instable (la logique métier). L'idée était que le code des dépendances changerait moins souvent, donc moins de fichiers cascaderaient.

Ça ne résout pas vraiment le problème de fond, ça l'atténue seulement. On a toujours des hashs en cascade dans les chunks de logique métier. Je voulais une solution qui s'attaque au cœur du problème, pas juste qui le rende un peu moins pénible.

Les Import Maps : la solution moderne

Les Import Maps sont une fonctionnalité native du navigateur (avec un polyfill pour les navigateurs plus anciens) qui découple les spécificateurs de modules des chemins de fichiers. Au lieu que le Fichier A importe "./button-abc123.js", il importe "button". Le navigateur utilise l'import map pour résoudre "button" vers le nom de fichier hashé réel.

C'est exactement ce dont j'avais besoin. Le contenu du Fichier A reste identique (il importe toujours "button"), donc son hash reste le même. Seuls l'import map et le fichier modifié obtiennent de nouveaux hashs. J'ai été un peu choqué que personne n'ait déjà fait un bon plugin pour ça !

Construire le plugin Vite

J'ai décidé de construire un plugin Vite qui :

  1. Transformerait tous les imports relatifs pour utiliser des spécificateurs de modules stables
  2. Générerait une import map qui ferait correspondre ces spécificateurs aux noms de fichiers hashés réels
  3. Injecterait l'import map dans le HTML

Le plugin est désormais disponible sur GitHub : @foony/vite-plugin-import-map

Approche initiale

J'ai commencé avec un plugin Vite utilisant le hook generateBundle. Ma première tentative utilisait une regex pour trouver et remplacer les chemins d'imports. C'était facile à coder et fonctionnait pour notre petite équipe Foony, mais c'était fragile et ne fonctionnerait certainement pas dans un plugin où il pourrait y avoir des faux positifs qui se feraient muter.

L'approche regex avait des problèmes évidents : et si une chaîne dans le code ressemblait par hasard à un nom de fichier ? Et les imports dynamiques ? Et les déclarations d'export ? J'avais besoin d'une solution plus robuste si je voulais construire un plugin pour les autres.

Parsing AST

Il fallait que je parse correctement le code JavaScript pour trouver toutes les déclarations d'import. Ma première tentative a été es-module-lexer, spécifiquement conçu pour parser les modules ES. Malheureusement, il provoquait des panics natifs pendant la phase d'analyse de modules de Vite. Même essayer le build asm.js n'a pas aidé à arrêter les panics.

Je me suis finalement rabattu sur Acorn, un parser JavaScript pur, rapide et léger. Combiné avec acorn-walk pour la traversée d'AST, il me donnait tout ce dont j'avais besoin sans les problèmes de dépendance native.

Principaux défis résolus

Gérer tous les types d'imports

Les imports prennent plusieurs formes, et ils sont traités différemment dans l'AST. Je devais gérer :

  • Les imports statiques : import x from "./file.js"
  • Les imports dynamiques : import("./file.js")
  • Les ré-exports nommés : export { x } from "./file.js" (j'avais raté celui-là au début !)
  • Les ré-exports complets : export * from "./file.js"

Le cas des ré-exports était particulièrement délicat parce que je l'avais raté jusqu'à ce que je voie un fichier qui n'était pas transformé. Le code contenait export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" et mon plugin l'ignorait complètement parce que je cherchais uniquement les nœuds ImportDeclaration et ImportExpression.

Voici comment je les gère tous maintenant :

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

Résolution déterministe des conflits

Quand plusieurs fichiers ont le même nom de base (comme plusieurs fichiers index.tsx dans des répertoires différents), je dois les désambiguïser. Je ne peux pas juste utiliser "index" pour tous.

Ma solution : s'il y a un conflit, je hashe le chemin source original plus le nom de base. Par exemple, src/client/games/chess/index.tsx:index est hashé pour créer index-abc123. Cela garantit que le même fichier obtient toujours le même spécificateur de module entre les builds, même si d'autres fichiers du même nom sont ajoutés ou supprimés.

J'utilise chunk.facadeModuleId (le point d'entrée) comme identifiant principal, en me rabattant sur chunk.moduleIds[0] si ce n'est pas disponible. Cela me donne un chemin source stable pour un hashage déterministe.

Chaînage des source maps

Quand je transforme le code, je casse la chaîne des source maps. La source map existante mappe depuis le code TypeScript original à travers Babel et la minification jusqu'au code actuel. Mes transformations ajoutent une autre couche, donc je dois préserver cette chaîne.

J'utilise MagicString pour suivre mes transformations et générer une nouvelle source map. Puis je la fusionne avec la map existante en préservant les tableaux sources et sourcesContent originaux. Cela maintient la chaîne complète : Source originale → (map existante) → Code transformé.

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-hashage du contenu transformé

J'ai besoin d'un contenu de fichier stable. Pour ce faire, je transforme les imports (en remplaçant les imports hashés de Vite par mes imports stables), puis je retire les commentaires de source map du calcul du hash (ils référencent d'anciens noms de fichiers).

Après ça, je calcule un nouveau hash, et je mets à jour à la fois le nom de fichier et l'entrée de l'import map.

L'implémentation finale

Le plugin utilise une stratégie en quatre passes :

  1. Passe de comptage : Détecter les collisions de noms en comptant combien de fichiers partagent chaque nom de base
  2. Passe de mapping : Créer le mapping des chunks (nom de fichier hashé → spécificateur de module) et l'import map initiale
  3. Passe de transformation : Réécrire les chemins d'imports dans le code, recalculer les hashs, mettre à jour les source maps
  4. Passe de renommage : Mettre à jour les noms de fichiers du bundle et finaliser l'import map

Voici la logique de transformation centrale :

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

Pour injecter l'import map dans le HTML, j'utilise l'API d'injection de tags de Vite plutôt que de la manipulation de regex :

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

C'est bien plus fiable que d'essayer de matcher des balises HTML par regex.

Quelques chiffres

Pour vous donner une idée de ce que fait ce plugin :

  • ~1 000+ fichiers JavaScript traités par build
  • ~2-3 secondes ajoutées au temps de build (compromis acceptable)
  • ~99 % de réduction des changements de hash inutiles (la plupart des fichiers ne changent désormais que lorsque leur contenu réel change)
  • ~340 lignes de code de plugin (commentaires et gestion d'erreurs inclus)

Le plugin gère tous les cas limites que j'ai rencontrés jusqu'à présent, et le processus de build est maintenant bien plus prévisible.

Leçons apprises

Pourquoi le parsing AST est essentiel

Utiliser des regex sur du code bundlé est dangereux. Si une chaîne dans votre code ressemble par hasard à un nom de fichier, la regex la réécrira. Le parsing AST garantit que vous ne transformez que les véritables déclarations d'import/export.

Pourquoi Acorn plutôt qu'es-module-lexer

es-module-lexer est plus rapide et plus spécialisé, mais les problèmes de panic natifs le rendaient inutilisable dans le contexte de mon plugin Vite. Acorn est en JavaScript pur, ce qui signifie aucune dépendance native dont s'inquiéter. Je voudrai regarder es-module-lexer à l'avenir comme optimisation de vitesse, mais pour l'instant Acorn fonctionne parfaitement.

Pourquoi les Import Maps plutôt que les alternatives

Les Import Maps sont un standard web avec un support natif des navigateurs. C'est la « bonne » façon de résoudre ce problème. Le polyfill (es-module-shims) gère les navigateurs plus anciens (par exemple Safari < 16.4) avec élégance, et la solution est propre et maintenable.

Conclusion

Le plugin Import Maps empêche avec succès les changements de hash en cascade dans mes builds Vite. Les fichiers n'obtiennent désormais de nouveaux hashs que lorsque leur contenu réel change, pas quand leurs dépendances changent. Cela rend les builds plus prévisibles, réduit les invalidations de cache inutiles, et nous aide à rester sous les limites de fichiers de Cloudflare Pages.

La solution est simple, maintenable, et utilise des standards web modernes. C'est un bon exemple de la manière dont, parfois, la « bonne » solution est aussi la plus simple, dès lors que vous comprenez le problème assez profondément pour la voir.

Le plugin est open source et disponible sur GitHub : @foony/vite-plugin-import-map. Vous pouvez l'installer avec npm install @foony/vite-plugin-import-map et commencer à l'utiliser dans vos propres projets Vite.

Les améliorations futures pourraient inclure une optimisation avec es-module-lexer une fois les problèmes de panic natifs résolus, ou ajouter le support de scénarios d'imports plus complexes. Mais pour l'instant, le plugin fait exactement ce que j'attends de lui.

Et qui sait ? Peut-être qu'un jour Vite supportera ce genre de chose nativement.

(Mise à jour : Après avoir essayé le plugin sur le build de Foony, certains utilisateurs avaient des problèmes inattendus, donc je l'ai désactivé pour l'instant. J'y reviendrai plus tard. Peut-être. Je trouve toujours que c'est une chouette solution.)

8 Ball Pool online multiplayer billiards icon