Article écrit par Tom Panier
Pour ce nouvel article, j’ai décidé de vous faire un cadeau de Noël en retard : je ne vous parlerai pas de Vue.js ! En effet, et pour compenser les inévitables kilos que la majorité d’entre nous avons pris en cette période de débauche généralisée, je vais aujourd’hui me focaliser sur un outil facilitant la perte de poids… de votre code JavaScript : le fort bien nommé Svelte !
Mais qu’est-ce que c’est quoi donc !?
Svelte est un framework assez jeune mais faisant beaucoup parler de lui, et pour cause : basé sur une philosophie similaire à celle de Vue.js ou encore React, à savoir une arborescence de composants écrits de manière déclarative dans laquelle la donnée circule en sens unique, il diffère toutefois fondamentalement de ses deux grands frères en un point crucial. Il se trouve que Svelte est aussi — voire surtout — un compilateur ; autrement dit, le code que vous servez à vos utilisateurs ne consiste pas en le code du framework plus votre code, transpilé si besoin est, mais plutôt en un code équivalent à celui que vous avez rédigé, mais transformé en opérations compréhensibles par le navigateur moyen. Nulle trace de la source de Svelte dans votre bundle, mais uniquement du code directement utile et efficace, qui d’ailleurs ne s’appuie pas sur un virtual DOM pour fonctionner.
Et ça joue beaucoup sur la taille du bundle en question ?
Tu l’as dit, bouffi ! Pour prendre l’exemple de React, le framework (react
et react-dom
) pèse environ 45 ko à lui seul. L’implémentation de TodoMVC en Svelte (donc une app qui fait effectivement quelque chose) pèse 3.6 ko. En termes de performance, Svelte est également plus rapide que React ou Vue.js au global. Je te laisse lire ce billet pour de plus amples informations sur tout ça.
En cette période où l’écologie est — enfin — un sujet récurrent, je trouve important qu’en tant qu’acteur·rices du numérique, nous nous interrogions sur le rôle que notre industrie joue dans le grand processus du réchauffement climatique : le terme de sobriété numérique n’a pas émergé sans raison. Si on peut utiliser un outil sympa pour concevoir nos apps tout en faisant transiter moins de code par les tuyaux et les rendre plus rapides par-dessus le marché, c’est toujours ça de pris !
Pour la petite histoire, Svelte est écrit par Rich Harris, qui est également l’auteur de Rollup, un concurrent de Webpack d’ailleurs utilisé par défaut dans les projets Svelte. Étonnant, non ? Non.
Trêve de billevesées
Histoire de voir rapidement à quoi ressemble l’outil, je te propose de coder une petite application qui va taper sur l’API de Speedrun.com et nous permettre de consulter le classement des meilleurs speedruns pour n’importe quel jeu vidéo ; comme ça, tu auras de quoi rager quand tu allumeras ta PS4 flambant neuve. Cette API a l’avantage de ne pas nécessiter de clé pour être utilisée (et c’est mon article, je fais ce que je veux, tralalère).
Allons-y donc gaiement :
$ npx degit sveltejs/template svelterun # on crée un nouveau projet Svelte $ cd svelterun # on entre dans le dossier ainsi créé $ npm i # on installe les dépendances
Jusqu’ici, rien ne devrait te choquer outre mesure, sinon tu t’es probablement trompé de blog en cherchant une recette de confiture de châtaignes (mais reste, hein, on est bien). Note au passage que svelte
est déclaré en tant que devDependency
dans package.json
, signe que je ne t’ai pas menti. On va également récupérer un ou deux paquets complémentaires dont on va avoir rapidement besoin :
$ npm i milligram # framework CSS minimaliste #sobriéténumérique $ npm i rollup-plugin-postcss --save-dev # plugin Rollup pour le traitement du CSS
On peut enfin lancer notre app sans plus de cérémonie :
$ npm run dev
Comme avec tout framework digne de ce nom, cette dernière tourne avec un live reload pour que nos changements à venir soient immédiatement constatés :
Your application is ready~! - Local: http://localhost:5000
Il est grand temps de commencer à faire de la confiture écrire du code !
Les mains dans le cambouis 0%
Pour commencer, ce serait pas mal d’arriver à lister les différents jeux disponibles via l’API, non ? Créons donc un fichier src/client.js
qui contiendra, tu l’auras deviné, notre client d’API :
const baseURL = "https://www.speedrun.com/api/v1"; async function get(endpoint) { return (await (await fetch(baseURL + endpoint)).json()).data; } export default { getGames() { return get(`/games`); } };
Tu noteras que je ne me tracasse pas outre mesure concernant la gestion des erreurs, etc. : ce n’est pas le propos de cet article.
On va d’ores et déjà pouvoir remplacer le contenu de src/App.svelte
, qui est le composant racine de notre application. Si tu as déjà joué avec Vue.js, tu devrais être en terrain connu ; on va pousser le bouchon encore plus loin en utilisant l’ordre template/script
/style
au lieu de script
/template/style
(enfin, tu fais comme tu veux, ça ne change absolument rien) :
{#each games as game} <button>{game.names.international}</button> {/each} <script> import "milligram/dist/milligram.min.css"; import { onMount } from "svelte"; import client from "./client"; let games = []; onMount(async () => { games = await client.getGames(); }); </script>
Si tu as tout bien fait comme je t’ai dit, tu devrais pouvoir constater dans ton terminal que le live reload s’est complètement vautré : en effet, Rollup n’est pour l’instant pas configuré pour gérer l’import
du CSS de Milligram que nous lui demandons (je t’ai bien eu). Rectifions donc ça dans rollup.config.js
:
// ... import postcss from "rollup-plugin-postcss"; // ... export default { // ... plugins: [ // ... postcss() ], // ... }; // ...
En relançant npm run dev
, tout devrait désormais se passer pour le mieux, et http://localhost:5000
devrait t’afficher une magnifique page avec une vingtaine de boutons violacés libellés de noms de jeux. Note au passage qu’on aurait pu @import
er le CSS de Milligram côté style
plutôt que côté script
, ce qui aurait été sémantiquement plus correct mais aurait nécessité l’installation du plugin postcss-import
en sus.
Avant d’aller plus loin, penchons-nous sur le code que nous venons d’écrire dans src/App.svelte
:
- Nous déclarons une variable
games
agissant en tant qu’état local de notre composant, et destinée à contenir la liste des jeux sous forme d’un tableau d’objets - Lors de l’insertion du composant dans le DOM (« on mount »), nous appelons l’API de Speedrun.com via notre client et récupérons cette liste, que nous assignons donc à la variable susmentionnée
- Côté template, nous itérons simplement sur le tableau en question et affichons les noms des jeux dans des boutons
Tu me diras qu’un bouton, c’est fait pour être cliqué, et tu auras bien raison. Faisons donc en sorte de pouvoir sélectionner un jeu parmi la liste :
{#each games as game} <button class:button-outline="{selectedGameID === game.id}" on:click={() => selectGame(game.id)} >{game.names.international}</button> {/each} <script> // ... let games = []; let selectedGameID = null; function selectGame(id) { selectedGameID = selectedGameID === id ? null : id; } // ... </script>
Grâce à l’ajout de la variable d’état selectedGameID
et de la fonction selectGame
, on garde désormais une trace du jeu sélectionné. Celui-ci est également déselectionné si l’utilisateur clique de nouveau sur le même bouton.
Bon, c’est bien joli, mais ça ne sert à rien ! Ajoutons donc à src/client.js
la possibilité de récupérer les différentes catégories de speedrun d’un jeu donné :
// ... export default { // ... async getCategories(gameID) { return (await get(`/games/${gameID}/categories`)).filter(({ type }) => type === "per-game"); } };
Par souci de simplicité, on ne va garder que les catégories qui concernent un jeu entier, nous épargnant ainsi de devoir gérer la notion de niveau.
Tu l’auras compris, l’idée est donc d’afficher ces infos pour le jeu sélectionné. Modifions dès lors notre composant :
<!-- ... --> <hr /> {#each categories as category} <button>{category.name}</button> {/each} <script> // ... let games = []; let selectedGameID = null; let categories = []; async function selectGame(id) { selectedGameID = selectedGameID === id ? null : id; categories = await client.getCategories(selectedGameID); } // ... </script>
Ça fonctionne, mais il y a quand même plusieurs trucs pas terribles dans cette approche :
- Si on sélectionne plusieurs fois le même jeu au cours de la navigation, on se retrouve à faire plusieurs fois le même appel, et ça c’est pas très Greta
- On a étroitement couplé le fait de sélectionner un jeu et celui de récupérer ses catégories, en écrivant du code impératif et non pas déclaratif
Avec Vue.js, on aurait utilisé une propriété calculée, non ?
Exactement, et ça aurait résolu du même coup ces deux problèmes ! Fort heureusement, Svelte possède un mécanisme très similaire appelé « propriétés réactives », que nous allons utiliser de ce pas :
<!-- ... --> {#if selectedGame && selectedGame.categories} <hr /> {#each selectedGame.categories as category} <button>{category.name}</button> {/each} {/if} <script> // ... let games = []; let selectedGameID = null; $: selectedGame = games.find(({ id }) => id === selectedGameID); // Quand selectedGame change, si ses catégories ne sont pas chargées, on y procède $: if (selectedGame && !("categories" in selectedGame)) { client.getCategories(selectedGameID).then(categories => { selectedGame.categories = categories; }); } function selectGame(id) { selectedGameID = selectedGameID === id ? null : id; } // ... </script>
Il y a plusieurs trucs intéressants à noter à cette étape :
- On a défini
selectedGame
, notre première propriété réactive, comme étant le jeu dont l’id correspond à l’état localselectedGameID
: jusqu’ici, rien de très surprenant, hormis peut-être la syntaxe$:
, assez particulière mais techniquement valide en JavaScript, pour l’anecdote - On a défini une seconde propriété réactive qui n’en est pas vraiment une, puisqu’il s’agit d’un
if
(la classe, non ?) : quand la valeur deselectedGame
change, on charge la liste des catégories correspondantes si on ne les a pas déjà : puisqu’on référence un objet contenu dans notre tableaugames
et qu’en JavaScript, les objets ne sont pas copiés lorsqu’on les assigne à une nouvelle variable (qui référence de fait un emplacement déjà existant dans la mémoire), on « met en cache » les catégories au fur et à mesure qu’on les charge sans avoir à écrire de code supplémentaire ; sobriété numérique, on t’a dit ! - Il n’est pas possible d’utiliser
await
dans ce « réact-if
» (© moi, 2020) puisqu’on ne se trouve pas dans une fonctionasync
, mais un bon vieux.then
fait très bien le taf -
selectGame
perd du même coup son caractèreasync
, qui n’est plus nécessaire - On met un
if
autour de la boucle sur les catégories en question côté template pour éviter de référencer une donnée inexistante, ce qui a pour avantage accessoire de ne pas afficher inutilement l’élémenthr
Un jour je seraaai le meilleur codeur…
Maintenant qu’on a vu les bases de l’outil, exploitons-les davantage afin d’atteindre notre but initial, à savoir afficher le classement pour chacune de ces catégories ! On va une fois encore commencer par src/client.js
:
// ... export default { // ... async getLeaderboard(gameID, categoryID) { const leaderboard = await get(`/leaderboards/${gameID}/category/${categoryID}?embed=players`); return { ...leaderboard, runs: leaderboard.runs.filter(({ place }) => place > 0).map(({ place, run }) => ({ place, player: "name" in run.players[0] ? run.players[0].name : leaderboard.players.data.find(({ id }) => id === run.players[0].id).names.international, time: run.times.primary_t, date: run.date, video: run.videos && run.videos.links ? run.videos.links[0].uri : null })) }; } };
Cette troisième (et dernière) méthode du client est un peu plus velue car la donnée récupérée est bien plus complexe, et j’ai volontairement choisi d’abstraire cette complexité pour garder une donnée simple à exploiter côté composant. En deux mots, on vire les runs obsolètes (pour lesquelles le même joueur a soumis un meilleur temps) et on fait en sorte de récupérer une info complète et homogène concernant les joueurs eux-mêmes.
Avant de repasser côté composant, installons deux petites dépendances supplémentaires afin de pouvoir afficher les durées des runs (récupérées en secondes) et leurs dates de façon un peu plus sympa à lire pour des humains :
$ npm i format-duration s-ago
Et c’est reparti ! Commençons par le fait de pouvoir sélectionner une catégorie :
<!-- ... --> {#if selectedGame && selectedGame.categories} <hr /> {#each selectedGame.categories as category} <button class:button-outline="{selectedCategoryID === category.id}" on:click={() => selectCategory(category.id)} >{category.name}</button> {/each} {/if} <script> // ... let games = []; let selectedGameID = null; let selectedCategoryID = null; // ... function selectCategory(id) { selectedCategoryID = selectedCategoryID === id ? null : id; } // ... </script>
Comme tu t’en doutes si tu as bien suivi, on va ensuite faire en sorte d’appeler la nouvelle méthode de notre client et d’en stocker le résultat en suivant la logique implémentée jusqu’ici (décorrélation entre action utilisateur et appel subséquent, stockage des données dans notre variable games
initiale afin de les mettre en cache « gratuitement ») :
<!-- ... --> {#if categoriesLoaded} <!-- ... --> {/if} {#if leaderboardLoaded} <table> <thead> <tr> <th class="center">#</th> <th>Player</th> <th class="right">Time</th> <th class="right">Date</th> <th class="center">Video</th> </tr> </thead> <tbody> {#each selectedCategory.leaderboard.runs as run} <tr> <td class="center">{formatPlace(run.place)}</td> <td>{run.player}</td> <td class="right">{formatDuration(run.time * 1000)}</td> <td class="right">{ago(new Date(run.date))}</td> <td class="center">{#if run.video}<a href={run.video} target="_blank"> </a>{/if}</td> </tr> {/each} </tbody> </table> {/if} <script> import formatDuration from "format-duration"; import ago from "s-ago"; // ... $: selectedGame = games.find(({ id }) => id === selectedGameID); $: categoriesLoaded = selectedGame && "categories" in selectedGame; $: selectedCategory = categoriesLoaded ? selectedGame.categories.find(({ id }) => id === selectedCategoryID) : undefined; $: leaderboardLoaded = selectedCategory && "leaderboard" in selectedCategory; // ... // Quand selectedCategory change, si son leaderboard n'est pas chargé, on y procède $: if (selectedCategory && !("leaderboard" in selectedCategory)) { client.getLeaderboard(selectedGameID, selectedCategoryID).then(leaderboard => { selectedCategory.leaderboard = leaderboard; }); } function formatPlace(place) { switch (place) { case 1: return " "; case 2: return " "; case 3: return " "; } return place; } // ... </script> <style> .center { text-align: center; } .right { text-align: right; } </style>
N’aie pas peur, on a ajouté pas mal de code, mais rien de complexe :
- On a créé une propriété réactive
categoriesLoaded
afin de factoriser la condition vérifiant qu’on a sélectionné un jeu et que ses catégories sont chargées, qui était déjà utilisée dans notre template - On a créé une autre propriété réactive
leaderboardLoaded
, par souci de cohérence, qui vérifie qu’on a sélectionné une catégorie et chargé le classement idoine - On affiche ce classement une fois récupéré, avec un peu de vernis pour rendre le tout joli (utilisation des deux dépendances qu’on a installées plus haut, écriture d’une incroyable fonction
formatPlace
pour afficher des médailles pour les trois meilleures runs — on a même écrit du CSS, décidément c’est toujours un peu Noël !)
On n’aurait pas pu utiliser
categoriesLoaded
etleaderboardLoaded
dans les « réact-if
s » ?
Hélas non : ceux-ci agissant sur les variables d’état sur lesquelles se basent ces propriétés, cela créerait une dépendance cyclique non permise par le framework.
Une page se tourne
Notre petite application commence à ressembler à quelque chose, mais tu auras remarqué qu’on est pour l’instant limités à 20 jeux, alors que l’API de Speedrun.com en référence plusieurs milliers ; c’est quand même ballot. Heureusement, comme on a bien conçu notre bousin jusqu’ici, faire en sorte de pouvoir charger et restituer davantage de données va être un vrai jeu d’enfants (si, si).
Modifions donc légèrement la méthode getGames
de notre client d’API afin de lui permettre de tenir compte d’un numéro de page à charger :
// ... const pageSize = 20; // ... export default { getGames(page) { return get(`/games${page ? `?offset=${(page - 1) * pageSize}` : ""}`); }, // ... };
Nous prenons ensuite le parti d’ajouter un bouton supplémentaire à la fin de notre liste de jeux afin de charger la page suivante :
{#each games as game} <button class:button-outline="{selectedGameID === game.id}" on:click={() => selectGame(game.id)} >{game.names.international}</button> {/each} <button class="button-black" on:click={() => loadMoreGames()}>Load more...</button> <!-- ... --> <script> // ... let page = 1; // ... async function loadMoreGames() { games = [...games, ...await client.getGames(++page)]; } // ... </script> <style> // ... .button-black { background-color: black; border-color: black; } </style>
Et voilà ! On peut désormais charger autant de jeux que l’on souhaite, qui viennent agrémenter notre fameuse variable games
et donc s’intégrer gentiment dans le fonctionnement implémenté jusqu’ici, et ce avec un effort quasiment nul. Pas mal, non ?
Perdu de recherche
On ne va évidemment pas s’arrêter en si bon chemin ; notre app n’est, en l’état, pas géniale à utiliser, surtout si on veut consulter les classements sur un jeu dont le nom commence par Z. Ce serait quand même plus simple de pouvoir rechercher des jeux par leur nom plutôt que de récupérer une liste alphabétique qui, en pratique, n’a que peu d’intérêt. De plus, l’API de Speedrun.com permet de charger les jeux en bulk, ce qui donne un payload plus succinct (mais suffisant pour nos besoins) et permet surtout de charger les jeux par paquets de 1000, ce qui fait totalement disparaître le besoin de pagination (même « super » retourne moins de 700 résultats).
On va donc de nouveau modifier getGames
afin d’en enlever cette notion de pagination et d’y adjoindre le support de la recherche en bulk :
// ... export default { getGames(query) { return get(`/games?_bulk=yes&max=1000&name=${query}`); }, // ... };
Dans la foulée, voici venir le moment que tu attendais plus fébrilement encore que les cadeaux sous le sapin : on va créer un second composant !
En effet, pour éviter d’appeler l’API à tout bout de champ, on va utiliser le debouncing afin d’attendre que l’utilisateur ait fini de taper : s’agissant d’un besoin un minimum générique, on va donc l’abstraire de ce contexte précis, et tu pourras même le réutiliser dans tes projets futurs (ne me remercie pas). Créons donc on fichier src/DebouncedInput.svelte
:
<input type="text" {placeholder} on:input={event => emit(event.target.value)} /> <script> import { createEventDispatcher } from "svelte"; const dispatch = createEventDispatcher(); export let minLength = 0; export let delay = 0; export let placeholder = ""; let debouncer; let debouncing = false; function emit(value) { if (value.length < minLength) { return; } if (debouncing) { window.clearTimeout(debouncer); } debouncing = true; debouncer = window.setTimeout(() => { dispatch("input", { value }); debouncing = false; }, delay); } </script>
Décortiquons un peu tout ça :
- Sous tes yeux ébahis, tu vois apparaître ici nos premières props (les propriétés immuables servant à configurer le composant, comme dans React et Vue.js encore une fois) sous la forme des instructions
export let
(permettant au passage d’en définir les valeurs par défaut) :-
minLength
définit une longueur minimale à partir de laquelle prendre la saisie utilisateur en compte -
delay
définit le temps (en millisecondes) qui doit s’écouler pour qu’on considère la saisie comme terminée -
placeholder
est passé tel quel à l’input
natif utilisé dans le template
-
- La fonction
emit
implémente la logique de debouncing elle-même et (comme son nom l’indique) émet un évènement à destination du parent afin que celui-ci récupère la valeur du champ texte au moment voulu
Enfin, utilisons ce composant dans src/App.svelte
(et profitons-en pour en virer le onMount
, la fonction loadMoreGames
et le bouton correspondant qui ne servent plus à rien) :
<DebouncedInput minLength={3} delay={300} placeholder="Search for games..." on:input={event => search(event.detail.value)} /> {#if loading} <div class="spinner"></div> {:else if noResults} <div class="center">Woops, no results were found! Check your query.</div> {:else} <!-- ... --> {/if} <script> // ... import DebouncedInput from "./DebouncedInput.svelte"; // ... let query = ""; let loading = false; $: noResults = query.length > 0 && games.length === 0; // ... async function search(value) { query = value; selectedGameID = null; selectedCategoryID = null; loading = true; games = await client.getGames(query); loading = false; } </script> <style> // ... .spinner::before { content: "Ça charge lol"; } </style>
Analysons ensemble cette ultime étape si tu le veux bien (ou pas) :
- On a ajouté une notion de chargement en cours via la variable d’état local
loading
, afin d’afficher un spinner (dont l’écriture des styles t’est laissée en exercice comme tu peux le voir) - On gère également le cas où il n’y a pas de résultats via une nouvelle propriété réactive
noResults
Et c’est tout !
En conclusion
Svelte est un outil franchement intéressant : non content d’embrasser le paradigme déclaratif des frameworks qui ont plié en douze le game du développement frontend ces dernières années, il apporte son lot d’innovations propres, notamment de par son concept fondamental de « compilateur » permettant de réduire drastiquement la taille du JavaScript servi aux utilisateurs, ce qui constitue un argument de poids (je suis désopilant) tant en termes de performance que d’impact écologique, deux facteurs dont l’importance va aujourd’hui croissant (celui qui a dit « ou petit pain », tu sors).
Bien sûr, la jeunesse de l’outil se ressent sur plusieurs aspects :
- le debugging n’est pas toujours aisé, avec des messages d’erreur parfois obscurs (alors que d’autres sont par ailleurs surprenants de clarté !)
- il manque certaines fonctionnalités par rapport à un concurrent plus robuste comme Vue.js : de « vraies » propriétés calculées avec mise en cache, une syntaxe de filtres, un système d’évènements personnalisés moins verbeux…
- la documentation de Rollup laisse à désirer dès lors qu’on souhaite modifier un peu le fonctionnement par défaut (cela dit, c’est aussi vrai pour Webpack)
- arrêter et relancer rapidement l’application donne parfois lieu à des conflits de ports (les fameux
EADDRINUSE
) ; personnellement,kill -9 `lsof -ti :5000`
est vite devenu une habitude
Pour aller plus loin, sache également que Rich Harris est aussi l’auteur de Sapper, un framework « universel » dans la veine de Next.js côté React ou Nuxt.js côté Vue, qui a également très bonne presse (et il s’est déjà un peu plus foulé que les frères Chopin pour lui trouver un nom, on va pas se mentir).
Enfin, l’intégralité du code de la petite application que nous avons construite ensemble est disponible sur Github, si tu es paresseux curieux de jeter un œil à l’ensemble.
Bisous chez toi et bonne année !