Asset Publisher
Développer une Single Page Application avec Turbo - Partie 3
Rédigé par Benjamin Dreux le 25 janv. 2024
Dans le premier article de cette série, nous avons exploré diverses approches pour développer le front-end d'une application, ainsi que les avantages et inconvénients des deux alternatives.
Dans le deuxième article, nous avons introduit Turbo, un outil qui facilite le processus de développement d'applications présentant une haute fidélité (similaire à celle d'une application monopage), tout en mettant l'accent sur la création principale d'une application multipages.
Nous avons conclu l'article en soulignant que la majorité des cas classiques pouvait être résolue grâce aux fonctionnalités distinctes offertes par Turbo Frame. Cependant, il reste des situations où Turbo n'est pas suffisant, et/ou il est nécessaire de prendre en main le développement en manipulant le JavaScript de manière plus directe.
Inclusion de javascript dans une application
Dans n'importe quel site web, plusieurs choix s'offrent à nous pour intégrer du JavaScript dans une page web.
-
Inclure le JavaScript dans le corps (body) du document avec une balise script. Ce code s'exécutera immédiatement pendant l'interprétation du HTML. S'il est placé en bas de la page, il sera exécuté après l'interprétation complète du HTML. Cette approche peut être intéressante si l'on souhaite attacher des comportements à la présence d'éléments particuliers, car ils auront tous été chargés.
-
Inclure le JavaScript dans l'en-tête (head) du document HTML. C'est généralement l'approche privilégiée, car cet emplacement offre plus de liberté. En effet, les scripts pointés dans l'en-tête peuvent être annotés pour être chargés de manière asynchrone. Cela signifie que le script sera téléchargé, analysé, et évalué en parallèle de l'exécution de la page. De plus, on peut prendre en charge l'importation de scripts en tant que modules JavaScript et exécuter les scripts immédiatement avant que le reste de la page ne soit chargé.
La seule structure supplémentaire que l'on peut adopter est de livrer nos fichiers JS sous forme de modules.
Ce qui est bien, mais ne donne pas beaucoup de structure à la manière dont est bâtie une application. Nous avons tous déjà vu à quoi peut ressembler une application sans structure... Personne ne veut y retourner une fois qu'il/elle l'a quittée, cet endroit terrible!
Structure de code
C'est précisément sur ce point que je trouve que Stimulus apporte un intérêt en offrant une structure, tout comme Rails a introduit un certain modèle de séparation de code avec des dossiers pour les contrôleurs et les modèles.
Lors du développement d'une application de type "single-page app", il est tout à fait normal de séparer les différents concepts, du moins c'est ce à quoi on s'attend. Étant donné la quantité importante de code, son organisation devient cruciale. Cependant, dans le cadre d'une application de type "MPA" (multi-page app), la quantité de JavaScript est généralement réduite à sa plus simple expression, notamment lors de l'utilisation de Turbo Frame ou HTMX. Dans nos expérimentations, la quantité de JavaScript par page varie de 0 à une centaine de lignes.
Dans un contexte comme celui-ci, il est difficile de justifier la mise en place d'une infrastructure de code. Le déploiement de 10 lignes de JavaScript devrait être évident et sans superflu, du moins c'est notre idée. L'échelle est tellement petite que le jeu en vaut difficilement la chandelle. C'est précisément dans ce contexte qu'un cadre (framework) peut apporter un gain non négligeable.
En incitant son utilisateur à adopter une certaine structure et plusieurs normes, un framework rend la lecture plus prévisible. Lors des revues de code effectuées quotidiennement, cela devient d'une grande aide. En adoptant des modèles spécifiques, il est plus facile de repérer ce qui est différent. De plus, cela permet d'appliquer de bonnes pratiques de manière plus cohérente, sans avoir à s'assurer que l'ensemble de l'équipe soit parfaitement au courant de tous les détails de la plateforme sur laquelle nous travaillons.
La standardisation du code offre également des facilités pour l'intégration de notre code dans l'application, comme l'activation automatique du code JavaScript pour chaque page visitée. Le concept de base repose sur un contrôleur, une classe JavaScript associée à un ou plusieurs éléments dans une page.
Voici comment cela fonctionne :
Chaque modification du HTML présenté dans la page est analysée par Stimulus, à la recherche d'éléments portant l'attribut data-controller
.
<div class="ui container" data-controller="ClientList">
...
</div>
Cet attribut permet de définir quel contrôleur Stimulus doit être chargé pour gérer cet élément. Dans notre cas, voici le contrôleur Stimulus qui serait activé.
import * as Stimulus from "stimulus";
class ClientList extends Stimulus.Controller {
connect() {
$(".ui.dropdown", this.element).dropdown();
}
}
export default ClientList;
Comme on peut le voir, Stimulus utilise une logique de classe pour ses contrôleurs. Cette structure rappelle les classes de React, cependant, une distinction importante est à prendre en considération. Avec React, tous les composants sous forme de classes doivent déclarer une méthode render
. Cette fonction permet au composant de produire le rendu du composant. En revanche, avec Stimulus, l'idée de base est de ne pas créer de HTML, mais plutôt d'animer le HTML produit par le backend.
Les versions récentes de React ont abandonné l'idée de composants sous forme de classe au profit des composants sous forme de fonction. Stimulus, quant à lui, emprunte le chemin inverse et se concentre uniquement sur ce qui n'est pas le rendu.
Cette approche permet également à Stimulus d'offrir un branchement déclaratif entre le code HTML et le code JavaScript.
Activation et désactivation d’un controlleur
Quand un contrôleur est mis en place, il est courant de vouloir exécuter du code pour activer le composant.
React utilise ce concept sous le terme de montage/démontage. C'est l'idée de préparer un élément une fois qu'il est affiché. On peut le comparer à un document.load
dédié à un composant/contrôleur.
Un exemple d'utilisation est celui que nous avons démontré précédemment :
class ClientList extends Stimulus.Controller {
static targets = ["imprimer"];
connect() {
$(".ui.dropdown", this.element).dropdown();
}
onPrint(){
this.imprimerTarget.classList.add("printed");
}
}
export default CabinetList;
La fonction connect
est une fonction qui n'est pas nécessaire d'appeler, mais qui sera appelée par Stimulus lorsque le contrôleur est requis par l'un des éléments de la page courante. Notons également this.element
, qui représente la façon dont Stimulus référence l'élément HTML sur lequel est basé le contrôleur courant.
Sélection d’éléments
Dans une application MPA, il est très courant de devoir référencer un ou plusieurs éléments HTML dans le code JavaScript, que ce soit pour modifier un attribut, lire une valeur, ou ajuster le style de cet élément. Ce type d'opérations fait partie intégrante du paysage du JavaScript frontend depuis très longtemps. En témoigne jQuery, qui propose de nombreuses méthodes pour faciliter ce genre d'opérations.
Commençons par ce qui a rendu jQuery populaire : son moteur de sélection similaire au sélecteur CSS. Cette approche de travail est tellement répandue aujourd'hui que les navigateurs l'ont intégrée nativement, non pas dans un navigateur spécifique, mais dans le standard W3C. Depuis 2006 (Selectors API), mais selon Can I Use, c'est plutôt en 2008 que les premières implémentations ont été réalisées pour les principaux navigateurs.
Bien que cet aspect de jQuery ne soit plus nécessaire depuis 15 ans, il est encore présent dans le code de plus de 75% des 10 millions de sites web les plus populaires. En considérant que jQuery n'est plus indispensable, Stimulus s'appuie sur les normes du W3C pour offrir une méthode différente de liaison entre le code JavaScript et le HTML.
Par exemple, dans une application, lorsqu'on fait référence à un élément HTML, il est courant de devoir le référencer plus d'une fois. Prenons l'exemple d'un bouton pour soumettre un formulaire : dans un premier temps, le bouton sera mis en rouge, le temps que l'utilisateur saisisse les informations requises pour satisfaire la validation. Puis, une fois le formulaire rempli et envoyé, en attendant la réponse, on souhaitera désactiver le bouton pour éviter toute double soumission du même formulaire.
Chaque contrôleur peut définir des cibles qui lui doivent être fournies. Du côté du JavaScript, les cibles (targets) sont définies de cette manière.
class ClientList extends Stimulus.Controller {
static targets = ["imprimer"];
connect() {
$(".ui.dropdown", this.element).dropdown();
}
}
export default CabinetList;
Et du côté du HTML, c’est tout aussi simple :
<div class="ui button" data-ClientList-target="imprimer"></div>
Comme on peut le voir, c'est entièrement déclaratif. On remarquera que le nom du contrôleur se retrouve dans l'attribut utilisé, ce qui permet d'avoir deux contrôleurs qui peuvent travailler sur le même sous-arbre HTML. Avec cela en place, on pourra référencer cet élément depuis le contrôleur de manière simple.
class ClientList extends Stimulus.Controller {
static targets = ["imprimer"];
connect() {
$(".ui.dropdown", this.element).dropdown();
}
onPrint(){
this.imprimerTarget.classList.add("printed");
}
}
export default CabinetList;
En plus d'être déclarative, cette approche offre différents avantages.
-
Il n'est plus nécessaire de se poser la question de savoir quand un élément doit être ciblé dans la page. S'il est présent, il sera disponible sous le nom attendu.
-
D'autres fonctionnalités permettent de tester la présence d'une cible, ou la présence de plusieurs occurrences d'une même cible.
-
Une erreur sera générée si jamais on effectue un appel sur une cible qui n'est pas présente. Cela contraste avec le comportement de jQuery qui, dans ce cas, sera simplement indifférent et ne fera rien.
Actions
Si la tâche la plus fréquente de JavaScript est de sélectionner un élément, la seconde est clairement de réagir à un événement. De manière conventionnelle, on pourrait écrire quelque chose dans ce style :
$(".button").on("click", callSomePredefinedFunction );
Cet exemple peut fonctionner très bien, toutefois, il y’a plusieurs éléments à prendre en compte:
-
Présence de l’élément recherché lors de l’assignation du gestionnaire d'événements..
-
Suppression du gestionnaire d'événements.
Dans cet exemple, on attache un gestionnaire d'événements au résultat d'une sélection jQuery. Rappelons-nous que le résultat d'une sélection peut être une liste vide et qu'aucune des fonctions de jQuery que l'on appelle ensuite n'y verra de problème. Ainsi, il est possible que la ligne ci-dessus ne produise aucun effet.
Si cet élément n'est pas dans notre page, c'est potentiellement correct. Cependant, s'il s'agit d'un problème de synchronisation où cette partie du JavaScript est évaluée avant que le HTML correspondant ne soit en place, alors nous sommes confrontés à un problème plus complexe à débusquer. Il faudra soit exécuter cette section de JavaScript ultérieurement, soit différer son exécution, par exemple en utilisant la fonction ready
de jQuery.
Dans une application multipage conventionnelle, chaque changement de page signifie que l’on repart d’un contexte JavaScript complètement nouveau. Ainsi, laisser des écouteurs d'événements non résolus n’est pas aussi problématique, puisqu’on repart d'une feuille blanche régulièrement.
Cependant, avec Turbo, le contexte est conservé d’une page à l’autre. Par conséquent, négliger de résoudre ces écouteurs n'est plus une option acceptable.
La mise en place (et le retrait) approprié d'un écouteur d'événements n'est donc pas aussi évident qu'il n'y paraît. Stimulus propose une solution pour nous aider. Stimulus offre le concept d'action. Une action permet de créer un lien entre un élément, un événement et une fonction.
Voici comment mettre en place un élément dans le HTML
:
<div class="ui primary button" data-action="click->ContactCard#showEditForm">
...
</div>
Comme on peut le voir dans l’exemple ci-dessus, l’attribut data-action permet de définir, pour un élément donné, quel événement doit être attendu et quoi faire lorsque cet événement se produit. Pour que ceci fonctionne, il faut évidemment que l’élément en question soit inclus dans le contexte d’exécution d’un contrôleur, c'est-à-dire dans le sous-arbre HTML pointé par un contrôleur Stimulus.
Dans cet exemple, lorsque l’élément reçoit un événement de clic, Stimulus va passer comme premier argument lors de l’appel de la fonction showEditForm
du contrôleur ContactCard
.
La fonction au bout de cet appel n’a pas besoin d’avoir quelque chose de spécifique ; si elle a besoin de l’événement pour son traitement, ce sera le premier argument. C’est le seul contrat qui soit nécessaire.
Lorsque la page courante contient un attribut data-action, Stimulus va automatiquement mettre en place le gestionnaire d'événements pour nous. Et lorsque la page change et que ce même élément est supprimé, Stimulus va automatiquement retirer le gestionnaire qui avait été mis en place.
Passer de l’information d’un page web vers un controlleur js
Quand on construit une application principalement côté backend, le contenu de la page est l'endroit où l’on garde toute l’information. Parfois, il arrive que l’on ait besoin d'informations qui ne sont pas affichées mais qui sont tout de même nécessaires pour exécuter une fonction JavaScript.
Prenons un exemple pour rendre ceci concret. Dans une application de feuille de temps, pour enregistrer notre temps, on peut effectuer un premier appel HTTP afin de lancer un compteur dans notre feuille de temps. Par la suite, lorsque l’on affiche notre feuille de temps, on souhaite voir le temps écoulé sur ce compteur en direct.
Il est évident que l’on ne veut pas effectuer un appel HTTP à chaque seconde pour cela. Chaque appel HTTP ajoute trop de temps et risque de saturer notre serveur, alors même que cet appel est là pour faciliter la vie des utilisateurs, mais n'est pas essentiel.
L'autre alternative que l’on a est de mettre en place ce qui est nécessaire pour calculer le temps actuellement écoulé dans le JavaScript. Ainsi, la mise à jour visuelle pourrait se faire en temps quasi réel.
Dans notre modèle de données, nous avons besoin de retenir l’heure à laquelle un compteur est lancé. Ensuite, il suffit de calculer le nombre de secondes entre ce moment et l’heure actuelle. Enfin, à partir de la durée calculée, il reste à mettre cette durée dans un format heure/minutes.
Le problème qui persiste est de savoir comment passer de l’information entre la page HTML et le contrôleur Stimulus. Depuis l'arrivée de l'API dataset de HTML (https://caniuse.com/dataset), on a une manière simple de faire ce genre de manipulation. Un attribut data-* nous permet de stocker toutes les informations que l’on souhaite. Toutefois, un attribut data par lui-même ne nous donne pas d’indication sur le type de cette valeur.
Qu'à cela ne tienne, dans notre cas, il suffit de parser un nombre (via parseInt(X, 10)) pour obtenir le nombre de millisecondes depuis l'époque (epoch) et exécuter le reste de notre routine.
Comme pour le cas des actions, Stimulus offre des facilités pour les tâches que les développeurs web font fréquemment, comme c’est le cas dans notre exemple. Ce concept est noté value dans la terminologie de Stimulus. Voici comment on peut définir une value et l’utiliser dans un contrôleur.
<div
class="ui segment raised"
data-controller="TimesheetEdit"
data-TimesheetEdit-stopwatch-value="41243243243242"
>
…
</div>
Ici, on affiche une version statique pour faciliter la compréhension, mais dans un cas réel, ce HTML serait produit par un template.
Dans le contrôleur Stimulus, voici comment on pourrait définir la valeur.
class TimesheetEdit extends Stimulus.Controller {
static values = {
stopwatch: Number,
};
…
}
Et lorsque l’on souhaite utiliser cette valeur, on peut l’utiliser de cette manière :
var currentDuration = durationSince(this.stopwatchValue);
var currentDurationFormatted = format(currentDuration);
this.stopWatchTarget.textContent = currentDurationFormatted;
Cette valeur sera convertie du type texte, dans lequel elle est transmise dans notre HTML, au type JavaScript défini dans le contrôleur, ici ce sera un nombre.
On retiendra que les contrôleurs Stimulus ne cherchent pas à gérer l’état côté client en mémoire comme on le fait dans une application React, par exemple. À la place, on réduit au maximum l’état, et quand on a besoin d’avoir un état, c’est le DOM (Document Object Model) qui va servir de stockage.
Conclusion
Dans les articles précédents, nous avons exploré comment Turbo permet de se passer de JavaScript dans la majorité des cas. Toutefois, lorsque des besoins plus avancés nécessitent une plus grande liberté, JavaScript devient incontournable. C'est à ce moment que Stimulus brille en offrant un cadre structuré pour organiser les aspects mécaniques (chargement de fichiers, exécution de code), tout en laissant aux développeurs la liberté nécessaire.
De plus, Stimulus simplifie les tâches récurrentes en fournissant des utilitaires, notamment la sélection d'éléments, la gestion d'événements, et le passage de valeurs entre le code HTML et le code JavaScript.
L'adoption de Turbo et Stimulus ne s'est pas faite d'un coup pour nous. Nous avons d'abord adopté Turbolinks, le prédécesseur de Turbo, pour un premier projet. Cependant, Turbolinks ne prenait pas en charge l'idée de turbo-frame, se limitant au chargement progressif de pages. Cette adoption a été relativement simple, étant donné que notre application était orientée backend avec une petite dose de « magie » JavaScript. Après quelques mois, nous avons commencé à ressentir des limitations, principalement liées au chargement du code. C'est à ce moment que la première version de Stimulus a été publiée.
Bien que nous n'ayons pas immédiatement adopté Stimulus, car notre projet était presque terminé à l'époque, nous avons commencé à évaluer l'impact de cette bibliothèque sur la production d'applications. Lorsque le projet suivant est apparu, nous étions prêts à adopter Turbo et Stimulus.
Notre parcours d'adoption a été spécifique, mais il est important de noter que ces deux bibliothèques, bien qu'elles fonctionnent particulièrement bien ensemble, ne sont pas étroitement couplées et peuvent être utilisées de manière indépendante.
Nous avons présenté les aspects les plus convaincants du duo Turbo/Stimulus, mais si vous souhaitez en savoir davantage, consultez https://turbo.hotwired.dev et https://stimulus.hotwired.dev pour des détails plus approfondis.