background blurbackground mobile blur

1/1/1970

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

Salut ! Je traîne ce problème depuis plus de 5 ans, mais je me suis décidé à m'y attaquer seulement maintenant, parce qu'il est arrivé à un point où je ne pouvais plus l'ignorer. Quand je changeais un seul caractère dans un fichier, la moitié des fichiers JavaScript de mon build se retrouvaient avec un nouveau nom de fichier hashé, alors que leur contenu réel n'avait pas bougé. Ça provoquait une invalidation de cache complètement inutile, rendait presque impossible le suivi de ce qui avait vraiment changé entre deux builds et, pire que tout : ça cassait mes builds Cloudflare Pages à cause d'une limite de fichiers.

Ci‑dessous, je détaille le problème, pourquoi les solutions existantes ne marchaient pas pour moi, et comment j'ai construit un plugin Vite maison en utilisant les Import Maps pour régler ça une bonne fois pour toutes.

Le problème : les changements de hash en cascade

Vite utilise un hash basé sur le contenu pour les builds de production. Quand tu build ton app, chaque fichier JavaScript reçoit un hash dans son nom de fichier, basé sur son contenu. Si button.tsx est compilé en button-abc12345.js, et que le contenu change, ça devient button-def45678.js. C'est super pour le cache busting, les utilisateurs récupèrent le nouveau fichier quand il change.

Le problème arrive quand le fichier A importe le fichier B. Disons que tu as :

// 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 n'est plus la bonne. Du coup main.js obtient aussi un nouveau hash, alors que la logique de main.js n'a absolument pas changé.

Et ça se propage dans tout ton graphe de dépendances. Tu modifies une seule fonction utilitaire, et d'un coup la moitié de tes fichiers JS se retrouvent avec un nouveau hash. Dans mon cas, changer un seul caractère dans useBackgroundMusic.ts faisait re-hasher plus de 500 fichiers.

L'impact dans la vraie vie était assez costaud. On embarque 8 versions des assets de nos anciens builds pour que les utilisateurs sur une version un peu ancienne de notre client puissent toujours faire tourner leur version quand on déploie la nouvelle version sur Cloudflare Pages. Sauf que Cloudflare Pages a une limite de 20 000 fichiers, que l'on a commencé à atteindre à cause de notre changement i18n précédent, qui a fait exploser le nombre de fichiers générés.

Résoudre les hashs en cascade nous permet de stocker beaucoup plus d'anciens builds sans dépasser ces limites, parce que la plupart des fichiers n'ont plus besoin de changer. Ça réduit aussi la probabilité qu'un utilisateur sur un vieux build se retrouve avec une erreur, puisqu'il y a beaucoup plus de chances qu'il demande un fichier désormais inchangé qu'on a encore en stock.

Pourquoi pas [des solutions alternatives] ?

Quand j'ai commencé à réfléchir à une solution, j'ai envisagé plusieurs approches. Aucune ne collait vraiment.

Scripts post-build

Mon premier réflexe, c'était d'écrire un script post-build qui normaliserait tous les chemins d'import, re-hasherait les fichiers et mettrait à jour les références. Ça paraissait simple, il suffisait de faire un remplacement par regex des noms de fichiers hashés vers des noms stables, puis de recalculer les hashs.

J'ai abandonné cette approche à cause des "Heisenbugs" et du risque d'empoisonnement du cache. Même si on stocke les anciens builds dans Cloudflare Pages, le risque d'incohérences de cache ne valait pas le coup. Un script qui modifie des fichiers après le build pourrait introduire des bugs subtils qui n'apparaissent qu'en production, et déboguer ça serait un cauchemar.

Vite manualChunks

Une autre option, c'était d'utiliser la config manualChunks de Vite pour séparer le code stable (genre node_modules) du code instable (la logique métier). L'idée étant que le code des dépendances change moins souvent, donc moins de fichiers partent en cascade.

Sauf que ça ne règle pas vraiment le problème de fond, ça le limite juste un peu. Tu as toujours des hashs en cascade à l'intérieur de tes chunks de logique métier. Je voulais une solution qui s'attaque à la vraie racine du problème, pas juste un pansement qui rend les choses un peu moins pénibles.

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 "module specifiers" 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 vrai nom de fichier hashé.

C'est exactement ce qu'il me fallait. 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 un nouveau hash. J'étais un peu choqué que personne n'ait déjà fait un bon plugin pour ça !

Le cheminement de l'implémentation

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

  1. Transformer toutes les importations relatives pour utiliser des "module specifiers" stables
  2. Générer un import map qui fait correspondre ces specifiers aux vrais noms de fichiers hashés
  3. Injecter l'import map dans le HTML

Le plugin est maintenant disponible sur GitHub : @foony/vite-plugin-import-map

Première approche

J'ai commencé avec un plugin Vite en utilisant le hook generateBundle. Ma première tentative utilisait des regex pour trouver et remplacer les chemins d'import. C'était simple à coder et ça fonctionnait pour notre petite équipe Foony, mais c'était fragile et clairement pas adapté pour un plugin générique où des faux positifs pourraient être modifiés.

L'approche regex avait des problèmes évidents : et si une chaîne dans le code ressemblait à un nom de fichier ? Et les imports dynamiques ? Et les instructions d'export ? Il me fallait une solution plus robuste si je voulais faire un plugin que d'autres pourraient utiliser sans crainte.

Parsing de l'AST

Je devais parser correctement le code JavaScript pour trouver toutes les instructions d'import. Ma première tentative a été es-module-lexer, qui est justement fait pour parser les modules ES. Malheureusement, ça provoquait des "native panics" pendant la phase d'analyse des modules de Vite. Même en essayant la build asm.js, ça ne réglait pas les plantages.

Je me suis finalement posé sur Acorn, un parser rapide, léger, en pur JavaScript. Combiné avec acorn-walk pour parcourir l'AST, ça me donnait exactement ce dont j'avais besoin, sans les soucis de dépendances natives.

Les défis principaux que j'ai résolus

Gérer tous les types d'import

Les imports se présentent sous plein de formes différentes, et l'AST les traite différemment. 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" (celui-là, je l'ai raté au début !)
  • Les ré-exports globaux : export * from "./file.js"

Le cas des ré-exports était particulièrement piégeux, parce que je l'ai manqué jusqu'à ce que je tombe sur un fichier qui n'était pas transformé. Le code contenait export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" et mon plugin l'ignorait totalement, parce que je ne regardais que les nœuds ImportDeclaration et ImportExpression.

Voilà comment je gère tout ça maintenant :

walk(ast, {
  ImportDeclaration(node: any) {
    // Imports statiques : import x from "spec"
    const specifier = node.source.value;
    // ... logique de transformation
  },
  ExportNamedDeclaration(node: any) {
    // Exports nommés avec source : export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... logique de transformation
  },
  ExportAllDeclaration(node: any) {
    // Export global : export * from "spec"
    if (!node.source?.value) return;
    // ... logique de transformation
  },
  ImportExpression(node: any) {
    // Imports dynamiques : import("spec")
    // ... logique de transformation
  },
});

Résolution déterministe des collisions

Quand plusieurs fichiers partagent le même nom de base (par exemple plusieurs index.tsx dans des dossiers différents), je dois les différencier. Je ne peux pas juste utiliser "index" pour tous.

Ma solution : s'il y a un conflit, je hash 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. Ça garantit que le même fichier reçoit toujours le même module specifier d'un build à l'autre, même si d'autres fichiers avec le même nom apparaissent ou disparaissent.

J'utilise chunk.facadeModuleId (le point d'entrée) comme identifiant principal, avec repli sur chunk.moduleIds[0] si ce n'est pas dispo. Ça me donne un chemin source stable pour le hash déterministe.

Chaînage des source maps

Quand je transforme le code, je casse la chaîne des source maps. La source map existante fait le lien entre le TypeScript original, Babel, la minification et le code actuel. Mes transformations ajoutent une couche de plus, donc je dois préserver cette chaîne.

J'utilise MagicString pour suivre mes transformations et générer une nouvelle source map. Ensuite je la fusionne avec la map existante en préservant les tableaux sources et sourcesContent originaux. Ça garde toute la chaîne : 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,
});

// Fusion : on utilise les mappings de la nouvelle map mais on préserve les sources originales
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

Re-hasher le contenu transformé

J'ai besoin d'un contenu de fichier stable. Pour ça, je transforme les imports (je remplace les imports hashés de Vite par mes imports stables), puis je retire les commentaires de source map du calcul de hash (ils font référence aux 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 correspondante dans l'import map.

L'implémentation finale

Le plugin suit une stratégie en quatre passes :

  1. Passage de comptage : détecter les collisions de noms en comptant combien de fichiers partagent chaque nom de base
  2. Passage de mapping : créer la table de correspondance des chunks (nom de fichier hashé → module specifier) et l'import map initial
  3. Passage de transformation : réécrire les chemins d'import dans le code, recalculer les hashs, mettre à jour les source maps
  4. Passage de renommage : mettre à jour les noms de fichiers du bundle et finaliser l'import map

Voilà le cœur de la logique de transformation :

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

// On parse le code pour obtenir un AST
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

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

// On parcourt l'AST pour trouver tous les 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 pour sauter le guillemet ouvrant
        end: node.source.end - 1,     // -1 pour sauter le guillemet fermant
        replacement: moduleSpec,
      });
    }
  },
  // ... gestion des autres types de nœuds
});

// On applique les transformations en ordre inverse pour préserver les 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 au lieu de manipuler le HTML à coup de regex :

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

C'est beaucoup plus fiable que d'essayer de matcher des balises HTML avec des regex.

Les chiffres

Pour te 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 (un compromis largement acceptable)
  • ~99 % de réduction des changements de hash inutiles (la plupart des fichiers ne changent plus que lorsque leur contenu réel change)
  • ~340 lignes de code dans le plugin (commentaires et gestion d'erreurs compris)

Le plugin gère tous les cas tordus que j'ai croisés jusqu'ici, et le process de build est maintenant beaucoup plus prévisible.

Ce que j'ai appris

Pourquoi le parsing d'AST est essentiel

Faire des regex sur du code bundlé, c'est dangereux. Si une chaîne dans ton code ressemble à un nom de fichier, la regex va la réécrire. Le parsing d'AST garantit que tu ne transformes que les vraies instructions d'import/export.

Pourquoi Acorn plutôt que es-module-lexer

es-module-lexer est plus rapide et plus spécialisé pour ce boulot, mais les problèmes de "native panic" le rendaient inutilisable dans mon contexte de plugin Vite. Acorn est en pur JavaScript, donc aucune dépendance native à gérer. J'aimerais bien me repencher sur es-module-lexer plus tard comme optimisation de perf, mais pour l'instant Acorn fait parfaitement le job.

Pourquoi les Import Maps plutôt que le reste

Les Import Maps sont un standard du web, avec un support natif dans les navigateurs. C'est la "bonne" manière de résoudre ce problème. Le polyfill (es-module-shims) gère proprement les vieux navigateurs (par exemple Safari < 16.4), et la solution reste propre et maintenable.

Conclusion

Le plugin Import Maps empêche efficacement les changements de hash en cascade dans mes builds Vite. Les fichiers n'obtiennent un nouveau hash que lorsque leur contenu réel change, pas quand leurs dépendances changent. Les builds sont plus prévisibles, on évite une bonne partie de l'invalidation de cache inutile, et ça nous aide à rester sous les limites de fichiers de Cloudflare Pages.

La solution est simple, maintenable et s'appuie sur des standards modernes du web. C'est un bon exemple de ces moments où la "bonne" solution est aussi la plus simple, une fois que tu comprends suffisamment bien le problème pour la voir.

Le plugin est open source et dispo sur GitHub : @foony/vite-plugin-import-map. Tu peux l'installer avec npm install @foony/vite-plugin-import-map et commencer à l'utiliser dans tes propres projets Vite.

Les améliorations futures pourraient inclure une optimisation avec es-module-lexer une fois les problèmes de native panic réglés, ou l'ajout de support pour des scénarios d'import encore plus complexes. Mais pour l'instant, le plugin fait exactement ce dont j'ai besoin.

Et qui sait ? Peut-être qu'un jour Vite intégrera quelque chose comme ça nativement.

8 Ball Pool online multiplayer billiards icon