Aller au contenu principal

AssetMapper : le frontend de Symfony sans Node ni bundler.

Pourquoi un blog Symfony 8.1 a pu retirer Webpack Encore : importmap natif, HTTP/2, pre-compression brotli. Mesures, anti-patterns et borne épistémo.

MAJ 7 min de lecture
Sommaire · 5

J'ai porté du Webpack Encore pendant des années, versionné des webpack.config.js que je ne relisais plus, hurlé après des mode: 'production' qui marchaient en dev et plus en prod ou lu trois fois la doc de splitChunks en espérant que la lecture suivante ferait apparaître la phrase que j'avais ratée. Tout ceci, c'était avant la sortie d'AssetMapper.

AssetMapper est un composant officiel Symfony. Il sert le frontend de Symfony sans Node, sans bundler, sans étape de build JavaScript. À la place : les import maps natifs du navigateur (standard W3C, supporté par Chrome, Firefox, Safari et Edge depuis 2023), une compilation Symfony qui versionne les fichiers par hash de contenu, et un serveur web qui distribue le tout en HTTP/2.

La proposition de valeur est l'inverse de celle d'un bundler. Un bundler concatène, transpile, minifie et splitte pour fabriquer la chaîne d'assets la plus efficace possible côté client. AssetMapper, lui, ne fait que deux choses : il copie les fichiers source vers public/assets/ en y ajoutant un hash dans le nom, et il génère le <script type="importmap"> qui indique au navigateur où chercher chaque module. Pas de transformation, pas de concaténation, pas de tree-shaking. C'est le navigateur — pas un build step — qui charge les modules en parallèle quand il en a besoin.

Le pari sous-jacent : avec HTTP/2, modulepreload et brotli serveur, les optimisations historiques que faisait Webpack ne paient plus leur coût en complexité. Le reste du billet montre comment ça se monte dans un projet Symfony 8.1, ce que le composant fait précisément, où il s'arrête, et dans quels cas il ne suffit pas.

Ce qu'AssetMapper retire

Le pipeline frontend de ce site tient en sept commandes orchestrées par composer auto-scripts-build :

cache:clear --no-warmup
assets:install public
importmap:install
minify:install
tailwind:build --minify
asset-map:compile
assets:compress --no-interaction

Pas de node, pas de node_modules, pas de webpack, pas de babel, pas de postcss-loader. Le package.json à la racine du projet existe, mais il sert exclusivement au tooling QA (Biome, Stylelint, Knip, Vitest) — les dépendances runtime du front vivent dans importmap.php, jamais ailleurs. On a 163 entrées dans cet importmap.php aujourd'hui : un mélange de packages versionnés (@hotwired/stimulus, @hotwired/turbo, @tiptap/react, @dnd-kit/core…) et de chemins locaux vers les sources du projet (./assets/app.js, les fichiers React de l'éditeur de blocs, etc.).

Ce qui fait le boulot quand un visiteur arrive sur une page : le navigateur reçoit un <script type="importmap"> qui liste les chemins versionnés (genre /assets/app-WkoGTBH.js), puis charge en parallèle ce dont il a besoin via HTTP/2. Il n'y a pas de bundle unique, parce qu'il n'y a personne à servir avec une seule connexion HTTP/1.1. Les fichiers sont versionnés par hash de contenu (cache immutable d'un an, Cache-Control: public, max-age=31536000, immutable) et compressés à la build — pas à la volée.

Pourquoi ça tient en 2026

La concaténation, c'était une optimisation pour HTTP/1.1, où chaque requête coûtait un round-trip TCP et où le navigateur limitait à six requêtes parallèles par domaine. Sur HTTP/2 (et a fortiori HTTP/3, ce qui est le cas de ce blog en prod via FrankenPHP), le multiplexing permet de charger des dizaines de fichiers sur une seule connexion sans pénalité visible. Bundler 50 fichiers en un seul devient une optimisation pour un problème qui n'existe plus.

Le second pilier, c'est modulepreload. Quand le HTML envoie <link rel="modulepreload" href="/assets/app-...js">, le navigateur démarre le téléchargement avant d'avoir parsé le script qui en a besoin. C'est natif, c'est bien supporté, et ça remplace pas mal de ce que faisait splitChunks.

Le troisième, c'est la compression. Brotli côté serveur, niveau 6 ou 11 selon qu'on compresse à la volée ou au build, donne sur du JS et du CSS des taux qui rendent la question du bundle accessoire. On y vient.

Pre-compression au build : assets:compress

La dernière commande du pipeline mérite une lecture attentive, parce qu'elle change ce que fait le serveur web sur chaque requête d'asset.

bin/console assets:compress lit tous les fichiers compilés sous public/assets/ et matérialise pour chacun deux variantes : un .br (brotli, niveau 11) et un .gz (gzip, niveau 9). Sur ce blog, ça donne 380 .br et 380 .gz côte à côte avec les fichiers d'origine. Mesure concrète sur app-WkoGTBH.css : 136 267 octets en source, 19 988 octets en brotli (−85 %), 24 901 octets en gzip (−82 %).

Une fois ces variantes en place, on configure le serveur pour les servir telles quelles si le client les accepte. Côté Caddy (devops/images/frankenphp/Caddyfile:339) :

file_server {
    precompressed br zstd gzip
}

La directive precompressed cherche, pour chaque fichier demandé, l'extension correspondante à l'Accept-Encoding du navigateur. Si app-WkoGTBH.css.br existe et que le client annonce br, Caddy le sert directement avec Content-Encoding: br. Pas de compression à la volée, pas de coût CPU par requête. Sur des assets versionnés immutable (Cache-Control à un an), c'est le placement naturel : le contenu ne change pas entre deux deploys, recompresser à chaque hit n'apporte rien.

L'optimisation s'arrête là où l'invariant cesse. Sur du contenu dynamique (HTML rendu, réponses JSON), le serveur compresse à la volée parce que l'entrée change à chaque requête — pré-compresser n'a pas de sens. La règle générale : pré-compresser ce qu'on cache, compresser à la volée ce qu'on calcule.

Stimulus, Turbo, Live Components : ce qui se branche dessus

Quand on retire Webpack, on ne retire pas l'interactivité côté client. Sur ce site, l'interactivité passe par trois outils, tous orchestrés via AssetMapper :

  • Stimulus pour la glue DOM (toggles, dropdowns, init de composants tiers comme Tiptap),
  • Turbo pour la navigation sans rechargement et les frames partielles,
  • Live Components quand le serveur doit re-rendre un fragment HTML en réponse à un input — le composant Search du blog, par exemple.

L'éditeur de blocs admin et la médiathèque sont des îlots React isolées sous assets/admin/react/, déclarées dans importmap.php avec leur propre point d'entrée. AssetMapper s'en occupe comme du reste : pas de webpack séparé pour le coin admin, pas de second build à entretenir. Les Twig Components.

Quand AssetMapper ne tient pas

Ce billet ne couvre pas tous les fronts. Il y a une classe de projets — un blog Symfony qui sert du HTML rendu côté serveur avec un peu de Stimulus et trois îles React — où le bundler ne paie plus son ticket. Mais il y a aussi une classe de projets où AssetMapper coince :

  • SPA Vue ou React avec 200 composants, tree-shaking agressif, dynamic imports orchestrés — Vite reste mieux outillé pour ce travail-là.
  • Code splitting fin par route avec préchargement spéculatif — possible avec modulepreload à la main, plus simple via un bundler qui le calcule.
  • Transpilation non-triviale (TypeScript, JSX dans des .ts, Svelte, SCSS avec imports complexes) — AssetMapper laisse le navigateur faire son job, il ne transforme pas le code. Si on a besoin de transformer, on remet une étape de build.

Le mot de la fin

L'histoire du bundler en JavaScript, c'est l'histoire d'une optimisation qui a survécu au problème qu'elle résolvait. HTTP/1.1 limitait les connexions parallèles, donc on concaténait. HTTP/2 a retiré la limite ; le code de bundling, lui, est resté, parce qu'un outillage en place résiste plus longtemps que la contrainte qui l'a justifié. AssetMapper ne fait pas mieux qu'Encore — il fait moins, et c'est ce qui le sauve sur cette catégorie de projets.

Reste la même question pour les autres pièces du décor dans une stack Symfony : qu'est-ce qui est là parce qu'il sert aujourd'hui, et qu'est-ce qui est là parce qu'il était là hier ? La réponse n'est pas générale ; elle se mesure projet par projet.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.