Hotwire Turbo: Drive, Frames et Streams pour des projets en Ruby on Rails

Hotwire Turbo: Drive, Frames et Streams pour des projets en Ruby on Rails

Le créateur de Rails, DHH (David Heinemeier Hansson), a toujours montré son mécontentement envers JavaScript et TypeScript en particulier, ces dernières années. Il suggère que les difficultés d’apprendre un nouveau langage de programmation, qui te déconcentre de ton travail sur les règles de ton entreprise, peuvent être désavantageuses à long terme. Pour ce cas, dans leur propre entreprise Basecamp, il a créé un framework qui utilise JavaScript en interne à travers Ruby, ainsi tu ne le dois pas faire. L’objectif est de se concentrer principalement sur ton code Backend (Ruby dans ce cas), ta logique d’entreprise, au lieu de configurations complexes sur JavaScript, spécialement depuis une perspective pour faire des SPAs (Applications à page unique).

J’ai fait une présentation sur ce sujet en mai 2024 lors du meetup de Montreal.rb. Comme certaines personnes n’aiment pas regarder des vidéos ou des diapositives, ou préfèrent une version écrite, voici une version résumée. Tu peux la mettre en favoris et y revenir si tu oublies certains concepts. Cependant, ce post est étroitement lié à l’application démo RoR que j’ai préparée pour la présentation, télécharge-la et essaie-la. Je parie que les concepts seront beaucoup plus clairs une fois que tu l’auras testée et exécutée.

Liens intéressants

Pourquoi éviter l’usage de JavaScript. Mon opinion en tant que développeur NodeJS

Je dirais que c’est vrai, en tant que développeur fréquent en TypeScript, tu verras que chaque jour tu te trouveras dans le dilemme de comment organiser ou ajouter les types à la logique d’entreprise. Je dis que JavaScript n’a pas une courbe d’apprentissage facile pour un développeur débutant, en particulier pour commencer à partir de l’étape de build.

Comme TypeScript n’est pas supporté dans le navigateur ou le runtime Node par défaut, tu dois configurer ton environnement de développement, en te concentrant sur les étapes de build et de watch pour supporter TypeScript. Il y a beaucoup de projets préconfigurés là-bas, mais malgré cela, il n’y a pas d’options pour le développeur débutant qui pourrait vouloir essayer TypeScript. Et on ne va même pas commencer à parler des tuples, des génériques, etc.

Alors, on sacrifie la vitesse de développement pour ajouter un typage fort à JavaScript. Ne me comprends pas mal, je suis d’accord qu’à long terme c’est mieux d’avoir un typage fort, surtout pour des objets JSON grands et complexes. Mais, comme un point de départ, ça peut décourager les débutants de continuer à apprendre la programmation, à écrire du code fonctionnel qui résout des problèmes réels, et à l’avenir, ils peuvent devenir experts dans leur domaine. Ils devraient éviter de se créer des problèmes inutiles.

Pourquoi Turbo?

Turbo est la solution pour créer des expériences SPA en utilisant principalement tes connaissances en Ruby. Tu peux écrire des applications qui peuvent remplacer de nombreuses fonctionnalités qu’une SPA aurait, comme le chargement rapide des pages et la modification de parties spécifiques du DOM.

L’un des avantages des versions modernes de Ruby on Rails, le framework d’application web pour créer des applications web en Ruby, c’est qu’il est livré avec Turbo installé par défaut.

Turbo Drive : Précharge les pages

Un des facteurs clés pour choisir une application à page unique (SPA), c’est la vitesse à laquelle chaque page se charge. Après que tu as envoyé la première requête au serveur, il renvoie tout le JavaScript nécessaire pour s’exécuter dans ton navigateur. C’est là qu’intervient Turbo Drive. Il aide à précharger n’importe quelle page à partir d’un lien que tu veux visiter et il garde également une trace historique en même temps.

Bien sûr, tu peux étendre cette fonctionnalité pour des liens spécifiques, ce qui est utile s’il y a des liens qui sont vraiment lourds à charger. Sinon, comme ils se chargent de manière asynchrone, cela va donner à tes clients l’impression d’une navigation rapide.

Recharger des styles ou du JavaScript lorsqu’ils changent

Étant donné que tu fais souvent des modifications à ton code JavaScript, tu peux indiquer à Turbo Drive de recharger du code JavaScript ou CSS lorsque des modifications sont apportées à l’un d’eux. Assure-toi simplement d’ajouter les balises HTML requises.

<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>

Barre de chargement en Turbo

Tu peux habiliter la barre de chargement encore, comme les dernières versions de Rails. T’as besoin de faire des modifications a ton code.

La barre apparaît sur les pages qui mettent plus de 500 millisecondes à charger, on peut configurer le minimum à 0. Comme ça, on va toujours la montrer.

On va utiliser notre fichier principal de JS pour le montrer.

import { Turbo } from "@hotwired/turbo-rails";
// Ça réduit le temps de chargement de 500ms à 0ms.
Turbo.setProgressBarDelay(0);

Aussi, tu peux changer la couleur de la barre de chargement. Il suffit de mettre à jour la classe .turbo-progress-bar.

.turbo-progress-bar {
height: 5px;
background-color: red;
}

Utilisation en JavaScript

On peut utiliser les fonctions et les propriétés de Turbo Drive depuis JavaScript. Voici un exemple.

import { Turbo } from "@hotwired/turbo-rails";
// Naviguant vers l'avant
Turbo.visit(location);
Turbo.visit(location, { action: "navigate" });
// Remplacer le sommet de l'historique
Turbo.visit(location, { action: "replace" });
// Restaure l'historique (réservé pour notre utilisation interne)
Turbo.visit(location, { action: "restore" });

Utilisation en HTML

Et voici deux exemples en HTML. Ils sont compatibles avec ERB.

<!-- Naviguer vers un autre lien -->
<a href="/edit">Modifier</a>
<!-- Remplacer tout l'historique -->
<a href="/edit" data-turbo-action="replace">Modifier</a>

Morphing

On peut spécifier comment on met à jour la page et comment le contenu est renouvelé en utilisant le code suivant.

<head>
...
<meta name="turbo-refresh-method" content="morph" />
</head>

Conservation du déplacement vertical

On peut aussi garder la position du défilement pendant la navigation. Par exemple, si on revient à un long article, en tant qu’utilisateur, tu aimerais revenir à la même position où tu as arrêté de lire avant de visiter le lien dans la publication.

<head>
...
<meta name="turbo-refresh-scroll" content="preserve" />
</head>

Exclure le contenu du morphing

C’est une caractéristique très intéressante si tu veux garder des sections exclues du morphing. On pourrait dire, par exemple, pour garder les messages d’alerte ou les informations qui persistent entre les pages.

<div data-turbo-permanent>...</div>

Méthodes d’aide avec le gemme de Ruby

De plus, quand on utilise la gemme de Turbo Drive, on peut profiter des fonctions et des helpers que la gemme nous donne. Voici une liste d’exemples avec quelques méthodes les plus utilisées.

<% turbo_exempts_page_from_cache %>
<% turbo_exempts_page_from_cache_tag %>
<% turbo_page_requires_reload %>
<% turbo_page_requires_reload_tag %>
<% turbo_refresh_method_tag(method = :replace) %>
<% turbo_refresh_scroll_tag(scroll = :reset) %>
<% turbo_refreshes_with(method: :replace, scroll: :reset) %>

Écrire des tests pour Turbo Drive

Pour tester ton code, tu peux utiliser la même approche que celle que tu utiliserais pour tester ton code Ruby on Rails.

Pour beaucoup d’applications CRUD là-bas, on doit s’assurer que notre application exécute les 4 actions principales (Créer, Mettre à jour, Lire, Effacer).

Regarde le code suivant, il n’y a rien de nouveau avec Turbo Drive.

class JobsControllerTest < ActionDispatch::IntegrationTest
test "on devrait lister" do
get jobs_path
assert_response :success
end
test "on devrait créer un boulot" do
job_count_first = Job.count
post jobs_path, params: { job: { name: 'Nouveau Boulot', status: :active, tag_id: tags(:tag_ruby).id } }
job_count_last = Job.count
assert_equal job_count_first + 1, job_count_last
assert_redirected_to job_path(Job.last)
end
# ... d'autres tests
end

Turbo Frames : chargement d’autres pages dans la même page, comportement des composants

Travailler avec Turbo Frames, c’est comme gérer des composants dans un Framework Frontend.

On peut créer des partiels dans une autre vue, puis utiliser la vue parente pour les charger.

Une fois que le changement est fait, tu peux lancer un ordre de rafraîchir ou le recharger dans le même conteneur.

Exemple de Turbo Frame

Un Turbo Frame déclaré seul en HTML ressemblera au code suivant.

<turbo-frame id="my-refreshing-frame" refresh="morph"> ... </turbo-frame>

Propriétés d’un Turbo Frame

Les Turbo Frames nous permettent de modifier leur comportement avec les propriétés suivantes :

  • src : une URL avec un “path” qui contrôle la navigation d’un élément.
  • loading : a deux valeurs, eager et lazy. loading="eager" chargera le frame immédiatement, tandis que loading="lazy" chargera le frame quand il devient visible.
  • busy : C’est un attribut booléen qui indique si le frame est en train de charger. C’est géré par Turbo.
  • disabled : On l’utilise pour désactiver la navigation dans le frame.
  • complete : C’est un attribut booléen qui indique si le frame a fini de charger. C’est géré par Turbo.
  • autoscroll : C’est un attribut booléen qui indique si on doit faire défiler automatiquement le frame en haut de la page après le chargement. C’est géré par Turbo.

De plus, comme on peut utiliser Turbo depuis JavaScript, n’oublie pas qu’on a accès à toutes ces propriétés aussi.

  • FrameElement.disabled
  • FrameElement.loading
  • FrameElement.loaded
  • FrameElement.complete
  • FrameElement.autoscroll
  • FrameElement.isActive
  • FrameElement.isPreview

En utilisant la gemme

Si tu utilises la gemme, tu peux également utiliser les helpers pour Turbo Frame.

<%= turbo_frame_tag "tray", src: tray_path(tray) %>
# => <turbo-frame id="tray" src="http://example.com/trays/1"></turbo-frame>
<%= turbo_frame_tag tray, src: tray_path(tray) %>
# => <turbo-frame id="tray_1" src="http://example.com/trays/1"></turbo-frame>
<%= turbo_frame_tag "tray", src: tray_path(tray), target: "_top" %>
# => <turbo-frame id="tray" target="_top" src="http://example.com/trays/1"></turbo-frame>
<%= turbo_frame_tag "tray", target: "other_tray" %>
# => <turbo-frame id="tray" target="other_tray"></turbo-frame>
<%= turbo_frame_tag "tray", src: tray_path(tray), loading: "lazy" %>
# => <turbo-frame id="tray" src="http://example.com/trays/1" loading="lazy"></turbo-frame>
<%= turbo_frame_tag "tray" do %>
<div>My tray frame!</div>
<% end %>
# => <turbo-frame id="tray"><div>My tray frame!</div></turbo-frame>
<%= turbo_frame_tag [user_id, "tray"], src: tray_path(tray) %>
# => <turbo-frame id="1_tray" src="http://example.com/trays/1"></turbo-frame>

Deux Turbo Frames dans la même page

On peut utiliser plusieurs Turbo Frames sur la même page. Je vais utiliser une version faite avec des balises HTML pour générer deux frames dans la même vue. Les objectifs à résoudre sont :

  • Afficher un formulaire <turbo-frame/> qui stocke des balises, qui sera pris d’un partiel.
  • Afficher la liste des balises à côté, qui sera rafraîchie quand on envoie le formulaire.
  • On évite de rafraîchir la page.
app/views/tag_frame/index.html.erb
<turbo-frame id="<%= TagFrameController::TAG_FRAME_FORM_ID %>" src="<%= new_tag_frame_path(@tag) %>">
</turbo-frame>
<turbo-frame id="<%= TagFrameController::TAG_FRAME_ID %>">
<!-- code pour afficher le tableau -->
</turbo-frame>

Le premier <turbo-frame> montre une propriété src, qui est la vue/partielle d’où on prend le contenu. Dans ce cas, c’est la route qui rend la vue new. Voici le contenu.

app/views/tag_frame/new.html.erb
<turbo-frame id="<%= TagFrameController::TAG_FRAME_FORM_ID %>">
<%= form_with model: @tag, url: tag_frame_index_path, data: { turbo_frame: TagFrameController::TAG_FRAME_ID } do |form| %>
<div class="mb-2">
<%= form.label :name %>
<%= form.text_field :name %>
</div>
<%= form.submit "Créer balise", class: "block mt-7 py-3 bg-black rounded text-white text-center w-full" %>
<% end %>
</turbo-frame>

Comme tu peux le voir, on a une propriété data: { turbo_frame: TagFrameController::TAG_FRAME_ID } dans le formulaire. Cela signifie qu’une fois que le formulaire est traité, on va afficher le contenu dans ce frame cible. Donc, notre backend ressemblera à ça :

class TagFrameController < ApplicationController
TAG_FRAME_ID = "tags-frame"
TAG_FRAME_FORM_ID = "tags-frame-form"
def index
@tags = Tag.all
@tag = Tag.new
end
def create
@tag = Tag.new(tag_params)
if @tag.save
redirect_to tag_frame_index_path
else
redirect_to new_tag_frame_path
end
end
def new
@tag = Tag.new
end
private
def tag_params
params.require(:tag).permit(:name)
end
end

Quelques trucs intéressants sur Turbo Frames

Je n’ai pas couvert cela dans ma présentation, mais tu peux ajouter des actions supplémentaires avec Turbo Frames, y compris des fonctionnalités intéressantes comme:

  • Charger des frames en différé (lazy loading)
  • Mise en cache
  • Protection contre la falsification de requête sur des sites croisés (Cross-Site Request Forgery, CSRF)
  • Navigation à partir d’un Frame

Réalisation de tests sur Turbo Frames

Les tests fonctionnent de la même manière que pour n’importe quelle application Rails classique. Mais, dans ce cas, vérifie qu’on utilise la même URL pour exécuter les actions de création et de liste, parce que les deux actions sont exécutées depuis la même route.

class TagFrameControllerTest < ActionDispatch::IntegrationTest
test "on devrait lister" do
get tag_frame_index_path
assert_response :success
end
test "on devrait créer une étiquette" do
post tag_frame_index_path, params: { tag: { name: 'Nouvelle étiquette' } }
assert_redirected_to tag_frame_index_path
end
# ... d'autres tests
end

Turbo Frames: On charge seulement les données mises à jour

Dans Turbo Frame, on charge des grands morceaux de vues. Avec Turbo Stream, tu ne rends que les données dont tu as vraiment besoin, en spécifiant leur comportement, comme l’ajout, la suppression, le remplacement, etc.

Cette caractéristique peut être combinée optionnellement avec Websockets. Du coup, toute nouvelle info stockée dans ta base de données apparaîtra en temps réel pour tout le monde.

Cependant, la documentation de Turbo Stream affirme ce qui suit :

C’est une bonne pratique de commencer ton design sans Turbo Streams. Développe ton application complète comme si Turbo Streams n’était pas disponible, ensuite, ajoute-le au fur et à mesure que tu grandis. Ça signifie qu’on ne dépendra pas lourdement des mises à jour pour les flux qui doivent fonctionner avec des applications natives ou ailleurs.

De plus, si tu l’utilises avec Ruby on Rails, tu peux tirer parti d’Action Cable et d’Active Jobs pour rendre le contenu comme nécessaire. Pour cet exemple, on va utiliser la gemme dans un projet Ruby on Rails.

Fonctionnalité supplémentaire lors du rendu

Si tu veux déclencher des effets secondaires lorsque tu réalises un rendu en Turbo Stream, peut-être que tu voudrais utiliser des contrôleurs Stimulus. Pour cela, tu devrais travailler sur des fichiers JavaScript.

Les actions

Avec Turbo Streams, on a huit actions (ou comportements) disponibles. Ces spécifient comment ton contenu s’ajoutera au contenu déjà rendu.

Ces actions sont:

  • append : On ajoute le contenu à la fin de l’élément spécifié par l’objectif.
  • prepend : On ajoute le contenu au début de l’élément spécifié par l’objectif.
  • replace : Remplace tout le contenu de l’élément cible avec le contenu donné.
  • update : Remplace l’élément cible avec le contenu donné.
  • remove : On efface l’élément cible du DOM.
  • before : On insère le contenu donné juste avant l’élément cible.
  • after : On insère le contenu donné juste après l’élément cible.
  • morph : On remplace le contenu en utilisant la technique de morphing sur l’élément cible.
  • refresh : On rafraîchit le contenu à l’intérieur de l’élément cible.

Structure d’un rendu en Turbo Stream

<turbo-stream action="append" target="dom_id">
<template> Contenu à ajouter au conteneur désigné avec l'ID dom_id </template>
</turbo-stream>

Maintenant, pour permettre à Turbo Streams d’insérer du contenu dans le HTML, on doit pré-rendre un élément avec un id cible, comme dom_id par exemple.

Ça signifie que, si on veut remplacer ou transformer un élément spécifique, ton HTML ressemblera à ça :

<tr id="<%= dom_id job_application %>">
...
</tr>

Mais, si on veut ajouter quelque chose au début, ajouter à la fin ou faire une action similaire à l’élément parent, on doit ajouter un identifiant unique.

<tbody id="<%= JobApplicationController::JOB_APPLICATION_STREAM_ID %>">
...
</tbody>

Ensuite, si on veut créer un nouvel enregistrement et l’ajouter à notre propre tableau, le code du contrôleur ressemblera à ceci :

class JobApplicationController < ApplicationController
#...
def create
@job_application = Application.new(job_application_params)
@job_application.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
# On affiche la liste
turbo_stream.prepend(JOB_APPLICATION_STREAM_ID, partial: 'job_application/job_application', locals: { job_application: @job_application }),
# Comme c'est un tableau, tu peux ajouter un autre turbo stream ici
]
end
end
end
#...
private
def job_application_params
params.require(:application).permit(:job_id, :name, :cover_letter)
end
end

C’est pourquoi tu as besoin d’un identifiant parent. On va voir un exemple où on doit enlever un élément spécifique du tableau.

class JobApplicationController < ApplicationController
#...
def destroy
@job_application = Application.find(params[:id])
@job_application.destroy
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.remove(@job_application)
end
end
end
end

L’utilisation de la gemme Ruby nous donne beaucoup d’avantages et elle offre des méthodes déjà établies pour interagir avec Turbo.

Écrire des tests pour Turbo Streams

On doit être très précis sur le type de réponse qu’on attend du contrôleur. Selon le cas, on doit indiquer à notre outil de test qu’on attend un objet “stream”.

Pour changer ta façon de faire des tests, tu ajoutes as: :turbo_stream à la fin de ton appel de route. Regarde l’exemple suivant.

class JobApplicationControllerTest < ActionDispatch::IntegrationTest
test "on devrait créer l'application de travail avec turbo stream" do
compte_application = Application.count
post job_application_index_path, params: { application: { job_id: jobs(:job_javascript_sr_developer).id, name: 'John Doe', cover_letter: 'Je suis un excellent développeur' } }, as: :turbo_stream
compte_application_apres = Application.count
assert_equal compte_application + 1, compte_application_apres
assert_response :success
end
# ... autres tests
end

Conclusions

Souviens-toi que la majorité du contenu de cet article a été résumée pour être présentée en diapositives pendant une heure. Bien sûr, je ne vais pas couvrir chaque aspect, mais l’objectif est de te montrer les concepts de base pour que tu puisses commencer ta propre aventure en pratiquant et en apprenant Turbo, et l’utiliser dans ton prochain projet.

Utilise Turbo quand tu as l’occasion, surtout si tu travailles avec beaucoup de programmeurs Ruby talentueux. Le changement de contexte et de connaissance sera minimisé et le temps sera consacré à créer plus de fonctionnalités pour les utilisateurs au lieu de chercher comment transpiler correctement ton code TypeScript et quels outils utiliser.

Est-ce que je recommanderais aux développeurs d’arrêter d’apprendre JavaScript parce qu’on a des outils comme Turbo ? Pas du tout. Au contraire, je les encouragerais à continuer à apprendre, mais une fois qu’ils ont créé quelque chose, que ce soit au travail ou dans leurs propres projets. JavaScript est largement utilisé partout dans le monde et probablement, pour n’importe quel travail que tu cherches, on va te demander une connaissance de base à intermédiaire de JavaScript. De plus, souviens-toi que si tu as besoin de faire des choses supplémentaires avec Turbo, tôt ou tard tu devras travailler avec des contrôleurs Stimulus, qui sont écrits en JavaScript.

Mes articles ne sont pas generés par l'IA, cependant ils pourrait y être corrigés. Le premier brouillon est ma création originale

Tags

Auteur

Écrit par Helmer Davila

Dans d'autres langues

Llevando tu app al siguiente nivel, sin JavaScript

Hotwire Turbo: Drive, Frames y Streams para proyectos en Ruby on Rails

Taking your app to the next level, without JavaScript

Hotwire Turbo: Drive, Frames and Streams for Ruby on Rail projects

Articles connexes

Rails 6 + MySQL + PHPMyAdmin

Rails 6: Execution de Docker avec PHPMyAdmin

En utilisant Docker pour créer des environnements dans des conteneurs

Rails 7 avec Ruby 3, MySQL 8 et Redis sur Docker Alpine