Symfony UX Turbo : peut-on se passer de JavaScript pour nos applications modernes ?

Charles

Charles, Développeur web 22 avril 2021

Aujourd’hui, les applications dites “modernes” rendues interactives par JavaScript sont la norme pour concevoir une expérience utilisateur efficace voire optimale. Néanmoins, cela s’accompagne de quelques inconvénients : plus long et complexe à concevoir, duplication de logique entre front et back, etc. Symfony UX Turbo peut être une solution face à ces problèmes.

Symfony UX Turbo : concevoir des applications modernes efficacement

Dernier bundle en date de l’initiative Symfony UX, Symfony UX Turbo permet de donner à nos applications Symfony une expérience utilisateur similaire à une Single Page Application (aucun rechargement entre les pages, navigation fluide...). Cela se fait sans écrire une seule ligne de JavaScript grâce à l’implémentation de la librairie Turbo dans notre application.

Qu’est-ce que l'utilisation de ce bundle va réellement nous apporter ? Comment va-t-il répondre à nos besoins de conception de Single Page Application sans écrire de JavaScript ? Nous allons voir ceci tout de suite !

Afin de commencer au mieux l'introduction de ce bundle, voyons plus en détail ce qu’est Symfony UX et ce que les bundles de cette initiative peuvent nous apporter.

L’initiative Symfony UX

Jusqu’à présent, concevoir une expérience utilisateur satisfaisante n’est pas chose aisée, et cela passe par la mise en place d’un front-end solide.

Il faut assurer la configuration de nombreuses librairies et l’intégration de chacune d’elles sur les pages de nos applications. Ainsi, la partie front-end communique de manière cohérente avec les informations reçues depuis le back-end.

C’est ici qu’intervient l’initiative UX. Principalement basé sur Stimulus, Symfony UX simplifie grandement la tâche en facilitant la communication entre le front-end et le back-end.

Stimulus est un framework JavaScript faisant partie de l’écosystème Hotwire. Simple d’utilisation, il nous aide à greffer des fonctionnalités JavaScript à notre HTML en y ajoutant simplement des attributs “data”, reflétant la logique d’un Controller Stimulus.

Symfony UX - en nous fournissant d'ores et déjà de nombreux bundles visant à faire le lien entre notre application Symfony et certaines librairies JavaScript - nous offre la possibilité de configurer entièrement le fonctionnement et les données de ces librairies dans notre code PHP ou nos balises HTML, et gérer toute la partie JavaScript pour nous.

Et comme UX, ainsi que tous ses bundles, sont basés sur Stimulus, il est très simple de surcharger la configuration de base des bundles en utilisant nos propres Controllers Stimulus.

Voici la liste des bundles UX disponibles :

  • UX Chart.js : Intégration de la librairie Chart.js, permettant de faire afficher des graphiques
  • UX Cropper.js : Intégration de la librairie Cropper.js, permettant de redimensionner ou recadrer des images
  • UX Dropzone : Zones de drag-and-drop dans les formulaires
  • UX LazyImage : Amélioration des performances sur le chargement des images avec du lazy-loading
  • UX Swup : Intégration de la librairie Swup, permettant de créer des transition entre les pages et donner à notre site la sensation d’être une SPA
  • UX Turbo : Ce qui va nous intéresser ici

Il est important de rappeler que, comme chaque nouveauté récente débarquant dans Symfony, UX est en statut expérimental. Cela veut dire qu’il est certes totalement utilisable, mais n’est pas soumis à la politique anti-BC de Symfony.

Qu’est-ce que Symfony UX Turbo ?

Comme évoqué dans l’introduction, Symfony UX Turbo est une implémentation de la librairie JavaScript Turbo au sein de notre application Symfony via l'initiative Symfony UX.

Tout comme Stimulus, Turbo fait partie de l’initiative Hotwire. Son but est de permettre de concevoir une Single Page Application, sans écrire une ligne de JavaScript. Si jamais de la logique en JavaScript nécessite d’être écrite, on pourra naturellement se pencher vers Stimulus pour rester cohérent dans l’utilisation maximale du potentiel des librairies de Hotwire.

Son fonctionnement est très simple : Turbo va écouter un nœud HTML ciblé et, au moment d’une action effectuée dans ce nœud (clic sur un lien, validation d’un formulaire, etc.), une requête sera effectuée en AJAX vers une route prédéfinie. Le contenu de ce nœud va pouvoir être par la suite dynamiquement modifié, remplacé, selon les besoins.

Exemple d’application utilisant UX Turbo

Maintenant que le contexte est posé, passons à la pratique. Afin de ne pas laisser cette introduction à Symfony UX Turbo purement théorique, voici un exemple fictif de quelques utilisations possibles avec ce bundle.

UX Turbo nécessite PHP 7.2+ et Symfony 5.2+. Pour notre exemple, PHP 8.0.3 et Symfony 5.2.6 seront utilisés, en intégrant l’ensemble des Composants du framework.

Pour bien commencer, créons un Controller ainsi que quelques pages pour avoir un semblant de structure :

  • Une page d’accueil, avec du lorem ipsum
  • Une page listant, par exemple, des produits
<?php
// src/Controller/PagesController.php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

final class PagesController extends AbstractController
{
    #[Route('/', name: 'app_homepage')]
    public function home(): Response
    {
        return $this->render('pages/home.html.twig');
    }

    #[Route('/produits', name: 'app_produits')]
    public function products(): Response
    {
        $products = [];

        for ($i = 1; $i <= 5; $i++) {
            $products[] = [
                'title' => "Produit $i",
            ];
        }

        return $this->render('pages/produits/list.html.twig', [
            'products' => $products,
        ]);
    }
}
{# templates/pages/home.html.twig #}

{% extends 'base.html.twig' %}

{% block title 'Accueil' %}

{% block body %}
    <h1>{{ block('title') }}</h1>

    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dolorem neque odio placeat possimus provident! A animi assumenda eveniet exercitationem, impedit in ipsa ipsum labore nam nemo neque quae similique ullam.
{% endblock %}
{# templates/pages/produits/list.html.twig #}

{% extends 'base.html.twig' %}

{% block title 'Liste des produits' %}

{% block body %}
    <h1>{{ block('title') }}</h1>

    <ul>
        {% for product in products %}
            <li>{{ product.title }}</li>
        {% endfor %}
    </ul>
{% endblock %}

Une fois fait, on va rajouter une sympathique (ndlr: très sommaire) navigation dans le template de base.

{# templates/partials/_nav.html.twig #}

<nav>
    <a href="{{ path('app_homepage') }}">Page d'accueil</a> |
    <a href="{{ path('app_produits') }}">Produits</a>
</nav>
{# templates/base.html.twig #}

[...]

<body>
    {% include 'partials/_nav.html.twig' %}
    
    {% block body %}{% endblock %}
</body>

[...]

Maintenant, il est temps d’intégrer UX Turbo à notre application. Pour ce faire, il suffit de lancer la commande “composer require symfony/ux-turbo”, et Flex s’occupera d’installer pour nous le bundle ainsi que ses dépendances (dont Webpack Encore).

Une fois le bundle installé, il suffit d’installer et build les dépendances automatiquement ajoutées dans le fichier package.json et de rajouter les fonctions Twig relatives à Webpack Encore dans le template de base.

{# templates/base.html.twig #}

[...]

{% block stylesheets %}
    {{ encore_entry_link_tags('app') }}
{% endblock %}

[...]

{% block javascripts %}
    {{ encore_entry_script_tags('app') }}
{% endblock %}

[...]

Et voilà, votre application est désormais une SPA ! Vous n’avez qu’à naviguer entre les pages pour constater qu’il n’y a aucun rafraîchissement. Tout est fluide et rapide ! Tout ceci grâce à Turbo Drive, une brique de la librairie Turbo se chargeant de gérer pour nous l’actualisation de la page en Ajax intégral, la gestion de l’historique du navigateur, etc.

Cependant, suivant votre environnement, le chargement de la debugbar peut ralentir ce fonctionnement, vous pouvez dans ce cas là temporairement la supprimer.

Dans une SPA, la fluidité de l’expérience utilisateur ne s’arrête pas à l’absence de rafraîchissement entre les pages lors de la navigation. Elle va bien au-delà. Chaque élément de la page courante doit pouvoir être facilement et rapidement actualisé sans gêner le fonctionnement ni la navigation.

Démonstration avec un formulaire. Un peu plus de code à ajouter, mais ça vaut le coup.

<?php
// src/Form/ContactType.php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

final class ContactType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class, [
                'label' => 'Votre nom',
                'attr' => [
                    'placeholder' => 'Ex : John Smith',
                ],
                'constraints' => [
                    new NotBlank(),
                ],
            ])
            ->add('email', EmailType::class, [
                'label' => 'Votre adresse email',
                'attr' => [
                    'placeholder' => 'Ex : johnsmith@exemple.fr',
                ],
                'constraints' => [
                    new NotBlank(),
                    new Email(),
                ],
            ])
            ->add('subject', TextType::class, [
                'label' => 'Le sujet de votre message',
                'constraints' => [
                    new NotBlank(),
                    new Length(min: 3),
                ],
            ])
            ->add('content', TextareaType::class, [
                'label' => 'Le contenu de votre message',
                'constraints' => [
                    new NotBlank(),
                    new Length(min: 10),
                ],
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'attr' => [
                'novalidate' => true, // On ne veut pas de validation HTML pour cet exemple
            ],
        ]);
    }
}
// src/Controller/PagesController.php

[...]

use App\Form\ContactType;
use Symfony\Component\HttpFoundation\Request;

[...]

    #[Route('/contact', name: 'app_contact')]
    public function contact(Request $request): Response
    {
        $form = $this->createForm(ContactType::class);
        $form->handleRequest($request);

        return $this->render('pages/contact/index.html.twig', [
            'contactForm' => $form->createView(),
        ]);
    }

[...]
{# templates/pages/contact/index.html.twig #}

{% extends 'base.html.twig' %}

{% block title 'Contact' %}

{% block body %}
    <h1>{{ block('title') }}</h1>
    
    <div>
        {% include 'pages/contact/partials/_form.html.twig' with {
            form: contactForm,
        } only %}
    </div>
{% endblock %}
{# templates/pages/contact/partials/_form.html.twig #}

{{ form_start(form) }}
    {{ form_row(form) }}
    
    <button type="submit">Envoyer</button>
{{ form_end(form) }}

Les Turbos Frames

Nous allons désormais aborder une notion très importante qui nous servira pour la suite : les Turbo Frames.

Il s’agit de nouvelles balises HTML ajoutées par Turbo, qui auront pour effet de rendre une partie de template indépendante. Cela permet, entre autres, de ne pas recharger le reste de la page si une interaction est effectuée dans ces balises (cliquer sur un lien, valider un formulaire, etc.).

À la place, une requête est envoyée à l’URL du lien ou en action du formulaire, avec comme indication que cette requête a été déclenchée par Turbo.

{# templates/pages/contact/index.html.twig #}

[...]

<div>
    <turbo-frame id="contact_form">
        {% include 'pages/contact/partials/_form.html.twig' with {
            form: contactForm,
        } only %}
    </turbo-frame>
</div>

[...]
// src/Controller/PagesController.php

[...]

use Symfony\Component\HttpFoundation\Request;
use Symfony\UX\Turbo\Stream\TurboStreamResponse;

[...]

public function contact(Request $request): Response
{
    $form = $this->createForm(ContactType::class);
    $form->handleRequest($request);
    
    if ($form->isSubmitted() && $form->isValid()) {
        $contactName = $form->get('name')->getData();
    
        if (TurboStreamResponse::STREAM_FORMAT === $request->getPreferredFormat()) {
            // Si la requête provient de Turbo, on va la traiter ici !
        }
    
        // Si le client ne supporte pas ou n'accepte pas l'utilisation de JavaScript, l'application doit continuer de fonctionner.
        // Ici, on rajoute un flash message ainsi qu'une redirection classique.
    
        $this->addFlash('success', "Merci pour votre message $contactName !");
    
        return $this->redirectToRoute('app_contact', [], Response::HTTP_SEE_OTHER);
    }
    
    return $this->render('pages/contact/index.html.twig', [
        'contactForm' => $form->createView(),
    ]);

[...]

À cette étape, les erreurs des champs invalides s’affichent automatiquement sur le formulaire à chaque tentative de validation, grâce à Turbo !

Maintenant que nous pouvons recevoir des données provenant de Turbo, il est temps de voir comment les renvoyer, avec une autre notion : Turbo Streams.

Les Turbo Streams sont des balises HTML ayant comme attributs des actions visant à modifier le contenu d’un Turbo Frame existant par un ID ciblé.

Lorsqu’une requête est déclenchée par Turbo, elle est également réceptionnée par ce dernier. Le ou les Turbo Streams, contenu dans le template HTML d’une réponse de type TurboStreamResponse et réceptionnés par Turbo, iront dynamiquement modifier les Turbo Frames ciblés en fonction des actions données.

Voici la liste totale des actions disponibles, pour vous donner une idée de ce qu’il est possible de faire :

  • append
  • prepend
  • replace
  • update
  • remove

Pour continuer l’exemple, nous allons à la fois vider le formulaire si celui-ci est valide, et afficher un message de confirmation.

{# templates/pages/contact/success.stream.html.twig #}

{# Mise à jour du formulaire pour vider les champs #}
<turbo-stream action="update" target="contact_form">
    <template>
        {% include 'pages/contact/partials/_form.html.twig' with {
            form: contactForm,
        } only %}
    </template>
</turbo-stream>

{# Ajout d'un "flash message" au dessus du formulaire, dans la frame "contact_form" #}
<turbo-stream action="prepend" target="contact_form">
    <template>
        <p style="font-weight: bold; color: green">Merci pour votre message {{ contactName }} !</p>

        <hr>
    </template>
</turbo-stream>
// src/Controller/PagesController.php

[...]

$form = $this->createForm(ContactType::class);
$blankForm = clone $form;
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
    $contactName = $form->get('name')->getData();

    if (TurboStreamResponse::STREAM_FORMAT === $request->getPreferredFormat()) {
        return $this->render('pages/contact/success.stream.html.twig', [
            'contactName' => $contactName,
            'contactForm' => $blankForm->createView(),
        ], new TurboStreamResponse());
    }

[...]

Nous en avons terminé avec le formulaire.

Une dernière chose : comme je l’ai indiqué plus haut, les Turbo Frames sont des parties de templates indépendantes, ce qui veut dire qu’ils peuvent être chargés sur n’importe quelle page en lazy-loading !

{# templates/pages/produits/list.html.twig #}

[...]
<turbo-frame id="products_list">
    <ul>
        {% for product in products %}
            <li>{{ product.title }}</li>
        {% endfor %}
    </ul>
</turbo-frame>
[...]
{# templates/pages/home.html.twig #}

[...]

<turbo-frame id="products_list" src="{{ path('app_produits') }}">
    <p>Chargement de la liste des produits...</p>
</turbo-frame>

[...]

Nous avons désormais vu l’essentiel de ce que je voulais vous montrer à propos de Symfony UX Turbo.

Il reste énormément de choses à voir avec, comme la possibilité de brancher Mercure aux streams pour gérer l’affichage en temps réel du modification de contenu chez plusieurs clients.

Conclusion sur Symfony UX Turbo

Les outils de l’écosystème Hotwire (Stimulus, Turbo) sont très intéressants à utiliser, avec une prise en main simple et rapide. L’intégration de ces outils dans Symfony, via l’initiative Symfony UX et le bundle Symfony UX Turbo facilite énormément la conception d’une Single Page Application. Ils allègent de manière conséquente la charge de travail pour les développeurs.

Néanmoins, il est bon de rappeler une chose : cette nouvelle méthode n’a pas pour vocation de remplacer la combinaison framework JavaScript + API exposée pour la conception des SPA.

Utiliser Symfony UX et Symfony UX Turbo, c’est accéder à une nouvelle manière de concevoir une application. Celle-ci mérite d’être évoquée lors de la réflexion des technologies à utiliser pour répondre aux besoins des clients lors des phases de pré-production des projets.