Symfony UX Turbo : 4 outils qui vont donner un coup de boost pour votre application !

Symfony UX : Hotwire Turbo

Hotwire Turbo

Comme nous avons pu le voir dans l'article précédent sur Hotwire Stimulus et Symfony UX Initiative, Hotwire Turbo va également nous mettre à disposition des outils nous permettant d'améliorer l'expérience utilisateur de nos applications sans avoir a écrire de code JavaScript. Hotwire Turbo est vraiment le coeur même du framework Hotwire. Il permet de gérer pratiquement 80% des cas utilisateurs prévus par celui-ci.

Pour cela, nous avons plusieurs composants à disposition :

  • Turbo Drive, qui permet l'amélioration de la navigation entre pages évitant au maximun les re-chargements inutiles de contenus dans une même page, un peu à la manière d'une SPA. C'est la nouvelle évolution de la librairie TurboLinks.
  • Turbo Frames, qui permet de décomposer une page en plusieurs petits blocs indépendants les uns des autres, un peu comme avec des composants ReactJS ou VueJS.
  • Turbo Streams, qui permet au serveur d'envoyer via WebSocket des changements aux clients pour mettre à jour les vues en temps réel.
  • Turbo Native, qui permet d'intégrer Turbo Drive, Turbo Frames et Turbo Streams dans des applications mobiles natives iOS et Android.

Symfony UX Turbo

Contrairement aux composants Symfony UX Stimulus, des optimisations dédiées à Symfony UX Turbo ont été ajoutées dans Symfony 5, notament pour la partie Turbo Streams. Pour en profiter pleinement vous devez donc disposer d'un projet sous Symfony 5.2+ et PHP 7.2+.

Pour commencer, vous devez installer le bundle symfony/ux-turbo-mercure via composer. Comme pour les composants stimulus, nous sommes sur un composant hybride PHP/JS. Vous devez donc installer les dépendances JavaScript via NPM ou Yarn.

> composer require symfony/ux-turbo-mercure && yarn install --force

Je considère que vous avez déjà installé et configuré WebpackEncoreBundle. Si ce n'est pas le cas je vous renvoie vers l'article précédent.

Comme vous l'aurez remarqué, le bundle contient Hotwire Turbo, mais également Mercure. Il s'agit d'un protocole de communication client/serveur (WebSocket), créé par Kévin Dunglas, permettant de transmettre en temps réel des mises à jour de données vers les navigateurs web de manière fiable, rapide et économe en énergie. Vous trouverez plus d'informations sur le site mercure.rocks.

Turbo Drive

Dès que vous aurez installé symfony/ux-turbo-mercure ce composant sera déjà actif, vous n'aurez rien d'autre à faire. Turbo Drive va écouter les interactions utilisateurs sur votre page (clique sur un lien, soumission de formulaire, etc.) et précharger en ajax, en arrière plan, le contenu de la page demandée. Ce fonctionnement est toutefois restreint au domaine courant et pas aux liens externes. Une fois ce préchargement terminé, Turbo Drive va simplement remplacer le contenu de la page existante par le contenu de la page préchargée, mais en ciblant uniquement les zones ou le code a changé, un peu comme si vous faisiez un git merge. Ainsi vous allez éviter d'avoir cet effet de "page blanche" que l'on a lorsqu'on change de page et qu'on attend l'affichage de la page suivante. L'affichage va être plus rapide puisque seules les zones ayant changé vont être mises à jour.

Si le préchargement de la page est trop long, il va s'afficher automatiquement une barre de chargement, qu'il sera possible de customiser via un peu de CSS :

.turbo-progress-bar { ... }

Turbo Drive va également manipuler l'historique du navigateur via l'API History.pushState() pour ajouter une entrée dans l'historique à chaque interaction de l'utilisateur et garder ainsi les fonctionnalités des boutons de navigation du navigateur. Ce processus va même être amélioré via l'utilisation d'un cache local.

Pour affiner le comportement de Turbo Drive, vous avez la possibilité d'utiliser des data-attributes :

  • data-turbo="false" pour désactiver Turbo Drive (exemple)
  • data-turbo-track="reload" pour rafraichir la page aux changements des assets (exemple)
  • data-turbo-action="replace" pour définir un bouton qui va procéder à une mise à jour (exemple)
  • data-turbo-method="delete" pour définir un bouton qui va envoyer une requête de suppression (exemple)

Vous pouvez aller encore plus loin dans la maitrise de ce comportement via différents évènements que vous allez pouvoir utiliser. Cela impliquera cette fois une ou deux lignes de JavaScript. Je vous renvoie à la documentation qui est très bien faite et facile à comprendre :

Sur la documentation de Symfony UX Turbo vous trouverez également deux exemples dans Symfony permettant :

Turbo Frames

Turbo Frames s'apparente à ce qu'on pouvait faire à l'époque avec les <frame> et les <iframe> HTML. Il va utiliser un élément HTML personnalisé utilisant les Web Component afin de définir des sections ou des blocs d'une page. Cela va permettre de créer un contexte et d'isoler chaque bloc de la page.

Si par exemple j'ai un lien dans l'un de ces blocs, lorsque Turbo Drive va récupérer la réponse de ce lien, il ne va toucher qu'à ce bloc là et ignorer le reste de la page. On retrouve un peu le même comportement que les composants React. On va ainsi pouvoir avoir différents blocs sur une même page qui seront isolés et qui vont pouvoir être mis à jour indépendamment les uns des autres sans avoir à recharger la page.

Pour créer un bloc vous devez utiliser la balise <turbo-frame></turbo-frame>. Celle-ci doit obligatoirement avoir un identifiant.

{# layout.html.twig #}
...
<body>
    ...
    <turbo-frame id="header"> ... </turbo-frame>
    ...
    <turbo-frame id="navbar"> ... </turbo-frame>
    ...
    <turbo-frame id="footer"> ... </turbo-frame>
    ...
</body>

Vous trouverez différents exemples sur la documentation de Symfony UX Turbo et celle de Turbo Frames.

Autre point intéressant avec Turbo Frames, c'est qu'il est possible de faire du lazy-loading sur chaque bloc, soit en définissant l'attribut src avec l'URL pointant vers le contenu à charger, soit en ajoutant l'attribut loading="lazy" :

<turbo-frame src="/link-to-get-content-for-this-frame"></turbo-frame>
<turbo-frame loading="lazy"> ... </turbo-frame>

Dernier point important, chaque bloc va pouvoir avoir son propre cache. Cela va nous donner une plus grande flexibilité sur la gestion du cache sur une même page. On pourra différencier facilement une zone qui a besoin d'un cache très court, d'une zone qui a besoin d'un cache très long, comme sur une page e-commerce par exemple (panier vs produits). On va pouvoir gérer ceci comme avec les blocs ESI (Edge-Side-Includes) de Symfony, sauf qu'ici on va le gérer côté client au lieu de le gérer côté serveur.

Il suffit de définir la durée de validité du cache pour chaque route comme ceci :

/** @Cache(public=true, maxage="3600") */
public function products(): Response
{
    return $this->render('product.html.twig');
}

/** @Cache(public=false, maxage="0") */
public function cart(): Response
{
    return $this->render('cart.html.twig');
}

Puis côté template, on ajoute juste l'attribut src pour indiquer quelle route utiliser pour chaque bloc :

{# shop.html.twig #}
...
<turbo-frame 
    id="cart"
    src="{{ fragment_uri(controller('App\\Controller\\ShopController::cart')) }}"></turbo-frame>
...
<turbo-frame 
    id="product"
    src="{{ fragment_uri(controller('App\\Controller\\ShopController::products')) }}"></turbo-frame>

Et c'est tout !

Turbo Streams

Les Turbo Streams vont vous permettre d'ajouter des capacités temps réel à votre application. Concrètement, cela va permettre au serveur d'envoyer des changements à appliquer aux pages déjà chargées par les navigateurs web. À l'aide d'une balise HTML spécifique, vous allez pouvoir pousser des mises à jour dans des zones spécifiques à tous les utilisateurs connectés.

Pour cela Turbo Streams va avoir besoin d'un protocole temps réel du type WebSocket ou Mercure. Vous l'aurez compris, c'est le protocole Mercure dont il est question ici, celui-ci étant maintenant nativement supporté sous Symfony 5.2+ grâce aux composants Symfony UX Turbo et notamment au MercureBundle.

Le principe de Mercure, c'est que vous allez avoir à maintenir un serveur, communément appelé "Mercure HUB", qui va maintenir des connexions persistantes de type SSE avec tous les utilisateurs de votre application. Il vous suffira ensuite d'envoyer une simple requête POST depuis votre application vers le HUB pour que celui-ci envoie la mise à jour à tous les clients connectés (voir la documentation).

Pour cela, il va simplement falloir s'abonner à des topics afin de recevoir les mises à jour. Pour cela, on va utiliser la méthode turbo_stream_listen() dans laquelle on passe en argument l'identifiant du topic que l'on doit écouter :

{# home.html.twig #}
...
<div {{ turbo_stream_listen('topic_id') }}>
    <h1 id="title">{{ news.title }}</h1>
    <p id="content">{{ news.content }}</p>
</div>

Maintenant, pour pousser une mise à jour vers le HUB :

// NewController.php
...
/** @HubInterface $hub */
$hub->publish(
    new Update('topic_id', $this->renderView('news.html.twig', ['news' => $news]));
);
{# news.stream.html.twig #}
...
<turbo-stream action="update" target="title">
    <template>{{ news.title }}</template>
</turbo-stream>
...
<turbo-stream action="update" target="content">
    <template>{{ news.content }}</template>
</turbo-stream>

Sur le template on ajoute les balises <turbo-stream></turbo-stream> afin d'indiquer à Turbo les actions à faire (ici update), sur quel identifiant agir (ici, title et content), et quel contenu ajouter, celui-ci étant défini entre les balises <template></template>. Dans mon exemple je veux donc juste mettre à jour le titre et le contenu de mon actualité.

On dispose également d'une intégration entre Turbo Stream et Doctrine ORM. On va ainsi pouvoir notifier tous les clients connectés au HUB Mercure dès qu'une mise à jour va être faite sur une entité dans votre base de données. C'est intégré nativement dans le MakerBundle, vous avez juste à ajouter le flag --broadcast lorsque vous créez vos entités. Sinon, vous pouvez le faire manuellement, il s'agit juste d'ajouter une annotation spécifique :

# Via MakerBundle
php bin/console make:entity --broadcast Comment
php bin/console make:crud Comment

# Manuellement
// src/Entity/Comment.php 

use Symfony\UX\Turbo\Attribute\Broadcast;

/**
 * @ORM\Entity()
 * @Broadcast()
 */
clas Comment {
    // ...
}

Ensuite il faut créer le template de base (créé automatiquement avec le MakerBundle) :

{# templates/broadcast/Comment.stream.html.twig #}

{% block create %}
<turbo-stream action="append" target="comments">
    <template>
        <div id="{{ 'comment_' ~ id }}">
            {{ entity.content }}
        </div>
    </template>
</turbo-stream>
{% endblock %}

{% block update %}
<turbo-stream action="update" target="comment_{{ id }}">
    <template>
        {{ entity.content }}
    </template>
</turbo-stream>
{% endblock %}

{% block remove %}
<turbo-stream action="remove" target="comment_{{ id }}"></turbo-stream>
{% endblock %}

Enfin, pour déclarer le listener, c'est comme précédemment, sauf que le topic va être le namespace de l'entité en question :

{# templates/news/comment.html.twig #}

<h1>Comments</h1>

<div id="comments" {{ turbo_stream_listen('App\\Entity\\Comment') }}>
    {% for comment in comments %}
        <div id="{{ 'comment_' ~ comment.id }}">
            {{ comment.content }}
        </div>
    {% endfor %}
</div>

Si l'on veut écouter uniquement une instance précise, il suffit de passer l'instance en paramètre à la place du namespace.

Comment créer un HUB Mercure ? C'est très simple, vous avez trois possibilités :

  1. En développement, via Symfony CLI. Symfony détectera automatiquement la présence du Hub Mercure et l'utilisera. Sinon vous devrez installer un HUB local (avec docker par exemple) et définir une variable d'environnement :
  2. En production, vous pouvez utiliser le HUB officiel disponible sur le site mercure.rocks. Il y a une version basique gratuite, et des versions managées payantes selon vos besoins.
  3. Le protocole Mercure étant open-source, vous pouvez également écrire votre propre HUB ou utiliser l'un des HUB créés par la communauté. Voici un exemple de HUB Mercure proposé en PHP : bpolaszek/mercure-php-hub

Turbo Native

Comme indiqué au début de cette article, Turbo Native permet l'intégration des trois composants ci-dessus dans les applications mobiles. N'étant pas développeur mobile, je n'ai pas pu le tester personnellement. Je vous renvoie donc directement vers le GitHub de chaque OS :

  • Turbo Native pour iOS fournit les outils nécessaires pour envelopper votre application Web compatible Turbo dans un shell iOS natif. Il gère une seule instance WKWebView sur plusieurs contrôleurs de vue, vous offrant une interface utilisateur de navigation native avec tous les avantages de performances côté client de Turbo.
  • Turbo Native pour Android fournit le même type d'outils, gérant une seule instance WebView sur plusieurs fragments.

Conclusion

Symfony UX Turbo est un outil vraiment puissant et complet qui va dans de nombreux cas répondre à tous les besoins que l'on peut avoir sur nos projets sans avoir à passer par un framework FRONT complexe comme ReactJS ou VuesJS. Comme on a pu le voir, son utilisation est vraiment très simple et comme promis ne nécessite aucune ligne de code JavaScript. Sur ce point le contrat est vraiment bien rempli, que ce soit avec Symfony UX Turbo ou avec Symfony UX Stimulus.

J'espère que ce tour d'horizon vous aura inspiré et donné envie de tester ces nouveaux outils ! Merci à vous d'avoir pris le temps de lire cet article.