Pendant des années, j'ai porté du Webpack Encore sans me demander ce que je portais. Des webpack.config.js versionnés que je ne relisais plus. Un mode: 'production' qui marchait en dev et cassait en prod. La doc de splitChunks, lue trois fois, en espérant que la lecture suivante ferait apparaître la phrase que j'avais ratée. Ce rituel s'est arrêté avec la sortie d'AssetMapper, en 2023.
Ce billet remonte la chaîne : pourquoi le bundler est né (une contrainte HTTP/1.1 tout à fait réelle), pourquoi il est resté après la mort de cette contrainte, et ce que donne un frontend qui s'en passe. Avec les chiffres de ce blog à l'appui : 141 modules déclarés, zéro Node, zéro étape de build JavaScript. Et, parce qu'aucun outil ne gagne partout, les cas où AssetMapper ne suffit pas.
AssetMapper est un composant officiel Symfony. Il fait deux choses, et deux seulement. Il copie les fichiers source vers public/assets/ en glissant un hash de contenu dans chaque nom. Et il génère le <script type="importmap"> qui indique au navigateur où chercher chaque module. Aucune transformation du code au passage : la transpilation, la concaténation et le tree-shaking ne font pas partie du contrat. Charger les modules, c'est le travail du navigateur, en parallèle, quand il en a besoin.
Le problème que ça évite
Le FOUC n'est pas un bug graphique, c'est une faille d'architecture !
Pourquoi on bundlait
À l'origine du bundler, il y a une contrainte parfaitement réelle. HTTP/1.1 traite les requêtes d'une connexion l'une après l'autre, et les navigateurs plafonnent à six connexions parallèles par domaine. Chaque fichier coûtait un aller-retour réseau, derrière une file d'attente limitée à six. Servir cinquante modules ES à un navigateur de 2014, c'était le meilleur moyen de lui faire regarder une page blanche pendant dix secondes.
Donc on a concaténé. Puis minifié, sprité les images, inliné le CSS critique, shardé les domaines. Toute une génération d'outillage, webpack en tête, s'est construite pour contourner les limites d'un protocole de 1997. Et à l'époque, c'était justifié : un bundle unique sur HTTP/1.1, c'étaient des secondes gagnées, mesurables, pour chaque visiteur.
Webpack Encore, côté Symfony, empaquetait ce savoir-faire : les entrées, splitChunks, le versioning, tout le rituel. On l'a tous configuré. On l'a surtout configuré sans plus se demander à quelle contrainte répondait chaque ligne.
La contrainte est morte, l'outillage est resté
En mai 2015, HTTP/2 est standardisé : multiplexing, une seule connexion, des dizaines de fichiers en parallèle sans pénalité. En 2022, HTTP/3 enfonce le clou par-dessus QUIC. Ce blog est servi en HTTP/3 par FrankenPHP : y charger cinquante fichiers coûte à peine plus cher qu'en charger un.
modulepreload fait une bonne part du reste. Un <link rel="modulepreload"> dans le HTML, et le navigateur télécharge un module avant même d'avoir parsé le script qui l'importe. C'est natif, c'est bien supporté, et ça couvre l'essentiel de ce que splitChunks calculait à la build.
Troisième pilier, la compression. Brotli côté serveur atteint sur du JS et du CSS des taux qui rendent la question du poids de bundle accessoire. On la mesure plus bas, chiffres du blog à l'appui.
Résumons la chronologie. La contrainte disparaît en 2015. Les import maps arrivent dans le dernier navigateur majeur en 2023. Entre les deux, huit ans pendant lesquels l'outillage de contournement a continué de s'installer, de se versionner, de se transmettre de projet en projet comme une couche de sédiment. Un outillage en place résiste toujours plus longtemps que la contrainte qui l'a justifié. Le bundler de 2026, sur un site à rendu serveur, est un fossile : la trace d'un protocole disparu, imprimée dans nos scripts de build.
Ce qu'AssetMapper fait à la place
AssetMapper prend acte de cette chronologie : il retire la couche au lieu de la moderniser. 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-interactionPas de node, pas de node_modules, pas de webpack, pas de babel, pas de postcss-loader. Les deux seuls binaires du pipeline, Tailwind et le minifier, sont des exécutables autonomes que Symfony télécharge dans var/ : le CSS utilitaire et la minification passent par là, toujours sans Node.
Le package.json à la racine existe encore, mais il ne sert qu'au tooling QA (Biome, Stylelint, Knip, Vitest). Les dépendances runtime du front vivent dans importmap.php : 141 entrées aujourd'hui, entre packages versionnés (@hotwired/stimulus, @hotwired/turbo, @tiptap/react, @dnd-kit/core…) et chemins locaux vers les sources du projet (./assets/app.js, les fichiers React de l'éditeur de blocs).
Côté navigateur, chaque page embarque un <script type="importmap"> qui associe chaque nom de module à son chemin versionné, /assets/app-s97hzlY.js au moment où j'écris ces lignes. Les import maps sont standardisées dans la spec HTML et supportées par Chrome, Firefox, Safari et Edge depuis 2023. Le navigateur charge en parallèle ce dont la page a besoin. Il n'y a pas de bundle unique, parce qu'il n'y a plus personne à servir sur une seule connexion HTTP/1.1.
Chaque fichier est versionné par le hash de son contenu et caché un an ferme : Cache-Control: public, max-age=31536000, immutable. Une URL d'asset ne ment jamais : si le contenu change, le nom change.
Pré-compression au build : assets:compress
La dernière commande du pipeline mérite qu'on s'y arrête, parce qu'elle déplace du travail : ce que le serveur refaisait à chaque requête se fait désormais une fois, au build.
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 319 .br et 319 .gz posés à côté des fichiers d'origine. Mesure du jour sur le CSS principal, app-JZ6RNn_.css : 165 151 octets en source, 22 314 octets en brotli (−86 %), 28 089 octets en gzip (−83 %).
Reste à servir ces variantes. Côté Caddy (devops/images/frankenphp/Caddyfile) :
file_server {
precompressed br zstd gzip
}La directive precompressed regarde, pour chaque fichier demandé, l'Accept-Encoding du navigateur. Si app-JZ6RNn_.css.br existe et que le client annonce br, Caddy le sert tel quel avec Content-Encoding: br. Aucune compression à la volée, aucun coût CPU par requête. Sur des assets immutables cachés un an, c'est le placement naturel : le contenu ne change pas entre deux deploys, le recompresser à chaque hit n'apporte rien.
L'optimisation s'arrête là où l'invariant cesse. Le HTML rendu et les réponses JSON changent à chaque requête, donc le serveur les compresse à la volée. La règle tient en une ligne : pré-compresser ce qu'on cache, compresser à la volée ce qu'on calcule.
Stimulus, Turbo, Live Components : ce qui se branche dessus
Retirer Webpack ne retire pas l'interactivité. Sur ce site, elle passe par trois outils, tous distribués par 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, comme le composant
Searchdu blog.
Les Twig Components, eux, illustrent le cas le plus confortable : du HTML rendu côté serveur, zéro JavaScript embarqué, rien à déclarer dans l'importmap.
L'éditeur de blocs de l'admin et la médiathèque sont des îlots React isolés sous assets/admin/react/, déclarés 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.
Quand AssetMapper ne tient pas
Tout ce qui précède vaut pour une catégorie de projets : du HTML rendu serveur, un peu de Stimulus, quelques îlots React. Il en existe une autre, où le bundler paie encore son ticket :
- 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 à imports complexes) : AssetMapper ne transforme pas le code, il le sert. S'il faut transformer, on remet une étape de build.
Le critère tient en une question : ce projet a-t-il besoin de transformer son JavaScript, ou seulement de le servir ?
Le mot de la fin
L'histoire du bundler, c'est l'histoire d'une optimisation qui a survécu au problème qu'elle résolvait. HTTP/1.1 plafonnait les connexions, donc on concaténait. HTTP/2 a levé le plafond en 2015 ; l'outillage, lui, est resté, recopié de boilerplate en boilerplate longtemps après la contrainte. AssetMapper ne fait pas mieux qu'Encore : il fait moins, et c'est ce qui le sauve sur cette catégorie de projets.
Depuis que ce blog tourne sans bundler, je regarde le reste de la stack en géologue : cette couche répond à quelle contrainte, et cette contrainte est-elle encore vivante ? Certaines strates servent tous les jours. D'autres tiennent par habitude, et on continue de les versionner, de les documenter, de les transmettre aux nouveaux arrivants. Votre webpack.config.js répond à quelle contrainte, exactement ? Si la réponse commence par « à l'époque », vous savez quoi auditer lundi matin.
À lire ensuite
Symfony UX Toolkit : quand le frontend devient (enfin) un plaisir
Une coquille, une erreur dans ce billet ? Signale-la-moi.
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).