Une meilleure architecture pour vos Twig Components de Symfony UX
info
🇬🇧 This article is available in english : A Better Architecture for Your Symfony UX Twig Components
Symfony UX, l'initiative JavaScript pour Symfony, a depuis un moment introduit les Twig Component. Similaires aux composants Vue ou React, ces composants Twig sont facilement réutilisables, isolent leur logique, acceptent des props et des blocks (équivalents des slots en Vue), et permettent la composition :
1<twig:Alert type="danger"> 2 Une erreur s'est produite 3</twig:Alert>
En suivant la documentation, l'architecture de notre composant Alert
ressemblerait à ceci :
1├── assets 2│ ├── controllers 3│ │ └── alert_controller.js 4│ └── styles 5│ └── alert.css (ou app.css) 6├── src 7│ ├── Twig 8│ │ └── Components 9│ │ └── Alert.php 10└── templates 11 └── components 12 └── alert.html.twig
On remarque la classe PHP, le template Twig, le script JS et les styles CSS totalement éparpillés. Personnellement je ne trouve pas ça très pratique.
Je préférerais avoir tous ces fichiers à un seul et unique endroit, regroupés par composant, un peu comme lorsqu'on organise son code métier en domaine. Un peu comme ce qu'Angular propose à la création d'un composant :
1├── component 2│ └── Alert 3│ ├── Alert.php 4│ ├── alert.css 5│ ├── alert.html.twig 6│ └── alert_controller.js
info
Les sources de cet article peuvent être trouvées sur GitHub à Kocal/twig-components-better-architecture.
Pré-requis
Nos composants seront situés dans un dossier component/
à la racine du projet :
1mkdir component
Configuration de l'auto-loader Composer
Ce nouveau dossier n'étant pas sous src/
, il faut configurer Composer et rajouter une nouvelle entrée d'autoload
:
1 // ... 2 "autoload": { 3 "psr-4": { 4 - "App\\": "src/" 5 + "App\\": "src/", 6 + "App\\Component\\": "component/" 7 } 8 }, 9 // ...
Configuration du container Symfony
Même remarque pour le container Symfony, il faut lui faire connaitre ce dossier component/
:
1 # ... 2 3 + App\Component\: 4 + resource: '../component/' 5
Installation et configuration des dépendances
Commençons par installer les dépendances nécessaires. Nous aurons besoin du Symfony AssetMapper, de Twig, du StimulusBundle, et des Twig Components de Symfony UX:
1composer require symfony/asset-mapper symfony/asset symfony/twig-pack symfony/stimulus-bundle symfony/ux-twig-component
Grâce à Symfony Flex et les recettes Symfony, les nouvelles dépendances devraient être configurées correctement. Si ce n'est pas le cas, assurez-vous d'avoir :
1framework: 2 asset_mapper: 3 # The paths to make available to the asset mapper. 4 paths: 5 - assets/
1twig_component: 2 anonymous_template_directory: 'components/' 3 defaults: 4 # Namespace & directory for components 5 App\Twig\Components\: 'components/'
- et ce bloc de code dans le fichier
templates/base.html.twig
(avant le</head>
) :
1{% block javascripts %} 2 {% block importmap %}{{ importmap('app') }}{% endblock %} 3{% endblock %}
Mise en place et rendu du composant
Notre composant de test
Je vais donc reprendre le composant Alert
, c'est le composant par excellence qui se retrouve dans quasiment tous les projets, et qui parlera à plus d'un.
Voici les fichiers utilisés :
- la classe PHP :
1<?php 2declare(strict_types=1); 3 4namespace App\Component\Alert; 5 6use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; 7 8#[AsTwigComponent('Alert', template: 'Alert/Alert.html.twig')] 9final class Alert 10{ 11 public ?string $type = null; 12}
- le template Twig :
1{% set alert_style = cva({ 2 base: 'alert', 3 variants: { 4 type: { 5 'success': 'alert--success', 6 'info': 'alert--info', 7 'warning': 'alert--warning', 8 'danger': 'alert--danger', 9 } 10 } 11}) %} 12 13<div{{ attributes.defaults({'data-controller': 'alert'}).without('class') }} 14 class="{{ alert_style.apply({type}, attributes.render('class')) }}" 15> 16 {% block content '' %} 17</div>
- les styles CSS :
1.alert { 2 --alert-color: #818182; 3 4 color: color-mix(in srgb, var(--alert-color) 50%, black); 5 background-color: color-mix(in srgb, var(--alert-color) 30%, white); 6 padding: 15px; 7 border-radius: 5px; 8} 9 10.alert--success { --alert-color: #2cbf50; } 11.alert--danger { --alert-color: #d13242; } 12.alert--warning { --alert-color: #cf9c05; } 13.alert--info { --alert-color: #15b0c6; }
- et le contrôleur Stimulus :
1import {Controller} from "@hotwired/stimulus"; 2import './Alert.css'; 3 4export default class extends Controller { 5 connect() { 6 console.log('Alert controller connected!'); 7 } 8}
Configuration de Twig Components
Après la création du composant, une erreur similaire devrait s'afficher :
Could not generate a component name for class "App\Component\Alert\Alert": no matching namespace found under the "twig_component.defaults" to use as a root. Check the config or give your component an explicit name.
C'est normal, on a utilisé l'attribut AsTwigComponent
sur une classe qui n'est pas dans un namespace connu des Twig Components. Il faut modifier la configuration pour configurer notre namespace :
1twig_component: 2 anonymous_template_directory: 'components/' 3 defaults: 4 # Namespace & directory for components 5 App\Twig\Components\: 'components/' 6 + App\Component\: '../component/'
L'erreur devrait disparaître.
Configuration de Twig
On peut donc commencer à utiliser notre composant <twig:Alert>
, par exemple de cette façon pour tester nos différentes variantes :
1{% extends 'base.html.twig' %} 2 3{% block body %} 4 <div style="display: grid; gap: 10px"> 5 <twig:Alert>This is a default message.</twig:Alert> 6 <twig:Alert type="success">This is a success message.</twig:Alert> 7 <twig:Alert type="info">This is an info message.</twig:Alert> 8 <twig:Alert type="warning">This is a warning message.</twig:Alert> 9 <twig:Alert type="danger">This is a danger message.</twig:Alert> 10 </div> 11{% endblock %}
Une erreur devrait s'afficher :
Unable to find template "Alert/Alert.html.twig" (looked into: /path/to/app/templates).
C'est normal, Twig ne connaît pas encore notre dossier component/
. Il faut modifier la configuration pour ajouter ce dossier :
1twig: 2 file_name_pattern: '*.twig' 3 + paths: 4 + - '%kernel.project_dir%/component' 5 6when@test: 7 twig: 8 strict_variables: true
L'erreur devrait disparaître.
Configuration de l'AssetMapper et du StimulusBundle
Notre composant Alert
s'affiche, mais on remarque que ni les styles CSS ni le contrôleur Stimulus ne sont chargés. Ces fichiers ne sont pas référencés dans l'importmap
de la page (<script type="importmap">
).
Il faut configurer l'AssetMapper en y rajoutant notre dossier component/
:
1framework: 2 asset_mapper: 3 # The paths to make available to the asset mapper. 4 paths: 5 - assets/ 6 + - component/
et de même pour le StimulusBundle, qui attend un chemin absolu :
1 +stimulus: 2 + controller_paths: 3 + - '%kernel.project_dir%/component'
Après avoir clear le cache de Symfony, les styles CSS et le controlleur Stimulus devraient être chargés correctement. Bravo 👏
Problématiques
Cette solution n'est pas sans poser de problèmes, notamment concernant le CSS et sa méthode de chargement.
Lazy-loading du CSS
Le CSS est chargé dans le <head>
via <link rel="stylesheet" href="/assets/Alert/Alert-eb07d1eb98abd4138db0be29530797ef.css">
, ça sera pareil pour chaque nouveau composant.
Selon vos décisions sur les performances web (plus précisement les CrUX), vous pouvez préférer :
- garder le fonctionnement actuel, mais ces CSS pourront bloquer l'affichage du site tant qu'ils ne sont pas téléchargés, parsés, et interprétés,
- ou alors les lazy-loader en passant par un controlleur Stimulus, mais cela pourra provoquer un FOUC, et donc impacter négativement le CLS.
Pour lazy-loader le CSS, on peut utiliser la syntaxe import()
qui, interprété par l'AssetMapper, permettra de rendre l'asset Alert/Alert.css
lazy ou non :
1import {Controller} from "@hotwired/stimulus"; 2 -import './Alert.css'; 3 +import('./Alert.css'); 4 5export default class extends Controller { 6 connect() { 7 console.log('Alert controller connected!'); 8 } 9}
En actualisant la page, on voit la balise <link rel="stylesheet" href="/assets/Alert/Alert-eb07d1eb98abd4138db0be29530797ef.css">
disparaître du <head>
, et on remarque que l'importmap
a un peu changé :
1<script type="importmap"> 2{ 3 "imports": { 4 "app": "/assets/app-252595b8e73750f20f965f60c9ddb057.js", 5 "/assets/bootstrap.js": "/assets/bootstrap-c423b8bbc1f9cae218c105ca8ca9f767.js", 6 "/assets/styles/app.css": "data:application/javascript,", 7 "@symfony/stimulus-bundle": "/assets/@symfony/stimulus-bundle/loader-e1ee9ace0562f2e6a52301e4ccc8627d.js", 8 "@hotwired/stimulus": "/assets/vendor/@hotwired/stimulus/stimulus.index-b5b1d00e42695b8959b4a1e94e3bc92a.js", 9 "/assets/@symfony/stimulus-bundle/controllers.js": "/assets/@symfony/stimulus-bundle/controllers-45d15ea38841e6394b6ce7a4de5fed1f.js", 10 "/assets/Alert/alert_controller.js": "/assets/Alert/alert_controller-75e28941942c87874bb05b336a3d7f53.js", 11 "/assets/Alert/Alert.css": "data:application/javascript,document.head.appendChild%28Object.assign%28document.createElement%28%22link%22%29%2C%7Brel%3A%22stylesheet%22%2Chref%3A%22%2Fassets%2FAlert%2FAlert-eb07d1eb98abd4138db0be29530797ef.css%22%7D%29%29" 12 } 13} 14</script>
Lorsque /assets/Alert/Alert.css
sera importé par le code JavaScript, alors ce code suivant sera exécuté : data:application/javascript,document.head.appendChild(Object.assign(document.createElement("link"),{rel:"stylesheet",href:"/assets/Alert/Alert-eb07d1eb98abd4138db0be29530797ef.css"}))
.
warning
Lazy-loader le CSS ainsi provoque désormais un FOUC.
Charger le CSS des composants sans contrôleur Stimulus
Autre problématique, le CSS d'un composant n'est détecté par l'AssetMapper uniquement si ce CSS est importé par un fichier JavaScript. Dans la plupart des cas, ce ne sera pas un problème, car nous aurions besoin d'un contrôleur Stimulus associé au composant, mais ce n'est pas une vérité absolue.
Pour éviter la création d'un controlleur Stimulus fantôme, on peut simplement adapter la ligne import('./Alert.css');
et la déplacer dans le fichier assets/app.js
:
1import './bootstrap.js'; 2/* 3 * Welcome to your app's main JavaScript file! 4 * 5 * This file will be included onto the page via the importmap() Twig function, 6 * which should already be in your base.html.twig. 7 */ 8import './styles/app.css'; 9 +import('../component/Alert/Alert.css'); 10 11console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
Pour aller plus loin
Regrouper la configuration
Au fil de l'article, on a configuré notre chemin component/
dans les fichiers de configuration de Twig, de l'AssetMapper, de Stimulus, et des Twig Components.
On peut déplacer tout ce qu'on a configuré dans un fichier dédié, cela permettra de faciliter la maintenance :
1parameters: 2 app.ui_components.dir: '%kernel.project_dir%/component' 3 4services: 5 _defaults: 6 autowire: true 7 autoconfigure: true 8 9 App\Component\: 10 resource: '%app.ui_components.dir%' 11 12twig: 13 paths: 14 - '%app.ui_components.dir%' 15 16framework: 17 asset_mapper: 18 paths: 19 - '%app.ui_components.dir%' 20 21stimulus: 22 controller_paths: 23 - '%app.ui_components.dir%' 24 25twig_component: 26 defaults: 27 App\Component\: '%app.ui_components.dir%'
Inclure le CSS d'un composant uniquement si nécessaire et sans lazy-loading/FOUC
Pendant ma phase d'expérimentation avant la rédaction de l'article, je m'étais penché sur une façon d'inclure le CSS d'un composant uniquement s'il est rendu sur la page, et ce sans passer par du lazy-loading (et donc sans provoquer de FOUC).
C'était énormément de bricolage, car il fallait :
- Détecter lorsqu'un composant était rendu sur la page, ça qui peut se faire en écoutant sur l'event PreCreateForRenderEvent
- Faire comprendre à l'AssetMapper/ImportMap que le CSS d'un composant pouvait être inclus sur la page sans passer par un JavaScript
- faire comprendre à l'AssetMapper/ImportMap que le CSS d'un composant devait être inclus ou non sur la page, selon si le composant avait été rendu sur cette page
Je suis arrivé à mes fins en remplaçant le service par \Symfony\Component\AssetMapper\ImportMap\ImportMapGenerator
par un service customisé, en surchargeant la méthode getImportMapData()
.
Ce n'était pas très beau à voir, cette partie du code de l'AssetMapper n'est vraiment pas faite pour être étendue facilement : j'ai jonglé entre décoration et héritage, surcharge de méthode et bricolage pour savoir si un MappedAsset
provenait d'un composant ou non.
Enfin, puisque le rendu de l'importmap
se fait dans le <head>
, il n'était pas possible d'interférer sur son contenu selon si un composant était rendu sur la page ou non. Pour ce faire, j'ai dû adapter le template Twig de base pour faire le rendu de l'importmap
avant le </body>
:
1<!DOCTYPE html> 2<html> 3 <head> 4 <meta charset="UTF-8"> 5 <title>{% block title %}Welcome!{% endblock %}</title> 6 <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> 7 {% block stylesheets %} 8 {% endblock %} 9 10 <!-- Importmap: placeholder --> 11 </head> 12 <body> 13 {% block body %}{% endblock %} 14 15 {% block javascripts %} 16 <!-- Importmap: begin --> 17 {% block importmap %}{{ importmap('app') }}{% endblock %} 18 <!-- Importmap: end --> 19 {% endblock %} 20 </body> 21</html>
Il n'est en revanche pas souhaitable d'avoir cette importmap
en dehors du <head>
, on peut remédier à ce problème en écoutant sur l'event Symfony\Component\HttpKernel\Event\ResponseEvent
:
1<?php 2declare(strict_types=1); 3 4namespace App\EventListener; 5 6use Symfony\Component\EventDispatcher\Attribute\AsEventListener; 7use Symfony\Component\HttpKernel\Event\ResponseEvent; 8 9#[AsEventListener] 10final readonly class ImportMapResponseListener 11{ 12 private const IMPORTMAP_PLACEHOLDER = '<!-- Importmap: placeholder -->'; 13 private const IMPORTMAP_BEGIN = '<!-- Importmap: begin -->'; 14 private const IMPORTMAP_END = '<!-- Importmap: end -->'; 15 16 public function __invoke(ResponseEvent $event): void 17 { 18 if (!$event->isMainRequest()) { 19 return; 20 } 21 22 $response = $event->getResponse(); 23 $content = $response->getContent(); 24 25 if (!str_contains($content, self::IMPORTMAP_PLACEHOLDER) 26 || !str_contains($content, self::IMPORTMAP_BEGIN) 27 || !str_contains($content, self::IMPORTMAP_END) 28 ) { 29 return; 30 } 31 32 $importMap = substr( 33 $content, 34 $begin = strpos($content, self::IMPORTMAP_BEGIN) + strlen(self::IMPORTMAP_BEGIN), 35 strpos($content, self::IMPORTMAP_END) - $begin, 36 ); 37 38 $content = str_replace($importMap, '', $content); 39 $content = str_replace(self::IMPORTMAP_PLACEHOLDER, trim($importMap), $content); 40 41 $response->setContent($content); 42 } 43}