Récemment, le terme WebAssembly a capté mon attention, particulièrement dans le contexte des dernières avancées et de mon intérêt croissant pour Rust. Ce langage de programmation se distingue par son typage statique, sa gestion sécurisée de la mémoire et de la nullité, ainsi que par l'absence de garbage collector. Intrigué, j'ai décidé d'explorer plus profondément ce domaine.
Au cours de mes recherches, j'ai découvert le framework Leptos, relativement nouveau mais déjà fort de plus de 12.7k étoiles sur GitHub. Ses fonctionnalités récentes, notamment "Signal", m'ont particulièrement séduit et ont enrichi mon expérience de développement. À noter, ces fonctionnalités sont pour l'instant uniquement disponibles dans le build nocturne, mais elles sont bien documentées par Leptos.
Un autre outil essentiel pour mon projet a été Trunkrs, qui facilite la compilation et l'emballage de notre site web avec tous les artefacts nécessaires. Il offre également la précieuse fonctionnalité de rechargement automatique des pages durant le développement.
Pour mettre en place notre environnement de développement, les pré-requis sont accessibles via les liens suivants :
Et un IDE de votre choix, comme VS-Code, IntelliJ, ou le nouveau RustRover de JetBrains, actuellement en pré-lancement et bientôt disponible sous licence.
Initialisation du projet
Avant de nous lancer dans la création de l'application ToDo, nous commençons par un simple "Hello World". Cette étape nous permet de vérifier que notre environnement est bien configuré et de ressentir un premier effet "waouh", car le code nécessaire est étonnamment succinct.
Nous débutons par la création d'un nouveau projet Rust et l'ajout des dépendances nécessaires à Leptos :
cargo new todo-leptos
cd todo-leptos
cargo add leptos --features=csr,nightly
Cargo, le gestionnaire de paquets et outil de construction de Rust, est comparable à NPM ou Maven. Il génère un fichier de configuration Cargo.toml
, équivalent du package.json
ou pom.xml
.
[dependencies]
leptos = { version = "0.5.2", features = ["csr", "nightly"] }
Dans la racine du projet, nous créons un fichier index.html
qui sert de conteneur pour notre code Wasm et charge les éléments web tels que CSS et Font Awesome.
<!DOCTYPE html>
<html lang="en">
<head><title>TODO-LAPTOS</title></head>
<body></body>
</html>
Développement de l'application :
Le code Rust remplace celui du fichier main.rs
dans le répertoire src
:
use leptos::*;
fn main() {
mount_to_body(|| view! { <h1> Hello World </h1> })
}
Explications :
use leptos::*
importe les bibliothèques du Framework.fn main()
est la fonction principale, le point d'entrée de notre application.mount_to_body
est une fonction de Leptos qui injecte le contenu dans le corps de notre pageindex.html
.||
représente une fonction de fermeture sans paramètre, appelant le macroview!
, qui affiche le contenu HTML.
En utilisant ces lignes et Trunkrs, nous pouvons générer la page et y accéder via un navigateur à l'adresse http://127.0.0.1:8080.
trunk serve --open --port=8080
Si tout fonctionne, le navigateur ouvre une nouvelle fenêtre affichant "Hello World".
Vous pouvez également expérimenter le rechargement automatique en modifiant le texte dans le programme, par exemple en "Hello Rust", et observer le résultat.
Félicitations pour cette première réussite ! Vous pouvez créer un candidat à la publication avec Trunkrs, qui génère les artefacts dans le répertoire spécifié ou, par défaut, dans le répertoire /dist
du projet.
trunk build --release
Création de l'application Todo
Notre application actuelle affiche une page statique sans interaction utilisateur. Pour l'application Todo, nous souhaitons permettre à l'utilisateur d'ajouter et de modifier une liste de tâches. À cet effet, nous ajoutons quelques dépendances dans notre fichier Cargo.toml
:
[dependencies]
leptos = {version = "0.5.2", features = ["csr", "nightly"]}
uuid = {version = "1.5.0", features = ["v4"]}
instant = {version = "0.1", features = [ "wasm-bindgen", "inaccurate" ]}
uuid
sert à générer des identifiants uniques pour nos entrées Todo.instant
est une bibliothèque pour créer des timestamps. Intéressant à noter, Rust possède une classestd::time::Instant
, mais en raison de sa dépendance au système d'exploitation, elle provoque un crash en wasm. D'après GitHub, cette bibliothèque est utilisée dans plus de 242k projets.
Pour faciliter le suivi, nous regroupons toutes les structures et fonctions de l'application dans le fichier main.rs
.
Un premier élément est la structure Todo
avec ses propriétés. #[derive(Clone, Debug)]
est une annotation clé en Rust pour générer automatiquement du code pour l'élément auquel elle est attachée, comme du code pour la duplication de l'élément ou pour les fonctions de débogage.
#[derive(Clone, Debug)]
pub struct Todo {
pub id: String,
pub title: String,
pub description: String,
pub created: instant::Instant
}
Nous ajoutons ensuite une liste vide de Todo dans l'application. Ici, nous exploitons la fonctionnalité signal
pour définir un vecteur dans un RwSignal
(signal en lecture/écriture) et initialiser la variable todos
. Nous pourrions également modifier le titre de l'application en "Todo List".
fn main() {
let todos:RwSignal<Vec<Todo>> = create_rw_signal(Vec::new());
mount_to_body(|| view! { <h1>Todo List</h1> })
}
Comme dans d'autres frameworks, Rust et Leptos tirent parti de la décomposition pour faciliter la réutilisation et la maintenance des différents éléments. Nous déclarons un composant TodoListItem
qui reçoit un objet Todo
et l'intègre dans un RwSignal
spécifique pour cet élément. Cette fonction contient également un macro view!
responsable uniquement de l'affichage d'un Todo
. L'attribut #[component]
nous permet d'utiliser la fonction comme un élément HTML <TodoListItem>
.
#[component]
pub fn TodoListItem(todo:Todo) -> impl IntoView {
let todo_item: RwSignal<Todo> = create_rw_signal(todo);
view! {
<p> {todo_item.get().title} </p>
}
}
Ajoutons maintenant à la fonction main
un bouton et une fonction pour ajouter un Todo
. Pour ce faire, dans le HTML, nous ajoutons l'élément button
et, à travers l'événement on:click
, nous exécutons une fonction add_todo
. Cette fonction est à nouveau appelée par une fonction de fermeture. Le mot-clé move
spécifie le transfert de propriété des variables dans la fonction de fermeture, si nécessaire.
La fonction add_todo
crée un nouveau Todo
avec des informations génériques non saisies par l'utilisateur pour l'instant. Pour mettre à jour le signal todos
, nous utilisons la méthode update
, qui nous fournit également l'élément précédent. Grâce à ce signal, après la mise à jour, les éléments HTML sont rafraîchis dans le DOM.
Nous ajoutons une boucle For
pour afficher la liste des todos
et utilisons maintenant le composant <TodoListItem>
. Si vous avez déjà créé des sites web avec des frameworks tels qu'Angular ou React, vous trouverez des similitudes.
fn main() {
let todos:RwSignal<Vec<Todo>> = create_rw_signal(Vec::new());
let add_todo = move | | {
let description = String::from("Description");
let id = Uuid::new_v4().to_string();
let created = instant::Instant::now();
let title = format!("Title {}", id);
todos.update(|old| {
old.push(Todo { title,description,id,created })
})
};
mount_to_body(move || view! {
<h1>Todo List</h1>
<p> <button on:click=move |_| {add_todo()}>Add Todo </button> </p>
<For each=todos
key=|item|
(item.id.clone(),
item.description.clone()) let:child>
<TodoListItem todo=child/>
</For>
})
}
Nous pouvons à présent revisiter l'application avec ses nouveaux éléments directement dans le navigateur. L'utilisation du bouton nous permet d'ajouter aisément de nouvelles tâches 'todo'. La communication entre les composants parent et enfant se fait par les propriétés. Cependant, comment un élément enfant communique-t-il avec son parent ? On pourrait transmettre le signal comme propriété, ou mieux encore, utiliser une fonction de rappel (callback).
Dans notre exemple, nous optons pour un callback, défini dans la fonction TodoListItem
. La notation #[prop(into)]
transforme la closure du parent en un Callback :
pub fn TodoListItem(todo:Todo, #[prop(into)]
on_delete: Callback<Todo>) -> impl IntoView {
Cette approche ajoute un bouton dans TodoListItem
qui, lorsqu'activé, exécute le callback avec le todo concerné comme paramètre.
<p>
{todo_item.get().title}
<button on:click=move |_| {on_delete(todo_item.get())}> Delete </button>
</p>
Ensuite, dans la fonction principale, nous ajoutons la propriété on_delete
à TodoListItem
, reliée à une closure qui met à jour la liste en supprimant l'élément spécifié :
<TodoListItem todo=child on_delete=on_delete_todo_event/>
avec une fonction qui actualise notre liste en retirant l'élément transmis dans la fonction closure par l'élément child.
let on_delete_todo_event = move |todo : Todo| {
todos.update(|old| {
old.retain(|x| x.id != todo.id);
})
};
Grâce à Trunkrs, toujours actif, l'application se rafraîchit automatiquement, illustrant ainsi le plaisir du développement continu.
La prochaine étape consiste à ajouter un titre individuel à chaque tâche. Pour cela, nous introduisons un nouveau signal dans la fonction principale, initialisé avec une chaîne de caractères vide :
let todo_title:RwSignal<String> = create_rw_signal(String::new();
Nous utilisons ensuite un élément <input>
non surveillé, pour lequel nous déclarons une référence :
let input_title: NodeRef<Input> = create_node_ref();
Nous associons cette référence à l'élément <input>
dans le HTML :
<p>
<input node_ref=input_title id="title" type="text"
value = {todo_title.get()}
placeholder = "Title"/>
<button on:click=move |_| {add_todo()}>Add Todo </button>
</p>
Dans la fonction add_todo
, nous récupérons la valeur de l'input pour l'utiliser comme titre :
let title = input_title().expect("<input> to exist").value();
Ainsi, notre application est désormais capable d'ajouter des tâches 'todo', le tout réalisé en quelques lignes de code.
Bonus
En bonus, nous nous penchons sur l'aspect design. Sans entrer dans les détails, nous explorons l'utilisation de Wasm et Leptos sous un angle esthétique. Nous intégrons Tailwind, un framework CSS en vogue, directement dans notre projet. L'avantage ici est que Trunkrs inclut Tailwind, éliminant le besoin d'installer NodeJS.
Dans le root du projet, on rajoute deux fichiers
tailwind.config.js
/** @type {import('tailwindcssl').Config} */
module.exports = {
mode: "jit",
content: {
files: ["*.html", "./src/**/*.rs"],
},
theme: {
extend: {},
},
plugins: [],
}
tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;
et on rajoute dans le fichier index.html une référence
<link data-trunk rel="tailwind-css" href="tailwind.css"/>
Nous pouvons maintenant styliser les éléments HTML avec les classes Tailwind, par exemple :
<div class="bg-white shadow-md rounded
px-8 pt-6 pb-8 mb-4 flex flex-row">
Conclusion
Pour conclure, cet exemple illustre que WebAssembly et Rust ne sont pas des technologies hors de portée. Familiarisés avec des frameworks tels que ReactJs ou Angular, vous pourriez être tentés d'expérimenter par vous-même. Le code complet est disponible sur https://github.com/oxide-byte/todo-leptos avec une mise en page Tailwind, une fenêtre modale pour l'ajout et la modification des tâches, le tout organisé en différents fichiers. Une démo est également accessible ici: https://oxide-byte.github.io/todo-leptos/.
Pour ceux qui s'intéressent davantage à Rust et au développement frontend, cet article pourrait vous interesser: https://www.sfeir.dev/front/le-monorepo-sans-friction/. Plus un projet grandit, plus la structure devient essentielle.