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 :

composer.json
  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/ :

config/services.yaml
  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 :

config/packages/asset_mapper.yaml
1framework:
2    asset_mapper:
3        # The paths to make available to the asset mapper.
4        paths:
5            - assets/
config/packages/twig_component.yaml
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>) :
templates/base.html.twig
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 :
component/Alert/Alert.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 :
component/Alert/Alert.html.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 :
component/Alert/Alert.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 :
component/Alert/alert_controller.js
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 :

config/packages/twig_component.yaml
  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 :

votre_template.html.twig
 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/ :

config/packages/asset_mapper.yaml
  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 :

config/packages/stimulus.yaml
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 👏

Rendu de nos alertes

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 :

component/Alert/alert_controller.js
  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 :

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 :

config/packages/ui_components.yaml
 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 :

  1. Détecter lorsqu'un composant était rendu sur la page, ça qui peut se faire en écoutant sur l'event PreCreateForRenderEvent
  2. Faire comprendre à l'AssetMapper/ImportMap que le CSS d'un composant pouvait être inclus sur la page sans passer par un JavaScript
  3. 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> :

templates/base.html.twig
 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 :

src/EventListener/ImportMapResponseListener.php
 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}